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/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()