1"""
2Convenience module intending to match the standard library ``json`` module.
3"""
4
5from collections.abc import Iterable
6from typing import Callable, overload
7import typing
8from uuid import UUID
9from warnings import warn
10from dataclasses import dataclass
11
12from .._loading import BaseModel, Environment
13from .. import core as syside
14
15
[docs]
16@dataclass
17class SerializationError(Exception):
18 """
19 Error serializing element to SysML v2 JSON.
20 """
21
22 report: syside.SerdeReport[syside.Element]
23 """Messages emitted during serialization of ``Element`` tree."""
24
25
26type DeserializationReport = syside.SerdeReport[
27 syside.Element | str | syside.DocumentSegment
28]
29"""Messages emitted during deserialization of ``Element`` tree."""
30
31
[docs]
32class SerdeError(Exception):
33 """
34 Class for exceptions from serialization and deserialization
35 """
36
37
[docs]
38@dataclass
39class DeserializationError(SerdeError):
40 """
41 Error deserializing document from SysML v2 JSON.
42 """
43
44 model: syside.DeserializedModel
45 """
46 The deserialized model.
47
48 May be incomplete due to errors.
49 """
50 report: DeserializationReport
51 """Messages emitted during deserialization of ``Element`` tree."""
52
53 def __str__(self) -> str:
54 return str(self.report)
55
56
[docs]
57@dataclass
58class ProjectDeserializationError(SerdeError):
59 """
60 Error deserializing project from SysML v2 JSON.
61 """
62
63 models: list[syside.DeserializedModel]
64 """
65 The deserialized models in this project.
66
67 Models may be incomplete due to errors.
68 """
69 reports: list[DeserializationReport]
70 """
71 Messages emitted during project deserialization.
72
73 Each entry corresponds to an entry in ``models`` at the same index.
74 """
75
76 def __str__(self) -> str:
77 return "\n".join(str(report) for report in self.reports)
78
79
[docs]
80class SerdeWarning(Warning):
81 """
82 Class for warnings from serialization and deserialization
83 """
84
85
[docs]
86def dumps(
87 element: syside.Element,
88 options: syside.SerializationOptions,
89 indent: int = 2,
90 use_spaces: bool = True,
91 final_new_line: bool = True,
92 include_cross_ref_uris: bool = True,
93) -> str:
94 """
95 Serialize ``element`` to a SysML v2 JSON ``str``.
96
97 See the documentation of the :py:class:`SerializationOptions
98 <syside.SerializationOptions>` class for documentation of the possible
99 options. The options object constructed with
100 :py:meth:`SerializationOptions.minimal
101 <syside.SerializationOptions.minimal>` instructs to produce a minimal JSON
102 without any redundant elements that results in significantly smaller JSONs.
103 Examples of redundant information that is avoided using minimal
104 configuration are:
105
106 + including fields for null values;
107 + including fields whose values match the default values;
108 + including redefined fields that are duplicates of redefining fields;
109 + including derived fields that can be computed from minimal JSON (for
110 example, the result value of evaluating an expression);
111 + including implied relationships.
112
113 .. note::
114
115 Syside does not construct all derived properties yet. Therefore, setting
116 ``options.include_derived`` to ``True`` may result in a JSON that does
117 not satisfy the schema.
118
119 :param element:
120 The SysML v2 element to be serialized to SysML v2 JSON.
121 :param options:
122 The serialization options to use when serializing SysML v2 to JSON.
123 :param indent:
124 How many space or tab characters to use for indenting the JSON.
125 :param use_spaces:
126 Whether use spaces or tabs for indentation.
127 :param final_new_line:
128 Whether to add a newline character at the end of the generated string.
129 :param include_cross_ref_uris:
130 Whether to add potentially relative URIs as ``@uri`` property to
131 references of Elements from documents other than the one owning
132 ``element``. Note that while such references are non-standard, they
133 match the behaviour of XMI exports in Pilot implementation which use
134 relative URIs for references instead of plain element IDs.
135 :return:
136 ``element`` serialized as JSON.
137 """
138
139 writer = syside.JsonStringWriter(
140 indent=indent,
141 use_spaces=use_spaces,
142 final_new_line=final_new_line,
143 include_cross_ref_uris=include_cross_ref_uris,
144 )
145
146 report = syside.serialize(element, writer, options)
147
148 if not report:
149 raise SerializationError(report)
150
151 for msg in report.messages:
152 if msg.severity == syside.DiagnosticSeverity.Warning:
153 warn(msg.message, category=SerdeWarning, stacklevel=2)
154
155 return writer.result
156
157
158type JsonSourceNew = tuple[str | syside.Url, str]
159"""A pair of ``(url, json)`` to deserialize ``json`` into a new document with ``url``."""
160type JsonSourceInto = tuple[syside.Document, str]
161"""A pair of ``(document, json)`` to deserialize ``json`` into an existing ``document``."""
162
163
164def _deserialize_document(
165 s: str,
166 document: str | syside.Url | syside.Document,
167 reader: syside.JsonReader,
168 attributes: syside.AttributeMap | None = None,
169) -> tuple[
170 syside.DeserializedModel,
171 DeserializationReport,
172 syside.AttributeMap,
173]:
174 new_doc: syside.SharedMutex[syside.Document] | None = None
175 if isinstance(document, str):
176 document = syside.Url(document)
177 if isinstance(document, syside.Url):
178 ext = document.path.rsplit(".", 1)[-1].lower()
179 if ext == "sysml":
180 lang = syside.ModelLanguage.SysML
181 elif ext == "kerml":
182 lang = syside.ModelLanguage.KerML
183 else:
184 raise ValueError(f"Unknown document language, could not infer from '{ext}'")
185 new_doc = syside.Document.create_st(url=document, language=lang)
186 with new_doc.lock() as doc:
187 document = doc # appease pylint
188
189 with reader.bind(s) as json:
190 if attributes is None:
191 attributes = json.attribute_hint()
192 if attributes is None:
193 raise ValueError("Cannot deserialize model with unmapped attributes")
194
195 model, report = syside.deserialize(document, json, attributes)
196
197 return model, report, attributes
198
199
200_STDLIB_MAP: syside.IdMap | None = None
201
202
203def _map_from_env(docs: Iterable[syside.SharedMutex[syside.Document]]) -> syside.IdMap:
204 ids = syside.IdMap()
205 for mutex in docs:
206 with mutex.lock() as doc:
207 ids.insert_or_assign(doc)
208 return ids
209
210
211def _loads_project(
212 s: Iterable[JsonSourceNew | JsonSourceInto],
213 environment: Environment | None = None,
214 resolve: Callable[[str, UUID], syside.Element | None] | None = None,
215 attributes: syside.AttributeMap | None = None,
216) -> tuple[
217 BaseModel,
218 list[tuple[syside.DeserializedModel, DeserializationReport]],
219]:
220 reader = syside.JsonReader()
221
222 init: list[tuple[syside.DeserializedModel, DeserializationReport]] = []
223 for doc, src in s:
224 model, report, attributes = _deserialize_document(src, doc, reader, attributes)
225 init.append((model, report))
226
227 passed = all(report.passed() for _, report in init)
228 if not passed:
229 raise ProjectDeserializationError(
230 models=[model for model, _ in init], reports=[report for _, report in init]
231 )
232
233 for _, report in init:
234 for msg in report.messages:
235 if (
236 msg.severity == syside.DiagnosticSeverity.Warning
237 and not msg.message.startswith("Could not find reference")
238 ):
239 warn(msg.message, category=SerdeWarning, stacklevel=3)
240
241 base: syside.IdMap | None = None
242 if environment is None or environment is getattr(Environment, "_STDLIB", None):
243 environment = Environment.get_default()
244 # ignore pylint, we use global variable for caching
245 global _STDLIB_MAP # pylint: disable=global-statement
246 if _STDLIB_MAP is None:
247 _STDLIB_MAP = _map_from_env(environment.documents)
248 base = _STDLIB_MAP
249 elif not resolve:
250 # defer environment map creation as it may be relatively expensive and
251 # duplicate user provided ``resolve``
252 base = _map_from_env(environment.documents)
253
254 local: syside.IdMap | None = None
255 if len(init) > 1:
256 # small optimization as 1 document will have resolved its own references
257 # automatically
258 local = syside.IdMap()
259 for model, _ in init:
260 local.insert_or_assign(model.document)
261
262 def resolver(url: str, uuid: UUID) -> syside.Element | None:
263 if local and (result := local(url, uuid)):
264 return result
265
266 if resolve and (result := resolve(url, uuid)):
267 return result
268
269 nonlocal base
270 if base is None:
271 base = _map_from_env(environment.documents)
272
273 if result := base(url, uuid):
274 return result
275
276 return None
277
278 index = environment.index()
279 for i, (model, _) in enumerate(init):
280 init[i] = (model, model.link(resolver)[0])
281 syside.collect_exports(model.document)
282 index.insert(model.document)
283
284 passed = all(report.passed() for _, report in init)
285 if not passed:
286 raise ProjectDeserializationError(
287 models=[model for model, _ in init], reports=[report for _, report in init]
288 )
289
290 return BaseModel(
291 result=None,
292 environment=environment,
293 documents=[model.document.mutex for model, _ in init],
294 lib=environment.lib,
295 index=index,
296 ), init
297
298
299def _loads_document(
300 s: str,
301 document: syside.Document | syside.Url | str,
302 attributes: syside.AttributeMap | None = None,
303) -> (
304 syside.DeserializedModel
305 | tuple[syside.DeserializedModel, syside.SharedMutex[syside.Document]]
306):
307 reader = syside.JsonReader()
308
309 model, report, _ = _deserialize_document(s, document, reader, attributes)
310
311 if not report:
312 raise DeserializationError(model, report)
313
314 for msg in report.messages:
315 if msg.severity == syside.DiagnosticSeverity.Warning:
316 warn(msg.message, category=SerdeWarning, stacklevel=3)
317
318 if not isinstance(document, syside.Document):
319 return model, model.document.mutex
320 return model
321
322
323@overload
324def loads(
325 s: str,
326 document: syside.Document,
327 attributes: syside.AttributeMap | None = None,
328) -> syside.DeserializedModel:
329 """
330 Deserialize a model from ``s`` into an already existing ``document``.
331
332 Root node will be inferred as:
333
334 1. The first ``Namespace`` (not subtype) without an owning relationship.
335 2. The first ``Element`` that has no serialized owning related element or owning relationship,
336 starting from the first element in the JSON array, and following owning elements up.
337 3. The first element in the array otherwise.
338
339 :param s:
340 The string contained serialized SysML model in JSON array.
341 :param document:
342 The document the model will be deserialized into.
343 :param attributes:
344 Attribute mapping of ``s``. If none provided, this will attempt to infer
345 a corresponding mapping or raise a ``ValueError``.
346 :return:
347 Model deserialized from JSON array. Note that references into other
348 documents will not be resolved, users will need to resolve them by
349 calling ``link`` on the returned model. See also :py:class:`IdMap
350 <syside.IdMap>`.
351 """
352
353
354@overload
355def loads(
356 s: str,
357 document: syside.Url | str,
358 attributes: syside.AttributeMap | None = None,
359) -> tuple[syside.DeserializedModel, syside.SharedMutex[syside.Document]]:
360 """
361 Create a new ``document`` and deserialize a model from ``s`` into it.
362
363 Root node will be inferred as:
364
365 1. The first ``Namespace`` (not subtype) without an owning relationship.
366 2. The first ``Element`` that has no serialized owning related element or owning relationship,
367 starting from the first element in the JSON array, and following owning elements up.
368 3. The first element in the array otherwise.
369
370 :param s:
371 The string contained serialized SysML model in JSON array.
372 :param document:
373 A URI in the form of :py:class:`Url <syside.Url>` or a string, new
374 document will be created with. If URI path has no extension, or the
375 extension does not match ``sysml`` or ``kerml``, ``ValueError`` is
376 raised.
377 :param attributes:
378 Attribute mapping of ``s``. If none provided, this will attempt to infer
379 a corresponding mapping or raise a ``ValueError``.
380 :return:
381 Model deserialized from JSON array and the newly created document. Note that
382 references into other documents will not be resolved, users will need to
383 resolve them by calling ``link`` on the returned model. See also
384 :py:class:`IdMap <syside.IdMap>`.
385 """
386
387
388@overload
389def loads(
390 s: Iterable[JsonSourceNew | JsonSourceInto],
391 /,
392 environment: Environment | None = None,
393 resolve: Callable[[str, UUID], syside.Element | None] | None = None,
394 attributes: syside.AttributeMap | None = None,
395) -> tuple[
396 BaseModel,
397 list[tuple[syside.DeserializedModel, DeserializationReport]],
398]:
399 """
400 Deserialize a project of multiple documents from ``s``.
401
402 This is effectively calling ``loads(src, document, attributes) for document,
403 src in s`` and performing the link step afterwards. See also other overloads
404 of ``loads``.
405
406 :param s:
407 Projects sources to deserialize from. If providing a URL string or a
408 ``Url``, new documents will be created for corresponding sources,
409 otherwise deserialization will be performed into the provided
410 ``Documents``.
411 :param environment:
412 ``Environment`` this project depends on. Defaults to the bundled
413 standard library. The ``environment`` will be used to attempt to resolve
414 missing references in the deserialized project.
415 :param resolve:
416 User-provided reference resolution callback that takes priority over
417 ``environment``. See :py:meth:`DeserializedModel.link
418 <syside.DeserializedModel.link>` for more details.
419 :param attributes:
420 Attribute mapping of ``s``. If none provided, this will attempt to infer
421 a corresponding mapping or raise a ``ValueError``.
422 :return:
423 A tuple of project deserialized from JSON sources, and deserialization
424 results
425 :raises ProjectDeserializationError:
426 If either the deserialization or the reference resolution had errors.
427 """
428
429
[docs]
430def loads(
431 s: str | Iterable[JsonSourceNew | JsonSourceInto],
432 *args: typing.Any,
433 **kwargs: typing.Any,
434) -> (
435 syside.DeserializedModel
436 | tuple[syside.DeserializedModel, syside.SharedMutex[syside.Document]]
437 | tuple[
438 BaseModel,
439 list[tuple[syside.DeserializedModel, DeserializationReport]],
440 ]
441):
442 """loads implementation"""
443 if isinstance(s, str):
444 return _loads_document(s, *args, **kwargs)
445 return _loads_project(s, *args, **kwargs)