reg-schema 2.0.0__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.
- reg_schema/__init__.py +54 -0
- reg_schema/project_data.py +318 -0
- reg_schema/structural.py +1593 -0
- reg_schema/validation.py +73 -0
- reg_schema-2.0.0.dist-info/METADATA +54 -0
- reg_schema-2.0.0.dist-info/RECORD +7 -0
- reg_schema-2.0.0.dist-info/WHEEL +4 -0
reg_schema/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""reg_schema: project_data.json schema + structural validator.
|
|
2
|
+
|
|
3
|
+
See ``DESIGN.md`` for scope and dependency direction; the models in
|
|
4
|
+
``project_data.py`` are the authoritative schema (see DESIGN.md → Two
|
|
5
|
+
layers: models vs. validator).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .project_data import (
|
|
9
|
+
Binding,
|
|
10
|
+
ColumnType,
|
|
11
|
+
EntityKey,
|
|
12
|
+
IdSubtype,
|
|
13
|
+
LiteralPeriod,
|
|
14
|
+
NumericSubtype,
|
|
15
|
+
Panel,
|
|
16
|
+
PanelMember,
|
|
17
|
+
Period,
|
|
18
|
+
PeriodRange,
|
|
19
|
+
ProjectData,
|
|
20
|
+
Source,
|
|
21
|
+
Steward,
|
|
22
|
+
StudyWindow,
|
|
23
|
+
TimeKey,
|
|
24
|
+
TimePoint,
|
|
25
|
+
TimeRange,
|
|
26
|
+
)
|
|
27
|
+
from .structural import validate_structural
|
|
28
|
+
from .validation import IssueLevel, ValidationIssue, ValidationResult
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Binding",
|
|
32
|
+
"ColumnType",
|
|
33
|
+
"EntityKey",
|
|
34
|
+
"IdSubtype",
|
|
35
|
+
"IssueLevel",
|
|
36
|
+
"LiteralPeriod",
|
|
37
|
+
"NumericSubtype",
|
|
38
|
+
"Panel",
|
|
39
|
+
"PanelMember",
|
|
40
|
+
"Period",
|
|
41
|
+
"PeriodRange",
|
|
42
|
+
"ProjectData",
|
|
43
|
+
"Source",
|
|
44
|
+
"Steward",
|
|
45
|
+
"StudyWindow",
|
|
46
|
+
"TimeKey",
|
|
47
|
+
"TimePoint",
|
|
48
|
+
"TimeRange",
|
|
49
|
+
"ValidationIssue",
|
|
50
|
+
"ValidationResult",
|
|
51
|
+
"validate_structural",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
__version__ = "2.0.0"
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""project_data.json schema models (see DESIGN.md → Two layers: models vs. validator).
|
|
2
|
+
|
|
3
|
+
Pure shape definitions, not validators. Pydantic v2 ``BaseModel`` —
|
|
4
|
+
``reg_schema`` is the deliberate exception to the workspace no-Pydantic
|
|
5
|
+
rule (``CLAUDE.md`` stack §): these models are the canonical project_data
|
|
6
|
+
shape, FastAPI response models in ``reg_webapp``, and the source of the
|
|
7
|
+
SPA's TypeScript types via ``model_json_schema()``.
|
|
8
|
+
|
|
9
|
+
Models are ``frozen`` + tuple-backed so consumers (reg_webapp and the SPA
|
|
10
|
+
via TS codegen) can hash, share, and pass instances freely. Pydantic
|
|
11
|
+
coerces list → tuple for ``tuple[...]`` fields automatically, so callers
|
|
12
|
+
may construct from list-shaped composites without losing the frozen +
|
|
13
|
+
hashable contract.
|
|
14
|
+
|
|
15
|
+
JSON deserialization and structural validation are deliberately
|
|
16
|
+
separate concerns:
|
|
17
|
+
|
|
18
|
+
- Structural rules (see DESIGN.md → Structural rules and issue codes — type/subtype consistency, FQID
|
|
19
|
+
well-formedness, panel ordering, period grammar, etc.) live in
|
|
20
|
+
``validate_structural()`` and run on the **raw dict** before any
|
|
21
|
+
model is constructed. They accumulate every issue into a
|
|
22
|
+
``ValidationResult`` rather than raising, so every runtime that shares
|
|
23
|
+
the contract (SPA via TS mirror, webapp via direct import) sees the full
|
|
24
|
+
issue list. The models intentionally do NOT re-encode those rules as
|
|
25
|
+
raising field validators — that would replace the issue-accumulating
|
|
26
|
+
contract with a fail-fast one.
|
|
27
|
+
- Models are constructed at boundaries (API ingress) from data that
|
|
28
|
+
already passed ``validate_structural``. A Pydantic raise at that point
|
|
29
|
+
signals validator/model drift, not user error.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import Literal
|
|
35
|
+
|
|
36
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
37
|
+
|
|
38
|
+
# Top-level enums (see DESIGN.md → Two layers: models vs. validator). Mirrored at runtime by the structural
|
|
39
|
+
# validator using ``get_args`` — same drift-protection pattern as
|
|
40
|
+
# ``IssueLevel`` in ``validation.py``.
|
|
41
|
+
Steward = Literal["global", "ifau", "swecov"]
|
|
42
|
+
ColumnType = Literal["id", "categorical", "numeric", "date", "datetime", "opaque"]
|
|
43
|
+
IdSubtype = Literal["integer", "string"]
|
|
44
|
+
NumericSubtype = Literal["integer", "double"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Model(BaseModel):
|
|
48
|
+
"""Shared config: frozen (immutable + hashable) and extra-forbidding.
|
|
49
|
+
|
|
50
|
+
``extra="forbid"`` makes a typo in a constructed model fail loudly
|
|
51
|
+
instead of dropping into defaults — the same drift guard the IR
|
|
52
|
+
models use (``reg_meta_build.ir``).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Period -------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PeriodRange(_Model):
|
|
62
|
+
"""The ``{"from": ..., "to": ...}`` range form of ``Source.period``.
|
|
63
|
+
|
|
64
|
+
Endpoints follow the same int / period-token-string forms as a bare
|
|
65
|
+
period. ``from`` is a Python keyword, so the field is ``from_`` with a
|
|
66
|
+
``"from"`` alias; ``populate_by_name`` lets callers use either.
|
|
67
|
+
|
|
68
|
+
This bare object is **only** legal as a ``Source.period`` value; a
|
|
69
|
+
``TimePoint`` range uses the discriminated ``TimeRange`` wrapper
|
|
70
|
+
(``{"range": {...}}``) so ``TimeKey``'s union stays unambiguous.
|
|
71
|
+
|
|
72
|
+
``serialize_by_alias=True`` so ``model_dump()`` emits ``"from"`` (not the
|
|
73
|
+
Python-safe ``"from_"``) without every caller having to pass
|
|
74
|
+
``by_alias=True`` — the un-aliased key would fail re-validation
|
|
75
|
+
(``_is_period_range_obj`` requires exactly ``{"from", "to"}``).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
model_config = ConfigDict(
|
|
79
|
+
frozen=True,
|
|
80
|
+
extra="forbid",
|
|
81
|
+
populate_by_name=True,
|
|
82
|
+
serialize_by_alias=True,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
from_: int | str = Field(alias="from")
|
|
86
|
+
to: int | str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# One contiguous piece of a ``Source.period``: bare year, period-token string,
|
|
90
|
+
# or explicit range. The ``"_default"`` snapshot sentinel is a plain string at
|
|
91
|
+
# the top level only — it is NOT a legal list member (structural rule).
|
|
92
|
+
PeriodSegment = int | str | PeriodRange
|
|
93
|
+
|
|
94
|
+
# ``Source.period``: a single segment, the ``"_default"`` sentinel (rides the
|
|
95
|
+
# ``str`` arm), or a LIST of segments — an interrupted series (#307, e.g.
|
|
96
|
+
# ``[{"from": 2005, "to": 2010}, {"from": 2015, "to": 2020}]``). The list form
|
|
97
|
+
# keeps one source = one register extraction (panel keys / binding sets are not
|
|
98
|
+
# duplicated across pseudo-sources). Structural rules for the list: non-empty,
|
|
99
|
+
# members are segments (no ``_default``, no nesting), sorted ascending and
|
|
100
|
+
# non-overlapping (adjacency allowed — the wire form stays canonical). Always
|
|
101
|
+
# required.
|
|
102
|
+
Period = PeriodSegment | tuple[PeriodSegment, ...]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Binding ------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Binding(_Model):
|
|
109
|
+
"""A binding on a Source — one variable to include in the extract.
|
|
110
|
+
|
|
111
|
+
``variable`` is the binding FQID: ``<provider>/<register>/<slug>`` (3
|
|
112
|
+
segments, see reg_meta/DESIGN.md → FQID grammar). Its ``provider/register`` prefix (first 2 segments) must
|
|
113
|
+
equal the source's ``register_variant`` prefix — the variant is NOT
|
|
114
|
+
repeated here, it lives once on the Source. That cross-field rule
|
|
115
|
+
is enforced by the structural validator. There is no ``@version`` pin —
|
|
116
|
+
that grammar is retired.
|
|
117
|
+
|
|
118
|
+
A FQID names one CONCEPT. The reg_meta build enforces one value set per
|
|
119
|
+
``(variable, variant, period, delivery_column)``, but a concept may carry
|
|
120
|
+
several co-existing delivery columns — parallel REPRESENTATIONS of it (SSYK
|
|
121
|
+
3/4/5-digit, age 5/10-yr brackets). ``representation`` selects which one (by
|
|
122
|
+
its ``variable_alias.delivery_column_name``); it is required only when the
|
|
123
|
+
concept resolves to >1 column at the source's ``(variant, period)`` — the
|
|
124
|
+
semantic validator (see reg_webapp/DESIGN.md → Semantic validation (semantic.py)) flags an ambiguous binding that omits it, and the
|
|
125
|
+
SPA offers a chooser. A single-representation concept leaves it ``None``.
|
|
126
|
+
|
|
127
|
+
``display_name`` is optional: when absent, reg_meta-backed consumers
|
|
128
|
+
resolve the default from ``variable_alias.delivery_column_name`` for
|
|
129
|
+
the binding's state at the source's ``(register_variant, period)``. A
|
|
130
|
+
reg_meta-free consumer that materializes data artifacts must resolve
|
|
131
|
+
the default itself before emitting them — it never carries an
|
|
132
|
+
unresolved ``display_name`` into its output.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
variable: str
|
|
136
|
+
type: ColumnType
|
|
137
|
+
display_name: str | None = None
|
|
138
|
+
id_subtype: IdSubtype | None = None
|
|
139
|
+
numeric_subtype: NumericSubtype | None = None
|
|
140
|
+
date_format: str | None = None
|
|
141
|
+
datetime_format: str | None = None
|
|
142
|
+
value_set: str | None = None
|
|
143
|
+
representation: str | None = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Source(_Model):
|
|
147
|
+
"""A data source / table in the spec.
|
|
148
|
+
|
|
149
|
+
``register_variant`` is the 3-part variant **coordinate**
|
|
150
|
+
(``<provider>/<register>/<variant>``) — not an FQID kind (see reg_meta/DESIGN.md → FQID grammar), but
|
|
151
|
+
the same 3-part grammar. ``period`` is always required and polymorphic
|
|
152
|
+
(``Period``). Together ``(register_variant's variant, period)`` selects
|
|
153
|
+
each binding variable's ``variable_state``. ``name`` is the internal
|
|
154
|
+
source handle referenced by panel members; it is not an FQID.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
name: str
|
|
158
|
+
register_variant: str
|
|
159
|
+
period: Period
|
|
160
|
+
bindings: tuple[Binding, ...]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Panel ---------------------------------------------------------------
|
|
164
|
+
#
|
|
165
|
+
# Type aliases:
|
|
166
|
+
#
|
|
167
|
+
# EntityKey = string | string[] // always column refs
|
|
168
|
+
# TimePoint = int | string | LiteralPeriod | TimeRange // string is column ref
|
|
169
|
+
# TimeKey = TimePoint | TimePoint[]
|
|
170
|
+
#
|
|
171
|
+
# Bare strings in panel keys are *always* column refs against a source's
|
|
172
|
+
# binding ``display_name`` values; literal string-shaped periods (e.g.
|
|
173
|
+
# ``"2018-01"``, ``"HT2018"``) must use the ``LiteralPeriod`` object form,
|
|
174
|
+
# and ranges the ``TimeRange`` wrapper. Integer literals stay as plain ints.
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class LiteralPeriod(_Model):
|
|
178
|
+
"""The ``{"period": int | string}`` time_key form.
|
|
179
|
+
|
|
180
|
+
The only way to express a string-shaped literal period at the
|
|
181
|
+
schema level. Disambiguates ``"2018"`` (column ref) from
|
|
182
|
+
``{"period": "2018-01"}`` (literal period).
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
period: int | str
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class TimeRange(_Model):
|
|
189
|
+
"""The ``{"range": {"from": ..., "to": ...}}`` time_key form.
|
|
190
|
+
|
|
191
|
+
The discriminated wrapper for a period range in ``TimeKey`` position —
|
|
192
|
+
distinct from the bare ``{"from", "to"}`` object, which is legal only
|
|
193
|
+
as a ``Source.period`` (``PeriodRange``). The wrapper keeps the
|
|
194
|
+
``TimePoint`` union unambiguous.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
range: PeriodRange
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
TimePoint = int | str | LiteralPeriod | TimeRange
|
|
201
|
+
TimeKey = TimePoint | tuple[TimePoint, ...]
|
|
202
|
+
EntityKey = str | tuple[str, ...]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class PanelMember(_Model):
|
|
206
|
+
"""A member of a Panel.
|
|
207
|
+
|
|
208
|
+
``source`` is the source ``name`` (the panel layer joins on
|
|
209
|
+
delivered-data column headers, not FQIDs). ``entity_key`` /
|
|
210
|
+
``time_key`` override panel-level defaults; when both panel and member
|
|
211
|
+
leave a key unset, it is inherited from the member's variant's
|
|
212
|
+
``panel_template`` at kit/bundle-build time — the structural
|
|
213
|
+
validator does not flag the absence (it has no reg_meta).
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
source: str
|
|
217
|
+
entity_key: EntityKey | None = None
|
|
218
|
+
time_key: TimeKey | None = None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Panel(_Model):
|
|
222
|
+
"""A panel definition over sources.
|
|
223
|
+
|
|
224
|
+
Members are stored uniformly as ``PanelMember``. The bare-string
|
|
225
|
+
shorthand (a source name with panel-level key defaults) is normalized
|
|
226
|
+
to ``PanelMember(source=<name>)`` by the ``members`` validator, so
|
|
227
|
+
consumers never branch on ``str | PanelMember``. Source-collision (each
|
|
228
|
+
source belongs to at most one panel) and composite ordering /
|
|
229
|
+
homogeneity rules are enforced by the structural validator, not here.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
panel_id: str
|
|
233
|
+
members: tuple[PanelMember, ...]
|
|
234
|
+
entity_key: EntityKey | None = None
|
|
235
|
+
time_key: TimeKey | None = None
|
|
236
|
+
comment: str | None = None
|
|
237
|
+
|
|
238
|
+
@field_validator(
|
|
239
|
+
"members",
|
|
240
|
+
mode="before",
|
|
241
|
+
json_schema_input_type=tuple[str | PanelMember, ...],
|
|
242
|
+
)
|
|
243
|
+
@classmethod
|
|
244
|
+
def _normalize_member_shorthand(cls, value: object) -> object:
|
|
245
|
+
# A bare-string member is the source name. Expand it to the
|
|
246
|
+
# object form before per-element validation; dicts and already-built
|
|
247
|
+
# PanelMember instances pass through untouched.
|
|
248
|
+
if isinstance(value, (list, tuple)):
|
|
249
|
+
return [{"source": m} if isinstance(m, str) else m for m in value]
|
|
250
|
+
return value
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# Study window --------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class StudyWindow(_Model):
|
|
257
|
+
"""The optional ``{"from": <year>, "to": <year>}`` project study window.
|
|
258
|
+
|
|
259
|
+
The global "project window" the redesigned subject page defaults each
|
|
260
|
+
page's period picker to (see issue #611 → Period model). Deliberately
|
|
261
|
+
NOT the full ``Period`` / ``PeriodRange`` grammar: it is a plain
|
|
262
|
+
year-int pair, matching the year-granular header slider. Per-page
|
|
263
|
+
deviation (months/quarters/terms, interrupted segments) keeps the rich
|
|
264
|
+
grammar via ``?period``; this window only seeds the default.
|
|
265
|
+
|
|
266
|
+
``from`` is a Python keyword, so the field is ``from_`` with a ``"from"``
|
|
267
|
+
alias — mirroring ``PeriodRange``. ``serialize_by_alias=True`` so
|
|
268
|
+
``model_dump()`` emits ``"from"`` (not ``"from_"``) without callers
|
|
269
|
+
passing ``by_alias=True``.
|
|
270
|
+
|
|
271
|
+
Endpoints are plain ``int`` years (not the ``int | str`` period-token
|
|
272
|
+
forms of ``PeriodRange``): the window is year-granular by design. The
|
|
273
|
+
only invariant is ``to >= from`` — a same-year window (``from == to``)
|
|
274
|
+
is valid; a window can't end before it starts.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
model_config = ConfigDict(
|
|
278
|
+
frozen=True,
|
|
279
|
+
extra="forbid",
|
|
280
|
+
populate_by_name=True,
|
|
281
|
+
serialize_by_alias=True,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
from_: int = Field(alias="from")
|
|
285
|
+
to: int
|
|
286
|
+
|
|
287
|
+
@model_validator(mode="after")
|
|
288
|
+
def _check_order(self) -> StudyWindow:
|
|
289
|
+
if self.to < self.from_:
|
|
290
|
+
raise ValueError(
|
|
291
|
+
f"study window 'to' ({self.to}) must be >= 'from' ({self.from_})"
|
|
292
|
+
)
|
|
293
|
+
return self
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# Top-level shape -----------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class ProjectData(_Model):
|
|
300
|
+
"""The top-level ``project_data.json`` shape.
|
|
301
|
+
|
|
302
|
+
The root is closed like every nested ``_Model``: an unknown field is a
|
|
303
|
+
model-construction error. API boundaries still run the accumulating
|
|
304
|
+
structural validator first so callers receive one stable
|
|
305
|
+
``unexpected_field`` issue per unknown key rather than a fail-fast Pydantic
|
|
306
|
+
error. A future extension needs an explicit modeled container; arbitrary
|
|
307
|
+
namespaced root blocks are not part of the v1 contract.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
schema_version: str
|
|
311
|
+
steward: Steward
|
|
312
|
+
reg_meta_version: str
|
|
313
|
+
name: str
|
|
314
|
+
sources: tuple[Source, ...]
|
|
315
|
+
panels: tuple[Panel, ...] = ()
|
|
316
|
+
# Optional global study window (see issue #611 → Period model). Absent =
|
|
317
|
+
# full history; existing specs validate unchanged (additive surface).
|
|
318
|
+
window: StudyWindow | None = None
|