joltio 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.
- joltio/__init__.py +19 -0
- joltio/cli.py +111 -0
- joltio/client.py +142 -0
- joltio/config.py +91 -0
- joltio/esios/__init__.py +21 -0
- joltio/esios/manager.py +237 -0
- joltio/esios/models.py +121 -0
- joltio/exceptions.py +57 -0
- joltio-0.1.0.dist-info/METADATA +76 -0
- joltio-0.1.0.dist-info/RECORD +12 -0
- joltio-0.1.0.dist-info/WHEEL +4 -0
- joltio-0.1.0.dist-info/entry_points.txt +2 -0
joltio/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Joltio — Python client for Joltio Data."""
|
|
2
|
+
|
|
3
|
+
from joltio.client import Client
|
|
4
|
+
from joltio.exceptions import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
DatonsError,
|
|
7
|
+
JoltioError,
|
|
8
|
+
QueryError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Client",
|
|
14
|
+
"AuthenticationError",
|
|
15
|
+
"DatonsError",
|
|
16
|
+
"JoltioError",
|
|
17
|
+
"QueryError",
|
|
18
|
+
"RateLimitError",
|
|
19
|
+
]
|
joltio/cli.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Joltio CLI — query data and manage authentication.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
joltio auth set <KEY> Save API key to ~/.config/joltio/config.toml
|
|
5
|
+
joltio auth show Show the saved key (masked)
|
|
6
|
+
joltio auth remove Remove the saved key
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from joltio.config import read_api_key, remove_api_key, write_api_key
|
|
16
|
+
|
|
17
|
+
def _data(args: argparse.Namespace) -> None:
|
|
18
|
+
from joltio import Client
|
|
19
|
+
with Client() as client:
|
|
20
|
+
if args.data_command == 'query':
|
|
21
|
+
result = client.data.query_raw(args.sql).model_dump()
|
|
22
|
+
elif args.data_command == 'coverage':
|
|
23
|
+
result = client.data.coverage(args.table)
|
|
24
|
+
elif args.data_command == 'search':
|
|
25
|
+
result = client.data.search(args.query).model_dump()
|
|
26
|
+
else:
|
|
27
|
+
result = client.data.metadata().model_dump()
|
|
28
|
+
print(json.dumps(result, default=str))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _auth_set(args: argparse.Namespace) -> None:
|
|
32
|
+
write_api_key(args.key)
|
|
33
|
+
masked = args.key[:8] + "..." + args.key[-4:] if len(args.key) > 12 else args.key[:4] + "..."
|
|
34
|
+
print(f"API key saved: {masked}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _auth_show(_args: argparse.Namespace) -> None:
|
|
38
|
+
key = read_api_key()
|
|
39
|
+
if not key:
|
|
40
|
+
print("No API key configured.")
|
|
41
|
+
print("Set one with: joltio auth set <KEY>")
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
masked = key[:8] + "..." + key[-4:] if len(key) > 12 else key[:4] + "..."
|
|
44
|
+
print(f"API key: {masked}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _auth_remove(_args: argparse.Namespace) -> None:
|
|
48
|
+
if remove_api_key():
|
|
49
|
+
print("API key removed.")
|
|
50
|
+
else:
|
|
51
|
+
print("No API key to remove.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv: list[str] | None = None) -> None:
|
|
55
|
+
"""CLI entry point."""
|
|
56
|
+
parser = argparse.ArgumentParser(
|
|
57
|
+
prog="joltio",
|
|
58
|
+
description="Joltio Data CLI",
|
|
59
|
+
)
|
|
60
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
61
|
+
|
|
62
|
+
data_parser = subparsers.add_parser('data', help='Query Joltio Data')
|
|
63
|
+
data_sub = data_parser.add_subparsers(dest='data_command', required=True)
|
|
64
|
+
query = data_sub.add_parser('query')
|
|
65
|
+
query.add_argument('sql')
|
|
66
|
+
query.set_defaults(func=_data)
|
|
67
|
+
search = data_sub.add_parser('search')
|
|
68
|
+
search.add_argument('query')
|
|
69
|
+
search.set_defaults(func=_data)
|
|
70
|
+
coverage = data_sub.add_parser('coverage')
|
|
71
|
+
coverage.add_argument('--table')
|
|
72
|
+
coverage.set_defaults(func=_data)
|
|
73
|
+
metadata = data_sub.add_parser('metadata')
|
|
74
|
+
metadata.set_defaults(func=_data)
|
|
75
|
+
|
|
76
|
+
# joltio auth
|
|
77
|
+
auth_parser = subparsers.add_parser("auth", help="Manage API key authentication")
|
|
78
|
+
auth_sub = auth_parser.add_subparsers(dest="auth_command")
|
|
79
|
+
|
|
80
|
+
# joltio auth set <KEY>
|
|
81
|
+
set_parser = auth_sub.add_parser("set", help="Save API key")
|
|
82
|
+
set_parser.add_argument("key", help="Your Joltio API key")
|
|
83
|
+
set_parser.set_defaults(func=_auth_set)
|
|
84
|
+
|
|
85
|
+
# joltio auth show
|
|
86
|
+
show_parser = auth_sub.add_parser("show", help="Show saved API key (masked)")
|
|
87
|
+
show_parser.set_defaults(func=_auth_show)
|
|
88
|
+
|
|
89
|
+
# joltio auth remove
|
|
90
|
+
remove_parser = auth_sub.add_parser("remove", help="Remove saved API key")
|
|
91
|
+
remove_parser.set_defaults(func=_auth_remove)
|
|
92
|
+
|
|
93
|
+
args = parser.parse_args(argv)
|
|
94
|
+
|
|
95
|
+
if not args.command:
|
|
96
|
+
parser.print_help()
|
|
97
|
+
sys.exit(0)
|
|
98
|
+
|
|
99
|
+
if args.command == "auth" and not args.auth_command:
|
|
100
|
+
auth_parser.print_help()
|
|
101
|
+
sys.exit(0)
|
|
102
|
+
|
|
103
|
+
if hasattr(args, "func"):
|
|
104
|
+
args.func(args)
|
|
105
|
+
else:
|
|
106
|
+
parser.print_help()
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
main()
|
joltio/client.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Joltio API client.
|
|
2
|
+
|
|
3
|
+
Central entry point that lazily initializes product-specific managers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from joltio.exceptions import AuthenticationError, DatonsError, QueryError, RateLimitError
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.joltio.app"
|
|
16
|
+
DEFAULT_TIMEOUT = 30.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Client:
|
|
20
|
+
"""Client for Joltio Data APIs.
|
|
21
|
+
|
|
22
|
+
Usage::
|
|
23
|
+
|
|
24
|
+
from joltio import Client
|
|
25
|
+
|
|
26
|
+
client = Client(api_key="jol_live_...")
|
|
27
|
+
df = client.data.query("SELECT unit, energy FROM esios.archives_i90 WHERE program='PDBF' LIMIT 10")
|
|
28
|
+
|
|
29
|
+
Or with context manager::
|
|
30
|
+
|
|
31
|
+
with Client(api_key="jol_live_...") as client:
|
|
32
|
+
df = client.data.query("SELECT ...")
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: str | None = None,
|
|
38
|
+
*,
|
|
39
|
+
token: str | None = None,
|
|
40
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
41
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
42
|
+
):
|
|
43
|
+
self.token = api_key or token or os.getenv("JOLTIO_API_KEY") or os.getenv("DATONS_API_KEY")
|
|
44
|
+
if not self.token:
|
|
45
|
+
from joltio.config import read_api_key
|
|
46
|
+
|
|
47
|
+
self.token = read_api_key()
|
|
48
|
+
if not self.token:
|
|
49
|
+
raise DatonsError(
|
|
50
|
+
"API key required. Pass api_key=, set JOLTIO_API_KEY, or run: joltio auth set <KEY>"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self.base_url = base_url.rstrip("/")
|
|
54
|
+
self.timeout = timeout
|
|
55
|
+
|
|
56
|
+
self._http = httpx.Client(
|
|
57
|
+
base_url=self.base_url,
|
|
58
|
+
headers={
|
|
59
|
+
"X-API-Key": self.token,
|
|
60
|
+
"User-Agent": "python-joltio/0.1.0",
|
|
61
|
+
},
|
|
62
|
+
timeout=self.timeout,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Lazy-initialized managers
|
|
66
|
+
self._esios: Any = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def data(self):
|
|
70
|
+
return self.esios
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def esios(self):
|
|
74
|
+
"""Access ESIOS preprocessed data (I90, market programs)."""
|
|
75
|
+
if self._esios is None:
|
|
76
|
+
from joltio.esios.manager import EsiosDataManager
|
|
77
|
+
|
|
78
|
+
self._esios = EsiosDataManager(self)
|
|
79
|
+
return self._esios
|
|
80
|
+
|
|
81
|
+
# -- HTTP primitives (used by managers) ------------------------------------
|
|
82
|
+
|
|
83
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> dict:
|
|
84
|
+
"""Issue a GET request."""
|
|
85
|
+
return self._request("GET", path, params=params)
|
|
86
|
+
|
|
87
|
+
def post(self, path: str, json: dict[str, Any] | None = None) -> dict:
|
|
88
|
+
"""Issue a POST request."""
|
|
89
|
+
return self._request("POST", path, json=json)
|
|
90
|
+
|
|
91
|
+
def _request(
|
|
92
|
+
self,
|
|
93
|
+
method: str,
|
|
94
|
+
path: str,
|
|
95
|
+
params: dict[str, Any] | None = None,
|
|
96
|
+
json: dict[str, Any] | None = None,
|
|
97
|
+
) -> dict:
|
|
98
|
+
"""Execute an HTTP request with error handling."""
|
|
99
|
+
try:
|
|
100
|
+
response = self._http.request(method, path, params=params, json=json)
|
|
101
|
+
except httpx.ConnectError as exc:
|
|
102
|
+
raise DatonsError(f"Connection failed: {exc}") from exc
|
|
103
|
+
except httpx.TimeoutException as exc:
|
|
104
|
+
raise DatonsError(f"Request timed out: {exc}") from exc
|
|
105
|
+
|
|
106
|
+
if response.status_code == 401:
|
|
107
|
+
raise AuthenticationError()
|
|
108
|
+
if response.status_code == 429:
|
|
109
|
+
retry_after = response.headers.get("Retry-After")
|
|
110
|
+
# Parse structured error body from server
|
|
111
|
+
try:
|
|
112
|
+
body = response.json()
|
|
113
|
+
detail = body.get("detail", body)
|
|
114
|
+
except Exception:
|
|
115
|
+
detail = None
|
|
116
|
+
tier = detail.get("tier") if isinstance(detail, dict) else None
|
|
117
|
+
raise RateLimitError(
|
|
118
|
+
int(retry_after) if retry_after else None,
|
|
119
|
+
tier=tier,
|
|
120
|
+
detail=detail,
|
|
121
|
+
)
|
|
122
|
+
if response.status_code >= 400:
|
|
123
|
+
detail = response.text[:500]
|
|
124
|
+
raise QueryError(response.status_code, detail)
|
|
125
|
+
|
|
126
|
+
return response.json()
|
|
127
|
+
|
|
128
|
+
# -- Lifecycle -------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def close(self) -> None:
|
|
131
|
+
"""Close the underlying HTTP connection."""
|
|
132
|
+
self._http.close()
|
|
133
|
+
|
|
134
|
+
def __enter__(self) -> Client:
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def __exit__(self, *args: Any) -> None:
|
|
138
|
+
self.close()
|
|
139
|
+
|
|
140
|
+
def __repr__(self) -> str:
|
|
141
|
+
masked = self.token[:8] + "..." if self.token else "None"
|
|
142
|
+
return f"Client(token='{masked}', base_url='{self.base_url}')"
|
joltio/config.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Configuration file management for Joltio CLI.
|
|
2
|
+
|
|
3
|
+
Reads and writes API keys to ~/.config/joltio/config.toml.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "joltio"
|
|
12
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_toml(text: str) -> dict[str, dict[str, str]]:
|
|
16
|
+
"""Minimal TOML parser — handles [section] and key = "value" pairs."""
|
|
17
|
+
result: dict[str, dict[str, str]] = {}
|
|
18
|
+
current_section: str | None = None
|
|
19
|
+
for line in text.splitlines():
|
|
20
|
+
stripped = line.strip()
|
|
21
|
+
if not stripped or stripped.startswith("#"):
|
|
22
|
+
continue
|
|
23
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
24
|
+
current_section = stripped[1:-1].strip()
|
|
25
|
+
result.setdefault(current_section, {})
|
|
26
|
+
elif "=" in stripped and current_section is not None:
|
|
27
|
+
key, _, value = stripped.partition("=")
|
|
28
|
+
value = value.strip().strip('"').strip("'")
|
|
29
|
+
result[current_section][key.strip()] = value
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write_toml(data: dict[str, dict[str, str]]) -> str:
|
|
34
|
+
"""Serialize a dict of sections to minimal TOML."""
|
|
35
|
+
lines: list[str] = []
|
|
36
|
+
for section, entries in data.items():
|
|
37
|
+
lines.append(f"[{section}]")
|
|
38
|
+
for key, value in entries.items():
|
|
39
|
+
lines.append(f'{key} = "{value}"')
|
|
40
|
+
lines.append("")
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_api_key() -> str | None:
|
|
45
|
+
"""Read the API key from the config file, or None if not set."""
|
|
46
|
+
if not CONFIG_FILE.exists():
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
text = CONFIG_FILE.read_text(encoding="utf-8")
|
|
50
|
+
except OSError:
|
|
51
|
+
return None
|
|
52
|
+
data = _parse_toml(text)
|
|
53
|
+
return data.get("auth", {}).get("api_key") or None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def write_api_key(api_key: str) -> None:
|
|
57
|
+
"""Write the API key to the config file, preserving other sections."""
|
|
58
|
+
data: dict[str, dict[str, str]] = {}
|
|
59
|
+
if CONFIG_FILE.exists():
|
|
60
|
+
try:
|
|
61
|
+
text = CONFIG_FILE.read_text(encoding="utf-8")
|
|
62
|
+
data = _parse_toml(text)
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
data.setdefault("auth", {})
|
|
66
|
+
data["auth"]["api_key"] = api_key
|
|
67
|
+
|
|
68
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
CONFIG_FILE.write_text(_write_toml(data), encoding="utf-8")
|
|
70
|
+
CONFIG_FILE.chmod(0o600)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def remove_api_key() -> bool:
|
|
74
|
+
"""Remove the API key from the config file. Returns True if it existed."""
|
|
75
|
+
if not CONFIG_FILE.exists():
|
|
76
|
+
return False
|
|
77
|
+
try:
|
|
78
|
+
text = CONFIG_FILE.read_text(encoding="utf-8")
|
|
79
|
+
except OSError:
|
|
80
|
+
return False
|
|
81
|
+
data = _parse_toml(text)
|
|
82
|
+
if "auth" not in data or "api_key" not in data["auth"]:
|
|
83
|
+
return False
|
|
84
|
+
del data["auth"]["api_key"]
|
|
85
|
+
if not data["auth"]:
|
|
86
|
+
del data["auth"]
|
|
87
|
+
if data:
|
|
88
|
+
CONFIG_FILE.write_text(_write_toml(data), encoding="utf-8")
|
|
89
|
+
else:
|
|
90
|
+
CONFIG_FILE.unlink(missing_ok=True)
|
|
91
|
+
return True
|
joltio/esios/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""ESIOS Data — preprocessed Spanish electricity market data from ClickHouse."""
|
|
2
|
+
|
|
3
|
+
from joltio.esios.manager import EsiosDataManager
|
|
4
|
+
from joltio.esios.models import (
|
|
5
|
+
ColumnInfo,
|
|
6
|
+
DimensionResult,
|
|
7
|
+
MetadataResult,
|
|
8
|
+
ProgramInfo,
|
|
9
|
+
QueryResult,
|
|
10
|
+
SearchResult,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"EsiosDataManager",
|
|
15
|
+
"ColumnInfo",
|
|
16
|
+
"DimensionResult",
|
|
17
|
+
"MetadataResult",
|
|
18
|
+
"ProgramInfo",
|
|
19
|
+
"QueryResult",
|
|
20
|
+
"SearchResult",
|
|
21
|
+
]
|
joltio/esios/manager.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""ESIOS Data manager — preprocessed I90 market data from ClickHouse.
|
|
2
|
+
|
|
3
|
+
Endpoints:
|
|
4
|
+
/data/metadata — schema, programs, global stats
|
|
5
|
+
/data/query — read-only SQL queries
|
|
6
|
+
/data/search — fuzzy search across dimensions
|
|
7
|
+
/data/dimensions — unit/company/technology lookups
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
13
|
+
|
|
14
|
+
import polars as pl
|
|
15
|
+
|
|
16
|
+
from joltio.esios.models import (
|
|
17
|
+
DimensionResult,
|
|
18
|
+
MetadataResult,
|
|
19
|
+
QueryResult,
|
|
20
|
+
SearchResult,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from joltio.client import Client
|
|
25
|
+
|
|
26
|
+
API_PREFIX = "/data"
|
|
27
|
+
|
|
28
|
+
Backend = Literal["polars", "pandas"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EsiosDataManager:
|
|
32
|
+
"""Manager for ESIOS preprocessed data.
|
|
33
|
+
|
|
34
|
+
Usage::
|
|
35
|
+
|
|
36
|
+
from joltio import Client
|
|
37
|
+
|
|
38
|
+
client = Client(token="esd_live_...")
|
|
39
|
+
|
|
40
|
+
# SQL query → Polars DataFrame (default)
|
|
41
|
+
df = client.esios.query(
|
|
42
|
+
"SELECT unit, datetime, energy FROM esios.archives_i90 "
|
|
43
|
+
"WHERE program='PDBF' LIMIT 100"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# SQL query → pandas DataFrame
|
|
47
|
+
df = client.esios.query("SELECT ...", backend="pandas")
|
|
48
|
+
|
|
49
|
+
# Metadata (schema, programs, stats)
|
|
50
|
+
meta = client.esios.metadata()
|
|
51
|
+
|
|
52
|
+
# Search for dimension values
|
|
53
|
+
results = client.esios.search("iberdrola")
|
|
54
|
+
|
|
55
|
+
# Dimension lookup
|
|
56
|
+
techs = client.esios.dimensions("technology")
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, client: Client):
|
|
60
|
+
self._client = client
|
|
61
|
+
|
|
62
|
+
def query(
|
|
63
|
+
self,
|
|
64
|
+
sql: str,
|
|
65
|
+
*,
|
|
66
|
+
limit: int | None = None,
|
|
67
|
+
backend: Backend = "polars",
|
|
68
|
+
) -> Any:
|
|
69
|
+
"""Execute a read-only SQL query and return a DataFrame.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
sql: SQL SELECT query against ``esios.archives_i90`` (per-program
|
|
73
|
+
I90 dispatch) or ``esios.indicators`` (time series).
|
|
74
|
+
limit: Max rows to return. Server enforces 50 for raw queries,
|
|
75
|
+
10000 for aggregated queries.
|
|
76
|
+
backend: DataFrame backend — ``"polars"`` (default) or ``"pandas"``.
|
|
77
|
+
Pandas requires ``pip install joltio[pandas]``.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Polars or pandas DataFrame with the query results.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
QueryError: On invalid SQL, timeout, or write attempt.
|
|
84
|
+
"""
|
|
85
|
+
body: dict = {"sql": sql}
|
|
86
|
+
if limit is not None:
|
|
87
|
+
body["limit"] = limit
|
|
88
|
+
|
|
89
|
+
data = self._client.post(f"{API_PREFIX}/query", json=body)
|
|
90
|
+
result = QueryResult.model_validate(data)
|
|
91
|
+
|
|
92
|
+
if backend == "pandas":
|
|
93
|
+
return self._to_pandas(result)
|
|
94
|
+
return self._to_polars(result)
|
|
95
|
+
|
|
96
|
+
def query_raw(self, sql: str, *, limit: int | None = None) -> QueryResult:
|
|
97
|
+
"""Execute a query and return the raw API response (no DataFrame conversion).
|
|
98
|
+
|
|
99
|
+
Useful when you need metadata (query_type, truncated, max_rows_applied)
|
|
100
|
+
or want to handle the data yourself.
|
|
101
|
+
"""
|
|
102
|
+
body: dict = {"sql": sql}
|
|
103
|
+
if limit is not None:
|
|
104
|
+
body["limit"] = limit
|
|
105
|
+
|
|
106
|
+
data = self._client.post(f"{API_PREFIX}/query", json=body)
|
|
107
|
+
return QueryResult.model_validate(data)
|
|
108
|
+
|
|
109
|
+
def metadata(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
lang: Literal["en", "es"] = "en",
|
|
113
|
+
detail: Literal["summary", "full"] = "summary",
|
|
114
|
+
) -> MetadataResult:
|
|
115
|
+
"""Get dataset metadata: schema, programs, and global statistics.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
lang: Language for column descriptions ('en' or 'es').
|
|
119
|
+
detail: 'summary' for lightweight overview (~2.7K tokens),
|
|
120
|
+
'full' for complete stats + categorical values (~5.7K tokens).
|
|
121
|
+
"""
|
|
122
|
+
data = self._client.get(
|
|
123
|
+
f"{API_PREFIX}/metadata",
|
|
124
|
+
params={"lang": lang, "detail": detail},
|
|
125
|
+
)
|
|
126
|
+
return MetadataResult.model_validate(data)
|
|
127
|
+
|
|
128
|
+
def search(
|
|
129
|
+
self,
|
|
130
|
+
q: str,
|
|
131
|
+
*,
|
|
132
|
+
column: str | None = None,
|
|
133
|
+
) -> SearchResult:
|
|
134
|
+
"""Fuzzy search across all metadata dimensions.
|
|
135
|
+
|
|
136
|
+
Searches unit codes, unit names, company names, technologies,
|
|
137
|
+
and other categorical values.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
q: Search query (case-insensitive substring match).
|
|
141
|
+
column: Optional column to limit search to.
|
|
142
|
+
|
|
143
|
+
Examples::
|
|
144
|
+
|
|
145
|
+
client.esios.search("iber") # → Iberdrola units/companies
|
|
146
|
+
client.esios.search("ciclo") # → Ciclo Combinado technology
|
|
147
|
+
client.esios.search("CTGN") # → unit codes CTGN1, CTGN2...
|
|
148
|
+
"""
|
|
149
|
+
params: dict = {"q": q}
|
|
150
|
+
if column:
|
|
151
|
+
params["column"] = column
|
|
152
|
+
|
|
153
|
+
data = self._client.get(f"{API_PREFIX}/search", params=params)
|
|
154
|
+
return SearchResult.model_validate(data)
|
|
155
|
+
|
|
156
|
+
def dimensions(
|
|
157
|
+
self,
|
|
158
|
+
dim: Literal["unit", "company", "technology"] = "unit",
|
|
159
|
+
*,
|
|
160
|
+
detail: Literal["summary", "full"] = "summary",
|
|
161
|
+
q: str | None = None,
|
|
162
|
+
) -> DimensionResult:
|
|
163
|
+
"""Get dimension values (unit registry, companies, technologies).
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
dim: Dimension to query.
|
|
167
|
+
detail: 'summary' for flat list, 'full' for enriched records.
|
|
168
|
+
q: Optional fuzzy filter.
|
|
169
|
+
"""
|
|
170
|
+
params: dict = {"dim": dim, "detail": detail}
|
|
171
|
+
if q:
|
|
172
|
+
params["q"] = q
|
|
173
|
+
|
|
174
|
+
data = self._client.get(f"{API_PREFIX}/dimensions", params=params)
|
|
175
|
+
return DimensionResult.model_validate(data)
|
|
176
|
+
|
|
177
|
+
def health(self) -> bool:
|
|
178
|
+
"""Check if the ESIOS Data API is reachable."""
|
|
179
|
+
try:
|
|
180
|
+
data = self._client.get(f"{API_PREFIX}/health")
|
|
181
|
+
return data.get("status") == "ok"
|
|
182
|
+
except Exception:
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
def coverage(self, table: str | None = None) -> list[dict]:
|
|
186
|
+
return self._client.get(f"{API_PREFIX}/coverage", params={"table": table} if table else None)
|
|
187
|
+
|
|
188
|
+
# -- Internal helpers ------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _to_polars(result: QueryResult) -> pl.DataFrame:
|
|
192
|
+
"""Convert a QueryResult to a Polars DataFrame."""
|
|
193
|
+
col_names = [c.name for c in result.columns]
|
|
194
|
+
schema: dict[str, pl.DataType] = {}
|
|
195
|
+
|
|
196
|
+
for col in result.columns:
|
|
197
|
+
col_type = col.type.lower()
|
|
198
|
+
if "datetime" in col_type:
|
|
199
|
+
schema[col.name] = pl.Datetime("ms")
|
|
200
|
+
elif "date" in col_type:
|
|
201
|
+
schema[col.name] = pl.Date
|
|
202
|
+
elif "float" in col_type:
|
|
203
|
+
schema[col.name] = pl.Float64
|
|
204
|
+
elif "int" in col_type or "uint" in col_type:
|
|
205
|
+
schema[col.name] = pl.Int64
|
|
206
|
+
else:
|
|
207
|
+
schema[col.name] = pl.Utf8
|
|
208
|
+
|
|
209
|
+
data = dict(zip(col_names, zip(*result.rows))) if result.rows else {c: [] for c in col_names}
|
|
210
|
+
df = pl.DataFrame(data, schema=schema)
|
|
211
|
+
return df
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _to_pandas(result: QueryResult) -> Any:
|
|
215
|
+
"""Convert a QueryResult to a pandas DataFrame."""
|
|
216
|
+
try:
|
|
217
|
+
import pandas as pd
|
|
218
|
+
except ImportError:
|
|
219
|
+
raise ImportError(
|
|
220
|
+
"pandas is required for backend='pandas'. "
|
|
221
|
+
"Install it with: pip install joltio[pandas]"
|
|
222
|
+
) from None
|
|
223
|
+
|
|
224
|
+
col_names = [c.name for c in result.columns]
|
|
225
|
+
df = pd.DataFrame(result.rows, columns=col_names)
|
|
226
|
+
|
|
227
|
+
for col in result.columns:
|
|
228
|
+
if "datetime" in col.type.lower() or "date" in col.type.lower():
|
|
229
|
+
try:
|
|
230
|
+
df[col.name] = pd.to_datetime(df[col.name])
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
return df
|
|
235
|
+
|
|
236
|
+
def __repr__(self) -> str:
|
|
237
|
+
return f"EsiosDataManager(base_url='{self._client.base_url}{API_PREFIX}')"
|
joltio/esios/models.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Pydantic models for ESIOS Data API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ColumnInfo(BaseModel):
|
|
9
|
+
"""Column metadata from schema."""
|
|
10
|
+
|
|
11
|
+
name: str
|
|
12
|
+
type: str
|
|
13
|
+
nullable: bool = False
|
|
14
|
+
default: str | None = None
|
|
15
|
+
description: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SchemaInfo(BaseModel):
|
|
19
|
+
"""Table schema information."""
|
|
20
|
+
|
|
21
|
+
table: str
|
|
22
|
+
database: str
|
|
23
|
+
engine: str | None = None
|
|
24
|
+
columns: list[ColumnInfo]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProgramStats(BaseModel):
|
|
28
|
+
"""Per-program statistics."""
|
|
29
|
+
|
|
30
|
+
row_count: int
|
|
31
|
+
first_date: str
|
|
32
|
+
last_date: str
|
|
33
|
+
unique_units: int
|
|
34
|
+
energy_min: float | None = None
|
|
35
|
+
energy_max: float | None = None
|
|
36
|
+
energy_avg: float | None = None
|
|
37
|
+
price_min: float | None = None
|
|
38
|
+
price_max: float | None = None
|
|
39
|
+
price_avg: float | None = None
|
|
40
|
+
detected_resolution: str
|
|
41
|
+
columns_used: list[str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ProgramInfo(BaseModel):
|
|
45
|
+
"""Market program metadata."""
|
|
46
|
+
|
|
47
|
+
code: str
|
|
48
|
+
name: str
|
|
49
|
+
description: str
|
|
50
|
+
energy_source: str = ""
|
|
51
|
+
price_source: str = ""
|
|
52
|
+
energy_unit: str
|
|
53
|
+
aggregation: str = ""
|
|
54
|
+
stats: ProgramStats | None = None
|
|
55
|
+
# Summary-only fields
|
|
56
|
+
columns_used: list[str] = []
|
|
57
|
+
row_count: int = 0
|
|
58
|
+
date_range: list[str] = []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GlobalStats(BaseModel):
|
|
62
|
+
"""Global dataset statistics."""
|
|
63
|
+
|
|
64
|
+
total_rows: int
|
|
65
|
+
date_min: str
|
|
66
|
+
date_max: str
|
|
67
|
+
unique_units: int
|
|
68
|
+
unique_companies: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MetadataResult(BaseModel):
|
|
72
|
+
"""Full metadata response."""
|
|
73
|
+
|
|
74
|
+
schema_info: SchemaInfo
|
|
75
|
+
programs: list[ProgramInfo]
|
|
76
|
+
global_stats: GlobalStats | None = None
|
|
77
|
+
categorical_values: dict[str, list[str]] = {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class QueryColumn(BaseModel):
|
|
81
|
+
"""Column in a query result."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
type: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class QueryResult(BaseModel):
|
|
88
|
+
"""SQL query result."""
|
|
89
|
+
|
|
90
|
+
columns: list[QueryColumn]
|
|
91
|
+
rows: list[list]
|
|
92
|
+
row_count: int
|
|
93
|
+
query_type: str
|
|
94
|
+
max_rows_applied: int
|
|
95
|
+
truncated: bool
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SearchResult(BaseModel):
|
|
99
|
+
"""Search response.
|
|
100
|
+
|
|
101
|
+
Shape as returned by ``/esios-data/search`` (May 2026 backend):
|
|
102
|
+
``{query, count, results, hints, offset}``. ``results`` is a list of
|
|
103
|
+
matching records (dimension rows or domain-catalog entries depending on
|
|
104
|
+
the params sent).
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
query: str
|
|
108
|
+
count: int = 0
|
|
109
|
+
results: list[dict] = []
|
|
110
|
+
hints: list[str] | None = None
|
|
111
|
+
offset: int = 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class DimensionResult(BaseModel):
|
|
115
|
+
"""Dimension lookup response."""
|
|
116
|
+
|
|
117
|
+
dimension: str = ""
|
|
118
|
+
detail: str = ""
|
|
119
|
+
count: int = 0
|
|
120
|
+
values: list[str] = []
|
|
121
|
+
records: list[dict] = []
|
joltio/exceptions.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Joltio client exceptions, including the legacy DatonsError alias."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class JoltioError(Exception):
|
|
5
|
+
"""Base exception for all Joltio client errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DatonsError = JoltioError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthenticationError(DatonsError):
|
|
12
|
+
"""Invalid or missing API key."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str = "Invalid or missing API key."):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class QueryError(DatonsError):
|
|
19
|
+
"""Query execution failed (bad SQL, timeout, read-only violation, etc.)."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, status_code: int, detail: str = ""):
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
super().__init__(f"Query error ({status_code}): {detail}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RateLimitError(DatonsError):
|
|
27
|
+
"""Rate limit exceeded.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
retry_after: Seconds until the limit resets.
|
|
31
|
+
tier: Current API key tier (e.g. 'explorer', 'professional').
|
|
32
|
+
detail: Full error detail from the server.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
retry_after: int | None = None,
|
|
38
|
+
*,
|
|
39
|
+
tier: str | None = None,
|
|
40
|
+
detail: dict | str | None = None,
|
|
41
|
+
):
|
|
42
|
+
self.retry_after = retry_after
|
|
43
|
+
self.tier = tier
|
|
44
|
+
self.detail = detail
|
|
45
|
+
|
|
46
|
+
# Build a human-readable message from the server response
|
|
47
|
+
if isinstance(detail, dict):
|
|
48
|
+
msg = detail.get("error", "Rate limit exceeded.")
|
|
49
|
+
upgrade = detail.get("upgrade")
|
|
50
|
+
if upgrade:
|
|
51
|
+
msg += f" {upgrade}"
|
|
52
|
+
else:
|
|
53
|
+
msg = "Rate limit exceeded."
|
|
54
|
+
if retry_after:
|
|
55
|
+
msg += f" Retry after {retry_after}s."
|
|
56
|
+
|
|
57
|
+
super().__init__(msg)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: joltio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Joltio Data
|
|
5
|
+
Project-URL: Homepage, https://joltio.app/data
|
|
6
|
+
Project-URL: Documentation, https://joltio.app/data/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/datons/python-joltio
|
|
8
|
+
Project-URL: Issues, https://github.com/datons/python-joltio/issues
|
|
9
|
+
Author-email: Jesús López <jesus.lopez@datons.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: api,data,datons,electricity,energy,esios,spain
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: polars>=1.0
|
|
25
|
+
Requires-Dist: pydantic>=2.0
|
|
26
|
+
Provides-Extra: pandas
|
|
27
|
+
Requires-Dist: pandas>=2.0; extra == 'pandas'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# joltio
|
|
31
|
+
|
|
32
|
+
Python client for [Joltio Data](https://joltio.app/data). The canonical endpoint is `https://api.joltio.app/data`.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install joltio
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from joltio import Client
|
|
44
|
+
|
|
45
|
+
client = Client(api_key="jol_live_...")
|
|
46
|
+
|
|
47
|
+
# Query the electricity market clearing price
|
|
48
|
+
df = client.data.query(
|
|
49
|
+
"SELECT datetime, price "
|
|
50
|
+
"FROM omie.prices "
|
|
51
|
+
"WHERE country = 'ES' AND datetime >= now() - INTERVAL 7 DAY "
|
|
52
|
+
"LIMIT 100"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Dataset metadata (schema, programs, stats)
|
|
56
|
+
meta = client.data.metadata()
|
|
57
|
+
|
|
58
|
+
# Search for units, companies, technologies
|
|
59
|
+
results = client.data.search("iberdrola")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Authentication
|
|
63
|
+
|
|
64
|
+
Request a personal Data key from your Joltio workspace administrator.
|
|
65
|
+
|
|
66
|
+
Pass it directly or set `JOLTIO_API_KEY`. `DATONS_API_KEY` remains a fallback during migration.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
export JOLTIO_API_KEY="jol_live_..."
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from joltio import Client
|
|
74
|
+
|
|
75
|
+
client = Client() # picks up JOLTIO_API_KEY
|
|
76
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
joltio/__init__.py,sha256=ScDHiX2pOf0CJdfpjmhI5YiVeH9HH-hpj5sseUyvWTk,345
|
|
2
|
+
joltio/cli.py,sha256=Rs1SivH1ce0SEZ1iHGrISi8oPrSTEUAXtZEZ5JYjBu4,3500
|
|
3
|
+
joltio/client.py,sha256=ncu3cJuiokdMjn_SlwEe_cnxudMpetEXU0w2nBd86mc,4341
|
|
4
|
+
joltio/config.py,sha256=7h96er8iN7HvpeC0bNLW5f5FN03vu7FzVhMIU-pNjsw,2962
|
|
5
|
+
joltio/exceptions.py,sha256=roCFOIzabU4WcwgNEx7pBijHPs3vc2GZyyDH5LIK-fA,1615
|
|
6
|
+
joltio/esios/__init__.py,sha256=azapiBUvtxo3TngDtbQ3FhMQuw1PJeHhI4jX-mBjI8o,439
|
|
7
|
+
joltio/esios/manager.py,sha256=8a_MQ89nz5l6pkmMYhW8LQwKmGujhO5BtfZlrkdmdR4,7657
|
|
8
|
+
joltio/esios/models.py,sha256=QZALvLk9RedzeIrvYsY6XUsJfOk3hp-Wiiyl4lzbnKw,2563
|
|
9
|
+
joltio-0.1.0.dist-info/METADATA,sha256=8eYxeh08afMSl7mA54KF26uBshlm0AGUNCilJ_Wgf80,2134
|
|
10
|
+
joltio-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
joltio-0.1.0.dist-info/entry_points.txt,sha256=5qL7RQTVsj41RfXvMqKU19YoopIeJBFrcyibQ28kMWw,43
|
|
12
|
+
joltio-0.1.0.dist-info/RECORD,,
|