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/db/cli.py ADDED
@@ -0,0 +1,709 @@
1
+ """CLI commands for the database subsystem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated, Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from anysite.db.config import ConnectionConfig, DatabaseType, OnConflict
15
+ from anysite.db.manager import ConnectionManager
16
+
17
+ app = typer.Typer(help="Store API data in SQL databases")
18
+
19
+
20
+ def _get_manager() -> ConnectionManager:
21
+ return ConnectionManager()
22
+
23
+
24
+ def _get_config_or_exit(name: str) -> ConnectionConfig:
25
+ """Get a connection config by name or exit with error."""
26
+ config = _get_manager().get(name)
27
+ if config is None:
28
+ typer.echo(f"Error: connection '{name}' not found", err=True)
29
+ typer.echo("Run 'anysite db list' to see available connections.", err=True)
30
+ raise typer.Exit(1)
31
+ return config
32
+
33
+
34
+ # ── Connection management ──────────────────────────────────────────────
35
+
36
+
37
+ @app.command("add")
38
+ def add(
39
+ name: Annotated[
40
+ str,
41
+ typer.Argument(help="Connection name"),
42
+ ],
43
+ type: Annotated[
44
+ DatabaseType,
45
+ typer.Option("--type", "-t", help="Database type"),
46
+ ] = DatabaseType.SQLITE,
47
+ host: Annotated[
48
+ str | None,
49
+ typer.Option("--host", "-h", help="Database host"),
50
+ ] = None,
51
+ port: Annotated[
52
+ int | None,
53
+ typer.Option("--port", "-p", help="Database port"),
54
+ ] = None,
55
+ database: Annotated[
56
+ str | None,
57
+ typer.Option("--database", "-d", help="Database name"),
58
+ ] = None,
59
+ user: Annotated[
60
+ str | None,
61
+ typer.Option("--user", "-u", help="Database user"),
62
+ ] = None,
63
+ password: Annotated[
64
+ str | None,
65
+ typer.Option("--password", help="Database password (stored via auto-generated env var)"),
66
+ ] = None,
67
+ password_env: Annotated[
68
+ str | None,
69
+ typer.Option("--password-env", help="Env var containing password"),
70
+ ] = None,
71
+ url_env: Annotated[
72
+ str | None,
73
+ typer.Option("--url-env", help="Env var containing connection URL"),
74
+ ] = None,
75
+ path: Annotated[
76
+ str | None,
77
+ typer.Option("--path", help="Database file path (SQLite/DuckDB)"),
78
+ ] = None,
79
+ ssl: Annotated[
80
+ bool,
81
+ typer.Option("--ssl", help="Enable SSL"),
82
+ ] = False,
83
+ ) -> None:
84
+ """Add a named database connection.
85
+
86
+ \b
87
+ Examples:
88
+ anysite db add local --type sqlite --path ./data.db
89
+ anysite db add prod --type postgres --host db.example.com --database analytics --user app --password-env DB_PASS
90
+ anysite db add remote --type postgres --url-env DATABASE_URL
91
+ """
92
+ if password and password_env:
93
+ typer.echo("Error: --password and --password-env are mutually exclusive", err=True)
94
+ raise typer.Exit(1)
95
+
96
+ generated_env_var: str | None = None
97
+ if password:
98
+ safe_name = name.upper().replace("-", "_").replace(".", "_")
99
+ generated_env_var = f"ANYSITE_DB_{safe_name}_PASS"
100
+ os.environ[generated_env_var] = password
101
+ password_env = generated_env_var
102
+
103
+ try:
104
+ config = ConnectionConfig(
105
+ name=name,
106
+ type=type,
107
+ host=host,
108
+ port=port,
109
+ database=database,
110
+ user=user,
111
+ password_env=password_env,
112
+ url_env=url_env,
113
+ path=path,
114
+ ssl=ssl,
115
+ )
116
+ except ValueError as e:
117
+ typer.echo(f"Error: {e}", err=True)
118
+ raise typer.Exit(1) from None
119
+
120
+ manager = _get_manager()
121
+ manager.add(config)
122
+
123
+ console = Console()
124
+ console.print(f"[green]Added[/green] connection '{name}' ({type.value})")
125
+ if generated_env_var:
126
+ console.print(
127
+ f"[dim]Password stored via env var {generated_env_var}. "
128
+ f"Export it in your shell for future sessions: "
129
+ f"export {generated_env_var}=\"...\"[/dim]"
130
+ )
131
+
132
+
133
+ @app.command("list")
134
+ def list_connections() -> None:
135
+ """List all saved database connections."""
136
+ manager = _get_manager()
137
+ connections = manager.list()
138
+
139
+ if not connections:
140
+ typer.echo("No connections configured.")
141
+ typer.echo("Add one with: anysite db add <name> --type sqlite --path ./data.db")
142
+ return
143
+
144
+ console = Console()
145
+ table = Table(title="Database Connections")
146
+ table.add_column("Name", style="bold")
147
+ table.add_column("Type")
148
+ table.add_column("Location")
149
+
150
+ for conn in connections:
151
+ if conn.type in (DatabaseType.SQLITE, DatabaseType.DUCKDB):
152
+ location = conn.path or ""
153
+ elif conn.url_env:
154
+ location = f"${conn.url_env}"
155
+ else:
156
+ parts = []
157
+ if conn.user:
158
+ parts.append(f"{conn.user}@")
159
+ if conn.host:
160
+ parts.append(conn.host)
161
+ if conn.port:
162
+ parts.append(f":{conn.port}")
163
+ if conn.database:
164
+ parts.append(f"/{conn.database}")
165
+ location = "".join(parts)
166
+
167
+ table.add_row(conn.name, conn.type.value, location)
168
+
169
+ console.print(table)
170
+
171
+
172
+ @app.command("test")
173
+ def test(
174
+ name: Annotated[
175
+ str,
176
+ typer.Argument(help="Connection name to test"),
177
+ ],
178
+ ) -> None:
179
+ """Test a database connection."""
180
+ _get_config_or_exit(name)
181
+
182
+ console = Console()
183
+ console.print(f"Testing connection '{name}'...")
184
+
185
+ manager = _get_manager()
186
+ try:
187
+ info = manager.test(name)
188
+ console.print(f"[green]Connected[/green] to {info.get('type', 'unknown')}")
189
+ for key, value in info.items():
190
+ console.print(f" {key}: {value}")
191
+ except Exception as e:
192
+ console.print(f"[red]Failed[/red]: {e}")
193
+ raise typer.Exit(1) from None
194
+
195
+
196
+ @app.command("remove")
197
+ def remove(
198
+ name: Annotated[
199
+ str,
200
+ typer.Argument(help="Connection name to remove"),
201
+ ],
202
+ force: Annotated[
203
+ bool,
204
+ typer.Option("--force", "-f", help="Skip confirmation"),
205
+ ] = False,
206
+ ) -> None:
207
+ """Remove a saved database connection."""
208
+ _get_config_or_exit(name)
209
+
210
+ if not force:
211
+ confirm = typer.confirm(f"Remove connection '{name}'?")
212
+ if not confirm:
213
+ raise typer.Abort()
214
+
215
+ manager = _get_manager()
216
+ manager.remove(name)
217
+
218
+ console = Console()
219
+ console.print(f"[green]Removed[/green] connection '{name}'")
220
+
221
+
222
+ @app.command("info")
223
+ def info(
224
+ name: Annotated[
225
+ str,
226
+ typer.Argument(help="Connection name"),
227
+ ],
228
+ ) -> None:
229
+ """Show details about a database connection."""
230
+ config = _get_config_or_exit(name)
231
+
232
+ console = Console()
233
+ console.print(f"[bold]Connection: {config.name}[/bold]")
234
+ console.print(f" Type: {config.type.value}")
235
+
236
+ if config.path:
237
+ console.print(f" Path: {config.path}")
238
+ if config.host:
239
+ console.print(f" Host: {config.host}")
240
+ if config.port:
241
+ console.print(f" Port: {config.port}")
242
+ if config.database:
243
+ console.print(f" Database: {config.database}")
244
+ if config.user:
245
+ console.print(f" User: {config.user}")
246
+ if config.password_env:
247
+ console.print(f" Password: ${config.password_env}")
248
+ if config.url_env:
249
+ console.print(f" URL: ${config.url_env}")
250
+ if config.ssl:
251
+ console.print(" SSL: enabled")
252
+ if config.options:
253
+ console.print(f" Options: {config.options}")
254
+
255
+
256
+ # ── Schema commands ────────────────────────────────────────────────────
257
+
258
+
259
+ @app.command("schema")
260
+ def schema(
261
+ name: Annotated[
262
+ str,
263
+ typer.Argument(help="Connection name"),
264
+ ],
265
+ table: Annotated[
266
+ str | None,
267
+ typer.Option("--table", "-t", help="Table to inspect"),
268
+ ] = None,
269
+ ) -> None:
270
+ """Inspect database schema — list tables or show table columns.
271
+
272
+ \b
273
+ Examples:
274
+ anysite db schema mydb # list all tables
275
+ anysite db schema mydb --table users # show columns of 'users'
276
+ """
277
+ config = _get_config_or_exit(name)
278
+ manager = _get_manager()
279
+ adapter = manager.get_adapter(config)
280
+
281
+ console = Console()
282
+
283
+ with adapter:
284
+ if table:
285
+ if not adapter.table_exists(table):
286
+ typer.echo(f"Error: table '{table}' does not exist", err=True)
287
+ raise typer.Exit(1)
288
+
289
+ columns = adapter.get_table_schema(table)
290
+ tbl = Table(title=f"Table: {table}")
291
+ tbl.add_column("Column", style="bold")
292
+ tbl.add_column("Type")
293
+ tbl.add_column("Nullable")
294
+ tbl.add_column("Primary Key")
295
+
296
+ for col in columns:
297
+ tbl.add_row(col["name"], col["type"], col["nullable"], col["primary_key"])
298
+ console.print(tbl)
299
+ else:
300
+ # List tables - adapter-agnostic via querying system tables
301
+ tables = _list_tables(adapter, config.type)
302
+ if not tables:
303
+ console.print("[dim]No tables found[/dim]")
304
+ return
305
+
306
+ tbl = Table(title="Tables")
307
+ tbl.add_column("Table Name", style="bold")
308
+ for t in tables:
309
+ tbl.add_row(t)
310
+ console.print(tbl)
311
+
312
+
313
+ def _list_tables(adapter: Any, db_type: DatabaseType) -> list[str]:
314
+ """List all tables in the database."""
315
+ if db_type == DatabaseType.SQLITE:
316
+ rows = adapter.fetch_all(
317
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
318
+ )
319
+ return [r["name"] for r in rows]
320
+ elif db_type == DatabaseType.POSTGRES:
321
+ rows = adapter.fetch_all(
322
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
323
+ )
324
+ return [r["tablename"] for r in rows]
325
+ return []
326
+
327
+
328
+ @app.command("create-table")
329
+ def create_table(
330
+ name: Annotated[
331
+ str,
332
+ typer.Argument(help="Connection name"),
333
+ ],
334
+ table: Annotated[
335
+ str,
336
+ typer.Option("--table", "-t", help="Table name to create"),
337
+ ],
338
+ stdin: Annotated[
339
+ bool,
340
+ typer.Option("--stdin", help="Infer schema from JSONL on stdin"),
341
+ ] = False,
342
+ pk: Annotated[
343
+ str | None,
344
+ typer.Option("--pk", help="Primary key column"),
345
+ ] = None,
346
+ dry_run: Annotated[
347
+ bool,
348
+ typer.Option("--dry-run", help="Show CREATE TABLE SQL without executing"),
349
+ ] = False,
350
+ ) -> None:
351
+ """Create a table with schema inferred from JSON data.
352
+
353
+ \b
354
+ Examples:
355
+ echo '{"name":"test","age":30}' | anysite db create-table mydb --table users --stdin
356
+ echo '{"id":1,"name":"test"}' | anysite db create-table mydb --table users --stdin --pk id --dry-run
357
+ """
358
+ import json
359
+
360
+ from anysite.db.schema.inference import infer_table_schema
361
+ from anysite.db.utils.sanitize import sanitize_identifier, sanitize_table_name
362
+
363
+ if not stdin:
364
+ typer.echo("Error: --stdin is required (provide JSON data via stdin)", err=True)
365
+ raise typer.Exit(1)
366
+
367
+ content = sys.stdin.read().strip()
368
+ if not content:
369
+ typer.echo("Error: no data on stdin", err=True)
370
+ raise typer.Exit(1)
371
+
372
+ # Parse input
373
+ rows: list[dict[str, Any]] = []
374
+ try:
375
+ data = json.loads(content)
376
+ if isinstance(data, list):
377
+ rows = [r for r in data if isinstance(r, dict)]
378
+ elif isinstance(data, dict):
379
+ rows = [data]
380
+ except json.JSONDecodeError:
381
+ for line in content.split("\n"):
382
+ line = line.strip()
383
+ if line:
384
+ try:
385
+ obj = json.loads(line)
386
+ if isinstance(obj, dict):
387
+ rows.append(obj)
388
+ except json.JSONDecodeError:
389
+ continue
390
+
391
+ if not rows:
392
+ typer.echo("Error: no valid JSON objects found in input", err=True)
393
+ raise typer.Exit(1)
394
+
395
+ config = _get_config_or_exit(name)
396
+ manager = _get_manager()
397
+ dialect = config.type.value
398
+
399
+ schema = infer_table_schema(table, rows)
400
+ sql_types = schema.to_sql_types(dialect)
401
+
402
+ safe_table = sanitize_table_name(table)
403
+ col_defs = []
404
+ for col_name, col_type in sql_types.items():
405
+ safe_col = sanitize_identifier(col_name)
406
+ pk_suffix = " PRIMARY KEY" if col_name == pk else ""
407
+ col_defs.append(f" {safe_col} {col_type}{pk_suffix}")
408
+ create_sql = f"CREATE TABLE IF NOT EXISTS {safe_table} (\n" + ",\n".join(col_defs) + "\n)"
409
+
410
+ console = Console()
411
+
412
+ if dry_run:
413
+ console.print(f"[dim]-- Inferred from {len(rows)} row(s)[/dim]")
414
+ console.print(create_sql)
415
+ return
416
+
417
+ adapter = manager.get_adapter(config)
418
+ with adapter:
419
+ if adapter.table_exists(table):
420
+ typer.echo(f"Error: table '{table}' already exists", err=True)
421
+ raise typer.Exit(1)
422
+ adapter.create_table(table, sql_types, primary_key=pk)
423
+ console.print(f"[green]Created[/green] table '{table}' with {len(sql_types)} columns")
424
+
425
+
426
+ # ── Data commands ──────────────────────────────────────────────────────
427
+
428
+
429
+ @app.command("insert")
430
+ def insert(
431
+ name: Annotated[
432
+ str,
433
+ typer.Argument(help="Connection name"),
434
+ ],
435
+ table: Annotated[
436
+ str,
437
+ typer.Option("--table", "-t", help="Target table"),
438
+ ],
439
+ stdin: Annotated[
440
+ bool,
441
+ typer.Option("--stdin", help="Read JSON data from stdin"),
442
+ ] = False,
443
+ file: Annotated[
444
+ Path | None,
445
+ typer.Option("--file", "-f", help="Read JSON data from file"),
446
+ ] = None,
447
+ auto_create: Annotated[
448
+ bool,
449
+ typer.Option("--auto-create", help="Create table if it doesn't exist"),
450
+ ] = False,
451
+ pk: Annotated[
452
+ str | None,
453
+ typer.Option("--pk", help="Primary key column (for auto-create)"),
454
+ ] = None,
455
+ on_conflict: Annotated[
456
+ OnConflict,
457
+ typer.Option("--on-conflict", help="Conflict handling: error, ignore, replace, update"),
458
+ ] = OnConflict.ERROR,
459
+ conflict_columns: Annotated[
460
+ str | None,
461
+ typer.Option("--conflict-columns", help="Comma-separated conflict columns (for upsert)"),
462
+ ] = None,
463
+ batch_size: Annotated[
464
+ int,
465
+ typer.Option("--batch-size", help="Rows per batch insert"),
466
+ ] = 100,
467
+ quiet: Annotated[
468
+ bool,
469
+ typer.Option("--quiet", "-q", help="Suppress non-data output"),
470
+ ] = False,
471
+ ) -> None:
472
+ """Insert JSON data into a database table.
473
+
474
+ Reads JSONL (one JSON object per line) or a JSON array from stdin or file.
475
+
476
+ \b
477
+ Examples:
478
+ echo '{"name":"test","value":42}' | anysite db insert mydb --table demo --stdin --auto-create
479
+ anysite api /api/linkedin/user user=satyanadella | anysite db insert mydb --table users --stdin
480
+ anysite db insert mydb --table users --file data.jsonl
481
+ """
482
+ from anysite.db.operations.insert import insert_from_file, insert_from_stdin
483
+
484
+ if not stdin and not file:
485
+ typer.echo("Error: provide --stdin or --file", err=True)
486
+ raise typer.Exit(1)
487
+
488
+ if file and not file.exists():
489
+ typer.echo(f"Error: file not found: {file}", err=True)
490
+ raise typer.Exit(1)
491
+
492
+ conflict_cols = [c.strip() for c in conflict_columns.split(",")] if conflict_columns else None
493
+
494
+ config = _get_config_or_exit(name)
495
+ manager = _get_manager()
496
+ adapter = manager.get_adapter(config)
497
+
498
+ console = Console()
499
+
500
+ with adapter:
501
+ if stdin:
502
+ count = insert_from_stdin(
503
+ adapter,
504
+ table,
505
+ on_conflict=on_conflict,
506
+ conflict_columns=conflict_cols,
507
+ auto_create=auto_create,
508
+ primary_key=pk,
509
+ batch_size=batch_size,
510
+ quiet=quiet,
511
+ )
512
+ else:
513
+ assert file is not None
514
+ count = insert_from_file(
515
+ adapter,
516
+ table,
517
+ file,
518
+ on_conflict=on_conflict,
519
+ conflict_columns=conflict_cols,
520
+ auto_create=auto_create,
521
+ primary_key=pk,
522
+ batch_size=batch_size,
523
+ quiet=quiet,
524
+ )
525
+
526
+ if not quiet:
527
+ console.print(f"[green]Inserted[/green] {count} row(s) into '{table}'")
528
+
529
+
530
+ @app.command("upsert")
531
+ def upsert(
532
+ name: Annotated[
533
+ str,
534
+ typer.Argument(help="Connection name"),
535
+ ],
536
+ table: Annotated[
537
+ str,
538
+ typer.Option("--table", "-t", help="Target table"),
539
+ ],
540
+ conflict_columns: Annotated[
541
+ str,
542
+ typer.Option("--conflict-columns", help="Comma-separated conflict columns"),
543
+ ],
544
+ stdin: Annotated[
545
+ bool,
546
+ typer.Option("--stdin", help="Read JSON data from stdin"),
547
+ ] = False,
548
+ file: Annotated[
549
+ Path | None,
550
+ typer.Option("--file", "-f", help="Read JSON data from file"),
551
+ ] = None,
552
+ auto_create: Annotated[
553
+ bool,
554
+ typer.Option("--auto-create", help="Create table if it doesn't exist"),
555
+ ] = False,
556
+ pk: Annotated[
557
+ str | None,
558
+ typer.Option("--pk", help="Primary key column (for auto-create)"),
559
+ ] = None,
560
+ batch_size: Annotated[
561
+ int,
562
+ typer.Option("--batch-size", help="Rows per batch insert"),
563
+ ] = 100,
564
+ quiet: Annotated[
565
+ bool,
566
+ typer.Option("--quiet", "-q", help="Suppress non-data output"),
567
+ ] = False,
568
+ ) -> None:
569
+ """Upsert JSON data — insert or update on conflict.
570
+
571
+ Shorthand for `insert --on-conflict update --conflict-columns ...`.
572
+
573
+ \b
574
+ Examples:
575
+ anysite api /api/linkedin/user user=satyanadella \\
576
+ | anysite db upsert mydb --table users --conflict-columns linkedin_url --stdin
577
+ """
578
+ from anysite.db.operations.insert import insert_from_file, insert_from_stdin
579
+
580
+ if not stdin and not file:
581
+ typer.echo("Error: provide --stdin or --file", err=True)
582
+ raise typer.Exit(1)
583
+
584
+ if file and not file.exists():
585
+ typer.echo(f"Error: file not found: {file}", err=True)
586
+ raise typer.Exit(1)
587
+
588
+ conflict_cols = [c.strip() for c in conflict_columns.split(",")]
589
+
590
+ config = _get_config_or_exit(name)
591
+ manager = _get_manager()
592
+ adapter = manager.get_adapter(config)
593
+
594
+ console = Console()
595
+
596
+ with adapter:
597
+ if stdin:
598
+ count = insert_from_stdin(
599
+ adapter,
600
+ table,
601
+ on_conflict=OnConflict.UPDATE,
602
+ conflict_columns=conflict_cols,
603
+ auto_create=auto_create,
604
+ primary_key=pk,
605
+ batch_size=batch_size,
606
+ quiet=quiet,
607
+ )
608
+ else:
609
+ assert file is not None
610
+ count = insert_from_file(
611
+ adapter,
612
+ table,
613
+ file,
614
+ on_conflict=OnConflict.UPDATE,
615
+ conflict_columns=conflict_cols,
616
+ auto_create=auto_create,
617
+ primary_key=pk,
618
+ batch_size=batch_size,
619
+ quiet=quiet,
620
+ )
621
+
622
+ if not quiet:
623
+ console.print(f"[green]Upserted[/green] {count} row(s) into '{table}'")
624
+
625
+
626
+ # ── Query command ──────────────────────────────────────────────────────
627
+
628
+
629
+ @app.command("query")
630
+ def query(
631
+ name: Annotated[
632
+ str,
633
+ typer.Argument(help="Connection name"),
634
+ ],
635
+ sql: Annotated[
636
+ str | None,
637
+ typer.Option("--sql", help="SQL query to execute"),
638
+ ] = None,
639
+ file: Annotated[
640
+ Path | None,
641
+ typer.Option("--file", "-f", help="Read SQL from file"),
642
+ ] = None,
643
+ format: Annotated[
644
+ str,
645
+ typer.Option("--format", help="Output format (json/jsonl/csv/table)"),
646
+ ] = "table",
647
+ output: Annotated[
648
+ Path | None,
649
+ typer.Option("--output", "-o", help="Save output to file"),
650
+ ] = None,
651
+ fields: Annotated[
652
+ str | None,
653
+ typer.Option("--fields", help="Comma-separated fields to include"),
654
+ ] = None,
655
+ ) -> None:
656
+ """Run a SQL query against a database.
657
+
658
+ \b
659
+ Examples:
660
+ anysite db query mydb --sql "SELECT * FROM users LIMIT 10"
661
+ anysite db query mydb --sql "SELECT * FROM users" --format csv --output users.csv
662
+ anysite db query mydb --file queries/report.sql --format json
663
+ """
664
+ from anysite.db.operations.query import execute_query, execute_query_from_file
665
+
666
+ if not sql and not file:
667
+ typer.echo("Error: provide --sql or --file", err=True)
668
+ raise typer.Exit(1)
669
+
670
+ if file and not file.exists():
671
+ typer.echo(f"Error: SQL file not found: {file}", err=True)
672
+ raise typer.Exit(1)
673
+
674
+ config = _get_config_or_exit(name)
675
+ manager = _get_manager()
676
+ adapter = manager.get_adapter(config)
677
+
678
+ with adapter:
679
+ try:
680
+ if file:
681
+ results = execute_query_from_file(adapter, file)
682
+ else:
683
+ assert sql is not None
684
+ results = execute_query(adapter, sql)
685
+ except Exception as e:
686
+ typer.echo(f"Query error: {e}", err=True)
687
+ raise typer.Exit(1) from None
688
+
689
+ _output_results(results, format, output, fields)
690
+
691
+
692
+ def _output_results(
693
+ data: list[dict[str, Any]],
694
+ format: str = "table",
695
+ output: Path | None = None,
696
+ fields: str | None = None,
697
+ ) -> None:
698
+ """Output query results using the existing formatter pipeline."""
699
+ from anysite.cli.options import parse_fields
700
+ from anysite.output.formatters import OutputFormat, format_output
701
+
702
+ try:
703
+ fmt = OutputFormat(format.lower())
704
+ except ValueError:
705
+ typer.echo(f"Error: invalid format '{format}', use json/jsonl/csv/table", err=True)
706
+ raise typer.Exit(1) from None
707
+
708
+ include_fields = parse_fields(fields)
709
+ format_output(data, fmt, include_fields, output, quiet=False)