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 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