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.
Files changed (42) hide show
  1. arcobi_sdk/__init__.py +88 -0
  2. arcobi_sdk/cli.py +395 -0
  3. arcobi_sdk/config.py +78 -0
  4. arcobi_sdk/datahub/__init__.py +63 -0
  5. arcobi_sdk/datahub/client.py +525 -0
  6. arcobi_sdk/datahub/extract.py +588 -0
  7. arcobi_sdk/datahub/specs.py +247 -0
  8. arcobi_sdk/errors.py +68 -0
  9. arcobi_sdk/excel/__init__.py +58 -0
  10. arcobi_sdk/excel/convert.py +361 -0
  11. arcobi_sdk/excel/udf.py +369 -0
  12. arcobi_sdk/excel/vba/ArcobiSdk.bas +327 -0
  13. arcobi_sdk/excel/vba/README.md +157 -0
  14. arcobi_sdk/io.py +234 -0
  15. arcobi_sdk/pool/__init__.py +35 -0
  16. arcobi_sdk/pool/client.py +704 -0
  17. arcobi_sdk/pool/priority.py +105 -0
  18. arcobi_sdk/pool/protocol.py +143 -0
  19. arcobi_sdk/pool/server/__init__.py +12 -0
  20. arcobi_sdk/pool/server/__main__.py +55 -0
  21. arcobi_sdk/pool/server/api/__init__.py +1 -0
  22. arcobi_sdk/pool/server/api/deps.py +55 -0
  23. arcobi_sdk/pool/server/api/routes.py +113 -0
  24. arcobi_sdk/pool/server/api/websocket.py +141 -0
  25. arcobi_sdk/pool/server/app.py +111 -0
  26. arcobi_sdk/pool/server/config.py +88 -0
  27. arcobi_sdk/pool/server/core/__init__.py +1 -0
  28. arcobi_sdk/pool/server/core/credentials.py +146 -0
  29. arcobi_sdk/pool/server/core/lease_manager.py +639 -0
  30. arcobi_sdk/pool/server/core/metrics.py +85 -0
  31. arcobi_sdk/pool/server/core/queue.py +179 -0
  32. arcobi_sdk/pool/server/core/store.py +973 -0
  33. arcobi_sdk/pool/server/providers/__init__.py +80 -0
  34. arcobi_sdk/pool/server/providers/base.py +68 -0
  35. arcobi_sdk/pool/server/providers/datahub.py +182 -0
  36. arcobi_sdk/pool/server/providers/static.py +57 -0
  37. arcobi_sdk/timeutils.py +76 -0
  38. arcobi_sdk-0.1.0b1.dist-info/METADATA +164 -0
  39. arcobi_sdk-0.1.0b1.dist-info/RECORD +42 -0
  40. arcobi_sdk-0.1.0b1.dist-info/WHEEL +4 -0
  41. arcobi_sdk-0.1.0b1.dist-info/entry_points.txt +2 -0
  42. 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
+ ]