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.
@@ -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()