sqlseed-cli 0.2.5__tar.gz → 0.2.7__tar.gz
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.
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/.gitignore +11 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/PKG-INFO +36 -5
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/README.md +35 -4
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/src/sqlseed_cli/main.py +65 -29
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/test_cli_yaml_priority.py +8 -4
- sqlseed_cli-0.2.7/tests/test_config_load_errors.py +106 -0
- sqlseed_cli-0.2.7/tests/test_fill_database_errors.py +124 -0
- sqlseed_cli-0.2.7/tests/test_init_config_roundtrip.py +155 -0
- sqlseed_cli-0.2.7/tests/test_init_output_collision.py +179 -0
- sqlseed_cli-0.2.7/tests/test_literal_console_output.py +92 -0
- sqlseed_cli-0.2.7/tests/test_replay_load_errors.py +226 -0
- sqlseed_cli-0.2.7/tests/test_replay_table_identity.py +154 -0
- sqlseed_cli-0.2.7/tests/test_snapshot_enrich.py +79 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/LICENSE +0 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/pyproject.toml +0 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/src/sqlseed_cli/__init__.py +0 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/src/sqlseed_cli/_utils.py +0 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/.pylintrc +0 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/__init__.py +0 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/conftest.py +0 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/test_cli.py +0 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/test_cli_ai_commands.py +0 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/test_cli_utils.py +0 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/test_config_execution_safety.py +0 -0
- {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/test_snapshot_transform.py +0 -0
|
@@ -56,9 +56,20 @@ dmypy.json
|
|
|
56
56
|
.import_linter_cache/
|
|
57
57
|
|
|
58
58
|
# sqlseed
|
|
59
|
+
.release-backups/
|
|
59
60
|
*.db
|
|
60
61
|
*.sqlite
|
|
61
62
|
*.sqlite3
|
|
63
|
+
# SQLite runtime sidecars
|
|
64
|
+
*.db-wal
|
|
65
|
+
*.db-shm
|
|
66
|
+
*.db-journal
|
|
67
|
+
*.sqlite-wal
|
|
68
|
+
*.sqlite-shm
|
|
69
|
+
*.sqlite-journal
|
|
70
|
+
*.sqlite3-wal
|
|
71
|
+
*.sqlite3-shm
|
|
72
|
+
*.sqlite3-journal
|
|
62
73
|
.sqlseed_cache/
|
|
63
74
|
snapshots/
|
|
64
75
|
.env
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sqlseed-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Command-line interface for sqlseed test data generation, preview and configuration replay
|
|
5
5
|
Project-URL: Documentation, https://sunbos.github.io/sqlseed/guide/
|
|
6
6
|
Project-URL: Homepage, https://github.com/sunbos/sqlseed
|
|
@@ -29,12 +29,12 @@ configuration. This package provides the `sqlseed` command.
|
|
|
29
29
|
|
|
30
30
|
## Installation
|
|
31
31
|
|
|
32
|
-
These instructions target version 0.2.
|
|
32
|
+
These instructions target version 0.2.7. Check [Releases](https://github.com/sunbos/sqlseed/releases)
|
|
33
33
|
for publication status; use the source installation below to test an unpublished candidate.
|
|
34
34
|
Use a Python 3.10+ virtual environment:
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
python -m pip install "sqlseed-cli==0.2.
|
|
37
|
+
python -m pip install "sqlseed-cli==0.2.7"
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
Core 0.2.4 and older lack the shared diagnostic interfaces required by this version.
|
|
@@ -76,10 +76,12 @@ sqlseed replay <cache_dir>/snapshots/YYYY-MM-DD_HHMMSS_ffffff_users.yaml
|
|
|
76
76
|
| `init` | Write a YAML configuration template |
|
|
77
77
|
| `replay` | Run a saved configuration snapshot |
|
|
78
78
|
|
|
79
|
+
`init` rejects an output path that aliases its source SQLite file, including symbolic and hard links. Use a separate configuration path; replacing an existing configuration remains supported. The check covers existing ordinary local files, not concurrent path changes or custom SQLite VFS targets.
|
|
80
|
+
|
|
79
81
|
For PostgreSQL, install the Core `postgres` extra and use `--url`:
|
|
80
82
|
|
|
81
83
|
```bash
|
|
82
|
-
python -m pip install "sqlseed[postgres]==0.2.
|
|
84
|
+
python -m pip install "sqlseed[postgres]==0.2.7" "sqlseed-cli==0.2.7"
|
|
83
85
|
sqlseed fill --url "postgresql+psycopg://user:pass@host/db" -t users -n 1000 --provider faker --no-ai
|
|
84
86
|
sqlseed inspect --url "postgresql+psycopg://user:pass@host/db"
|
|
85
87
|
```
|
|
@@ -97,17 +99,45 @@ sqlseed fill --config generate.yaml --no-ai
|
|
|
97
99
|
override those values, including options equal to the command defaults. Without
|
|
98
100
|
`--config`, the defaults are `mimesis`, `en_US`, and `5000`.
|
|
99
101
|
|
|
102
|
+
If the configuration file cannot be read, parsed, or validated, `fill` reports the
|
|
103
|
+
reason and exits with status 2 before generation or clearing tables. Correct the
|
|
104
|
+
file path, UTF-8 YAML/JSON syntax, or reported fields and retry.
|
|
105
|
+
Each invocation reads the configuration once; changes made after loading take
|
|
106
|
+
effect on a later invocation.
|
|
107
|
+
|
|
108
|
+
Database exceptions during direct or configuration-driven `fill` are reported as
|
|
109
|
+
`Database fill failed:` with exit status 1 and URL credentials redacted, without a
|
|
110
|
+
Python traceback. For a missing SQLite `mode=rw` target, no file is created;
|
|
111
|
+
correct the target and retry. This diagnostic does not imply rollback of earlier
|
|
112
|
+
committed batches.
|
|
113
|
+
|
|
100
114
|
If any table reports generation errors, the command prints those errors and exits
|
|
101
115
|
with status 1. `count` reports rows actually committed; a later batch failure can
|
|
102
116
|
leave earlier commits. A snapshot retains a supplied transform script path and replay
|
|
103
117
|
runs that script again; the script itself is not embedded in the snapshot.
|
|
104
118
|
|
|
119
|
+
Replay matches saved table settings using SQLite's ASCII-only case-insensitive
|
|
120
|
+
name rules; non-ASCII names and PostgreSQL names remain distinct. Multiple saved
|
|
121
|
+
configurations matching the replay table are rejected before generation rather
|
|
122
|
+
than choosing one silently. Snapshots without matching table settings retain the
|
|
123
|
+
legacy defaults.
|
|
124
|
+
|
|
125
|
+
Snapshots also retain `--enrich`. Enriched replay reads the current table distribution,
|
|
126
|
+
not a frozen copy of the original rows. Older snapshots without this setting
|
|
127
|
+
retain the default (`false`).
|
|
128
|
+
|
|
129
|
+
`replay` reports missing, unreadable, or malformed snapshot files with exit status 2
|
|
130
|
+
before generation starts. Check the path, read permissions, and UTF-8 YAML syntax.
|
|
131
|
+
Snapshot metadata requires a nonblank string `table_name`, a positive integer
|
|
132
|
+
`count`, and an integer or null `seed` (which may be omitted). Boolean values are
|
|
133
|
+
not accepted as integers.
|
|
134
|
+
|
|
105
135
|
The optional AI plugin registers `ai-suggest`, `ai-analyze`, and `auto-heal` through
|
|
106
136
|
`sqlseed.cli_commands`. CLI does not require AI for its five base commands.
|
|
107
137
|
|
|
108
138
|
## Requirements
|
|
109
139
|
|
|
110
|
-
These metadata requirements apply to version 0.2.
|
|
140
|
+
These metadata requirements apply to version 0.2.7 and its source candidates.
|
|
111
141
|
Use local Core and plugins together when developing from source.
|
|
112
142
|
|
|
113
143
|
- Python `>=3.10`
|
|
@@ -121,3 +151,4 @@ See the [user guide](https://sunbos.github.io/sqlseed/guide/),
|
|
|
121
151
|
|
|
122
152
|
License: [AGPL-3.0-or-later](https://github.com/sunbos/sqlseed/blob/main/LICENSE).
|
|
123
153
|
The distribution includes the full LICENSE text.
|
|
154
|
+
|
|
@@ -6,12 +6,12 @@ configuration. This package provides the `sqlseed` command.
|
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
|
-
These instructions target version 0.2.
|
|
9
|
+
These instructions target version 0.2.7. Check [Releases](https://github.com/sunbos/sqlseed/releases)
|
|
10
10
|
for publication status; use the source installation below to test an unpublished candidate.
|
|
11
11
|
Use a Python 3.10+ virtual environment:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
python -m pip install "sqlseed-cli==0.2.
|
|
14
|
+
python -m pip install "sqlseed-cli==0.2.7"
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Core 0.2.4 and older lack the shared diagnostic interfaces required by this version.
|
|
@@ -53,10 +53,12 @@ sqlseed replay <cache_dir>/snapshots/YYYY-MM-DD_HHMMSS_ffffff_users.yaml
|
|
|
53
53
|
| `init` | Write a YAML configuration template |
|
|
54
54
|
| `replay` | Run a saved configuration snapshot |
|
|
55
55
|
|
|
56
|
+
`init` rejects an output path that aliases its source SQLite file, including symbolic and hard links. Use a separate configuration path; replacing an existing configuration remains supported. The check covers existing ordinary local files, not concurrent path changes or custom SQLite VFS targets.
|
|
57
|
+
|
|
56
58
|
For PostgreSQL, install the Core `postgres` extra and use `--url`:
|
|
57
59
|
|
|
58
60
|
```bash
|
|
59
|
-
python -m pip install "sqlseed[postgres]==0.2.
|
|
61
|
+
python -m pip install "sqlseed[postgres]==0.2.7" "sqlseed-cli==0.2.7"
|
|
60
62
|
sqlseed fill --url "postgresql+psycopg://user:pass@host/db" -t users -n 1000 --provider faker --no-ai
|
|
61
63
|
sqlseed inspect --url "postgresql+psycopg://user:pass@host/db"
|
|
62
64
|
```
|
|
@@ -74,17 +76,45 @@ sqlseed fill --config generate.yaml --no-ai
|
|
|
74
76
|
override those values, including options equal to the command defaults. Without
|
|
75
77
|
`--config`, the defaults are `mimesis`, `en_US`, and `5000`.
|
|
76
78
|
|
|
79
|
+
If the configuration file cannot be read, parsed, or validated, `fill` reports the
|
|
80
|
+
reason and exits with status 2 before generation or clearing tables. Correct the
|
|
81
|
+
file path, UTF-8 YAML/JSON syntax, or reported fields and retry.
|
|
82
|
+
Each invocation reads the configuration once; changes made after loading take
|
|
83
|
+
effect on a later invocation.
|
|
84
|
+
|
|
85
|
+
Database exceptions during direct or configuration-driven `fill` are reported as
|
|
86
|
+
`Database fill failed:` with exit status 1 and URL credentials redacted, without a
|
|
87
|
+
Python traceback. For a missing SQLite `mode=rw` target, no file is created;
|
|
88
|
+
correct the target and retry. This diagnostic does not imply rollback of earlier
|
|
89
|
+
committed batches.
|
|
90
|
+
|
|
77
91
|
If any table reports generation errors, the command prints those errors and exits
|
|
78
92
|
with status 1. `count` reports rows actually committed; a later batch failure can
|
|
79
93
|
leave earlier commits. A snapshot retains a supplied transform script path and replay
|
|
80
94
|
runs that script again; the script itself is not embedded in the snapshot.
|
|
81
95
|
|
|
96
|
+
Replay matches saved table settings using SQLite's ASCII-only case-insensitive
|
|
97
|
+
name rules; non-ASCII names and PostgreSQL names remain distinct. Multiple saved
|
|
98
|
+
configurations matching the replay table are rejected before generation rather
|
|
99
|
+
than choosing one silently. Snapshots without matching table settings retain the
|
|
100
|
+
legacy defaults.
|
|
101
|
+
|
|
102
|
+
Snapshots also retain `--enrich`. Enriched replay reads the current table distribution,
|
|
103
|
+
not a frozen copy of the original rows. Older snapshots without this setting
|
|
104
|
+
retain the default (`false`).
|
|
105
|
+
|
|
106
|
+
`replay` reports missing, unreadable, or malformed snapshot files with exit status 2
|
|
107
|
+
before generation starts. Check the path, read permissions, and UTF-8 YAML syntax.
|
|
108
|
+
Snapshot metadata requires a nonblank string `table_name`, a positive integer
|
|
109
|
+
`count`, and an integer or null `seed` (which may be omitted). Boolean values are
|
|
110
|
+
not accepted as integers.
|
|
111
|
+
|
|
82
112
|
The optional AI plugin registers `ai-suggest`, `ai-analyze`, and `auto-heal` through
|
|
83
113
|
`sqlseed.cli_commands`. CLI does not require AI for its five base commands.
|
|
84
114
|
|
|
85
115
|
## Requirements
|
|
86
116
|
|
|
87
|
-
These metadata requirements apply to version 0.2.
|
|
117
|
+
These metadata requirements apply to version 0.2.7 and its source candidates.
|
|
88
118
|
Use local Core and plugins together when developing from source.
|
|
89
119
|
|
|
90
120
|
- Python `>=3.10`
|
|
@@ -98,3 +128,4 @@ See the [user guide](https://sunbos.github.io/sqlseed/guide/),
|
|
|
98
128
|
|
|
99
129
|
License: [AGPL-3.0-or-later](https://github.com/sunbos/sqlseed/blob/main/LICENSE).
|
|
100
130
|
The distribution includes the full LICENSE text.
|
|
131
|
+
|
|
@@ -15,8 +15,10 @@ from typing import Any
|
|
|
15
15
|
|
|
16
16
|
import click
|
|
17
17
|
import pydantic
|
|
18
|
+
import yaml
|
|
18
19
|
from rich.console import Console
|
|
19
20
|
from rich.table import Table as RichTable
|
|
21
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
20
22
|
|
|
21
23
|
from sqlseed import fill as api_fill
|
|
22
24
|
from sqlseed import fill_from_config
|
|
@@ -25,10 +27,11 @@ from sqlseed._utils.logger import configure_logging, get_logger
|
|
|
25
27
|
from sqlseed._utils.paths import get_cache_dir
|
|
26
28
|
from sqlseed._utils.redaction import redact_url_credentials
|
|
27
29
|
from sqlseed._version import __version__
|
|
28
|
-
from sqlseed.config.loader import generate_template,
|
|
30
|
+
from sqlseed.config.loader import generate_template, save_config
|
|
29
31
|
from sqlseed.config.models import GeneratorConfig, ProviderType, TableConfig
|
|
30
32
|
from sqlseed.config.snapshot import SnapshotManager
|
|
31
33
|
from sqlseed.core.orchestrator import DataOrchestrator
|
|
34
|
+
from sqlseed.database._sqlite_schema import resolve_sqlite_table_name
|
|
32
35
|
from sqlseed.generators import ConfigurationError
|
|
33
36
|
|
|
34
37
|
CONFLICTING_DATABASE_OPTIONS = "Cannot specify both positional db_path and --url. Use one or the other."
|
|
@@ -53,14 +56,19 @@ def cli() -> None:
|
|
|
53
56
|
|
|
54
57
|
|
|
55
58
|
def _fill_from_config_cmd(config_path: str, *, clear_before: bool = False, **kwargs: Any) -> None:
|
|
56
|
-
config
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
click.echo(f"Loading config: {config_path}")
|
|
60
|
+
if not clear_before:
|
|
61
|
+
click.echo(
|
|
62
|
+
"Note: Tables without clear_before=true will receive appended data. Use --clear to reset configured tables."
|
|
63
|
+
)
|
|
59
64
|
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
try:
|
|
66
|
+
# Let the core API load and execute one configuration, without a separate
|
|
67
|
+
# diagnostic read that could observe a different version of the file.
|
|
68
|
+
results = fill_from_config(config_path, clear_before=clear_before, **kwargs)
|
|
69
|
+
except (OSError, TypeError, ValueError, yaml.YAMLError) as exc:
|
|
70
|
+
raise click.UsageError(_redact_credentials(f"Configuration fill failed: {exc}")) from None
|
|
62
71
|
|
|
63
|
-
results = fill_from_config(config_path, clear_before=clear_before, **kwargs)
|
|
64
72
|
for result in results:
|
|
65
73
|
click.echo(str(result))
|
|
66
74
|
for error in result.errors:
|
|
@@ -81,6 +89,7 @@ def _save_snapshot_cmd(
|
|
|
81
89
|
*,
|
|
82
90
|
url: str | None = None,
|
|
83
91
|
transform: str | None = None,
|
|
92
|
+
enrich: bool = False,
|
|
84
93
|
) -> None:
|
|
85
94
|
config = GeneratorConfig(
|
|
86
95
|
db_path=db_path,
|
|
@@ -95,6 +104,7 @@ def _save_snapshot_cmd(
|
|
|
95
104
|
clear_before=clear,
|
|
96
105
|
seed=seed,
|
|
97
106
|
transform=transform,
|
|
107
|
+
enrich=enrich,
|
|
98
108
|
)
|
|
99
109
|
],
|
|
100
110
|
)
|
|
@@ -275,6 +285,8 @@ def fill(**kwargs: Any) -> None:
|
|
|
275
285
|
_execute_fill(options)
|
|
276
286
|
except ConfigurationError as exc:
|
|
277
287
|
raise click.UsageError(_redact_credentials(str(exc))) from None
|
|
288
|
+
except SQLAlchemyError as exc:
|
|
289
|
+
raise click.ClickException(_redact_credentials(f"Database fill failed: {exc}")) from None
|
|
278
290
|
|
|
279
291
|
|
|
280
292
|
def _execute_config_fill(options: FillOptions, config_path: str) -> None:
|
|
@@ -358,6 +370,7 @@ def _execute_fill(options: FillOptions) -> None:
|
|
|
358
370
|
clear=options.flags.clear,
|
|
359
371
|
url=fill_url,
|
|
360
372
|
transform=options.transform_path,
|
|
373
|
+
enrich=options.flags.enrich,
|
|
361
374
|
)
|
|
362
375
|
|
|
363
376
|
# Generation completed with errors: exit non-zero so callers/scripts can detect partial failure.
|
|
@@ -421,7 +434,7 @@ def preview(
|
|
|
421
434
|
click.echo("No data generated.")
|
|
422
435
|
return
|
|
423
436
|
|
|
424
|
-
console = Console()
|
|
437
|
+
console = Console(markup=False, emoji=False)
|
|
425
438
|
rich_table = RichTable(title=f"Preview: {table} ({count} rows)")
|
|
426
439
|
|
|
427
440
|
for col_name in rows[0]:
|
|
@@ -505,7 +518,7 @@ def inspect(db_path: str | None, table: str | None, show_mapping: bool, db_url:
|
|
|
505
518
|
raise click.UsageError("db_path or --url is required.")
|
|
506
519
|
try:
|
|
507
520
|
with DataOrchestrator(target) as orch:
|
|
508
|
-
console = Console()
|
|
521
|
+
console = Console(markup=False, emoji=False)
|
|
509
522
|
|
|
510
523
|
tables = [table] if table else orch.get_table_names()
|
|
511
524
|
|
|
@@ -555,20 +568,53 @@ def init(config_path: str, db: str | None, db_url: str | None) -> None:
|
|
|
555
568
|
click.echo(f"Configuration template saved to: {config_path}")
|
|
556
569
|
|
|
557
570
|
|
|
558
|
-
|
|
571
|
+
class _ReplayMetadata(pydantic.BaseModel):
|
|
572
|
+
"""Validate the snapshot envelope before opening its configured database."""
|
|
573
|
+
|
|
574
|
+
model_config = pydantic.ConfigDict(strict=True, hide_input_in_errors=True)
|
|
575
|
+
|
|
576
|
+
table_name: str
|
|
577
|
+
count: int = pydantic.Field(gt=0)
|
|
578
|
+
seed: int | None = None
|
|
579
|
+
|
|
580
|
+
@pydantic.field_validator("table_name")
|
|
581
|
+
@classmethod
|
|
582
|
+
def nonblank_table_name(cls, value: str) -> str:
|
|
583
|
+
"""Keep valid identifier spelling while rejecting empty names."""
|
|
584
|
+
if not value.strip():
|
|
585
|
+
raise ValueError("table_name must not be blank")
|
|
586
|
+
return value
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _load_replay_config(snapshot_path: str) -> tuple[_ReplayMetadata, GeneratorConfig]:
|
|
559
590
|
manager = SnapshotManager()
|
|
560
591
|
try:
|
|
561
592
|
data = manager.load(snapshot_path)
|
|
593
|
+
metadata = _ReplayMetadata.model_validate(data)
|
|
562
594
|
except FileNotFoundError as exc:
|
|
563
595
|
raise click.UsageError(f"Snapshot file not found: {snapshot_path}") from exc
|
|
564
|
-
except
|
|
565
|
-
raise click.UsageError(_redact_credentials(f"
|
|
596
|
+
except OSError as exc:
|
|
597
|
+
raise click.UsageError(_redact_credentials(f"Cannot read snapshot file: {exc}")) from None
|
|
598
|
+
except (ValueError, KeyError, yaml.YAMLError) as exc:
|
|
599
|
+
raise click.UsageError(_redact_credentials(f"Invalid snapshot file format: {exc}")) from None
|
|
566
600
|
|
|
567
601
|
try:
|
|
568
602
|
config = GeneratorConfig(**data["config"])
|
|
569
603
|
except (pydantic.ValidationError, KeyError, TypeError) as exc:
|
|
570
604
|
raise click.UsageError(_redact_credentials(f"Invalid config in snapshot: {exc}")) from exc
|
|
571
|
-
return
|
|
605
|
+
return metadata, config
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _replay_table_config(config: GeneratorConfig, table_name: str, dialect_name: str) -> TableConfig | None:
|
|
609
|
+
"""Select saved table settings using the connected database's name rules."""
|
|
610
|
+
matches = []
|
|
611
|
+
for table in config.tables:
|
|
612
|
+
name = resolve_sqlite_table_name(table.name, [table_name]) if dialect_name == "sqlite" else table.name
|
|
613
|
+
if name == table_name:
|
|
614
|
+
matches.append(table)
|
|
615
|
+
if len(matches) > 1:
|
|
616
|
+
raise click.UsageError("Snapshot contains multiple configurations for the replay table")
|
|
617
|
+
return matches[0] if matches else None
|
|
572
618
|
|
|
573
619
|
|
|
574
620
|
@cli.command()
|
|
@@ -584,31 +630,21 @@ def replay(snapshot_path: str) -> None:
|
|
|
584
630
|
if ".." in Path(snapshot_path).parts and not resolved_path.is_relative_to(cache_dir):
|
|
585
631
|
raise click.UsageError("snapshot path must be within cache directory")
|
|
586
632
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
try:
|
|
590
|
-
table_name = data["table_name"]
|
|
591
|
-
count = data["count"]
|
|
592
|
-
except (KeyError, TypeError) as exc:
|
|
593
|
-
raise click.UsageError(f"Invalid snapshot file format: {exc}") from exc
|
|
594
|
-
seed = data.get("seed")
|
|
595
|
-
|
|
596
|
-
table_config = None
|
|
597
|
-
for tc in config.tables:
|
|
598
|
-
if tc.name == table_name:
|
|
599
|
-
table_config = tc
|
|
600
|
-
break
|
|
633
|
+
metadata, config = _load_replay_config(snapshot_path)
|
|
634
|
+
table_name = metadata.table_name
|
|
601
635
|
|
|
602
636
|
try:
|
|
603
637
|
with DataOrchestrator.from_config(config) as orch:
|
|
638
|
+
table_config = _replay_table_config(config, table_name, orch._get_dialect_name())
|
|
604
639
|
result = orch.fill_table(
|
|
605
640
|
table_name=table_name,
|
|
606
|
-
count=count,
|
|
607
|
-
seed=seed,
|
|
641
|
+
count=metadata.count,
|
|
642
|
+
seed=metadata.seed,
|
|
608
643
|
batch_size=table_config.batch_size if table_config else 5000,
|
|
609
644
|
clear_before=table_config.clear_before if table_config else False,
|
|
610
645
|
column_configs=table_config.columns if table_config else None,
|
|
611
646
|
transform=table_config.transform if table_config else None,
|
|
647
|
+
enrich=table_config.enrich if table_config else False,
|
|
612
648
|
)
|
|
613
649
|
except (ValueError, RuntimeError, OSError, ConfigurationError) as exc:
|
|
614
650
|
raise click.UsageError(_redact_credentials(str(exc))) from None
|
|
@@ -309,7 +309,7 @@ class TestCLIConfigPriorityIntegration:
|
|
|
309
309
|
cli,
|
|
310
310
|
["fill", "--config", config_path, "--no-ai"],
|
|
311
311
|
)
|
|
312
|
-
assert "
|
|
312
|
+
assert "Tables without clear_before=true will receive appended data" in result.output
|
|
313
313
|
|
|
314
314
|
def test_cli_fill_config_no_note_when_clear(self, tmp_path: Any) -> None:
|
|
315
315
|
db_path = _make_db(tmp_path)
|
|
@@ -319,9 +319,9 @@ class TestCLIConfigPriorityIntegration:
|
|
|
319
319
|
cli,
|
|
320
320
|
["fill", "--config", config_path, "--clear", "--no-ai"],
|
|
321
321
|
)
|
|
322
|
-
assert "
|
|
322
|
+
assert "will receive appended data" not in result.output
|
|
323
323
|
|
|
324
|
-
def
|
|
324
|
+
def test_cli_fill_config_note_preserves_yaml_clear(self, tmp_path: Any) -> None:
|
|
325
325
|
db_path = _make_db(tmp_path)
|
|
326
326
|
config_path = _make_config(
|
|
327
327
|
tmp_path,
|
|
@@ -329,8 +329,12 @@ class TestCLIConfigPriorityIntegration:
|
|
|
329
329
|
tables=[{"name": "users", "count": 10, "clear_before": True, "columns": []}],
|
|
330
330
|
)
|
|
331
331
|
runner = CliRunner()
|
|
332
|
+
first = runner.invoke(cli, ["fill", "--config", config_path, "--no-ai"])
|
|
333
|
+
assert first.exit_code == 0, first.output
|
|
332
334
|
result = runner.invoke(
|
|
333
335
|
cli,
|
|
334
336
|
["fill", "--config", config_path, "--no-ai"],
|
|
335
337
|
)
|
|
336
|
-
assert
|
|
338
|
+
assert result.exit_code == 0, result.output
|
|
339
|
+
assert "Tables without clear_before=true will receive appended data" in result.output
|
|
340
|
+
assert _count_rows(db_path) == 10
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Users can diagnose and correct configuration errors without changing data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
import yaml
|
|
10
|
+
from click.testing import CliRunner
|
|
11
|
+
from sqlseed_cli.main import cli
|
|
12
|
+
|
|
13
|
+
from tests.cli_helpers import fixed_items_config, retry_items_document
|
|
14
|
+
from tests.sqlite_helpers import sqlite_connection
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.parametrize(
|
|
23
|
+
("filename", "contents"),
|
|
24
|
+
[
|
|
25
|
+
("missing.yaml", None),
|
|
26
|
+
("broken.yaml", b"tables: [\n"),
|
|
27
|
+
("broken.json", b'{"tables": ['),
|
|
28
|
+
("invalid.yaml", b"db_path: demo.db\ntables: wrong-type\n"),
|
|
29
|
+
("numeric-key.yaml", b"1: value\n"),
|
|
30
|
+
("encoding.yaml", b"db_path: \xff\n"),
|
|
31
|
+
("tag.yaml", b"db_path: !unsupported {}\n"),
|
|
32
|
+
("empty.yaml", b""),
|
|
33
|
+
],
|
|
34
|
+
)
|
|
35
|
+
def test_config_load_error_preserves_rows_and_allows_retry(
|
|
36
|
+
tmp_path: Path, filename: str, contents: bytes | None
|
|
37
|
+
) -> None:
|
|
38
|
+
database = tmp_path / "demo.db"
|
|
39
|
+
with sqlite_connection(database) as connection:
|
|
40
|
+
connection.execute("CREATE TABLE items (value INTEGER NOT NULL)")
|
|
41
|
+
connection.execute("INSERT INTO items VALUES (99)")
|
|
42
|
+
config = tmp_path / filename
|
|
43
|
+
if contents is not None:
|
|
44
|
+
config.write_bytes(contents)
|
|
45
|
+
runner = CliRunner()
|
|
46
|
+
args = ["fill", "--config", str(config), "--clear", "--no-ai"]
|
|
47
|
+
|
|
48
|
+
failed = runner.invoke(cli, args)
|
|
49
|
+
|
|
50
|
+
assert failed.exit_code == 2, repr(failed.exception)
|
|
51
|
+
assert "Configuration fill failed:" in failed.output
|
|
52
|
+
assert "Traceback" not in failed.output
|
|
53
|
+
with sqlite_connection(database) as connection:
|
|
54
|
+
assert connection.execute("SELECT value FROM items").fetchall() == [(99,)]
|
|
55
|
+
|
|
56
|
+
# Correct the file and retry through the same CLI, with real SQLite writes.
|
|
57
|
+
retry_items_document(config, fixed_items_config(database), runner, args, database)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_config_directory_reports_actionable_error(tmp_path: Path) -> None:
|
|
61
|
+
result = CliRunner().invoke(cli, ["fill", "--config", str(tmp_path), "--no-ai"])
|
|
62
|
+
|
|
63
|
+
assert result.exit_code == 2, repr(result.exception)
|
|
64
|
+
assert "Configuration fill failed:" in result.output
|
|
65
|
+
assert "Traceback" not in result.output
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.parametrize("change", ["replace", "remove"])
|
|
69
|
+
def test_config_file_change_after_read_cannot_redirect_fill(
|
|
70
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, change: str
|
|
71
|
+
) -> None:
|
|
72
|
+
"""A path replaced after its first read must not select a different database."""
|
|
73
|
+
database = tmp_path / "requested.db"
|
|
74
|
+
replacement_database = tmp_path / "replacement.db"
|
|
75
|
+
for target in (database, replacement_database):
|
|
76
|
+
with sqlite_connection(target) as connection:
|
|
77
|
+
connection.execute("CREATE TABLE items (value INTEGER NOT NULL)")
|
|
78
|
+
connection.execute("INSERT INTO items VALUES (99)")
|
|
79
|
+
document = fixed_items_config(database)
|
|
80
|
+
config = tmp_path / "generate.yaml"
|
|
81
|
+
config.write_text(yaml.safe_dump(document), encoding="utf-8")
|
|
82
|
+
original_open = open
|
|
83
|
+
changed = False
|
|
84
|
+
|
|
85
|
+
@contextmanager
|
|
86
|
+
def open_then_change(file: Any, *args: Any, **kwargs: Any) -> Iterator[Any]:
|
|
87
|
+
nonlocal changed
|
|
88
|
+
with original_open(file, *args, **kwargs) as handle:
|
|
89
|
+
yield handle
|
|
90
|
+
if file == config and not changed:
|
|
91
|
+
changed = True
|
|
92
|
+
if change == "remove":
|
|
93
|
+
config.unlink()
|
|
94
|
+
else:
|
|
95
|
+
document["db_path"] = str(replacement_database)
|
|
96
|
+
config.write_text(yaml.safe_dump(document), encoding="utf-8")
|
|
97
|
+
|
|
98
|
+
monkeypatch.setattr("builtins.open", open_then_change)
|
|
99
|
+
result = CliRunner().invoke(cli, ["fill", "--config", str(config), "--clear", "--no-ai"])
|
|
100
|
+
|
|
101
|
+
assert changed
|
|
102
|
+
assert result.exit_code == 0, result.output
|
|
103
|
+
with sqlite_connection(database) as connection:
|
|
104
|
+
assert connection.execute("SELECT value FROM items").fetchall() == [(7,), (7,)]
|
|
105
|
+
with sqlite_connection(replacement_database) as connection:
|
|
106
|
+
assert connection.execute("SELECT value FROM items").fetchall() == [(99,)]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Database fill failures are actionable, redacted, and safe to retry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from click.testing import CliRunner
|
|
13
|
+
from sqlalchemy.exc import OperationalError
|
|
14
|
+
|
|
15
|
+
from tests.sqlite_helpers import sqlite_connection
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
main = importlib.import_module("sqlseed_cli.main")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _args(config: bool) -> list[str]:
|
|
24
|
+
if config:
|
|
25
|
+
return ["fill", "--config", "unused.yaml", "--no-ai"]
|
|
26
|
+
return ["fill", "unused.db", "--table", "items", "--count", "2", "--provider", "base", "--no-ai"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _invoke(args: list[str], root: Path) -> subprocess.CompletedProcess[str]:
|
|
30
|
+
# Invoke the actual console-script callable in a new interpreter so an
|
|
31
|
+
# uncaught exception cannot be hidden by CliRunner's exception capture.
|
|
32
|
+
return subprocess.run(
|
|
33
|
+
[sys.executable, "-c", "from sqlseed_cli.main import main; main()", *args],
|
|
34
|
+
cwd=root,
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
timeout=30,
|
|
38
|
+
check=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize("config", [False, True], ids=["direct", "config"])
|
|
43
|
+
def test_missing_rw_target_reports_error_and_allows_retry(tmp_path: Path, config: bool) -> None:
|
|
44
|
+
target = tmp_path / "absent %41.db"
|
|
45
|
+
url = f"sqlite:///{target.as_uri()}?uri=true&mode=rw"
|
|
46
|
+
if config:
|
|
47
|
+
config_path = tmp_path / "generate.json"
|
|
48
|
+
config_path.write_text(
|
|
49
|
+
json.dumps({"url": url, "provider": "base", "tables": [{"name": "items", "count": 2}]}),
|
|
50
|
+
encoding="utf-8",
|
|
51
|
+
)
|
|
52
|
+
args = ["fill", "--config", str(config_path), "--no-ai"]
|
|
53
|
+
else:
|
|
54
|
+
args = ["fill", "--url", url, "--table", "items", "--count", "2", "--provider", "base", "--no-ai"]
|
|
55
|
+
before = set(tmp_path.iterdir())
|
|
56
|
+
|
|
57
|
+
failed = _invoke(args, tmp_path)
|
|
58
|
+
|
|
59
|
+
assert failed.returncode == 1, failed.stdout + failed.stderr
|
|
60
|
+
assert "Error: Database fill failed:" in failed.stderr
|
|
61
|
+
assert "unable to open database file" in failed.stderr
|
|
62
|
+
assert "Traceback" not in failed.stdout + failed.stderr
|
|
63
|
+
assert set(tmp_path.iterdir()) == before
|
|
64
|
+
assert not target.exists()
|
|
65
|
+
|
|
66
|
+
# Correct only the target and retry exactly the same command.
|
|
67
|
+
with sqlite_connection(target) as connection:
|
|
68
|
+
connection.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, value INTEGER NOT NULL)")
|
|
69
|
+
connection.execute("INSERT INTO items VALUES (1, 99)")
|
|
70
|
+
recovered = _invoke(args, tmp_path)
|
|
71
|
+
|
|
72
|
+
assert recovered.returncode == 0, recovered.stdout + recovered.stderr
|
|
73
|
+
with sqlite_connection(target) as connection:
|
|
74
|
+
assert connection.execute("SELECT COUNT(*) FROM items").fetchone() == (3,)
|
|
75
|
+
assert connection.execute("SELECT value FROM items WHERE id = 1").fetchone() == (99,)
|
|
76
|
+
assert connection.execute("PRAGMA integrity_check").fetchall() == [("ok",)]
|
|
77
|
+
assert set(tmp_path.iterdir()) == before | {target}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize("config", [False, True], ids=["direct", "config"])
|
|
81
|
+
def test_database_diagnostic_redacts_credentials(monkeypatch: pytest.MonkeyPatch, config: bool) -> None:
|
|
82
|
+
def fail(*args: Any, **kwargs: Any) -> None:
|
|
83
|
+
raise OperationalError(
|
|
84
|
+
None, None, Exception("failed postgresql://synthetic_user:synthetic_secret@localhost/db")
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Mock only error text; connection behavior is exercised with real SQLite above.
|
|
88
|
+
monkeypatch.setattr(main, "fill_from_config" if config else "api_fill", fail)
|
|
89
|
+
result = CliRunner().invoke(main.cli, _args(config))
|
|
90
|
+
|
|
91
|
+
assert result.exit_code == 1
|
|
92
|
+
assert "Error: Database fill failed:" in result.output
|
|
93
|
+
assert "synthetic_user" not in result.output
|
|
94
|
+
assert "synthetic_secret" not in result.output
|
|
95
|
+
assert "Traceback" not in result.output
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.parametrize("config", [False, True], ids=["direct", "config"])
|
|
99
|
+
@pytest.mark.parametrize("error", [RuntimeError("unrelated"), SystemExit(7), KeyboardInterrupt()])
|
|
100
|
+
def test_unrelated_exceptions_are_not_database_errors(
|
|
101
|
+
monkeypatch: pytest.MonkeyPatch, config: bool, error: BaseException
|
|
102
|
+
) -> None:
|
|
103
|
+
def fail(*args: Any, **kwargs: Any) -> None:
|
|
104
|
+
raise error
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr(main, "fill_from_config" if config else "api_fill", fail)
|
|
107
|
+
result = CliRunner().invoke(main.cli, _args(config))
|
|
108
|
+
|
|
109
|
+
assert "Database fill failed:" not in result.output
|
|
110
|
+
if isinstance(error, RuntimeError):
|
|
111
|
+
assert result.exception is error
|
|
112
|
+
elif isinstance(error, SystemExit):
|
|
113
|
+
assert result.exit_code == 7
|
|
114
|
+
else:
|
|
115
|
+
assert result.exit_code == 1
|
|
116
|
+
assert "Aborted!" in result.output
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_usage_errors_keep_exit_code_two() -> None:
|
|
120
|
+
result = CliRunner().invoke(main.cli, ["fill", "unused.db", "--table", "items", "--no-ai"])
|
|
121
|
+
|
|
122
|
+
assert result.exit_code == 2
|
|
123
|
+
assert "--count is required" in result.output
|
|
124
|
+
assert "Database fill failed:" not in result.output
|