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.
Files changed (25) hide show
  1. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/.gitignore +11 -0
  2. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/PKG-INFO +36 -5
  3. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/README.md +35 -4
  4. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/src/sqlseed_cli/main.py +65 -29
  5. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/test_cli_yaml_priority.py +8 -4
  6. sqlseed_cli-0.2.7/tests/test_config_load_errors.py +106 -0
  7. sqlseed_cli-0.2.7/tests/test_fill_database_errors.py +124 -0
  8. sqlseed_cli-0.2.7/tests/test_init_config_roundtrip.py +155 -0
  9. sqlseed_cli-0.2.7/tests/test_init_output_collision.py +179 -0
  10. sqlseed_cli-0.2.7/tests/test_literal_console_output.py +92 -0
  11. sqlseed_cli-0.2.7/tests/test_replay_load_errors.py +226 -0
  12. sqlseed_cli-0.2.7/tests/test_replay_table_identity.py +154 -0
  13. sqlseed_cli-0.2.7/tests/test_snapshot_enrich.py +79 -0
  14. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/LICENSE +0 -0
  15. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/pyproject.toml +0 -0
  16. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/src/sqlseed_cli/__init__.py +0 -0
  17. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/src/sqlseed_cli/_utils.py +0 -0
  18. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/.pylintrc +0 -0
  19. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/__init__.py +0 -0
  20. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/conftest.py +0 -0
  21. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/test_cli.py +0 -0
  22. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/test_cli_ai_commands.py +0 -0
  23. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/test_cli_utils.py +0 -0
  24. {sqlseed_cli-0.2.5 → sqlseed_cli-0.2.7}/tests/test_config_execution_safety.py +0 -0
  25. {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.5
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.5. Check [Releases](https://github.com/sunbos/sqlseed/releases)
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.5"
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.5" "sqlseed-cli==0.2.5"
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.5 and its source candidates.
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.5. Check [Releases](https://github.com/sunbos/sqlseed/releases)
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.5"
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.5" "sqlseed-cli==0.2.5"
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.5 and its source candidates.
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, load_config, save_config
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 = load_config(config_path)
57
- table_count = len(config.tables)
58
- click.echo(f"Loading config: {config_path} ({table_count} table(s))")
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
- if not (clear_before or any(tc.clear_before for tc in config.tables)):
61
- click.echo("Note: Data will be appended. Use --clear to reset tables before generation.")
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
- def _load_replay_config(snapshot_path: str) -> tuple[dict[str, Any], GeneratorConfig]:
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 (ValueError, KeyError) as exc:
565
- raise click.UsageError(_redact_credentials(f"Invalid snapshot file format: {exc}")) from exc
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 data, config
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
- data, config = _load_replay_config(snapshot_path)
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 "Data will be appended" in result.output
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 "Data will be appended" not in result.output
322
+ assert "will receive appended data" not in result.output
323
323
 
324
- def test_cli_fill_config_no_note_when_yaml_clear(self, tmp_path: Any) -> None:
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 "Data will be appended" not in result.output
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