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
tariffkit/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Real-time and forecast electricity import/export prices for PG&E E-ELEC under NEM 3.0.
|
|
2
|
+
|
|
3
|
+
>>> from tariffkit import RateEngine
|
|
4
|
+
>>> engine = RateEngine()
|
|
5
|
+
>>> point = engine.price_now()
|
|
6
|
+
>>> point.import_price.total, point.export_price.total # doctest: +SKIP
|
|
7
|
+
(0.33358, 0.07035)
|
|
8
|
+
|
|
9
|
+
Both sides come from published static tables, so lookups need no network access
|
|
10
|
+
and ``forecast`` reads ahead in a real schedule rather than predicting one.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from importlib.metadata import version
|
|
16
|
+
|
|
17
|
+
from .config import CcaConfig, Config
|
|
18
|
+
from .engine import RateEngine
|
|
19
|
+
from .errors import ConfigError, DataError, OutOfRangeError, TariffKitError
|
|
20
|
+
from .models import (
|
|
21
|
+
ExportPrice,
|
|
22
|
+
ImportPrice,
|
|
23
|
+
PriceCurve,
|
|
24
|
+
PricePoint,
|
|
25
|
+
Season,
|
|
26
|
+
Supplier,
|
|
27
|
+
TouPeriod,
|
|
28
|
+
)
|
|
29
|
+
from .timeutil import PACIFIC, DayType, now_pacific
|
|
30
|
+
|
|
31
|
+
__version__ = version("tariffkit")
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"PACIFIC",
|
|
35
|
+
"CcaConfig",
|
|
36
|
+
"Config",
|
|
37
|
+
"ConfigError",
|
|
38
|
+
"DataError",
|
|
39
|
+
"DayType",
|
|
40
|
+
"ExportPrice",
|
|
41
|
+
"ImportPrice",
|
|
42
|
+
"OutOfRangeError",
|
|
43
|
+
"PriceCurve",
|
|
44
|
+
"PricePoint",
|
|
45
|
+
"RateEngine",
|
|
46
|
+
"Season",
|
|
47
|
+
"Supplier",
|
|
48
|
+
"TariffKitError",
|
|
49
|
+
"TouPeriod",
|
|
50
|
+
"__version__",
|
|
51
|
+
"now_pacific",
|
|
52
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Public account profiles and secure named-profile persistence."""
|
|
2
|
+
|
|
3
|
+
from .errors import (
|
|
4
|
+
AccountError,
|
|
5
|
+
ProfileConflictError,
|
|
6
|
+
ProfileNameError,
|
|
7
|
+
ProfileNotFoundError,
|
|
8
|
+
ProfileStorageError,
|
|
9
|
+
)
|
|
10
|
+
from .model import (
|
|
11
|
+
SCHEMA_VERSION,
|
|
12
|
+
AccountEpoch,
|
|
13
|
+
AccountObservation,
|
|
14
|
+
AccountProfile,
|
|
15
|
+
MeterSource,
|
|
16
|
+
MeterSources,
|
|
17
|
+
ObservedAgreement,
|
|
18
|
+
mask_account_digits,
|
|
19
|
+
)
|
|
20
|
+
from .rates import AccountRateEngine
|
|
21
|
+
from .repository import NamedProfileRepository, configured_profile_name, validate_profile_name
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"SCHEMA_VERSION",
|
|
25
|
+
"AccountEpoch",
|
|
26
|
+
"AccountError",
|
|
27
|
+
"AccountObservation",
|
|
28
|
+
"AccountProfile",
|
|
29
|
+
"AccountRateEngine",
|
|
30
|
+
"MeterSource",
|
|
31
|
+
"MeterSources",
|
|
32
|
+
"NamedProfileRepository",
|
|
33
|
+
"ObservedAgreement",
|
|
34
|
+
"ProfileConflictError",
|
|
35
|
+
"ProfileNameError",
|
|
36
|
+
"ProfileNotFoundError",
|
|
37
|
+
"ProfileStorageError",
|
|
38
|
+
"configured_profile_name",
|
|
39
|
+
"mask_account_digits",
|
|
40
|
+
"validate_profile_name",
|
|
41
|
+
]
|
tariffkit/account/cli.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Account-profile operations used by the command line interface.
|
|
2
|
+
|
|
3
|
+
The account model and repository deliberately know nothing about argparse or
|
|
4
|
+
PG&E. This module is the narrow CLI boundary: it performs migrations,
|
|
5
|
+
statement reconciliation, and portal synchronization while keeping all
|
|
6
|
+
persisted and printed values sanitized by the public account model.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
from datetime import date
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
from uuid import uuid4
|
|
21
|
+
|
|
22
|
+
from ..config import Config
|
|
23
|
+
from ..errors import ConfigError
|
|
24
|
+
from ..secrets import get_named_secret
|
|
25
|
+
from .errors import AccountError
|
|
26
|
+
from .model import AccountEpoch, AccountObservation, AccountProfile, MeterSource, MeterSources
|
|
27
|
+
from .repository import NamedProfileRepository, validate_profile_name
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_config_json(path: Path) -> Config:
|
|
31
|
+
"""Read a complete ``Config`` snapshot from JSON, without accepting secrets."""
|
|
32
|
+
try:
|
|
33
|
+
raw = sys.stdin.read() if str(path) == "-" else path.read_text(encoding="utf-8")
|
|
34
|
+
value = json.loads(raw)
|
|
35
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
36
|
+
raise ConfigError(f"could not read config JSON from {path}") from exc
|
|
37
|
+
if isinstance(value, Mapping) and "config" in value:
|
|
38
|
+
value = value["config"]
|
|
39
|
+
if not isinstance(value, Mapping):
|
|
40
|
+
raise ConfigError("config JSON must contain an object")
|
|
41
|
+
try:
|
|
42
|
+
return Config.from_dict(dict(value))
|
|
43
|
+
except (ConfigError, TypeError, ValueError) as exc:
|
|
44
|
+
raise ConfigError(f"invalid config JSON: {exc}") from exc
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _config_from_audit(path: Path, *, name: str, credential_set: str | None) -> AccountProfile:
|
|
48
|
+
"""Convert the repository's legacy ``audit/account.toml`` representation."""
|
|
49
|
+
import tomllib
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
table = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
53
|
+
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as exc:
|
|
54
|
+
raise AccountError(f"could not read legacy account history {path}") from exc
|
|
55
|
+
base_value = table.get("base", {})
|
|
56
|
+
if not isinstance(base_value, Mapping):
|
|
57
|
+
raise AccountError("legacy account history [base] must be a table")
|
|
58
|
+
try:
|
|
59
|
+
base = Config.from_dict(dict(base_value))
|
|
60
|
+
except (ConfigError, TypeError, ValueError) as exc:
|
|
61
|
+
raise AccountError(f"legacy account history has an invalid base config: {exc}") from exc
|
|
62
|
+
|
|
63
|
+
entries = table.get("epoch", [])
|
|
64
|
+
if not isinstance(entries, list):
|
|
65
|
+
raise AccountError("legacy account history [[epoch]] must be an array")
|
|
66
|
+
epochs: list[AccountEpoch] = []
|
|
67
|
+
for entry in entries:
|
|
68
|
+
if not isinstance(entry, Mapping):
|
|
69
|
+
raise AccountError("legacy account history epochs must be tables")
|
|
70
|
+
raw = dict(entry)
|
|
71
|
+
start = raw.pop("from", None)
|
|
72
|
+
if start is None:
|
|
73
|
+
raise AccountError("legacy account history epochs need a 'from' date")
|
|
74
|
+
try:
|
|
75
|
+
effective = start if isinstance(start, date) else date.fromisoformat(str(start))
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
raise AccountError("legacy account history epoch dates must be ISO dates") from exc
|
|
78
|
+
note = raw.pop("note", "")
|
|
79
|
+
if not isinstance(note, str):
|
|
80
|
+
raise AccountError("legacy account history epoch notes must be strings")
|
|
81
|
+
merged = base.to_dict()
|
|
82
|
+
merged.update(raw)
|
|
83
|
+
try:
|
|
84
|
+
config = Config.from_dict(merged)
|
|
85
|
+
except (ConfigError, TypeError, ValueError) as exc:
|
|
86
|
+
raise AccountError(
|
|
87
|
+
f"legacy account history epoch {effective} has an invalid config: {exc}"
|
|
88
|
+
) from exc
|
|
89
|
+
epochs.append(AccountEpoch(effective, config, note))
|
|
90
|
+
|
|
91
|
+
if not epochs:
|
|
92
|
+
raise AccountError("legacy account history has no epochs to migrate")
|
|
93
|
+
return AccountProfile(
|
|
94
|
+
tuple(sorted(epochs, key=lambda epoch: epoch.effective)),
|
|
95
|
+
name=name,
|
|
96
|
+
credential_set=credential_set,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def migrate_existing(
|
|
101
|
+
name: str,
|
|
102
|
+
*,
|
|
103
|
+
config_path: str | Path | None = None,
|
|
104
|
+
audit_path: str | Path | None = None,
|
|
105
|
+
effective: date | None = None,
|
|
106
|
+
credential_set: str | None = None,
|
|
107
|
+
) -> AccountProfile:
|
|
108
|
+
"""Build a named profile from an explicit legacy file or the current Config.
|
|
109
|
+
|
|
110
|
+
An explicit config path wins over an explicit audit path. Requiring the
|
|
111
|
+
legacy path avoids silently reading developer-only repository state.
|
|
112
|
+
"""
|
|
113
|
+
validate_profile_name(name)
|
|
114
|
+
if config_path is None and audit_path is not None:
|
|
115
|
+
candidate = Path(audit_path)
|
|
116
|
+
if not candidate.is_file():
|
|
117
|
+
raise ConfigError(f"legacy audit account file not found: {candidate}")
|
|
118
|
+
return _config_from_audit(candidate, name=name, credential_set=credential_set)
|
|
119
|
+
|
|
120
|
+
config = Config.load(config_path)
|
|
121
|
+
return AccountProfile(
|
|
122
|
+
(
|
|
123
|
+
AccountEpoch(
|
|
124
|
+
effective or date.today(),
|
|
125
|
+
config,
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
name=name,
|
|
129
|
+
credential_set=credential_set,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def init_profile(
|
|
134
|
+
repository: NamedProfileRepository,
|
|
135
|
+
name: str,
|
|
136
|
+
*,
|
|
137
|
+
config_path: str | Path | None = None,
|
|
138
|
+
config_json: Path | None = None,
|
|
139
|
+
effective: date | None = None,
|
|
140
|
+
credential_set: str | None = None,
|
|
141
|
+
audit_path: str | Path | None = None,
|
|
142
|
+
) -> AccountProfile:
|
|
143
|
+
"""Create a profile from explicit inputs or the resolved public configuration."""
|
|
144
|
+
validate_profile_name(name)
|
|
145
|
+
if name in repository.names():
|
|
146
|
+
raise ConfigError(f"profile {name!r} already exists")
|
|
147
|
+
if config_json is not None and config_path is not None:
|
|
148
|
+
raise ConfigError("choose either --config or --config-json")
|
|
149
|
+
if config_json is not None:
|
|
150
|
+
config = read_config_json(config_json)
|
|
151
|
+
profile = AccountProfile(
|
|
152
|
+
(AccountEpoch(effective or date.today(), config),),
|
|
153
|
+
name=name,
|
|
154
|
+
credential_set=credential_set,
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
profile = migrate_existing(
|
|
158
|
+
name,
|
|
159
|
+
config_path=config_path,
|
|
160
|
+
audit_path=audit_path,
|
|
161
|
+
effective=effective,
|
|
162
|
+
credential_set=credential_set,
|
|
163
|
+
)
|
|
164
|
+
return repository.save(name, profile)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def config_changes(args: Any) -> dict[str, object]:
|
|
168
|
+
changes: dict[str, object] = {}
|
|
169
|
+
for option, field in (
|
|
170
|
+
("tariff", "tariff"),
|
|
171
|
+
("supplier", "supplier"),
|
|
172
|
+
("interconnection_year", "interconnection_year"),
|
|
173
|
+
("pto_date", "pto_date"),
|
|
174
|
+
("vintage", "vintage"),
|
|
175
|
+
("acc_plus_segment", "acc_plus_segment"),
|
|
176
|
+
("discount", "discount"),
|
|
177
|
+
("base_services_charge_tier", "base_services_charge_tier"),
|
|
178
|
+
("baseline_territory", "baseline_territory"),
|
|
179
|
+
("baseline_code", "baseline_code"),
|
|
180
|
+
("nsc_rate", "nsc_rate"),
|
|
181
|
+
):
|
|
182
|
+
value = getattr(args, option, None)
|
|
183
|
+
if value is not None:
|
|
184
|
+
changes[field] = value
|
|
185
|
+
cca_json = getattr(args, "cca_json", None)
|
|
186
|
+
if cca_json is not None:
|
|
187
|
+
try:
|
|
188
|
+
cca = json.loads(cca_json)
|
|
189
|
+
except json.JSONDecodeError as exc:
|
|
190
|
+
raise ConfigError("--cca-json is not valid JSON") from exc
|
|
191
|
+
if not isinstance(cca, dict):
|
|
192
|
+
raise ConfigError("--cca-json must contain an object")
|
|
193
|
+
changes["cca"] = cca
|
|
194
|
+
return changes
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def update_profile(
|
|
198
|
+
repository: NamedProfileRepository,
|
|
199
|
+
name: str,
|
|
200
|
+
*,
|
|
201
|
+
effective: date,
|
|
202
|
+
config_path: str | Path | None = None,
|
|
203
|
+
config_json: Path | None = None,
|
|
204
|
+
changes: Mapping[str, object] | None = None,
|
|
205
|
+
note: str | None = None,
|
|
206
|
+
credential_set: str | None = None,
|
|
207
|
+
apply: bool = False,
|
|
208
|
+
) -> AccountProfile:
|
|
209
|
+
"""Create or replace one complete effective-dated Config snapshot."""
|
|
210
|
+
profile = repository.load(name)
|
|
211
|
+
if config_json is not None and config_path is not None:
|
|
212
|
+
raise ConfigError("choose either --config or --config-json")
|
|
213
|
+
if config_json is not None:
|
|
214
|
+
config = read_config_json(config_json)
|
|
215
|
+
elif config_path is not None:
|
|
216
|
+
config = Config.load(config_path)
|
|
217
|
+
else:
|
|
218
|
+
try:
|
|
219
|
+
current = profile.config_at(effective)
|
|
220
|
+
except AccountError as exc:
|
|
221
|
+
raise ConfigError(
|
|
222
|
+
"an update before the first epoch needs --config or --config-json"
|
|
223
|
+
) from exc
|
|
224
|
+
merged = current.to_dict()
|
|
225
|
+
merged.update(dict(changes or {}))
|
|
226
|
+
try:
|
|
227
|
+
config = Config.from_dict(merged)
|
|
228
|
+
except (ConfigError, TypeError, ValueError) as exc:
|
|
229
|
+
raise ConfigError(f"invalid account update: {exc}") from exc
|
|
230
|
+
|
|
231
|
+
epochs = list(profile.epochs)
|
|
232
|
+
replacement = AccountEpoch(
|
|
233
|
+
effective,
|
|
234
|
+
config,
|
|
235
|
+
note
|
|
236
|
+
if note is not None
|
|
237
|
+
else next(
|
|
238
|
+
(epoch.note for epoch in epochs if epoch.effective == effective),
|
|
239
|
+
"",
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
for index, epoch in enumerate(epochs):
|
|
243
|
+
if epoch.effective == effective:
|
|
244
|
+
epochs[index] = replacement
|
|
245
|
+
break
|
|
246
|
+
else:
|
|
247
|
+
epochs.append(replacement)
|
|
248
|
+
epochs.sort(key=lambda epoch: epoch.effective)
|
|
249
|
+
updated = AccountProfile(
|
|
250
|
+
tuple(epochs),
|
|
251
|
+
name=profile.name,
|
|
252
|
+
credential_set=credential_set if credential_set is not None else profile.credential_set,
|
|
253
|
+
observations=profile.observations,
|
|
254
|
+
meter_sources=profile.meter_sources,
|
|
255
|
+
)
|
|
256
|
+
return repository.save(name, updated, expected_revision=profile.revision) if apply else updated
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def set_meter_source(
|
|
260
|
+
repository: NamedProfileRepository,
|
|
261
|
+
name: str,
|
|
262
|
+
*,
|
|
263
|
+
provider: str,
|
|
264
|
+
grid_import_entity: str,
|
|
265
|
+
grid_export_entity: str,
|
|
266
|
+
apply: bool = False,
|
|
267
|
+
) -> AccountProfile:
|
|
268
|
+
"""Preview or persist one provider's profile-scoped meter mapping."""
|
|
269
|
+
if provider not in ("ha", "influx"):
|
|
270
|
+
raise ConfigError("meter source must be ha or influx")
|
|
271
|
+
profile = repository.load(name)
|
|
272
|
+
source = MeterSource(
|
|
273
|
+
grid_import_entity=grid_import_entity,
|
|
274
|
+
grid_export_entity=grid_export_entity,
|
|
275
|
+
)
|
|
276
|
+
if provider == "ha":
|
|
277
|
+
sources = MeterSources(ha=source, influx=profile.meter_sources.influx)
|
|
278
|
+
else:
|
|
279
|
+
sources = MeterSources(ha=profile.meter_sources.ha, influx=source)
|
|
280
|
+
updated = AccountProfile(
|
|
281
|
+
epochs=profile.epochs,
|
|
282
|
+
name=profile.name,
|
|
283
|
+
credential_set=profile.credential_set,
|
|
284
|
+
observations=profile.observations,
|
|
285
|
+
meter_sources=sources,
|
|
286
|
+
)
|
|
287
|
+
return repository.save(name, updated, expected_revision=profile.revision) if apply else updated
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def meter_source_summary(profile: AccountProfile, provider: str) -> dict[str, object]:
|
|
291
|
+
"""Return sanitized CLI data for one profile-scoped meter mapping."""
|
|
292
|
+
if provider not in ("ha", "influx"):
|
|
293
|
+
raise ConfigError("meter source must be ha or influx")
|
|
294
|
+
source = profile.meter_sources.ha if provider == "ha" else profile.meter_sources.influx
|
|
295
|
+
return {
|
|
296
|
+
"profile": profile.name,
|
|
297
|
+
"source": provider,
|
|
298
|
+
"configured": source is not None,
|
|
299
|
+
"grid_import_entity": source.grid_import_entity if source is not None else None,
|
|
300
|
+
"grid_export_entity": source.grid_export_entity if source is not None else None,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def apply_observations(
|
|
305
|
+
repository: NamedProfileRepository,
|
|
306
|
+
name: str,
|
|
307
|
+
observations: Sequence[AccountObservation],
|
|
308
|
+
*,
|
|
309
|
+
apply: bool,
|
|
310
|
+
) -> tuple[AccountProfile, list[dict[str, object]]]:
|
|
311
|
+
"""Reconcile evidence in order and optionally persist one atomic update."""
|
|
312
|
+
from ..providers.pge.reconcile import reconcile
|
|
313
|
+
|
|
314
|
+
profile = repository.load(name)
|
|
315
|
+
working = profile
|
|
316
|
+
proposals: list[dict[str, object]] = []
|
|
317
|
+
can_apply = True
|
|
318
|
+
for observation in observations:
|
|
319
|
+
proposal = reconcile(working, observation)
|
|
320
|
+
proposals.append(proposal.to_dict())
|
|
321
|
+
if proposal.can_apply:
|
|
322
|
+
working = proposal.apply(working)
|
|
323
|
+
else:
|
|
324
|
+
can_apply = False
|
|
325
|
+
|
|
326
|
+
if apply:
|
|
327
|
+
if not can_apply:
|
|
328
|
+
raise ConfigError("account update contains conflicts or missing required values")
|
|
329
|
+
if working != profile:
|
|
330
|
+
working = repository.save(name, working, expected_revision=profile.revision)
|
|
331
|
+
return working, proposals
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def import_statements(
|
|
335
|
+
repository: NamedProfileRepository,
|
|
336
|
+
name: str,
|
|
337
|
+
paths: Sequence[Path],
|
|
338
|
+
*,
|
|
339
|
+
apply: bool,
|
|
340
|
+
) -> tuple[AccountProfile, list[dict[str, object]]]:
|
|
341
|
+
"""Parse local PDFs and reconcile only their sanitized observations."""
|
|
342
|
+
from ..providers.pge.reconcile import import_statement
|
|
343
|
+
|
|
344
|
+
observations = [import_statement(path) for path in paths]
|
|
345
|
+
return apply_observations(repository, name, observations, apply=apply)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _cache_directory(name: str) -> Path:
|
|
349
|
+
root = Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache")))
|
|
350
|
+
parent = root / "tariffkit" / "account-sync"
|
|
351
|
+
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
352
|
+
parent.chmod(0o700)
|
|
353
|
+
path = parent / f"{name}-{uuid4().hex}"
|
|
354
|
+
path.mkdir(mode=0o700)
|
|
355
|
+
path.chmod(0o700)
|
|
356
|
+
return path
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _row_value(row: Mapping[str, object], *needles: str) -> str | None:
|
|
360
|
+
for key, value in row.items():
|
|
361
|
+
normalized = re.sub(r"[^a-z0-9]", "", str(key).casefold())
|
|
362
|
+
if any(needle in normalized for needle in needles) and value not in (None, ""):
|
|
363
|
+
return str(value)
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _row_date(row: Mapping[str, object]) -> date | None:
|
|
368
|
+
for key, value in row.items():
|
|
369
|
+
normalized = re.sub(r"[^a-z0-9]", "", str(key).casefold())
|
|
370
|
+
if not any(token in normalized for token in ("billdate", "statementdate", "invoicedate")):
|
|
371
|
+
continue
|
|
372
|
+
text = str(value).strip()
|
|
373
|
+
try:
|
|
374
|
+
return date.fromisoformat(text[:10])
|
|
375
|
+
except ValueError:
|
|
376
|
+
for pattern in ("%m/%d/%Y", "%m/%d/%y"):
|
|
377
|
+
try:
|
|
378
|
+
from datetime import datetime
|
|
379
|
+
|
|
380
|
+
return datetime.strptime(text, pattern).date()
|
|
381
|
+
except ValueError:
|
|
382
|
+
continue
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _pge_settings(profile: AccountProfile, config_path: str | Path | None = None) -> Any:
|
|
387
|
+
from ..sources.pge import PgeSettings
|
|
388
|
+
|
|
389
|
+
if profile.credential_set is None:
|
|
390
|
+
return PgeSettings.load(config_path)
|
|
391
|
+
prefix = profile.credential_set
|
|
392
|
+
username = get_named_secret(prefix, "pge.username")
|
|
393
|
+
password = get_named_secret(prefix, "pge.password")
|
|
394
|
+
if not username or not password:
|
|
395
|
+
raise ConfigError(
|
|
396
|
+
f"credential set {prefix!r} does not contain both pge.username and pge.password"
|
|
397
|
+
)
|
|
398
|
+
settings = PgeSettings.load(config_path, username=username, password=password)
|
|
399
|
+
values = {
|
|
400
|
+
"browser_cookie": get_named_secret(prefix, "pge.browser_cookie"),
|
|
401
|
+
"validation_cookie": get_named_secret(prefix, "pge.validation_cookie"),
|
|
402
|
+
"account_urn": get_named_secret(prefix, "pge.account_urn"),
|
|
403
|
+
}
|
|
404
|
+
return type(settings)(
|
|
405
|
+
username=settings.username,
|
|
406
|
+
password=settings.password,
|
|
407
|
+
account_id=settings.account_id,
|
|
408
|
+
cookie_path=settings.cookie_path,
|
|
409
|
+
browser_cookie=values["browser_cookie"] or settings.browser_cookie,
|
|
410
|
+
validation_cookie=values["validation_cookie"] or settings.validation_cookie,
|
|
411
|
+
account_urn=values["account_urn"] or settings.account_urn,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def sync_profile(
|
|
416
|
+
repository: NamedProfileRepository,
|
|
417
|
+
name: str,
|
|
418
|
+
*,
|
|
419
|
+
since: date | None = None,
|
|
420
|
+
apply: bool,
|
|
421
|
+
keep_statements: bool = False,
|
|
422
|
+
config_path: str | Path | None = None,
|
|
423
|
+
) -> tuple[AccountProfile, list[dict[str, object]]]:
|
|
424
|
+
"""Download, parse, and reconcile portal statements through a private cache."""
|
|
425
|
+
from ..providers.pge.reconcile import import_statement
|
|
426
|
+
from ..sources.pge import PgeSession
|
|
427
|
+
|
|
428
|
+
profile = repository.load(name)
|
|
429
|
+
cache = _cache_directory(name)
|
|
430
|
+
observations: list[AccountObservation] = []
|
|
431
|
+
try:
|
|
432
|
+
settings = _pge_settings(profile, config_path)
|
|
433
|
+
with PgeSession(settings) as session:
|
|
434
|
+
rows = session.bill_history()
|
|
435
|
+
selected: list[tuple[str, str | None]] = []
|
|
436
|
+
for row in rows:
|
|
437
|
+
identifier = _row_value(row, "billpdf", "billid", "invoiceid", "statementid")
|
|
438
|
+
if not identifier:
|
|
439
|
+
continue
|
|
440
|
+
issued = _row_date(row)
|
|
441
|
+
if since is not None and (issued is None or issued < since):
|
|
442
|
+
continue
|
|
443
|
+
selected.append((identifier, issued.isoformat() if issued else None))
|
|
444
|
+
if not selected:
|
|
445
|
+
return profile, []
|
|
446
|
+
for index, (identifier, _issued) in enumerate(selected):
|
|
447
|
+
pdf_path = cache / f"statement-{index:04d}.pdf"
|
|
448
|
+
pdf_path.write_bytes(session.download_bill(identifier))
|
|
449
|
+
pdf_path.chmod(0o600)
|
|
450
|
+
try:
|
|
451
|
+
observations.append(import_statement(pdf_path))
|
|
452
|
+
finally:
|
|
453
|
+
if not keep_statements:
|
|
454
|
+
pdf_path.unlink(missing_ok=True)
|
|
455
|
+
finally:
|
|
456
|
+
if not keep_statements:
|
|
457
|
+
shutil.rmtree(cache, ignore_errors=False)
|
|
458
|
+
return apply_observations(repository, name, observations, apply=apply)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def profile_summary(profile: AccountProfile) -> dict[str, Any]:
|
|
462
|
+
"""Return sanitized data suitable for human or JSON CLI output."""
|
|
463
|
+
return {
|
|
464
|
+
"name": profile.name,
|
|
465
|
+
"credential_set": profile.credential_set,
|
|
466
|
+
"epochs": [
|
|
467
|
+
{
|
|
468
|
+
"effective": epoch.effective.isoformat(),
|
|
469
|
+
"tariff": epoch.config.tariff,
|
|
470
|
+
"supplier": epoch.config.supplier.value,
|
|
471
|
+
"note": epoch.note,
|
|
472
|
+
}
|
|
473
|
+
for epoch in profile.epochs
|
|
474
|
+
],
|
|
475
|
+
"observations": len(profile.observations),
|
|
476
|
+
"meter_sources": profile.meter_sources.to_dict(),
|
|
477
|
+
"revision": profile.revision,
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
__all__ = [
|
|
482
|
+
"apply_observations",
|
|
483
|
+
"config_changes",
|
|
484
|
+
"import_statements",
|
|
485
|
+
"init_profile",
|
|
486
|
+
"meter_source_summary",
|
|
487
|
+
"migrate_existing",
|
|
488
|
+
"profile_summary",
|
|
489
|
+
"read_config_json",
|
|
490
|
+
"set_meter_source",
|
|
491
|
+
"sync_profile",
|
|
492
|
+
"update_profile",
|
|
493
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Errors raised by account profiles and their managed storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..errors import TariffKitError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AccountError(TariffKitError):
|
|
9
|
+
"""An account profile is invalid or cannot price the requested date."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProfileNotFoundError(AccountError):
|
|
13
|
+
"""A named profile does not exist."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProfileNameError(AccountError):
|
|
17
|
+
"""A named profile identifier is not a safe slug."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProfileStorageError(AccountError):
|
|
21
|
+
"""A managed profile file is malformed or unsafe to use."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProfileConflictError(ProfileStorageError):
|
|
25
|
+
"""A profile changed after the caller read the revision being replaced."""
|