gedcom-x 0.5.10__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.10.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 +5 -3
- gedcomx/address.py +3 -0
- gedcomx/agent.py +11 -6
- gedcomx/attribution.py +3 -1
- gedcomx/conclusion.py +9 -5
- 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/fact.py +6 -2
- gedcomx/gedcom5x.py +21 -3
- 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 +6 -5
- 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 +521 -319
- 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.10.dist-info/RECORD +0 -58
- gedcomx/Logging.py +0 -19
- {gedcom_x-0.5.10.dist-info → gedcom_x-0.5.11.dist-info}/WHEEL +0 -0
- {gedcom_x-0.5.10.dist-info → gedcom_x-0.5.11.dist-info}/top_level.txt +0 -0
gedcomx/schemas.py
CHANGED
@@ -1,328 +1,530 @@
|
|
1
|
-
|
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
|
+
|
2
35
|
|
3
36
|
|
4
37
|
|
5
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
|
+
|
6
48
|
def __init__(self) -> None:
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
"
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
"analysis": Resource,
|
81
|
-
"notes": List[Note],
|
82
|
-
"confidence": ConfidenceLevel,
|
83
|
-
"attribution": Attribution,
|
84
|
-
"type": DocumentType,
|
85
|
-
"extracted": bool,
|
86
|
-
"textType": TextType,
|
87
|
-
"text": str,
|
88
|
-
},
|
89
|
-
"Event": {
|
90
|
-
"id": str,
|
91
|
-
"lang": str,
|
92
|
-
"sources": List[SourceReference],
|
93
|
-
"analysis": Resource,
|
94
|
-
"notes": List[Note],
|
95
|
-
"confidence": ConfidenceLevel,
|
96
|
-
"attribution": Attribution,
|
97
|
-
"extracted": bool,
|
98
|
-
"evidence": List[EvidenceReference],
|
99
|
-
"media": List[SourceReference],
|
100
|
-
"identifiers": List[Identifier],
|
101
|
-
"type": EventType,
|
102
|
-
"date": Date,
|
103
|
-
"place": PlaceReference,
|
104
|
-
"roles": List[EventRole],
|
105
|
-
},
|
106
|
-
"EventRole": {
|
107
|
-
"id:": str,
|
108
|
-
"lang": str,
|
109
|
-
"sources": List[SourceReference],
|
110
|
-
"analysis": Resource,
|
111
|
-
"notes": List[Note],
|
112
|
-
"confidence": ConfidenceLevel,
|
113
|
-
"attribution": Attribution,
|
114
|
-
"person": Resource,
|
115
|
-
"type": EventRoleType,
|
116
|
-
"details": str,
|
117
|
-
},
|
118
|
-
"Fact": {
|
119
|
-
"id": str,
|
120
|
-
"lang": str,
|
121
|
-
"sources": List[SourceReference],
|
122
|
-
"analysis": Resource | Document,
|
123
|
-
"notes": List[Note],
|
124
|
-
"confidence": ConfidenceLevel,
|
125
|
-
"attribution": Attribution,
|
126
|
-
"type": FactType,
|
127
|
-
"date": Date,
|
128
|
-
"place": PlaceReference,
|
129
|
-
"value": str,
|
130
|
-
"qualifiers": List[FactQualifier],
|
131
|
-
"links": _rsLinks,
|
132
|
-
},
|
133
|
-
"GedcomX": {
|
134
|
-
"persons": List[Person],
|
135
|
-
"relationships": List[Relationship],
|
136
|
-
"sourceDescriptions": List[SourceDescription],
|
137
|
-
"agents": List[Agent],
|
138
|
-
"places": List[PlaceDescription]
|
139
|
-
},
|
140
|
-
"Gender": {
|
141
|
-
"id": str,
|
142
|
-
"lang": str,
|
143
|
-
"sources": List[SourceReference],
|
144
|
-
"analysis": Resource,
|
145
|
-
"notes": List[Note],
|
146
|
-
"confidence": ConfidenceLevel,
|
147
|
-
"attribution": Attribution,
|
148
|
-
"type": GenderType,
|
149
|
-
},
|
150
|
-
"KnownSourceReference": {
|
151
|
-
"name": str,
|
152
|
-
"value": str,
|
153
|
-
},
|
154
|
-
"Name": {
|
155
|
-
"id": str,
|
156
|
-
"lang": str,
|
157
|
-
"sources": List[SourceReference],
|
158
|
-
"analysis": Resource,
|
159
|
-
"notes": List[Note],
|
160
|
-
"confidence": ConfidenceLevel,
|
161
|
-
"attribution": Attribution,
|
162
|
-
"type": NameType,
|
163
|
-
"nameForms": List[NameForm], # use string to avoid circulars if needed
|
164
|
-
"date": Date,
|
165
|
-
},
|
166
|
-
"NameForm": {
|
167
|
-
"lang": str,
|
168
|
-
"fullText": str,
|
169
|
-
"parts": List[NamePart], # use "NamePart" as a forward-ref to avoid circulars
|
170
|
-
},
|
171
|
-
"NamePart": {
|
172
|
-
"type": NamePartType,
|
173
|
-
"value": str,
|
174
|
-
"qualifiers": List["NamePartQualifier"], # quote if you want to avoid circulars
|
175
|
-
},
|
176
|
-
"Note":{"lang":str,
|
177
|
-
"subject":str,
|
178
|
-
"text":str,
|
179
|
-
"attribution": Attribution},
|
180
|
-
"Person": {
|
181
|
-
"id": str,
|
182
|
-
"lang": str,
|
183
|
-
"sources": List[SourceReference],
|
184
|
-
"analysis": Resource,
|
185
|
-
"notes": List[Note],
|
186
|
-
"confidence": ConfidenceLevel,
|
187
|
-
"attribution": Attribution,
|
188
|
-
"extracted": bool,
|
189
|
-
"evidence": List[EvidenceReference],
|
190
|
-
"media": List[SourceReference],
|
191
|
-
"identifiers": IdentifierList,
|
192
|
-
"private": bool,
|
193
|
-
"gender": Gender,
|
194
|
-
"names": List[Name],
|
195
|
-
"facts": List[Fact],
|
196
|
-
"living": bool,
|
197
|
-
"links": _rsLinks,
|
198
|
-
},
|
199
|
-
"PlaceDescription": {
|
200
|
-
"id": str,
|
201
|
-
"lang": str,
|
202
|
-
"sources": List[SourceReference],
|
203
|
-
"analysis": Resource,
|
204
|
-
"notes": List[Note],
|
205
|
-
"confidence": ConfidenceLevel,
|
206
|
-
"attribution": Attribution,
|
207
|
-
"extracted": bool,
|
208
|
-
"evidence": List[EvidenceReference],
|
209
|
-
"media": List[SourceReference],
|
210
|
-
"identifiers": List[IdentifierList],
|
211
|
-
"names": List[TextValue],
|
212
|
-
"type": str,
|
213
|
-
"place": URI,
|
214
|
-
"jurisdiction": Resource,
|
215
|
-
"latitude": float,
|
216
|
-
"longitude": float,
|
217
|
-
"temporalDescription": Date,
|
218
|
-
"spatialDescription": Resource,
|
219
|
-
},
|
220
|
-
"PlaceReference": {
|
221
|
-
"original": str,
|
222
|
-
"description": URI,
|
223
|
-
},
|
224
|
-
"Qualifier": {
|
225
|
-
"name": str,
|
226
|
-
"value": str,
|
227
|
-
},
|
228
|
-
"_rsLinks": {
|
229
|
-
"person":rsLink,
|
230
|
-
"portrait":rsLink},
|
231
|
-
"rsLink": {
|
232
|
-
"href": URI,
|
233
|
-
"template": str,
|
234
|
-
"type": str,
|
235
|
-
"accept": str,
|
236
|
-
"allow": str,
|
237
|
-
"hreflang": str,
|
238
|
-
"title": str,
|
239
|
-
},
|
240
|
-
"Relationship": {
|
241
|
-
"id": str,
|
242
|
-
"lang": str,
|
243
|
-
"sources": List[SourceReference],
|
244
|
-
"analysis": Resource,
|
245
|
-
"notes": List[Note],
|
246
|
-
"confidence": ConfidenceLevel,
|
247
|
-
"attribution": Attribution,
|
248
|
-
"extracted": bool,
|
249
|
-
"evidence": List[EvidenceReference],
|
250
|
-
"media": List[SourceReference],
|
251
|
-
"identifiers": IdentifierList,
|
252
|
-
"type": RelationshipType,
|
253
|
-
"person1": Resource,
|
254
|
-
"person2": Resource,
|
255
|
-
"facts": List[Fact],
|
256
|
-
},
|
257
|
-
"Resource": {
|
258
|
-
"resource": str,
|
259
|
-
"resourceId": str,
|
260
|
-
},
|
261
|
-
"SourceDescription": {
|
262
|
-
"id": str,
|
263
|
-
"resourceType": ResourceType,
|
264
|
-
"citations": List[SourceCitation],
|
265
|
-
"mediaType": str,
|
266
|
-
"about": URI,
|
267
|
-
"mediator": Resource,
|
268
|
-
"publisher": Resource, # forward-ref to avoid circular import
|
269
|
-
"authors": List[Resource],
|
270
|
-
"sources": List[SourceReference], # SourceReference
|
271
|
-
"analysis": Resource, # analysis is typically a Document (kept union to avoid cycle)
|
272
|
-
"componentOf": SourceReference, # SourceReference
|
273
|
-
"titles": List[TextValue],
|
274
|
-
"notes": List[Note],
|
275
|
-
"attribution": Attribution,
|
276
|
-
"rights": List[Resource],
|
277
|
-
"coverage": List[Coverage], # Coverage
|
278
|
-
"descriptions": List[TextValue],
|
279
|
-
"identifiers": IdentifierList,
|
280
|
-
"created": Date,
|
281
|
-
"modified": Date,
|
282
|
-
"published": Date,
|
283
|
-
"repository": Agent, # forward-ref
|
284
|
-
"max_note_count": int,
|
285
|
-
},
|
286
|
-
"SourceReference": {
|
287
|
-
"description": Resource,
|
288
|
-
"descriptionId": str,
|
289
|
-
"attribution": Attribution,
|
290
|
-
"qualifiers": List[Qualifier],
|
291
|
-
},
|
292
|
-
"Subject": {
|
293
|
-
"id": str,
|
294
|
-
"lang": str,
|
295
|
-
"sources": List["SourceReference"],
|
296
|
-
"analysis": Resource,
|
297
|
-
"notes": List["Note"],
|
298
|
-
"confidence": ConfidenceLevel,
|
299
|
-
"attribution": Attribution,
|
300
|
-
"extracted": bool,
|
301
|
-
"evidence": List[EvidenceReference],
|
302
|
-
"media": List[SourceReference],
|
303
|
-
"identifiers": IdentifierList,
|
304
|
-
"uri": Resource,
|
305
|
-
"links": _rsLinks,
|
306
|
-
},
|
307
|
-
"TextValue":{"lang":str,"value":str},
|
308
|
-
"URI": {
|
309
|
-
"value": str,
|
310
|
-
},
|
311
|
-
|
312
|
-
}
|
313
|
-
|
314
|
-
def register_extra(self, cls, name, typ ):
|
315
|
-
print("Adding...",cls,name,typ)
|
316
|
-
if cls.__name__ not in self.field_type_table.keys():
|
317
|
-
print("A")
|
318
|
-
self.field_type_table[cls.__name__] = {name:typ}
|
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)
|
319
122
|
else:
|
320
|
-
|
321
|
-
|
322
|
-
|
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 = []
|
323
450
|
else:
|
324
|
-
|
325
|
-
|
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
|
+
|
326
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
|
327
525
|
SCHEMA = Schema()
|
328
|
-
|
526
|
+
|
527
|
+
|
528
|
+
|
529
|
+
|
530
|
+
|