metergraph-core 0.4.0__tar.gz → 0.5.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.
- {metergraph_core-0.4.0/src/metergraph_core.egg-info → metergraph_core-0.5.0}/PKG-INFO +21 -8
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/README.md +20 -7
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/pyproject.toml +1 -1
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/__init__.py +2 -2
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/catalog.py +1 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/catalog_delta.py +212 -17
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/data/prices.yaml +6198 -3
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/data/sources.yaml +20 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/extractors.py +375 -1
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/refresh.py +340 -5
- {metergraph_core-0.4.0 → metergraph_core-0.5.0/src/metergraph_core.egg-info}/PKG-INFO +21 -8
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/MANIFEST.in +0 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/setup.cfg +0 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/billing.py +0 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/data/models.yaml +0 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/loader.py +0 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/models.py +0 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/retrieval.py +0 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/sources.py +0 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core.egg-info/SOURCES.txt +0 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core.egg-info/requires.txt +0 -0
- {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metergraph-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Reusable MeterGraph catalog and deterministic billing engine
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -229,7 +229,7 @@ A pricing delta proposes changes to a catalog's price rows. It is a change
|
|
|
229
229
|
artifact to review and apply, never a catalog to serve.
|
|
230
230
|
|
|
231
231
|
```yaml
|
|
232
|
-
schema_version:
|
|
232
|
+
schema_version: 2
|
|
233
233
|
kind: pricing_delta
|
|
234
234
|
provenance:
|
|
235
235
|
observations: [openai-api-pricing@2026-10-20]
|
|
@@ -326,12 +326,12 @@ only the labels, headers and key paths that parser relies on, so a price change
|
|
|
326
326
|
or a newly listed model keeps the fingerprint and a format change alters it.
|
|
327
327
|
Extractors never fetch. The package adds no browser or LLM dependency.
|
|
328
328
|
|
|
329
|
-
##
|
|
329
|
+
## Pricing maintenance scripts
|
|
330
330
|
|
|
331
|
-
|
|
332
|
-
|
|
331
|
+
The repository scripts run the registry against live sources without credentials
|
|
332
|
+
or GitHub access.
|
|
333
333
|
|
|
334
|
-
|
|
334
|
+
The fetch-capable scripts never emit, store or redistribute raw page content. Their
|
|
335
335
|
reports and evidence files hold only structural fingerprints, normalized
|
|
336
336
|
extracted values (row labels as slugs, parsed amounts, numeric candidates),
|
|
337
337
|
candidates, operations and short non-verbatim diagnostics. No output may name
|
|
@@ -354,7 +354,7 @@ an escalation, and 1 on error.
|
|
|
354
354
|
behind a refresh report's escalations (`--escalations`), and new-source content
|
|
355
355
|
from a file or stdin (`--new-source`, never a URL). It prints evidence for each:
|
|
356
356
|
candidate and prior values, the reason for review, normalized page facts, and a
|
|
357
|
-
proposed delta for
|
|
357
|
+
proposed delta for supported catalog changes. An optional adapter
|
|
358
358
|
(`--llm-adapter module:callable`) is called as
|
|
359
359
|
`propose(source, content, current_rows)` and returns delta operations. Its
|
|
360
360
|
output is kept as review evidence only after it passes schema validation and
|
|
@@ -362,7 +362,20 @@ encodes as strict JSON. Adapter failures and rejected items are reported with
|
|
|
362
362
|
stable codes, never with the adapter's own text. Everything discovery emits
|
|
363
363
|
requires review.
|
|
364
364
|
|
|
365
|
-
|
|
365
|
+
For structured gateway feeds, discovery also proposes `add_model` and
|
|
366
|
+
`add_alias` operations when an exact model identity is safe to extend. Free,
|
|
367
|
+
conditional, tiered, regional and unsupported-charge rows remain exclusions.
|
|
368
|
+
After review, `scripts/apply_price_delta.py --reviewed` writes additive operations
|
|
369
|
+
to a new catalog path while preserving existing comments. It rejects in-place
|
|
370
|
+
writes and history-changing operations, then verifies the rendered catalog
|
|
371
|
+
against the pure delta result.
|
|
372
|
+
|
|
373
|
+
Apply overlapping gateway sources sequentially. Run discovery for the next
|
|
374
|
+
source against the prior candidate so a shared new model becomes an alias and
|
|
375
|
+
price on one canonical entry instead of a conflicting second `add_model`.
|
|
376
|
+
|
|
377
|
+
The fetch-capable scripts read `<source-id>.*` files from `--content-dir` instead
|
|
378
|
+
of fetching. The
|
|
366
379
|
tests use one synthetic fixture per low-risk source, plus OpenAI's, in
|
|
367
380
|
`tests/fixtures/sources/`, reproducing only the structure its parser reads;
|
|
368
381
|
the generic discovery parsers are tested on small inline synthetic pages.
|
|
@@ -217,7 +217,7 @@ A pricing delta proposes changes to a catalog's price rows. It is a change
|
|
|
217
217
|
artifact to review and apply, never a catalog to serve.
|
|
218
218
|
|
|
219
219
|
```yaml
|
|
220
|
-
schema_version:
|
|
220
|
+
schema_version: 2
|
|
221
221
|
kind: pricing_delta
|
|
222
222
|
provenance:
|
|
223
223
|
observations: [openai-api-pricing@2026-10-20]
|
|
@@ -314,12 +314,12 @@ only the labels, headers and key paths that parser relies on, so a price change
|
|
|
314
314
|
or a newly listed model keeps the fingerprint and a format change alters it.
|
|
315
315
|
Extractors never fetch. The package adds no browser or LLM dependency.
|
|
316
316
|
|
|
317
|
-
##
|
|
317
|
+
## Pricing maintenance scripts
|
|
318
318
|
|
|
319
|
-
|
|
320
|
-
|
|
319
|
+
The repository scripts run the registry against live sources without credentials
|
|
320
|
+
or GitHub access.
|
|
321
321
|
|
|
322
|
-
|
|
322
|
+
The fetch-capable scripts never emit, store or redistribute raw page content. Their
|
|
323
323
|
reports and evidence files hold only structural fingerprints, normalized
|
|
324
324
|
extracted values (row labels as slugs, parsed amounts, numeric candidates),
|
|
325
325
|
candidates, operations and short non-verbatim diagnostics. No output may name
|
|
@@ -342,7 +342,7 @@ an escalation, and 1 on error.
|
|
|
342
342
|
behind a refresh report's escalations (`--escalations`), and new-source content
|
|
343
343
|
from a file or stdin (`--new-source`, never a URL). It prints evidence for each:
|
|
344
344
|
candidate and prior values, the reason for review, normalized page facts, and a
|
|
345
|
-
proposed delta for
|
|
345
|
+
proposed delta for supported catalog changes. An optional adapter
|
|
346
346
|
(`--llm-adapter module:callable`) is called as
|
|
347
347
|
`propose(source, content, current_rows)` and returns delta operations. Its
|
|
348
348
|
output is kept as review evidence only after it passes schema validation and
|
|
@@ -350,7 +350,20 @@ encodes as strict JSON. Adapter failures and rejected items are reported with
|
|
|
350
350
|
stable codes, never with the adapter's own text. Everything discovery emits
|
|
351
351
|
requires review.
|
|
352
352
|
|
|
353
|
-
|
|
353
|
+
For structured gateway feeds, discovery also proposes `add_model` and
|
|
354
|
+
`add_alias` operations when an exact model identity is safe to extend. Free,
|
|
355
|
+
conditional, tiered, regional and unsupported-charge rows remain exclusions.
|
|
356
|
+
After review, `scripts/apply_price_delta.py --reviewed` writes additive operations
|
|
357
|
+
to a new catalog path while preserving existing comments. It rejects in-place
|
|
358
|
+
writes and history-changing operations, then verifies the rendered catalog
|
|
359
|
+
against the pure delta result.
|
|
360
|
+
|
|
361
|
+
Apply overlapping gateway sources sequentially. Run discovery for the next
|
|
362
|
+
source against the prior candidate so a shared new model becomes an alias and
|
|
363
|
+
price on one canonical entry instead of a conflicting second `add_model`.
|
|
364
|
+
|
|
365
|
+
The fetch-capable scripts read `<source-id>.*` files from `--content-dir` instead
|
|
366
|
+
of fetching. The
|
|
354
367
|
tests use one synthetic fixture per low-risk source, plus OpenAI's, in
|
|
355
368
|
`tests/fixtures/sources/`, reproducing only the structure its parser reads;
|
|
356
369
|
the generic discovery parsers are tested on small inline synthetic pages.
|
|
@@ -50,7 +50,7 @@ from .retrieval import (
|
|
|
50
50
|
RetrievalPrice,
|
|
51
51
|
)
|
|
52
52
|
from .catalog_delta import (
|
|
53
|
-
|
|
53
|
+
CATALOG_DELTA_SCHEMA_VERSION,
|
|
54
54
|
CatalogDelta,
|
|
55
55
|
CatalogDeltaConflict,
|
|
56
56
|
CatalogDeltaError,
|
|
@@ -85,6 +85,7 @@ from .sources import (
|
|
|
85
85
|
|
|
86
86
|
__all__ = [
|
|
87
87
|
"CatalogDelta",
|
|
88
|
+
"CATALOG_DELTA_SCHEMA_VERSION",
|
|
88
89
|
"CatalogDeltaConflict",
|
|
89
90
|
"CatalogDeltaError",
|
|
90
91
|
"DeltaConflict",
|
|
@@ -93,7 +94,6 @@ __all__ = [
|
|
|
93
94
|
"Extraction",
|
|
94
95
|
"LowRiskPolicy",
|
|
95
96
|
"Observation",
|
|
96
|
-
"PRICING_DELTA_SCHEMA_VERSION",
|
|
97
97
|
"PriceRowKey",
|
|
98
98
|
"PriceSource",
|
|
99
99
|
"RefreshResult",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
"""Pricing deltas: proposed changes to
|
|
1
|
+
"""Pricing deltas: proposed changes to a catalog document.
|
|
2
2
|
|
|
3
3
|
A delta never serves prices. It names each target row by its identity
|
|
4
4
|
``(canonical_id, channel, region, effective_from)``, carries the row's
|
|
5
5
|
expected current state as a precondition, and carries the new
|
|
6
|
-
effective-dated value.
|
|
6
|
+
effective-dated value. The current schema supports price rows on models the
|
|
7
7
|
catalog already has:
|
|
8
8
|
|
|
9
9
|
- ``add_price`` appends a row; ``expect`` is null because no such row may
|
|
@@ -12,6 +12,11 @@ catalog already has:
|
|
|
12
12
|
and appends the new row. History is never rewritten.
|
|
13
13
|
- ``close_price`` closes the open row without a replacement.
|
|
14
14
|
|
|
15
|
+
It also supports reviewed catalog expansion:
|
|
16
|
+
|
|
17
|
+
- ``add_model`` appends one complete model entry.
|
|
18
|
+
- ``add_alias`` appends one alias to an existing model.
|
|
19
|
+
|
|
15
20
|
``provenance`` references the observations and evidence the delta came
|
|
16
21
|
from.
|
|
17
22
|
"""
|
|
@@ -26,9 +31,11 @@ from typing import Any, Mapping
|
|
|
26
31
|
|
|
27
32
|
from .loader import CatalogError, _canonical_json, load_catalog_document
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
CATALOG_DELTA_SCHEMA_VERSION = 2
|
|
30
35
|
PRICING_DELTA_KIND = "pricing_delta"
|
|
31
|
-
|
|
36
|
+
PRICE_OPERATIONS = ("add_price", "replace_price", "close_price")
|
|
37
|
+
CATALOG_OPERATIONS = ("add_model", "add_alias")
|
|
38
|
+
OPERATIONS = PRICE_OPERATIONS + CATALOG_OPERATIONS
|
|
32
39
|
|
|
33
40
|
RATE_FIELDS = (
|
|
34
41
|
"input_per_mtok",
|
|
@@ -58,13 +65,29 @@ class PriceRowKey:
|
|
|
58
65
|
effective_from: str
|
|
59
66
|
|
|
60
67
|
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class ModelKey:
|
|
70
|
+
canonical_id: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class AliasKey:
|
|
75
|
+
canonical_id: str
|
|
76
|
+
provider: str
|
|
77
|
+
alias: str
|
|
78
|
+
channel: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
OperationKey = PriceRowKey | ModelKey | AliasKey
|
|
82
|
+
|
|
83
|
+
|
|
61
84
|
@dataclass(frozen=True, slots=True)
|
|
62
85
|
class DeltaConflict:
|
|
63
86
|
"""One operation whose precondition does not hold against the document."""
|
|
64
87
|
|
|
65
88
|
index: int
|
|
66
89
|
op: str
|
|
67
|
-
key:
|
|
90
|
+
key: OperationKey
|
|
68
91
|
reason: str
|
|
69
92
|
detail: str = ""
|
|
70
93
|
|
|
@@ -75,8 +98,7 @@ class CatalogDeltaConflict(CatalogDeltaError):
|
|
|
75
98
|
def __init__(self, conflicts: tuple[DeltaConflict, ...]) -> None:
|
|
76
99
|
self.conflicts = conflicts
|
|
77
100
|
summary = "; ".join(
|
|
78
|
-
f"operation {c.index} {c.op} {c.key.
|
|
79
|
-
f"{c.key.channel}/{c.key.region}@{c.key.effective_from}: {c.reason}"
|
|
101
|
+
f"operation {c.index} {c.op} {_key_label(c.key)}: {c.reason}"
|
|
80
102
|
+ (f" ({c.detail})" if c.detail else "")
|
|
81
103
|
for c in conflicts
|
|
82
104
|
)
|
|
@@ -87,13 +109,17 @@ class CatalogDeltaConflict(CatalogDeltaError):
|
|
|
87
109
|
class DeltaOperation:
|
|
88
110
|
index: int
|
|
89
111
|
op: str
|
|
90
|
-
key:
|
|
112
|
+
key: OperationKey
|
|
91
113
|
# The exact current row for replace/close; None for add.
|
|
92
114
|
expect: Mapping[str, Any] | None
|
|
93
115
|
# The new row for add/replace; None for close.
|
|
94
116
|
price: Mapping[str, Any] | None
|
|
95
117
|
# The closing date for close_price.
|
|
96
118
|
effective_to: str | None
|
|
119
|
+
# The complete entry for add_model.
|
|
120
|
+
model: Mapping[str, Any] | None = None
|
|
121
|
+
# The alias row for add_alias.
|
|
122
|
+
alias: Mapping[str, Any] | None = None
|
|
97
123
|
|
|
98
124
|
|
|
99
125
|
@dataclass(frozen=True, slots=True)
|
|
@@ -161,6 +187,31 @@ def _parse_key(raw: Any, label: str) -> PriceRowKey:
|
|
|
161
187
|
)
|
|
162
188
|
|
|
163
189
|
|
|
190
|
+
def _parse_model_key(raw: Any, label: str) -> ModelKey:
|
|
191
|
+
if not isinstance(raw, Mapping):
|
|
192
|
+
raise CatalogDeltaError(f"{label}: key must be a mapping")
|
|
193
|
+
if set(raw) != {"canonical_id"} or _text(raw.get("canonical_id")) is None:
|
|
194
|
+
raise CatalogDeltaError(f"{label}: add_model key needs only canonical_id")
|
|
195
|
+
return ModelKey(_text(raw["canonical_id"]))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _parse_alias_key(raw: Any, label: str) -> AliasKey:
|
|
199
|
+
if not isinstance(raw, Mapping):
|
|
200
|
+
raise CatalogDeltaError(f"{label}: key must be a mapping")
|
|
201
|
+
fields = ("canonical_id", "provider", "alias", "channel")
|
|
202
|
+
for field in fields:
|
|
203
|
+
if _text(raw.get(field)) is None:
|
|
204
|
+
raise CatalogDeltaError(f"{label}: key needs {field}")
|
|
205
|
+
if set(raw) != set(fields):
|
|
206
|
+
raise CatalogDeltaError(f"{label}: add_alias key has unknown fields")
|
|
207
|
+
return AliasKey(
|
|
208
|
+
_text(raw["canonical_id"]),
|
|
209
|
+
_text(raw["provider"]).lower(),
|
|
210
|
+
_text(raw["alias"]).lower(),
|
|
211
|
+
_text(raw["channel"]),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
164
215
|
def _decimal_string(value: Any) -> bool:
|
|
165
216
|
if not isinstance(value, str):
|
|
166
217
|
return False
|
|
@@ -206,6 +257,62 @@ def _parse_price(raw: Any, key: PriceRowKey, label: str, *, same_date: bool) ->
|
|
|
206
257
|
return price
|
|
207
258
|
|
|
208
259
|
|
|
260
|
+
def _probe_document(model: Mapping[str, Any]) -> None:
|
|
261
|
+
load_catalog_document(
|
|
262
|
+
{
|
|
263
|
+
"version": "probe",
|
|
264
|
+
"currency": "USD",
|
|
265
|
+
"pricing_verified_at": "2026-10-20",
|
|
266
|
+
"models": [copy.deepcopy(dict(model))],
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _parse_model(raw: Any, key: ModelKey, label: str) -> dict[str, Any]:
|
|
272
|
+
if not isinstance(raw, Mapping):
|
|
273
|
+
raise CatalogDeltaError(f"{label}: invalid model: entry must be a mapping")
|
|
274
|
+
model = copy.deepcopy(dict(raw))
|
|
275
|
+
if _text(model.get("canonical_id")) != key.canonical_id:
|
|
276
|
+
raise CatalogDeltaError(f"{label}: model canonical_id must match key")
|
|
277
|
+
if _text(model.get("publisher")) is None:
|
|
278
|
+
raise CatalogDeltaError(f"{label}: invalid model: entry needs publisher")
|
|
279
|
+
if not isinstance(model.get("aliases"), list) or not model["aliases"]:
|
|
280
|
+
raise CatalogDeltaError(f"{label}: invalid model: entry needs aliases")
|
|
281
|
+
if not isinstance(model.get("prices"), list) or not model["prices"]:
|
|
282
|
+
raise CatalogDeltaError(f"{label}: invalid model: entry needs prices")
|
|
283
|
+
try:
|
|
284
|
+
_probe_document(model)
|
|
285
|
+
except CatalogError as exc:
|
|
286
|
+
raise CatalogDeltaError(f"{label}: invalid model: {exc}") from exc
|
|
287
|
+
return model
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _parse_alias(raw: Any, key: AliasKey, label: str) -> dict[str, Any]:
|
|
291
|
+
if not isinstance(raw, Mapping):
|
|
292
|
+
raise CatalogDeltaError(f"{label}: alias must be a mapping")
|
|
293
|
+
alias = copy.deepcopy(dict(raw))
|
|
294
|
+
for field in ("provider", "alias", "channel"):
|
|
295
|
+
value = _text(alias.get(field))
|
|
296
|
+
if value is None:
|
|
297
|
+
raise CatalogDeltaError(f"{label}: alias needs {field}")
|
|
298
|
+
normalized = value.lower() if field in ("provider", "alias") else value
|
|
299
|
+
if normalized != getattr(key, field):
|
|
300
|
+
raise CatalogDeltaError(f"{label}: alias {field} must match key")
|
|
301
|
+
alias[field] = normalized
|
|
302
|
+
try:
|
|
303
|
+
_probe_document(
|
|
304
|
+
{
|
|
305
|
+
"canonical_id": key.canonical_id,
|
|
306
|
+
"publisher": "probe",
|
|
307
|
+
"aliases": [alias],
|
|
308
|
+
"prices": [],
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
except CatalogError as exc:
|
|
312
|
+
raise CatalogDeltaError(f"{label}: invalid alias: {exc}") from exc
|
|
313
|
+
return alias
|
|
314
|
+
|
|
315
|
+
|
|
209
316
|
def _parse_operation(index: int, raw: Any) -> DeltaOperation:
|
|
210
317
|
label = f"operation {index}"
|
|
211
318
|
if not isinstance(raw, Mapping):
|
|
@@ -213,16 +320,31 @@ def _parse_operation(index: int, raw: Any) -> DeltaOperation:
|
|
|
213
320
|
op = raw.get("op")
|
|
214
321
|
if op not in OPERATIONS:
|
|
215
322
|
raise CatalogDeltaError(f"{label}: unknown op {op!r}")
|
|
216
|
-
|
|
323
|
+
if op == "add_model":
|
|
324
|
+
key: OperationKey = _parse_model_key(raw.get("key"), label)
|
|
325
|
+
elif op == "add_alias":
|
|
326
|
+
key = _parse_alias_key(raw.get("key"), label)
|
|
327
|
+
else:
|
|
328
|
+
key = _parse_key(raw.get("key"), label)
|
|
217
329
|
expect = raw.get("expect")
|
|
218
|
-
if op
|
|
330
|
+
if op in ("add_price", "add_model", "add_alias"):
|
|
219
331
|
if "expect" not in raw or expect is not None:
|
|
220
|
-
raise CatalogDeltaError(f"{label}:
|
|
332
|
+
raise CatalogDeltaError(f"{label}: {op} expect must be null")
|
|
221
333
|
elif not isinstance(expect, Mapping):
|
|
222
334
|
raise CatalogDeltaError(f"{label}: {op} expect must be the full current row")
|
|
223
335
|
price = None
|
|
224
336
|
effective_to = None
|
|
225
|
-
|
|
337
|
+
model = None
|
|
338
|
+
alias = None
|
|
339
|
+
if op == "add_model":
|
|
340
|
+
if "price" in raw or "alias" in raw or "effective_to" in raw:
|
|
341
|
+
raise CatalogDeltaError(f"{label}: add_model takes only model")
|
|
342
|
+
model = _parse_model(raw.get("model"), key, label)
|
|
343
|
+
elif op == "add_alias":
|
|
344
|
+
if "price" in raw or "model" in raw or "effective_to" in raw:
|
|
345
|
+
raise CatalogDeltaError(f"{label}: add_alias takes only alias")
|
|
346
|
+
alias = _parse_alias(raw.get("alias"), key, label)
|
|
347
|
+
elif op == "close_price":
|
|
226
348
|
if "price" in raw:
|
|
227
349
|
raise CatalogDeltaError(f"{label}: close_price takes no price")
|
|
228
350
|
effective_to = _iso_date(raw.get("effective_to"))
|
|
@@ -243,16 +365,18 @@ def _parse_operation(index: int, raw: Any) -> DeltaOperation:
|
|
|
243
365
|
expect=copy.deepcopy(dict(expect)) if expect is not None else None,
|
|
244
366
|
price=price,
|
|
245
367
|
effective_to=effective_to,
|
|
368
|
+
model=model,
|
|
369
|
+
alias=alias,
|
|
246
370
|
)
|
|
247
371
|
|
|
248
372
|
|
|
249
373
|
def parse_catalog_delta(delta_document: Mapping[str, Any]) -> CatalogDelta:
|
|
250
|
-
"""Validate a delta document
|
|
374
|
+
"""Validate a delta document without applying
|
|
251
375
|
it. Raises :class:`CatalogDeltaError` naming the first malformed part."""
|
|
252
376
|
if not isinstance(delta_document, Mapping):
|
|
253
377
|
raise CatalogDeltaError("delta document must be a mapping")
|
|
254
378
|
version = delta_document.get("schema_version")
|
|
255
|
-
if version !=
|
|
379
|
+
if version != CATALOG_DELTA_SCHEMA_VERSION:
|
|
256
380
|
raise CatalogDeltaError(f"unsupported delta schema_version {version}")
|
|
257
381
|
if delta_document.get("kind") != PRICING_DELTA_KIND:
|
|
258
382
|
raise CatalogDeltaError(f"delta kind must be {PRICING_DELTA_KIND}")
|
|
@@ -267,12 +391,14 @@ def parse_catalog_delta(delta_document: Mapping[str, Any]) -> CatalogDelta:
|
|
|
267
391
|
if not isinstance(raw_operations, list):
|
|
268
392
|
raise CatalogDeltaError("delta operations must be a list")
|
|
269
393
|
operations: list[DeltaOperation] = []
|
|
270
|
-
seen: set[
|
|
394
|
+
seen: set[tuple[str, OperationKey]] = set()
|
|
271
395
|
for index, raw in enumerate(raw_operations):
|
|
272
396
|
operation = _parse_operation(index, raw)
|
|
273
|
-
if operation.
|
|
397
|
+
family = "price" if operation.op in PRICE_OPERATIONS else operation.op
|
|
398
|
+
target = (family, operation.key)
|
|
399
|
+
if target in seen:
|
|
274
400
|
raise CatalogDeltaError(f"operation {index}: duplicate target")
|
|
275
|
-
seen.add(
|
|
401
|
+
seen.add(target)
|
|
276
402
|
operations.append(operation)
|
|
277
403
|
return CatalogDelta(
|
|
278
404
|
schema_version=version,
|
|
@@ -322,8 +448,66 @@ def _locate(
|
|
|
322
448
|
return models[0], rows, None
|
|
323
449
|
|
|
324
450
|
|
|
451
|
+
def _model_indexes(document: Mapping[str, Any], canonical_id: str) -> list[int]:
|
|
452
|
+
return [
|
|
453
|
+
index
|
|
454
|
+
for index, entry in enumerate(document.get("models") or [])
|
|
455
|
+
if isinstance(entry, Mapping) and entry.get("canonical_id") == canonical_id
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _aliases(document: Mapping[str, Any]):
|
|
460
|
+
for entry in document.get("models") or []:
|
|
461
|
+
if not isinstance(entry, Mapping):
|
|
462
|
+
continue
|
|
463
|
+
canonical_id = str(entry.get("canonical_id") or "")
|
|
464
|
+
for alias in entry.get("aliases") or []:
|
|
465
|
+
if not isinstance(alias, Mapping):
|
|
466
|
+
continue
|
|
467
|
+
provider = _text(alias.get("provider"))
|
|
468
|
+
name = _text(alias.get("alias"))
|
|
469
|
+
if provider and name:
|
|
470
|
+
yield canonical_id, provider.lower(), name.lower()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _key_label(key: OperationKey) -> str:
|
|
474
|
+
if isinstance(key, PriceRowKey):
|
|
475
|
+
return f"{key.canonical_id} {key.channel}/{key.region}@{key.effective_from}"
|
|
476
|
+
if isinstance(key, AliasKey):
|
|
477
|
+
return f"{key.canonical_id} {key.provider}:{key.alias}@{key.channel}"
|
|
478
|
+
return key.canonical_id
|
|
479
|
+
|
|
480
|
+
|
|
325
481
|
def _precondition(document: Mapping[str, Any], operation: DeltaOperation) -> str | None:
|
|
326
482
|
"""Why ``operation`` does not apply to ``document``, or None when it does."""
|
|
483
|
+
if operation.op == "add_model":
|
|
484
|
+
key = operation.key
|
|
485
|
+
if _model_indexes(document, key.canonical_id):
|
|
486
|
+
return "model_exists"
|
|
487
|
+
known = {(provider, name) for _, provider, name in _aliases(document)}
|
|
488
|
+
for alias in operation.model.get("aliases") or []:
|
|
489
|
+
pair = (
|
|
490
|
+
str(alias.get("provider") or "").strip().lower(),
|
|
491
|
+
str(alias.get("alias") or "").strip().lower(),
|
|
492
|
+
)
|
|
493
|
+
if pair in known:
|
|
494
|
+
return "alias_taken"
|
|
495
|
+
return None
|
|
496
|
+
if operation.op == "add_alias":
|
|
497
|
+
key = operation.key
|
|
498
|
+
models = _model_indexes(document, key.canonical_id)
|
|
499
|
+
if not models:
|
|
500
|
+
return "unknown_model"
|
|
501
|
+
if len(models) > 1:
|
|
502
|
+
return "duplicate_model"
|
|
503
|
+
for canonical_id, provider, name in _aliases(document):
|
|
504
|
+
if (provider, name) == (key.provider, key.alias):
|
|
505
|
+
return (
|
|
506
|
+
"alias_exists"
|
|
507
|
+
if canonical_id == key.canonical_id
|
|
508
|
+
else "alias_conflict"
|
|
509
|
+
)
|
|
510
|
+
return None
|
|
327
511
|
model_index, rows, reason = _locate(document, operation)
|
|
328
512
|
if reason is not None:
|
|
329
513
|
return reason
|
|
@@ -389,6 +573,17 @@ def apply_catalog_delta(
|
|
|
389
573
|
|
|
390
574
|
result = copy.deepcopy(dict(document))
|
|
391
575
|
for operation in delta.operations:
|
|
576
|
+
if operation.op == "add_model":
|
|
577
|
+
result.setdefault("models", []).append(
|
|
578
|
+
copy.deepcopy(dict(operation.model))
|
|
579
|
+
)
|
|
580
|
+
continue
|
|
581
|
+
if operation.op == "add_alias":
|
|
582
|
+
[model_index] = _model_indexes(result, operation.key.canonical_id)
|
|
583
|
+
result["models"][model_index].setdefault("aliases", []).append(
|
|
584
|
+
copy.deepcopy(dict(operation.alias))
|
|
585
|
+
)
|
|
586
|
+
continue
|
|
392
587
|
model_index, rows, _ = _locate(result, operation)
|
|
393
588
|
prices = result["models"][model_index].setdefault("prices", [])
|
|
394
589
|
if operation.op == "add_price":
|