power-openapi-models 0.1.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (40) hide show
  1. {power_openapi_models-0.1.0/src/power_openapi_models.egg-info → power_openapi_models-0.3.0}/PKG-INFO +46 -4
  2. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/README.md +42 -3
  3. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/pyproject.toml +4 -2
  4. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/__init__.py +3 -0
  5. power_openapi_models-0.3.0/src/power_openapi_models/_schema_version.txt +1 -0
  6. power_openapi_models-0.3.0/src/power_openapi_models/_versioning.py +195 -0
  7. power_openapi_models-0.3.0/src/power_openapi_models/bundles/0.3.0/PortfolioDocument.json +3822 -0
  8. power_openapi_models-0.3.0/src/power_openapi_models/bundles/0.3.0/SystemDocument.json +9057 -0
  9. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/core/models.py +158 -11
  10. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/document.py +224 -44
  11. power_openapi_models-0.3.0/src/power_openapi_models/dynamics/models.py +1884 -0
  12. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/infrastructure_core/models.py +77 -12
  13. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/investments/models.py +64 -8
  14. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/operations/models.py +702 -393
  15. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/timeseries/models.py +46 -6
  16. power_openapi_models-0.3.0/src/power_openapi_models/timestamps.py +39 -0
  17. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0/src/power_openapi_models.egg-info}/PKG-INFO +46 -4
  18. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models.egg-info/SOURCES.txt +8 -1
  19. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models.egg-info/requires.txt +4 -0
  20. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/tests/test_document.py +2 -2
  21. power_openapi_models-0.3.0/tests/test_document_versioning.py +410 -0
  22. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/tests/test_public_api.py +13 -2
  23. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/tests/test_serde_fixture.py +1 -0
  24. power_openapi_models-0.3.0/tests/test_timestamps.py +75 -0
  25. power_openapi_models-0.3.0/tests/test_unknown_properties.py +36 -0
  26. power_openapi_models-0.1.0/src/power_openapi_models/_schema_version.txt +0 -1
  27. power_openapi_models-0.1.0/src/power_openapi_models/dynamics/models.py +0 -257
  28. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/LICENSE +0 -0
  29. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/setup.cfg +0 -0
  30. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/core/__init__.py +0 -0
  31. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/dynamics/__init__.py +0 -0
  32. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/infrastructure_core/__init__.py +0 -0
  33. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/investments/__init__.py +0 -0
  34. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/operations/__init__.py +0 -0
  35. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/py.typed +0 -0
  36. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models/timeseries/__init__.py +0 -0
  37. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models.egg-info/dependency_links.txt +0 -0
  38. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/src/power_openapi_models.egg-info/top_level.txt +0 -0
  39. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/tests/test_import.py +0 -0
  40. {power_openapi_models-0.1.0 → power_openapi_models-0.3.0}/tests/test_readme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-openapi-models
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Typed Python models for the Sienna power system data format
5
5
  Author: Sienna Platform
6
6
  License-Expression: BSD-3-Clause
@@ -22,7 +22,10 @@ Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: pydantic>=2.0
25
+ Provides-Extra: source-version
26
+ Requires-Dist: jsonschema>=4; extra == "source-version"
25
27
  Provides-Extra: dev
28
+ Requires-Dist: jsonschema>=4; extra == "dev"
26
29
  Requires-Dist: ruff>=0.6; extra == "dev"
27
30
  Requires-Dist: pytest>=8; extra == "dev"
28
31
  Requires-Dist: pyright>=1.1.380; extra == "dev"
@@ -111,6 +114,20 @@ print(UnitSystem.NATURAL_UNITS.value)
111
114
  `base_power`. Getting this wrong is the most common source of
112
115
  wrong-by-a-factor-of-100 bugs across the ecosystem.
113
116
 
