gedcom-x 0.5.9__py3-none-any.whl → 0.5.11__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.9.dist-info → gedcom_x-0.5.11.dist-info}/METADATA +1 -1
- gedcom_x-0.5.11.dist-info/RECORD +57 -0
- gedcomx/Extensions/rs10/rsLink.py +2 -1
- gedcomx/__init__.py +8 -3
- gedcomx/address.py +3 -0
- gedcomx/agent.py +11 -6
- gedcomx/attribution.py +3 -1
- gedcomx/conclusion.py +10 -6
- gedcomx/converter.py +92 -27
- gedcomx/coverage.py +3 -1
- gedcomx/date.py +3 -0
- gedcomx/document.py +3 -1
- gedcomx/event.py +3 -0
- gedcomx/evidence_reference.py +30 -3
- gedcomx/extensible.py +86 -0
- gedcomx/fact.py +6 -2
- gedcomx/gedcom5x.py +21 -3
- gedcomx/gedcom7/GedcomStructure.py +1 -3
- gedcomx/gedcom7/__init__.py +1 -1
- gedcomx/gedcom7/gedcom7.py +3 -3
- gedcomx/gedcom7/specification.py +4817 -0
- gedcomx/gedcomx.py +3 -0
- gedcomx/gender.py +5 -1
- gedcomx/group.py +11 -2
- gedcomx/identifier.py +5 -2
- gedcomx/logging_hub.py +132 -22
- gedcomx/name.py +15 -6
- gedcomx/note.py +25 -10
- gedcomx/online_account.py +20 -0
- gedcomx/person.py +8 -6
- gedcomx/place_description.py +3 -1
- gedcomx/place_reference.py +5 -2
- gedcomx/qualifier.py +2 -0
- gedcomx/relationship.py +8 -5
- gedcomx/resource.py +20 -6
- gedcomx/schemas.py +530 -0
- gedcomx/serialization.py +36 -16
- gedcomx/source_citation.py +22 -0
- gedcomx/source_description.py +22 -18
- gedcomx/source_reference.py +25 -3
- gedcomx/subject.py +2 -3
- gedcomx/textvalue.py +19 -4
- gedcomx/uri.py +8 -6
- gedcom_x-0.5.9.dist-info/RECORD +0 -56
- gedcomx/Logging.py +0 -19
- gedcomx/gedcom7/Specification.py +0 -347
- {gedcom_x-0.5.9.dist-info → gedcom_x-0.5.11.dist-info}/WHEEL +0 -0
- {gedcom_x-0.5.9.dist-info → gedcom_x-0.5.11.dist-info}/top_level.txt +0 -0
gedcomx/schemas.py
ADDED
@@ -0,0 +1,530 @@
|
|
1
|
+
|
2
|
+
from __future__ import annotations
|
3
|
+
|
4
|
+
import inspect
|
5
|
+
import sys
|
6
|
+
import types
|
7
|
+
from typing import Any, Dict, Union, get_args, get_origin, get_type_hints
|
8
|
+
|
9
|
+
try:
|
10
|
+
# typing.Annotated may not exist in older 3.9 without typing_extensions
|
11
|
+
from typing import Annotated # type: ignore
|
12
|
+
except Exception: # pragma: no cover
|
13
|
+
Annotated = None # type: ignore
|
14
|
+
|
15
|
+
_UNION_ORIGINS = tuple(
|
16
|
+
x for x in (Union, getattr(types, "UnionType", None)) if x is not None
|
17
|
+
)
|
18
|
+
|
19
|
+
"""
|
20
|
+
======================================================================
|
21
|
+
Project: Gedcom-X
|
22
|
+
File: schema.py
|
23
|
+
Author: David J. Cartwright
|
24
|
+
Purpose: provide schema for serializatin and extensibility
|
25
|
+
|
26
|
+
Created: 2025-08-25
|
27
|
+
Updated:
|
28
|
+
- 2025-09-03: _from_json_ refactor
|
29
|
+
- 2025-09-09: added schema_class
|
30
|
+
|
31
|
+
======================================================================
|
32
|
+
"""
|
33
|
+
|
34
|
+
|
35
|
+
|
36
|
+
|
37
|
+
|
38
|
+
class Schema:
|
39
|
+
"""
|
40
|
+
Central registry of fields for classes.
|
41
|
+
|
42
|
+
- field_type_table: {"ClassName": {"field": <type or type-string>}}
|
43
|
+
- URI/Resource preference in unions: URI > Resource > first-declared
|
44
|
+
- Optional/None is stripped
|
45
|
+
- Containers are preserved; their inner args are normalized recursively
|
46
|
+
"""
|
47
|
+
|
48
|
+
def __init__(self) -> None:
|
49
|
+
self.field_type_table: Dict[str, Dict[str, Any]] = {}
|
50
|
+
self._extras: Dict[str, Dict[str, Any]] = {}
|
51
|
+
self._toplevel: Dict[str, Dict[str, Any]] = {}
|
52
|
+
|
53
|
+
# Optional binding to concrete classes to avoid name-only matching.
|
54
|
+
self._uri_cls: type | None = None
|
55
|
+
self._resource_cls: type | None = None
|
56
|
+
|
57
|
+
# ──────────────────────────────
|
58
|
+
# Bind concrete classes (optional)
|
59
|
+
# ──────────────────────────────
|
60
|
+
def set_uri_class(self, cls: type | None) -> None:
|
61
|
+
self._uri_cls = cls
|
62
|
+
|
63
|
+
def set_resource_class(self, cls: type | None) -> None:
|
64
|
+
self._resource_cls = cls
|
65
|
+
|
66
|
+
# ──────────────────────────────
|
67
|
+
# Public API
|
68
|
+
# ──────────────────────────────
|
69
|
+
def register_class(
|
70
|
+
self,
|
71
|
+
cls: type,
|
72
|
+
*,
|
73
|
+
mapping: Dict[str, Any] | None = None,
|
74
|
+
include_bases: bool = True,
|
75
|
+
use_annotations: bool = True,
|
76
|
+
use_init: bool = True,
|
77
|
+
overwrite: bool = False,
|
78
|
+
ignore: set[str] | None = None,
|
79
|
+
toplevel: bool = False,
|
80
|
+
toplevel_meta: Dict[str, Any] | None = None,
|
81
|
+
) -> None:
|
82
|
+
"""
|
83
|
+
Introspect and register fields for a class.
|
84
|
+
|
85
|
+
- reads class __annotations__ (preferred) or __init__ annotations
|
86
|
+
- merges base classes (MRO) if include_bases=True
|
87
|
+
- applies `mapping` overrides last
|
88
|
+
- normalizes each type:
|
89
|
+
strip Optional → prefer URI/Resource → collapse union to single
|
90
|
+
"""
|
91
|
+
cname = cls.__name__
|
92
|
+
ignore = ignore or set()
|
93
|
+
|
94
|
+
def collect(c: type) -> Dict[str, Any]:
|
95
|
+
d: Dict[str, Any] = {}
|
96
|
+
if use_annotations:
|
97
|
+
d.update(self._get_hints_from_class(c))
|
98
|
+
if use_init and not d:
|
99
|
+
d.update(self._get_hints_from_init(c))
|
100
|
+
# filter private / ignored
|
101
|
+
for k in list(d.keys()):
|
102
|
+
if k in ignore or k.startswith("_"):
|
103
|
+
d.pop(k, None)
|
104
|
+
# normalize each
|
105
|
+
for k, v in list(d.items()):
|
106
|
+
d[k] = self._normalize_field_type(v)
|
107
|
+
return d
|
108
|
+
|
109
|
+
fields: Dict[str, Any] = {}
|
110
|
+
classes = list(reversed(cls.mro())) if include_bases else [cls]
|
111
|
+
for c in classes:
|
112
|
+
if c is object:
|
113
|
+
continue
|
114
|
+
fields.update(collect(c))
|
115
|
+
|
116
|
+
if mapping:
|
117
|
+
for k, v in mapping.items():
|
118
|
+
fields[k] = self._normalize_field_type(v)
|
119
|
+
|
120
|
+
if not overwrite and cname in self.field_type_table:
|
121
|
+
self.field_type_table[cname].update(fields)
|
122
|
+
else:
|
123
|
+
self.field_type_table[cname] = fields
|
124
|
+
|
125
|
+
if toplevel:
|
126
|
+
self._toplevel[cname] = dict(toplevel_meta or {})
|
127
|
+
|
128
|
+
def register_extra(
|
129
|
+
self,
|
130
|
+
cls: type,
|
131
|
+
name: str,
|
132
|
+
typ: Any,
|
133
|
+
*,
|
134
|
+
overwrite: bool = False,
|
135
|
+
) -> None:
|
136
|
+
"""Register a single extra field (normalized)."""
|
137
|
+
cname = cls.__name__
|
138
|
+
self.field_type_table.setdefault(cname, {})
|
139
|
+
if not overwrite and name in self.field_type_table[cname]:
|
140
|
+
return
|
141
|
+
nt = self._normalize_field_type(typ)
|
142
|
+
self.field_type_table[cname][name] = nt
|
143
|
+
self._extras.setdefault(cname, {})[name] = nt
|
144
|
+
|
145
|
+
def normalize_all(self) -> None:
|
146
|
+
"""Re-run normalization across all registered fields."""
|
147
|
+
for _, fields in self.field_type_table.items():
|
148
|
+
for k, v in list(fields.items()):
|
149
|
+
fields[k] = self._normalize_field_type(v)
|
150
|
+
|
151
|
+
# lookups
|
152
|
+
def get_class_fields(self, type_name: str) -> Dict[str, Any] | None:
|
153
|
+
return self.field_type_table.get(type_name)
|
154
|
+
|
155
|
+
def set_toplevel(self, cls: type, *, meta: Dict[str, Any] | None = None) -> None:
|
156
|
+
self._toplevel[cls.__name__] = dict(meta or {})
|
157
|
+
|
158
|
+
def is_toplevel(self, cls_or_name: type | str) -> bool:
|
159
|
+
name = cls_or_name if isinstance(cls_or_name, str) else cls_or_name.__name__
|
160
|
+
return name in self._toplevel
|
161
|
+
|
162
|
+
def get_toplevel(self) -> Dict[str, Dict[str, Any]]:
|
163
|
+
return dict(self._toplevel)
|
164
|
+
|
165
|
+
def get_extras(self, cls_or_name: type | str) -> Dict[str, Any]:
|
166
|
+
name = cls_or_name if isinstance(cls_or_name, str) else cls_or_name.__name__
|
167
|
+
return dict(self._extras.get(name, {}))
|
168
|
+
|
169
|
+
@property
|
170
|
+
def json(self) -> dict[str, dict[str, str]]:
|
171
|
+
return schema_to_jsonable(self)
|
172
|
+
|
173
|
+
# ──────────────────────────────
|
174
|
+
# Introspection helpers
|
175
|
+
# ──────────────────────────────
|
176
|
+
def _get_hints_from_class(self, cls: type) -> Dict[str, Any]:
|
177
|
+
"""Resolved type hints from class annotations; fallback to raw __annotations__."""
|
178
|
+
module = sys.modules.get(cls.__module__)
|
179
|
+
gns = module.__dict__ if module else {}
|
180
|
+
lns = dict(vars(cls))
|
181
|
+
try:
|
182
|
+
return get_type_hints(cls, include_extras=True, globalns=gns, localns=lns)
|
183
|
+
except Exception:
|
184
|
+
return dict(getattr(cls, "__annotations__", {}) or {})
|
185
|
+
|
186
|
+
def _get_hints_from_init(self, cls: type) -> Dict[str, Any]:
|
187
|
+
"""Parameter annotations from __init__ (excluding self/return/*args/**kwargs)."""
|
188
|
+
fn = cls.__dict__.get("__init__", getattr(cls, "__init__", None))
|
189
|
+
if not callable(fn):
|
190
|
+
return {}
|
191
|
+
module = sys.modules.get(cls.__module__)
|
192
|
+
gns = module.__dict__ if module else {}
|
193
|
+
lns = dict(vars(cls))
|
194
|
+
try:
|
195
|
+
hints = get_type_hints(fn, include_extras=True, globalns=gns, localns=lns)
|
196
|
+
except Exception:
|
197
|
+
hints = dict(getattr(fn, "__annotations__", {}) or {})
|
198
|
+
hints.pop("return", None)
|
199
|
+
hints.pop("self", None)
|
200
|
+
# drop *args/**kwargs
|
201
|
+
sig = inspect.signature(fn)
|
202
|
+
for pname, p in list(sig.parameters.items()):
|
203
|
+
if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
|
204
|
+
hints.pop(pname, None)
|
205
|
+
return hints
|
206
|
+
|
207
|
+
# ──────────────────────────────
|
208
|
+
# Normalization pipeline
|
209
|
+
# strip Optional -> prefer URI/Resource -> collapse unions
|
210
|
+
# (recurse into containers)
|
211
|
+
# ──────────────────────────────
|
212
|
+
def _normalize_field_type(self, tp: Any) -> Any:
|
213
|
+
tp = self._strip_optional(tp)
|
214
|
+
tp = self._prefer_uri_or_resource_or_first(tp)
|
215
|
+
tp = self._collapse_unions(tp)
|
216
|
+
return tp
|
217
|
+
|
218
|
+
# 1) Remove None from unions and strip Optional[...] wrappers
|
219
|
+
def _strip_optional(self, tp: Any) -> Any:
|
220
|
+
if isinstance(tp, str):
|
221
|
+
return self._strip_optional_str(tp)
|
222
|
+
|
223
|
+
origin = get_origin(tp)
|
224
|
+
args = get_args(tp)
|
225
|
+
|
226
|
+
if Annotated is not None and origin is Annotated:
|
227
|
+
return self._strip_optional(args[0])
|
228
|
+
|
229
|
+
if origin in _UNION_ORIGINS:
|
230
|
+
kept = tuple(a for a in args if a is not type(None)) # noqa: E721
|
231
|
+
if not kept:
|
232
|
+
return Any
|
233
|
+
if len(kept) == 1:
|
234
|
+
return self._strip_optional(kept[0])
|
235
|
+
# rebuild union (still a union; later steps will collapse)
|
236
|
+
return self._rebuild_union_tuple(kept)
|
237
|
+
|
238
|
+
if origin in (list, set, tuple, dict):
|
239
|
+
sub = tuple(self._strip_optional(a) for a in args)
|
240
|
+
return self._rebuild_param(origin, sub, fallback=tp)
|
241
|
+
|
242
|
+
return tp
|
243
|
+
|
244
|
+
# 2) In any union, prefer URI (if present), else Resource (if present), else first declared
|
245
|
+
def _prefer_uri_or_resource_or_first(self, tp: Any) -> Any:
|
246
|
+
if isinstance(tp, str):
|
247
|
+
return self._prefer_str(tp)
|
248
|
+
|
249
|
+
origin = get_origin(tp)
|
250
|
+
args = get_args(tp)
|
251
|
+
|
252
|
+
if Annotated is not None and origin is Annotated:
|
253
|
+
return self._prefer_uri_or_resource_or_first(args[0])
|
254
|
+
|
255
|
+
if origin in _UNION_ORIGINS:
|
256
|
+
# order is as-declared in typing args
|
257
|
+
names = [self._name_of(a) for a in args]
|
258
|
+
if "URI" in names:
|
259
|
+
i = names.index("URI")
|
260
|
+
return args[i] if not isinstance(args[i], str) else "URI"
|
261
|
+
if "Resource" in names:
|
262
|
+
i = names.index("Resource")
|
263
|
+
return args[i] if not isinstance(args[i], str) else "Resource"
|
264
|
+
# no preferred present → pick first declared
|
265
|
+
return self._prefer_uri_or_resource_or_first(args[0])
|
266
|
+
|
267
|
+
if origin in (list, set, tuple, dict):
|
268
|
+
sub = tuple(self._prefer_uri_or_resource_or_first(a) for a in args)
|
269
|
+
return self._rebuild_param(origin, sub, fallback=tp)
|
270
|
+
|
271
|
+
return tp
|
272
|
+
|
273
|
+
# 3) If any union still remains (it shouldn't after step 2), collapse to first
|
274
|
+
def _collapse_unions(self, tp: Any) -> Any:
|
275
|
+
if isinstance(tp, str):
|
276
|
+
return self._collapse_unions_str(tp)
|
277
|
+
|
278
|
+
origin = get_origin(tp)
|
279
|
+
args = get_args(tp)
|
280
|
+
|
281
|
+
if Annotated is not None and origin is Annotated:
|
282
|
+
return self._collapse_unions(args[0])
|
283
|
+
|
284
|
+
if origin in _UNION_ORIGINS:
|
285
|
+
return self._collapse_unions(args[0]) # first only
|
286
|
+
|
287
|
+
if origin in (list, set, tuple, dict):
|
288
|
+
sub = tuple(self._collapse_unions(a) for a in args)
|
289
|
+
return self._rebuild_param(origin, sub, fallback=tp)
|
290
|
+
|
291
|
+
return tp
|
292
|
+
|
293
|
+
# ──────────────────────────────
|
294
|
+
# Helpers: typing objects
|
295
|
+
# ──────────────────────────────
|
296
|
+
def _name_of(self, a: Any) -> str:
|
297
|
+
if isinstance(a, str):
|
298
|
+
return a.strip()
|
299
|
+
if isinstance(a, type):
|
300
|
+
return getattr(a, "__name__", str(a))
|
301
|
+
# typing objects: str(...) fallback
|
302
|
+
return str(a).replace("typing.", "")
|
303
|
+
|
304
|
+
def _rebuild_union_tuple(self, args: tuple[Any, ...]) -> Any:
|
305
|
+
# Rebuild a typing-style union from args (for 3.10+ this becomes A|B)
|
306
|
+
out = args[0]
|
307
|
+
for a in args[1:]:
|
308
|
+
try:
|
309
|
+
out = out | a # type: ignore[operator]
|
310
|
+
except TypeError:
|
311
|
+
out = Union[(out, a)] # type: ignore[index]
|
312
|
+
return out
|
313
|
+
|
314
|
+
def _rebuild_param(self, origin: Any, sub: tuple[Any, ...], *, fallback: Any) -> Any:
|
315
|
+
try:
|
316
|
+
return origin[sub] if sub else origin
|
317
|
+
except TypeError:
|
318
|
+
return fallback
|
319
|
+
|
320
|
+
# ──────────────────────────────
|
321
|
+
# Helpers: strings
|
322
|
+
# ──────────────────────────────
|
323
|
+
def _strip_optional_str(self, s: str) -> str:
|
324
|
+
s = s.strip().replace("typing.", "")
|
325
|
+
# peel Optional[...] wrappers
|
326
|
+
while s.startswith("Optional[") and s.endswith("]"):
|
327
|
+
s = s[len("Optional["):-1].strip()
|
328
|
+
|
329
|
+
# Union[A, B, None]
|
330
|
+
if s.startswith("Union[") and s.endswith("]"):
|
331
|
+
inner = s[len("Union["):-1].strip()
|
332
|
+
parts = [p for p in self._split_top_level(inner, ",") if p.strip() not in ("None", "NoneType", "")]
|
333
|
+
return f"Union[{', '.join(parts)}]" if len(parts) > 1 else (parts[0].strip() if parts else "Any")
|
334
|
+
|
335
|
+
# A | B | None
|
336
|
+
if "|" in s:
|
337
|
+
parts = [p.strip() for p in self._split_top_level(s, "|")]
|
338
|
+
parts = [p for p in parts if p not in ("None", "NoneType", "")]
|
339
|
+
return " | ".join(parts) if len(parts) > 1 else (parts[0] if parts else "Any")
|
340
|
+
|
341
|
+
# containers: normalize inside
|
342
|
+
for head in ("List", "Set", "Tuple", "Dict", "Annotated"):
|
343
|
+
if s.startswith(head + "[") and s.endswith("]"):
|
344
|
+
inner = s[len(head) + 1:-1]
|
345
|
+
if head == "Annotated":
|
346
|
+
# Annotated[T, ...] -> T
|
347
|
+
items = self._split_top_level(inner, ",")
|
348
|
+
return self._strip_optional_str(items[0].strip()) if items else "Any"
|
349
|
+
if head in ("List", "Set"):
|
350
|
+
elem = self._strip_optional_str(inner)
|
351
|
+
return f"{head}[{elem}]"
|
352
|
+
if head == "Tuple":
|
353
|
+
elems = [self._strip_optional_str(p.strip()) for p in self._split_top_level(inner, ",")]
|
354
|
+
return f"Tuple[{', '.join(elems)}]"
|
355
|
+
if head == "Dict":
|
356
|
+
kv = self._split_top_level(inner, ",")
|
357
|
+
k = self._strip_optional_str(kv[0].strip()) if kv else "Any"
|
358
|
+
v = self._strip_optional_str(kv[1].strip()) if len(kv) > 1 else "Any"
|
359
|
+
return f"Dict[{k}, {v}]"
|
360
|
+
|
361
|
+
return s
|
362
|
+
|
363
|
+
def _prefer_str(self, s: str) -> str:
|
364
|
+
s = s.strip().replace("typing.", "")
|
365
|
+
# Union[...] form
|
366
|
+
if s.startswith("Union[") and s.endswith("]"):
|
367
|
+
inner = s[len("Union["):-1]
|
368
|
+
parts = [p.strip() for p in self._split_top_level(inner, ",")]
|
369
|
+
if "URI" in parts:
|
370
|
+
return "URI"
|
371
|
+
if "Resource" in parts:
|
372
|
+
return "Resource"
|
373
|
+
return parts[0] if parts else "Any"
|
374
|
+
|
375
|
+
# PEP 604 bars
|
376
|
+
if "|" in s:
|
377
|
+
parts = [p.strip() for p in self._split_top_level(s, "|")]
|
378
|
+
if "URI" in parts:
|
379
|
+
return "URI"
|
380
|
+
if "Resource" in parts:
|
381
|
+
return "Resource"
|
382
|
+
return parts[0] if parts else "Any"
|
383
|
+
|
384
|
+
# containers
|
385
|
+
for head in ("List", "Set", "Tuple", "Dict", "Annotated"):
|
386
|
+
if s.startswith(head + "[") and s.endswith("]"):
|
387
|
+
inner = s[len(head) + 1:-1]
|
388
|
+
if head == "Annotated":
|
389
|
+
items = self._split_top_level(inner, ",")
|
390
|
+
return self._prefer_str(items[0].strip()) if items else "Any"
|
391
|
+
if head in ("List", "Set"):
|
392
|
+
elem = self._prefer_str(inner)
|
393
|
+
return f"{head}[{elem}]"
|
394
|
+
if head == "Tuple":
|
395
|
+
elems = [self._prefer_str(p.strip()) for p in self._split_top_level(inner, ",")]
|
396
|
+
return f"Tuple[{', '.join(elems)}]"
|
397
|
+
if head == "Dict":
|
398
|
+
kv = self._split_top_level(inner, ",")
|
399
|
+
k = self._prefer_str(kv[0].strip()) if kv else "Any"
|
400
|
+
v = self._prefer_str(kv[1].strip()) if len(kv) > 1 else "Any"
|
401
|
+
return f"Dict[{k}, {v}]"
|
402
|
+
|
403
|
+
return s
|
404
|
+
|
405
|
+
def _collapse_unions_str(self, s: str) -> str:
|
406
|
+
s = s.strip().replace("typing.", "")
|
407
|
+
if s.startswith("Union[") and s.endswith("]"):
|
408
|
+
inner = s[len("Union["):-1]
|
409
|
+
parts = [p.strip() for p in self._split_top_level(inner, ",")]
|
410
|
+
return parts[0] if parts else "Any"
|
411
|
+
if "|" in s:
|
412
|
+
parts = [p.strip() for p in self._split_top_level(s, "|")]
|
413
|
+
return parts[0] if parts else "Any"
|
414
|
+
|
415
|
+
# containers
|
416
|
+
for head in ("List", "Set", "Tuple", "Dict", "Annotated"):
|
417
|
+
if s.startswith(head + "[") and s.endswith("]"):
|
418
|
+
inner = s[len(head) + 1:-1]
|
419
|
+
if head == "Annotated":
|
420
|
+
items = self._split_top_level(inner, ",")
|
421
|
+
return self._collapse_unions_str(items[0].strip()) if items else "Any"
|
422
|
+
if head in ("List", "Set"):
|
423
|
+
elem = self._collapse_unions_str(inner)
|
424
|
+
return f"{head}[{elem}]"
|
425
|
+
if head == "Tuple":
|
426
|
+
elems = [self._collapse_unions_str(p.strip()) for p in self._split_top_level(inner, ",")]
|
427
|
+
return f"Tuple[{', '.join(elems)}]"
|
428
|
+
if head == "Dict":
|
429
|
+
kv = self._split_top_level(inner, ",")
|
430
|
+
k = self._collapse_unions_str(kv[0].strip()) if kv else "Any"
|
431
|
+
v = self._collapse_unions_str(kv[1].strip()) if len(kv) > 1 else "Any"
|
432
|
+
return f"Dict[{k}, {v}]"
|
433
|
+
return s
|
434
|
+
|
435
|
+
def _split_top_level(self, s: str, sep: str) -> list[str]:
|
436
|
+
"""
|
437
|
+
Split `s` by single-char separator (',' or '|') at top level (not inside brackets).
|
438
|
+
"""
|
439
|
+
out: list[str] = []
|
440
|
+
buf: list[str] = []
|
441
|
+
depth = 0
|
442
|
+
for ch in s:
|
443
|
+
if ch == "[":
|
444
|
+
depth += 1
|
445
|
+
elif ch == "]":
|
446
|
+
depth -= 1
|
447
|
+
if ch == sep and depth == 0:
|
448
|
+
out.append("".join(buf).strip())
|
449
|
+
buf = []
|
450
|
+
else:
|
451
|
+
buf.append(ch)
|
452
|
+
out.append("".join(buf).strip())
|
453
|
+
return [p for p in out if p != ""]
|
454
|
+
|
455
|
+
# ──────────────────────────────
|
456
|
+
# Stringification helpers (JSON dump)
|
457
|
+
# ──────────────────────────────
|
458
|
+
def type_repr(tp: Any) -> str:
|
459
|
+
if isinstance(tp, str):
|
460
|
+
return tp
|
461
|
+
|
462
|
+
origin = get_origin(tp)
|
463
|
+
args = get_args(tp)
|
464
|
+
|
465
|
+
if origin is None:
|
466
|
+
if isinstance(tp, type):
|
467
|
+
return tp.__name__
|
468
|
+
return str(tp).replace("typing.", "")
|
469
|
+
|
470
|
+
if origin in _UNION_ORIGINS:
|
471
|
+
return " | ".join(type_repr(a) for a in args)
|
472
|
+
|
473
|
+
if origin is list:
|
474
|
+
return f"List[{type_repr(args[0])}]" if args else "List[Any]"
|
475
|
+
if origin is set:
|
476
|
+
return f"Set[{type_repr(args[0])}]" if args else "Set[Any]"
|
477
|
+
if origin is tuple:
|
478
|
+
return "Tuple[" + ", ".join(type_repr(a) for a in args) + "]" if args else "Tuple"
|
479
|
+
if origin is dict:
|
480
|
+
k, v = args or (Any, Any)
|
481
|
+
return f"Dict[{type_repr(k)}, {type_repr(v)}]"
|
482
|
+
|
483
|
+
return str(tp).replace("typing.", "")
|
484
|
+
|
485
|
+
|
486
|
+
def schema_to_jsonable(schema: Schema) -> dict[str, dict[str, str]]:
|
487
|
+
out: dict[str, dict[str, str]] = {}
|
488
|
+
for cls_name, fields in schema.field_type_table.items():
|
489
|
+
out[cls_name] = {fname: type_repr(ftype) for fname, ftype in fields.items()}
|
490
|
+
return out
|
491
|
+
|
492
|
+
|
493
|
+
|
494
|
+
|
495
|
+
|
496
|
+
def schema_class(
|
497
|
+
mapping: Dict[str, Any] | None = None,
|
498
|
+
*,
|
499
|
+
include_bases: bool = True,
|
500
|
+
use_annotations: bool = True,
|
501
|
+
use_init: bool = True,
|
502
|
+
overwrite: bool = False,
|
503
|
+
ignore: set[str] | None = None,
|
504
|
+
toplevel: bool = False,
|
505
|
+
toplevel_meta: Dict[str, Any] | None = None,
|
506
|
+
):
|
507
|
+
"""Decorator to register a class with SCHEMA at import time."""
|
508
|
+
def deco(cls: type):
|
509
|
+
SCHEMA.register_class(
|
510
|
+
cls,
|
511
|
+
mapping=mapping,
|
512
|
+
include_bases=include_bases,
|
513
|
+
use_annotations=use_annotations,
|
514
|
+
use_init=use_init,
|
515
|
+
overwrite=overwrite,
|
516
|
+
ignore=ignore,
|
517
|
+
toplevel=toplevel,
|
518
|
+
toplevel_meta=toplevel_meta,
|
519
|
+
)
|
520
|
+
return cls
|
521
|
+
return deco
|
522
|
+
|
523
|
+
|
524
|
+
# Singleton instance
|
525
|
+
SCHEMA = Schema()
|
526
|
+
|
527
|
+
|
528
|
+
|
529
|
+
|
530
|
+
|
gedcomx/serialization.py
CHANGED
@@ -51,6 +51,7 @@ from .place_reference import PlaceReference
|
|
51
51
|
from .qualifier import Qualifier
|
52
52
|
from .relationship import Relationship, RelationshipType
|
53
53
|
from .resource import Resource
|
54
|
+
from .schemas import SCHEMA
|
54
55
|
from .source_description import SourceDescription, ResourceType, SourceCitation, Coverage
|
55
56
|
from .source_reference import SourceReference
|
56
57
|
from .textvalue import TextValue
|
@@ -74,6 +75,10 @@ class Serialization:
|
|
74
75
|
def serialize(obj: object):
|
75
76
|
if obj is not None:
|
76
77
|
with hub.use(serial_log):
|
78
|
+
if SCHEMA.is_toplevel(type(obj)):
|
79
|
+
if hub.logEnabled: log.debug("-" * 20)
|
80
|
+
if hub.logEnabled: log.debug(f"Serializing TOP LEVEL TYPE '{type(obj).__name__}'")
|
81
|
+
#if hub.logEnabled: log.debug(f"Serializing a '{type(obj).__name__}'")
|
77
82
|
|
78
83
|
if isinstance(obj, (str, int, float, bool, type(None))):
|
79
84
|
return obj
|
@@ -82,43 +87,58 @@ class Serialization:
|
|
82
87
|
if isinstance(obj, URI):
|
83
88
|
return obj.value
|
84
89
|
if isinstance(obj, (list, tuple, set)):
|
85
|
-
|
86
|
-
if len(
|
87
|
-
|
90
|
+
if hub.logEnabled: log.debug(f"'{type(obj).__name__}' is an (list, tuple, set)")
|
91
|
+
if len(obj) == 0:
|
92
|
+
if hub.logEnabled: log.debug(f"'{type(obj).__name__}' is an empty (list, tuple, set)")
|
93
|
+
return None
|
94
|
+
#l = [Serialization.serialize(v) for v in obj]
|
95
|
+
r_l = []
|
96
|
+
for i, item in enumerate(obj):
|
97
|
+
if hub.logEnabled: log.debug(f"Serializing item {i} of '{type(obj).__name__}'")
|
98
|
+
r_l.append(Serialization.serialize(item) )
|
99
|
+
return r_l
|
100
|
+
|
88
101
|
if type(obj).__name__ == 'Collection':
|
89
102
|
l= [Serialization.serialize(v) for v in obj]
|
90
103
|
if len(l) == 0: return None
|
91
104
|
return l
|
92
105
|
if isinstance(obj, enum.Enum):
|
106
|
+
if hub.logEnabled: log.debug(f"'{type(obj).__name__}' is an eNum")
|
93
107
|
return Serialization.serialize(obj.value)
|
94
108
|
|
95
|
-
log.debug(f"Serializing a '{type(obj).__name__}'")
|
109
|
+
#if hub.logEnabled: log.debug(f"Serializing a '{type(obj).__name__}'")
|
96
110
|
type_as_dict = {}
|
97
|
-
fields =
|
111
|
+
fields = SCHEMA.get_class_fields(type(obj).__name__)
|
98
112
|
if fields:
|
99
113
|
for field_name, type_ in fields.items():
|
100
114
|
if hasattr(obj,field_name):
|
115
|
+
|
101
116
|
if (v := getattr(obj,field_name)) is not None:
|
117
|
+
if hub.logEnabled: log.debug(f"Found {type(obj).__name__}.{field_name} with a '{type_}'")
|
102
118
|
if type_ == Resource:
|
103
|
-
log.
|
119
|
+
log.debug(f"Refering to a {type(obj).__name__}.{field_name} with a '{type_}'")
|
104
120
|
res = Resource(target=v)
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
log.error(f"Refering to a {type(obj).__name__} with a '{type_}'")
|
121
|
+
type_as_dict[field_name] = Serialization.serialize(res.value)
|
122
|
+
elif type_ == URI or type_ == 'URI':
|
123
|
+
log.debug(f"Refering to a {type(obj).__name__}.{field_name} with a '{type_}'")
|
109
124
|
uri = URI(target=v)
|
110
|
-
|
111
|
-
type_as_dict[field_name] = sv
|
125
|
+
type_as_dict[field_name] = uri.value
|
112
126
|
elif (sv := Serialization.serialize(v)) is not None:
|
127
|
+
if hub.logEnabled: log.debug(f"Fall through, {type(obj).__name__}.{field_name}'")
|
113
128
|
type_as_dict[field_name] = sv
|
114
129
|
else:
|
115
|
-
log.
|
116
|
-
if type_as_dict == {}: log.error(f"Serialized a '{type(obj).__name__}' with empty fields: '{fields}'")
|
117
|
-
else:
|
130
|
+
if hub.logEnabled: log.warning(f"{type(obj).__name__} did not have field '{field_name}'")
|
131
|
+
#if type_as_dict == {}: log.error(f"Serialized a '{type(obj).__name__}' with empty fields: '{fields}'")
|
132
|
+
#else:
|
133
|
+
#if hub.logEnabled: log.debug(f"Serialized a '{type(obj).__name__}' with fields '{type_as_dict})'")
|
134
|
+
if hub.logEnabled: log.debug(f"<- Serialized a '%s'",type(obj).__name__)
|
118
135
|
#return Serialization._serialize_dict(type_as_dict)
|
119
136
|
return type_as_dict if type_as_dict != {} else None
|
137
|
+
elif hasattr(obj,'_serializer'):
|
138
|
+
if hub.logEnabled: log.debug(f"'%s' has a serializer, using it.",type(obj).__name__)
|
139
|
+
return getattr(obj,'_serializer')
|
120
140
|
else:
|
121
|
-
log.error(f"Could not find fields for {type(obj).__name__}")
|
141
|
+
if hub.logEnabled: log.error(f"Could not find fields for {type(obj).__name__}")
|
122
142
|
return None
|
123
143
|
|
124
144
|
@staticmethod
|
gedcomx/source_citation.py
CHANGED
@@ -1,3 +1,24 @@
|
|
1
|
+
"""
|
2
|
+
======================================================================
|
3
|
+
Project: Gedcom-X
|
4
|
+
File: source_citation.py
|
5
|
+
Author: David J. Cartwright
|
6
|
+
Purpose:
|
7
|
+
|
8
|
+
Created: 2025-07-25
|
9
|
+
Updated:
|
10
|
+
- 2025-09-09 added schema_class
|
11
|
+
|
12
|
+
|
13
|
+
======================================================================
|
14
|
+
"""
|
15
|
+
|
16
|
+
"""
|
17
|
+
======================================================================
|
18
|
+
GEDCOM Module Types
|
19
|
+
======================================================================
|
20
|
+
"""
|
21
|
+
from .schemas import schema_class
|
1
22
|
from typing import Optional
|
2
23
|
from .logging_hub import hub, logging
|
3
24
|
"""
|
@@ -9,6 +30,7 @@ log = logging.getLogger("gedcomx")
|
|
9
30
|
serial_log = "gedcomx.serialization"
|
10
31
|
#=====================================================================
|
11
32
|
|
33
|
+
@schema_class()
|
12
34
|
class SourceCitation:
|
13
35
|
identifier = 'http://gedcomx.org/v1/SourceCitation'
|
14
36
|
version = 'http://gedcomx.org/conceptual-model/v1'
|