gedcom-x 0.5.7__py3-none-any.whl → 0.5.9__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.
- {gedcom_x-0.5.7.dist-info → gedcom_x-0.5.9.dist-info}/METADATA +1 -1
- gedcom_x-0.5.9.dist-info/RECORD +56 -0
- gedcomx/Extensions/rs10/rsLink.py +110 -60
- gedcomx/TopLevelTypeCollection.py +1 -1
- gedcomx/__init__.py +43 -42
- gedcomx/address.py +217 -0
- gedcomx/{Agent.py → agent.py} +107 -34
- gedcomx/attribution.py +115 -0
- gedcomx/{Conclusion.py → conclusion.py} +120 -51
- gedcomx/{Converter.py → converter.py} +261 -116
- gedcomx/coverage.py +64 -0
- gedcomx/{Date.py → date.py} +43 -9
- gedcomx/{Document.py → document.py} +60 -12
- gedcomx/{Event.py → event.py} +88 -31
- gedcomx/evidence_reference.py +20 -0
- gedcomx/{Fact.py → fact.py} +81 -74
- gedcomx/{Gedcom.py → gedcom.py} +10 -0
- gedcomx/{Gedcom5x.py → gedcom5x.py} +31 -21
- gedcomx/gedcom7/Exceptions.py +9 -0
- gedcomx/gedcom7/GedcomStructure.py +94 -0
- gedcomx/gedcom7/Specification.py +347 -0
- gedcomx/gedcom7/__init__.py +26 -0
- gedcomx/gedcom7/g7interop.py +205 -0
- gedcomx/gedcom7/gedcom7.py +160 -0
- gedcomx/gedcom7/logger.py +19 -0
- gedcomx/{GedcomX.py → gedcomx.py} +109 -106
- gedcomx/gender.py +91 -0
- gedcomx/group.py +72 -0
- gedcomx/{Identifier.py → identifier.py} +48 -21
- gedcomx/{LoggingHub.py → logging_hub.py} +19 -0
- gedcomx/{Mutations.py → mutations.py} +59 -30
- gedcomx/{Name.py → name.py} +88 -47
- gedcomx/note.py +105 -0
- gedcomx/online_account.py +19 -0
- gedcomx/{Person.py → person.py} +61 -41
- gedcomx/{PlaceDescription.py → place_description.py} +71 -23
- gedcomx/{PlaceReference.py → place_reference.py} +32 -10
- gedcomx/{Qualifier.py → qualifier.py} +20 -4
- gedcomx/relationship.py +156 -0
- gedcomx/resource.py +112 -0
- gedcomx/serialization.py +794 -0
- gedcomx/source_citation.py +37 -0
- gedcomx/source_description.py +401 -0
- gedcomx/{SourceReference.py → source_reference.py} +56 -21
- gedcomx/subject.py +122 -0
- gedcomx/textvalue.py +89 -0
- gedcomx/{Translation.py → translation.py} +4 -4
- gedcomx/uri.py +273 -0
- gedcom_x-0.5.7.dist-info/RECORD +0 -49
- gedcomx/Address.py +0 -131
- gedcomx/Attribution.py +0 -91
- gedcomx/Coverage.py +0 -37
- gedcomx/EvidenceReference.py +0 -11
- gedcomx/Gender.py +0 -65
- gedcomx/Group.py +0 -37
- gedcomx/Note.py +0 -73
- gedcomx/OnlineAccount.py +0 -10
- gedcomx/Relationship.py +0 -97
- gedcomx/Resource.py +0 -85
- gedcomx/Serialization.py +0 -816
- gedcomx/SourceCitation.py +0 -25
- gedcomx/SourceDescription.py +0 -314
- gedcomx/Subject.py +0 -59
- gedcomx/TextValue.py +0 -35
- gedcomx/URI.py +0 -105
- {gedcom_x-0.5.7.dist-info → gedcom_x-0.5.9.dist-info}/WHEEL +0 -0
- {gedcom_x-0.5.7.dist-info → gedcom_x-0.5.9.dist-info}/top_level.txt +0 -0
- /gedcomx/{Exceptions.py → exceptions.py} +0 -0
- /gedcomx/{ExtensibleEnum.py → extensible_enum.py} +0 -0
gedcomx/serialization.py
ADDED
@@ -0,0 +1,794 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
from functools import lru_cache
|
3
|
+
|
4
|
+
import enum
|
5
|
+
import logging
|
6
|
+
import types
|
7
|
+
from collections.abc import Sized
|
8
|
+
from typing import Any, Dict, List, Set, Tuple, Union, Annotated, ForwardRef, get_args, get_origin
|
9
|
+
from typing import Any, Callable, Mapping, List, Dict, Tuple, Set
|
10
|
+
from typing import List, Optional
|
11
|
+
from time import perf_counter
|
12
|
+
|
13
|
+
"""
|
14
|
+
======================================================================
|
15
|
+
Project: Gedcom-X
|
16
|
+
File: Serialization.py
|
17
|
+
Author: David J. Cartwright
|
18
|
+
Purpose: Serialization/Deserialization of gedcomx Objects
|
19
|
+
|
20
|
+
Created: 2025-08-25
|
21
|
+
Updated:
|
22
|
+
- 2025-08-31: cleaned up imports and documentation
|
23
|
+
|
24
|
+
======================================================================
|
25
|
+
"""
|
26
|
+
|
27
|
+
"""
|
28
|
+
======================================================================
|
29
|
+
GEDCOM Module Types
|
30
|
+
======================================================================
|
31
|
+
"""
|
32
|
+
from .address import Address
|
33
|
+
from .agent import Agent
|
34
|
+
from .attribution import Attribution
|
35
|
+
from .conclusion import ConfidenceLevel
|
36
|
+
from .date import Date
|
37
|
+
from .document import Document, DocumentType, TextType
|
38
|
+
from .evidence_reference import EvidenceReference
|
39
|
+
from .event import Event, EventType, EventRole, EventRoleType
|
40
|
+
from .Extensions.rs10.rsLink import _rsLinks, rsLink
|
41
|
+
from .fact import Fact, FactType, FactQualifier
|
42
|
+
from .gender import Gender, GenderType
|
43
|
+
from .identifier import IdentifierList, Identifier
|
44
|
+
from .logging_hub import hub, ChannelConfig
|
45
|
+
from .name import Name, NameType, NameForm, NamePart, NamePartType, NamePartQualifier
|
46
|
+
from .note import Note
|
47
|
+
from .online_account import OnlineAccount
|
48
|
+
from .person import Person
|
49
|
+
from .place_description import PlaceDescription
|
50
|
+
from .place_reference import PlaceReference
|
51
|
+
from .qualifier import Qualifier
|
52
|
+
from .relationship import Relationship, RelationshipType
|
53
|
+
from .resource import Resource
|
54
|
+
from .source_description import SourceDescription, ResourceType, SourceCitation, Coverage
|
55
|
+
from .source_reference import SourceReference
|
56
|
+
from .textvalue import TextValue
|
57
|
+
from .uri import URI
|
58
|
+
#======================================================================
|
59
|
+
|
60
|
+
log = logging.getLogger("gedcomx")
|
61
|
+
deserialization = "gedcomx.deserialization"
|
62
|
+
|
63
|
+
serial_log = "gedcomx.serialization"
|
64
|
+
deserial_log = "gedcomx.deserialization"
|
65
|
+
|
66
|
+
_PRIMITIVES = (str, int, float, bool, type(None))
|
67
|
+
|
68
|
+
def _has_parent_class(obj) -> bool:
|
69
|
+
return hasattr(obj, '__class__') and hasattr(obj.__class__, '__bases__') and len(obj.__class__.__bases__) > 0
|
70
|
+
|
71
|
+
class Serialization:
|
72
|
+
|
73
|
+
@staticmethod
|
74
|
+
def serialize(obj: object):
|
75
|
+
if obj is not None:
|
76
|
+
with hub.use(serial_log):
|
77
|
+
|
78
|
+
if isinstance(obj, (str, int, float, bool, type(None))):
|
79
|
+
return obj
|
80
|
+
if isinstance(obj, dict):
|
81
|
+
return {k: Serialization.serialize(v) for k, v in obj.items()}
|
82
|
+
if isinstance(obj, URI):
|
83
|
+
return obj.value
|
84
|
+
if isinstance(obj, (list, tuple, set)):
|
85
|
+
l = [Serialization.serialize(v) for v in obj]
|
86
|
+
if len(l) == 0: return None
|
87
|
+
return l
|
88
|
+
if type(obj).__name__ == 'Collection':
|
89
|
+
l= [Serialization.serialize(v) for v in obj]
|
90
|
+
if len(l) == 0: return None
|
91
|
+
return l
|
92
|
+
if isinstance(obj, enum.Enum):
|
93
|
+
return Serialization.serialize(obj.value)
|
94
|
+
|
95
|
+
log.debug(f"Serializing a '{type(obj).__name__}'")
|
96
|
+
type_as_dict = {}
|
97
|
+
fields = Serialization.get_class_fields(type(obj).__name__)
|
98
|
+
if fields:
|
99
|
+
for field_name, type_ in fields.items():
|
100
|
+
if hasattr(obj,field_name):
|
101
|
+
if (v := getattr(obj,field_name)) is not None:
|
102
|
+
if type_ == Resource:
|
103
|
+
log.error(f"Refering to a {type(obj).__name__} with a '{type_}'")
|
104
|
+
res = Resource(target=v)
|
105
|
+
sv = Serialization.serialize(res)
|
106
|
+
type_as_dict[field_name] = sv
|
107
|
+
elif type_ == URI:
|
108
|
+
log.error(f"Refering to a {type(obj).__name__} with a '{type_}'")
|
109
|
+
uri = URI(target=v)
|
110
|
+
sv = Serialization.serialize(uri)
|
111
|
+
type_as_dict[field_name] = sv
|
112
|
+
elif (sv := Serialization.serialize(v)) is not None:
|
113
|
+
type_as_dict[field_name] = sv
|
114
|
+
else:
|
115
|
+
log.error(f"{type(obj).__name__} did not have field '{field_name}'")
|
116
|
+
if type_as_dict == {}: log.error(f"Serialized a '{type(obj).__name__}' with empty fields: '{fields}'")
|
117
|
+
else: log.debug(f"Serialized a '{type(obj).__name__}' with fields '{type_as_dict})'")
|
118
|
+
#return Serialization._serialize_dict(type_as_dict)
|
119
|
+
return type_as_dict if type_as_dict != {} else None
|
120
|
+
else:
|
121
|
+
log.error(f"Could not find fields for {type(obj).__name__}")
|
122
|
+
return None
|
123
|
+
|
124
|
+
@staticmethod
|
125
|
+
def _serialize_dict(dict_to_serialize: dict) -> dict:
|
126
|
+
"""
|
127
|
+
Walk a dict and serialize nested GedcomX objects to JSON-compatible values.
|
128
|
+
- Uses `_as_dict_` on your objects when present
|
129
|
+
- Recurse into dicts / lists / sets / tuples
|
130
|
+
- Drops None and empty containers
|
131
|
+
"""
|
132
|
+
def _serialize(value):
|
133
|
+
if isinstance(value, (str, int, float, bool, type(None))):
|
134
|
+
return value
|
135
|
+
if (fields := Serialization.get_class_fields(type(value).__name__)) is not None:
|
136
|
+
# Expect your objects expose a snapshot via _as_dict_
|
137
|
+
return Serialization.serialize(value)
|
138
|
+
if isinstance(value, dict):
|
139
|
+
return {k: _serialize(v) for k, v in value.items()}
|
140
|
+
if isinstance(value, (list, tuple, set)):
|
141
|
+
return [_serialize(v) for v in value]
|
142
|
+
# Fallback: string representation
|
143
|
+
return str(value)
|
144
|
+
|
145
|
+
if isinstance(dict_to_serialize, dict):
|
146
|
+
cooked = {
|
147
|
+
k: _serialize(v)
|
148
|
+
for k, v in dict_to_serialize.items()
|
149
|
+
if v is not None
|
150
|
+
}
|
151
|
+
# prune empty containers (after serialization)
|
152
|
+
return {
|
153
|
+
k: v
|
154
|
+
for k, v in cooked.items()
|
155
|
+
if not (isinstance(v, Sized) and len(v) == 0)
|
156
|
+
}
|
157
|
+
return {}
|
158
|
+
|
159
|
+
# --- tiny helpers --------------------------------------------------------
|
160
|
+
@staticmethod
|
161
|
+
def _is_resource(obj: Any) -> bool:
|
162
|
+
"""
|
163
|
+
try:
|
164
|
+
from Resource import Resource
|
165
|
+
except Exception:
|
166
|
+
class Resource: pass
|
167
|
+
"""
|
168
|
+
return isinstance(obj, Resource)
|
169
|
+
|
170
|
+
@staticmethod
|
171
|
+
def _has_resource_value(x: Any) -> bool:
|
172
|
+
if Serialization._is_resource(x):
|
173
|
+
return True
|
174
|
+
if isinstance(x, (list, tuple, set)):
|
175
|
+
return any(Serialization._has_resource_value(v) for v in x)
|
176
|
+
if isinstance(x, dict):
|
177
|
+
return any(Serialization._has_resource_value(v) for v in x.values())
|
178
|
+
return False
|
179
|
+
|
180
|
+
@staticmethod
|
181
|
+
def _resolve_structure(x: Any, resolver: Callable[[Any], Any]) -> Any:
|
182
|
+
"""Return a deep copy with Resources resolved via resolver(Resource)->Any."""
|
183
|
+
if Serialization._is_resource(x):
|
184
|
+
return resolver(x)
|
185
|
+
if isinstance(x, list):
|
186
|
+
return [Serialization._resolve_structure(v, resolver) for v in x]
|
187
|
+
if isinstance(x, tuple):
|
188
|
+
return tuple(Serialization._resolve_structure(v, resolver) for v in x)
|
189
|
+
if isinstance(x, set):
|
190
|
+
return {Serialization._resolve_structure(v, resolver) for v in x}
|
191
|
+
if isinstance(x, dict):
|
192
|
+
return {k: Serialization._resolve_structure(v, resolver) for k, v in x.items()}
|
193
|
+
return x
|
194
|
+
|
195
|
+
@classmethod
|
196
|
+
def apply_resource_resolutions(cls, inst: Any, resolver: Callable[[Any], Any]) -> None:
|
197
|
+
"""Resolve any queued attribute setters stored on the instance."""
|
198
|
+
setters: List[Callable[[Any], None]] = getattr(inst, "_resource_setters", [])
|
199
|
+
for set_fn in setters:
|
200
|
+
set_fn(inst, resolver)
|
201
|
+
# Optional: clear after applying
|
202
|
+
inst._resource_setters = []
|
203
|
+
|
204
|
+
# --- your deserialize with setters --------------------------------------
|
205
|
+
|
206
|
+
@classmethod
|
207
|
+
def deserialize(
|
208
|
+
cls,
|
209
|
+
data: dict[str, Any],
|
210
|
+
class_type: type,
|
211
|
+
*,
|
212
|
+
resolver: Callable[[Any], Any] | None = None,
|
213
|
+
queue_setters: bool = True,
|
214
|
+
) -> Any:
|
215
|
+
|
216
|
+
with hub.use(deserial_log):
|
217
|
+
t0 = perf_counter()
|
218
|
+
class_fields = cls.get_class_fields(class_type.__name__)
|
219
|
+
|
220
|
+
result: dict[str, Any] = {}
|
221
|
+
pending: list[tuple[str, Any]] = []
|
222
|
+
|
223
|
+
# bind hot callables
|
224
|
+
_coerce = cls._coerce_value
|
225
|
+
_hasres = cls._has_resource_value
|
226
|
+
|
227
|
+
log.debug("deserialize[%s]: keys=%s", class_type.__name__, list(data.keys()))
|
228
|
+
|
229
|
+
for name, typ in class_fields.items():
|
230
|
+
raw = data.get(name, None)
|
231
|
+
if raw is None:
|
232
|
+
continue
|
233
|
+
try:
|
234
|
+
val = _coerce(raw, typ)
|
235
|
+
except Exception:
|
236
|
+
log.exception("deserialize[%s]: coercion failed for field '%s' raw=%r",
|
237
|
+
class_type.__name__, name, raw)
|
238
|
+
raise
|
239
|
+
result[name] = val
|
240
|
+
if _hasres(val):
|
241
|
+
pending.append((name, val))
|
242
|
+
|
243
|
+
# instantiate
|
244
|
+
try:
|
245
|
+
inst = class_type(**result)
|
246
|
+
except TypeError:
|
247
|
+
log.exception("deserialize[%s]: __init__ failed with kwargs=%s",
|
248
|
+
class_type.__name__, list(result.keys()))
|
249
|
+
raise
|
250
|
+
|
251
|
+
# resolve now (optional)
|
252
|
+
if resolver and pending:
|
253
|
+
for attr, raw in pending:
|
254
|
+
try:
|
255
|
+
resolved = cls._resolve_structure(raw, resolver)
|
256
|
+
setattr(inst, attr, resolved)
|
257
|
+
except Exception:
|
258
|
+
log.exception("deserialize[%s]: resolver failed for '%s'", class_type.__name__, attr)
|
259
|
+
raise
|
260
|
+
|
261
|
+
# queue setters (store (attr, raw) tuples) — preserves your later-resolution behavior
|
262
|
+
if queue_setters and pending:
|
263
|
+
existing = getattr(inst, "_resource_setters", [])
|
264
|
+
inst._resource_setters = [*existing, *pending]
|
265
|
+
|
266
|
+
|
267
|
+
log.debug("deserialize[%s]: done in %.3f ms (resolved=%d, queued=%d)",
|
268
|
+
class_type.__name__, (perf_counter() - t0) * 1000,
|
269
|
+
int(bool(resolver)) * len(pending), len(pending))
|
270
|
+
return inst
|
271
|
+
|
272
|
+
@staticmethod
|
273
|
+
@lru_cache(maxsize=None)
|
274
|
+
def get_class_fields(cls_name) -> Dict:
|
275
|
+
# NOTE: keep imports local to avoid circulars
|
276
|
+
|
277
|
+
|
278
|
+
fields = {
|
279
|
+
"Agent": {
|
280
|
+
"id": str,
|
281
|
+
"identifiers": IdentifierList,
|
282
|
+
"names": List[TextValue],
|
283
|
+
"homepage": URI,
|
284
|
+
"openid": URI,
|
285
|
+
"accounts": List[OnlineAccount],
|
286
|
+
"emails": List[URI],
|
287
|
+
"phones": List[URI],
|
288
|
+
"addresses": List[Address],
|
289
|
+
"person": object | Resource, # intended Person | Resource
|
290
|
+
"attribution": object, # GEDCOM5/7 compatibility
|
291
|
+
"uri": URI | Resource,
|
292
|
+
},
|
293
|
+
"Attribution": {
|
294
|
+
"contributor": Resource,
|
295
|
+
"modified": str,
|
296
|
+
"changeMessage": str,
|
297
|
+
"creator": Resource,
|
298
|
+
"created": str,
|
299
|
+
},
|
300
|
+
"Conclusion": {
|
301
|
+
"id": str,
|
302
|
+
"lang": str,
|
303
|
+
"sources": List["SourceReference"],
|
304
|
+
"analysis": Document | Resource,
|
305
|
+
"notes": List[Note],
|
306
|
+
"confidence": ConfidenceLevel,
|
307
|
+
"attribution": Attribution,
|
308
|
+
"uri": "Resource",
|
309
|
+
"max_note_count": int,
|
310
|
+
"links": _rsLinks,
|
311
|
+
},
|
312
|
+
"Date": {
|
313
|
+
"original": str,
|
314
|
+
"formal": str,
|
315
|
+
"normalized": str,
|
316
|
+
},
|
317
|
+
"Document": {
|
318
|
+
"id": str,
|
319
|
+
"lang": str,
|
320
|
+
"sources": List[SourceReference],
|
321
|
+
"analysis": Resource,
|
322
|
+
"notes": List[Note],
|
323
|
+
"confidence": ConfidenceLevel,
|
324
|
+
"attribution": Attribution,
|
325
|
+
"type": DocumentType,
|
326
|
+
"extracted": bool,
|
327
|
+
"textType": TextType,
|
328
|
+
"text": str,
|
329
|
+
},
|
330
|
+
"Event": {
|
331
|
+
"id": str,
|
332
|
+
"lang": str,
|
333
|
+
"sources": List[SourceReference],
|
334
|
+
"analysis": Resource,
|
335
|
+
"notes": List[Note],
|
336
|
+
"confidence": ConfidenceLevel,
|
337
|
+
"attribution": Attribution,
|
338
|
+
"extracted": bool,
|
339
|
+
"evidence": List[EvidenceReference],
|
340
|
+
"media": List[SourceReference],
|
341
|
+
"identifiers": List[Identifier],
|
342
|
+
"type": EventType,
|
343
|
+
"date": Date,
|
344
|
+
"place": PlaceReference,
|
345
|
+
"roles": List[EventRole],
|
346
|
+
},
|
347
|
+
"EventRole": {
|
348
|
+
"id:": str,
|
349
|
+
"lang": str,
|
350
|
+
"sources": List[SourceReference],
|
351
|
+
"analysis": Resource,
|
352
|
+
"notes": List[Note],
|
353
|
+
"confidence": ConfidenceLevel,
|
354
|
+
"attribution": Attribution,
|
355
|
+
"person": Resource,
|
356
|
+
"type": EventRoleType,
|
357
|
+
"details": str,
|
358
|
+
},
|
359
|
+
"Fact": {
|
360
|
+
"id": str,
|
361
|
+
"lang": str,
|
362
|
+
"sources": List[SourceReference],
|
363
|
+
"analysis": Resource | Document,
|
364
|
+
"notes": List[Note],
|
365
|
+
"confidence": ConfidenceLevel,
|
366
|
+
"attribution": Attribution,
|
367
|
+
"type": FactType,
|
368
|
+
"date": Date,
|
369
|
+
"place": PlaceReference,
|
370
|
+
"value": str,
|
371
|
+
"qualifiers": List[FactQualifier],
|
372
|
+
"links": _rsLinks,
|
373
|
+
},
|
374
|
+
"GedcomX": {
|
375
|
+
"persons": List[Person],
|
376
|
+
"relationships": List[Relationship],
|
377
|
+
"sourceDescriptions": List[SourceDescription],
|
378
|
+
"agents": List[Agent],
|
379
|
+
"places": List[PlaceDescription]
|
380
|
+
},
|
381
|
+
"Gender": {
|
382
|
+
"id": str,
|
383
|
+
"lang": str,
|
384
|
+
"sources": List[SourceReference],
|
385
|
+
"analysis": Resource,
|
386
|
+
"notes": List[Note],
|
387
|
+
"confidence": ConfidenceLevel,
|
388
|
+
"attribution": Attribution,
|
389
|
+
"type": GenderType,
|
390
|
+
},
|
391
|
+
"KnownSourceReference": {
|
392
|
+
"name": str,
|
393
|
+
"value": str,
|
394
|
+
},
|
395
|
+
"Name": {
|
396
|
+
"id": str,
|
397
|
+
"lang": str,
|
398
|
+
"sources": List[SourceReference],
|
399
|
+
"analysis": Resource,
|
400
|
+
"notes": List[Note],
|
401
|
+
"confidence": ConfidenceLevel,
|
402
|
+
"attribution": Attribution,
|
403
|
+
"type": NameType,
|
404
|
+
"nameForms": List[NameForm], # use string to avoid circulars if needed
|
405
|
+
"date": Date,
|
406
|
+
},
|
407
|
+
"NameForm": {
|
408
|
+
"lang": str,
|
409
|
+
"fullText": str,
|
410
|
+
"parts": List[NamePart], # use "NamePart" as a forward-ref to avoid circulars
|
411
|
+
},
|
412
|
+
"NamePart": {
|
413
|
+
"type": NamePartType,
|
414
|
+
"value": str,
|
415
|
+
"qualifiers": List["NamePartQualifier"], # quote if you want to avoid circulars
|
416
|
+
},
|
417
|
+
"Note":{"lang":str,
|
418
|
+
"subject":str,
|
419
|
+
"text":str,
|
420
|
+
"attribution": Attribution},
|
421
|
+
"Person": {
|
422
|
+
"id": str,
|
423
|
+
"lang": str,
|
424
|
+
"sources": List[SourceReference],
|
425
|
+
"analysis": Resource,
|
426
|
+
"notes": List[Note],
|
427
|
+
"confidence": ConfidenceLevel,
|
428
|
+
"attribution": Attribution,
|
429
|
+
"extracted": bool,
|
430
|
+
"evidence": List[EvidenceReference],
|
431
|
+
"media": List[SourceReference],
|
432
|
+
"identifiers": IdentifierList,
|
433
|
+
"private": bool,
|
434
|
+
"gender": Gender,
|
435
|
+
"names": List[Name],
|
436
|
+
"facts": List[Fact],
|
437
|
+
"living": bool,
|
438
|
+
"links": _rsLinks,
|
439
|
+
},
|
440
|
+
"PlaceDescription": {
|
441
|
+
"id": str,
|
442
|
+
"lang": str,
|
443
|
+
"sources": List[SourceReference],
|
444
|
+
"analysis": Resource,
|
445
|
+
"notes": List[Note],
|
446
|
+
"confidence": ConfidenceLevel,
|
447
|
+
"attribution": Attribution,
|
448
|
+
"extracted": bool,
|
449
|
+
"evidence": List[EvidenceReference],
|
450
|
+
"media": List[SourceReference],
|
451
|
+
"identifiers": List[IdentifierList],
|
452
|
+
"names": List[TextValue],
|
453
|
+
"type": str,
|
454
|
+
"place": URI,
|
455
|
+
"jurisdiction": Resource,
|
456
|
+
"latitude": float,
|
457
|
+
"longitude": float,
|
458
|
+
"temporalDescription": Date,
|
459
|
+
"spatialDescription": Resource,
|
460
|
+
},
|
461
|
+
"PlaceReference": {
|
462
|
+
"original": str,
|
463
|
+
"description": URI,
|
464
|
+
},
|
465
|
+
"Qualifier": {
|
466
|
+
"name": str,
|
467
|
+
"value": str,
|
468
|
+
},
|
469
|
+
"_rsLinks": {
|
470
|
+
"person":rsLink,
|
471
|
+
"portrait":rsLink},
|
472
|
+
"rsLink": {
|
473
|
+
"href": URI,
|
474
|
+
"template": str,
|
475
|
+
"type": str,
|
476
|
+
"accept": str,
|
477
|
+
"allow": str,
|
478
|
+
"hreflang": str,
|
479
|
+
"title": str,
|
480
|
+
},
|
481
|
+
"Relationship": {
|
482
|
+
"id": str,
|
483
|
+
"lang": str,
|
484
|
+
"sources": List[SourceReference],
|
485
|
+
"analysis": Resource,
|
486
|
+
"notes": List[Note],
|
487
|
+
"confidence": ConfidenceLevel,
|
488
|
+
"attribution": Attribution,
|
489
|
+
"extracted": bool,
|
490
|
+
"evidence": List[EvidenceReference],
|
491
|
+
"media": List[SourceReference],
|
492
|
+
"identifiers": IdentifierList,
|
493
|
+
"type": RelationshipType,
|
494
|
+
"person1": Resource,
|
495
|
+
"person2": Resource,
|
496
|
+
"facts": List[Fact],
|
497
|
+
},
|
498
|
+
"Resource": {
|
499
|
+
"resource": str,
|
500
|
+
"resourceId": str,
|
501
|
+
},
|
502
|
+
"SourceDescription": {
|
503
|
+
"id": str,
|
504
|
+
"resourceType": ResourceType,
|
505
|
+
"citations": List[SourceCitation],
|
506
|
+
"mediaType": str,
|
507
|
+
"about": URI,
|
508
|
+
"mediator": Resource,
|
509
|
+
"publisher": Resource, # forward-ref to avoid circular import
|
510
|
+
"authors": List[Resource],
|
511
|
+
"sources": List[SourceReference], # SourceReference
|
512
|
+
"analysis": Resource, # analysis is typically a Document (kept union to avoid cycle)
|
513
|
+
"componentOf": SourceReference, # SourceReference
|
514
|
+
"titles": List[TextValue],
|
515
|
+
"notes": List[Note],
|
516
|
+
"attribution": Attribution,
|
517
|
+
"rights": List[Resource],
|
518
|
+
"coverage": List[Coverage], # Coverage
|
519
|
+
"descriptions": List[TextValue],
|
520
|
+
"identifiers": IdentifierList,
|
521
|
+
"created": Date,
|
522
|
+
"modified": Date,
|
523
|
+
"published": Date,
|
524
|
+
"repository": Agent, # forward-ref
|
525
|
+
"max_note_count": int,
|
526
|
+
},
|
527
|
+
"SourceReference": {
|
528
|
+
"description": Resource,
|
529
|
+
"descriptionId": str,
|
530
|
+
"attribution": Attribution,
|
531
|
+
"qualifiers": List[Qualifier],
|
532
|
+
},
|
533
|
+
"Subject": {
|
534
|
+
"id": str,
|
535
|
+
"lang": str,
|
536
|
+
"sources": List["SourceReference"],
|
537
|
+
"analysis": Resource,
|
538
|
+
"notes": List["Note"],
|
539
|
+
"confidence": ConfidenceLevel,
|
540
|
+
"attribution": Attribution,
|
541
|
+
"extracted": bool,
|
542
|
+
"evidence": List[EvidenceReference],
|
543
|
+
"media": List[SourceReference],
|
544
|
+
"identifiers": IdentifierList,
|
545
|
+
"uri": Resource,
|
546
|
+
"links": _rsLinks,
|
547
|
+
},
|
548
|
+
"TextValue":{"lang":str,"value":str},
|
549
|
+
"URI": {
|
550
|
+
"value": str,
|
551
|
+
},
|
552
|
+
|
553
|
+
}
|
554
|
+
|
555
|
+
return fields.get(cls_name, {})
|
556
|
+
|
557
|
+
|
558
|
+
@classmethod
|
559
|
+
def _coerce_value(cls, value: Any, Typ: Any) -> Any:
|
560
|
+
"""Coerce `value` into `Typ` using the registry (recursively), with verbose logging."""
|
561
|
+
log.debug("COERCE enter: value=%r (type=%s) -> Typ=%r", value, type(value).__name__, Typ)
|
562
|
+
|
563
|
+
# Enums
|
564
|
+
if cls._is_enum_type(Typ):
|
565
|
+
U = cls._resolve_forward(cls._unwrap(Typ))
|
566
|
+
log.debug("COERCE enum: casting %r to %s", value, getattr(U, "__name__", U))
|
567
|
+
try:
|
568
|
+
ret = U(value)
|
569
|
+
log.debug("COERCE enum: success -> %r", ret)
|
570
|
+
return ret
|
571
|
+
except Exception:
|
572
|
+
log.exception("COERCE enum: failed to cast %r to %s", value, U)
|
573
|
+
return value
|
574
|
+
|
575
|
+
# Unwrap typing once
|
576
|
+
T = cls._resolve_forward(cls._unwrap(Typ))
|
577
|
+
origin = get_origin(T) or T
|
578
|
+
args = get_args(T)
|
579
|
+
log.debug("COERCE typing: unwrapped Typ=%r -> T=%r, origin=%r, args=%r", Typ, T, origin, args)
|
580
|
+
|
581
|
+
# Late imports to reduce circulars (and to allow logging if they aren't available)
|
582
|
+
'''
|
583
|
+
try:
|
584
|
+
from gedcomx.resource import Resource
|
585
|
+
from gedcomx.uri import URI
|
586
|
+
from gedcomx.identifier import IdentifierList
|
587
|
+
_gx_import_ok = True
|
588
|
+
except Exception as _imp_err:
|
589
|
+
_gx_import_ok = False
|
590
|
+
Resource = URI = IdentifierList = object # fallbacks avoid NameError
|
591
|
+
log.debug("COERCE imports: gedcomx types not available (%r); using object fallbacks", _imp_err)
|
592
|
+
'''
|
593
|
+
|
594
|
+
# Strings to Resource/URI
|
595
|
+
if isinstance(value, str):
|
596
|
+
if T is Resource:
|
597
|
+
log.debug("COERCE str->Resource: %r", value)
|
598
|
+
try:
|
599
|
+
ret = Resource(resourceId=value)
|
600
|
+
log.debug("COERCE str->Resource: built %r", ret)
|
601
|
+
return ret
|
602
|
+
except Exception:
|
603
|
+
log.exception("COERCE str->Resource: failed for %r", value)
|
604
|
+
return value
|
605
|
+
if T is URI:
|
606
|
+
log.debug("COERCE str->URI: %r", value)
|
607
|
+
try:
|
608
|
+
ret: Any = URI.from_url(value)
|
609
|
+
log.debug("COERCE str->URI: built %r", ret)
|
610
|
+
return ret
|
611
|
+
except Exception:
|
612
|
+
log.exception("COERCE str->URI: failed for %r", value)
|
613
|
+
return value
|
614
|
+
log.debug("COERCE str passthrough: target %r is not Resource/URI", T)
|
615
|
+
return value
|
616
|
+
|
617
|
+
# Dict to Resource
|
618
|
+
if T is Resource and isinstance(value, dict):
|
619
|
+
log.debug("COERCE dict->Resource: %r", value)
|
620
|
+
try:
|
621
|
+
ret = Resource(resource=value.get("resource"), resourceId=value.get("resourceId"))
|
622
|
+
log.debug("COERCE dict->Resource: built %r", ret)
|
623
|
+
return ret
|
624
|
+
except Exception:
|
625
|
+
log.exception("COERCE dict->Resource: failed for %r", value)
|
626
|
+
return value
|
627
|
+
|
628
|
+
# IdentifierList special
|
629
|
+
if T is IdentifierList:
|
630
|
+
log.debug("COERCE IdentifierList: %r", value)
|
631
|
+
try:
|
632
|
+
ret = IdentifierList._from_json_(value)
|
633
|
+
log.debug("COERCE IdentifierList: built %r", ret)
|
634
|
+
return ret
|
635
|
+
except Exception:
|
636
|
+
log.exception("COERCE IdentifierList: _from_json_ failed for %r", value)
|
637
|
+
return value
|
638
|
+
|
639
|
+
# Containers
|
640
|
+
if cls._is_list_like(T):
|
641
|
+
elem_t = args[0] if args else Any
|
642
|
+
log.debug("COERCE list-like: len=%s, elem_t=%r", len(value or []), elem_t)
|
643
|
+
try:
|
644
|
+
ret = [cls._coerce_value(v, elem_t) for v in (value or [])]
|
645
|
+
log.debug("COERCE list-like: result sample=%r", ret[:3] if isinstance(ret, list) else ret)
|
646
|
+
return ret
|
647
|
+
except Exception:
|
648
|
+
log.exception("COERCE list-like: failed for value=%r elem_t=%r", value, elem_t)
|
649
|
+
return value
|
650
|
+
|
651
|
+
if cls._is_set_like(T):
|
652
|
+
elem_t = args[0] if args else Any
|
653
|
+
log.debug("COERCE set-like: len=%s, elem_t=%r", len(value or []), elem_t)
|
654
|
+
try:
|
655
|
+
ret = {cls._coerce_value(v, elem_t) for v in (value or [])}
|
656
|
+
log.debug("COERCE set-like: result size=%d", len(ret))
|
657
|
+
return ret
|
658
|
+
except Exception:
|
659
|
+
log.exception("COERCE set-like: failed for value=%r elem_t=%r", value, elem_t)
|
660
|
+
return value
|
661
|
+
|
662
|
+
if cls._is_tuple_like(T):
|
663
|
+
log.debug("COERCE tuple-like: value=%r, args=%r", value, args)
|
664
|
+
try:
|
665
|
+
if not value:
|
666
|
+
log.debug("COERCE tuple-like: empty/None -> ()")
|
667
|
+
return tuple(value or ())
|
668
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
669
|
+
elem_t = args[0]
|
670
|
+
ret = tuple(cls._coerce_value(v, elem_t) for v in (value or ()))
|
671
|
+
log.debug("COERCE tuple-like variadic: size=%d", len(ret))
|
672
|
+
return ret
|
673
|
+
ret = tuple(cls._coerce_value(v, t) for v, t in zip(value, args))
|
674
|
+
log.debug("COERCE tuple-like fixed: size=%d", len(ret))
|
675
|
+
return ret
|
676
|
+
except Exception:
|
677
|
+
log.exception("COERCE tuple-like: failed for value=%r args=%r", value, args)
|
678
|
+
return value
|
679
|
+
|
680
|
+
if cls._is_dict_like(T):
|
681
|
+
k_t = args[0] if len(args) >= 1 else Any
|
682
|
+
v_t = args[1] if len(args) >= 2 else Any
|
683
|
+
log.debug("COERCE dict-like: keys=%s, k_t=%r, v_t=%r", len((value or {}).keys()), k_t, v_t)
|
684
|
+
try:
|
685
|
+
ret = {
|
686
|
+
cls._coerce_value(k, k_t): cls._coerce_value(v, v_t)
|
687
|
+
for k, v in (value or {}).items()
|
688
|
+
}
|
689
|
+
log.debug("COERCE dict-like: result size=%d", len(ret))
|
690
|
+
return ret
|
691
|
+
except Exception:
|
692
|
+
log.exception("COERCE dict-like: failed for value=%r k_t=%r v_t=%r", value, k_t, v_t)
|
693
|
+
return value
|
694
|
+
|
695
|
+
# Objects via registry
|
696
|
+
if isinstance(T, type) and isinstance(value, dict):
|
697
|
+
fields = cls.get_class_fields(T.__name__) or {}
|
698
|
+
log.debug(
|
699
|
+
"COERCE object: class=%s, input_keys=%s, registered_fields=%s",
|
700
|
+
T.__name__, list(value.keys()), list(fields.keys())
|
701
|
+
)
|
702
|
+
if fields:
|
703
|
+
kwargs = {}
|
704
|
+
present = []
|
705
|
+
for fname, ftype in fields.items():
|
706
|
+
if fname in value:
|
707
|
+
resolved = cls._resolve_forward(cls._unwrap(ftype))
|
708
|
+
log.debug("COERCE object.field: %s.%s -> %r, raw=%r", T.__name__, fname, resolved, value[fname])
|
709
|
+
try:
|
710
|
+
coerced = cls._coerce_value(value[fname], resolved)
|
711
|
+
kwargs[fname] = coerced
|
712
|
+
present.append(fname)
|
713
|
+
log.debug("COERCE object.field: %s.%s coerced -> %r", T.__name__, fname, coerced)
|
714
|
+
except Exception:
|
715
|
+
log.exception("COERCE object.field: %s.%s failed", T.__name__, fname)
|
716
|
+
unknown = [k for k in value.keys() if k not in fields]
|
717
|
+
if unknown:
|
718
|
+
log.debug("COERCE object: %s unknown keys ignored: %s", T.__name__, unknown)
|
719
|
+
try:
|
720
|
+
log.debug("COERCE object: instantiate %s(**%s)", T.__name__, present)
|
721
|
+
ret = T(**kwargs)
|
722
|
+
log.debug("COERCE object: success -> %r", ret)
|
723
|
+
return ret
|
724
|
+
except TypeError as e:
|
725
|
+
log.error("COERCE object: instantiate %s failed with kwargs=%s: %s", T.__name__, list(kwargs.keys()), e)
|
726
|
+
log.debug("COERCE object: returning partially coerced dict")
|
727
|
+
return kwargs
|
728
|
+
|
729
|
+
# Already correct type?
|
730
|
+
try:
|
731
|
+
if isinstance(value, T):
|
732
|
+
log.debug("COERCE passthrough: value already instance of %r", T)
|
733
|
+
return value
|
734
|
+
except TypeError:
|
735
|
+
log.debug("COERCE isinstance not applicable: T=%r", T)
|
736
|
+
|
737
|
+
log.debug("COERCE fallback: returning original value=%r (type=%s)", value, type(value).__name__)
|
738
|
+
return value
|
739
|
+
|
740
|
+
|
741
|
+
|
742
|
+
# -------------------------- TYPE HELPERS --------------------------
|
743
|
+
|
744
|
+
@staticmethod
|
745
|
+
@lru_cache(maxsize=None)
|
746
|
+
def _unwrap(T: Any) -> Any:
|
747
|
+
origin = get_origin(T)
|
748
|
+
if origin is None:
|
749
|
+
return T
|
750
|
+
if str(origin).endswith("Annotated"):
|
751
|
+
args = get_args(T)
|
752
|
+
return Serialization._unwrap(args[0]) if args else Any
|
753
|
+
if origin in (Union, types.UnionType):
|
754
|
+
args = tuple(a for a in get_args(T) if a is not type(None))
|
755
|
+
return Serialization._unwrap(args[0]) if len(args) == 1 else tuple(Serialization._unwrap(a) for a in args)
|
756
|
+
return T
|
757
|
+
|
758
|
+
@staticmethod
|
759
|
+
@lru_cache(maxsize=None)
|
760
|
+
def _resolve_forward(T: Any) -> Any:
|
761
|
+
if isinstance(T, ForwardRef):
|
762
|
+
return globals().get(T.__forward_arg__, T)
|
763
|
+
if isinstance(T, str):
|
764
|
+
return globals().get(T, T)
|
765
|
+
return T
|
766
|
+
|
767
|
+
@staticmethod
|
768
|
+
@lru_cache(maxsize=None)
|
769
|
+
def _is_enum_type(T: Any) -> bool:
|
770
|
+
U = Serialization._resolve_forward(Serialization._unwrap(T))
|
771
|
+
try:
|
772
|
+
return isinstance(U, type) and issubclass(U, enum.Enum)
|
773
|
+
except TypeError:
|
774
|
+
return False
|
775
|
+
|
776
|
+
@staticmethod
|
777
|
+
def _is_list_like(T: Any) -> bool:
|
778
|
+
origin = get_origin(T) or T
|
779
|
+
return origin in (list, List)
|
780
|
+
|
781
|
+
@staticmethod
|
782
|
+
def _is_set_like(T: Any) -> bool:
|
783
|
+
origin = get_origin(T) or T
|
784
|
+
return origin in (set, Set)
|
785
|
+
|
786
|
+
@staticmethod
|
787
|
+
def _is_tuple_like(T: Any) -> bool:
|
788
|
+
origin = get_origin(T) or T
|
789
|
+
return origin in (tuple, Tuple)
|
790
|
+
|
791
|
+
@staticmethod
|
792
|
+
def _is_dict_like(T: Any) -> bool:
|
793
|
+
origin = get_origin(T) or T
|
794
|
+
return origin in (dict, Dict)
|