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