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 +90 -0
- clarigrid/_auth.py +449 -0
- clarigrid/_browser_flow.py +196 -0
- clarigrid/_keystore.py +110 -0
- clarigrid/cli.py +205 -0
- clarigrid/core/__init__.py +0 -0
- clarigrid/core/api.py +476 -0
- clarigrid/core/cache.py +381 -0
- clarigrid/core/config.py +213 -0
- clarigrid/core/exceptions.py +163 -0
- clarigrid/core/http.py +222 -0
- clarigrid/core/interface.py +224 -0
- clarigrid/core/normalise.py +253 -0
- clarigrid/core/registry.py +58 -0
- clarigrid/core/router.py +133 -0
- clarigrid/core/session.py +157 -0
- clarigrid/core/types.py +26 -0
- clarigrid/providers/__init__.py +17 -0
- clarigrid/providers/dwd.py +379 -0
- clarigrid/providers/elexon.py +184 -0
- clarigrid/providers/elia.py +195 -0
- clarigrid/providers/entsoe.py +2 -0
- clarigrid/providers/entsog.py +183 -0
- clarigrid/providers/neso.py +189 -0
- clarigrid/providers/openmeteo.py +182 -0
- clarigrid/providers/rmi.py +271 -0
- clarigrid/providers/smard.py +159 -0
- clarigrid/providers/tennet.py +237 -0
- clarigrid/utils/__init__.py +0 -0
- clarigrid/utils/time.py +34 -0
- clarigrid/utils/validation.py +21 -0
- clarigrid-0.1.0.dist-info/METADATA +577 -0
- clarigrid-0.1.0.dist-info/RECORD +36 -0
- clarigrid-0.1.0.dist-info/WHEEL +4 -0
- clarigrid-0.1.0.dist-info/entry_points.txt +2 -0
- clarigrid-0.1.0.dist-info/licenses/LICENSE +183 -0
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()
|