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 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
@@ -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
+ ]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ joltio = joltio.cli:main