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/cli.py
ADDED
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import getpass
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import date, datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .engine import RateEngine
|
|
18
|
+
from .errors import ConfigError, TariffKitError
|
|
19
|
+
from .models import PriceCurve, PricePoint
|
|
20
|
+
from .secrets import (
|
|
21
|
+
SECRET_NAMES,
|
|
22
|
+
configured_named_secrets,
|
|
23
|
+
configured_secrets,
|
|
24
|
+
delete_named_secret,
|
|
25
|
+
delete_secret,
|
|
26
|
+
set_named_secret,
|
|
27
|
+
set_secret,
|
|
28
|
+
)
|
|
29
|
+
from .timeutil import PACIFIC, to_pacific
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="tariffkit",
|
|
35
|
+
description="PG&E E-ELEC import/export prices under NEM 3.0.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument("--version", action="version", version=f"tariffkit {__version__}")
|
|
38
|
+
parser.add_argument("--config", type=Path, help="path to a config TOML file")
|
|
39
|
+
parser.add_argument("--account", help="named account profile to use")
|
|
40
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="log to stderr")
|
|
41
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
42
|
+
|
|
43
|
+
credentials = sub.add_parser(
|
|
44
|
+
"credentials",
|
|
45
|
+
help="store credentials in the operating-system keyring",
|
|
46
|
+
)
|
|
47
|
+
credential_commands = credentials.add_subparsers(dest="credential_command", required=True)
|
|
48
|
+
credential_set = credential_commands.add_parser("set", help="prompt for and store a secret")
|
|
49
|
+
credential_set.add_argument("--set", dest="credential_set", metavar="NAME")
|
|
50
|
+
credential_set.add_argument("name", choices=SECRET_NAMES)
|
|
51
|
+
credential_delete = credential_commands.add_parser("delete", help="delete a stored secret")
|
|
52
|
+
credential_delete.add_argument("--set", dest="credential_set", metavar="NAME")
|
|
53
|
+
credential_delete.add_argument("name", choices=SECRET_NAMES)
|
|
54
|
+
credential_list = credential_commands.add_parser(
|
|
55
|
+
"list", help="list configured names without values"
|
|
56
|
+
)
|
|
57
|
+
credential_list.add_argument("--set", dest="credential_set", metavar="NAME")
|
|
58
|
+
|
|
59
|
+
account = sub.add_parser("account", help="manage named account profiles")
|
|
60
|
+
account_commands = account.add_subparsers(dest="account_command", required=True)
|
|
61
|
+
account_init = account_commands.add_parser("init", help="create a named account profile")
|
|
62
|
+
account_init.add_argument("name")
|
|
63
|
+
account_init.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
|
64
|
+
account_init.add_argument("--effective", type=date.fromisoformat)
|
|
65
|
+
account_init.add_argument("--config-json", type=Path)
|
|
66
|
+
account_init.add_argument("--credential-set")
|
|
67
|
+
account_init.add_argument("--audit-file", type=Path)
|
|
68
|
+
account_init.add_argument("--json", action="store_true")
|
|
69
|
+
account_commands.add_parser("list", help="list named account profiles").add_argument(
|
|
70
|
+
"--json", action="store_true"
|
|
71
|
+
)
|
|
72
|
+
account_show = account_commands.add_parser("show", help="show a named account profile")
|
|
73
|
+
account_show.add_argument("name")
|
|
74
|
+
account_show.add_argument("--json", action="store_true")
|
|
75
|
+
account_history = account_commands.add_parser(
|
|
76
|
+
"history", help="show account epochs and evidence"
|
|
77
|
+
)
|
|
78
|
+
account_history.add_argument("name")
|
|
79
|
+
account_history.add_argument("--json", action="store_true")
|
|
80
|
+
account_update = account_commands.add_parser("update", help="add or replace an account epoch")
|
|
81
|
+
account_update.add_argument("name")
|
|
82
|
+
account_update.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
|
83
|
+
account_update.add_argument("--effective", required=True, type=date.fromisoformat)
|
|
84
|
+
account_update.add_argument("--config-json", type=Path)
|
|
85
|
+
account_update.add_argument("--credential-set")
|
|
86
|
+
account_update.add_argument("--tariff")
|
|
87
|
+
account_update.add_argument("--supplier")
|
|
88
|
+
account_update.add_argument("--interconnection-year", type=int, dest="interconnection_year")
|
|
89
|
+
account_update.add_argument("--pto-date", type=date.fromisoformat)
|
|
90
|
+
account_update.add_argument("--vintage")
|
|
91
|
+
account_update.add_argument("--acc-plus-segment", dest="acc_plus_segment")
|
|
92
|
+
account_update.add_argument("--discount")
|
|
93
|
+
account_update.add_argument("--base-services-charge-tier", type=int)
|
|
94
|
+
account_update.add_argument("--baseline-territory", dest="baseline_territory")
|
|
95
|
+
account_update.add_argument("--baseline-code", dest="baseline_code")
|
|
96
|
+
account_update.add_argument("--nsc-rate", type=float, dest="nsc_rate")
|
|
97
|
+
account_update.add_argument("--cca-json")
|
|
98
|
+
account_update.add_argument("--note")
|
|
99
|
+
account_update.add_argument("--apply", action="store_true")
|
|
100
|
+
account_update.add_argument("--json", action="store_true")
|
|
101
|
+
account_import = account_commands.add_parser(
|
|
102
|
+
"import-statement", help="import one or more local PG&E statement PDFs"
|
|
103
|
+
)
|
|
104
|
+
account_import.add_argument("name")
|
|
105
|
+
account_import.add_argument("pdf", nargs="+", type=Path)
|
|
106
|
+
account_import.add_argument("--apply", action="store_true")
|
|
107
|
+
account_import.add_argument("--json", action="store_true")
|
|
108
|
+
account_sync = account_commands.add_parser("sync", help="sync statements from the PG&E portal")
|
|
109
|
+
account_sync.add_argument("name")
|
|
110
|
+
account_sync.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
|
111
|
+
account_sync.add_argument("--since", type=date.fromisoformat)
|
|
112
|
+
account_sync.add_argument("--apply", action="store_true")
|
|
113
|
+
account_sync.add_argument("--keep-statements", action="store_true")
|
|
114
|
+
account_sync.add_argument("--json", action="store_true")
|
|
115
|
+
account_export = account_commands.add_parser(
|
|
116
|
+
"export", help="export a sanitized account profile"
|
|
117
|
+
)
|
|
118
|
+
account_export.add_argument("name")
|
|
119
|
+
account_export.add_argument("--output", type=Path)
|
|
120
|
+
account_export.add_argument("--json", action="store_true")
|
|
121
|
+
account_source = account_commands.add_parser(
|
|
122
|
+
"source", help="manage profile-scoped grid meter entities"
|
|
123
|
+
)
|
|
124
|
+
account_source.add_argument("name")
|
|
125
|
+
source_commands = account_source.add_subparsers(dest="source_command", required=True)
|
|
126
|
+
source_show = source_commands.add_parser("show", help="show one provider's meter entities")
|
|
127
|
+
source_show.add_argument("provider", choices=("ha", "influx"))
|
|
128
|
+
source_show.add_argument("--json", action="store_true")
|
|
129
|
+
source_set = source_commands.add_parser("set", help="set one provider's meter entities")
|
|
130
|
+
source_set.add_argument("provider", choices=("ha", "influx"))
|
|
131
|
+
source_set.add_argument(
|
|
132
|
+
"--grid-import-entity",
|
|
133
|
+
"--import-entity",
|
|
134
|
+
dest="grid_import_entity",
|
|
135
|
+
required=True,
|
|
136
|
+
help="entity measuring energy consumed from the grid",
|
|
137
|
+
)
|
|
138
|
+
source_set.add_argument(
|
|
139
|
+
"--grid-export-entity",
|
|
140
|
+
"--export-entity",
|
|
141
|
+
dest="grid_export_entity",
|
|
142
|
+
required=True,
|
|
143
|
+
help="entity measuring energy exported to the grid",
|
|
144
|
+
)
|
|
145
|
+
source_set.add_argument("--apply", action="store_true")
|
|
146
|
+
source_set.add_argument("--json", action="store_true")
|
|
147
|
+
|
|
148
|
+
now = sub.add_parser("now", help="current import and export price")
|
|
149
|
+
now.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
|
150
|
+
now.add_argument("--account", default=argparse.SUPPRESS, help="named account profile to use")
|
|
151
|
+
now.add_argument("--json", action="store_true", help="emit JSON")
|
|
152
|
+
|
|
153
|
+
forecast = sub.add_parser("forecast", help="upcoming hourly prices")
|
|
154
|
+
forecast.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
|
155
|
+
forecast.add_argument(
|
|
156
|
+
"--account", default=argparse.SUPPRESS, help="named account profile to use"
|
|
157
|
+
)
|
|
158
|
+
forecast.add_argument("--hours", type=int, default=24)
|
|
159
|
+
forecast.add_argument("--start", type=datetime.fromisoformat, help="ISO 8601 with offset")
|
|
160
|
+
forecast.add_argument("--format", choices=("table", "json", "csv"), default="table")
|
|
161
|
+
|
|
162
|
+
info_parser = sub.add_parser("info", help="which data is loaded, and from where")
|
|
163
|
+
info_parser.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
|
164
|
+
info_parser.add_argument(
|
|
165
|
+
"--account", default=argparse.SUPPRESS, help="named account profile to use"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
bill = sub.add_parser("bill", help="compute a bill from interval meter data")
|
|
169
|
+
bill.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
|
170
|
+
bill.add_argument("--account", default=argparse.SUPPRESS, help="named account profile to use")
|
|
171
|
+
bill.add_argument(
|
|
172
|
+
"csv",
|
|
173
|
+
type=Path,
|
|
174
|
+
nargs="?",
|
|
175
|
+
metavar="GREEN_BUTTON_CSV",
|
|
176
|
+
help="PG&E Green Button CSV ('Download my data'); '-' for stdin. "
|
|
177
|
+
"Omit with --source ha or --source influx",
|
|
178
|
+
)
|
|
179
|
+
bill.add_argument(
|
|
180
|
+
"--source",
|
|
181
|
+
# "csv" stays accepted so existing invocations keep working, but it is
|
|
182
|
+
# not the documented spelling: it says nothing about which CSV.
|
|
183
|
+
choices=("green-button", "csv", "ha", "influx"),
|
|
184
|
+
default="green-button",
|
|
185
|
+
help="where the readings come from (default: green-button)",
|
|
186
|
+
)
|
|
187
|
+
bill.add_argument("--start", type=date.fromisoformat, help="cycle start (meter read date)")
|
|
188
|
+
bill.add_argument("--end", type=date.fromisoformat, help="cycle end, inclusive")
|
|
189
|
+
bill.add_argument("--json", action="store_true")
|
|
190
|
+
bill.add_argument("--no-check", dest="check", action="store_false", help="skip coverage checks")
|
|
191
|
+
bill.add_argument("--ha-import-entity", help="override the grid-import entity")
|
|
192
|
+
bill.add_argument("--ha-export-entity", help="override the grid-export entity")
|
|
193
|
+
bill.add_argument("--influx-import-entity", help="override the grid-import series")
|
|
194
|
+
bill.add_argument("--influx-export-entity", help="override the grid-export series")
|
|
195
|
+
bill.add_argument(
|
|
196
|
+
"--influx-resolution",
|
|
197
|
+
type=int,
|
|
198
|
+
default=60,
|
|
199
|
+
metavar="MINUTES",
|
|
200
|
+
help="interval length for InfluxDB counters (default: 60; finer needs dense sampling)",
|
|
201
|
+
)
|
|
202
|
+
bill.add_argument(
|
|
203
|
+
"--ha-resolution",
|
|
204
|
+
choices=("auto", "5minute", "hour"),
|
|
205
|
+
default="auto",
|
|
206
|
+
help="statistics resolution; auto prefers 5-minute where it still exists",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
mqtt = sub.add_parser("mqtt", help="publish to MQTT every hour")
|
|
210
|
+
mqtt.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
|
211
|
+
mqtt.add_argument("--account", default=argparse.SUPPRESS, help="named account profile to use")
|
|
212
|
+
mqtt.add_argument("--broker")
|
|
213
|
+
mqtt.add_argument("--port", type=int)
|
|
214
|
+
mqtt.add_argument("--username")
|
|
215
|
+
mqtt.add_argument("--topic-prefix")
|
|
216
|
+
mqtt.add_argument("--forecast-hours", type=int)
|
|
217
|
+
mqtt.add_argument("--tls", action="store_true", default=None)
|
|
218
|
+
mqtt.add_argument(
|
|
219
|
+
"--no-discovery",
|
|
220
|
+
dest="discovery",
|
|
221
|
+
action="store_false",
|
|
222
|
+
default=None,
|
|
223
|
+
help="skip Home Assistant discovery config",
|
|
224
|
+
)
|
|
225
|
+
mqtt.add_argument("--once", action="store_true", help="publish once and exit")
|
|
226
|
+
|
|
227
|
+
serve = sub.add_parser("serve", help="run the REST API")
|
|
228
|
+
serve.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
|
229
|
+
serve.add_argument("--account", default=argparse.SUPPRESS, help="named account profile to use")
|
|
230
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
231
|
+
serve.add_argument("--port", type=int, default=8000)
|
|
232
|
+
|
|
233
|
+
return parser
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _midnight(day: date) -> datetime:
|
|
237
|
+
"""Local midnight starting ``day`` -- where a billing cycle boundary falls.
|
|
238
|
+
|
|
239
|
+
Callers add ``timedelta(days=1)`` to get the end of a cycle, and that is
|
|
240
|
+
deliberately wall-clock arithmetic: a cycle closes at the next local
|
|
241
|
+
midnight, 23 real hours later across the spring transition and 25 across the
|
|
242
|
+
autumn one. Converting to absolute time first would hold the window at 24
|
|
243
|
+
hours and land it an hour off on those two days -- the opposite of what
|
|
244
|
+
coverage checking needs, where elapsed time is the right measure.
|
|
245
|
+
"""
|
|
246
|
+
return datetime(day.year, day.month, day.day, tzinfo=PACIFIC)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _format_point(point: PricePoint) -> str:
|
|
250
|
+
lines = [
|
|
251
|
+
# %Z on both ends: across the fall-back transition the two sides carry
|
|
252
|
+
# different offsets, and "01:00 PDT - 01:00" reads as a zero-length hour.
|
|
253
|
+
f"{point.start:%Y-%m-%d %H:%M %Z} - {point.end:%H:%M %Z}",
|
|
254
|
+
f" import {point.import_price.total:>9.5f} $/kWh"
|
|
255
|
+
f" ({point.import_price.season}/{point.import_price.period})",
|
|
256
|
+
f" export {point.export_price.total:>9.5f} $/kWh"
|
|
257
|
+
f" ({point.export_price.vintage}/{point.export_price.day_type})",
|
|
258
|
+
f" spread {point.spread:>+9.5f} $/kWh",
|
|
259
|
+
]
|
|
260
|
+
if point.spread > 0:
|
|
261
|
+
lines.append(" exporting beats self-consumption this hour")
|
|
262
|
+
for note in _caveats(point):
|
|
263
|
+
lines.append(f" note: {note}")
|
|
264
|
+
return "\n".join(lines)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _caveats(point: PricePoint) -> list[str]:
|
|
268
|
+
notes = []
|
|
269
|
+
if not point.export_price.locked:
|
|
270
|
+
notes.append("past the 9-year rate lock; PG&E publishes this for illustration only")
|
|
271
|
+
if not point.export_price.exact:
|
|
272
|
+
notes.append("upstream hour labels drift this far out; value may be off by one hour slot")
|
|
273
|
+
if not point.export_price.complete:
|
|
274
|
+
notes.append("delivery only; configure your CCA's export rate for a full credit")
|
|
275
|
+
if not point.import_price.complete:
|
|
276
|
+
notes.append("delivery only; configure your CCA's generation rate card")
|
|
277
|
+
return notes
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _write_csv(curve: PriceCurve, stream: Any) -> None:
|
|
281
|
+
writer = csv.writer(stream)
|
|
282
|
+
writer.writerow(["start", "end", "import", "export", "spread", "season", "period", "locked"])
|
|
283
|
+
for point in curve:
|
|
284
|
+
writer.writerow(
|
|
285
|
+
[
|
|
286
|
+
point.start.isoformat(),
|
|
287
|
+
point.end.isoformat(),
|
|
288
|
+
f"{point.import_price.total:.5f}",
|
|
289
|
+
f"{point.export_price.total:.5f}",
|
|
290
|
+
f"{point.spread:.5f}",
|
|
291
|
+
point.import_price.season,
|
|
292
|
+
point.import_price.period,
|
|
293
|
+
point.export_price.locked,
|
|
294
|
+
]
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _print_table(curve: PriceCurve) -> None:
|
|
299
|
+
print(f"{'hour':<17} {'import':>9} {'export':>9} {'spread':>9} period")
|
|
300
|
+
print("-" * 62)
|
|
301
|
+
best = max(p.export_price.total for p in curve)
|
|
302
|
+
for point in curve:
|
|
303
|
+
marker = " *" if point.export_price.total == best else ""
|
|
304
|
+
print(
|
|
305
|
+
f"{point.start:%Y-%m-%d %H:%M} "
|
|
306
|
+
f"{point.import_price.total:>9.5f} {point.export_price.total:>9.5f} "
|
|
307
|
+
f"{point.spread:>+9.5f} {point.import_price.period}{marker}"
|
|
308
|
+
)
|
|
309
|
+
print(f"\n* highest export credit in this window ({best:.5f} $/kWh)")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _print_bill(bill: Any) -> None:
|
|
313
|
+
p = bill.period
|
|
314
|
+
print(f"Billing period {p.start} to {p.end} ({p.days} days)\n")
|
|
315
|
+
print(f"{'':<11} {'imported':>10} {'$':>8} {'exported':>10} {'$':>8}")
|
|
316
|
+
print("-" * 54)
|
|
317
|
+
for b in bill.buckets:
|
|
318
|
+
print(
|
|
319
|
+
f"{b.period!s:<11} {b.imported:>10.3f} {b.import_charge:>8.2f} "
|
|
320
|
+
f"{b.exported:>10.3f} {b.export_credit:>8.2f}"
|
|
321
|
+
)
|
|
322
|
+
print("-" * 54)
|
|
323
|
+
print(
|
|
324
|
+
f"{'totals':<11} {bill.imported_kwh:>10.3f} {bill.energy_charges:>8.2f} "
|
|
325
|
+
f"{bill.exported_kwh:>10.3f} {bill.export_credits:>8.2f}"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
print("\ncharges")
|
|
329
|
+
for name, amount in sorted(bill.import_components.items(), key=lambda kv: -abs(kv[1])):
|
|
330
|
+
print(f" {name:<34} {amount:>+9.2f}")
|
|
331
|
+
print("\ncredits")
|
|
332
|
+
for name, amount in sorted(bill.export_components.items(), key=lambda kv: -abs(kv[1])):
|
|
333
|
+
print(f" {name:<34} {amount:>+9.2f}")
|
|
334
|
+
print("\nfixed")
|
|
335
|
+
for name, amount in bill.fixed_components.items():
|
|
336
|
+
print(f" {name:<34} {amount:>+9.2f}")
|
|
337
|
+
|
|
338
|
+
print(f"\n {'TOTAL':<34} {bill.total:>+9.2f}")
|
|
339
|
+
if bill.effective_import_rate:
|
|
340
|
+
print(f" {'effective $/kWh imported':<34} {bill.effective_import_rate:>9.5f}")
|
|
341
|
+
for warning in bill.warnings:
|
|
342
|
+
print(f"\n warning: {warning}")
|
|
343
|
+
if not bill.complete:
|
|
344
|
+
print(" note: some prices were incomplete or inexact; treat the total as an estimate")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _account_repository() -> Any:
|
|
348
|
+
from .account import NamedProfileRepository
|
|
349
|
+
|
|
350
|
+
return NamedProfileRepository()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _selected_profile_name(args: Any) -> str | None:
|
|
354
|
+
explicit = cast(str | None, getattr(args, "account", None))
|
|
355
|
+
config_path = getattr(args, "config", None)
|
|
356
|
+
if explicit is not None and config_path is not None:
|
|
357
|
+
raise ConfigError("--account cannot be combined with --config")
|
|
358
|
+
if explicit is not None:
|
|
359
|
+
return explicit
|
|
360
|
+
if config_path is not None:
|
|
361
|
+
return None
|
|
362
|
+
from .account import configured_profile_name
|
|
363
|
+
|
|
364
|
+
return configured_profile_name()
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _pricing_context(args: Any) -> tuple[Any, Config | None, str | None, Any | None]:
|
|
368
|
+
"""Load either a stateless Config engine or a named profile engine."""
|
|
369
|
+
from .account import AccountRateEngine
|
|
370
|
+
|
|
371
|
+
profile_name = _selected_profile_name(args)
|
|
372
|
+
if profile_name is not None:
|
|
373
|
+
repository = _account_repository()
|
|
374
|
+
profile = repository.load(profile_name)
|
|
375
|
+
return AccountRateEngine(profile), None, profile_name, repository
|
|
376
|
+
config = Config.load(args.config)
|
|
377
|
+
return RateEngine(config), config, None, None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _print_profile(profile: Any, *, json_output: bool) -> None:
|
|
381
|
+
from .account.cli import profile_summary
|
|
382
|
+
|
|
383
|
+
if json_output:
|
|
384
|
+
print(json.dumps(profile.to_dict(), indent=2, default=str))
|
|
385
|
+
return
|
|
386
|
+
summary = profile_summary(profile)
|
|
387
|
+
print(f"name: {summary['name']}")
|
|
388
|
+
if summary["credential_set"]:
|
|
389
|
+
print(f"credential set: {summary['credential_set']}")
|
|
390
|
+
print("epochs")
|
|
391
|
+
for epoch in summary["epochs"]:
|
|
392
|
+
print(
|
|
393
|
+
f" {epoch['effective']} {epoch['tariff']} / {epoch['supplier']}"
|
|
394
|
+
+ (f" {epoch['note']}" if epoch["note"] else "")
|
|
395
|
+
)
|
|
396
|
+
print(f"observations: {summary['observations']}")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _run_account_command(args: Any) -> int:
|
|
400
|
+
from .account.cli import (
|
|
401
|
+
config_changes,
|
|
402
|
+
import_statements,
|
|
403
|
+
init_profile,
|
|
404
|
+
sync_profile,
|
|
405
|
+
update_profile,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
repository = _account_repository()
|
|
409
|
+
command = args.account_command
|
|
410
|
+
if command == "list":
|
|
411
|
+
names = repository.names()
|
|
412
|
+
if args.json:
|
|
413
|
+
print(json.dumps(list(names), indent=2))
|
|
414
|
+
else:
|
|
415
|
+
for name in names:
|
|
416
|
+
print(name)
|
|
417
|
+
return 0
|
|
418
|
+
|
|
419
|
+
if command == "init":
|
|
420
|
+
profile = init_profile(
|
|
421
|
+
repository,
|
|
422
|
+
args.name,
|
|
423
|
+
config_path=args.config,
|
|
424
|
+
config_json=args.config_json,
|
|
425
|
+
effective=args.effective,
|
|
426
|
+
credential_set=args.credential_set,
|
|
427
|
+
audit_path=args.audit_file,
|
|
428
|
+
)
|
|
429
|
+
_print_profile(profile, json_output=args.json)
|
|
430
|
+
return 0
|
|
431
|
+
|
|
432
|
+
profile = repository.load(args.name)
|
|
433
|
+
if command == "show":
|
|
434
|
+
_print_profile(profile, json_output=args.json)
|
|
435
|
+
return 0
|
|
436
|
+
|
|
437
|
+
if command == "history":
|
|
438
|
+
if args.json:
|
|
439
|
+
print(json.dumps(profile.to_dict(), indent=2, default=str))
|
|
440
|
+
else:
|
|
441
|
+
_print_profile(profile, json_output=False)
|
|
442
|
+
for index, observation in enumerate(profile.observations, start=1):
|
|
443
|
+
agreements = ", ".join(
|
|
444
|
+
f"{agreement.period.start}..{agreement.period.end} "
|
|
445
|
+
f"{agreement.tariff or 'unknown'}"
|
|
446
|
+
for agreement in observation.agreements
|
|
447
|
+
)
|
|
448
|
+
print(f"evidence {index}: {agreements}")
|
|
449
|
+
return 0
|
|
450
|
+
|
|
451
|
+
if command == "update":
|
|
452
|
+
changes = config_changes(args)
|
|
453
|
+
updated = update_profile(
|
|
454
|
+
repository,
|
|
455
|
+
args.name,
|
|
456
|
+
effective=args.effective,
|
|
457
|
+
config_path=args.config,
|
|
458
|
+
config_json=args.config_json,
|
|
459
|
+
changes=changes,
|
|
460
|
+
note=args.note,
|
|
461
|
+
credential_set=args.credential_set,
|
|
462
|
+
apply=args.apply,
|
|
463
|
+
)
|
|
464
|
+
if args.json:
|
|
465
|
+
print(
|
|
466
|
+
json.dumps(
|
|
467
|
+
{"profile": updated.to_dict(), "applied": args.apply},
|
|
468
|
+
indent=2,
|
|
469
|
+
default=str,
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
else:
|
|
473
|
+
print(f"updated {args.name} at {args.effective}")
|
|
474
|
+
if not args.apply:
|
|
475
|
+
print("preview only; pass --apply to save")
|
|
476
|
+
return 0
|
|
477
|
+
|
|
478
|
+
if command == "import-statement":
|
|
479
|
+
_updated, proposals = import_statements(
|
|
480
|
+
repository,
|
|
481
|
+
args.name,
|
|
482
|
+
args.pdf,
|
|
483
|
+
apply=args.apply,
|
|
484
|
+
)
|
|
485
|
+
payload = {"profile": args.name, "applied": args.apply, "proposals": proposals}
|
|
486
|
+
if args.json:
|
|
487
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
488
|
+
else:
|
|
489
|
+
for path, proposal in zip(args.pdf, proposals, strict=True):
|
|
490
|
+
print(f"{path.name}:")
|
|
491
|
+
proposal_changes = cast(list[dict[str, Any]], proposal["changes"])
|
|
492
|
+
if proposal_changes:
|
|
493
|
+
for change in proposal_changes:
|
|
494
|
+
print(
|
|
495
|
+
f" {change['outcome'].upper()} {change['effective']} {change['field']}"
|
|
496
|
+
)
|
|
497
|
+
else:
|
|
498
|
+
print(" no account changes")
|
|
499
|
+
if not args.apply:
|
|
500
|
+
print("preview only; pass --apply to save")
|
|
501
|
+
return 0
|
|
502
|
+
|
|
503
|
+
if command == "sync":
|
|
504
|
+
_updated, proposals = sync_profile(
|
|
505
|
+
repository,
|
|
506
|
+
args.name,
|
|
507
|
+
since=args.since,
|
|
508
|
+
apply=args.apply,
|
|
509
|
+
keep_statements=args.keep_statements,
|
|
510
|
+
config_path=args.config,
|
|
511
|
+
)
|
|
512
|
+
payload = {"profile": args.name, "applied": args.apply, "proposals": proposals}
|
|
513
|
+
if args.json:
|
|
514
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
515
|
+
else:
|
|
516
|
+
print(f"received {len(proposals)} statement update(s)")
|
|
517
|
+
for proposal in proposals:
|
|
518
|
+
proposal_changes = cast(list[dict[str, Any]], proposal["changes"])
|
|
519
|
+
print(f" {len(proposal_changes)} change(s)" + ("" if args.apply else " (preview)"))
|
|
520
|
+
if not args.apply:
|
|
521
|
+
print("preview only; pass --apply to save")
|
|
522
|
+
return 0
|
|
523
|
+
|
|
524
|
+
if command == "export":
|
|
525
|
+
raw = json.dumps(profile.to_dict(), ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
|
526
|
+
if args.output is None:
|
|
527
|
+
print(raw, end="")
|
|
528
|
+
else:
|
|
529
|
+
args.output.write_text(raw, encoding="utf-8")
|
|
530
|
+
args.output.chmod(0o600)
|
|
531
|
+
if args.json:
|
|
532
|
+
print(json.dumps({"profile": args.name, "output": str(args.output)}))
|
|
533
|
+
return 0
|
|
534
|
+
|
|
535
|
+
if command == "source":
|
|
536
|
+
from .account.cli import meter_source_summary, set_meter_source
|
|
537
|
+
|
|
538
|
+
if args.source_command == "show":
|
|
539
|
+
summary = meter_source_summary(profile, args.provider)
|
|
540
|
+
if args.json:
|
|
541
|
+
print(json.dumps(summary, indent=2))
|
|
542
|
+
elif summary["configured"]:
|
|
543
|
+
print(f"profile: {summary['profile']}")
|
|
544
|
+
print(f"source: {summary['source']}")
|
|
545
|
+
print(f"grid import: {summary['grid_import_entity']}")
|
|
546
|
+
print(f"grid export: {summary['grid_export_entity']}")
|
|
547
|
+
else:
|
|
548
|
+
print(f"profile: {summary['profile']}")
|
|
549
|
+
print(f"source: {summary['source']}")
|
|
550
|
+
print("not configured; the source default will be used")
|
|
551
|
+
return 0
|
|
552
|
+
|
|
553
|
+
updated = set_meter_source(
|
|
554
|
+
repository,
|
|
555
|
+
args.name,
|
|
556
|
+
provider=args.provider,
|
|
557
|
+
grid_import_entity=args.grid_import_entity,
|
|
558
|
+
grid_export_entity=args.grid_export_entity,
|
|
559
|
+
apply=args.apply,
|
|
560
|
+
)
|
|
561
|
+
summary = meter_source_summary(updated, args.provider)
|
|
562
|
+
payload = {**summary, "applied": args.apply}
|
|
563
|
+
if args.json:
|
|
564
|
+
print(json.dumps(payload, indent=2))
|
|
565
|
+
else:
|
|
566
|
+
print(f"profile: {args.name}")
|
|
567
|
+
print(f"source: {args.provider}")
|
|
568
|
+
print(f"grid import: {summary['grid_import_entity']}")
|
|
569
|
+
print(f"grid export: {summary['grid_export_entity']}")
|
|
570
|
+
if not args.apply:
|
|
571
|
+
print("preview only; pass --apply to save")
|
|
572
|
+
return 0
|
|
573
|
+
|
|
574
|
+
raise AssertionError(f"unhandled account command {command}")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _mqtt_settings(args: Any, *, config: Config | None, profile_name: str | None) -> Any:
|
|
578
|
+
"""Build MQTT settings, keeping ``--config``'s stateless choice authoritative.
|
|
579
|
+
|
|
580
|
+
``MqttSettings.load`` independently falls back to a configured default
|
|
581
|
+
profile (from the config file's ``[account]`` table or the
|
|
582
|
+
``TARIFFKIT_ACCOUNT``/``TARIFFKIT_PROFILE`` environment variables) whenever
|
|
583
|
+
it is not told a profile explicitly. That fallback is correct for callers
|
|
584
|
+
of ``MqttSettings.load`` directly, but here ``_pricing_context`` already
|
|
585
|
+
resolved precedence: a non-``None`` ``config`` means the caller chose
|
|
586
|
+
``--config`` and no profile should apply, even if one is configured
|
|
587
|
+
elsewhere. ``profile=None`` is indistinguishable from "not specified" once
|
|
588
|
+
it reaches ``load``'s override handling, so that decision has to be
|
|
589
|
+
re-asserted here instead.
|
|
590
|
+
"""
|
|
591
|
+
from dataclasses import replace
|
|
592
|
+
|
|
593
|
+
from .mqtt import MqttSettings
|
|
594
|
+
|
|
595
|
+
settings = MqttSettings.load(
|
|
596
|
+
config_path=args.config,
|
|
597
|
+
broker=args.broker,
|
|
598
|
+
port=args.port,
|
|
599
|
+
username=args.username,
|
|
600
|
+
topic_prefix=args.topic_prefix,
|
|
601
|
+
discovery=args.discovery,
|
|
602
|
+
forecast_hours=args.forecast_hours,
|
|
603
|
+
tls=args.tls,
|
|
604
|
+
profile=profile_name,
|
|
605
|
+
)
|
|
606
|
+
if config is not None and settings.profile is not None:
|
|
607
|
+
settings = replace(settings, profile=None)
|
|
608
|
+
return settings
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def main(argv: list[str] | None = None) -> int:
|
|
612
|
+
parser = build_parser()
|
|
613
|
+
args = parser.parse_args(argv)
|
|
614
|
+
|
|
615
|
+
logging.basicConfig(
|
|
616
|
+
level=logging.INFO if args.verbose else logging.WARNING,
|
|
617
|
+
format="%(asctime)s %(levelname)s %(message)s",
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
if args.command == "credentials":
|
|
622
|
+
if args.credential_command == "set":
|
|
623
|
+
value = getpass.getpass(f"{args.name}: ")
|
|
624
|
+
if args.credential_set:
|
|
625
|
+
set_named_secret(args.credential_set, args.name, value)
|
|
626
|
+
else:
|
|
627
|
+
set_secret(args.name, value)
|
|
628
|
+
print(f"stored {args.name}")
|
|
629
|
+
elif args.credential_command == "delete":
|
|
630
|
+
if args.credential_set:
|
|
631
|
+
delete_named_secret(args.credential_set, args.name)
|
|
632
|
+
else:
|
|
633
|
+
delete_secret(args.name)
|
|
634
|
+
print(f"deleted {args.name}")
|
|
635
|
+
else:
|
|
636
|
+
names = (
|
|
637
|
+
configured_named_secrets(args.credential_set)
|
|
638
|
+
if args.credential_set
|
|
639
|
+
else configured_secrets()
|
|
640
|
+
)
|
|
641
|
+
for name in names:
|
|
642
|
+
print(name)
|
|
643
|
+
return 0
|
|
644
|
+
|
|
645
|
+
if args.command == "account":
|
|
646
|
+
return _run_account_command(args)
|
|
647
|
+
|
|
648
|
+
engine, config, profile_name, profile_repository = _pricing_context(args)
|
|
649
|
+
|
|
650
|
+
if args.command == "now":
|
|
651
|
+
point = engine.price_now()
|
|
652
|
+
print(json.dumps(point.to_dict(), indent=2) if args.json else _format_point(point))
|
|
653
|
+
return 0
|
|
654
|
+
|
|
655
|
+
if args.command == "forecast":
|
|
656
|
+
start = to_pacific(args.start) if args.start else None
|
|
657
|
+
curve = engine.forecast(hours=args.hours, start=start)
|
|
658
|
+
if args.format == "json":
|
|
659
|
+
print(json.dumps(curve.to_dict(), indent=2))
|
|
660
|
+
elif args.format == "csv":
|
|
661
|
+
_write_csv(curve, sys.stdout)
|
|
662
|
+
else:
|
|
663
|
+
_print_table(curve)
|
|
664
|
+
return 0
|
|
665
|
+
|
|
666
|
+
if args.command == "info":
|
|
667
|
+
info = dict(engine.describe())
|
|
668
|
+
if hasattr(engine, "export_rates"):
|
|
669
|
+
info["exact_through"] = engine.export_rates.exact_through
|
|
670
|
+
info["daily_fixed_charge"] = engine.daily_fixed_charge()
|
|
671
|
+
print(json.dumps(info, indent=2, default=str))
|
|
672
|
+
return 0
|
|
673
|
+
|
|
674
|
+
if args.command == "bill":
|
|
675
|
+
from .billing import BillEngine, BillingPeriod
|
|
676
|
+
from .sources import read_green_button
|
|
677
|
+
|
|
678
|
+
account_profile = None
|
|
679
|
+
if profile_name is not None:
|
|
680
|
+
from .account import AccountRateEngine
|
|
681
|
+
|
|
682
|
+
if not isinstance(engine, AccountRateEngine):
|
|
683
|
+
raise AssertionError("selected account did not produce an account rate engine")
|
|
684
|
+
account_profile = engine.profile
|
|
685
|
+
|
|
686
|
+
note = ""
|
|
687
|
+
if args.source == "ha":
|
|
688
|
+
from .sources import HaSettings, describe_resolution, read_statistics
|
|
689
|
+
|
|
690
|
+
if not (args.start and args.end):
|
|
691
|
+
raise ConfigError("--source ha requires --start and --end")
|
|
692
|
+
ha_settings = HaSettings.load(
|
|
693
|
+
config_path=args.config,
|
|
694
|
+
profile_source=(
|
|
695
|
+
account_profile.meter_sources.ha if account_profile is not None else None
|
|
696
|
+
),
|
|
697
|
+
import_entity=args.ha_import_entity,
|
|
698
|
+
export_entity=args.ha_export_entity,
|
|
699
|
+
)
|
|
700
|
+
readings = read_statistics(
|
|
701
|
+
ha_settings,
|
|
702
|
+
_midnight(args.start),
|
|
703
|
+
_midnight(args.end) + timedelta(days=1),
|
|
704
|
+
resolution=args.ha_resolution,
|
|
705
|
+
)
|
|
706
|
+
note = f" source: Home Assistant statistics ({describe_resolution(readings)})"
|
|
707
|
+
elif args.source == "influx":
|
|
708
|
+
from .sources import InfluxSettings, read_counters
|
|
709
|
+
|
|
710
|
+
if not (args.start and args.end):
|
|
711
|
+
raise ConfigError("--source influx requires --start and --end")
|
|
712
|
+
influx_settings = InfluxSettings.load(
|
|
713
|
+
config_path=args.config,
|
|
714
|
+
profile_source=(
|
|
715
|
+
account_profile.meter_sources.influx
|
|
716
|
+
if account_profile is not None
|
|
717
|
+
else None
|
|
718
|
+
),
|
|
719
|
+
import_entity=args.influx_import_entity,
|
|
720
|
+
export_entity=args.influx_export_entity,
|
|
721
|
+
)
|
|
722
|
+
step = timedelta(minutes=args.influx_resolution)
|
|
723
|
+
readings = read_counters(
|
|
724
|
+
influx_settings,
|
|
725
|
+
_midnight(args.start),
|
|
726
|
+
_midnight(args.end) + timedelta(days=1),
|
|
727
|
+
step,
|
|
728
|
+
)
|
|
729
|
+
note = (
|
|
730
|
+
f" source: InfluxDB counters ({len(readings)} x {args.influx_resolution}min; "
|
|
731
|
+
f"totals are exact, distribution follows sample density)"
|
|
732
|
+
)
|
|
733
|
+
elif args.csv is None:
|
|
734
|
+
raise ConfigError(
|
|
735
|
+
"give a Green Button CSV path, or use --source ha or --source influx"
|
|
736
|
+
)
|
|
737
|
+
else:
|
|
738
|
+
readings = read_green_button(sys.stdin if str(args.csv) == "-" else args.csv)
|
|
739
|
+
note = f" source: Green Button CSV ({len(readings)} intervals)"
|
|
740
|
+
|
|
741
|
+
period = (
|
|
742
|
+
BillingPeriod(args.start, args.end)
|
|
743
|
+
if args.start and args.end
|
|
744
|
+
else BillingPeriod.from_readings(readings)
|
|
745
|
+
)
|
|
746
|
+
if profile_name is not None:
|
|
747
|
+
from .billing.engine import compute_segments
|
|
748
|
+
|
|
749
|
+
assert account_profile is not None
|
|
750
|
+
result = compute_segments(
|
|
751
|
+
account_profile.segments_for(period),
|
|
752
|
+
readings,
|
|
753
|
+
check=args.check,
|
|
754
|
+
)
|
|
755
|
+
else:
|
|
756
|
+
result = BillEngine(engine).compute(readings, period, check=args.check)
|
|
757
|
+
if args.json:
|
|
758
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
759
|
+
else:
|
|
760
|
+
_print_bill(result)
|
|
761
|
+
if note:
|
|
762
|
+
print(note)
|
|
763
|
+
return 0
|
|
764
|
+
|
|
765
|
+
if args.command == "mqtt":
|
|
766
|
+
from .mqtt import MqttPublisher
|
|
767
|
+
|
|
768
|
+
settings = _mqtt_settings(args, config=config, profile_name=profile_name)
|
|
769
|
+
publisher = MqttPublisher(engine, settings)
|
|
770
|
+
if args.once:
|
|
771
|
+
publisher.connect()
|
|
772
|
+
try:
|
|
773
|
+
publisher.publish_now()
|
|
774
|
+
finally:
|
|
775
|
+
publisher.close()
|
|
776
|
+
else:
|
|
777
|
+
publisher.run_forever()
|
|
778
|
+
return 0
|
|
779
|
+
|
|
780
|
+
if args.command == "serve":
|
|
781
|
+
import uvicorn
|
|
782
|
+
|
|
783
|
+
from .web import create_app
|
|
784
|
+
|
|
785
|
+
uvicorn.run(
|
|
786
|
+
create_app(
|
|
787
|
+
config,
|
|
788
|
+
profile_name=profile_name,
|
|
789
|
+
profile_repository=profile_repository,
|
|
790
|
+
config_path=args.config,
|
|
791
|
+
),
|
|
792
|
+
host=args.host,
|
|
793
|
+
port=args.port,
|
|
794
|
+
)
|
|
795
|
+
return 0
|
|
796
|
+
|
|
797
|
+
except TariffKitError as exc:
|
|
798
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
799
|
+
return 1
|
|
800
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
801
|
+
return 130
|
|
802
|
+
|
|
803
|
+
# Unreachable: argparse enforces `required=True` on the subparser, so an
|
|
804
|
+
# unknown command exits before we get here.
|
|
805
|
+
raise AssertionError(f"unhandled command {args.command}")
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
if __name__ == "__main__": # pragma: no cover
|
|
809
|
+
sys.exit(main())
|