semanticpy 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
semanticpy/__init__.py ADDED
@@ -0,0 +1,1093 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import copy
6
+ import datetime
7
+ import requests
8
+
9
+ from semanticpy.logging import logger
10
+ from semanticpy.errors import SemanticPyError
11
+ from semanticpy.types import (
12
+ Node,
13
+ Namespace,
14
+ readonlydict,
15
+ )
16
+
17
+
18
+ logger.debug("semanticpy library imported from: %s" % (__file__))
19
+
20
+
21
+ class Model(Node):
22
+ """SemanticPy Base Model Class"""
23
+
24
+ _profile: str = None
25
+ _context: str = None
26
+ _entities: Namespace[str, Model] = Namespace()
27
+ _property: list[str] = []
28
+ _properties: dict[str, dict] = {}
29
+ _hidden: list[str] = []
30
+ _globals: dict[str, object] = None
31
+
32
+ @classmethod
33
+ def factory(
34
+ cls, profile: str, context: str = None, globals: dict = None
35
+ ) -> Namespace:
36
+ if not isinstance(cls._entities, Namespace):
37
+ raise TypeError(
38
+ "The %s._entities attribute must be an %s instance!"
39
+ % (
40
+ cls.__name__,
41
+ type(Namespace),
42
+ )
43
+ )
44
+
45
+ if not (isinstance(profile, str) and len(profile := profile.strip()) > 0):
46
+ raise SemanticPyError(
47
+ "The 'profile' argument must be assigned a string containing a valid profile name!"
48
+ )
49
+
50
+ if not (
51
+ context is None
52
+ or (isinstance(context, str) and len(context := context.strip()) > 0)
53
+ ):
54
+ raise TypeError(
55
+ "The 'context' argument must be None or a string containing the URL for a valid JSON-LD context!"
56
+ )
57
+
58
+ if not (globals is None or isinstance(globals, dict)):
59
+ raise TypeError(
60
+ "The 'globals' argument must be None or reference a dictionary!"
61
+ )
62
+
63
+ glo = globals if isinstance(globals, dict) else cls._globals
64
+
65
+ if not os.path.exists(profile):
66
+ if not profile.endswith(".json"):
67
+ profile += ".json"
68
+
69
+ profile = os.path.join(os.path.dirname(__file__), "profiles", profile)
70
+
71
+ if not os.path.exists(profile):
72
+ raise SemanticPyError(
73
+ "The specified profile (%s) does not exist!" % (profile)
74
+ )
75
+
76
+ if not os.path.isfile(profile):
77
+ raise SemanticPyError(
78
+ "The specified profile (%s) is not a file!" % (profile)
79
+ )
80
+
81
+ logger.debug("%s.factory() Loading profile => %s", cls.__name__, profile)
82
+
83
+ with open(profile, "r") as handle:
84
+ if contents := handle.read():
85
+ try:
86
+ cls._profile = json.loads(contents)
87
+ except json.decoder.JSONDecodeError as e:
88
+ raise SemanticPyError(
89
+ "The specified profile (%s) is invalid or incomplete (%s)!"
90
+ % (
91
+ profile,
92
+ str(e),
93
+ ),
94
+ )
95
+
96
+ if not isinstance(cls._profile, dict):
97
+ raise SemanticPyError(
98
+ "The specified profile (%s) is invalid or incomplete!" % (profile)
99
+ )
100
+
101
+ if context is None:
102
+ if not isinstance(context := cls._profile.get("context"), str):
103
+ raise SemanticPyError(
104
+ "The specified profile (%s) does not contain a valid 'context' property!"
105
+ % (profile),
106
+ )
107
+ elif not (isinstance(context, str) and len(context := context.strip()) > 0):
108
+ raise SemanticPyError(
109
+ "The 'context' argument must contain a URL for a valid JSON-LD context document!"
110
+ )
111
+ elif not (context.startswith("http://") or context.startswith("https://")):
112
+ raise SemanticPyError(
113
+ "The 'context' argument must contain a URL for a valid JSON-LD context document!"
114
+ )
115
+
116
+ if not isinstance(entities := cls._profile.get("entities"), dict):
117
+ raise SemanticPyError(
118
+ "The specified profile (%s) does not contain a valid 'entities' property!"
119
+ % (profile),
120
+ )
121
+
122
+ def _class_factory(name: str) -> type:
123
+ nonlocal cls, glo, entities
124
+
125
+ # If the named class already exists, return immediately
126
+ if isinstance(class_type := cls._entities.get(name, default=None), type):
127
+ if issubclass(class_type, Model):
128
+ return class_type
129
+ else:
130
+ raise TypeError(
131
+ "The %s.%s attribute is not a subclass of %s as expected! Ensure this attribute has not been set on the class accidentally!"
132
+ % (
133
+ cls.__name__,
134
+ name,
135
+ cls.__name__,
136
+ )
137
+ )
138
+
139
+ if not (entity := entities.get(name)):
140
+ raise SemanticPyError(
141
+ "The specified entity type (%s) has not been defined in the profile!"
142
+ % (name)
143
+ )
144
+
145
+ bases: tuple = ()
146
+ properties: dict[str, object] = {}
147
+
148
+ for prop, props in (cls._profile.get("properties") or {}).items():
149
+ properties[prop] = cls._validate_properties(props, prop)
150
+
151
+ if superclasses := entity.get("superclasses"):
152
+ if isinstance(superclasses, str):
153
+ superclasses = [superclasses]
154
+
155
+ for superclass_name in superclasses:
156
+ if superclass := _class_factory(superclass_name):
157
+ bases += (superclass,)
158
+
159
+ for prop, props in (superclass._properties or {}).items():
160
+ properties[prop] = cls._validate_properties(props, prop)
161
+ else:
162
+ raise SemanticPyError(
163
+ "Failed to find or create (base) superclass: %s!"
164
+ % (superclass_name),
165
+ )
166
+
167
+ if self_properties := entity.get("properties"):
168
+ for prop, props in self_properties.items():
169
+ properties[prop] = cls._validate_properties(props, prop)
170
+
171
+ if len(bases) == 0:
172
+ bases += (cls,)
173
+
174
+ accepted: bool = False
175
+ multiple: list[str] = []
176
+ hidden: list[str] = []
177
+ sorting: dict[str, int] = {}
178
+
179
+ # properties_sorted = {}
180
+ # for key in sorted(properties.keys()):
181
+ # properties_sorted[key] = properties[key]
182
+ # properties = properties_sorted
183
+
184
+ for prop, props in properties.items():
185
+ # determine if at least one property on the model is marked as accepted
186
+ if props.get("accepted", True) is True:
187
+ accepted = True
188
+
189
+ # assemble the list of properties on the model that accept multiple values
190
+ if props.get("individual", False) is False:
191
+ if not prop in multiple:
192
+ multiple.append(prop)
193
+
194
+ # assemble the list of properties on the model that are hidden
195
+ if props.get("hidden", False) is True:
196
+ if not prop in hidden:
197
+ hidden.append(prop)
198
+
199
+ sorting[prop] = props.get("sorting") or 10000
200
+
201
+ if accepted is False:
202
+ raise SemanticPyError(
203
+ "No accepted properties have been defined in the %s profile for %s!"
204
+ % (
205
+ profile,
206
+ name,
207
+ ),
208
+ )
209
+
210
+ attributes = {
211
+ "_context": context,
212
+ "_type": entity.get("type"),
213
+ "_name": entity.get("id"),
214
+ "_multiple": multiple,
215
+ "_sorting": sorting,
216
+ "_hidden": hidden,
217
+ "_property": None,
218
+ "_properties": properties,
219
+ }
220
+
221
+ if class_type := type(name, bases, attributes):
222
+ # Add the class to the Model's namespace so that it can be accessed elsewhere
223
+
224
+ # setattr(cls, name, class_type)
225
+ cls._entities[name] = class_type
226
+
227
+ if isinstance(glo, dict):
228
+ # Add the class to global namespace so that it can be accessed elsewhere
229
+ glo[name] = class_type
230
+
231
+ # If the class has a synonym, map it into the global namespace too; this
232
+ # is useful for supporting backwards compatibility if classes are renamed
233
+ # allowing existing code to produce output compliant with the latest model
234
+ if synonym := entities.get(name).get("synonym"):
235
+ if isinstance(synonym, list):
236
+ _synonyms = synonym
237
+ elif isinstance(synonym, str):
238
+ _synonyms = [synonym]
239
+ else:
240
+ raise TypeError(
241
+ "The `synonym` must be provided as a list of strings or a string!"
242
+ )
243
+
244
+ for _synonym in _synonyms:
245
+ if not isinstance(_synonym, str):
246
+ raise TypeError(
247
+ "Entity class synonyms must be defined as strings!"
248
+ )
249
+
250
+ class_type._synonym = synonym
251
+
252
+ # setattr(cls, synonym, class_type)
253
+ cls._entities[synonym] = class_type
254
+
255
+ if isinstance(glo, dict):
256
+ glo[synonym] = class_type
257
+
258
+ return class_type
259
+
260
+ for name in entities:
261
+ if class_type := _class_factory(name):
262
+ cls._entities[name] = class_type
263
+
264
+ # setattr(cls, name, class_type)
265
+
266
+ if isinstance(glo, dict):
267
+ glo[name] = class_type
268
+ else:
269
+ raise SemanticPyError("Failed to create entity type '%s'!" % (name))
270
+
271
+ return cls._entities
272
+
273
+ @classmethod
274
+ def teardown(cls, globals: dict = None):
275
+ """This method will clear the dynamically created model classes from the globals
276
+ dictionary, reversing the work of the factory method used during initialization.
277
+ """
278
+
279
+ if not (globals is None or isinstance(globals, dict)):
280
+ raise TypeError(
281
+ "The 'globals' argument must be None or reference a dictionary!"
282
+ )
283
+
284
+ glo: dict[str, object] = globals if globals else cls._globals
285
+
286
+ removals: list[str] = []
287
+
288
+ for key, value in cls._entities:
289
+ removals.append(key)
290
+
291
+ for key in removals:
292
+ del cls._entities[key]
293
+
294
+ if isinstance(glo, dict):
295
+ del glo[key]
296
+
297
+ @classmethod
298
+ def open(cls, filepath: str) -> Model:
299
+ """Support opening and loading model instances from stored JSON-LD files"""
300
+
301
+ # cls.factory(profile=profile, context=context, globals=globals)
302
+
303
+ logger.debug("%s.open(filepath: %s)", cls.__name__, filepath)
304
+
305
+ if not (isinstance(filepath, str) and len(filepath := filepath.strip()) > 0):
306
+ raise ValueError(
307
+ "The 'filepath' argument must be a valid non-empty string!"
308
+ )
309
+
310
+ if not cls._entities:
311
+ raise RuntimeError(
312
+ "Please ensure that the Model.factory() method has been called to initialize the models!"
313
+ )
314
+
315
+ data: dict[str, object] = None
316
+
317
+ if (
318
+ filepath.startswith("http://")
319
+ or filepath.startswith("https://")
320
+ or filepath.startswith("//")
321
+ ):
322
+ try:
323
+ if isinstance(response := requests.get(url), object):
324
+ if response.status_code == 200:
325
+ if not isinstance(data := response.json(), dict):
326
+ raise ValueError(
327
+ "The specified file does not contain valid JSON data!"
328
+ )
329
+ else:
330
+ raise ValueError(
331
+ "The specified file could not be loaded from its URL!"
332
+ )
333
+ else:
334
+ raise ValueError(
335
+ "The specified file could not be loaded from its URL!"
336
+ )
337
+ except Exception as exception:
338
+ raise ValueError(
339
+ "The specified file could not be loaded (%s) from its URL!"
340
+ % (exception)
341
+ )
342
+ elif (
343
+ filepath.startswith("/")
344
+ or filepath.startswith("./")
345
+ or filepath.startswith("../")
346
+ or filepath.startswith("~/")
347
+ ):
348
+ if filepath.startswith("~/"):
349
+ filepath = os.path.expanduser(filepath)
350
+
351
+ with open(filepath, "r") as handle:
352
+ if not isinstance(data := json.load(handle), dict):
353
+ raise ValueError(
354
+ "The specified file does not contain valid JSON data!"
355
+ )
356
+ else:
357
+ raise ValueError("The specified filepath (%s) is unsupported!" % (filepath))
358
+
359
+ if isinstance(data, dict):
360
+ if context := data.get("@context"):
361
+ if typed := data.get("type"):
362
+ if entity := cls.entity(typed):
363
+ if instance := entity(data=readonlydict(data)):
364
+ return instance
365
+ else:
366
+ raise ValueError(
367
+ "The data could not be loaded into an %s model entity instance!"
368
+ % (entity)
369
+ )
370
+ else:
371
+ raise ValueError(
372
+ "The type (%s) does not correspond to any known model entities; ensure that SemanticPy's Model.factory() method has been called with a suitable profile!"
373
+ % (typed)
374
+ )
375
+ else:
376
+ raise ValueError("The data does not contain a 'type' property!")
377
+ else:
378
+ raise ValueError(
379
+ "The filepath does not reference a valid JSON-LD file!"
380
+ )
381
+ else:
382
+ raise ValueError("No data could be loaded from the specified file!")
383
+
384
+ @classmethod
385
+ def _validate_properties(cls, properties: dict, property: str) -> dict:
386
+ """Helper method to validate property specification dictionaries"""
387
+
388
+ if not isinstance(properties, dict):
389
+ raise TypeError(
390
+ "The 'properties' argument provided for the '%s' property must have a dictionary value!"
391
+ % (property)
392
+ )
393
+
394
+ if "accepted" in properties:
395
+ if not isinstance(properties["accepted"], bool):
396
+ raise TypeError(
397
+ "The 'accepted' property for '%s' must have a boolean value!"
398
+ % (property)
399
+ )
400
+ else:
401
+ properties["accepted"] = True
402
+
403
+ if "individual" in properties:
404
+ if not isinstance(properties["individual"], bool):
405
+ raise TypeError(
406
+ "The 'individual' property for '%s' must have a boolean value!"
407
+ % (property)
408
+ )
409
+ else:
410
+ properties["individual"] = False
411
+
412
+ if "sorting" in properties:
413
+ if isinstance(properties["sorting"], int):
414
+ if not properties["sorting"].__class__ is int and issubclass(
415
+ properties["sorting"].__class__, int
416
+ ):
417
+ raise TypeError(
418
+ "The 'sorting' property for '%s' must have an integer value, held in an `int` data type!"
419
+ % (property)
420
+ )
421
+ elif not (0 <= properties["sorting"] <= 100000000):
422
+ raise ValueError(
423
+ "The 'sorting' property for '%s' must have a positive integer value (0 – 100,000,000), not %s!"
424
+ % (property, properties["sorting"])
425
+ )
426
+ else:
427
+ raise TypeError(
428
+ "The 'sorting' property for '%s' must have an integer value!"
429
+ % (property)
430
+ )
431
+ else:
432
+ properties["sorting"] = 10000
433
+
434
+ if "alias" in properties:
435
+ if not isinstance(properties["alias"], str):
436
+ raise TypeError("The 'alias' property must have a string value!")
437
+
438
+ if "canonical" in properties:
439
+ if not isinstance(properties["canonical"], str):
440
+ raise TypeError("The 'canonical' property must have a string value!")
441
+
442
+ return properties
443
+
444
+ @classmethod
445
+ def extend(
446
+ cls,
447
+ subclass: Model,
448
+ properties: dict = None,
449
+ context: str = None,
450
+ globals: dict = None,
451
+ typed: bool = True,
452
+ ) -> None:
453
+ """Class method to support extending the factory-generated model with additional
454
+ model subclasses, and optionally, additional model-wide properties"""
455
+
456
+ if not issubclass(subclass, Model):
457
+ raise TypeError(
458
+ "The 'subclass' argument must reference a subclass of Model!"
459
+ )
460
+
461
+ name: str = subclass.__name__
462
+
463
+ if globals is None:
464
+ pass
465
+ elif not isinstance(globals, dict):
466
+ raise TypeError(
467
+ "The 'globals' argument must be None or reference a dictionary!"
468
+ )
469
+
470
+ glo: dict[str, object] = globals if globals else cls._globals
471
+
472
+ # If any model-wide properties have been defined, apply them to each model entity
473
+ if properties is None:
474
+ pass
475
+ elif not isinstance(properties, dict):
476
+ raise TypeError("The 'properties' argument must be a dictionary!")
477
+ else:
478
+ # If any subclass-level properties have been defined, apply them to the subclass
479
+ if hasattr(subclass, "_property"):
480
+ if subclass._property is None:
481
+ subclass._property: list[str] = []
482
+ elif not isinstance(subclass._property, list):
483
+ raise TypeError("The '_property' attribute must be a list type")
484
+ else:
485
+ subclass._property: list[str] = []
486
+
487
+ for prop, props in properties.items():
488
+ props: dict[str, object] = cls._validate_properties(props, prop)
489
+
490
+ subclass._property.append(prop)
491
+
492
+ if alias := props.get("alias"):
493
+ subclass._property.append(alias)
494
+
495
+ if canonical := props.get("canonical"):
496
+ pass
497
+
498
+ for class_name, entity in cls._entities.items():
499
+ entity._properties[prop] = cls._validate_properties(props, prop)
500
+
501
+ # If a property supports being specified via an alias, map that here
502
+ if alias := props.get("alias"):
503
+ entity._properties[alias] = {**props, **{"alias": prop}}
504
+
505
+ if sorting := props.get("sorting"):
506
+ entity._sorting[prop] = sorting
507
+
508
+ # If any subclass-level properties have been defined, apply them to the subclass
509
+ if hasattr(subclass, "_properties"):
510
+ if not isinstance(subclass._properties, dict):
511
+ raise TypeError("The '_properties' attribute must be a dictionary!")
512
+
513
+ for prop, props in subclass._properties.items():
514
+ props = cls._validate_properties(props, prop)
515
+
516
+ if props.get("hidden") is True:
517
+ subclass._hidden.append(prop)
518
+
519
+ if sorting := props.get("sorting"):
520
+ subclass._sorting[prop] = sorting
521
+ else:
522
+ subclass._properties = {}
523
+
524
+ if context is None:
525
+ pass
526
+ elif isinstance(context, str):
527
+ if not (
528
+ len(context := context.strip()) > 0
529
+ and (context.startswith("https://") or context.startswith("http://"))
530
+ ):
531
+ raise ValueError(
532
+ "The 'context' argument, if specified, must have a valid non-empty context URL string value!"
533
+ )
534
+
535
+ subclass._context = context
536
+ else:
537
+ raise TypeError(
538
+ "The 'context' argument, if specified, must have a string value!"
539
+ )
540
+
541
+ # If this class is a special case that will be serialized without a "type", mark
542
+ # its "type" property as hidden, so when serialized, "type" will be skipped
543
+ if not isinstance(typed, bool):
544
+ raise TypeError("The 'typed' argument must have a boolean value!")
545
+ elif typed is False:
546
+ subclass._hidden.append("type")
547
+
548
+ # Merge any superclass properties into the subclass' property list
549
+ for superclass in subclass.__bases__:
550
+ if hasattr(superclass, "_properties"):
551
+ if isinstance(superclass._properties, dict):
552
+ for prop, props in superclass._properties.items():
553
+ if not prop in subclass._properties:
554
+ subclass._properties[prop] = props
555
+
556
+ if not name in cls._entities:
557
+ # raise RuntimeError(
558
+ # "The extended entity '%s' has the same name as an existing entity!" % (subclass.__name__)
559
+ # )
560
+
561
+ # setattr(cls, name, class_type)
562
+ cls._entities[name] = subclass
563
+
564
+ if isinstance(glo, dict):
565
+ # Add the class to global namespace so that it can be accessed elsewhere
566
+ glo[name] = subclass
567
+
568
+ @classmethod
569
+ def entity(cls, name: str = None, property: str = None) -> Model | None:
570
+ """Helper method to return the referenced entity type from the model"""
571
+
572
+ if isinstance(name, str):
573
+ if name in cls._entities:
574
+ return cls._entities[name]
575
+ elif isinstance(property, str):
576
+ for name, entity in cls._entities.items():
577
+ if isinstance(entity._property, list):
578
+ if property in entity._property:
579
+ return entity
580
+ else:
581
+ raise ValueError(
582
+ "An entity name or entity-assignable property name must be provided!"
583
+ )
584
+
585
+ def __new__(cls, *args, **kwargs):
586
+ # The '_special' list variable is defined in the base class and holds a list of
587
+ # special class attribute names
588
+
589
+ cls._special += [
590
+ attr
591
+ for attr in [
592
+ "_hidden",
593
+ "_reference",
594
+ "_referenced",
595
+ "_cloned",
596
+ ]
597
+ if attr not in cls._special
598
+ ]
599
+
600
+ return super().__new__(cls)
601
+
602
+ def __init__(
603
+ self,
604
+ ident: str = None,
605
+ label: str = None,
606
+ data: dict[str, object] = None,
607
+ **kwargs,
608
+ ):
609
+ super().__init__(
610
+ # TODO: Determine if setting data via the superclass' constructor is optimal
611
+ # data=data,
612
+ )
613
+
614
+ self._annotations: dict[str, object] = {}
615
+
616
+ # Enable support for the essential model properties
617
+ for prop in ["id", "type", "_label"]:
618
+ if not prop in self._properties:
619
+ self._properties[prop] = {
620
+ "accepted": True,
621
+ "individual": True,
622
+ "range": "xsd:string",
623
+ }
624
+
625
+ self.type: str = self.__class__.__name__
626
+
627
+ if isinstance(data, dict):
628
+ if ident is None:
629
+ ident = data.get("id")
630
+
631
+ if label is None:
632
+ label = data.get("_label")
633
+
634
+ if ident is None:
635
+ pass
636
+ elif not isinstance(ident, str):
637
+ raise TypeError(
638
+ "The 'ident' argument, if specified, must have a string value!"
639
+ )
640
+
641
+ self.id: str = ident or None
642
+
643
+ if label is None:
644
+ pass
645
+ elif not isinstance(label, str):
646
+ raise TypeError(
647
+ "The 'label' argument, if specified, must have a string value!"
648
+ )
649
+
650
+ self._label: str = label or None
651
+
652
+ # If a 'json' keyword argument has been specified, attempt to parse the value as
653
+ # a JSON serialized string so long as the 'data' argument has not been specified
654
+ if not (jsons := kwargs.pop("json", None)) is None:
655
+ if not isinstance(jsons, str):
656
+ raise TypeError(
657
+ "The 'json' argument must be a string containing the JSON-LD of the record to load!"
658
+ )
659
+
660
+ if data is None:
661
+ try:
662
+ data = json.loads(jsons)
663
+ except Exception as exception:
664
+ raise ValueError(
665
+ "The 'json' argument does not contain a valid JSON string: %s!"
666
+ % (str(exception))
667
+ )
668
+ else:
669
+ raise ValueError(
670
+ "The 'json' and 'data' arguments cannot be specified at the same time; please either provide data as a dictionary via the 'data' argument or as a serialized JSON string via the 'json' argument!"
671
+ )
672
+
673
+ if data is None:
674
+ pass
675
+ elif isinstance(data, dict):
676
+ self.load(data=data, model=self)
677
+ else:
678
+ raise TypeError(
679
+ "The 'data' argument, if specified, must have a dictionary value!"
680
+ )
681
+
682
+ for key, value in kwargs.items():
683
+ self.__setattr__(key, value)
684
+
685
+ # TODO: Should 'create' be a "private" method?
686
+ @classmethod
687
+ def create(cls, data: dict, property: str = None) -> Model:
688
+ """Support creating a model entity from its data (dictionary) representation"""
689
+
690
+ if not isinstance(data, dict):
691
+ raise TypeError("The 'data' argument must have a dictionary value!")
692
+
693
+ # Attempt to determine the entity type from the assigned 'type' string value
694
+ if isinstance(typed := data.get("type"), str):
695
+ if not isinstance(entity := cls.entity(name=typed), type):
696
+ raise ValueError(
697
+ "The '%s' entity type cannot be mapped to a model entity!" % (typed)
698
+ )
699
+
700
+ if not isinstance(model := entity(data=data), Model):
701
+ raise ValueError(
702
+ "The '%s' entity type could not be instantiated!" % (typed)
703
+ )
704
+
705
+ # Alternatively, for untyped model extensions, attempt to determine the entity
706
+ # type from the property name that the entity has been assigned to in data
707
+ elif isinstance(entity := cls.entity(property=property), type):
708
+ if not isinstance(model := entity(data=data), Model):
709
+ raise ValueError(
710
+ "The '%s' entity type could not be instantiated!" % (typed)
711
+ )
712
+
713
+ # If no entity type can be determined, raise an exception as the current data
714
+ # node cannot be loaded into the data model; ensure the model has been defined
715
+ # completely and in accordance with the provided data, including any extensions
716
+ else:
717
+ raise ValueError(
718
+ "The entity type cannot be determined for the provided data dictionary; the dictionary must contain a valid 'type' property, or be an extended model entity assigned to an expected named property!"
719
+ )
720
+
721
+ return model
722
+
723
+ # TODO: Should 'load' be a "private" method?
724
+ def load(self, data: dict, model: Model) -> None:
725
+ if not isinstance(data, dict):
726
+ raise ValueError("The 'data' argument must be provided as a dictionary!")
727
+
728
+ if not isinstance(model, self.__class__):
729
+ raise TypeError(
730
+ "The 'model' argument must be a subclass of %s!"
731
+ % (self.__class__.__name__)
732
+ )
733
+
734
+ for property, value in data.items():
735
+ if isinstance(value, dict):
736
+ setattr(model, property, self.create(data=value, property=property))
737
+ elif isinstance(value, list):
738
+ for index, item in enumerate(value):
739
+ # if not isinstance(item, dict):
740
+ # raise TypeError(
741
+ # "The list item at index %d is not a dictionary, but rather %s!" % (index, type(item))
742
+ # )
743
+ setattr(model, property, self.create(data=item, property=property))
744
+ else:
745
+ setattr(model, property, value)
746
+
747
+ def __getstate__(self) -> dict:
748
+ """Support serializing deep copies of instances of this class"""
749
+
750
+ return self.__dict__.copy()
751
+
752
+ def __setstate__(self, state: dict) -> None:
753
+ """Support restoring from deep copies of instances of this class"""
754
+
755
+ self.__dict__.update(state)
756
+
757
+ def __str__(self) -> str:
758
+ return self.__repr__()
759
+
760
+ def __repr__(self) -> str:
761
+ return f"<{self.__class__.__name__}(ident = {self.id}, label = {self._label})>"
762
+
763
+ def _find_type(self, range: str | Model) -> type | tuple[type] | None:
764
+ if isinstance(range, str):
765
+ if range == "rdfs:Literal":
766
+ return (str, int, float)
767
+ elif range == "rdfs:Class":
768
+ return str
769
+ elif range == "xsd:string":
770
+ return str
771
+ elif range == "xsd:dateTime":
772
+ return (str, datetime.datetime)
773
+
774
+ for key, entity in self._entities.items():
775
+ if isinstance(range, str):
776
+ if entity._name == range:
777
+ return entity
778
+ elif issubclass(range, Model):
779
+ if entity is range:
780
+ return entity
781
+
782
+ @property
783
+ def name(self) -> str:
784
+ return self.__class__.__name__
785
+
786
+ @property
787
+ def ident(self) -> str | None:
788
+ return self.id
789
+
790
+ @property
791
+ def label(self) -> str | None:
792
+ return self._label
793
+
794
+ def __setattr__(self, name: str, value: object) -> None:
795
+ # logger.debug("%s.%s(name: %s, value: %s) called" % (self.__class__.__name__, self.__setattr__.__name__, name, value))
796
+
797
+ prop: dict[str, object] = self._properties.get(name) or {}
798
+
799
+ if canonical := prop.get("canonical"):
800
+ name = canonical
801
+ elif alias := prop.get("alias"):
802
+ name = alias
803
+
804
+ if not (
805
+ name.startswith("@")
806
+ or name in self._special
807
+ or prop.get("accepted") is True
808
+ ):
809
+ raise AttributeError(
810
+ "Cannot set property '%s' on %s as it is not in the list of accepted properties: '%s'!"
811
+ % (
812
+ name,
813
+ self.__class__.__name__,
814
+ "', '".join(
815
+ sorted(
816
+ [
817
+ name
818
+ for name, prop in self._properties.items()
819
+ if prop.get("accepted") is True
820
+ ]
821
+ )
822
+ ),
823
+ ),
824
+ )
825
+
826
+ if value is None:
827
+ return super().__delattr__(name)
828
+ else:
829
+ if range := prop.get("range"):
830
+ types: tuple = ()
831
+
832
+ if isinstance(range, str):
833
+ ranges = [range]
834
+ elif isinstance(range, list):
835
+ ranges = range
836
+ elif issubclass(range, Model):
837
+ ranges = [range]
838
+ else:
839
+ raise TypeError(
840
+ "The 'range' property must be defined as a string, list, or a Model class type, not %s!"
841
+ % (range)
842
+ )
843
+
844
+ for range in ranges:
845
+ if not (isinstance(range, str) or issubclass(range, Model)):
846
+ raise TypeError(
847
+ "The 'range' property can only contain valid type names or Model class types!"
848
+ )
849
+
850
+ if typed := self._find_type(range=range):
851
+ types += (typed,)
852
+ else:
853
+ raise ValueError(
854
+ "The '%s' range for the '%s' property cannot be reconciled to a known range type!"
855
+ % (range, name)
856
+ )
857
+
858
+ if len(types) == 0:
859
+ raise RuntimeError(
860
+ "Unable to find associated types for any of the specified ranges!"
861
+ )
862
+
863
+ if not isinstance(value, types):
864
+ raise TypeError(
865
+ "Cannot set value of type '%s' on '%s'; must be of type %s!"
866
+ % (
867
+ type(value),
868
+ name,
869
+ (", ".join(["'%s'" % (x) for x in types])),
870
+ )
871
+ )
872
+
873
+ if domain := prop.get("domain"):
874
+ pass
875
+
876
+ return super().__setattr__(name, value)
877
+
878
+ def _serialize(
879
+ self,
880
+ source=None,
881
+ sorting: list[str] | dict[str, int] = None,
882
+ ) -> str:
883
+ """Support serializing the current model instance into JSON-LD."""
884
+
885
+ data: str = super()._serialize(source=source, sorting=sorting)
886
+
887
+ if self._hidden and isinstance(data, dict):
888
+ for prop in self._hidden:
889
+ if prop in data:
890
+ del data[prop]
891
+
892
+ return data
893
+
894
+ @property
895
+ def is_blank(self) -> bool:
896
+ """Determine if a node is a blank node (i.e. that it does not have an id)."""
897
+
898
+ return self.id is None
899
+
900
+ def clone(self, properties: bool = True, reference: bool = False) -> Model:
901
+ """Support cloning a Model instance."""
902
+
903
+ cloned: Model = self.__class__(ident=self.id, label=self._label)
904
+
905
+ special: list[str] = ["ident", "label", "data", "name", "type"]
906
+
907
+ for prop in dir(self):
908
+ if prop.startswith("_") or properties is False:
909
+ continue
910
+
911
+ if attr := getattr(self, prop):
912
+ if not callable(attr) and not prop in special:
913
+ setattr(cloned, prop, attr)
914
+
915
+ # if not "_cloned" in self._special: self._special.append("_cloned")
916
+
917
+ if reference is False:
918
+ cloned._cloned = self
919
+
920
+ return cloned
921
+
922
+ @property
923
+ def is_cloned(self) -> bool:
924
+ """Determine if a node has been cloned."""
925
+
926
+ return hasattr(self, "_cloned") and isinstance(self._cloned, Model)
927
+
928
+ def reference(self) -> Model:
929
+ """Support creating a reference to another Model instance."""
930
+
931
+ cloned: Model = self.clone(properties=False, reference=True)
932
+
933
+ # Create a reference to the current node for later access
934
+ cloned._reference = self
935
+
936
+ # Note that the current node has been referenced by another node at least once
937
+ self._referenced = True
938
+
939
+ # Copy any annotations across to the cloned reference entity
940
+ if annotations := self.annotations():
941
+ for name, value in annotations.items():
942
+ cloned.annotate(name, value)
943
+
944
+ return cloned
945
+
946
+ @property
947
+ def is_reference(self) -> bool:
948
+ """Determine if a node is reference to another node."""
949
+
950
+ return hasattr(self, "_reference") and isinstance(self._reference, Model)
951
+
952
+ @property
953
+ def was_referenced(self) -> bool:
954
+ """Determine if a node was referenced by another node at least once."""
955
+
956
+ return self._referenced is True
957
+
958
+ def properties(
959
+ self,
960
+ sorting: list[str] | dict[str, int] = None,
961
+ callback: callable = None,
962
+ attribute: str | int = None,
963
+ ) -> dict[str, object]:
964
+ """Support obtaining a dictionary representation of the properties assigned to
965
+ the current model instance."""
966
+
967
+ properties: dict[str, object] = (
968
+ super().properties(
969
+ sorting=sorting,
970
+ callback=callback,
971
+ attribute=attribute,
972
+ )
973
+ or {}
974
+ )
975
+
976
+ # If a context has been specified, prepend the @context property
977
+ if context := (self._context or self._profile.get("context")):
978
+ properties = {**{"@context": context}, **properties}
979
+
980
+ return properties
981
+
982
+ def property(self, name: str = None, default: object = None) -> dict | None:
983
+ """Support obtaining a copy of the value assigned to a model property"""
984
+
985
+ if name is None:
986
+ return copy.copy(self._properties)
987
+ elif info := self._properties.get(name):
988
+ return copy.copy(info)
989
+ else:
990
+ return default
991
+
992
+ def documents(
993
+ self,
994
+ blank: bool = True,
995
+ embedded: bool = True,
996
+ referenced: bool = True,
997
+ filter: callable = None,
998
+ ) -> list[Model]:
999
+ """Support assembling a list of documents from the current node structure"""
1000
+
1001
+ def _nodes(
1002
+ node: Model, nodes: list, parent: Model, ancestor: Model = None
1003
+ ) -> list[Model]:
1004
+ """Recursive method to support filtering and assembling a list of nodes"""
1005
+
1006
+ if node.is_cloned is True:
1007
+ node = parent = node._cloned
1008
+ elif node.is_reference is True:
1009
+ node = node._reference
1010
+
1011
+ if not isinstance(node, Model):
1012
+ logger.debug(">>> node is invalid: %s" % (type(node)))
1013
+ return nodes
1014
+
1015
+ if node in nodes: # node seen before, so return, preventing an endless loop
1016
+ logger.debug(">>> node seen before: %s" % (node))
1017
+ return nodes
1018
+
1019
+ logger.debug("> node: %s" % (node))
1020
+ logger.debug("> id: %s" % (node.id))
1021
+ logger.debug("> is_parent: %s" % (node is parent))
1022
+ logger.debug("> is_blank: %s" % (node.is_blank))
1023
+ logger.debug("> is_clone: %s" % (node.is_cloned))
1024
+ logger.debug("> is_reference: %s" % (node.is_reference))
1025
+ logger.debug("> was_referenced: %s" % (node.was_referenced))
1026
+
1027
+ included: bool = True
1028
+
1029
+ if node is parent and not self is parent:
1030
+ logger.debug(">>> node is parent: %s" % (node.id))
1031
+ included = False
1032
+
1033
+ if included is True and blank is False:
1034
+ if node.is_blank is True:
1035
+ logger.debug(">>> node is blank: %s" % (node))
1036
+ included = False
1037
+
1038
+ if included is True and embedded is False:
1039
+ if node.id and parent.id:
1040
+ if len(node.id) > len(parent.id) and node.id.startswith(parent.id):
1041
+ logger.debug(
1042
+ ">>> node is embedded (starts with parent.id): %s"
1043
+ % (node.id)
1044
+ )
1045
+ included = False
1046
+
1047
+ if included is True and referenced is False:
1048
+ if node.was_referenced is True:
1049
+ logger.debug(
1050
+ ">>> node was referenced by another node: %s" % (node.id)
1051
+ )
1052
+ included = False
1053
+
1054
+ if included is True and callable(filter):
1055
+ if filter(node, self) is False:
1056
+ logger.debug(
1057
+ ">>> node was filtered out by custom filter callback logic: %s"
1058
+ % (node.id)
1059
+ )
1060
+ included = False
1061
+
1062
+ if included is True:
1063
+ logger.debug(">>> node was included: %s" % (node.id))
1064
+ nodes += [node]
1065
+ else:
1066
+ logger.debug(">>> node not included: %s" % (node.id))
1067
+
1068
+ for key, value in node.data.items():
1069
+ if isinstance(value, Model):
1070
+ _nodes(value, nodes, parent=parent, ancestor=node)
1071
+ elif isinstance(value, list):
1072
+ for _index, _value in enumerate(value):
1073
+ if isinstance(_value, Model):
1074
+ _nodes(_value, nodes, parent=parent, ancestor=node)
1075
+ elif isinstance(value, dict):
1076
+ for _key, _value in value.items():
1077
+ if isinstance(_value, Model):
1078
+ _nodes(_value, nodes, parent=parent, ancestor=node)
1079
+
1080
+ return nodes
1081
+
1082
+ nodes: list[Model] = _nodes(self, nodes=[], parent=self)
1083
+
1084
+ if callable(filter):
1085
+ temp: list[Model] = []
1086
+
1087
+ for node in nodes:
1088
+ if filter(node, self) is True:
1089
+ temp.append(node)
1090
+
1091
+ nodes = temp
1092
+
1093
+ return nodes