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.
- anysite/__init__.py +4 -0
- anysite/__main__.py +6 -0
- anysite/api/__init__.py +21 -0
- anysite/api/client.py +271 -0
- anysite/api/errors.py +137 -0
- anysite/api/schemas.py +333 -0
- anysite/batch/__init__.py +1 -0
- anysite/batch/executor.py +176 -0
- anysite/batch/input.py +160 -0
- anysite/batch/rate_limiter.py +98 -0
- anysite/cli/__init__.py +1 -0
- anysite/cli/config.py +176 -0
- anysite/cli/executor.py +388 -0
- anysite/cli/options.py +249 -0
- anysite/config/__init__.py +11 -0
- anysite/config/paths.py +46 -0
- anysite/config/settings.py +187 -0
- anysite/dataset/__init__.py +37 -0
- anysite/dataset/analyzer.py +268 -0
- anysite/dataset/cli.py +644 -0
- anysite/dataset/collector.py +686 -0
- anysite/dataset/db_loader.py +248 -0
- anysite/dataset/errors.py +30 -0
- anysite/dataset/exporters.py +121 -0
- anysite/dataset/history.py +153 -0
- anysite/dataset/models.py +245 -0
- anysite/dataset/notifications.py +87 -0
- anysite/dataset/scheduler.py +107 -0
- anysite/dataset/storage.py +171 -0
- anysite/dataset/transformer.py +213 -0
- anysite/db/__init__.py +38 -0
- anysite/db/adapters/__init__.py +1 -0
- anysite/db/adapters/base.py +158 -0
- anysite/db/adapters/postgres.py +201 -0
- anysite/db/adapters/sqlite.py +183 -0
- anysite/db/cli.py +709 -0
- anysite/db/config.py +92 -0
- anysite/db/manager.py +166 -0
- anysite/db/operations/__init__.py +1 -0
- anysite/db/operations/insert.py +199 -0
- anysite/db/operations/query.py +43 -0
- anysite/db/schema/__init__.py +1 -0
- anysite/db/schema/inference.py +213 -0
- anysite/db/schema/types.py +71 -0
- anysite/db/utils/__init__.py +1 -0
- anysite/db/utils/sanitize.py +99 -0
- anysite/main.py +498 -0
- anysite/models/__init__.py +1 -0
- anysite/output/__init__.py +11 -0
- anysite/output/console.py +45 -0
- anysite/output/formatters.py +301 -0
- anysite/output/templates.py +76 -0
- anysite/py.typed +0 -0
- anysite/streaming/__init__.py +1 -0
- anysite/streaming/progress.py +121 -0
- anysite/streaming/writer.py +130 -0
- anysite/utils/__init__.py +1 -0
- anysite/utils/fields.py +242 -0
- anysite/utils/retry.py +109 -0
- anysite_cli-0.1.2.dist-info/METADATA +455 -0
- anysite_cli-0.1.2.dist-info/RECORD +64 -0
- anysite_cli-0.1.2.dist-info/WHEEL +4 -0
- anysite_cli-0.1.2.dist-info/entry_points.txt +2 -0
- 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)
|