sqlseed-cli 0.2.4__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.
- sqlseed_cli/__init__.py +66 -0
- sqlseed_cli/_utils.py +22 -0
- sqlseed_cli/main.py +619 -0
- sqlseed_cli-0.2.4.dist-info/METADATA +118 -0
- sqlseed_cli-0.2.4.dist-info/RECORD +8 -0
- sqlseed_cli-0.2.4.dist-info/WHEEL +4 -0
- sqlseed_cli-0.2.4.dist-info/entry_points.txt +2 -0
- sqlseed_cli-0.2.4.dist-info/licenses/LICENSE +679 -0
sqlseed_cli/main.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"""sqlseed CLI entry module.
|
|
2
|
+
|
|
3
|
+
Defines the `cli` group and core subcommands: fill, preview, inspect, init, replay.
|
|
4
|
+
AI-related commands (e.g. ai-suggest) are discovered via the
|
|
5
|
+
``sqlseed.cli_commands`` entry-point group and registered by
|
|
6
|
+
``sqlseed_cli.__init__`` (no source-level import of sqlseed-ai).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
import pydantic
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.table import Table as RichTable
|
|
21
|
+
|
|
22
|
+
from sqlseed import fill as api_fill
|
|
23
|
+
from sqlseed import fill_from_config
|
|
24
|
+
from sqlseed import preview as api_preview
|
|
25
|
+
from sqlseed._utils.logger import configure_logging, get_logger
|
|
26
|
+
from sqlseed._utils.paths import get_cache_dir
|
|
27
|
+
from sqlseed._version import __version__
|
|
28
|
+
from sqlseed.config.loader import generate_template, load_config, save_config
|
|
29
|
+
from sqlseed.config.models import GeneratorConfig, ProviderType, TableConfig
|
|
30
|
+
from sqlseed.config.snapshot import SnapshotManager
|
|
31
|
+
from sqlseed.core.orchestrator import DataOrchestrator
|
|
32
|
+
|
|
33
|
+
CONFLICTING_DATABASE_OPTIONS = "Cannot specify both positional db_path and --url. Use one or the other."
|
|
34
|
+
|
|
35
|
+
logger = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
# Redact the username and password in database URLs before displaying errors.
|
|
38
|
+
_CREDENTIAL_PATTERN = re.compile(r"://[^:@/\s]+:[^@/\s]+@")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _redact_credentials(text: str) -> str:
|
|
42
|
+
"""Redact credentials in URLs to prevent leaking secrets in error messages.
|
|
43
|
+
|
|
44
|
+
Replaces the ``user:pass@`` segment of a URL with ``***:***@``.
|
|
45
|
+
"""
|
|
46
|
+
return _CREDENTIAL_PATTERN.sub("://***:***@", text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@click.group()
|
|
50
|
+
@click.version_option(version=__version__, prog_name="sqlseed")
|
|
51
|
+
def cli() -> None:
|
|
52
|
+
"""sqlseed - Declarative SQLite test data generation toolkit."""
|
|
53
|
+
log_level = os.environ.get("SQLSEED_LOG_LEVEL", "WARNING").upper()
|
|
54
|
+
configure_logging(log_level)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _fill_from_config_cmd(config_path: str, *, clear_before: bool = False, **kwargs: Any) -> None:
|
|
58
|
+
config = load_config(config_path)
|
|
59
|
+
table_count = len(config.tables)
|
|
60
|
+
click.echo(f"Loading config: {config_path} ({table_count} table(s))")
|
|
61
|
+
|
|
62
|
+
if not (clear_before or any(tc.clear_before for tc in config.tables)):
|
|
63
|
+
click.echo("Note: Data will be appended. Use --clear to reset tables before generation.")
|
|
64
|
+
|
|
65
|
+
results = fill_from_config(config_path, clear_before=clear_before, **kwargs)
|
|
66
|
+
for result in results:
|
|
67
|
+
click.echo(str(result))
|
|
68
|
+
for error in result.errors:
|
|
69
|
+
click.echo(f" Error: {_redact_credentials(error)}", err=True)
|
|
70
|
+
if any(result.errors for result in results):
|
|
71
|
+
raise SystemExit(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _save_snapshot_cmd(
|
|
75
|
+
db_path: str | None,
|
|
76
|
+
table: str,
|
|
77
|
+
count: int,
|
|
78
|
+
provider: str,
|
|
79
|
+
locale: str,
|
|
80
|
+
seed: int | None,
|
|
81
|
+
batch_size: int,
|
|
82
|
+
clear: bool,
|
|
83
|
+
*,
|
|
84
|
+
url: str | None = None,
|
|
85
|
+
transform: str | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
config = GeneratorConfig(
|
|
88
|
+
db_path=db_path,
|
|
89
|
+
url=url,
|
|
90
|
+
provider=ProviderType(provider),
|
|
91
|
+
locale=locale,
|
|
92
|
+
tables=[
|
|
93
|
+
TableConfig(
|
|
94
|
+
name=table,
|
|
95
|
+
count=count,
|
|
96
|
+
batch_size=batch_size,
|
|
97
|
+
clear_before=clear,
|
|
98
|
+
seed=seed,
|
|
99
|
+
transform=transform,
|
|
100
|
+
)
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
manager = SnapshotManager()
|
|
104
|
+
snapshot_path = manager.save(config, table, count, seed)
|
|
105
|
+
click.echo(f"Snapshot saved: {snapshot_path}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_FILL_DEFAULT_COUNT = 1000
|
|
109
|
+
# Upper bound for --count to prevent memory exhaustion from accidental huge values.
|
|
110
|
+
_MAX_COUNT = 10_000_000
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class ConnectionTarget:
|
|
115
|
+
"""Connection target for the ``fill`` command (mutually exclusive)."""
|
|
116
|
+
|
|
117
|
+
db_path: str | None
|
|
118
|
+
db_url: str | None
|
|
119
|
+
|
|
120
|
+
def api_target(self) -> tuple[str | None, str | None]:
|
|
121
|
+
"""Resolve URL precedence into the mutually exclusive core arguments."""
|
|
122
|
+
if self.db_url:
|
|
123
|
+
return None, self.db_url
|
|
124
|
+
return self.db_path, None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class FillGeneratorConfig:
|
|
129
|
+
"""Generator configuration for the ``fill`` command."""
|
|
130
|
+
|
|
131
|
+
provider: str
|
|
132
|
+
locale: str
|
|
133
|
+
seed: int | None
|
|
134
|
+
batch_size: int
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class FillFlags:
|
|
139
|
+
"""Boolean flags for the ``fill`` command."""
|
|
140
|
+
|
|
141
|
+
clear: bool
|
|
142
|
+
snapshot: bool
|
|
143
|
+
enrich: bool
|
|
144
|
+
no_ai: bool
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class FillOptions:
|
|
149
|
+
"""Encapsulates all options for the ``fill`` command.
|
|
150
|
+
|
|
151
|
+
Passed from the Click ``fill`` command to ``_execute_fill`` to avoid
|
|
152
|
+
duplicating the 14-parameter list in both function signatures (which
|
|
153
|
+
triggered pylint ``duplicate-code`` warnings). Fields are grouped into
|
|
154
|
+
sub-dataclasses (ConnectionTarget, FillGeneratorConfig, FillFlags) to keep
|
|
155
|
+
the instance attribute count under pylint's too-many-instance-attributes
|
|
156
|
+
threshold (11).
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
connection: ConnectionTarget
|
|
160
|
+
generator: FillGeneratorConfig
|
|
161
|
+
flags: FillFlags
|
|
162
|
+
table: str | None
|
|
163
|
+
count: int | None
|
|
164
|
+
config_path: str | None
|
|
165
|
+
transform_path: str | None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@cli.command()
|
|
169
|
+
@click.argument("db_path", required=False)
|
|
170
|
+
@click.option("--table", "-t", default=None, help="Target table name")
|
|
171
|
+
@click.option(
|
|
172
|
+
"--count",
|
|
173
|
+
"-n",
|
|
174
|
+
default=None,
|
|
175
|
+
type=int,
|
|
176
|
+
help="Number of rows to generate (required when not using --config)",
|
|
177
|
+
)
|
|
178
|
+
@click.option(
|
|
179
|
+
"--provider",
|
|
180
|
+
"-p",
|
|
181
|
+
default="mimesis",
|
|
182
|
+
help="Data provider: mimesis|faker|base (default: mimesis)",
|
|
183
|
+
)
|
|
184
|
+
@click.option("--locale", "-l", default="en_US", help="Locale for data generation (default: en_US)")
|
|
185
|
+
@click.option("--seed", "-s", default=None, type=int, help="Random seed for reproducibility")
|
|
186
|
+
@click.option(
|
|
187
|
+
"--batch-size",
|
|
188
|
+
"-b",
|
|
189
|
+
default=5000,
|
|
190
|
+
type=int,
|
|
191
|
+
help="Batch size for insertion (default: 5000)",
|
|
192
|
+
)
|
|
193
|
+
@click.option("--clear", is_flag=True, help="Clear table before generating")
|
|
194
|
+
@click.option(
|
|
195
|
+
"--config",
|
|
196
|
+
"-c",
|
|
197
|
+
"config_path",
|
|
198
|
+
default=None,
|
|
199
|
+
help="YAML/JSON config file path (cannot combine with db_path or --url)",
|
|
200
|
+
)
|
|
201
|
+
@click.option("--transform", "transform_path", default=None, help="Python transform script path")
|
|
202
|
+
@click.option("--snapshot", is_flag=True, help="Save generation snapshot for replay")
|
|
203
|
+
@click.option("--enrich", is_flag=True, help="Enrich data using existing table distribution")
|
|
204
|
+
@click.option("--no-ai", is_flag=True, help="Skip AI suggestions and template generation")
|
|
205
|
+
@click.option(
|
|
206
|
+
"--url",
|
|
207
|
+
"db_url",
|
|
208
|
+
default=None,
|
|
209
|
+
help="Database URL (e.g., postgresql://user:pass@host/db). Alternative to db_path argument.",
|
|
210
|
+
)
|
|
211
|
+
def fill(**kwargs: Any) -> None:
|
|
212
|
+
"""Fill a table with generated test data.
|
|
213
|
+
|
|
214
|
+
Use --config for config-driven generation, or provide db_path + --table
|
|
215
|
+
+ --count for direct generation. With --config, set the database target
|
|
216
|
+
inside the config file; positional db_path and --url cannot be combined
|
|
217
|
+
with --config. Explicit count, provider, locale, seed, batch-size and clear
|
|
218
|
+
options override the corresponding config values.
|
|
219
|
+
|
|
220
|
+
Connection methods (mutually exclusive):
|
|
221
|
+
- Positional db_path: sqlseed fill app.db -t users -n 1000
|
|
222
|
+
- --url flag: sqlseed fill --url "postgresql://user:pass@host/db" -t users -n 1000
|
|
223
|
+
|
|
224
|
+
Note: the 14 Click options are collected via ``**kwargs`` to keep the
|
|
225
|
+
function signature under pylint's too-many-arguments threshold (10).
|
|
226
|
+
The kwargs are unpacked into a ``FillOptions`` dataclass below, which
|
|
227
|
+
is then passed to ``_execute_fill``.
|
|
228
|
+
"""
|
|
229
|
+
db_path: str | None = kwargs["db_path"]
|
|
230
|
+
table: str | None = kwargs["table"]
|
|
231
|
+
count: int | None = kwargs["count"]
|
|
232
|
+
provider: str = kwargs["provider"]
|
|
233
|
+
locale: str = kwargs["locale"]
|
|
234
|
+
seed: int | None = kwargs["seed"]
|
|
235
|
+
batch_size: int = kwargs["batch_size"]
|
|
236
|
+
clear: bool = kwargs["clear"]
|
|
237
|
+
config_path: str | None = kwargs["config_path"]
|
|
238
|
+
transform_path: str | None = kwargs["transform_path"]
|
|
239
|
+
snapshot: bool = kwargs["snapshot"]
|
|
240
|
+
enrich: bool = kwargs["enrich"]
|
|
241
|
+
no_ai: bool = kwargs["no_ai"]
|
|
242
|
+
db_url: str | None = kwargs["db_url"]
|
|
243
|
+
|
|
244
|
+
if count is not None and count <= 0:
|
|
245
|
+
logger.debug("Invalid count value", count=count)
|
|
246
|
+
raise click.UsageError(f"--count must be greater than 0, got {count}")
|
|
247
|
+
|
|
248
|
+
if count is not None and count > _MAX_COUNT:
|
|
249
|
+
logger.debug("Count exceeds maximum", count=count, max_count=_MAX_COUNT)
|
|
250
|
+
raise click.UsageError(f"--count must be <= {_MAX_COUNT}, got {count}")
|
|
251
|
+
|
|
252
|
+
if not config_path and count is None:
|
|
253
|
+
raise click.UsageError(
|
|
254
|
+
"--count is required when not using --config. Use -n <number> to specify the number of rows to generate."
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Validate that db_path and --url are mutually exclusive
|
|
258
|
+
if db_path and db_url:
|
|
259
|
+
raise click.UsageError(CONFLICTING_DATABASE_OPTIONS)
|
|
260
|
+
if config_path and (db_path or db_url):
|
|
261
|
+
raise click.UsageError(
|
|
262
|
+
"Cannot combine --config with positional db_path or --url. Set db_path or url in the config file."
|
|
263
|
+
)
|
|
264
|
+
if not config_path and not db_path and not db_url:
|
|
265
|
+
raise click.UsageError("db_path or --url is required when not using --config.")
|
|
266
|
+
|
|
267
|
+
options = FillOptions(
|
|
268
|
+
connection=ConnectionTarget(db_path=db_path, db_url=db_url),
|
|
269
|
+
generator=FillGeneratorConfig(provider=provider, locale=locale, seed=seed, batch_size=batch_size),
|
|
270
|
+
flags=FillFlags(clear=clear, snapshot=snapshot, enrich=enrich, no_ai=no_ai),
|
|
271
|
+
table=table,
|
|
272
|
+
count=count,
|
|
273
|
+
config_path=config_path,
|
|
274
|
+
transform_path=transform_path,
|
|
275
|
+
)
|
|
276
|
+
_execute_fill(options)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _execute_config_fill(options: FillOptions, config_path: str) -> None:
|
|
280
|
+
logger.debug("Using config-driven generation", config_path=config_path)
|
|
281
|
+
context = click.get_current_context()
|
|
282
|
+
config_overrides = {
|
|
283
|
+
name: value
|
|
284
|
+
for name, value in (
|
|
285
|
+
("provider", options.generator.provider),
|
|
286
|
+
("locale", options.generator.locale),
|
|
287
|
+
("batch_size", options.generator.batch_size),
|
|
288
|
+
)
|
|
289
|
+
if context.get_parameter_source(name) is not click.core.ParameterSource.DEFAULT
|
|
290
|
+
}
|
|
291
|
+
_fill_from_config_cmd(
|
|
292
|
+
config_path,
|
|
293
|
+
clear_before=options.flags.clear,
|
|
294
|
+
skip_ai=options.flags.no_ai,
|
|
295
|
+
count=options.count,
|
|
296
|
+
seed=options.generator.seed,
|
|
297
|
+
**config_overrides,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _execute_fill(options: FillOptions) -> None:
|
|
302
|
+
if config_path := options.config_path:
|
|
303
|
+
_execute_config_fill(options, config_path)
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
if not options.table:
|
|
307
|
+
raise click.UsageError("--table is required when not using --config")
|
|
308
|
+
|
|
309
|
+
effective_count = options.count if options.count is not None else _FILL_DEFAULT_COUNT
|
|
310
|
+
|
|
311
|
+
# Resolve connection target: db_url takes precedence over db_path.
|
|
312
|
+
# api_fill's db_path and url are mutually exclusive; pass None for the unused one.
|
|
313
|
+
fill_db_path, fill_url = options.connection.api_target()
|
|
314
|
+
|
|
315
|
+
if not (fill_db_path or fill_url):
|
|
316
|
+
raise click.UsageError("db_path or --url is required when not using --config")
|
|
317
|
+
|
|
318
|
+
logger.debug("Starting fill", target=fill_url or fill_db_path, table=options.table, count=effective_count)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
result = api_fill(
|
|
322
|
+
fill_db_path,
|
|
323
|
+
url=fill_url,
|
|
324
|
+
table=options.table,
|
|
325
|
+
count=effective_count,
|
|
326
|
+
provider=options.generator.provider,
|
|
327
|
+
locale=options.generator.locale,
|
|
328
|
+
seed=options.generator.seed,
|
|
329
|
+
batch_size=options.generator.batch_size,
|
|
330
|
+
clear_before=options.flags.clear,
|
|
331
|
+
enrich=options.flags.enrich,
|
|
332
|
+
transform=options.transform_path,
|
|
333
|
+
skip_ai=options.flags.no_ai,
|
|
334
|
+
)
|
|
335
|
+
except ValueError as exc:
|
|
336
|
+
logger.debug("Fill failed with ValueError", error=str(exc))
|
|
337
|
+
raise click.UsageError(_redact_credentials(str(exc))) from exc
|
|
338
|
+
click.echo(str(result))
|
|
339
|
+
if result.errors:
|
|
340
|
+
for err in result.errors:
|
|
341
|
+
click.echo(f" Warning: {err}", err=True)
|
|
342
|
+
|
|
343
|
+
if options.flags.snapshot:
|
|
344
|
+
_save_snapshot_cmd(
|
|
345
|
+
db_path=fill_db_path,
|
|
346
|
+
table=options.table,
|
|
347
|
+
count=effective_count,
|
|
348
|
+
provider=options.generator.provider,
|
|
349
|
+
locale=options.generator.locale,
|
|
350
|
+
seed=options.generator.seed,
|
|
351
|
+
batch_size=options.generator.batch_size,
|
|
352
|
+
clear=options.flags.clear,
|
|
353
|
+
url=fill_url,
|
|
354
|
+
transform=options.transform_path,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Generation completed with errors: exit non-zero so callers/scripts can detect partial failure.
|
|
358
|
+
if result.errors:
|
|
359
|
+
raise SystemExit(1)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@cli.command()
|
|
363
|
+
@click.argument("db_path", required=False)
|
|
364
|
+
@click.option("--table", "-t", required=True, help="Target table name")
|
|
365
|
+
@click.option("--count", "-n", default=5, type=int, help="Number of rows to preview (default: 5)")
|
|
366
|
+
@click.option(
|
|
367
|
+
"--provider",
|
|
368
|
+
"-p",
|
|
369
|
+
default="mimesis",
|
|
370
|
+
help="Data provider: mimesis|faker|base (default: mimesis)",
|
|
371
|
+
)
|
|
372
|
+
@click.option("--locale", "-l", default="en_US", help="Locale (default: en_US)")
|
|
373
|
+
@click.option("--seed", "-s", default=None, type=int, help="Random seed")
|
|
374
|
+
@click.option(
|
|
375
|
+
"--url",
|
|
376
|
+
"db_url",
|
|
377
|
+
default=None,
|
|
378
|
+
help="Database URL (e.g., postgresql://user:pass@host/db). Alternative to db_path argument.",
|
|
379
|
+
)
|
|
380
|
+
def preview(
|
|
381
|
+
db_path: str | None,
|
|
382
|
+
table: str,
|
|
383
|
+
count: int,
|
|
384
|
+
provider: str,
|
|
385
|
+
locale: str,
|
|
386
|
+
seed: int | None,
|
|
387
|
+
db_url: str | None,
|
|
388
|
+
) -> None:
|
|
389
|
+
"""Preview generated data without writing to database.
|
|
390
|
+
|
|
391
|
+
Connection methods (mutually exclusive):
|
|
392
|
+
- Positional db_path: sqlseed preview app.db -t users
|
|
393
|
+
- --url flag: sqlseed preview --url "postgresql://..." -t users
|
|
394
|
+
"""
|
|
395
|
+
if db_path and db_url:
|
|
396
|
+
raise click.UsageError(CONFLICTING_DATABASE_OPTIONS)
|
|
397
|
+
if not db_path and not db_url:
|
|
398
|
+
raise click.UsageError("db_path or --url is required.")
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
rows = api_preview(
|
|
402
|
+
db_path,
|
|
403
|
+
url=db_url,
|
|
404
|
+
table=table,
|
|
405
|
+
count=count,
|
|
406
|
+
provider=provider,
|
|
407
|
+
locale=locale,
|
|
408
|
+
seed=seed,
|
|
409
|
+
)
|
|
410
|
+
except (ValueError, RuntimeError, OSError) as exc:
|
|
411
|
+
logger.debug("Preview failed", error=str(exc))
|
|
412
|
+
raise click.UsageError(_redact_credentials(str(exc))) from exc
|
|
413
|
+
|
|
414
|
+
if not rows:
|
|
415
|
+
click.echo("No data generated.")
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
console = Console()
|
|
419
|
+
rich_table = RichTable(title=f"Preview: {table} ({count} rows)")
|
|
420
|
+
|
|
421
|
+
for col_name in rows[0]:
|
|
422
|
+
rich_table.add_column(col_name)
|
|
423
|
+
|
|
424
|
+
for row in rows:
|
|
425
|
+
rich_table.add_row(*[str(v) for v in row.values()])
|
|
426
|
+
|
|
427
|
+
console.print(rich_table)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _print_foreign_keys(fks: list[Any], tbl: str, console: Any) -> None:
|
|
431
|
+
if not fks:
|
|
432
|
+
return
|
|
433
|
+
fk_table = RichTable(title=f"Foreign Keys: {tbl}")
|
|
434
|
+
fk_table.add_column("Column")
|
|
435
|
+
fk_table.add_column("Ref Table")
|
|
436
|
+
fk_table.add_column("Ref Column")
|
|
437
|
+
for fk in fks:
|
|
438
|
+
fk_table.add_row(fk.column, fk.ref_table, fk.ref_column)
|
|
439
|
+
console.print(fk_table)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _inspect_table(orch: Any, tbl: str, show_mapping: bool, console: Any) -> None:
|
|
443
|
+
count = orch.get_row_count(tbl)
|
|
444
|
+
columns = orch.get_column_info(tbl)
|
|
445
|
+
fks = orch.get_foreign_keys(tbl)
|
|
446
|
+
|
|
447
|
+
rich_table = RichTable(title=f"Table: {tbl} ({count} rows)")
|
|
448
|
+
rich_table.add_column("Column")
|
|
449
|
+
rich_table.add_column("Type")
|
|
450
|
+
rich_table.add_column("Nullable")
|
|
451
|
+
rich_table.add_column("PK")
|
|
452
|
+
rich_table.add_column("Auto")
|
|
453
|
+
|
|
454
|
+
generator_specs = None
|
|
455
|
+
if show_mapping:
|
|
456
|
+
rich_table.add_column("Generator")
|
|
457
|
+
rich_table.add_column("Params")
|
|
458
|
+
generator_specs = orch.get_column_mapping(tbl)
|
|
459
|
+
|
|
460
|
+
for col in columns:
|
|
461
|
+
row_data = [
|
|
462
|
+
col.name,
|
|
463
|
+
col.type,
|
|
464
|
+
"\u2713" if col.nullable else "\u2717",
|
|
465
|
+
"\u2713" if col.is_primary_key else "",
|
|
466
|
+
"\u2713" if col.is_autoincrement else "",
|
|
467
|
+
]
|
|
468
|
+
if show_mapping and generator_specs:
|
|
469
|
+
if spec := generator_specs.get(col.name):
|
|
470
|
+
row_data.extend([spec.generator_name, str(spec.params)])
|
|
471
|
+
else:
|
|
472
|
+
row_data.extend(["skip", "{}"])
|
|
473
|
+
rich_table.add_row(*row_data)
|
|
474
|
+
|
|
475
|
+
console.print(rich_table)
|
|
476
|
+
_print_foreign_keys(fks, tbl, console)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@cli.command()
|
|
480
|
+
@click.argument("db_path", required=False)
|
|
481
|
+
@click.option("--table", "-t", default=None, help="Specific table to inspect")
|
|
482
|
+
@click.option("--show-mapping", is_flag=True, help="Show column mapping strategy")
|
|
483
|
+
@click.option(
|
|
484
|
+
"--url",
|
|
485
|
+
"db_url",
|
|
486
|
+
default=None,
|
|
487
|
+
help="Database URL (e.g., postgresql://user:pass@host/db). Alternative to db_path argument.",
|
|
488
|
+
)
|
|
489
|
+
def inspect(db_path: str | None, table: str | None, show_mapping: bool, db_url: str | None) -> None:
|
|
490
|
+
"""Inspect database schema and column mapping strategies.
|
|
491
|
+
|
|
492
|
+
Connection methods (mutually exclusive):
|
|
493
|
+
- Positional db_path: sqlseed inspect app.db
|
|
494
|
+
- --url flag: sqlseed inspect --url "postgresql://..."
|
|
495
|
+
"""
|
|
496
|
+
if db_path and db_url:
|
|
497
|
+
raise click.UsageError(CONFLICTING_DATABASE_OPTIONS)
|
|
498
|
+
if not (target := db_url or db_path):
|
|
499
|
+
raise click.UsageError("db_path or --url is required.")
|
|
500
|
+
try:
|
|
501
|
+
with DataOrchestrator(target) as orch:
|
|
502
|
+
console = Console()
|
|
503
|
+
|
|
504
|
+
tables = [table] if table else orch.get_table_names()
|
|
505
|
+
|
|
506
|
+
for tbl in tables:
|
|
507
|
+
_inspect_table(orch, tbl, show_mapping, console)
|
|
508
|
+
except (ValueError, RuntimeError, OSError) as exc:
|
|
509
|
+
logger.debug("Inspect failed", error=str(exc))
|
|
510
|
+
raise click.UsageError(_redact_credentials(str(exc))) from exc
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@cli.command()
|
|
514
|
+
@click.argument("config_path")
|
|
515
|
+
@click.option("--db", default=None, help="Database path for template (default: test.db)")
|
|
516
|
+
@click.option(
|
|
517
|
+
"--url",
|
|
518
|
+
"db_url",
|
|
519
|
+
default=None,
|
|
520
|
+
help="Database URL (e.g., postgresql://user:pass@host/db). Alternative to --db.",
|
|
521
|
+
)
|
|
522
|
+
def init(config_path: str, db: str | None, db_url: str | None) -> None:
|
|
523
|
+
"""Generate a YAML configuration template.
|
|
524
|
+
|
|
525
|
+
Connection methods (mutually exclusive):
|
|
526
|
+
- --db flag: sqlseed init config.yaml --db app.db
|
|
527
|
+
- --url flag: sqlseed init config.yaml --url "postgresql://..."
|
|
528
|
+
|
|
529
|
+
If neither --db nor --url is provided, --db defaults to "test.db".
|
|
530
|
+
"""
|
|
531
|
+
if db and db_url:
|
|
532
|
+
raise click.UsageError("Cannot specify both --db and --url. Use one or the other.")
|
|
533
|
+
|
|
534
|
+
# Apply the "test.db" default only when neither --db nor --url was provided.
|
|
535
|
+
# Previously --db had default="test.db" which made `db` always truthy,
|
|
536
|
+
# causing the mutual-exclusion check above to always fire when --url was
|
|
537
|
+
# passed — rendering `init --url` completely unusable.
|
|
538
|
+
if not db and not db_url:
|
|
539
|
+
db = "test.db"
|
|
540
|
+
|
|
541
|
+
effective_db = None if db_url else db
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
config = generate_template(db_path=effective_db, url=db_url)
|
|
545
|
+
save_config(config, config_path)
|
|
546
|
+
except (ValueError, RuntimeError, OSError) as exc:
|
|
547
|
+
logger.debug("Init failed", error=str(exc))
|
|
548
|
+
raise click.UsageError(_redact_credentials(str(exc))) from exc
|
|
549
|
+
click.echo(f"Configuration template saved to: {config_path}")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _load_replay_config(snapshot_path: str) -> tuple[dict[str, Any], GeneratorConfig]:
|
|
553
|
+
manager = SnapshotManager()
|
|
554
|
+
try:
|
|
555
|
+
data = manager.load(snapshot_path)
|
|
556
|
+
except FileNotFoundError as exc:
|
|
557
|
+
raise click.UsageError(f"Snapshot file not found: {snapshot_path}") from exc
|
|
558
|
+
except (ValueError, KeyError) as exc:
|
|
559
|
+
raise click.UsageError(_redact_credentials(f"Invalid snapshot file format: {exc}")) from exc
|
|
560
|
+
|
|
561
|
+
try:
|
|
562
|
+
config = GeneratorConfig(**data["config"])
|
|
563
|
+
except (pydantic.ValidationError, KeyError, TypeError) as exc:
|
|
564
|
+
raise click.UsageError(_redact_credentials(f"Invalid config in snapshot: {exc}")) from exc
|
|
565
|
+
return data, config
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@cli.command()
|
|
569
|
+
@click.argument("snapshot_path")
|
|
570
|
+
def replay(snapshot_path: str) -> None:
|
|
571
|
+
"""Replay a previously saved snapshot."""
|
|
572
|
+
# Security: block path-traversal attempts. A snapshot path containing ``..``
|
|
573
|
+
# that resolves outside the cache directory is rejected to prevent reading
|
|
574
|
+
# arbitrary files. Absolute paths without ``..`` are allowed so that users
|
|
575
|
+
# can still replay snapshots from custom directories.
|
|
576
|
+
cache_dir = get_cache_dir("snapshots")
|
|
577
|
+
resolved_path = Path(snapshot_path).resolve()
|
|
578
|
+
if ".." in Path(snapshot_path).parts and not resolved_path.is_relative_to(cache_dir):
|
|
579
|
+
raise click.UsageError("snapshot path must be within cache directory")
|
|
580
|
+
|
|
581
|
+
data, config = _load_replay_config(snapshot_path)
|
|
582
|
+
|
|
583
|
+
try:
|
|
584
|
+
table_name = data["table_name"]
|
|
585
|
+
count = data["count"]
|
|
586
|
+
except (KeyError, TypeError) as exc:
|
|
587
|
+
raise click.UsageError(f"Invalid snapshot file format: {exc}") from exc
|
|
588
|
+
seed = data.get("seed")
|
|
589
|
+
|
|
590
|
+
table_config = None
|
|
591
|
+
for tc in config.tables:
|
|
592
|
+
if tc.name == table_name:
|
|
593
|
+
table_config = tc
|
|
594
|
+
break
|
|
595
|
+
|
|
596
|
+
with DataOrchestrator.from_config(config) as orch:
|
|
597
|
+
result = orch.fill_table(
|
|
598
|
+
table_name=table_name,
|
|
599
|
+
count=count,
|
|
600
|
+
seed=seed,
|
|
601
|
+
batch_size=table_config.batch_size if table_config else 5000,
|
|
602
|
+
clear_before=table_config.clear_before if table_config else False,
|
|
603
|
+
column_configs=table_config.columns if table_config else None,
|
|
604
|
+
transform=table_config.transform if table_config else None,
|
|
605
|
+
)
|
|
606
|
+
click.echo(str(result))
|
|
607
|
+
if result.errors:
|
|
608
|
+
for err in result.errors:
|
|
609
|
+
click.echo(f" Warning: {err}", err=True)
|
|
610
|
+
raise SystemExit(1)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def main() -> None:
|
|
614
|
+
"""Entry point for the ``sqlseed`` console script (registered via ``[project.scripts]``)."""
|
|
615
|
+
cli()
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
if __name__ == "__main__":
|
|
619
|
+
main()
|