tariffkit 0.2.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.
- tariffkit/__init__.py +52 -0
- tariffkit/account/__init__.py +41 -0
- tariffkit/account/cli.py +493 -0
- tariffkit/account/errors.py +25 -0
- tariffkit/account/model.py +641 -0
- tariffkit/account/rates.py +61 -0
- tariffkit/account/repository.py +329 -0
- tariffkit/billing/__init__.py +56 -0
- tariffkit/billing/engine.py +505 -0
- tariffkit/billing/ledger.py +365 -0
- tariffkit/billing/models.py +274 -0
- tariffkit/billing/netting.py +137 -0
- tariffkit/billing/trueup.py +498 -0
- tariffkit/cca.py +122 -0
- tariffkit/cli.py +809 -0
- tariffkit/config.py +296 -0
- tariffkit/data/__init__.py +40 -0
- tariffkit/data/cca/mce/2023-01-01.toml +61 -0
- tariffkit/data/cca/mce/2026-04-01.toml +152 -0
- tariffkit/data/export/pge/acc_plus/2023-04-15.toml +43 -0
- tariffkit/data/export/pge/nbt00.json.gz +0 -0
- tariffkit/data/export/pge/nbt23.json.gz +0 -0
- tariffkit/data/export/pge/nbt24.json.gz +0 -0
- tariffkit/data/export/pge/nbt25.json.gz +0 -0
- tariffkit/data/export/pge/nbt26.json.gz +0 -0
- tariffkit/data/holidays.toml +36 -0
- tariffkit/data/manifest.json +56 -0
- tariffkit/data/nsc/pge.toml +57 -0
- tariffkit/data/tariff/pge/eelec/2025-01-01.toml +151 -0
- tariffkit/data/tariff/pge/eelec/2025-03-01.toml +150 -0
- tariffkit/data/tariff/pge/eelec/2025-09-01.toml +150 -0
- tariffkit/data/tariff/pge/eelec/2026-01-01.toml +153 -0
- tariffkit/data/tariff/pge/eelec/2026-03-01.toml +157 -0
- tariffkit/data/tariff/pge/etouc/2025-01-01.toml +222 -0
- tariffkit/data/tariff/pge/etouc/2025-03-01.toml +221 -0
- tariffkit/data/tariff/pge/etouc/2025-09-01.toml +221 -0
- tariffkit/data/tariff/pge/etouc/2026-01-01.toml +224 -0
- tariffkit/data/tariff/pge/etouc/2026-03-01.toml +231 -0
- tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +144 -0
- tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +143 -0
- tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +143 -0
- tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +146 -0
- tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +153 -0
- tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +27 -0
- tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +27 -0
- tariffkit/data/versioned.py +118 -0
- tariffkit/engine.py +82 -0
- tariffkit/errors.py +19 -0
- tariffkit/export/__init__.py +5 -0
- tariffkit/export/nbt.py +207 -0
- tariffkit/interop/__init__.py +21 -0
- tariffkit/interop/emhass.py +79 -0
- tariffkit/interop/predbat.py +102 -0
- tariffkit/interop/slots.py +63 -0
- tariffkit/models.py +164 -0
- tariffkit/mqtt/__init__.py +6 -0
- tariffkit/mqtt/discovery.py +84 -0
- tariffkit/mqtt/publisher.py +305 -0
- tariffkit/providers/__init__.py +1 -0
- tariffkit/providers/pge/__init__.py +33 -0
- tariffkit/providers/pge/reconcile.py +828 -0
- tariffkit/providers/pge/statements/__init__.py +26 -0
- tariffkit/providers/pge/statements/errors.py +20 -0
- tariffkit/providers/pge/statements/model.py +320 -0
- tariffkit/providers/pge/statements/ocr.py +193 -0
- tariffkit/providers/pge/statements/parse.py +813 -0
- tariffkit/py.typed +0 -0
- tariffkit/secrets.py +164 -0
- tariffkit/sources/__init__.py +71 -0
- tariffkit/sources/greenbutton.py +318 -0
- tariffkit/sources/homeassistant.py +342 -0
- tariffkit/sources/influx.py +359 -0
- tariffkit/sources/pge.py +1153 -0
- tariffkit/tariff/__init__.py +5 -0
- tariffkit/tariff/retail.py +271 -0
- tariffkit/timeutil.py +120 -0
- tariffkit/web/__init__.py +5 -0
- tariffkit/web/app.py +220 -0
- tariffkit-0.2.0.dist-info/METADATA +260 -0
- tariffkit-0.2.0.dist-info/RECORD +83 -0
- tariffkit-0.2.0.dist-info/WHEEL +4 -0
- tariffkit-0.2.0.dist-info/entry_points.txt +2 -0
- tariffkit-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""Provider-neutral account history and statement evidence models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from bisect import bisect_right
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import date, datetime, timedelta
|
|
12
|
+
from typing import Self
|
|
13
|
+
|
|
14
|
+
from ..billing import BillingPeriod
|
|
15
|
+
from ..billing.engine import Segment
|
|
16
|
+
from ..config import Config
|
|
17
|
+
from ..models import Supplier
|
|
18
|
+
from ..timeutil import to_pacific
|
|
19
|
+
from .errors import AccountError
|
|
20
|
+
|
|
21
|
+
_SCHEMA_VERSION = 1
|
|
22
|
+
SCHEMA_VERSION = _SCHEMA_VERSION
|
|
23
|
+
_DIGEST = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
24
|
+
_SAFE_TEXT = re.compile(r"^[^\x00-\x1f\x7f]+$")
|
|
25
|
+
_MASKED_ACCOUNT = re.compile(r"^\*{4}\d{1,4}$")
|
|
26
|
+
_EXTRACTION_MODE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$")
|
|
27
|
+
_METER_ENTITY = re.compile(r"^[A-Za-z0-9_.]+$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _as_date(value: object, *, field_name: str) -> date:
|
|
31
|
+
if isinstance(value, datetime):
|
|
32
|
+
raise AccountError(f"{field_name} must be a date, not a datetime")
|
|
33
|
+
if isinstance(value, date):
|
|
34
|
+
return value
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
try:
|
|
37
|
+
return date.fromisoformat(value)
|
|
38
|
+
except ValueError as exc:
|
|
39
|
+
raise AccountError(f"{field_name} must be an ISO date") from exc
|
|
40
|
+
raise AccountError(f"{field_name} must be an ISO date")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _text(value: object, *, field_name: str, allow_empty: bool = False) -> str:
|
|
44
|
+
if not isinstance(value, str):
|
|
45
|
+
raise AccountError(f"{field_name} must be a non-empty string")
|
|
46
|
+
if not value:
|
|
47
|
+
if allow_empty:
|
|
48
|
+
return value
|
|
49
|
+
raise AccountError(f"{field_name} must be a non-empty string")
|
|
50
|
+
if not _SAFE_TEXT.fullmatch(value):
|
|
51
|
+
raise AccountError(f"{field_name} contains control characters")
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _digest(value: object, *, field_name: str) -> str:
|
|
56
|
+
if not isinstance(value, str) or _DIGEST.fullmatch(value) is None:
|
|
57
|
+
raise AccountError(f"{field_name} must be a SHA-256 hex digest")
|
|
58
|
+
return value.lower()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def mask_account_digits(value: str) -> str:
|
|
62
|
+
"""Return only a four-digit account suffix with the leading digits masked."""
|
|
63
|
+
if not isinstance(value, str) or not value.isdigit():
|
|
64
|
+
raise AccountError("account digits must contain only decimal digits")
|
|
65
|
+
digits = value
|
|
66
|
+
if not 1 <= len(digits) <= 4:
|
|
67
|
+
raise AccountError("account digits must contain at most four unmasked digits")
|
|
68
|
+
return f"****{digits}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _masked_account(value: object, *, field_name: str) -> str | None:
|
|
72
|
+
if value is None:
|
|
73
|
+
return None
|
|
74
|
+
if not isinstance(value, str) or _MASKED_ACCOUNT.fullmatch(value) is None:
|
|
75
|
+
raise AccountError(f"{field_name} must be masked as **** followed by at most four digits")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate_config_snapshot(config: Config) -> None:
|
|
80
|
+
if not isinstance(config, Config):
|
|
81
|
+
raise AccountError("each account epoch needs a Config snapshot")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _meter_entity(value: object, *, field_name: str) -> str:
|
|
85
|
+
if not isinstance(value, str) or _METER_ENTITY.fullmatch(value) is None:
|
|
86
|
+
raise AccountError(
|
|
87
|
+
f"{field_name} must be a non-empty entity identifier containing only "
|
|
88
|
+
"letters, digits, underscores, and dots"
|
|
89
|
+
)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class MeterSource:
|
|
95
|
+
"""The grid-import and grid-export entities for one meter provider."""
|
|
96
|
+
|
|
97
|
+
grid_import_entity: str
|
|
98
|
+
grid_export_entity: str
|
|
99
|
+
|
|
100
|
+
def __post_init__(self) -> None:
|
|
101
|
+
object.__setattr__(
|
|
102
|
+
self,
|
|
103
|
+
"grid_import_entity",
|
|
104
|
+
_meter_entity(self.grid_import_entity, field_name="grid_import_entity"),
|
|
105
|
+
)
|
|
106
|
+
object.__setattr__(
|
|
107
|
+
self,
|
|
108
|
+
"grid_export_entity",
|
|
109
|
+
_meter_entity(self.grid_export_entity, field_name="grid_export_entity"),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def to_dict(self) -> dict[str, str]:
|
|
113
|
+
return {
|
|
114
|
+
"grid_import_entity": self.grid_import_entity,
|
|
115
|
+
"grid_export_entity": self.grid_export_entity,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_dict(cls, raw: Mapping[str, object]) -> MeterSource:
|
|
120
|
+
_check_keys(raw, {"grid_import_entity", "grid_export_entity"}, "meter source")
|
|
121
|
+
if "grid_import_entity" not in raw or "grid_export_entity" not in raw:
|
|
122
|
+
raise AccountError(
|
|
123
|
+
"a meter source needs both grid_import_entity and grid_export_entity"
|
|
124
|
+
)
|
|
125
|
+
return cls(
|
|
126
|
+
grid_import_entity=_meter_entity(
|
|
127
|
+
raw["grid_import_entity"], field_name="grid_import_entity"
|
|
128
|
+
),
|
|
129
|
+
grid_export_entity=_meter_entity(
|
|
130
|
+
raw["grid_export_entity"], field_name="grid_export_entity"
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class MeterSources:
|
|
137
|
+
"""Optional provider-specific meter mappings shared by all account epochs."""
|
|
138
|
+
|
|
139
|
+
ha: MeterSource | None = None
|
|
140
|
+
influx: MeterSource | None = None
|
|
141
|
+
|
|
142
|
+
def __post_init__(self) -> None:
|
|
143
|
+
for name in ("ha", "influx"):
|
|
144
|
+
value = getattr(self, name)
|
|
145
|
+
if value is not None and not isinstance(value, MeterSource):
|
|
146
|
+
raise AccountError(f"meter_sources.{name} must be a MeterSource")
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def home_assistant(self) -> MeterSource | None:
|
|
150
|
+
return self.ha
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def influxdb(self) -> MeterSource | None:
|
|
154
|
+
return self.influx
|
|
155
|
+
|
|
156
|
+
def to_dict(self) -> dict[str, object]:
|
|
157
|
+
return {
|
|
158
|
+
"ha": self.ha.to_dict() if self.ha is not None else None,
|
|
159
|
+
"influx": self.influx.to_dict() if self.influx is not None else None,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def from_dict(cls, raw: Mapping[str, object]) -> MeterSources:
|
|
164
|
+
_check_keys(raw, {"ha", "influx"}, "meter sources")
|
|
165
|
+
values: dict[str, MeterSource | None] = {}
|
|
166
|
+
for name in ("ha", "influx"):
|
|
167
|
+
value = raw.get(name)
|
|
168
|
+
if value is None:
|
|
169
|
+
values[name] = None
|
|
170
|
+
elif isinstance(value, Mapping):
|
|
171
|
+
values[name] = MeterSource.from_dict(value)
|
|
172
|
+
else:
|
|
173
|
+
raise AccountError(f"meter_sources.{name} must be an object or null")
|
|
174
|
+
return cls(ha=values["ha"], influx=values["influx"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True, slots=True)
|
|
178
|
+
class AccountEpoch:
|
|
179
|
+
"""A complete configuration in force from ``effective`` onward."""
|
|
180
|
+
|
|
181
|
+
effective: date
|
|
182
|
+
config: Config
|
|
183
|
+
note: str = ""
|
|
184
|
+
|
|
185
|
+
def __post_init__(self) -> None:
|
|
186
|
+
object.__setattr__(self, "effective", _as_date(self.effective, field_name="effective"))
|
|
187
|
+
_validate_config_snapshot(self.config)
|
|
188
|
+
if not isinstance(self.note, str) or (
|
|
189
|
+
self.note and _SAFE_TEXT.fullmatch(self.note) is None
|
|
190
|
+
):
|
|
191
|
+
raise AccountError("epoch note contains control characters")
|
|
192
|
+
|
|
193
|
+
def to_dict(self) -> dict[str, object]:
|
|
194
|
+
return {
|
|
195
|
+
"effective": self.effective.isoformat(),
|
|
196
|
+
"config": self.config.to_dict(),
|
|
197
|
+
"note": self.note,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def from_dict(cls, raw: Mapping[str, object]) -> AccountEpoch:
|
|
202
|
+
_check_keys(raw, {"effective", "config", "note"}, "epoch")
|
|
203
|
+
if "config" not in raw or "effective" not in raw:
|
|
204
|
+
raise AccountError("every account epoch needs effective and config")
|
|
205
|
+
config_value = raw["config"]
|
|
206
|
+
if not isinstance(config_value, Mapping):
|
|
207
|
+
raise AccountError("epoch config must be an object")
|
|
208
|
+
note_value = raw.get("note", "")
|
|
209
|
+
if not isinstance(note_value, str):
|
|
210
|
+
raise AccountError("epoch note must be a string")
|
|
211
|
+
return cls(
|
|
212
|
+
effective=_as_date(raw["effective"], field_name="effective"),
|
|
213
|
+
config=Config.from_dict(dict(config_value)),
|
|
214
|
+
note=note_value,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True, slots=True)
|
|
219
|
+
class ObservedAgreement:
|
|
220
|
+
"""Sanitized facts extracted from one service-agreement span."""
|
|
221
|
+
|
|
222
|
+
provider: str
|
|
223
|
+
statement_date: date
|
|
224
|
+
period: BillingPeriod
|
|
225
|
+
tariff: str | None = None
|
|
226
|
+
supplier: Supplier | None = None
|
|
227
|
+
cca_identity: str | None = None
|
|
228
|
+
baseline_territory: str | None = None
|
|
229
|
+
pcia_vintage: int | None = None
|
|
230
|
+
account_suffix: str | None = None
|
|
231
|
+
extraction_mode: str = "unknown"
|
|
232
|
+
source_digest: str | None = None
|
|
233
|
+
|
|
234
|
+
def __post_init__(self) -> None:
|
|
235
|
+
object.__setattr__(self, "provider", _text(self.provider, field_name="provider"))
|
|
236
|
+
object.__setattr__(
|
|
237
|
+
self,
|
|
238
|
+
"statement_date",
|
|
239
|
+
_as_date(self.statement_date, field_name="statement_date"),
|
|
240
|
+
)
|
|
241
|
+
if not isinstance(self.period, BillingPeriod):
|
|
242
|
+
raise AccountError("agreement period must be a BillingPeriod")
|
|
243
|
+
if self.tariff is not None:
|
|
244
|
+
object.__setattr__(self, "tariff", _text(self.tariff, field_name="tariff"))
|
|
245
|
+
if self.supplier is not None:
|
|
246
|
+
try:
|
|
247
|
+
object.__setattr__(self, "supplier", Supplier(self.supplier))
|
|
248
|
+
except ValueError as exc:
|
|
249
|
+
raise AccountError("agreement supplier is not supported") from exc
|
|
250
|
+
if self.cca_identity is not None:
|
|
251
|
+
object.__setattr__(
|
|
252
|
+
self, "cca_identity", _text(self.cca_identity, field_name="cca_identity")
|
|
253
|
+
)
|
|
254
|
+
if self.baseline_territory is not None:
|
|
255
|
+
object.__setattr__(
|
|
256
|
+
self,
|
|
257
|
+
"baseline_territory",
|
|
258
|
+
_text(self.baseline_territory, field_name="baseline_territory"),
|
|
259
|
+
)
|
|
260
|
+
if self.pcia_vintage is not None and (
|
|
261
|
+
not isinstance(self.pcia_vintage, int) or isinstance(self.pcia_vintage, bool)
|
|
262
|
+
):
|
|
263
|
+
raise AccountError("pcia_vintage must be an integer")
|
|
264
|
+
object.__setattr__(
|
|
265
|
+
self,
|
|
266
|
+
"account_suffix",
|
|
267
|
+
_masked_account(self.account_suffix, field_name="account_suffix"),
|
|
268
|
+
)
|
|
269
|
+
if (
|
|
270
|
+
not isinstance(self.extraction_mode, str)
|
|
271
|
+
or _EXTRACTION_MODE.fullmatch(self.extraction_mode) is None
|
|
272
|
+
):
|
|
273
|
+
raise AccountError(f"unsupported extraction_mode {self.extraction_mode!r}")
|
|
274
|
+
if self.source_digest is not None:
|
|
275
|
+
object.__setattr__(
|
|
276
|
+
self,
|
|
277
|
+
"source_digest",
|
|
278
|
+
_digest(self.source_digest, field_name="source_digest"),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def to_dict(self) -> dict[str, object]:
|
|
282
|
+
return {
|
|
283
|
+
"provider": self.provider,
|
|
284
|
+
"statement_date": self.statement_date.isoformat(),
|
|
285
|
+
"period": self.period.to_dict(),
|
|
286
|
+
"tariff": self.tariff,
|
|
287
|
+
"supplier": self.supplier.value if self.supplier is not None else None,
|
|
288
|
+
"cca_identity": self.cca_identity,
|
|
289
|
+
"baseline_territory": self.baseline_territory,
|
|
290
|
+
"pcia_vintage": self.pcia_vintage,
|
|
291
|
+
"account_suffix": self.account_suffix,
|
|
292
|
+
"extraction_mode": self.extraction_mode,
|
|
293
|
+
"source_digest": self.source_digest,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
@classmethod
|
|
297
|
+
def from_dict(cls, raw: Mapping[str, object]) -> ObservedAgreement:
|
|
298
|
+
_check_keys(
|
|
299
|
+
raw,
|
|
300
|
+
{
|
|
301
|
+
"provider",
|
|
302
|
+
"statement_date",
|
|
303
|
+
"period",
|
|
304
|
+
"tariff",
|
|
305
|
+
"supplier",
|
|
306
|
+
"cca_identity",
|
|
307
|
+
"baseline_territory",
|
|
308
|
+
"pcia_vintage",
|
|
309
|
+
"account_suffix",
|
|
310
|
+
"extraction_mode",
|
|
311
|
+
"source_digest",
|
|
312
|
+
},
|
|
313
|
+
"observed agreement",
|
|
314
|
+
)
|
|
315
|
+
period_value = raw.get("period")
|
|
316
|
+
if not isinstance(period_value, Mapping):
|
|
317
|
+
raise AccountError("agreement period must be an object")
|
|
318
|
+
supplier_value = raw.get("supplier")
|
|
319
|
+
supplier = None if supplier_value is None else Supplier(str(supplier_value))
|
|
320
|
+
pcia_value = raw.get("pcia_vintage")
|
|
321
|
+
if pcia_value is not None and (
|
|
322
|
+
not isinstance(pcia_value, int) or isinstance(pcia_value, bool)
|
|
323
|
+
):
|
|
324
|
+
raise AccountError("pcia_vintage must be an integer")
|
|
325
|
+
return cls(
|
|
326
|
+
provider=_text(raw.get("provider"), field_name="provider"),
|
|
327
|
+
statement_date=_as_date(raw.get("statement_date"), field_name="statement_date"),
|
|
328
|
+
period=BillingPeriod(
|
|
329
|
+
_as_date(period_value.get("start"), field_name="period.start"),
|
|
330
|
+
_as_date(period_value.get("end"), field_name="period.end"),
|
|
331
|
+
),
|
|
332
|
+
tariff=None if raw.get("tariff") is None else str(raw["tariff"]),
|
|
333
|
+
supplier=supplier,
|
|
334
|
+
cca_identity=(None if raw.get("cca_identity") is None else str(raw["cca_identity"])),
|
|
335
|
+
baseline_territory=(
|
|
336
|
+
None if raw.get("baseline_territory") is None else str(raw["baseline_territory"])
|
|
337
|
+
),
|
|
338
|
+
pcia_vintage=pcia_value,
|
|
339
|
+
account_suffix=(
|
|
340
|
+
None if raw.get("account_suffix") is None else str(raw["account_suffix"])
|
|
341
|
+
),
|
|
342
|
+
extraction_mode=str(raw.get("extraction_mode", "unknown")),
|
|
343
|
+
source_digest=None if raw.get("source_digest") is None else str(raw["source_digest"]),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass(frozen=True, slots=True)
|
|
348
|
+
class AccountObservation:
|
|
349
|
+
"""Evidence kept separately from authoritative account epochs."""
|
|
350
|
+
|
|
351
|
+
agreements: tuple[ObservedAgreement, ...] = ()
|
|
352
|
+
source_digest: str | None = None
|
|
353
|
+
observed_at: date | None = None
|
|
354
|
+
|
|
355
|
+
def __post_init__(self) -> None:
|
|
356
|
+
agreements = tuple(self.agreements)
|
|
357
|
+
if not agreements:
|
|
358
|
+
raise AccountError("an account observation needs at least one agreement")
|
|
359
|
+
if any(not isinstance(agreement, ObservedAgreement) for agreement in agreements):
|
|
360
|
+
raise AccountError("observation agreements must be ObservedAgreement values")
|
|
361
|
+
object.__setattr__(self, "agreements", agreements)
|
|
362
|
+
if self.source_digest is not None:
|
|
363
|
+
object.__setattr__(
|
|
364
|
+
self,
|
|
365
|
+
"source_digest",
|
|
366
|
+
_digest(self.source_digest, field_name="source_digest"),
|
|
367
|
+
)
|
|
368
|
+
if self.observed_at is not None:
|
|
369
|
+
object.__setattr__(
|
|
370
|
+
self, "observed_at", _as_date(self.observed_at, field_name="observed_at")
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def identity(self) -> tuple[str, ...]:
|
|
374
|
+
"""Stable identifiers used to make importing the same evidence idempotent."""
|
|
375
|
+
digests = tuple(
|
|
376
|
+
agreement.source_digest
|
|
377
|
+
for agreement in self.agreements
|
|
378
|
+
if agreement.source_digest is not None
|
|
379
|
+
)
|
|
380
|
+
if self.source_digest is not None:
|
|
381
|
+
return (self.source_digest,)
|
|
382
|
+
if digests:
|
|
383
|
+
return tuple(sorted(digests))
|
|
384
|
+
canonical = json.dumps(
|
|
385
|
+
[agreement.to_dict() for agreement in self.agreements],
|
|
386
|
+
sort_keys=True,
|
|
387
|
+
separators=(",", ":"),
|
|
388
|
+
).encode("utf-8")
|
|
389
|
+
return (f"facts:{hashlib.sha256(canonical).hexdigest()}",)
|
|
390
|
+
|
|
391
|
+
def to_dict(self) -> dict[str, object]:
|
|
392
|
+
return {
|
|
393
|
+
"agreements": [agreement.to_dict() for agreement in self.agreements],
|
|
394
|
+
"source_digest": self.source_digest,
|
|
395
|
+
"observed_at": self.observed_at.isoformat() if self.observed_at else None,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
@classmethod
|
|
399
|
+
def from_dict(cls, raw: Mapping[str, object]) -> AccountObservation:
|
|
400
|
+
_check_keys(raw, {"agreements", "source_digest", "observed_at"}, "observation")
|
|
401
|
+
agreements_value = raw.get("agreements")
|
|
402
|
+
if not isinstance(agreements_value, list):
|
|
403
|
+
raise AccountError("observation agreements must be an array")
|
|
404
|
+
agreements = tuple(
|
|
405
|
+
ObservedAgreement.from_dict(value)
|
|
406
|
+
for value in agreements_value
|
|
407
|
+
if isinstance(value, Mapping)
|
|
408
|
+
)
|
|
409
|
+
if len(agreements) != len(agreements_value):
|
|
410
|
+
raise AccountError("observation agreements must contain objects")
|
|
411
|
+
return cls(
|
|
412
|
+
agreements=agreements,
|
|
413
|
+
source_digest=(None if raw.get("source_digest") is None else str(raw["source_digest"])),
|
|
414
|
+
observed_at=(
|
|
415
|
+
None
|
|
416
|
+
if raw.get("observed_at") is None
|
|
417
|
+
else _as_date(raw["observed_at"], field_name="observed_at")
|
|
418
|
+
),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@dataclass(frozen=True, slots=True)
|
|
423
|
+
class AccountProfile:
|
|
424
|
+
"""A named account's complete, effective-dated configuration history."""
|
|
425
|
+
|
|
426
|
+
epochs: tuple[AccountEpoch, ...]
|
|
427
|
+
name: str = ""
|
|
428
|
+
credential_set: str | None = None
|
|
429
|
+
observations: tuple[AccountObservation, ...] = ()
|
|
430
|
+
_revision: str | None = field(default=None, repr=False, compare=False)
|
|
431
|
+
meter_sources: MeterSources = field(default_factory=MeterSources)
|
|
432
|
+
|
|
433
|
+
def __post_init__(self) -> None:
|
|
434
|
+
epochs = tuple(self.epochs)
|
|
435
|
+
if not epochs:
|
|
436
|
+
raise AccountError("an account profile needs at least one epoch")
|
|
437
|
+
if any(not isinstance(epoch, AccountEpoch) for epoch in epochs):
|
|
438
|
+
raise AccountError("profile epochs must be AccountEpoch values")
|
|
439
|
+
dates = tuple(epoch.effective for epoch in epochs)
|
|
440
|
+
if dates != tuple(sorted(dates)):
|
|
441
|
+
raise AccountError("account epoch effective dates must be sorted")
|
|
442
|
+
if len(set(dates)) != len(dates):
|
|
443
|
+
raise AccountError("account epoch effective dates must be unique")
|
|
444
|
+
object.__setattr__(self, "epochs", epochs)
|
|
445
|
+
if self.name:
|
|
446
|
+
self._validate_slug(self.name)
|
|
447
|
+
if self.credential_set is not None:
|
|
448
|
+
self._validate_credential_set(self.credential_set)
|
|
449
|
+
observations = tuple(self.observations)
|
|
450
|
+
if any(not isinstance(observation, AccountObservation) for observation in observations):
|
|
451
|
+
raise AccountError("profile observations must be AccountObservation values")
|
|
452
|
+
unique: list[AccountObservation] = []
|
|
453
|
+
seen: dict[tuple[str, ...], AccountObservation] = {}
|
|
454
|
+
for observation in observations:
|
|
455
|
+
identity = observation.identity()
|
|
456
|
+
if identity:
|
|
457
|
+
previous = seen.get(identity)
|
|
458
|
+
if previous is not None:
|
|
459
|
+
if (
|
|
460
|
+
previous.source_digest != observation.source_digest
|
|
461
|
+
or previous.agreements != observation.agreements
|
|
462
|
+
):
|
|
463
|
+
raise AccountError(
|
|
464
|
+
"profile observations with the same source identity conflict"
|
|
465
|
+
)
|
|
466
|
+
continue
|
|
467
|
+
seen[identity] = observation
|
|
468
|
+
unique.append(observation)
|
|
469
|
+
object.__setattr__(self, "observations", tuple(unique))
|
|
470
|
+
if not isinstance(self.meter_sources, MeterSources):
|
|
471
|
+
raise AccountError("profile meter_sources must be MeterSources")
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def _validate_slug(value: str) -> None:
|
|
475
|
+
if (
|
|
476
|
+
not isinstance(value, str)
|
|
477
|
+
or len(value) > 64
|
|
478
|
+
or re.fullmatch(r"[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?", value) is None
|
|
479
|
+
):
|
|
480
|
+
raise AccountError("profile name must be a lowercase slug")
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def _validate_credential_set(value: str) -> None:
|
|
484
|
+
if (
|
|
485
|
+
not isinstance(value, str)
|
|
486
|
+
or len(value) > 64
|
|
487
|
+
or re.fullmatch(r"[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?", value) is None
|
|
488
|
+
):
|
|
489
|
+
raise AccountError("credential_set must be a safe name")
|
|
490
|
+
|
|
491
|
+
@property
|
|
492
|
+
def effective_dates(self) -> tuple[date, ...]:
|
|
493
|
+
return tuple(epoch.effective for epoch in self.epochs)
|
|
494
|
+
|
|
495
|
+
@property
|
|
496
|
+
def revision(self) -> str | None:
|
|
497
|
+
"""Storage assigns revisions; in-memory profiles do not have one."""
|
|
498
|
+
return self._revision
|
|
499
|
+
|
|
500
|
+
def config_at(self, moment: date | datetime) -> Config:
|
|
501
|
+
"""Resolve the complete snapshot in force on a local Pacific date."""
|
|
502
|
+
if isinstance(moment, datetime):
|
|
503
|
+
target = to_pacific(moment).date()
|
|
504
|
+
else:
|
|
505
|
+
target = _as_date(moment, field_name="date")
|
|
506
|
+
index = bisect_right(self.effective_dates, target) - 1
|
|
507
|
+
if index < 0:
|
|
508
|
+
raise AccountError(
|
|
509
|
+
f"date {target} is before the first account epoch ({self.epochs[0].effective})"
|
|
510
|
+
)
|
|
511
|
+
return self.epochs[index].config
|
|
512
|
+
|
|
513
|
+
def epochs_in(self, period: BillingPeriod) -> tuple[AccountEpoch, ...]:
|
|
514
|
+
"""Return epochs active at any point in an inclusive billing period."""
|
|
515
|
+
if not isinstance(period, BillingPeriod):
|
|
516
|
+
raise AccountError("period must be a BillingPeriod")
|
|
517
|
+
index = bisect_right(self.effective_dates, period.start) - 1
|
|
518
|
+
if index < 0:
|
|
519
|
+
raise AccountError(
|
|
520
|
+
f"period {period.start}..{period.end} is before the first account epoch "
|
|
521
|
+
f"({self.epochs[0].effective})"
|
|
522
|
+
)
|
|
523
|
+
return self.epochs[index : bisect_right(self.effective_dates, period.end)]
|
|
524
|
+
|
|
525
|
+
def segments_for(self, period: BillingPeriod) -> list[Segment]:
|
|
526
|
+
"""Tile a billing period into segments priced by complete snapshots."""
|
|
527
|
+
applicable = self.epochs_in(period)
|
|
528
|
+
segments: list[Segment] = []
|
|
529
|
+
for index, epoch in enumerate(applicable):
|
|
530
|
+
start = max(period.start, epoch.effective)
|
|
531
|
+
next_start = (
|
|
532
|
+
applicable[index + 1].effective
|
|
533
|
+
if index + 1 < len(applicable)
|
|
534
|
+
else period.end + timedelta(days=1)
|
|
535
|
+
)
|
|
536
|
+
end = min(period.end, next_start - timedelta(days=1))
|
|
537
|
+
segments.append(Segment(epoch.config, BillingPeriod(start, end)))
|
|
538
|
+
return segments
|
|
539
|
+
|
|
540
|
+
def with_observation(self, observation: AccountObservation) -> Self:
|
|
541
|
+
"""Add evidence without changing any authoritative epoch."""
|
|
542
|
+
if not isinstance(observation, AccountObservation):
|
|
543
|
+
raise AccountError("observation must be an AccountObservation")
|
|
544
|
+
return type(self)(
|
|
545
|
+
epochs=self.epochs,
|
|
546
|
+
name=self.name,
|
|
547
|
+
credential_set=self.credential_set,
|
|
548
|
+
observations=(*self.observations, observation),
|
|
549
|
+
_revision=self._revision,
|
|
550
|
+
meter_sources=self.meter_sources,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
def to_dict(self) -> dict[str, object]:
|
|
554
|
+
"""Return the complete versioned, JSON-compatible managed representation."""
|
|
555
|
+
return {
|
|
556
|
+
"schema_version": _SCHEMA_VERSION,
|
|
557
|
+
"name": self.name or None,
|
|
558
|
+
"credential_set": self.credential_set,
|
|
559
|
+
"epochs": [epoch.to_dict() for epoch in self.epochs],
|
|
560
|
+
"observations": [observation.to_dict() for observation in self.observations],
|
|
561
|
+
"meter_sources": self.meter_sources.to_dict(),
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
def to_json(self) -> str:
|
|
565
|
+
"""Return the canonical versioned JSON form used by the repository."""
|
|
566
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
567
|
+
|
|
568
|
+
@classmethod
|
|
569
|
+
def from_dict(cls, raw: Mapping[str, object]) -> AccountProfile:
|
|
570
|
+
_check_keys(
|
|
571
|
+
raw,
|
|
572
|
+
{
|
|
573
|
+
"schema_version",
|
|
574
|
+
"name",
|
|
575
|
+
"credential_set",
|
|
576
|
+
"epochs",
|
|
577
|
+
"observations",
|
|
578
|
+
"meter_sources",
|
|
579
|
+
},
|
|
580
|
+
"profile",
|
|
581
|
+
)
|
|
582
|
+
version = raw.get("schema_version")
|
|
583
|
+
if not isinstance(version, int) or isinstance(version, bool) or version != _SCHEMA_VERSION:
|
|
584
|
+
raise AccountError(f"unsupported account profile schema_version {version!r}")
|
|
585
|
+
epochs_value = raw.get("epochs")
|
|
586
|
+
if not isinstance(epochs_value, list):
|
|
587
|
+
raise AccountError("profile epochs must be an array")
|
|
588
|
+
epochs = tuple(
|
|
589
|
+
AccountEpoch.from_dict(value) for value in epochs_value if isinstance(value, Mapping)
|
|
590
|
+
)
|
|
591
|
+
if len(epochs) != len(epochs_value):
|
|
592
|
+
raise AccountError("profile epochs must contain objects")
|
|
593
|
+
observations_value = raw.get("observations", [])
|
|
594
|
+
if not isinstance(observations_value, list):
|
|
595
|
+
raise AccountError("profile observations must be an array")
|
|
596
|
+
observations = tuple(
|
|
597
|
+
AccountObservation.from_dict(value)
|
|
598
|
+
for value in observations_value
|
|
599
|
+
if isinstance(value, Mapping)
|
|
600
|
+
)
|
|
601
|
+
if len(observations) != len(observations_value):
|
|
602
|
+
raise AccountError("profile observations must contain objects")
|
|
603
|
+
name = raw.get("name") or ""
|
|
604
|
+
credential_set = raw.get("credential_set")
|
|
605
|
+
if credential_set is not None and not isinstance(credential_set, str):
|
|
606
|
+
raise AccountError("credential_set must be a string")
|
|
607
|
+
if "meter_sources" not in raw:
|
|
608
|
+
meter_sources = MeterSources()
|
|
609
|
+
elif isinstance(meter_sources_value := raw["meter_sources"], Mapping):
|
|
610
|
+
meter_sources = MeterSources.from_dict(meter_sources_value)
|
|
611
|
+
else:
|
|
612
|
+
raise AccountError("profile meter_sources must be an object")
|
|
613
|
+
return cls(
|
|
614
|
+
epochs=epochs,
|
|
615
|
+
name=_text(name, field_name="name", allow_empty=True),
|
|
616
|
+
credential_set=credential_set,
|
|
617
|
+
observations=observations,
|
|
618
|
+
meter_sources=meter_sources,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
@classmethod
|
|
622
|
+
def from_json(cls, raw: str) -> AccountProfile:
|
|
623
|
+
"""Parse a versioned JSON profile without accepting non-finite values."""
|
|
624
|
+
try:
|
|
625
|
+
value = json.loads(
|
|
626
|
+
raw,
|
|
627
|
+
parse_constant=lambda token: (_ for _ in ()).throw(
|
|
628
|
+
ValueError(f"non-finite JSON value {token}")
|
|
629
|
+
),
|
|
630
|
+
)
|
|
631
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
632
|
+
raise AccountError("profile JSON is invalid") from exc
|
|
633
|
+
if not isinstance(value, Mapping):
|
|
634
|
+
raise AccountError("profile JSON must contain an object")
|
|
635
|
+
return cls.from_dict(value)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _check_keys(raw: Mapping[str, object], allowed: set[str], label: str) -> None:
|
|
639
|
+
unknown = set(raw) - allowed
|
|
640
|
+
if unknown:
|
|
641
|
+
raise AccountError(f"unknown {label} keys: {sorted(unknown)}")
|