anysite-cli 0.1.2__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.

Potentially problematic release.


This version of anysite-cli might be problematic. Click here for more details.

Files changed (64) hide show
  1. anysite/__init__.py +4 -0
  2. anysite/__main__.py +6 -0
  3. anysite/api/__init__.py +21 -0
  4. anysite/api/client.py +271 -0
  5. anysite/api/errors.py +137 -0
  6. anysite/api/schemas.py +333 -0
  7. anysite/batch/__init__.py +1 -0
  8. anysite/batch/executor.py +176 -0
  9. anysite/batch/input.py +160 -0
  10. anysite/batch/rate_limiter.py +98 -0
  11. anysite/cli/__init__.py +1 -0
  12. anysite/cli/config.py +176 -0
  13. anysite/cli/executor.py +388 -0
  14. anysite/cli/options.py +249 -0
  15. anysite/config/__init__.py +11 -0
  16. anysite/config/paths.py +46 -0
  17. anysite/config/settings.py +187 -0
  18. anysite/dataset/__init__.py +37 -0
  19. anysite/dataset/analyzer.py +268 -0
  20. anysite/dataset/cli.py +644 -0
  21. anysite/dataset/collector.py +686 -0
  22. anysite/dataset/db_loader.py +248 -0
  23. anysite/dataset/errors.py +30 -0
  24. anysite/dataset/exporters.py +121 -0
  25. anysite/dataset/history.py +153 -0
  26. anysite/dataset/models.py +245 -0
  27. anysite/dataset/notifications.py +87 -0
  28. anysite/dataset/scheduler.py +107 -0
  29. anysite/dataset/storage.py +171 -0
  30. anysite/dataset/transformer.py +213 -0
  31. anysite/db/__init__.py +38 -0
  32. anysite/db/adapters/__init__.py +1 -0
  33. anysite/db/adapters/base.py +158 -0
  34. anysite/db/adapters/postgres.py +201 -0
  35. anysite/db/adapters/sqlite.py +183 -0
  36. anysite/db/cli.py +709 -0
  37. anysite/db/config.py +92 -0
  38. anysite/db/manager.py +166 -0
  39. anysite/db/operations/__init__.py +1 -0
  40. anysite/db/operations/insert.py +199 -0
  41. anysite/db/operations/query.py +43 -0
  42. anysite/db/schema/__init__.py +1 -0
  43. anysite/db/schema/inference.py +213 -0
  44. anysite/db/schema/types.py +71 -0
  45. anysite/db/utils/__init__.py +1 -0
  46. anysite/db/utils/sanitize.py +99 -0
  47. anysite/main.py +498 -0
  48. anysite/models/__init__.py +1 -0
  49. anysite/output/__init__.py +11 -0
  50. anysite/output/console.py +45 -0
  51. anysite/output/formatters.py +301 -0
  52. anysite/output/templates.py +76 -0
  53. anysite/py.typed +0 -0
  54. anysite/streaming/__init__.py +1 -0
  55. anysite/streaming/progress.py +121 -0
  56. anysite/streaming/writer.py +130 -0
  57. anysite/utils/__init__.py +1 -0
  58. anysite/utils/fields.py +242 -0
  59. anysite/utils/retry.py +109 -0
  60. anysite_cli-0.1.2.dist-info/METADATA +455 -0
  61. anysite_cli-0.1.2.dist-info/RECORD +64 -0
  62. anysite_cli-0.1.2.dist-info/WHEEL +4 -0
  63. anysite_cli-0.1.2.dist-info/entry_points.txt +2 -0
  64. anysite_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
