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.
Files changed (69) hide show
  1. {gedcom_x-0.5.7.dist-info → gedcom_x-0.5.9.dist-info}/METADATA +1 -1
  2. gedcom_x-0.5.9.dist-info/RECORD +56 -0
  3. gedcomx/Extensions/rs10/rsLink.py +110 -60
  4. gedcomx/TopLevelTypeCollection.py +1 -1
  5. gedcomx/__init__.py +43 -42
  6. gedcomx/address.py +217 -0
  7. gedcomx/{Agent.py → agent.py} +107 -34
  8. gedcomx/attribution.py +115 -0
  9. gedcomx/{Conclusion.py → conclusion.py} +120 -51
  10. gedcomx/{Converter.py → converter.py} +261 -116
  11. gedcomx/coverage.py +64 -0
  12. gedcomx/{Date.py → date.py} +43 -9
  13. gedcomx/{Document.py → document.py} +60 -12
  14. gedcomx/{Event.py → event.py} +88 -31
  15. gedcomx/evidence_reference.py +20 -0
  16. gedcomx/{Fact.py → fact.py} +81 -74
  17. gedcomx/{Gedcom.py → gedcom.py} +10 -0
  18. gedcomx/{Gedcom5x.py → gedcom5x.py} +31 -21
  19. gedcomx/gedcom7/Exceptions.py +9 -0
  20. gedcomx/gedcom7/GedcomStructure.py +94 -0
  21. gedcomx/gedcom7/Specification.py +347 -0
  22. gedcomx/gedcom7/__init__.py +26 -0
  23. gedcomx/gedcom7/g7interop.py +205 -0
  24. gedcomx/gedcom7/gedcom7.py +160 -0
  25. gedcomx/gedcom7/logger.py +19 -0
  26. gedcomx/{GedcomX.py → gedcomx.py} +109 -106
  27. gedcomx/gender.py +91 -0
  28. gedcomx/group.py +72 -0
  29. gedcomx/{Identifier.py → identifier.py} +48 -21
  30. gedcomx/{LoggingHub.py → logging_hub.py} +19 -0
  31. gedcomx/{Mutations.py → mutations.py} +59 -30
  32. gedcomx/{Name.py → name.py} +88 -47
  33. gedcomx/note.py +105 -0
  34. gedcomx/online_account.py +19 -0
  35. gedcomx/{Person.py → person.py} +61 -41
  36. gedcomx/{PlaceDescription.py → place_description.py} +71 -23
  37. gedcomx/{PlaceReference.py → place_reference.py} +32 -10
  38. gedcomx/{Qualifier.py → qualifier.py} +20 -4
  39. gedcomx/relationship.py +156 -0
  40. gedcomx/resource.py +112 -0
  41. gedcomx/serialization.py +794 -0
  42. gedcomx/source_citation.py +37 -0
  43. gedcomx/source_description.py +401 -0
  44. gedcomx/{SourceReference.py → source_reference.py} +56 -21
  45. gedcomx/subject.py +122 -0
  46. gedcomx/textvalue.py +89 -0
  47. gedcomx/{Translation.py → translation.py} +4 -4
  48. gedcomx/uri.py +273 -0
  49. gedcom_x-0.5.7.dist-info/RECORD +0 -49
  50. gedcomx/Address.py +0 -131
  51. gedcomx/Attribution.py +0 -91
  52. gedcomx/Coverage.py +0 -37
  53. gedcomx/EvidenceReference.py +0 -11
  54. gedcomx/Gender.py +0 -65
  55. gedcomx/Group.py +0 -37
  56. gedcomx/Note.py +0 -73
  57. gedcomx/OnlineAccount.py +0 -10
  58. gedcomx/Relationship.py +0 -97
  59. gedcomx/Resource.py +0 -85
  60. gedcomx/Serialization.py +0 -816
  61. gedcomx/SourceCitation.py +0 -25
  62. gedcomx/SourceDescription.py +0 -314
  63. gedcomx/Subject.py +0 -59
  64. gedcomx/TextValue.py +0 -35
  65. gedcomx/URI.py +0 -105
  66. {gedcom_x-0.5.7.dist-info → gedcom_x-0.5.9.dist-info}/WHEEL +0 -0
  67. {gedcom_x-0.5.7.dist-info → gedcom_x-0.5.9.dist-info}/top_level.txt +0 -0
  68. /gedcomx/{Exceptions.py → exceptions.py} +0 -0
  69. /gedcomx/{ExtensibleEnum.py → extensible_enum.py} +0 -0
@@ -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)