arcobi-sdk 0.1.0b1__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.
- arcobi_sdk/__init__.py +88 -0
- arcobi_sdk/cli.py +395 -0
- arcobi_sdk/config.py +78 -0
- arcobi_sdk/datahub/__init__.py +63 -0
- arcobi_sdk/datahub/client.py +525 -0
- arcobi_sdk/datahub/extract.py +588 -0
- arcobi_sdk/datahub/specs.py +247 -0
- arcobi_sdk/errors.py +68 -0
- arcobi_sdk/excel/__init__.py +58 -0
- arcobi_sdk/excel/convert.py +361 -0
- arcobi_sdk/excel/udf.py +369 -0
- arcobi_sdk/excel/vba/ArcobiSdk.bas +327 -0
- arcobi_sdk/excel/vba/README.md +157 -0
- arcobi_sdk/io.py +234 -0
- arcobi_sdk/pool/__init__.py +35 -0
- arcobi_sdk/pool/client.py +704 -0
- arcobi_sdk/pool/priority.py +105 -0
- arcobi_sdk/pool/protocol.py +143 -0
- arcobi_sdk/pool/server/__init__.py +12 -0
- arcobi_sdk/pool/server/__main__.py +55 -0
- arcobi_sdk/pool/server/api/__init__.py +1 -0
- arcobi_sdk/pool/server/api/deps.py +55 -0
- arcobi_sdk/pool/server/api/routes.py +113 -0
- arcobi_sdk/pool/server/api/websocket.py +141 -0
- arcobi_sdk/pool/server/app.py +111 -0
- arcobi_sdk/pool/server/config.py +88 -0
- arcobi_sdk/pool/server/core/__init__.py +1 -0
- arcobi_sdk/pool/server/core/credentials.py +146 -0
- arcobi_sdk/pool/server/core/lease_manager.py +639 -0
- arcobi_sdk/pool/server/core/metrics.py +85 -0
- arcobi_sdk/pool/server/core/queue.py +179 -0
- arcobi_sdk/pool/server/core/store.py +973 -0
- arcobi_sdk/pool/server/providers/__init__.py +80 -0
- arcobi_sdk/pool/server/providers/base.py +68 -0
- arcobi_sdk/pool/server/providers/datahub.py +182 -0
- arcobi_sdk/pool/server/providers/static.py +57 -0
- arcobi_sdk/timeutils.py +76 -0
- arcobi_sdk-0.1.0b1.dist-info/METADATA +164 -0
- arcobi_sdk-0.1.0b1.dist-info/RECORD +42 -0
- arcobi_sdk-0.1.0b1.dist-info/WHEEL +4 -0
- arcobi_sdk-0.1.0b1.dist-info/entry_points.txt +2 -0
- arcobi_sdk-0.1.0b1.dist-info/licenses/LICENSE +37 -0
arcobi_sdk/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""arcobi_sdk: fetch, align and export energy-market time-series data.
|
|
2
|
+
|
|
3
|
+
The default install contains the Datahub client and extraction helpers (fetch
|
|
4
|
+
streams, align them on a time grid), time-zone conversion, export helpers
|
|
5
|
+
(DataFrame/CSV/Parquet/JSON) and the credential-pool *client*. Heavier pieces are
|
|
6
|
+
extras: ``arcobi-sdk[parquet]`` and ``[pool-server]`` (the credential-pool service).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from arcobi_sdk.config import load_env
|
|
10
|
+
from arcobi_sdk.datahub import (
|
|
11
|
+
DatahubClient,
|
|
12
|
+
DatasetSpec,
|
|
13
|
+
ResolutionPlan,
|
|
14
|
+
StreamSpec,
|
|
15
|
+
build_time_grid,
|
|
16
|
+
fetch_and_align,
|
|
17
|
+
fetch_streams,
|
|
18
|
+
interpolate_gaps,
|
|
19
|
+
plan_extraction,
|
|
20
|
+
)
|
|
21
|
+
from arcobi_sdk.errors import (
|
|
22
|
+
ArcobiError,
|
|
23
|
+
ConfigurationError,
|
|
24
|
+
DatahubAuthError,
|
|
25
|
+
DatahubError,
|
|
26
|
+
ExtractionError,
|
|
27
|
+
LeaseConflictError,
|
|
28
|
+
LeaseReclaimedError,
|
|
29
|
+
PoolError,
|
|
30
|
+
PoolExhaustedError,
|
|
31
|
+
PoolUnavailableError,
|
|
32
|
+
RateLimitedError,
|
|
33
|
+
StreamNotFoundError,
|
|
34
|
+
TokenExpiredError,
|
|
35
|
+
)
|
|
36
|
+
from arcobi_sdk.io import (
|
|
37
|
+
create_timestamp_directory,
|
|
38
|
+
find_latest_data_file,
|
|
39
|
+
save_json,
|
|
40
|
+
to_csv,
|
|
41
|
+
to_json,
|
|
42
|
+
to_parquet,
|
|
43
|
+
track_runtime,
|
|
44
|
+
)
|
|
45
|
+
from arcobi_sdk.pool import PoolClient, TokenLease, lease_token
|
|
46
|
+
from arcobi_sdk.timeutils import convert_local_to_utc, format_timestamp_folder, timestamp_name
|
|
47
|
+
|
|
48
|
+
__version__ = "0.1.0b1"
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"ArcobiError",
|
|
52
|
+
"ConfigurationError",
|
|
53
|
+
"DatahubAuthError",
|
|
54
|
+
"DatahubClient",
|
|
55
|
+
"DatahubError",
|
|
56
|
+
"DatasetSpec",
|
|
57
|
+
"ExtractionError",
|
|
58
|
+
"LeaseConflictError",
|
|
59
|
+
"LeaseReclaimedError",
|
|
60
|
+
"PoolClient",
|
|
61
|
+
"PoolError",
|
|
62
|
+
"PoolExhaustedError",
|
|
63
|
+
"PoolUnavailableError",
|
|
64
|
+
"RateLimitedError",
|
|
65
|
+
"ResolutionPlan",
|
|
66
|
+
"StreamNotFoundError",
|
|
67
|
+
"StreamSpec",
|
|
68
|
+
"TokenExpiredError",
|
|
69
|
+
"TokenLease",
|
|
70
|
+
"__version__",
|
|
71
|
+
"build_time_grid",
|
|
72
|
+
"convert_local_to_utc",
|
|
73
|
+
"create_timestamp_directory",
|
|
74
|
+
"fetch_and_align",
|
|
75
|
+
"fetch_streams",
|
|
76
|
+
"find_latest_data_file",
|
|
77
|
+
"format_timestamp_folder",
|
|
78
|
+
"interpolate_gaps",
|
|
79
|
+
"lease_token",
|
|
80
|
+
"load_env",
|
|
81
|
+
"plan_extraction",
|
|
82
|
+
"save_json",
|
|
83
|
+
"timestamp_name",
|
|
84
|
+
"to_csv",
|
|
85
|
+
"to_json",
|
|
86
|
+
"to_parquet",
|
|
87
|
+
"track_runtime",
|
|
88
|
+
]
|
arcobi_sdk/cli.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Command-line interface: ``arcobi-sdk fetch | check | version``.
|
|
2
|
+
|
|
3
|
+
The CLI exists so that tools without a Python runtime of their own (Excel/VBA macros,
|
|
4
|
+
PowerShell, schedulers) can pull Datahub data through the SDK. Its contract is
|
|
5
|
+
designed for ``Shell``/``WScript.Shell`` consumers:
|
|
6
|
+
|
|
7
|
+
* exit codes: ``0`` success, ``2`` bad arguments or configuration, ``3`` vendor or
|
|
8
|
+
authentication error, ``4`` the request succeeded but returned no data;
|
|
9
|
+
* every failure is reported on **stderr** as exactly one line starting with
|
|
10
|
+
``error:``; warnings from the SDK's loggers may precede it;
|
|
11
|
+
* ``--out -`` streams the result to **stdout** (CSV by default, or
|
|
12
|
+
``--format json-lines``), otherwise the format follows the ``--out`` extension and
|
|
13
|
+
stdout carries one ``ok:`` summary line (suppressed by ``--quiet``).
|
|
14
|
+
|
|
15
|
+
Credentials are never taken on the command line: set ``DATAHUB_USERNAME`` /
|
|
16
|
+
``DATAHUB_PASSWORD`` in the environment or in a ``.env`` file (``--env-file``,
|
|
17
|
+
default ``./.env``). Only stdlib modules are used besides the SDK itself.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import logging
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from collections.abc import Callable, Sequence
|
|
27
|
+
from datetime import datetime, timedelta
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import pandas as pd
|
|
31
|
+
|
|
32
|
+
from arcobi_sdk import __version__
|
|
33
|
+
from arcobi_sdk.config import load_env
|
|
34
|
+
from arcobi_sdk.datahub import DatahubClient, fetch_streams
|
|
35
|
+
from arcobi_sdk.errors import ArcobiError, ConfigurationError, DatahubError, ExtractionError
|
|
36
|
+
from arcobi_sdk.io import to_csv, to_json, to_parquet
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
EXIT_OK = 0
|
|
41
|
+
EXIT_USAGE = 2
|
|
42
|
+
EXIT_VENDOR = 3
|
|
43
|
+
EXIT_NO_DATA = 4
|
|
44
|
+
|
|
45
|
+
PROG = "arcobi-sdk"
|
|
46
|
+
INDEX_LABEL = "timestamp"
|
|
47
|
+
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
48
|
+
FORMATS: tuple[str, ...] = ("csv", "json", "json-lines", "parquet")
|
|
49
|
+
_SUFFIX_FORMATS: dict[str, str] = {
|
|
50
|
+
".csv": "csv",
|
|
51
|
+
".txt": "csv",
|
|
52
|
+
".json": "json",
|
|
53
|
+
".jsonl": "json-lines",
|
|
54
|
+
".ndjson": "json-lines",
|
|
55
|
+
".parquet": "parquet",
|
|
56
|
+
".pq": "parquet",
|
|
57
|
+
}
|
|
58
|
+
_LIST_SEPARATOR = re.compile(r"[,;\s]+")
|
|
59
|
+
_NO_ROWS_MARKER = "returned no rows"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class UsageError(Exception):
|
|
63
|
+
"""Invalid command-line arguments; mapped to exit code 2."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Parser(argparse.ArgumentParser):
|
|
67
|
+
"""ArgumentParser that raises :class:`UsageError` instead of exiting."""
|
|
68
|
+
|
|
69
|
+
def error(self, message: str) -> None: # type: ignore[override]
|
|
70
|
+
raise UsageError(message)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_timestamp(text: str) -> datetime:
|
|
74
|
+
"""Parse a CLI timestamp into a naive :class:`datetime`.
|
|
75
|
+
|
|
76
|
+
Accepts ISO 8601 (``2024-01-08``, ``2024-01-08 06:30``, ``2024-01-08T06:30:00``)
|
|
77
|
+
and the keywords ``now``, ``today``, ``yesterday`` and ``tomorrow``. Time-zone
|
|
78
|
+
offsets are dropped: Datahub interprets timestamps as market-local wall time.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
text: The raw argument.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
A naive datetime.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
UsageError: If the text is not a recognised timestamp.
|
|
88
|
+
"""
|
|
89
|
+
value = text.strip()
|
|
90
|
+
keyword = value.lower()
|
|
91
|
+
now = datetime.now()
|
|
92
|
+
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
93
|
+
if keyword == "now":
|
|
94
|
+
return now.replace(microsecond=0)
|
|
95
|
+
if keyword == "today":
|
|
96
|
+
return midnight
|
|
97
|
+
if keyword == "yesterday":
|
|
98
|
+
return midnight - timedelta(days=1)
|
|
99
|
+
if keyword == "tomorrow":
|
|
100
|
+
return midnight + timedelta(days=1)
|
|
101
|
+
try:
|
|
102
|
+
parsed = datetime.fromisoformat(value)
|
|
103
|
+
except ValueError:
|
|
104
|
+
raise UsageError(
|
|
105
|
+
f"invalid timestamp {text!r}; use YYYY-MM-DD[ HH:MM[:SS]], "
|
|
106
|
+
"today, yesterday, tomorrow or now"
|
|
107
|
+
) from None
|
|
108
|
+
return parsed.replace(tzinfo=None)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_stream_ids(text: str) -> list[int]:
|
|
112
|
+
"""Parse ``"1001,1002"`` (commas, semicolons or spaces) into positive ints.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
UsageError: On an empty list or a non-integer item.
|
|
116
|
+
"""
|
|
117
|
+
items = [item for item in _LIST_SEPARATOR.split(text.strip()) if item]
|
|
118
|
+
if not items:
|
|
119
|
+
raise UsageError("--streams needs at least one stream id")
|
|
120
|
+
ids: list[int] = []
|
|
121
|
+
for item in items:
|
|
122
|
+
try:
|
|
123
|
+
value = int(item)
|
|
124
|
+
except ValueError:
|
|
125
|
+
raise UsageError(f"invalid stream id {item!r}; expected an integer") from None
|
|
126
|
+
if value <= 0:
|
|
127
|
+
raise UsageError(f"invalid stream id {value}; expected a positive integer")
|
|
128
|
+
ids.append(value)
|
|
129
|
+
return ids
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_names(text: str | None, count: int) -> list[str] | None:
|
|
133
|
+
"""Parse ``--names a,b`` and check it matches the number of streams.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
UsageError: If the counts differ or a name is blank.
|
|
137
|
+
"""
|
|
138
|
+
if text is None or not text.strip():
|
|
139
|
+
return None
|
|
140
|
+
names = [name.strip() for name in text.split(",")]
|
|
141
|
+
if any(not name for name in names):
|
|
142
|
+
raise UsageError("--names contains an empty name")
|
|
143
|
+
if len(names) != count:
|
|
144
|
+
raise UsageError(f"--names has {len(names)} items but --streams has {count}")
|
|
145
|
+
return names
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_format(out: str, explicit: str | None) -> str:
|
|
149
|
+
"""Pick the output format from ``--format`` or the ``--out`` extension.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
out: The ``--out`` argument (``-`` means stdout).
|
|
153
|
+
explicit: The ``--format`` argument, if given.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
One of :data:`FORMATS`.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
UsageError: Unknown extension, or Parquet requested on stdout.
|
|
160
|
+
"""
|
|
161
|
+
if explicit is None:
|
|
162
|
+
if out == "-":
|
|
163
|
+
fmt = "csv"
|
|
164
|
+
else:
|
|
165
|
+
suffix = Path(out).suffix.lower()
|
|
166
|
+
fmt = _SUFFIX_FORMATS.get(suffix, "")
|
|
167
|
+
if not fmt:
|
|
168
|
+
raise UsageError(
|
|
169
|
+
f"cannot infer the format of {out!r}; use one of "
|
|
170
|
+
f"{sorted(_SUFFIX_FORMATS)} or pass --format"
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
fmt = explicit
|
|
174
|
+
if out == "-" and fmt == "parquet":
|
|
175
|
+
raise UsageError("parquet cannot be written to stdout; use --out <file>.parquet")
|
|
176
|
+
return fmt
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def write_frame(df: pd.DataFrame, out: str, fmt: str) -> None:
|
|
180
|
+
"""Write ``df`` to ``out`` (a path or ``-``) in ``fmt``.
|
|
181
|
+
|
|
182
|
+
The index is labelled ``timestamp`` and rendered as ``YYYY-MM-DD HH:MM:SS`` (CSV)
|
|
183
|
+
or ISO 8601 to the second (JSON), so VBA, PowerShell and Excel parse it directly.
|
|
184
|
+
"""
|
|
185
|
+
frame = df.copy()
|
|
186
|
+
frame.index.name = INDEX_LABEL
|
|
187
|
+
if fmt == "csv":
|
|
188
|
+
if out == "-":
|
|
189
|
+
frame.to_csv(sys.stdout, date_format=DATE_FORMAT, lineterminator="\n")
|
|
190
|
+
else:
|
|
191
|
+
to_csv(frame, out, date_format=DATE_FORMAT, lineterminator="\n")
|
|
192
|
+
return
|
|
193
|
+
if fmt == "parquet":
|
|
194
|
+
to_parquet(frame, out)
|
|
195
|
+
return
|
|
196
|
+
lines = fmt == "json-lines"
|
|
197
|
+
if out == "-":
|
|
198
|
+
text = frame.reset_index().to_json(
|
|
199
|
+
orient="records", date_format="iso", date_unit="s", lines=lines
|
|
200
|
+
)
|
|
201
|
+
sys.stdout.write(text)
|
|
202
|
+
if not text.endswith("\n"):
|
|
203
|
+
sys.stdout.write("\n")
|
|
204
|
+
return
|
|
205
|
+
to_json(frame, out, date_unit="s", lines=lines)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _fail(message: str) -> None:
|
|
209
|
+
"""Print one ``error:`` line on stderr (newlines collapsed)."""
|
|
210
|
+
flat = " ".join(str(message).split())
|
|
211
|
+
print(f"error: {flat}", file=sys.stderr)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def cmd_fetch(args: argparse.Namespace) -> int:
|
|
215
|
+
"""Run ``arcobi-sdk fetch``."""
|
|
216
|
+
stream_ids = parse_stream_ids(args.streams)
|
|
217
|
+
names = parse_names(args.names, len(stream_ids))
|
|
218
|
+
start = parse_timestamp(args.start)
|
|
219
|
+
end = parse_timestamp(args.end)
|
|
220
|
+
if end <= start:
|
|
221
|
+
raise UsageError(f"--end ({end}) must be after --start ({start})")
|
|
222
|
+
if args.resolution <= 0:
|
|
223
|
+
raise UsageError("--resolution must be a positive number of minutes")
|
|
224
|
+
fmt = resolve_format(args.out, args.format)
|
|
225
|
+
load_env(args.env_file)
|
|
226
|
+
|
|
227
|
+
with DatahubClient() as client:
|
|
228
|
+
try:
|
|
229
|
+
df = fetch_streams(
|
|
230
|
+
client,
|
|
231
|
+
stream_ids,
|
|
232
|
+
start,
|
|
233
|
+
end,
|
|
234
|
+
names=names,
|
|
235
|
+
data_option=args.data_option,
|
|
236
|
+
resolution=args.resolution,
|
|
237
|
+
)
|
|
238
|
+
except ExtractionError as exc:
|
|
239
|
+
if _NO_ROWS_MARKER in str(exc):
|
|
240
|
+
_fail(str(exc))
|
|
241
|
+
return EXIT_NO_DATA
|
|
242
|
+
raise
|
|
243
|
+
|
|
244
|
+
if df.empty or df.isna().all().all():
|
|
245
|
+
_fail(f"streams {args.streams} returned no data between {start} and {end}")
|
|
246
|
+
return EXIT_NO_DATA
|
|
247
|
+
|
|
248
|
+
df = df.sort_index()
|
|
249
|
+
write_frame(df, args.out, fmt)
|
|
250
|
+
if args.out != "-" and not args.quiet:
|
|
251
|
+
print(f"ok: {len(df)} rows, {len(df.columns)} columns -> {args.out}")
|
|
252
|
+
return EXIT_OK
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
256
|
+
"""Run ``arcobi-sdk check``: authenticate, report the expiry, release the session."""
|
|
257
|
+
load_env(args.env_file)
|
|
258
|
+
with DatahubClient() as client:
|
|
259
|
+
client.get_token()
|
|
260
|
+
expires = client.expires_at
|
|
261
|
+
when = expires.strftime("%Y-%m-%dT%H:%M:%SZ") if expires is not None else "unknown"
|
|
262
|
+
print(f"ok: authenticated; token expires {when}; session released")
|
|
263
|
+
return EXIT_OK
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def cmd_version(_args: argparse.Namespace) -> int:
|
|
267
|
+
"""Run ``arcobi-sdk version``."""
|
|
268
|
+
print(f"{PROG} {__version__}")
|
|
269
|
+
return EXIT_OK
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _add_common_arguments(parser: argparse.ArgumentParser) -> None:
|
|
273
|
+
"""Add the ``--env-file`` and verbosity options shared by the vendor-facing commands."""
|
|
274
|
+
parser.add_argument(
|
|
275
|
+
"--env-file",
|
|
276
|
+
default=".env",
|
|
277
|
+
metavar="PATH",
|
|
278
|
+
help="KEY=VALUE file with DATAHUB_USERNAME/DATAHUB_PASSWORD (default ./.env; "
|
|
279
|
+
"a missing file is ignored and never overrides the environment)",
|
|
280
|
+
)
|
|
281
|
+
verbosity = parser.add_mutually_exclusive_group()
|
|
282
|
+
verbosity.add_argument(
|
|
283
|
+
"--quiet", action="store_true", help="no 'ok:' summary and only error logs"
|
|
284
|
+
)
|
|
285
|
+
verbosity.add_argument("--verbose", action="store_true", help="info-level logs on stderr")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _add_fetch_arguments(parser: argparse.ArgumentParser) -> None:
|
|
289
|
+
"""Add the stream, window and output options of the ``fetch`` command."""
|
|
290
|
+
parser.add_argument(
|
|
291
|
+
"--streams", required=True, metavar="IDS", help="comma-separated stream ids, e.g. 1001,1002"
|
|
292
|
+
)
|
|
293
|
+
parser.add_argument(
|
|
294
|
+
"--names", metavar="NAMES", help="comma-separated column names, one per stream"
|
|
295
|
+
)
|
|
296
|
+
parser.add_argument("--start", required=True, metavar="WHEN", help="window start (inclusive)")
|
|
297
|
+
parser.add_argument("--end", required=True, metavar="WHEN", help="window end")
|
|
298
|
+
parser.add_argument(
|
|
299
|
+
"--resolution", type=float, default=60, metavar="MIN", help="expected step in minutes"
|
|
300
|
+
)
|
|
301
|
+
parser.add_argument(
|
|
302
|
+
"--data-option", dest="data_option", metavar="X", help="Datahub dataOption value"
|
|
303
|
+
)
|
|
304
|
+
parser.add_argument(
|
|
305
|
+
"--out",
|
|
306
|
+
required=True,
|
|
307
|
+
metavar="PATH",
|
|
308
|
+
help="output file (.csv, .parquet, .json, .jsonl) or '-' for stdout",
|
|
309
|
+
)
|
|
310
|
+
parser.add_argument(
|
|
311
|
+
"--format",
|
|
312
|
+
choices=FORMATS,
|
|
313
|
+
help="output format (default: from the --out extension; csv for stdout)",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
318
|
+
"""Build the top-level parser with the ``fetch``, ``check`` and ``version`` commands."""
|
|
319
|
+
parser = _Parser(
|
|
320
|
+
prog=PROG,
|
|
321
|
+
description="Fetch Datahub time series into CSV/Parquet/JSON files or stdout.",
|
|
322
|
+
epilog=(
|
|
323
|
+
"exit codes: 0 ok, 2 bad arguments/configuration, 3 vendor/auth error, "
|
|
324
|
+
"4 no data. Errors are one 'error: ...' line on stderr."
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
parser.add_argument("--version", action="version", version=f"{PROG} {__version__}")
|
|
328
|
+
sub = parser.add_subparsers(dest="command", metavar="command")
|
|
329
|
+
sub.required = True
|
|
330
|
+
|
|
331
|
+
fetch = sub.add_parser("fetch", help="fetch one or more streams and write them")
|
|
332
|
+
_add_fetch_arguments(fetch)
|
|
333
|
+
_add_common_arguments(fetch)
|
|
334
|
+
fetch.set_defaults(func=cmd_fetch)
|
|
335
|
+
|
|
336
|
+
check = sub.add_parser("check", help="authenticate, print the token expiry and release")
|
|
337
|
+
_add_common_arguments(check)
|
|
338
|
+
check.set_defaults(func=cmd_check)
|
|
339
|
+
|
|
340
|
+
version = sub.add_parser("version", help="print the SDK version")
|
|
341
|
+
version.set_defaults(func=cmd_version, quiet=False, verbose=False)
|
|
342
|
+
return parser
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _configure_logging(quiet: bool, verbose: bool) -> None:
|
|
346
|
+
level = logging.ERROR if quiet else logging.INFO if verbose else logging.WARNING
|
|
347
|
+
logging.basicConfig(
|
|
348
|
+
level=level, stream=sys.stderr, format="%(levelname)s %(name)s: %(message)s", force=True
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
353
|
+
"""Entry point for ``arcobi-sdk``.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
argv: Arguments without the program name; defaults to ``sys.argv[1:]``.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
The process exit code (see the module docstring).
|
|
360
|
+
"""
|
|
361
|
+
parser = build_parser()
|
|
362
|
+
try:
|
|
363
|
+
args = parser.parse_args(argv)
|
|
364
|
+
except UsageError as exc:
|
|
365
|
+
_fail(str(exc))
|
|
366
|
+
return EXIT_USAGE
|
|
367
|
+
except SystemExit as exc: # --help / --version
|
|
368
|
+
return int(exc.code or 0)
|
|
369
|
+
|
|
370
|
+
_configure_logging(getattr(args, "quiet", False), getattr(args, "verbose", False))
|
|
371
|
+
command: Callable[[argparse.Namespace], int] = args.func
|
|
372
|
+
try:
|
|
373
|
+
return command(args)
|
|
374
|
+
except (UsageError, ConfigurationError) as exc:
|
|
375
|
+
_fail(str(exc))
|
|
376
|
+
return EXIT_USAGE
|
|
377
|
+
except DatahubError as exc:
|
|
378
|
+
_fail(str(exc))
|
|
379
|
+
return EXIT_VENDOR
|
|
380
|
+
except ArcobiError as exc:
|
|
381
|
+
_fail(str(exc))
|
|
382
|
+
return EXIT_VENDOR
|
|
383
|
+
except OSError as exc:
|
|
384
|
+
_fail(f"cannot write output: {exc}")
|
|
385
|
+
return EXIT_USAGE
|
|
386
|
+
except ValueError as exc:
|
|
387
|
+
_fail(str(exc))
|
|
388
|
+
return EXIT_USAGE
|
|
389
|
+
except KeyboardInterrupt:
|
|
390
|
+
_fail("interrupted")
|
|
391
|
+
return 130
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
if __name__ == "__main__": # pragma: no cover
|
|
395
|
+
sys.exit(main())
|
arcobi_sdk/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Credential and settings helpers.
|
|
2
|
+
|
|
3
|
+
The SDK reads vendor credentials from constructor arguments first and from
|
|
4
|
+
environment variables second. :func:`load_env` is a tiny, dependency-free
|
|
5
|
+
``.env`` reader for local development so those variables do not have to be
|
|
6
|
+
exported by hand. It never overrides variables that are already set, so CI
|
|
7
|
+
secrets always win over a developer's file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
DATAHUB_ENV_VARS: tuple[str, ...] = (
|
|
17
|
+
"DATAHUB_USERNAME",
|
|
18
|
+
"DATAHUB_PASSWORD",
|
|
19
|
+
"DATAHUB_BEARER_TOKEN",
|
|
20
|
+
)
|
|
21
|
+
POOL_ENV_VARS: tuple[str, ...] = ("ARCOBI_POOL_URL", "ARCOBI_POOL_API_KEY")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_env_text(text: str) -> dict[str, str]:
|
|
25
|
+
"""Parse ``KEY=VALUE`` lines (``export`` prefix, quotes and ``#`` comments allowed)."""
|
|
26
|
+
values: dict[str, str] = {}
|
|
27
|
+
for raw in text.splitlines():
|
|
28
|
+
line = raw.strip()
|
|
29
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
30
|
+
continue
|
|
31
|
+
if line.startswith("export "):
|
|
32
|
+
line = line[len("export ") :].lstrip()
|
|
33
|
+
key, _, value = line.partition("=")
|
|
34
|
+
key = key.strip()
|
|
35
|
+
value = value.strip()
|
|
36
|
+
if value[:1] in {'"', "'"} and value[-1:] == value[:1] and len(value) >= 2:
|
|
37
|
+
value = value[1:-1]
|
|
38
|
+
elif " #" in value:
|
|
39
|
+
value = value.split(" #", 1)[0].rstrip()
|
|
40
|
+
if key:
|
|
41
|
+
values[key] = value
|
|
42
|
+
return values
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_env(
|
|
46
|
+
path: str | os.PathLike[str] = ".env",
|
|
47
|
+
*,
|
|
48
|
+
override: bool = False,
|
|
49
|
+
environ: Mapping[str, str] | None = None,
|
|
50
|
+
) -> dict[str, str]:
|
|
51
|
+
"""Load ``KEY=VALUE`` pairs from ``path`` into the process environment.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
path: The file to read. A missing file is not an error (returns ``{}``).
|
|
55
|
+
override: Replace variables that are already set. Default keeps existing values.
|
|
56
|
+
environ: Mapping to update; defaults to :data:`os.environ`. Mainly for tests.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The variables that were actually set by this call.
|
|
60
|
+
"""
|
|
61
|
+
target: Mapping[str, str] = os.environ if environ is None else environ
|
|
62
|
+
file = Path(path)
|
|
63
|
+
if not file.is_file():
|
|
64
|
+
return {}
|
|
65
|
+
applied: dict[str, str] = {}
|
|
66
|
+
for key, value in parse_env_text(file.read_text(encoding="utf-8")).items():
|
|
67
|
+
if override or key not in target:
|
|
68
|
+
target[key] = value # type: ignore[index]
|
|
69
|
+
applied[key] = value
|
|
70
|
+
return applied
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def datahub_credentials_present(environ: Mapping[str, str] | None = None) -> bool:
|
|
74
|
+
"""True when the environment holds a bearer token or a username+password pair."""
|
|
75
|
+
env = os.environ if environ is None else environ
|
|
76
|
+
if env.get("DATAHUB_BEARER_TOKEN"):
|
|
77
|
+
return True
|
|
78
|
+
return bool(env.get("DATAHUB_USERNAME") and env.get("DATAHUB_PASSWORD"))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Datahub integration: authenticated client, multi-stream extraction and specs.
|
|
2
|
+
|
|
3
|
+
Import :class:`DatahubClient` to talk to the API, describe what to pull with
|
|
4
|
+
:class:`StreamSpec` / :class:`DatasetSpec`, and use :func:`fetch_streams` or the
|
|
5
|
+
planner trio (:func:`plan_extraction`, :func:`build_time_grid`, :func:`fetch_and_align`)
|
|
6
|
+
to get aligned DataFrames.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from arcobi_sdk.datahub.client import (
|
|
12
|
+
DEFAULT_BASE_URL,
|
|
13
|
+
INDEX_CANDIDATES,
|
|
14
|
+
DatahubClient,
|
|
15
|
+
TokenProvider,
|
|
16
|
+
format_api_datetime,
|
|
17
|
+
frame_from_payload,
|
|
18
|
+
select_value_column,
|
|
19
|
+
)
|
|
20
|
+
from arcobi_sdk.datahub.extract import (
|
|
21
|
+
DEFAULT_INTERPOLATE_LIMIT,
|
|
22
|
+
DEFAULT_PADDING_HOURS,
|
|
23
|
+
ResolutionPlan,
|
|
24
|
+
build_time_grid,
|
|
25
|
+
datahub_extract,
|
|
26
|
+
extract_merge_data_settings,
|
|
27
|
+
fetch_and_align,
|
|
28
|
+
fetch_streams,
|
|
29
|
+
interpolate_gaps,
|
|
30
|
+
plan_extraction,
|
|
31
|
+
)
|
|
32
|
+
from arcobi_sdk.datahub.specs import (
|
|
33
|
+
NAME_COLUMN_SEPARATOR,
|
|
34
|
+
DatasetSpec,
|
|
35
|
+
Role,
|
|
36
|
+
StreamSpec,
|
|
37
|
+
split_name_column,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"DEFAULT_BASE_URL",
|
|
42
|
+
"DEFAULT_INTERPOLATE_LIMIT",
|
|
43
|
+
"DEFAULT_PADDING_HOURS",
|
|
44
|
+
"INDEX_CANDIDATES",
|
|
45
|
+
"NAME_COLUMN_SEPARATOR",
|
|
46
|
+
"DatahubClient",
|
|
47
|
+
"DatasetSpec",
|
|
48
|
+
"ResolutionPlan",
|
|
49
|
+
"Role",
|
|
50
|
+
"StreamSpec",
|
|
51
|
+
"TokenProvider",
|
|
52
|
+
"build_time_grid",
|
|
53
|
+
"datahub_extract",
|
|
54
|
+
"extract_merge_data_settings",
|
|
55
|
+
"fetch_and_align",
|
|
56
|
+
"fetch_streams",
|
|
57
|
+
"format_api_datetime",
|
|
58
|
+
"frame_from_payload",
|
|
59
|
+
"interpolate_gaps",
|
|
60
|
+
"plan_extraction",
|
|
61
|
+
"select_value_column",
|
|
62
|
+
"split_name_column",
|
|
63
|
+
]
|