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