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.
gedcomx/schemas.py CHANGED
@@ -1,328 +1,530 @@
1
- from typing import List
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
- pass
8
-
9
- def _init_schema(self):
10
- from .address import Address
11
- from .agent import Agent
12
- from .attribution import Attribution
13
- from .conclusion import ConfidenceLevel
14
- from .date import Date
15
- from .document import Document, DocumentType, TextType
16
- from .evidence_reference import EvidenceReference
17
- from .event import Event, EventType, EventRole, EventRoleType
18
- from .Extensions.rs10.rsLink import _rsLinks, rsLink
19
- from .fact import Fact, FactType, FactQualifier
20
- from .gender import Gender, GenderType
21
- from .identifier import IdentifierList, Identifier
22
- from .logging_hub import hub, ChannelConfig
23
- from .name import Name, NameType, NameForm, NamePart, NamePartType, NamePartQualifier
24
- from .note import Note
25
- from .online_account import OnlineAccount
26
- from .person import Person
27
- from .place_description import PlaceDescription
28
- from .place_reference import PlaceReference
29
- from .qualifier import Qualifier
30
- from .relationship import Relationship, RelationshipType
31
- from .resource import Resource
32
- from .source_description import SourceDescription, ResourceType, SourceCitation, Coverage
33
- from .source_reference import SourceReference
34
- from .textvalue import TextValue
35
- from .uri import URI
36
-
37
- self.field_type_table ={
38
- "Agent": {
39
- "id": str,
40
- "identifiers": IdentifierList,
41
- "names": List[TextValue],
42
- "homepage": URI,
43
- "openid": URI,
44
- "accounts": List[OnlineAccount],
45
- "emails": List[URI],
46
- "phones": List[URI],
47
- "addresses": List[Address],
48
- "person": object | Resource, # intended Person | Resource
49
- "attribution": object, # GEDCOM5/7 compatibility
50
- "uri": URI | Resource,
51
- },
52
- "Attribution": {
53
- "contributor": Resource,
54
- "modified": str,
55
- "changeMessage": str,
56
- "creator": Resource,
57
- "created": str,
58
- },
59
- "Conclusion": {
60
- "id": str,
61
- "lang": str,
62
- "sources": List["SourceReference"],
63
- "analysis": Document | Resource,
64
- "notes": List[Note],
65
- "confidence": ConfidenceLevel,
66
- "attribution": Attribution,
67
- "uri": "Resource",
68
- "max_note_count": int,
69
- "links": _rsLinks,
70
- },
71
- "Date": {
72
- "original": str,
73
- "formal": str,
74
- "normalized": str,
75
- },
76
- "Document": {
77
- "id": str,
78
- "lang": str,
79
- "sources": List[SourceReference],
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
- if name in self.field_type_table[cls.__name__].keys():
321
- print("B")
322
- raise ValueError
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
- self.field_type_table[cls.__name__][name] = typ
325
- print("C")
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
- SCHEMA._init_schema()
526
+
527
+
528
+
529
+
530
+