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