eolas-data 1.2.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.
- eolas_data/__init__.py +16 -0
- eolas_data/_dataset_names.py +1455 -0
- eolas_data/_regen_names.py +57 -0
- eolas_data/cli.py +617 -0
- eolas_data/client.py +333 -0
- eolas_data/dataset.py +66 -0
- eolas_data/exceptions.py +20 -0
- eolas_data/schedule.py +258 -0
- eolas_data-1.2.0.dist-info/METADATA +214 -0
- eolas_data-1.2.0.dist-info/RECORD +12 -0
- eolas_data-1.2.0.dist-info/WHEEL +4 -0
- eolas_data-1.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Regenerate ``_dataset_names.py`` from the live API.
|
|
2
|
+
|
|
3
|
+
Run before each release:
|
|
4
|
+
|
|
5
|
+
python -m eolas_data._regen_names
|
|
6
|
+
|
|
7
|
+
Writes to the same file inside the package. Commit the result.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as _dt
|
|
12
|
+
import json as _json
|
|
13
|
+
import pathlib as _pathlib
|
|
14
|
+
import urllib.request as _req
|
|
15
|
+
|
|
16
|
+
API = "https://api.eolas.fyi/v1/datasets"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def regenerate() -> None:
|
|
20
|
+
with _req.urlopen(API, timeout=30) as r:
|
|
21
|
+
data = _json.load(r)
|
|
22
|
+
names = sorted({d["name"] for d in data})
|
|
23
|
+
today = _dt.date.today().isoformat()
|
|
24
|
+
|
|
25
|
+
out = _pathlib.Path(__file__).with_name("_dataset_names.py")
|
|
26
|
+
lines: list[str] = []
|
|
27
|
+
lines.append('"""')
|
|
28
|
+
lines.append('Type stubs for dataset names.')
|
|
29
|
+
lines.append('')
|
|
30
|
+
lines.append('Auto-generated from https://api.eolas.fyi/v1/datasets at release time.')
|
|
31
|
+
lines.append(f'Snapshot: {today} ({len(names)} datasets).')
|
|
32
|
+
lines.append('Regenerate before each release with `python -m eolas_data._regen_names`.')
|
|
33
|
+
lines.append('')
|
|
34
|
+
lines.append('At runtime this is just a string — `Literal[...]` only constrains static type')
|
|
35
|
+
lines.append("checkers like mypy/pyright, so passing a name not in this list still works,")
|
|
36
|
+
lines.append("it just doesn't autocomplete.")
|
|
37
|
+
lines.append('"""')
|
|
38
|
+
lines.append('from typing import Literal')
|
|
39
|
+
lines.append('')
|
|
40
|
+
lines.append(f'CATALOG_SNAPSHOT_DATE = "{today}"')
|
|
41
|
+
lines.append(f'CATALOG_SNAPSHOT_COUNT = {len(names)}')
|
|
42
|
+
lines.append('')
|
|
43
|
+
lines.append('DatasetName = Literal[')
|
|
44
|
+
for n in names:
|
|
45
|
+
lines.append(f' {n!r},')
|
|
46
|
+
lines.append(']')
|
|
47
|
+
lines.append('')
|
|
48
|
+
lines.append('ALL_NAMES: tuple[str, ...] = (')
|
|
49
|
+
for n in names:
|
|
50
|
+
lines.append(f' {n!r},')
|
|
51
|
+
lines.append(')')
|
|
52
|
+
out.write_text("\n".join(lines) + "\n")
|
|
53
|
+
print(f"wrote {len(names)} datasets to {out}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
regenerate()
|
eolas_data/cli.py
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""eolas — command-line interface for the eolas.fyi data API.
|
|
2
|
+
|
|
3
|
+
Designed for two audiences:
|
|
4
|
+
- Humans typing in a terminal: rich tables, sensible defaults, --help everywhere.
|
|
5
|
+
- Shell scripts and AI agents: --json everywhere, auto-detect when stdout is
|
|
6
|
+
piped (drops to NDJSON automatically), distinct exit codes per error class,
|
|
7
|
+
stable output schemas.
|
|
8
|
+
|
|
9
|
+
The CLI is a thin layer over the existing `eolas_data.Client`. All HTTP, retry,
|
|
10
|
+
auth, and error-mapping behaviour stays in the Python client — the CLI only
|
|
11
|
+
formats input and output.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
from . import schedule as _schedule
|
|
27
|
+
from .client import Client
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
APIError,
|
|
30
|
+
AuthenticationError,
|
|
31
|
+
EolasError,
|
|
32
|
+
NotFoundError,
|
|
33
|
+
RateLimitError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
CONFIG_DIR = Path.home() / ".eolas"
|
|
37
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
38
|
+
|
|
39
|
+
# Stable, distinct exit codes — useful for shell scripts and agents that branch
|
|
40
|
+
# on outcome. Documented in the README.
|
|
41
|
+
EXIT_OK = 0
|
|
42
|
+
EXIT_GENERIC = 1
|
|
43
|
+
EXIT_AUTH = 2
|
|
44
|
+
EXIT_RATE_LIMIT = 3
|
|
45
|
+
EXIT_NOT_FOUND = 4
|
|
46
|
+
EXIT_API = 5
|
|
47
|
+
EXIT_USAGE = 64 # convention from sysexits.h
|
|
48
|
+
|
|
49
|
+
app = typer.Typer(
|
|
50
|
+
name="eolas",
|
|
51
|
+
help=(
|
|
52
|
+
"CLI for the eolas.fyi statistical data API. Browse and fetch 700+ NZ, "
|
|
53
|
+
"Australian, and OECD datasets. Pipes cleanly into jq, csvkit, etc."
|
|
54
|
+
),
|
|
55
|
+
no_args_is_help=True,
|
|
56
|
+
add_completion=True,
|
|
57
|
+
)
|
|
58
|
+
datasets_app = typer.Typer(help="Browse and inspect datasets.", no_args_is_help=True)
|
|
59
|
+
auth_app = typer.Typer(help="Manage your API key (env var or ~/.eolas/config.json).", no_args_is_help=True)
|
|
60
|
+
schedule_app = typer.Typer(help="Schedule recurring fetches via cron (POSIX) or Task Scheduler (Windows).", no_args_is_help=True)
|
|
61
|
+
integrate_app = typer.Typer(help="Generate connector configs for third-party data-pipeline tools (Enterprise plan).", no_args_is_help=True)
|
|
62
|
+
app.add_typer(datasets_app, name="datasets")
|
|
63
|
+
app.add_typer(auth_app, name="auth")
|
|
64
|
+
app.add_typer(schedule_app, name="schedule")
|
|
65
|
+
app.add_typer(integrate_app, name="integrate")
|
|
66
|
+
|
|
67
|
+
# Errors go to stderr, data to stdout — important for piping.
|
|
68
|
+
err_console = Console(stderr=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
# Auth resolution
|
|
73
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def _load_api_key() -> str:
|
|
76
|
+
"""Resolve the API key. Precedence: env var → config file → empty."""
|
|
77
|
+
for var in ("EOLAS_API_KEY", "VS_API_KEY"):
|
|
78
|
+
v = os.getenv(var)
|
|
79
|
+
if v:
|
|
80
|
+
return v
|
|
81
|
+
if CONFIG_FILE.exists():
|
|
82
|
+
try:
|
|
83
|
+
return json.loads(CONFIG_FILE.read_text()).get("api_key", "")
|
|
84
|
+
except (json.JSONDecodeError, OSError):
|
|
85
|
+
return ""
|
|
86
|
+
return ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _client(api_key: Optional[str] = None) -> Client:
|
|
90
|
+
return Client(api_key=api_key or _load_api_key())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
# Output helpers
|
|
95
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
def _machine_mode(json_flag: bool) -> bool:
|
|
98
|
+
"""True when output should be machine-readable (NDJSON / CSV)."""
|
|
99
|
+
if json_flag:
|
|
100
|
+
return True
|
|
101
|
+
return not sys.stdout.isatty()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _emit_ndjson(records) -> None:
|
|
105
|
+
"""Write one JSON object per line to stdout (no rich formatting)."""
|
|
106
|
+
for r in records:
|
|
107
|
+
sys.stdout.write(json.dumps(r, default=str, ensure_ascii=False))
|
|
108
|
+
sys.stdout.write("\n")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _row_to_dict(row) -> dict:
|
|
112
|
+
"""Convert a pandas Series row to a JSON-friendly dict (handles NaN)."""
|
|
113
|
+
try:
|
|
114
|
+
import pandas as pd
|
|
115
|
+
return {k: (None if pd.isna(v) else v) for k, v in row.items()}
|
|
116
|
+
except ImportError:
|
|
117
|
+
return dict(row.items())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _exit_for(e: EolasError) -> int:
|
|
121
|
+
"""Map a client-library exception class to an exit code."""
|
|
122
|
+
if isinstance(e, AuthenticationError): return EXIT_AUTH
|
|
123
|
+
if isinstance(e, RateLimitError): return EXIT_RATE_LIMIT
|
|
124
|
+
if isinstance(e, NotFoundError): return EXIT_NOT_FOUND
|
|
125
|
+
if isinstance(e, APIError): return EXIT_API
|
|
126
|
+
return EXIT_GENERIC
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _bail(msg: str, code: int = EXIT_GENERIC) -> None:
|
|
130
|
+
err_console.print(f"[red]error:[/red] {msg}")
|
|
131
|
+
raise typer.Exit(code=code)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
135
|
+
# Top-level commands
|
|
136
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def version() -> None:
|
|
140
|
+
"""Print the installed eolas-data version."""
|
|
141
|
+
typer.echo(__version__)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command()
|
|
145
|
+
def health() -> None:
|
|
146
|
+
"""Quick reachability check against api.eolas.fyi/health."""
|
|
147
|
+
import requests
|
|
148
|
+
try:
|
|
149
|
+
r = requests.get("https://api.eolas.fyi/health", timeout=10)
|
|
150
|
+
r.raise_for_status()
|
|
151
|
+
except Exception as e:
|
|
152
|
+
_bail(f"health check failed: {e}", EXIT_API)
|
|
153
|
+
if not sys.stdout.isatty():
|
|
154
|
+
sys.stdout.write(json.dumps(r.json()))
|
|
155
|
+
sys.stdout.write("\n")
|
|
156
|
+
else:
|
|
157
|
+
Console().print(f"[green]ok[/green] {r.json()}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
# datasets subcommands
|
|
162
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
@datasets_app.command("list")
|
|
165
|
+
def datasets_list(
|
|
166
|
+
source: Optional[str] = typer.Option(None, "--source", "-s", help="Filter by source, e.g. 'Stats NZ', 'OECD'."),
|
|
167
|
+
search: Optional[str] = typer.Option(None, "--search", help="Substring match against name or title."),
|
|
168
|
+
json_out: bool = typer.Option(False, "--json", help="Force NDJSON output."),
|
|
169
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar=None, help="Override resolved API key."),
|
|
170
|
+
) -> None:
|
|
171
|
+
"""List datasets, optionally filtered by source or search term."""
|
|
172
|
+
try:
|
|
173
|
+
items = _client(api_key).list(source=source)
|
|
174
|
+
except EolasError as e:
|
|
175
|
+
_bail(str(e), _exit_for(e))
|
|
176
|
+
|
|
177
|
+
if search:
|
|
178
|
+
needle = search.lower()
|
|
179
|
+
items = [
|
|
180
|
+
d for d in items
|
|
181
|
+
if needle in (str(d.get("name", "")) + str(d.get("title", ""))).lower()
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
if _machine_mode(json_out):
|
|
185
|
+
_emit_ndjson(items)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
table = Table(title=f"{len(items)} dataset{'' if len(items) == 1 else 's'}")
|
|
189
|
+
table.add_column("name", style="cyan", no_wrap=True)
|
|
190
|
+
table.add_column("source", style="magenta", no_wrap=True)
|
|
191
|
+
table.add_column("title")
|
|
192
|
+
for d in items:
|
|
193
|
+
title = (d.get("title") or "")
|
|
194
|
+
if len(title) > 80:
|
|
195
|
+
title = title[:77] + "..."
|
|
196
|
+
table.add_row(str(d.get("name", "")), str(d.get("source", "")), title)
|
|
197
|
+
Console().print(table)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@datasets_app.command("info")
|
|
201
|
+
def datasets_info(
|
|
202
|
+
name: str,
|
|
203
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
204
|
+
api_key: Optional[str] = typer.Option(None, "--api-key"),
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Show metadata for a single dataset."""
|
|
207
|
+
try:
|
|
208
|
+
meta = _client(api_key).info(name)
|
|
209
|
+
except EolasError as e:
|
|
210
|
+
_bail(str(e), _exit_for(e))
|
|
211
|
+
|
|
212
|
+
if _machine_mode(json_out):
|
|
213
|
+
sys.stdout.write(json.dumps(meta, default=str, ensure_ascii=False))
|
|
214
|
+
sys.stdout.write("\n")
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
table = Table(title=name, show_header=False, expand=False)
|
|
218
|
+
table.add_column("field", style="cyan", no_wrap=True)
|
|
219
|
+
table.add_column("value")
|
|
220
|
+
for k, v in meta.items():
|
|
221
|
+
if isinstance(v, (list, dict)):
|
|
222
|
+
v = json.dumps(v, default=str)
|
|
223
|
+
table.add_row(str(k), str(v))
|
|
224
|
+
Console().print(table)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@datasets_app.command("preview")
|
|
228
|
+
def datasets_preview(
|
|
229
|
+
name: str,
|
|
230
|
+
limit: int = typer.Option(10, "--limit", "-n", min=1, max=1000, help="Rows to preview."),
|
|
231
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
232
|
+
api_key: Optional[str] = typer.Option(None, "--api-key"),
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Preview the first N rows of a dataset."""
|
|
235
|
+
try:
|
|
236
|
+
df = _client(api_key).get(name, limit=limit)
|
|
237
|
+
except EolasError as e:
|
|
238
|
+
_bail(str(e), _exit_for(e))
|
|
239
|
+
|
|
240
|
+
if _machine_mode(json_out):
|
|
241
|
+
_emit_ndjson(_row_to_dict(row) for _, row in df.iterrows())
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
table = Table(title=f"{name} (showing {len(df)} rows)")
|
|
245
|
+
for col in df.columns:
|
|
246
|
+
table.add_column(str(col))
|
|
247
|
+
for _, row in df.iterrows():
|
|
248
|
+
table.add_row(*[("" if v is None else str(v)) for v in row.values])
|
|
249
|
+
Console().print(table)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
253
|
+
# get command — the heavy lifter (verb matches the Python client's client.get())
|
|
254
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
@app.command(name="get")
|
|
257
|
+
def get_cmd(
|
|
258
|
+
name: str,
|
|
259
|
+
start: Optional[str] = typer.Option(None, "--start", help="ISO date lower bound, e.g. 2020-01-01."),
|
|
260
|
+
end: Optional[str] = typer.Option(None, "--end", help="ISO date upper bound."),
|
|
261
|
+
limit: Optional[int] = typer.Option(None, "--limit", "-n", help="Max rows. Default: full dataset (Pro) or plan cap (Free/Starter)."),
|
|
262
|
+
fmt: str = typer.Option("csv", "--format", "-f", help="Output format: csv | json | parquet."),
|
|
263
|
+
out: Optional[Path] = typer.Option(None, "--out", "-o", help="Write to file. Default: stdout."),
|
|
264
|
+
api_key: Optional[str] = typer.Option(None, "--api-key"),
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Fetch a dataset and write rows to stdout or a file.
|
|
267
|
+
|
|
268
|
+
Examples
|
|
269
|
+
--------
|
|
270
|
+
eolas get nz_cpi --format csv > cpi.csv
|
|
271
|
+
eolas get nz_cpi --start 2020-01-01 --format json | jq '.[].value'
|
|
272
|
+
eolas get sa2_2023 --format parquet --out sa2.parquet
|
|
273
|
+
"""
|
|
274
|
+
fmt = fmt.lower()
|
|
275
|
+
if fmt not in ("csv", "json", "parquet"):
|
|
276
|
+
_bail(f"unknown --format {fmt!r}; expected csv | json | parquet", EXIT_USAGE)
|
|
277
|
+
if fmt == "parquet" and out is None:
|
|
278
|
+
_bail("parquet requires --out FILE (binary cannot be safely streamed to stdout)", EXIT_USAGE)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
df = _client(api_key).get(name, start=start, end=end, limit=limit)
|
|
282
|
+
except EolasError as e:
|
|
283
|
+
_bail(str(e), _exit_for(e))
|
|
284
|
+
|
|
285
|
+
if fmt == "csv":
|
|
286
|
+
df.to_csv(out if out else sys.stdout, index=False)
|
|
287
|
+
elif fmt == "json":
|
|
288
|
+
text = df.to_json(orient="records", date_format="iso")
|
|
289
|
+
if out:
|
|
290
|
+
out.write_text(text + "\n")
|
|
291
|
+
else:
|
|
292
|
+
sys.stdout.write(text)
|
|
293
|
+
sys.stdout.write("\n")
|
|
294
|
+
elif fmt == "parquet":
|
|
295
|
+
df.to_parquet(out, index=False)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
299
|
+
# auth subcommands
|
|
300
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
def _mask(key: str) -> str:
|
|
303
|
+
if not key:
|
|
304
|
+
return "(none)"
|
|
305
|
+
return key[:8] + "…" if len(key) > 8 else key
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@auth_app.command("set-key")
|
|
309
|
+
def auth_set_key(
|
|
310
|
+
api_key: str = typer.Option(
|
|
311
|
+
..., "--key", prompt="API key", hide_input=True,
|
|
312
|
+
help="Your eolas.fyi API key. Will be saved to ~/.eolas/config.json (chmod 600).",
|
|
313
|
+
),
|
|
314
|
+
) -> None:
|
|
315
|
+
"""Save your API key to ~/.eolas/config.json."""
|
|
316
|
+
CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
317
|
+
CONFIG_FILE.write_text(json.dumps({"api_key": api_key}, indent=2) + "\n")
|
|
318
|
+
CONFIG_FILE.chmod(0o600)
|
|
319
|
+
typer.echo(f"saved {CONFIG_FILE}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@auth_app.command("status")
|
|
323
|
+
def auth_status() -> None:
|
|
324
|
+
"""Show the resolved API key (masked) and which source supplied it."""
|
|
325
|
+
for var in ("EOLAS_API_KEY", "VS_API_KEY"):
|
|
326
|
+
v = os.getenv(var)
|
|
327
|
+
if v:
|
|
328
|
+
typer.echo(f"key: {_mask(v)}\nsource: env {var}")
|
|
329
|
+
return
|
|
330
|
+
if CONFIG_FILE.exists():
|
|
331
|
+
try:
|
|
332
|
+
k = json.loads(CONFIG_FILE.read_text()).get("api_key", "")
|
|
333
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
334
|
+
_bail(f"could not read {CONFIG_FILE}: {e}")
|
|
335
|
+
typer.echo(f"key: {_mask(k)}\nsource: {CONFIG_FILE}")
|
|
336
|
+
return
|
|
337
|
+
typer.echo("no API key configured\nset one with: eolas auth set-key")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@auth_app.command("clear")
|
|
341
|
+
def auth_clear() -> None:
|
|
342
|
+
"""Remove ~/.eolas/config.json (does not unset env vars)."""
|
|
343
|
+
if CONFIG_FILE.exists():
|
|
344
|
+
CONFIG_FILE.unlink()
|
|
345
|
+
typer.echo(f"removed {CONFIG_FILE}")
|
|
346
|
+
else:
|
|
347
|
+
typer.echo(f"no config at {CONFIG_FILE}")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
351
|
+
# schedule subcommands — cron (POSIX) / Task Scheduler (Windows)
|
|
352
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
def _resolve_eolas_path() -> str:
|
|
355
|
+
"""Find the absolute path to the `eolas` binary, for use inside cron lines.
|
|
356
|
+
cron runs with a minimal PATH so we can't rely on `eolas` resolving."""
|
|
357
|
+
import shutil as _shutil
|
|
358
|
+
p = _shutil.which("eolas")
|
|
359
|
+
if not p:
|
|
360
|
+
# Fallback: invoke the python module directly (works even if the script
|
|
361
|
+
# entry point isn't on PATH, e.g. inside an unusual venv layout).
|
|
362
|
+
return f"{sys.executable} -m eolas_data.cli"
|
|
363
|
+
return p
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _config_or_env_set() -> bool:
|
|
367
|
+
"""True if at least one source of API key resolution has a value."""
|
|
368
|
+
return bool(_load_api_key())
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@schedule_app.command("add")
|
|
372
|
+
def schedule_add(
|
|
373
|
+
name: str,
|
|
374
|
+
out: Path = typer.Option(..., "--out", "-o", help="Where to write the fetched data on each run. REQUIRED — cron jobs have no terminal."),
|
|
375
|
+
interval: Optional[str] = typer.Option(None, "--interval", help="hourly | daily | weekly | monthly. Default: daily."),
|
|
376
|
+
cron: Optional[str] = typer.Option(None, "--cron", help="Custom cron expression, e.g. '0 6 * * 1'. POSIX only. Mutually exclusive with --interval."),
|
|
377
|
+
fmt: str = typer.Option("csv", "--format", "-f", help="csv | json | parquet."),
|
|
378
|
+
start: Optional[str] = typer.Option(None, "--start"),
|
|
379
|
+
end: Optional[str] = typer.Option(None, "--end"),
|
|
380
|
+
daily: bool = typer.Option(False, "--daily", help="Shortcut for --interval daily."),
|
|
381
|
+
weekly: bool = typer.Option(False, "--weekly", help="Shortcut for --interval weekly."),
|
|
382
|
+
hourly: bool = typer.Option(False, "--hourly", help="Shortcut for --interval hourly."),
|
|
383
|
+
monthly: bool = typer.Option(False, "--monthly", help="Shortcut for --interval monthly."),
|
|
384
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print what would be installed; don't touch crontab/Task Scheduler."),
|
|
385
|
+
) -> None:
|
|
386
|
+
"""Schedule a recurring fetch. Defaults to daily at 06:00 local time.
|
|
387
|
+
|
|
388
|
+
The job will run as your user, with the env var search path cron provides
|
|
389
|
+
by default. Make sure your API key is in ~/.eolas/config.json (run `eolas
|
|
390
|
+
auth set-key` first) so the scheduled run can authenticate.
|
|
391
|
+
"""
|
|
392
|
+
# ----- pre-flight checks ----------------------------------------------
|
|
393
|
+
if not _config_or_env_set():
|
|
394
|
+
_bail(
|
|
395
|
+
"no API key configured. Run `eolas auth set-key` first so the "
|
|
396
|
+
"scheduled job can authenticate.",
|
|
397
|
+
EXIT_USAGE,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# ----- collapse interval flags ----------------------------------------
|
|
401
|
+
flag_count = sum([daily, weekly, hourly, monthly, interval is not None, cron is not None])
|
|
402
|
+
if flag_count > 1:
|
|
403
|
+
_bail("only one of --hourly/--daily/--weekly/--monthly/--interval/--cron may be set", EXIT_USAGE)
|
|
404
|
+
chosen_interval: Optional[str] = None
|
|
405
|
+
if hourly: chosen_interval = "hourly"
|
|
406
|
+
elif daily: chosen_interval = "daily"
|
|
407
|
+
elif weekly: chosen_interval = "weekly"
|
|
408
|
+
elif monthly: chosen_interval = "monthly"
|
|
409
|
+
elif interval: chosen_interval = interval
|
|
410
|
+
if cron and chosen_interval:
|
|
411
|
+
_bail("--cron and an interval flag are mutually exclusive", EXIT_USAGE)
|
|
412
|
+
if not cron and not chosen_interval:
|
|
413
|
+
chosen_interval = "daily" # default
|
|
414
|
+
|
|
415
|
+
# ----- build the command line -----------------------------------------
|
|
416
|
+
out_path = out.expanduser().resolve()
|
|
417
|
+
eolas_bin = _resolve_eolas_path()
|
|
418
|
+
command = _schedule.build_command(eolas_bin, name, str(out_path),
|
|
419
|
+
start=start, end=end, fmt=fmt)
|
|
420
|
+
|
|
421
|
+
# ----- platform-specific schedule expression --------------------------
|
|
422
|
+
if _schedule.is_windows():
|
|
423
|
+
if cron:
|
|
424
|
+
_bail("custom --cron expressions aren't supported on Windows; use --interval instead", EXIT_USAGE)
|
|
425
|
+
schedule_expr = chosen_interval
|
|
426
|
+
else:
|
|
427
|
+
if cron:
|
|
428
|
+
try:
|
|
429
|
+
_schedule.validate_cron_expr(cron)
|
|
430
|
+
except ValueError as e:
|
|
431
|
+
_bail(str(e), EXIT_USAGE)
|
|
432
|
+
schedule_expr = cron
|
|
433
|
+
else:
|
|
434
|
+
schedule_expr = _schedule.interval_to_cron(chosen_interval)
|
|
435
|
+
|
|
436
|
+
# ----- dry run --------------------------------------------------------
|
|
437
|
+
if dry_run:
|
|
438
|
+
if _schedule.is_windows():
|
|
439
|
+
typer.echo(f"[dry-run] would create scheduled task {_schedule.TASK_PREFIX}{name}")
|
|
440
|
+
typer.echo(f" run: {command}")
|
|
441
|
+
typer.echo(f" schedule: {schedule_expr}")
|
|
442
|
+
else:
|
|
443
|
+
typer.echo(f"[dry-run] would append to crontab:")
|
|
444
|
+
typer.echo(f" {schedule_expr} {command} {_schedule.SENTINEL} {name}")
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
# ----- install --------------------------------------------------------
|
|
448
|
+
try:
|
|
449
|
+
_schedule.add(name, schedule_expr, command)
|
|
450
|
+
except (RuntimeError, ValueError) as e:
|
|
451
|
+
_bail(str(e), EXIT_GENERIC)
|
|
452
|
+
|
|
453
|
+
typer.echo(f"scheduled '{name}' → {out_path}")
|
|
454
|
+
typer.echo(f" schedule: {schedule_expr}")
|
|
455
|
+
typer.echo(f" remove with: eolas schedule remove {name}")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@schedule_app.command("list")
|
|
459
|
+
def schedule_list(
|
|
460
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
461
|
+
) -> None:
|
|
462
|
+
"""List all eolas-managed scheduled tasks."""
|
|
463
|
+
try:
|
|
464
|
+
entries = _schedule.list_entries()
|
|
465
|
+
except RuntimeError as e:
|
|
466
|
+
_bail(str(e), EXIT_GENERIC)
|
|
467
|
+
|
|
468
|
+
if _machine_mode(json_out):
|
|
469
|
+
_emit_ndjson({"name": e.name, "schedule": e.schedule, "command": e.command} for e in entries)
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
if not entries:
|
|
473
|
+
typer.echo("no eolas schedules installed")
|
|
474
|
+
return
|
|
475
|
+
|
|
476
|
+
table = Table(title=f"{len(entries)} schedule{'' if len(entries) == 1 else 's'}")
|
|
477
|
+
table.add_column("name", style="cyan", no_wrap=True)
|
|
478
|
+
table.add_column("schedule", style="magenta", no_wrap=True)
|
|
479
|
+
table.add_column("command")
|
|
480
|
+
for e in entries:
|
|
481
|
+
table.add_row(e.name, e.schedule, e.command)
|
|
482
|
+
Console().print(table)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@schedule_app.command("remove")
|
|
486
|
+
def schedule_remove(name: str) -> None:
|
|
487
|
+
"""Remove a scheduled task by name."""
|
|
488
|
+
try:
|
|
489
|
+
removed = _schedule.remove(name)
|
|
490
|
+
except RuntimeError as e:
|
|
491
|
+
_bail(str(e), EXIT_GENERIC)
|
|
492
|
+
if removed:
|
|
493
|
+
typer.echo(f"removed schedule '{name}'")
|
|
494
|
+
else:
|
|
495
|
+
typer.echo(f"no schedule named '{name}' found")
|
|
496
|
+
raise typer.Exit(code=EXIT_NOT_FOUND)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
500
|
+
# integrate subcommands — Enterprise plan only, generates connector configs
|
|
501
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
def _run_integration(
|
|
504
|
+
platform: str,
|
|
505
|
+
datasets: str,
|
|
506
|
+
output: Path,
|
|
507
|
+
force: bool,
|
|
508
|
+
api_key: Optional[str],
|
|
509
|
+
json_out: bool,
|
|
510
|
+
) -> None:
|
|
511
|
+
"""Shared implementation for all `eolas integrate <platform>` commands."""
|
|
512
|
+
ds_list = [d.strip() for d in datasets.split(",") if d.strip()]
|
|
513
|
+
if not ds_list:
|
|
514
|
+
_bail("--datasets cannot be empty", EXIT_USAGE)
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
files = _client(api_key).integration(platform, ds_list)
|
|
518
|
+
except AuthenticationError as e:
|
|
519
|
+
# Server's 403 detail flows through — usually the "Enterprise feature"
|
|
520
|
+
# upgrade message. We surface it verbatim plus a pricing link.
|
|
521
|
+
err_console.print(f"[red]error:[/red] {e}")
|
|
522
|
+
err_console.print("[dim]→ https://eolas.fyi/pricing[/dim]")
|
|
523
|
+
raise typer.Exit(code=EXIT_AUTH)
|
|
524
|
+
except EolasError as e:
|
|
525
|
+
_bail(str(e), _exit_for(e))
|
|
526
|
+
|
|
527
|
+
if not files:
|
|
528
|
+
_bail(f"server returned no files for platform {platform!r}", EXIT_API)
|
|
529
|
+
|
|
530
|
+
# Default output dir is per-platform so two integrations don't clobber each
|
|
531
|
+
# other in the user's cwd.
|
|
532
|
+
out_dir = output.expanduser().resolve()
|
|
533
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
534
|
+
|
|
535
|
+
written: list[Path] = []
|
|
536
|
+
skipped: list[Path] = []
|
|
537
|
+
for filename, content in files.items():
|
|
538
|
+
target = out_dir / filename
|
|
539
|
+
if target.exists() and not force:
|
|
540
|
+
skipped.append(target)
|
|
541
|
+
continue
|
|
542
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
543
|
+
target.write_text(content)
|
|
544
|
+
written.append(target)
|
|
545
|
+
|
|
546
|
+
if _machine_mode(json_out):
|
|
547
|
+
sys.stdout.write(json.dumps({
|
|
548
|
+
"platform": platform,
|
|
549
|
+
"output_dir": str(out_dir),
|
|
550
|
+
"written": [str(p) for p in written],
|
|
551
|
+
"skipped": [str(p) for p in skipped],
|
|
552
|
+
}, default=str))
|
|
553
|
+
sys.stdout.write("\n")
|
|
554
|
+
return
|
|
555
|
+
|
|
556
|
+
Console().print(f"[green]✓[/green] wrote {len(written)} file(s) to {out_dir}")
|
|
557
|
+
for p in written:
|
|
558
|
+
Console().print(f" [dim]·[/dim] {p.name}")
|
|
559
|
+
if skipped:
|
|
560
|
+
Console().print(
|
|
561
|
+
f"[yellow]skipped {len(skipped)} existing file(s)[/yellow] "
|
|
562
|
+
"(use --force to overwrite):"
|
|
563
|
+
)
|
|
564
|
+
for p in skipped:
|
|
565
|
+
Console().print(f" [dim]·[/dim] {p.name}")
|
|
566
|
+
# Helpful nudge — every generator drops a README.
|
|
567
|
+
if any(p.name.lower() == "readme.md" for p in written):
|
|
568
|
+
Console().print(f"\nnext: open {out_dir / 'README.md'}")
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _default_output_dir(platform: str) -> Path:
|
|
572
|
+
return Path.cwd() / f"eolas-{platform}"
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@integrate_app.command("meltano")
|
|
576
|
+
def integrate_meltano(
|
|
577
|
+
datasets: str = typer.Option(..., "--datasets", "-d", help="Comma-separated dataset names, e.g. 'nz_cpi,nz_gdp'."),
|
|
578
|
+
output: Optional[Path]= typer.Option(None, "--output", "-o", help="Output directory. Default: ./eolas-meltano/"),
|
|
579
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files in the output directory."),
|
|
580
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
581
|
+
api_key: Optional[str] = typer.Option(None, "--api-key"),
|
|
582
|
+
) -> None:
|
|
583
|
+
"""Generate a Meltano project (uses `tap-rest-api-msdk`) for the chosen datasets."""
|
|
584
|
+
_run_integration("meltano", datasets, output or _default_output_dir("meltano"),
|
|
585
|
+
force, api_key, json_out)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
@integrate_app.command("fivetran")
|
|
589
|
+
def integrate_fivetran(
|
|
590
|
+
datasets: str = typer.Option(..., "--datasets", "-d"),
|
|
591
|
+
output: Optional[Path]= typer.Option(None, "--output", "-o", help="Default: ./eolas-fivetran/"),
|
|
592
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
593
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
594
|
+
api_key: Optional[str] = typer.Option(None, "--api-key"),
|
|
595
|
+
) -> None:
|
|
596
|
+
"""Generate a Fivetran Connector Builder YAML for the chosen datasets."""
|
|
597
|
+
_run_integration("fivetran", datasets, output or _default_output_dir("fivetran"),
|
|
598
|
+
force, api_key, json_out)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@integrate_app.command("azure-data-factory")
|
|
602
|
+
def integrate_adf(
|
|
603
|
+
datasets: str = typer.Option(..., "--datasets", "-d"),
|
|
604
|
+
output: Optional[Path]= typer.Option(None, "--output", "-o", help="Default: ./eolas-adf/"),
|
|
605
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
606
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
607
|
+
api_key: Optional[str] = typer.Option(None, "--api-key"),
|
|
608
|
+
) -> None:
|
|
609
|
+
"""Generate Azure Data Factory linked-service / dataset / pipeline JSON."""
|
|
610
|
+
_run_integration("azure-data-factory", datasets,
|
|
611
|
+
output or _default_output_dir("adf"),
|
|
612
|
+
force, api_key, json_out)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# Allow `python -m eolas_data.cli`
|
|
616
|
+
if __name__ == "__main__":
|
|
617
|
+
app()
|