117
+ ### Timestamps may omit the offset
118
+
119
+ The schema says RFC 3339, which requires an offset, but producers write
120
+ `2024-01-01T00:00:00`, so all three packages accept it and read it as UTC. An offset is
121
+ kept; a bare date or text that is not a timestamp is rejected. Fields are typed
122
+ `power_openapi_models.timestamps.UtcDatetime`, so they always hold an aware `datetime`.
123
+
124
+ ```python
125
+ from power_openapi_models.infrastructure_core.models import DataSource
126
+
127
+ source = DataSource(id=1, fields=[], retrieved_at="2024-01-01T00:00:00")
128
+ assert source.retrieved_at.utcoffset().total_seconds() == 0
129
+ ```
130
+
114
131
  ### Discriminated unions carry a `.root`
115
132
 
116
133
  Several types are one of several shapes, chosen by a discriminator field.
@@ -167,9 +184,34 @@ consumer's job.
167
184
  ## Round-tripping
168
185
 
169
186
  `read_document` followed by `write_document` reproduces the input byte for
170
- byte. Fields the input never carried stay omitted rather than being written
171
- back as explicit nulls, top-level keys are sorted, and the file ends with a
172
- newline.
187
+ byte, for a document in canonical form. Optional properties that are absent,
188
+ null or equal to their schema default (an empty `trading_hub_associations`,
189
+ say) are omitted rather than written back, top-level keys are sorted after the
190
+ `schema_version` stamp, and the file ends with a newline.
191
+
192
+ ## Schema versions
193
+
194
+ Both documents carry a required `schema_version`: the schema release that wrote
195
+ them. `read_document` and `read_portfolio_document` check it on the raw JSON,
196
+ before decoding, and raise `SchemaVersionError` (with `outcome`, `reader` and
197
+ `document` attributes) unless the document is from this package's schema version
198
+ or an older one on the same compatibility line. `check_schema_version(raw)` runs
199
+ the same rule on a parsed dict and returns the outcome without raising (a
200
+ non-object root raises pydantic's `ValidationError`).
201
+
202
+ ```python
203
+ from power_openapi_models.document import get_source_schema_version, read_document
204
+
205
+ doc = read_document("../fixtures/case14_operations.NATURAL_UNITS.json")
206
+ print(get_source_schema_version(doc))
207
+ ```
208
+
209
+ `write_document(doc, path)` stamps this package's version, so reading then writing
210
+ upgrades a document; `upgrade_document(src, dst)` does exactly that. To keep the
211
+ version a document was read at, pass `schema_version="source"`. When that version
212
+ is older, the encoded document is validated against that version's strict schema
213
+ first, and the write fails listing every path that version does not know; nothing
214
+ is dropped. That check needs `pip install "power-openapi-models[source-version]"`.
173
215
 
174
216
  ## Worked example
175
217
 
@@ -78,6 +78,20 @@ print(UnitSystem.NATURAL_UNITS.value)
78
78
  `base_power`. Getting this wrong is the most common source of
79
79
  wrong-by-a-factor-of-100 bugs across the ecosystem.
80
80
 
81
+ ### Timestamps may omit the offset
82
+
83
+ The schema says RFC 3339, which requires an offset, but producers write
84
+ `2024-01-01T00:00:00`, so all three packages accept it and read it as UTC. An offset is
85
+ kept; a bare date or text that is not a timestamp is rejected. Fields are typed
86
+ `power_openapi_models.timestamps.UtcDatetime`, so they always hold an aware `datetime`.
87
+
88
+ ```python
89
+ from power_openapi_models.infrastructure_core.models import DataSource
90
+
91
+ source = DataSource(id=1, fields=[], retrieved_at="2024-01-01T00:00:00")
92
+ assert source.retrieved_at.utcoffset().total_seconds() == 0
93
+ ```
94
+
81
95
  ### Discriminated unions carry a `.root`
82
96
 
83
97
  Several types are one of several shapes, chosen by a discriminator field.
@@ -134,9 +148,34 @@ consumer's job.
134
148
  ## Round-tripping
135
149
 
136
150
  `read_document` followed by `write_document` reproduces the input byte for
137
- byte. Fields the input never carried stay omitted rather than being written
138
- back as explicit nulls, top-level keys are sorted, and the file ends with a
139
- newline.
151
+ byte, for a document in canonical form. Optional properties that are absent,
152
+ null or equal to their schema default (an empty `trading_hub_associations`,
153
+ say) are omitted rather than written back, top-level keys are sorted after the
154
+ `schema_version` stamp, and the file ends with a newline.
155
+
156
+ ## Schema versions
157
+
158
+ Both documents carry a required `schema_version`: the schema release that wrote
159
+ them. `read_document` and `read_portfolio_document` check it on the raw JSON,
160
+ before decoding, and raise `SchemaVersionError` (with `outcome`, `reader` and
161
+ `document` attributes) unless the document is from this package's schema version
162
+ or an older one on the same compatibility line. `check_schema_version(raw)` runs
163
+ the same rule on a parsed dict and returns the outcome without raising (a
164
+ non-object root raises pydantic's `ValidationError`).
165
+
166
+ ```python
167
+ from power_openapi_models.document import get_source_schema_version, read_document
168
+
169
+ doc = read_document("../fixtures/case14_operations.NATURAL_UNITS.json")
170
+ print(get_source_schema_version(doc))
171
+ ```
172
+
173
+ `write_document(doc, path)` stamps this package's version, so reading then writing
174
+ upgrades a document; `upgrade_document(src, dst)` does exactly that. To keep the
175
+ version a document was read at, pass `schema_version="source"`. When that version
176
+ is older, the encoded document is validated against that version's strict schema
177
+ first, and the write fails listing every path that version does not know; nothing
178
+ is dropped. That check needs `pip install "power-openapi-models[source-version]"`.
140
179
 
141
180
  ## Worked example
142
181
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "power-openapi-models"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "Typed Python models for the Sienna power system data format"
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -41,7 +41,9 @@ Issues = "https://github.com/Sienna-Platform/power-openapi-models/issues"
41
41
  Changelog = "https://github.com/Sienna-Platform/power-openapi-models/blob/main/CHANGELOG.md"
42
42
 
43
43
  [project.optional-dependencies]
44
+ source-version = ["jsonschema>=4"]
44
45
  dev = [
46
+ "jsonschema>=4",
45
47
  "ruff>=0.6",
46
48
  "pytest>=8",
47
49
  "pyright>=1.1.380",
@@ -54,7 +56,7 @@ dev = [
54
56
  where = ["src"]
55
57
 
56
58
  [tool.setuptools.package-data]
57
- power_openapi_models = ["py.typed", "_schema_version.txt"]
59
+ power_openapi_models = ["py.typed", "_schema_version.txt", "bundles/*/*.json"]
58
60
 
59
61
  [tool.ruff]
60
62
  line-length = 100
@@ -17,6 +17,7 @@ from power_openapi_models import (
17
17
  operations,
18
18
  timeseries,
19
19
  )
20
+ from power_openapi_models._versioning import SchemaVersionError, check_schema_version
20
21
 
21
22
  try:
22
23
  __version__ = _version("power-openapi-models")
@@ -47,6 +48,8 @@ __all__ = [
47
48
  "investments",
48
49
  "operations",
49
50
  "timeseries",
51
+ "SchemaVersionError",
52
+ "check_schema_version",
50
53
  "__version__",
51
54
  "__schema_version__",
52
55
  ]
@@ -0,0 +1,195 @@
1
+ """Hand-written (NOT generated): the schema-version reader rule and the strict-bundle
2
+ check behind `write_document(..., schema_version="source")`.
3
+
4
+ The rule, its outcomes and the canonical message texts are defined once, in
5
+ SiennaSchemas' `docs/VERSIONING.md`; `scripts/schema_version.py` there is the reference
6
+ implementation. `check_schema_version` must agree with `tests/fixtures/versioning/cases.json`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ from pydantic import ValidationError
17
+
18
+ Outcome = Literal["missing", "malformed", "incompatible", "newer", "upgradable", "current"]
19
+
20
+ READER_VERSION = (
21
+ (Path(__file__).parent / "_schema_version.txt").read_text().strip().removeprefix("v")
22
+ )
23
+ BUNDLE_DIR = Path(__file__).parent / "bundles"
24
+
25
+ _PATTERN = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?")
26
+
27
+
28
+ class SchemaVersionError(ValueError):
29
+ """A document whose `schema_version` this reader cannot read."""
30
+
31
+ def __init__(self, outcome: Outcome, reader: str, document: object, message: str):
32
+ super().__init__(message)
33
+ self.outcome = outcome
34
+ self.reader = reader
35
+ self.document = document
36
+
37
+
38
+ def _parse(version: object) -> tuple[tuple[int, str], ...] | None:
39
+ """Components as (digit count, digits): the pattern forbids leading zeros, so this
40
+ orders like the integers without a size limit."""
41
+ if not isinstance(version, str):
42
+ return None
43
+ m = _PATTERN.fullmatch(version)
44
+ if m is None:
45
+ return None
46
+ pre = (0, m[4][1:]) if m[4] else (0, "")
47
+ return (*((len(m[i]), m[i]) for i in (1, 2, 3)), pre)
48
+
49
+
50
+ def _prerelease(version) -> bool:
51
+ return bool(version[3][1])
52
+
53
+
54
+ def _line(version) -> tuple[tuple[int, str], ...]:
55
+ major, minor = version[:2]
56
+ return (major, minor) if major[1] == "0" else (major,)
57
+
58
+
59
+ def _line_name(version) -> str:
60
+ return ".".join(digits for _, digits in _line(version))
61
+
62
+
63
+ def _classify(raw: object, reader: str) -> Outcome:
64
+ if not isinstance(raw, dict):
65
+ raise ValidationError.from_exception_data(
66
+ "document", [{"type": "dict_type", "loc": (), "input": raw}]
67
+ )
68
+ if "schema_version" not in raw:
69
+ return "missing"
70
+ d = _parse(raw["schema_version"])
71
+ if d is None:
72
+ return "malformed"
73
+ r = _parse(reader)
74
+ if r is None:
75
+ raise ValueError(f"reader schema version {reader!r} is not a valid version")
76
+ if (_prerelease(d) or _prerelease(r)) and d != r:
77
+ return "incompatible"
78
+ if _line(d) != _line(r):
79
+ return "incompatible"
80
+ if d[:3] > r[:3]:
81
+ return "newer"
82
+ if d[:3] < r[:3]:
83
+ return "upgradable"
84
+ return "current"
85
+
86
+
87
+ def _message(outcome: Outcome, reader: str, document: object) -> str:
88
+ if outcome == "missing":
89
+ return (
90
+ "document has no schema_version: it predates versioning; re-export it with a "
91
+ "current producer (psy5 bundles: PowerSystemsUpdater)"
92
+ )
93
+ if outcome == "malformed":
94
+ encoded = json.dumps(document, separators=(",", ":"), ensure_ascii=False)
95
+ return f"document schema_version {encoded} is not a valid version"
96
+ d = _parse(document)
97
+ r = _parse(reader)
98
+ assert d is not None and r is not None
99
+ if outcome == "incompatible":
100
+ if _prerelease(d) or _prerelease(r):
101
+ return (
102
+ f"document written by schema {document} cannot be read by schema {reader}: "
103
+ "dev builds read only their own output"
104
+ )
105
+ return (
106
+ f"document written by schema {document} (line {_line_name(d)}) cannot be read by "
107
+ f"schema {reader} (line {_line_name(r)}): documents do not cross compatibility "
108
+ "lines; migrating between lines is a separate upgrade tool's job "
109
+ "(psy5 bundles: PowerSystemsUpdater)"
110
+ )
111
+ return (
112
+ f"document written by schema {document}; this reader understands up to {reader}; "
113
+ f"update the model package to one built from >= {document}"
114
+ )
115
+
116
+
117
+ def _check(raw: object, reader: str) -> Outcome:
118
+ outcome = _classify(raw, reader)
119
+ if outcome in ("upgradable", "current"):
120
+ return outcome
121
+ document = raw.get("schema_version")
122
+ raise SchemaVersionError(outcome, reader, document, _message(outcome, reader, document))
123
+
124
+
125
+ def check_schema_version(raw: object) -> Outcome:
126
+ """Classify the raw parsed JSON of a document against this package's schema version.
127
+
128
+ Pure: returns the outcome and never raises `SchemaVersionError` (the read path does).
129
+ A non-object root is a format error and raises pydantic's `ValidationError`, as
130
+ reading it does. Run before decoding, so a newer document reports its version
131
+ rather than an unknown key.
132
+ """
133
+ return _classify(raw, READER_VERSION)
134
+
135
+
136
+ def _covered(error, branch: int, root: dict) -> int:
137
+ """How many instance keys the `branch` of an anyOf/oneOf `error` declares."""
138
+ sub = error.schema[error.validator][branch]
139
+ while "$ref" in sub:
140
+ node = root
141
+ for part in sub["$ref"].removeprefix("#/").split("/"):
142
+ node = node[part]
143
+ sub = node
144
+ if not isinstance(error.instance, dict):
145
+ return 0
146
+ return sum(1 for key in error.instance if key in sub.get("properties", {}))
147
+
148
+
149
+ def _leaves(error, root: dict):
150
+ """The offending (path, message) pairs under one validation error."""
151
+ if error.validator in ("anyOf", "oneOf") and error.context:
152
+ branches: dict[int, list] = {}
153
+ for e in error.context:
154
+ branches.setdefault(e.schema_path[0], []).extend(_leaves(e, root))
155
+ pick = min(
156
+ branches,
157
+ key=lambda i: (len(branches[i]), -_covered(error, i, root)),
158
+ )
159
+ yield from branches[pick]
160
+ return
161
+ path = "/" + "/".join(str(p) for p in error.absolute_path)
162
+ if error.validator == "additionalProperties" and isinstance(error.instance, dict):
163
+ known = error.schema.get("properties", {})
164
+ for key in error.instance:
165
+ if key not in known:
166
+ yield f"{path.rstrip('/')}/{key}", "is not a property of this schema version"
167
+ return
168
+ yield path, error.message
169
+
170
+
171
+ def validate_source(tree: dict, document: str, bundle_name: str, bundle_dir: Path) -> None:
172
+ """Validate the encoded `tree` against the strict bundle of schema `document`.
173
+
174
+ Raises one `ValueError` listing every offending path.
175
+ """
176
+ try:
177
+ from jsonschema import Draft7Validator
178
+ except ImportError as e:
179
+ raise ImportError(
180
+ f"writing at the source schema version {document} needs a validator; "
181
+ "install power-openapi-models[source-version]"
182
+ ) from e
183
+ bundle = Path(bundle_dir) / document / f"{bundle_name}.json"
184
+ if not bundle.is_file():
185
+ raise FileNotFoundError(f"strict bundle for schema {document} not found at {bundle}")
186
+ root = json.loads(bundle.read_text())
187
+ found = sorted(
188
+ {leaf for e in Draft7Validator(root).iter_errors(tree) for leaf in _leaves(e, root)}
189
+ )
190
+ if found:
191
+ lines = "\n".join(f" {path}: {message}" for path, message in found)
192
+ raise ValueError(
193
+ f'document does not fit schema {document}; save with schema_version="current" '
194
+ f"to write the current version:\n{lines}"
195
+ )