clarigrid 0.1.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.
clarigrid/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ """Clarigrid — unified European energy market data SDK."""
2
+
3
+ # Auto-register all built-in free providers FIRST (no API key required).
4
+ # Must import before defining conflicting names in this namespace.
5
+ import clarigrid.providers # noqa: E402, F401
6
+
7
+ from clarigrid.core import cache # exposed as cg.cache
8
+ from clarigrid.core.api import (
9
+ configure,
10
+ connect,
11
+ get_capacity,
12
+ get_gas_flows,
13
+ get_generation,
14
+ get_load,
15
+ get_prices,
16
+ get_weather,
17
+ list_providers,
18
+ reset,
19
+ set_api_key,
20
+ set_timezone,
21
+ status,
22
+ )
23
+ from clarigrid.core.registry import register_provider
24
+
25
+ __all__ = [
26
+ "connect",
27
+ "configure",
28
+ "reset",
29
+ "status",
30
+ "get_prices",
31
+ "get_load",
32
+ "get_generation",
33
+ "get_gas_flows",
34
+ "get_capacity",
35
+ "get_weather",
36
+ "set_api_key",
37
+ "set_timezone",
38
+ "list_providers",
39
+ "register_provider",
40
+ "cache",
41
+ ]
42
+
43
+ __version__ = "0.1.0"
44
+
45
+
46
+ # ── First-run setup check ──────────────────────────────────────────────────
47
+
48
+ def _check_first_run() -> None:
49
+ """Offer setup wizard on first import in an interactive terminal.
50
+
51
+ Contract:
52
+ - Exits in <50 ms when config already exists (single Path.exists() call).
53
+ - No-op in non-interactive environments (CI, scripts, notebooks).
54
+ - No network calls, no heavy imports unless the user opts in.
55
+ """
56
+ import sys
57
+ from pathlib import Path
58
+
59
+ # Fast path — single filesystem check, no further I/O.
60
+ config_path = Path.home() / ".config" / "clarigrid" / ".env"
61
+ old_config = Path.home() / ".clarigrid" # backward-compat: skip wizard if old config exists
62
+
63
+ if config_path.exists() or old_config.exists():
64
+ return
65
+
66
+ # Only prompt in fully interactive terminals.
67
+ try:
68
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
69
+ return
70
+ except Exception:
71
+ return
72
+
73
+ print("\nWelcome to Clarigrid!")
74
+ print("No configuration found.")
75
+ try:
76
+ answer = input("Set up your data source connections now? [Y/n]: ").strip().lower()
77
+ except (EOFError, KeyboardInterrupt):
78
+ print()
79
+ return
80
+
81
+ if answer in ("", "y", "yes"):
82
+ from clarigrid._auth import run_setup_wizard
83
+ run_setup_wizard()
84
+ else:
85
+ print("\nYou can set up later:")
86
+ print(" Call: import clarigrid; clarigrid.connect('entsoe')")
87
+ print(" Or run: clarigrid setup\n")
88
+
89
+
90
+ _check_first_run()
clarigrid/_auth.py ADDED
@@ -0,0 +1,449 @@
1
+ """Authentication layer for key-required data providers.
2
+
3
+ KeyState machine
4
+ ----------------
5
+ MISSING → 1. try CLARIGRID_API_KEY to fetch all keys from clarigrid.energy
6
+ 2. interactive: browser flow → prompt flow fallback
7
+ 3. non-interactive: raise ConfigurationError
8
+ PRESENT → lightweight upstream validation call
9
+ VALID → cached in-memory, zero latency on repeated connect() calls
10
+ INVALID → raise InvalidKeyError; never re-trigger auth flow
11
+
12
+ ClarigGrid SDK key
13
+ ------------------
14
+ CLARIGRID_API_KEY is a user-scoped UUID issued by clarigrid.energy.
15
+ When present it is used to fetch ALL provider keys (ENTSO-E, TenneT …)
16
+ from the backend in one call, replacing per-provider browser logins.
17
+ Set it via env var for CI/headless: export CLARIGRID_API_KEY=<uuid>
18
+
19
+ Adding a new provider
20
+ ---------------------
21
+ Add one entry to PROVIDER_AUTH below. Everything else (auth flow,
22
+ wizard, CLI, config store) picks it up automatically.
23
+
24
+ The distinction between MISSING (triggers auth) and INVALID (raises
25
+ immediately, no retry) is a hard requirement of the public API contract.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ import sys
32
+ from enum import Enum
33
+ from typing import Any
34
+
35
+ # ── UUID pattern (shared by ENTSO-E, TenneT, and ClarigGrid SDK key) ──────
36
+ _UUID_PATTERN = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
37
+ _UUID_HINT = "UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
38
+
39
+ # ── ClarigGrid SDK key (meta-key that unlocks all provider keys) ───────────
40
+ CLARIGRID_SDK_KEY_ENV = "CLARIGRID_API_KEY"
41
+
42
+ # ── Provider registry ──────────────────────────────────────────────────────
43
+
44
+ #: Metadata for every data source that requires an API key.
45
+ #: To add a new provider: append an entry here — nothing else needs changing.
46
+ PROVIDER_AUTH: dict[str, dict[str, Any]] = {
47
+ "entsoe": {
48
+ "env_var": "ENTSOE_API_KEY",
49
+ "display_name": "ENTSO-E Transparency Platform",
50
+ "registration_url": "https://transparency.entsoe.eu/",
51
+ "registration_instructions": (
52
+ " 1. Register at https://transparency.entsoe.eu/\n"
53
+ " 2. My Account → Security Token → Generate Token\n"
54
+ " 3. Copy the UUID shown"
55
+ ),
56
+ "key_pattern": _UUID_PATTERN,
57
+ "key_format_hint": _UUID_HINT,
58
+ },
59
+ "tennet": {
60
+ "env_var": "TENNET_API_KEY",
61
+ "display_name": "TenneT Transparency Portal",
62
+ "registration_url": "https://transparency.tennet.eu/",
63
+ "registration_instructions": (
64
+ " 1. Register at https://transparency.tennet.eu/\n"
65
+ " 2. My Account → API Access → Generate Token"
66
+ ),
67
+ "key_pattern": _UUID_PATTERN,
68
+ "key_format_hint": _UUID_HINT,
69
+ },
70
+ }
71
+
72
+ #: Providers that never need an API key — auth check is a no-op.
73
+ FREE_PROVIDERS: frozenset[str] = frozenset(
74
+ {"smard", "elia", "neso", "elexon", "entsog", "openmeteo"}
75
+ )
76
+
77
+ #: Base URL for the ClarigGrid web app (browser flow).
78
+ #: Override with CLARIGRID_APP_URL for local dev or staging.
79
+ import os as _os
80
+ _APP_BASE_URL = _os.environ.get("CLARIGRID_APP_URL", "https://clarigrid.energy").rstrip("/")
81
+
82
+ #: Base URL for backend API endpoints (token exchange, key fetching).
83
+ #: Defaults to /api/cli under the app URL.
84
+ #: Point directly at Supabase for testing (before clarigrid.energy is live):
85
+ #: export CLARIGRID_API_URL=https://YOUR_PROJECT.supabase.co/functions/v1
86
+ _API_BASE_URL = _os.environ.get(
87
+ "CLARIGRID_API_URL",
88
+ f"{_APP_BASE_URL}/api/cli",
89
+ ).rstrip("/")
90
+
91
+ _TOKEN_EXCHANGE_URL = f"{_API_BASE_URL}/exchange-cli-token"
92
+ _FETCH_KEYS_URL = f"{_API_BASE_URL}/fetch-keys"
93
+
94
+
95
+ # ── In-memory validation cache ─────────────────────────────────────────────
96
+
97
+ # provider_name → True (VALID) | False (INVALID).
98
+ # Populated after a successful/failed upstream validation call.
99
+ # Cleared only on process restart — never re-validate in the same session.
100
+ _valid_cache: dict[str, bool] = {}
101
+
102
+
103
+ class KeyState(Enum):
104
+ MISSING = "missing"
105
+ PRESENT = "present"
106
+ VALID = "valid"
107
+ INVALID = "invalid"
108
+
109
+
110
+ # ── Key state resolution ───────────────────────────────────────────────────
111
+
112
+ def get_key_state(source: str) -> KeyState:
113
+ """Determine the current key state for *source* without network calls."""
114
+ # Check in-memory cache first (VALID / INVALID).
115
+ if source in _valid_cache:
116
+ return KeyState.VALID if _valid_cache[source] else KeyState.INVALID
117
+
118
+ cfg = PROVIDER_AUTH.get(source)
119
+ if cfg is None:
120
+ # Unrecognised or free provider — treat as already valid.
121
+ return KeyState.VALID
122
+
123
+ from clarigrid._keystore import get_key
124
+ key = get_key(cfg["env_var"])
125
+ return KeyState.PRESENT if key is not None else KeyState.MISSING
126
+
127
+
128
+ # ── Upstream validation (lightweight test call) ────────────────────────────
129
+
130
+ def _validate_key(source: str, key: str) -> bool:
131
+ """Return True if *key* is accepted by the upstream API, False if rejected.
132
+
133
+ Network errors are treated as inconclusive (return True — try later).
134
+ The key value must not appear in any exception message.
135
+ """
136
+ if source == "entsoe":
137
+ return _validate_entsoe(key)
138
+ if source == "tennet":
139
+ return _validate_tennet(key)
140
+ return True # Unknown provider — assume valid.
141
+
142
+
143
+ def _validate_entsoe(key: str) -> bool:
144
+ try:
145
+ import requests
146
+ resp = requests.get(
147
+ "https://web-api.tp.entsoe.eu/api",
148
+ params={
149
+ "securityToken": key,
150
+ "documentType": "A75",
151
+ "processType": "A16",
152
+ "in_Domain": "10YBE----------2",
153
+ "periodStart": "202501010000",
154
+ "periodEnd": "202501010100",
155
+ },
156
+ timeout=10,
157
+ )
158
+ return resp.status_code not in (401, 403)
159
+ except Exception:
160
+ return True # Network issue — assume valid, will fail on real request.
161
+
162
+
163
+ def _validate_tennet(key: str) -> bool:
164
+ try:
165
+ import requests
166
+ resp = requests.get(
167
+ "https://api.tennet.eu/publications/v1/settlement-prices",
168
+ headers={"Accept": "text/csv", "apikey": key},
169
+ params={"date_from": "01-01-2025 00:00:00", "date_to": "01-01-2025 01:00:00"},
170
+ timeout=10,
171
+ )
172
+ return resp.status_code not in (401, 403)
173
+ except Exception:
174
+ return True # Network error — assume valid, will fail on real request.
175
+
176
+
177
+ # ── Main entry point ───────────────────────────────────────────────────────
178
+
179
+ # ── ClarigGrid SDK key: fetch all provider keys in one call ───────────────
180
+
181
+ def fetch_all_provider_keys(sdk_key: str) -> dict[str, str]:
182
+ """Call clarigrid.energy with *sdk_key* and return {service: api_key} map.
183
+
184
+ Saves all returned keys to ~/.config/clarigrid/.env so subsequent
185
+ get_key() calls resolve without network access.
186
+
187
+ Returns the raw {service_name: key_value} dict (may be empty if no
188
+ provider keys are stored in the user's account yet).
189
+ """
190
+ import requests
191
+
192
+ from clarigrid._keystore import write_config
193
+
194
+ try:
195
+ resp = requests.get(
196
+ _FETCH_KEYS_URL,
197
+ headers={"Authorization": f"Bearer {sdk_key}"},
198
+ timeout=15,
199
+ )
200
+ resp.raise_for_status()
201
+ service_keys: dict[str, str] = resp.json() # {"entsoe": "uuid", ...}
202
+ except Exception as exc:
203
+ # Non-fatal: fall through to per-provider auth flows.
204
+ # Don't log exc — it may contain the SDK key in headers repr.
205
+ return {}
206
+
207
+ # Map service names → env var names and persist.
208
+ env_vars: dict[str, str] = {
209
+ cfg["env_var"]: service_keys[src]
210
+ for src, cfg in PROVIDER_AUTH.items()
211
+ if src in service_keys
212
+ }
213
+ if env_vars:
214
+ write_config(env_vars)
215
+
216
+ return service_keys
217
+
218
+
219
+ def _try_sdk_key_fetch(source: str) -> bool:
220
+ """Try fetching all keys via CLARIGRID_API_KEY. Returns True if *source* key found."""
221
+ from clarigrid._keystore import get_key
222
+
223
+ sdk_key = get_key(CLARIGRID_SDK_KEY_ENV)
224
+ if not sdk_key:
225
+ return False
226
+
227
+ service_keys = fetch_all_provider_keys(sdk_key)
228
+ return source in service_keys
229
+
230
+
231
+ # ── Main entry point ───────────────────────────────────────────────────────
232
+
233
+ def ensure_authenticated(source: str) -> None:
234
+ """Ensure *source* has a confirmed-valid key before connecting.
235
+
236
+ Resolution order for MISSING state:
237
+ 1. CLARIGRID_API_KEY present → fetch all provider keys from clarigrid.energy
238
+ 2. Interactive terminal → browser flow → prompt flow fallback
239
+ 3. Non-interactive → raise ConfigurationError
240
+
241
+ Never loops on INVALID state — raises immediately.
242
+ No-op for free providers and unknown sources.
243
+ """
244
+ if source in FREE_PROVIDERS:
245
+ return
246
+
247
+ if source not in PROVIDER_AUTH:
248
+ return # Unrecognised source — no auth required.
249
+
250
+ state = get_key_state(source)
251
+
252
+ if state == KeyState.VALID:
253
+ return # In-memory cache hit — zero latency.
254
+
255
+ if state == KeyState.INVALID:
256
+ cfg = PROVIDER_AUTH[source]
257
+ from clarigrid.core.exceptions import InvalidKeyError
258
+ raise InvalidKeyError(source, cfg["env_var"])
259
+
260
+ if state == KeyState.MISSING:
261
+ # Step 1: try fetching via ClarigGrid SDK key (works in CI too).
262
+ if _try_sdk_key_fetch(source):
263
+ state = get_key_state(source)
264
+ # Step 2: still missing → interactive/non-interactive auth flow.
265
+ if state == KeyState.MISSING:
266
+ _handle_missing(source)
267
+ state = get_key_state(source)
268
+ # Step 3: still nothing → give up.
269
+ if state == KeyState.MISSING:
270
+ cfg = PROVIDER_AUTH[source]
271
+ from clarigrid.core.exceptions import ConfigurationError
272
+ raise ConfigurationError(source, cfg)
273
+ # Fall through to validate the newly-provided key.
274
+
275
+ # PRESENT — validate against upstream API.
276
+ cfg = PROVIDER_AUTH[source]
277
+ from clarigrid._keystore import get_key
278
+ key = get_key(cfg["env_var"])
279
+ assert key is not None # guaranteed by state check above
280
+
281
+ if _validate_key(source, key):
282
+ _valid_cache[source] = True
283
+ else:
284
+ _valid_cache[source] = False
285
+ from clarigrid.core.exceptions import InvalidKeyError
286
+ raise InvalidKeyError(source, cfg["env_var"])
287
+
288
+
289
+ def _is_interactive() -> bool:
290
+ """Return True when running in an interactive terminal."""
291
+ try:
292
+ return sys.stdin.isatty() and sys.stdout.isatty()
293
+ except Exception:
294
+ return False
295
+
296
+
297
+ def _handle_missing(source: str) -> None:
298
+ """Handle MISSING state: browser flow (interactive) or raise (non-interactive)."""
299
+ cfg = PROVIDER_AUTH[source]
300
+
301
+ if not _is_interactive():
302
+ from clarigrid.core.exceptions import ConfigurationError
303
+ raise ConfigurationError(source, cfg)
304
+
305
+ print(f"\nNo API key found for '{source}'.")
306
+ print("Opening ClarigGrid in your browser to authenticate...")
307
+
308
+ from clarigrid.core.exceptions import AuthTimeoutError, BrowserFlowError, MissingDependencyError
309
+
310
+ try:
311
+ run_browser_flow(source)
312
+ return
313
+ except MissingDependencyError:
314
+ # clarigrid[auth] not installed — fall back to manual.
315
+ print(" (browser flow requires clarigrid[auth] — falling back to manual entry)")
316
+ except BrowserFlowError as exc:
317
+ msg = str(exc)
318
+ if "not reachable" in msg:
319
+ # Site is down — fall back to manual immediately.
320
+ print(f" {msg}")
321
+ print(" Falling back to manual key entry...")
322
+ else:
323
+ # Callback issue, cancellation, or timeout — re-raise with full message.
324
+ # Do NOT silently fall back: user may just need to check the browser.
325
+ raise
326
+
327
+ run_prompt_flow(source)
328
+
329
+
330
+ # ── Browser flow (delegates to optional module) ────────────────────────────
331
+
332
+ def run_browser_flow(source: str) -> None:
333
+ """Open browser to authenticate. Raises MissingDependencyError if unavailable."""
334
+ try:
335
+ from clarigrid._browser_flow import _run_browser_flow
336
+ except ImportError as exc:
337
+ from clarigrid.core.exceptions import MissingDependencyError
338
+ raise MissingDependencyError("clarigrid[auth]", "browser authentication") from exc
339
+ _run_browser_flow(source)
340
+
341
+
342
+ # ── Prompt flow ────────────────────────────────────────────────────────────
343
+
344
+ def run_prompt_flow(source: str) -> None:
345
+ """Interactively prompt for an API key. Zero deps beyond stdlib + python-dotenv."""
346
+ import getpass
347
+
348
+ from clarigrid._keystore import write_config
349
+ from clarigrid.core.exceptions import ConfigurationError
350
+
351
+ cfg = PROVIDER_AUTH[source]
352
+ env_var: str = cfg["env_var"]
353
+ reg_url: str = cfg["registration_url"]
354
+ reg_instructions: str = cfg["registration_instructions"]
355
+ key_hint: str = cfg["key_format_hint"]
356
+ pattern: str | None = cfg.get("key_pattern")
357
+
358
+ print(f"\nClarigGrid — Manual setup for '{source}'")
359
+ print("-" * 45)
360
+ print(f"Get your key at: {reg_url}")
361
+ print()
362
+ print(reg_instructions)
363
+ print()
364
+
365
+ for attempt in range(3):
366
+ key = getpass.getpass(f"Paste your {env_var} (input hidden): ").strip()
367
+ if not key:
368
+ print(" Empty input — try again.")
369
+ continue
370
+ if pattern and not re.match(pattern, key, re.IGNORECASE):
371
+ remaining = 2 - attempt
372
+ print(f" Format invalid. Expected {key_hint}.")
373
+ if remaining > 0:
374
+ print(f" {remaining} attempt(s) remaining.")
375
+ continue
376
+ write_config({env_var: key})
377
+ print(f"\n Key saved to ~/.config/clarigrid/.env")
378
+ return
379
+
380
+ raise ConfigurationError(source, cfg)
381
+
382
+
383
+ # ── Setup wizard ───────────────────────────────────────────────────────────
384
+
385
+ def run_setup_wizard() -> None:
386
+ """Interactive wizard to configure all key-required data sources."""
387
+ from clarigrid._keystore import get_key
388
+ from clarigrid.core.exceptions import ConfigurationError
389
+
390
+ sources = list(PROVIDER_AUTH.keys())
391
+
392
+ print("\nClarigGrid Setup Wizard")
393
+ print("=" * 45)
394
+ print("Data sources that require API keys:\n")
395
+ for i, src in enumerate(sources, 1):
396
+ cfg = PROVIDER_AUTH[src]
397
+ print(f" {i}. {cfg['display_name']} ({src})")
398
+
399
+ print()
400
+ raw = input(
401
+ "Which sources would you like to set up? (e.g. 1,2 or 'all'): "
402
+ ).strip()
403
+
404
+ if raw.lower() == "all":
405
+ selected = list(sources)
406
+ else:
407
+ selected = []
408
+ for part in raw.split(","):
409
+ part = part.strip()
410
+ if part.isdigit():
411
+ idx = int(part) - 1
412
+ if 0 <= idx < len(sources):
413
+ selected.append(sources[idx])
414
+
415
+ if not selected:
416
+ print("No sources selected. Run 'clarigrid setup' again later.")
417
+ return
418
+
419
+ configured: list[str] = []
420
+ skipped: list[str] = []
421
+
422
+ for src in selected:
423
+ cfg = PROVIDER_AUTH[src]
424
+ env_var: str = cfg["env_var"]
425
+ existing = get_key(env_var)
426
+
427
+ if existing:
428
+ ans = input(f"\n '{src}' already configured. Update? [y/N]: ").strip().lower()
429
+ if ans not in ("y", "yes"):
430
+ skipped.append(src)
431
+ continue
432
+
433
+ print()
434
+ try:
435
+ try:
436
+ run_browser_flow(src)
437
+ except Exception:
438
+ run_prompt_flow(src)
439
+ configured.append(src)
440
+ except (ConfigurationError, KeyboardInterrupt):
441
+ skipped.append(src)
442
+ print(f" Skipped '{src}'.")
443
+
444
+ print("\n── Summary " + "─" * 35)
445
+ if configured:
446
+ print(f" Configured : {', '.join(configured)}")
447
+ if skipped:
448
+ print(f" Skipped : {', '.join(skipped)}")
449
+ print()