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.
Files changed (83) hide show
  1. tariffkit/__init__.py +52 -0
  2. tariffkit/account/__init__.py +41 -0
  3. tariffkit/account/cli.py +493 -0
  4. tariffkit/account/errors.py +25 -0
  5. tariffkit/account/model.py +641 -0
  6. tariffkit/account/rates.py +61 -0
  7. tariffkit/account/repository.py +329 -0
  8. tariffkit/billing/__init__.py +56 -0
  9. tariffkit/billing/engine.py +505 -0
  10. tariffkit/billing/ledger.py +365 -0
  11. tariffkit/billing/models.py +274 -0
  12. tariffkit/billing/netting.py +137 -0
  13. tariffkit/billing/trueup.py +498 -0
  14. tariffkit/cca.py +122 -0
  15. tariffkit/cli.py +809 -0
  16. tariffkit/config.py +296 -0
  17. tariffkit/data/__init__.py +40 -0
  18. tariffkit/data/cca/mce/2023-01-01.toml +61 -0
  19. tariffkit/data/cca/mce/2026-04-01.toml +152 -0
  20. tariffkit/data/export/pge/acc_plus/2023-04-15.toml +43 -0
  21. tariffkit/data/export/pge/nbt00.json.gz +0 -0
  22. tariffkit/data/export/pge/nbt23.json.gz +0 -0
  23. tariffkit/data/export/pge/nbt24.json.gz +0 -0
  24. tariffkit/data/export/pge/nbt25.json.gz +0 -0
  25. tariffkit/data/export/pge/nbt26.json.gz +0 -0
  26. tariffkit/data/holidays.toml +36 -0
  27. tariffkit/data/manifest.json +56 -0
  28. tariffkit/data/nsc/pge.toml +57 -0
  29. tariffkit/data/tariff/pge/eelec/2025-01-01.toml +151 -0
  30. tariffkit/data/tariff/pge/eelec/2025-03-01.toml +150 -0
  31. tariffkit/data/tariff/pge/eelec/2025-09-01.toml +150 -0
  32. tariffkit/data/tariff/pge/eelec/2026-01-01.toml +153 -0
  33. tariffkit/data/tariff/pge/eelec/2026-03-01.toml +157 -0
  34. tariffkit/data/tariff/pge/etouc/2025-01-01.toml +222 -0
  35. tariffkit/data/tariff/pge/etouc/2025-03-01.toml +221 -0
  36. tariffkit/data/tariff/pge/etouc/2025-09-01.toml +221 -0
  37. tariffkit/data/tariff/pge/etouc/2026-01-01.toml +224 -0
  38. tariffkit/data/tariff/pge/etouc/2026-03-01.toml +231 -0
  39. tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +144 -0
  40. tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +143 -0
  41. tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +143 -0
  42. tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +146 -0
  43. tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +153 -0
  44. tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +27 -0
  45. tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +27 -0
  46. tariffkit/data/versioned.py +118 -0
  47. tariffkit/engine.py +82 -0
  48. tariffkit/errors.py +19 -0
  49. tariffkit/export/__init__.py +5 -0
  50. tariffkit/export/nbt.py +207 -0
  51. tariffkit/interop/__init__.py +21 -0
  52. tariffkit/interop/emhass.py +79 -0
  53. tariffkit/interop/predbat.py +102 -0
  54. tariffkit/interop/slots.py +63 -0
  55. tariffkit/models.py +164 -0
  56. tariffkit/mqtt/__init__.py +6 -0
  57. tariffkit/mqtt/discovery.py +84 -0
  58. tariffkit/mqtt/publisher.py +305 -0
  59. tariffkit/providers/__init__.py +1 -0
  60. tariffkit/providers/pge/__init__.py +33 -0
  61. tariffkit/providers/pge/reconcile.py +828 -0
  62. tariffkit/providers/pge/statements/__init__.py +26 -0
  63. tariffkit/providers/pge/statements/errors.py +20 -0
  64. tariffkit/providers/pge/statements/model.py +320 -0
  65. tariffkit/providers/pge/statements/ocr.py +193 -0
  66. tariffkit/providers/pge/statements/parse.py +813 -0
  67. tariffkit/py.typed +0 -0
  68. tariffkit/secrets.py +164 -0
  69. tariffkit/sources/__init__.py +71 -0
  70. tariffkit/sources/greenbutton.py +318 -0
  71. tariffkit/sources/homeassistant.py +342 -0
  72. tariffkit/sources/influx.py +359 -0
  73. tariffkit/sources/pge.py +1153 -0
  74. tariffkit/tariff/__init__.py +5 -0
  75. tariffkit/tariff/retail.py +271 -0
  76. tariffkit/timeutil.py +120 -0
  77. tariffkit/web/__init__.py +5 -0
  78. tariffkit/web/app.py +220 -0
  79. tariffkit-0.2.0.dist-info/METADATA +260 -0
  80. tariffkit-0.2.0.dist-info/RECORD +83 -0
  81. tariffkit-0.2.0.dist-info/WHEEL +4 -0
  82. tariffkit-0.2.0.dist-info/entry_points.txt +2 -0
  83. 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
+ ]
@@ -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."""