anysite/dataset/cli.py ADDED
@@ -0,0 +1,644 @@
1
+ """CLI commands for the dataset subsystem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from anysite.dataset import check_data_deps
13
+ from anysite.dataset.errors import DatasetError
14
+
15
+ app = typer.Typer(help="Collect, store, and analyze multi-source datasets")
16
+
17
+
18
+ def _load_config(path: Path) -> Any:
19
+ """Load and validate dataset config from YAML."""
20
+ from anysite.dataset.models import DatasetConfig
21
+
22
+ if not path.exists():
23
+ typer.echo(f"Error: dataset config not found: {path}", err=True)
24
+ raise typer.Exit(1)
25
+ try:
26
+ return DatasetConfig.from_yaml(path)
27
+ except Exception as e:
28
+ typer.echo(f"Error parsing dataset config: {e}", err=True)
29
+ raise typer.Exit(1) from None
30
+
31
+
32
+ @app.callback()
33
+ def dataset_callback() -> None:
34
+ """Check data dependencies before running any dataset command."""
35
+ check_data_deps()
36
+
37
+
38
+ @app.command("init")
39
+ def init(
40
+ name: Annotated[
41
+ str,
42
+ typer.Argument(help="Dataset name (used as directory name)"),
43
+ ],
44
+ path: Annotated[
45
+ Path | None,
46
+ typer.Option("--path", "-p", help="Parent directory (default: current dir)"),
47
+ ] = None,
48
+ ) -> None:
49
+ """Create a new dataset directory with a template YAML config."""
50
+ import yaml
51
+
52
+ base = path or Path.cwd()
53
+ dataset_dir = base / name
54
+ dataset_dir.mkdir(parents=True, exist_ok=True)
55
+
56
+ config_path = dataset_dir / "dataset.yaml"
57
+ if config_path.exists():
58
+ typer.echo(f"Error: {config_path} already exists", err=True)
59
+ raise typer.Exit(1)
60
+
61
+ template: dict[str, Any] = {
62
+ "name": name,
63
+ "description": f"Dataset: {name}",
64
+ "sources": [
65
+ {
66
+ "id": "example_profiles",
67
+ "endpoint": "/api/linkedin/search/users",
68
+ "params": {
69
+ "keywords": "software engineer",
70
+ "count": 10,
71
+ },
72
+ },
73
+ ],
74
+ "storage": {
75
+ "format": "parquet",
76
+ "path": f"./data/{name}/",
77
+ "partition_by": ["source_id", "collected_date"],
78
+ },
79
+ }
80
+
81
+ with open(config_path, "w") as f:
82
+ yaml.dump(template, f, default_flow_style=False, sort_keys=False)
83
+
84
+ console = Console()
85
+ console.print(f"[green]Created[/green] {config_path}")
86
+ console.print(f"Edit the config and run: anysite dataset collect {config_path}")
87
+
88
+
89
+ @app.command("collect")
90
+ def collect(
91
+ config_path: Annotated[
92
+ Path,
93
+ typer.Argument(help="Path to dataset.yaml"),
94
+ ],
95
+ source: Annotated[
96
+ str | None,
97
+ typer.Option("--source", "-s", help="Collect only this source (and dependencies)"),
98
+ ] = None,
99
+ incremental: Annotated[
100
+ bool,
101
+ typer.Option("--incremental", "-i", help="Skip sources already collected today"),
102
+ ] = False,
103
+ dry_run: Annotated[
104
+ bool,
105
+ typer.Option("--dry-run", help="Show collection plan without executing"),
106
+ ] = False,
107
+ quiet: Annotated[
108
+ bool,
109
+ typer.Option("--quiet", "-q", help="Suppress progress output"),
110
+ ] = False,
111
+ load_db: Annotated[
112
+ str | None,
113
+ typer.Option("--load-db", help="After collection, load into database (connection name)"),
114
+ ] = None,
115
+ ) -> None:
116
+ """Collect data from all sources defined in the dataset config."""
117
+ config = _load_config(config_path)
118
+
119
+ try:
120
+ from anysite.dataset.collector import run_collect
121
+
122
+ results = run_collect(
123
+ config,
124
+ config_dir=config_path.parent.resolve(),
125
+ source_filter=source,
126
+ incremental=incremental,
127
+ dry_run=dry_run,
128
+ quiet=quiet,
129
+ )
130
+
131
+ if not dry_run and not quiet:
132
+ console = Console()
133
+ total = sum(results.values())
134
+ console.print(
135
+ f"\n[bold green]Done.[/bold green] "
136
+ f"Collected {total} records across {len(results)} sources."
137
+ )
138
+
139
+ # Auto-load into database if requested
140
+ if load_db and not dry_run:
141
+ _run_load_db(config, load_db, source_filter=source, quiet=quiet)
142
+
143
+ except DatasetError as e:
144
+ typer.echo(f"Error: {e}", err=True)
145
+ raise typer.Exit(1) from None
146
+
147
+
148
+ @app.command("status")
149
+ def status(
150
+ config_path: Annotated[
151
+ Path,
152
+ typer.Argument(help="Path to dataset.yaml"),
153
+ ],
154
+ ) -> None:
155
+ """Show collection status for all sources in the dataset."""
156
+ config = _load_config(config_path)
157
+
158
+ from anysite.dataset.storage import MetadataStore, get_source_dir
159
+
160
+ base_path = config.storage_path()
161
+ metadata = MetadataStore(base_path)
162
+
163
+ console = Console()
164
+ table = Table(title=f"Dataset: {config.name}")
165
+ table.add_column("Source", style="bold")
166
+ table.add_column("Endpoint")
167
+ table.add_column("Last Collected")
168
+ table.add_column("Records", justify="right")
169
+ table.add_column("Files", justify="right")
170
+
171
+ for src in config.sources:
172
+ info = metadata.get_source_info(src.id)
173
+ source_dir = get_source_dir(base_path, src.id)
174
+
175
+ files = list(source_dir.glob("*.parquet")) if source_dir.exists() else []
176
+
177
+ table.add_row(
178
+ src.id,
179
+ src.endpoint,
180
+ info.get("last_collected", "-") if info else "-",
181
+ str(info.get("record_count", 0)) if info else "0",
182
+ str(len(files)),
183
+ )
184
+
185
+ console.print(table)
186
+
187
+
188
+ @app.command("query")
189
+ def query(
190
+ config_path: Annotated[
191
+ Path,
192
+ typer.Argument(help="Path to dataset.yaml"),
193
+ ],
194
+ sql: Annotated[
195
+ str | None,
196
+ typer.Option("--sql", help="SQL query to execute"),
197
+ ] = None,
198
+ file: Annotated[
199
+ Path | None,
200
+ typer.Option("--file", "-f", help="Read SQL from file"),
201
+ ] = None,
202
+ interactive: Annotated[
203
+ bool,
204
+ typer.Option("--interactive", "-i", help="Start interactive SQL shell"),
205
+ ] = False,
206
+ format: Annotated[
207
+ str,
208
+ typer.Option("--format", help="Output format (json/jsonl/csv/table)"),
209
+ ] = "table",
210
+ output: Annotated[
211
+ Path | None,
212
+ typer.Option("--output", "-o", help="Save output to file"),
213
+ ] = None,
214
+ fields: Annotated[
215
+ str | None,
216
+ typer.Option("--fields", help="Comma-separated fields to include (supports dot-notation, e.g. 'name, urn.value AS urn_id')"),
217
+ ] = None,
218
+ source: Annotated[
219
+ str | None,
220
+ typer.Option("--source", "-s", help="Source to query (auto-generates SELECT query)"),
221
+ ] = None,
222
+ ) -> None:
223
+ """Run SQL queries against collected dataset data using DuckDB."""
224
+ config = _load_config(config_path)
225
+
226
+ from anysite.dataset.analyzer import DatasetAnalyzer, expand_dot_fields
227
+
228
+ with DatasetAnalyzer(config) as analyzer:
229
+ if interactive:
230
+ analyzer.interactive_shell()
231
+ return
232
+
233
+ # Auto-generate SQL from --source + --fields
234
+ if source and not sql and not file:
235
+ view_name = source.replace("-", "_").replace(".", "_")
236
+ if fields:
237
+ select_expr = expand_dot_fields(fields)
238
+ fields = None # Already in SQL, don't post-filter
239
+ else:
240
+ select_expr = "*"
241
+ sql = f"SELECT {select_expr} FROM {view_name}"
242
+
243
+ if file:
244
+ if not file.exists():
245
+ typer.echo(f"Error: SQL file not found: {file}", err=True)
246
+ raise typer.Exit(1)
247
+ sql = file.read_text().strip()
248
+
249
+ if not sql:
250
+ typer.echo("Error: provide --sql, --file, --source, or --interactive", err=True)
251
+ raise typer.Exit(1)
252
+
253
+ try:
254
+ results = analyzer.query(sql)
255
+ except Exception as e:
256
+ typer.echo(f"Query error: {e}", err=True)
257
+ raise typer.Exit(1) from None
258
+
259
+ _output_results(results, format, output, fields)
260
+
261
+
262
+ @app.command("stats")
263
+ def stats(
264
+ config_path: Annotated[
265
+ Path,
266
+ typer.Argument(help="Path to dataset.yaml"),
267
+ ],
268
+ source: Annotated[
269
+ str | None,
270
+ typer.Option("--source", "-s", help="Source to analyze"),
271
+ ] = None,
272
+ format: Annotated[
273
+ str,
274
+ typer.Option("--format", help="Output format (json/jsonl/csv/table)"),
275
+ ] = "table",
276
+ output: Annotated[
277
+ Path | None,
278
+ typer.Option("--output", "-o", help="Save output to file"),
279
+ ] = None,
280
+ ) -> None:
281
+ """Show column statistics for a source (min, max, nulls, distinct)."""
282
+ config = _load_config(config_path)
283
+
284
+ if not source:
285
+ # Default to first source
286
+ if config.sources:
287
+ source = config.sources[0].id
288
+ else:
289
+ typer.echo("Error: no sources defined in dataset config", err=True)
290
+ raise typer.Exit(1)
291
+
292
+ from anysite.dataset.analyzer import DatasetAnalyzer
293
+
294
+ with DatasetAnalyzer(config) as analyzer:
295
+ try:
296
+ results = analyzer.stats(source)
297
+ except Exception as e:
298
+ typer.echo(f"Stats error: {e}", err=True)
299
+ raise typer.Exit(1) from None
300
+
301
+ _output_results(results, format, output)
302
+
303
+
304
+ @app.command("profile")
305
+ def profile(
306
+ config_path: Annotated[
307
+ Path,
308
+ typer.Argument(help="Path to dataset.yaml"),
309
+ ],
310
+ format: Annotated[
311
+ str,
312
+ typer.Option("--format", help="Output format (json/jsonl/csv/table)"),
313
+ ] = "table",
314
+ output: Annotated[
315
+ Path | None,
316
+ typer.Option("--output", "-o", help="Save output to file"),
317
+ ] = None,
318
+ ) -> None:
319
+ """Profile dataset quality: completeness, record counts, missing data."""
320
+ config = _load_config(config_path)
321
+
322
+ from anysite.dataset.analyzer import DatasetAnalyzer
323
+
324
+ with DatasetAnalyzer(config) as analyzer:
325
+ try:
326
+ results = analyzer.profile()
327
+ except Exception as e:
328
+ typer.echo(f"Profile error: {e}", err=True)
329
+ raise typer.Exit(1) from None
330
+
331
+ _output_results(results, format, output)
332
+
333
+
334
+ @app.command("load-db")
335
+ def load_db(
336
+ config_path: Annotated[
337
+ Path,
338
+ typer.Argument(help="Path to dataset.yaml"),
339
+ ],
340
+ connection: Annotated[
341
+ str,
342
+ typer.Option("--connection", "-c", help="Database connection name"),
343
+ ],
344
+ source: Annotated[
345
+ str | None,
346
+ typer.Option("--source", "-s", help="Load only this source (and dependencies)"),
347
+ ] = None,
348
+ dry_run: Annotated[
349
+ bool,
350
+ typer.Option("--dry-run", help="Show plan without executing"),
351
+ ] = False,
352
+ drop_existing: Annotated[
353
+ bool,
354
+ typer.Option("--drop-existing", help="Drop tables before creating"),
355
+ ] = False,
356
+ quiet: Annotated[
357
+ bool,
358
+ typer.Option("--quiet", "-q", help="Suppress progress output"),
359
+ ] = False,
360
+ ) -> None:
361
+ """Load collected Parquet data into a relational database with FK linking."""
362
+ config = _load_config(config_path)
363
+
364
+ from anysite.db.manager import ConnectionManager
365
+
366
+ manager = ConnectionManager()
367
+ try:
368
+ adapter = manager.get_adapter_by_name(connection)
369
+ except ValueError as e:
370
+ typer.echo(f"Error: {e}", err=True)
371
+ raise typer.Exit(1) from None
372
+
373
+ from anysite.dataset.db_loader import DatasetDbLoader
374
+
375
+ with adapter:
376
+ loader = DatasetDbLoader(config, adapter)
377
+ try:
378
+ results = loader.load_all(
379
+ source_filter=source,
380
+ drop_existing=drop_existing,
381
+ dry_run=dry_run,
382
+ )
383
+ except Exception as e:
384
+ typer.echo(f"Load error: {e}", err=True)
385
+ raise typer.Exit(1) from None
386
+
387
+ if not quiet:
388
+ console = Console()
389
+ if dry_run:
390
+ console.print("[bold]Dry run — no tables modified.[/bold]")
391
+
392
+ table = Table(title="Load Results")
393
+ table.add_column("Source", style="bold")
394
+ table.add_column("Table")
395
+ table.add_column("Rows", justify="right")
396
+
397
+ from anysite.dataset.db_loader import _table_name_for
398
+
399
+ for src in config.sources:
400
+ if src.id in results:
401
+ table.add_row(
402
+ src.id,
403
+ _table_name_for(src),
404
+ str(results[src.id]),
405
+ )
406
+
407
+ console.print(table)
408
+
409
+ total = sum(results.values())
410
+ console.print(
411
+ f"\n[bold green]{'Would load' if dry_run else 'Loaded'}[/bold green] "
412
+ f"{total} rows across {len(results)} tables."
413
+ )
414
+
415
+
416
+ @app.command("history")
417
+ def history(
418
+ name: Annotated[
419
+ str,
420
+ typer.Argument(help="Dataset name"),
421
+ ],
422
+ limit: Annotated[
423
+ int,
424
+ typer.Option("--limit", "-n", help="Number of recent runs to show"),
425
+ ] = 20,
426
+ ) -> None:
427
+ """Show run history for a dataset."""
428
+ from anysite.dataset.history import HistoryStore
429
+
430
+ store = HistoryStore()
431
+ runs = store.get_history(name, limit=limit)
432
+
433
+ if not runs:
434
+ typer.echo(f"No history found for dataset '{name}'")
435
+ return
436
+
437
+ console = Console()
438
+ table = Table(title=f"Run History: {name}")
439
+ table.add_column("ID", style="bold")
440
+ table.add_column("Status")
441
+ table.add_column("Started")
442
+ table.add_column("Duration", justify="right")
443
+ table.add_column("Records", justify="right")
444
+ table.add_column("Sources", justify="right")
445
+ table.add_column("Error")
446
+
447
+ for run in runs:
448
+ status_style = {
449
+ "success": "green",
450
+ "failed": "red",
451
+ "running": "yellow",
452
+ "partial": "yellow",
453
+ }.get(run.status, "")
454
+
455
+ duration_str = f"{run.duration:.1f}s" if run.duration else "-"
456
+ started = run.started_at[:19] if run.started_at else "-"
457
+
458
+ table.add_row(
459
+ str(run.id),
460
+ f"[{status_style}]{run.status}[/{status_style}]" if status_style else run.status,
461
+ started,
462
+ duration_str,
463
+ str(run.record_count),
464
+ str(run.source_count),
465
+ (run.error or "")[:60],
466
+ )
467
+
468
+ console.print(table)
469
+
470
+
471
+ @app.command("logs")
472
+ def logs(
473
+ name: Annotated[
474
+ str,
475
+ typer.Argument(help="Dataset name"),
476
+ ],
477
+ run_id: Annotated[
478
+ int | None,
479
+ typer.Option("--run", help="Specific run ID (default: latest)"),
480
+ ] = None,
481
+ ) -> None:
482
+ """Show logs for a dataset collection run."""
483
+ from anysite.dataset.history import HistoryStore, LogManager
484
+
485
+ log_mgr = LogManager()
486
+
487
+ if run_id is None:
488
+ store = HistoryStore()
489
+ runs = store.get_history(name, limit=1)
490
+ if not runs:
491
+ typer.echo(f"No runs found for dataset '{name}'")
492
+ raise typer.Exit(1)
493
+ run_id = runs[0].id
494
+
495
+ content = log_mgr.read_log(name, run_id) # type: ignore[arg-type]
496
+ if content:
497
+ typer.echo(content)
498
+ else:
499
+ typer.echo(f"No log file found for run {run_id}")
500
+
501
+
502
+ @app.command("schedule")
503
+ def schedule(
504
+ config_path: Annotated[
505
+ Path,
506
+ typer.Argument(help="Path to dataset.yaml"),
507
+ ],
508
+ crontab: Annotated[
509
+ bool,
510
+ typer.Option("--crontab", help="Generate crontab entry"),
511
+ ] = False,
512
+ systemd: Annotated[
513
+ bool,
514
+ typer.Option("--systemd", help="Generate systemd timer units"),
515
+ ] = False,
516
+ incremental: Annotated[
517
+ bool,
518
+ typer.Option("--incremental", "-i", help="Include --incremental flag"),
519
+ ] = False,
520
+ load_db: Annotated[
521
+ str | None,
522
+ typer.Option("--load-db", help="Include --load-db <connection> flag"),
523
+ ] = None,
524
+ ) -> None:
525
+ """Generate schedule entries for automated collection."""
526
+ config = _load_config(config_path)
527
+
528
+ if not config.schedule:
529
+ typer.echo("Error: no 'schedule' block in dataset config", err=True)
530
+ raise typer.Exit(1)
531
+
532
+ from anysite.dataset.scheduler import ScheduleGenerator
533
+
534
+ gen = ScheduleGenerator(
535
+ dataset_name=config.name,
536
+ cron_expr=config.schedule.cron,
537
+ yaml_path=str(config_path),
538
+ )
539
+
540
+ console = Console()
541
+
542
+ if crontab or (not crontab and not systemd):
543
+ entry = gen.generate_crontab(incremental=incremental, load_db=load_db)
544
+ console.print("[bold]Crontab entry:[/bold]")
545
+ console.print(entry)
546
+
547
+ if systemd:
548
+ units = gen.generate_systemd(incremental=incremental, load_db=load_db)
549
+ for filename, content in units.items():
550
+ console.print(f"\n[bold]{filename}:[/bold]")
551
+ console.print(content)
552
+
553
+
554
+ @app.command("reset-cursor")
555
+ def reset_cursor(
556
+ config_path: Annotated[
557
+ Path,
558
+ typer.Argument(help="Path to dataset.yaml"),
559
+ ],
560
+ source: Annotated[
561
+ str | None,
562
+ typer.Option("--source", "-s", help="Reset only this source"),
563
+ ] = None,
564
+ ) -> None:
565
+ """Reset incremental collection state (re-collect everything)."""
566
+ config = _load_config(config_path)
567
+
568
+ from anysite.dataset.storage import MetadataStore
569
+
570
+ base_path = config.storage_path()
571
+ metadata = MetadataStore(base_path)
572
+
573
+ console = Console()
574
+
575
+ if source:
576
+ metadata.reset_collected_inputs(source)
577
+ console.print(f"[green]Reset[/green] incremental state for source: {source}")
578
+ else:
579
+ for src in config.sources:
580
+ metadata.reset_collected_inputs(src.id)
581
+ console.print(f"[green]Reset[/green] incremental state for all {len(config.sources)} sources")
582
+
583
+
584
+ def _run_load_db(
585
+ config: Any,
586
+ connection: str,
587
+ *,
588
+ source_filter: str | None = None,
589
+ quiet: bool = False,
590
+ ) -> None:
591
+ """Load collected Parquet data into a database after collection."""
592
+ from anysite.db.manager import ConnectionManager
593
+
594
+ manager = ConnectionManager()
595
+ try:
596
+ adapter = manager.get_adapter_by_name(connection)
597
+ except ValueError as e:
598
+ typer.echo(f"Error loading to DB: {e}", err=True)
599
+ return
600
+
601
+ from anysite.dataset.db_loader import DatasetDbLoader, _table_name_for
602
+
603
+ with adapter:
604
+ loader = DatasetDbLoader(config, adapter)
605
+ try:
606
+ results = loader.load_all(source_filter=source_filter)
607
+ except Exception as e:
608
+ typer.echo(f"DB load error: {e}", err=True)
609
+ return
610
+
611
+ if not quiet:
612
+ console = Console()
613
+ table = Table(title="DB Load Results")
614
+ table.add_column("Source", style="bold")
615
+ table.add_column("Table")
616
+ table.add_column("Rows", justify="right")
617
+
618
+ for src in config.sources:
619
+ if src.id in results:
620
+ table.add_row(src.id, _table_name_for(src), str(results[src.id]))
621
+
622
+ console.print(table)
623
+ total = sum(results.values())
624
+ console.print(f"[bold green]Loaded[/bold green] {total} rows into {connection}.")
625
+
626
+
627
+ def _output_results(
628
+ data: list[dict[str, Any]],
629
+ format: str = "table",
630
+ output: Path | None = None,
631
+ fields: str | None = None,
632
+ ) -> None:
633
+ """Output query results using the existing formatter pipeline."""
634
+ from anysite.cli.options import parse_fields
635
+ from anysite.output.formatters import OutputFormat, format_output
636
+
637
+ try:
638
+ fmt = OutputFormat(format.lower())
639
+ except ValueError:
640
+ typer.echo(f"Error: invalid format '{format}', use json/jsonl/csv/table", err=True)
641
+ raise typer.Exit(1) from None
642
+
643
+ include_fields = parse_fields(fields)
644
+ format_output(data, fmt, include_fields, output, quiet=False)