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,61 @@
|
|
|
1
|
+
"""Rate facade that resolves account history for every priced timestamp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bisect import bisect_right
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from ..engine import RateEngine
|
|
9
|
+
from ..models import PriceCurve, PricePoint
|
|
10
|
+
from ..timeutil import hour_floor, now_pacific, to_pacific
|
|
11
|
+
from .model import AccountProfile
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AccountRateEngine:
|
|
15
|
+
"""Price a profile while selecting its epoch independently for each moment."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, profile: AccountProfile) -> None:
|
|
18
|
+
self.profile = profile
|
|
19
|
+
self._engines: dict[int, RateEngine] = {}
|
|
20
|
+
|
|
21
|
+
def _engine_at(self, moment: datetime) -> RateEngine:
|
|
22
|
+
pacific_date = to_pacific(moment).date()
|
|
23
|
+
index = bisect_right(self.profile.effective_dates, pacific_date) - 1
|
|
24
|
+
if index < 0:
|
|
25
|
+
self.profile.config_at(moment)
|
|
26
|
+
raise AssertionError("config_at should have raised for prehistory")
|
|
27
|
+
engine = self._engines.get(index)
|
|
28
|
+
if engine is None:
|
|
29
|
+
engine = RateEngine(self.profile.epochs[index].config)
|
|
30
|
+
self._engines[index] = engine
|
|
31
|
+
return engine
|
|
32
|
+
|
|
33
|
+
def price_at(self, moment: datetime) -> PricePoint:
|
|
34
|
+
"""Price the clock hour containing ``moment`` under its active epoch."""
|
|
35
|
+
return self._engine_at(moment).price_at(moment)
|
|
36
|
+
|
|
37
|
+
def price_now(self) -> PricePoint:
|
|
38
|
+
return self.price_at(now_pacific())
|
|
39
|
+
|
|
40
|
+
def forecast(self, hours: int = 24, start: datetime | None = None) -> PriceCurve:
|
|
41
|
+
"""Forecast across transitions without applying one config to all hours."""
|
|
42
|
+
if hours < 1:
|
|
43
|
+
raise ValueError("hours must be >= 1")
|
|
44
|
+
cursor = hour_floor(to_pacific(start) if start else now_pacific())
|
|
45
|
+
base = cursor.astimezone(UTC)
|
|
46
|
+
return PriceCurve(
|
|
47
|
+
tuple(self.price_at(base + timedelta(hours=offset)) for offset in range(hours))
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def daily_fixed_charge(self, moment: datetime | None = None) -> float:
|
|
51
|
+
"""Return the fixed charge from the epoch active at ``moment``."""
|
|
52
|
+
when = moment or now_pacific()
|
|
53
|
+
return self._engine_at(when).daily_fixed_charge(when)
|
|
54
|
+
|
|
55
|
+
def describe(self, moment: datetime | None = None) -> dict[str, object]:
|
|
56
|
+
"""Return rate provenance plus the active profile epoch."""
|
|
57
|
+
when = moment or now_pacific()
|
|
58
|
+
description = self._engine_at(when).describe(when)
|
|
59
|
+
description["account_profile"] = self.profile.name or None
|
|
60
|
+
description["account_effective"] = self.profile.config_at(when).to_dict()
|
|
61
|
+
return description
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Private, versioned JSON storage for named account profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import tomllib
|
|
14
|
+
from collections.abc import Iterator, Mapping
|
|
15
|
+
from contextlib import contextmanager, suppress
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ..config import default_config_path
|
|
19
|
+
from ..errors import TariffKitError
|
|
20
|
+
from .errors import (
|
|
21
|
+
ProfileConflictError,
|
|
22
|
+
ProfileNameError,
|
|
23
|
+
ProfileNotFoundError,
|
|
24
|
+
ProfileStorageError,
|
|
25
|
+
)
|
|
26
|
+
from .model import AccountProfile
|
|
27
|
+
|
|
28
|
+
_NAME = re.compile(r"^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$")
|
|
29
|
+
_MODE_DIR = 0o700
|
|
30
|
+
_MODE_FILE = 0o600
|
|
31
|
+
_THREAD_LOCKS: dict[Path, threading.Lock] = {}
|
|
32
|
+
_THREAD_LOCKS_GUARD = threading.Lock()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_profile_name(name: str) -> str:
|
|
36
|
+
"""Validate a profile slug before it participates in path construction."""
|
|
37
|
+
if not isinstance(name, str) or _NAME.fullmatch(name) is None:
|
|
38
|
+
raise ProfileNameError("profile name must be a lowercase slug")
|
|
39
|
+
return name
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def configured_profile_name(config_path: str | Path | None = None) -> str | None:
|
|
43
|
+
"""Return the configured default profile without loading any credentials.
|
|
44
|
+
|
|
45
|
+
Profile selection is deliberately kept separate from ``Config`` parsing:
|
|
46
|
+
the main TOML file may contain integration sections that are not pricing
|
|
47
|
+
fields, while a stateless ``Config`` must continue to reject unknown
|
|
48
|
+
pricing keys.
|
|
49
|
+
"""
|
|
50
|
+
for environment_name in ("TARIFFKIT_ACCOUNT", "TARIFFKIT_PROFILE"):
|
|
51
|
+
if value := os.environ.get(environment_name):
|
|
52
|
+
return validate_profile_name(value)
|
|
53
|
+
|
|
54
|
+
path = Path(config_path) if config_path is not None else default_config_path()
|
|
55
|
+
if not path.is_file():
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
with path.open("rb") as handle:
|
|
59
|
+
table = tomllib.load(handle)
|
|
60
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
61
|
+
raise ProfileStorageError(f"could not read profile selection from {path}") from exc
|
|
62
|
+
|
|
63
|
+
account = table.get("account")
|
|
64
|
+
candidates: list[object] = []
|
|
65
|
+
if isinstance(account, Mapping):
|
|
66
|
+
candidates.extend(account.get(key) for key in ("default_profile", "profile", "default"))
|
|
67
|
+
elif account is not None:
|
|
68
|
+
candidates.append(account)
|
|
69
|
+
candidates.extend(table.get(key) for key in ("default_profile", "profile", "account_profile"))
|
|
70
|
+
for candidate in candidates:
|
|
71
|
+
if candidate is None:
|
|
72
|
+
continue
|
|
73
|
+
if not isinstance(candidate, str):
|
|
74
|
+
raise ProfileStorageError("configured profile name must be a string")
|
|
75
|
+
return validate_profile_name(candidate)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _config_home() -> Path:
|
|
80
|
+
value = os.environ.get("XDG_CONFIG_HOME")
|
|
81
|
+
return Path(value) if value else Path.home() / ".config"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _json_bytes(profile: AccountProfile, *, name: str) -> bytes:
|
|
85
|
+
if profile.name not in ("", name):
|
|
86
|
+
raise ProfileStorageError(f"profile is named {profile.name!r}, not the requested {name!r}")
|
|
87
|
+
stored = (
|
|
88
|
+
profile
|
|
89
|
+
if profile.name == name
|
|
90
|
+
else AccountProfile(
|
|
91
|
+
epochs=profile.epochs,
|
|
92
|
+
name=name,
|
|
93
|
+
credential_set=profile.credential_set,
|
|
94
|
+
observations=profile.observations,
|
|
95
|
+
meter_sources=profile.meter_sources,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
try:
|
|
99
|
+
payload = (
|
|
100
|
+
json.dumps(
|
|
101
|
+
stored.to_dict(),
|
|
102
|
+
ensure_ascii=False,
|
|
103
|
+
allow_nan=False,
|
|
104
|
+
sort_keys=True,
|
|
105
|
+
separators=(",", ":"),
|
|
106
|
+
).encode("utf-8")
|
|
107
|
+
+ b"\n"
|
|
108
|
+
)
|
|
109
|
+
except (TypeError, ValueError) as exc:
|
|
110
|
+
raise ProfileStorageError("profile contains values that are not JSON-compatible") from exc
|
|
111
|
+
return payload
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _revision(raw: bytes) -> str:
|
|
115
|
+
return hashlib.sha256(raw).hexdigest()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class NamedProfileRepository:
|
|
119
|
+
"""Manage user-only profile files below the XDG configuration directory."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, config_home: str | Path | None = None) -> None:
|
|
122
|
+
base = Path(config_home) if config_home is not None else _config_home()
|
|
123
|
+
self.directory = base / "tariffkit" / "accounts"
|
|
124
|
+
self._check_no_symlink_components(base)
|
|
125
|
+
self._ensure_directory()
|
|
126
|
+
|
|
127
|
+
def _check_no_symlink(self, path: Path) -> None:
|
|
128
|
+
if path.is_symlink():
|
|
129
|
+
raise ProfileStorageError(f"refusing symlink in profile path: {path}")
|
|
130
|
+
|
|
131
|
+
def _check_no_symlink_components(self, path: Path) -> None:
|
|
132
|
+
"""Reject an escaping symlink anywhere below the configured root."""
|
|
133
|
+
current = Path(path.anchor) if path.is_absolute() else Path()
|
|
134
|
+
for part in path.parts:
|
|
135
|
+
if path.is_absolute() and part == path.anchor:
|
|
136
|
+
continue
|
|
137
|
+
current /= part
|
|
138
|
+
self._check_no_symlink(current)
|
|
139
|
+
|
|
140
|
+
def _ensure_directory(self) -> None:
|
|
141
|
+
try:
|
|
142
|
+
root = self.directory
|
|
143
|
+
base = root.parent.parent
|
|
144
|
+
self._check_no_symlink_components(base)
|
|
145
|
+
base.mkdir(mode=_MODE_DIR, parents=True, exist_ok=True)
|
|
146
|
+
for current in (root.parent, root):
|
|
147
|
+
self._check_no_symlink_components(current)
|
|
148
|
+
current.mkdir(mode=_MODE_DIR, exist_ok=True)
|
|
149
|
+
self._check_no_symlink_components(current)
|
|
150
|
+
current.chmod(_MODE_DIR)
|
|
151
|
+
except OSError as exc:
|
|
152
|
+
raise ProfileStorageError(f"could not create private profile directory {root}") from exc
|
|
153
|
+
|
|
154
|
+
def path_for(self, name: str) -> Path:
|
|
155
|
+
"""Return a safe profile path after validating the name."""
|
|
156
|
+
validated = validate_profile_name(name)
|
|
157
|
+
self._check_private_directory()
|
|
158
|
+
path = self.directory / f"{validated}.json"
|
|
159
|
+
self._check_no_symlink_components(path)
|
|
160
|
+
return path
|
|
161
|
+
|
|
162
|
+
def names(self) -> tuple[str, ...]:
|
|
163
|
+
"""List valid regular profile files, refusing unsafe entries."""
|
|
164
|
+
self._check_private_directory()
|
|
165
|
+
result: list[str] = []
|
|
166
|
+
for path in self.directory.iterdir():
|
|
167
|
+
self._check_no_symlink_components(path)
|
|
168
|
+
if path.suffix != ".json" or not path.is_file():
|
|
169
|
+
continue
|
|
170
|
+
name = path.stem
|
|
171
|
+
validate_profile_name(name)
|
|
172
|
+
result.append(name)
|
|
173
|
+
return tuple(sorted(result))
|
|
174
|
+
|
|
175
|
+
def load(self, name: str) -> AccountProfile:
|
|
176
|
+
path = self.path_for(name)
|
|
177
|
+
if not path.exists():
|
|
178
|
+
raise ProfileNotFoundError(f"profile {name!r} does not exist")
|
|
179
|
+
if path.is_symlink() or not path.is_file():
|
|
180
|
+
raise ProfileStorageError(f"profile path is not a regular file: {path}")
|
|
181
|
+
if stat.S_IMODE(path.stat().st_mode) != _MODE_FILE:
|
|
182
|
+
raise ProfileStorageError(f"profile {name!r} is not private (expected mode 0600)")
|
|
183
|
+
try:
|
|
184
|
+
raw = path.read_bytes()
|
|
185
|
+
parsed = json.loads(
|
|
186
|
+
raw.decode("utf-8"),
|
|
187
|
+
parse_constant=lambda value: (_ for _ in ()).throw(
|
|
188
|
+
ValueError(f"non-finite JSON value {value}")
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
|
|
192
|
+
raise ProfileStorageError(f"profile {name!r} is not valid JSON") from exc
|
|
193
|
+
if not isinstance(parsed, Mapping):
|
|
194
|
+
raise ProfileStorageError("profile JSON must contain an object")
|
|
195
|
+
try:
|
|
196
|
+
profile = AccountProfile.from_dict(parsed)
|
|
197
|
+
except (TariffKitError, TypeError, ValueError) as exc:
|
|
198
|
+
raise ProfileStorageError(f"profile {name!r} failed validation: {exc}") from exc
|
|
199
|
+
if profile.name != name:
|
|
200
|
+
raise ProfileStorageError(f"profile name does not match filename {name!r}")
|
|
201
|
+
profile = AccountProfile(
|
|
202
|
+
epochs=profile.epochs,
|
|
203
|
+
name=profile.name,
|
|
204
|
+
credential_set=profile.credential_set,
|
|
205
|
+
observations=profile.observations,
|
|
206
|
+
meter_sources=profile.meter_sources,
|
|
207
|
+
)
|
|
208
|
+
return _with_revision(profile, _revision(raw))
|
|
209
|
+
|
|
210
|
+
def save(
|
|
211
|
+
self,
|
|
212
|
+
name: str,
|
|
213
|
+
profile: AccountProfile,
|
|
214
|
+
*,
|
|
215
|
+
expected_revision: str | None = None,
|
|
216
|
+
) -> AccountProfile:
|
|
217
|
+
"""Atomically save a validated profile with optimistic concurrency."""
|
|
218
|
+
path = self.path_for(name)
|
|
219
|
+
replacement = _json_bytes(profile, name=name)
|
|
220
|
+
with self._profile_lock(name):
|
|
221
|
+
original = self._read_existing(path)
|
|
222
|
+
original_revision = None if original is None else _revision(original)
|
|
223
|
+
expected = expected_revision
|
|
224
|
+
if expected is None:
|
|
225
|
+
expected = getattr(profile, "_revision", None)
|
|
226
|
+
if original_revision != expected:
|
|
227
|
+
raise ProfileConflictError(f"profile {name!r} changed; reload it before saving")
|
|
228
|
+
fd, temporary_name = tempfile.mkstemp(
|
|
229
|
+
prefix=f".{name}.", suffix=".tmp", dir=self.directory
|
|
230
|
+
)
|
|
231
|
+
temporary = Path(temporary_name)
|
|
232
|
+
try:
|
|
233
|
+
os.fchmod(fd, _MODE_FILE)
|
|
234
|
+
with os.fdopen(fd, "wb") as handle:
|
|
235
|
+
handle.write(replacement)
|
|
236
|
+
handle.flush()
|
|
237
|
+
os.fsync(handle.fileno())
|
|
238
|
+
temporary.replace(path)
|
|
239
|
+
self._check_no_symlink(path)
|
|
240
|
+
path.chmod(_MODE_FILE)
|
|
241
|
+
self._fsync_directory()
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
raise ProfileStorageError(f"could not save profile {name!r}") from exc
|
|
244
|
+
finally:
|
|
245
|
+
with suppress(FileNotFoundError):
|
|
246
|
+
temporary.unlink()
|
|
247
|
+
saved = AccountProfile(
|
|
248
|
+
epochs=profile.epochs,
|
|
249
|
+
name=name,
|
|
250
|
+
credential_set=profile.credential_set,
|
|
251
|
+
observations=profile.observations,
|
|
252
|
+
meter_sources=profile.meter_sources,
|
|
253
|
+
)
|
|
254
|
+
return _with_revision(saved, _revision(replacement))
|
|
255
|
+
|
|
256
|
+
def delete(self, name: str, *, expected_revision: str | None = None) -> None:
|
|
257
|
+
"""Delete a profile only if its revision is the one the caller read."""
|
|
258
|
+
path = self.path_for(name)
|
|
259
|
+
with self._profile_lock(name):
|
|
260
|
+
original = self._read_existing(path)
|
|
261
|
+
if original is None:
|
|
262
|
+
raise ProfileNotFoundError(f"profile {name!r} does not exist")
|
|
263
|
+
revision = _revision(original)
|
|
264
|
+
if expected_revision is not None and revision != expected_revision:
|
|
265
|
+
raise ProfileConflictError(f"profile {name!r} changed; reload it before deleting")
|
|
266
|
+
try:
|
|
267
|
+
path.unlink()
|
|
268
|
+
self._fsync_directory()
|
|
269
|
+
except OSError as exc:
|
|
270
|
+
raise ProfileStorageError(f"could not delete profile {name!r}") from exc
|
|
271
|
+
|
|
272
|
+
@contextmanager
|
|
273
|
+
def _profile_lock(self, name: str) -> Iterator[None]:
|
|
274
|
+
"""Serialize revision checks and mutations across threads and processes."""
|
|
275
|
+
lock_path = self.directory / f".{validate_profile_name(name)}.lock"
|
|
276
|
+
self._check_no_symlink_components(lock_path)
|
|
277
|
+
resolved = lock_path.resolve()
|
|
278
|
+
with _THREAD_LOCKS_GUARD:
|
|
279
|
+
thread_lock = _THREAD_LOCKS.setdefault(resolved, threading.Lock())
|
|
280
|
+
with thread_lock:
|
|
281
|
+
flags = os.O_CREAT | os.O_RDWR
|
|
282
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
283
|
+
flags |= os.O_NOFOLLOW
|
|
284
|
+
descriptor: int | None = None
|
|
285
|
+
try:
|
|
286
|
+
descriptor = os.open(lock_path, flags, _MODE_FILE)
|
|
287
|
+
os.fchmod(descriptor, _MODE_FILE)
|
|
288
|
+
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
|
289
|
+
raise ProfileStorageError(f"profile lock is not a regular file: {lock_path}")
|
|
290
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
|
291
|
+
yield
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
raise ProfileStorageError(f"could not lock profile {name!r}") from exc
|
|
294
|
+
finally:
|
|
295
|
+
if descriptor is not None:
|
|
296
|
+
with suppress(OSError):
|
|
297
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
298
|
+
os.close(descriptor)
|
|
299
|
+
|
|
300
|
+
def _read_existing(self, path: Path) -> bytes | None:
|
|
301
|
+
self._check_no_symlink_components(path)
|
|
302
|
+
try:
|
|
303
|
+
return path.read_bytes() if path.exists() else None
|
|
304
|
+
except OSError as exc:
|
|
305
|
+
raise ProfileStorageError(f"could not read profile path {path}") from exc
|
|
306
|
+
|
|
307
|
+
def _check_private_directory(self) -> None:
|
|
308
|
+
self._check_no_symlink(self.directory)
|
|
309
|
+
try:
|
|
310
|
+
mode = stat.S_IMODE(self.directory.stat().st_mode)
|
|
311
|
+
except OSError as exc:
|
|
312
|
+
raise ProfileStorageError("could not inspect profile directory") from exc
|
|
313
|
+
if mode != _MODE_DIR:
|
|
314
|
+
raise ProfileStorageError("profile directory is not private (expected mode 0700)")
|
|
315
|
+
|
|
316
|
+
def _fsync_directory(self) -> None:
|
|
317
|
+
flags = os.O_RDONLY
|
|
318
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
319
|
+
flags |= os.O_DIRECTORY
|
|
320
|
+
descriptor = os.open(self.directory, flags)
|
|
321
|
+
try:
|
|
322
|
+
os.fsync(descriptor)
|
|
323
|
+
finally:
|
|
324
|
+
os.close(descriptor)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _with_revision(profile: AccountProfile, revision: str) -> AccountProfile:
|
|
328
|
+
object.__setattr__(profile, "_revision", revision)
|
|
329
|
+
return profile
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Bill computation from interval meter data.
|
|
2
|
+
|
|
3
|
+
Pure and dependency-free: readings in, decomposed charges out. See
|
|
4
|
+
``tariffkit.billing.engine`` for what is deliberately out of scope.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .engine import BillEngine, hourly
|
|
8
|
+
from .ledger import (
|
|
9
|
+
CreditBalances,
|
|
10
|
+
CreditBucket,
|
|
11
|
+
Ledger,
|
|
12
|
+
LedgerEntry,
|
|
13
|
+
apply_credits,
|
|
14
|
+
credits_earned,
|
|
15
|
+
run_ledger,
|
|
16
|
+
)
|
|
17
|
+
from .models import Bill, BillingPeriod, IntervalReading, UsageBucket
|
|
18
|
+
from .netting import check_coverage, find_gaps, find_overlaps, net_intervals
|
|
19
|
+
from .trueup import (
|
|
20
|
+
TrueUp,
|
|
21
|
+
TrueUpKind,
|
|
22
|
+
cash_out_periods,
|
|
23
|
+
mce_cash_out,
|
|
24
|
+
pge_true_up,
|
|
25
|
+
published_nsc_rate,
|
|
26
|
+
relevant_period_end,
|
|
27
|
+
run_true_ups,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Bill",
|
|
32
|
+
"BillEngine",
|
|
33
|
+
"BillingPeriod",
|
|
34
|
+
"CreditBalances",
|
|
35
|
+
"CreditBucket",
|
|
36
|
+
"IntervalReading",
|
|
37
|
+
"Ledger",
|
|
38
|
+
"LedgerEntry",
|
|
39
|
+
"TrueUp",
|
|
40
|
+
"TrueUpKind",
|
|
41
|
+
"UsageBucket",
|
|
42
|
+
"apply_credits",
|
|
43
|
+
"cash_out_periods",
|
|
44
|
+
"check_coverage",
|
|
45
|
+
"credits_earned",
|
|
46
|
+
"find_gaps",
|
|
47
|
+
"find_overlaps",
|
|
48
|
+
"hourly",
|
|
49
|
+
"mce_cash_out",
|
|
50
|
+
"net_intervals",
|
|
51
|
+
"pge_true_up",
|
|
52
|
+
"published_nsc_rate",
|
|
53
|
+
"relevant_period_end",
|
|
54
|
+
"run_ledger",
|
|
55
|
+
"run_true_ups",
|
|
56
|
+
]
|