fraiseql-confiture 0.1.0__cp311-cp311-manylinux_2_34_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fraiseql-confiture might be problematic. Click here for more details.

confiture/cli/main.py ADDED
@@ -0,0 +1,720 @@
1
+ """Main CLI entry point for Confiture.
2
+
3
+ This module defines the main Typer application and all CLI commands.
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from confiture.core.builder import SchemaBuilder
13
+ from confiture.core.differ import SchemaDiffer
14
+ from confiture.core.migration_generator import MigrationGenerator
15
+
16
+ # Create Typer app
17
+ app = typer.Typer(
18
+ name="confiture",
19
+ help="PostgreSQL migrations, sweetly done 🍓",
20
+ add_completion=False,
21
+ )
22
+
23
+ # Create Rich console for pretty output
24
+ console = Console()
25
+
26
+ # Version
27
+ __version__ = "0.1.0"
28
+
29
+
30
+ def version_callback(value: bool) -> None:
31
+ """Print version and exit."""
32
+ if value:
33
+ console.print(f"confiture version {__version__}")
34
+ raise typer.Exit()
35
+
36
+
37
+ @app.callback()
38
+ def main(
39
+ version: bool = typer.Option(
40
+ False,
41
+ "--version",
42
+ callback=version_callback,
43
+ is_eager=True,
44
+ help="Show version and exit",
45
+ ),
46
+ ) -> None:
47
+ """Confiture - PostgreSQL migrations, sweetly done 🍓."""
48
+ pass
49
+
50
+
51
+ @app.command()
52
+ def init(
53
+ path: Path = typer.Argument(
54
+ Path("."),
55
+ help="Project directory to initialize",
56
+ ),
57
+ ) -> None:
58
+ """Initialize a new Confiture project.
59
+
60
+ Creates necessary directory structure and configuration files.
61
+ """
62
+ try:
63
+ # Create directory structure
64
+ db_dir = path / "db"
65
+ schema_dir = db_dir / "schema"
66
+ seeds_dir = db_dir / "seeds"
67
+ migrations_dir = db_dir / "migrations"
68
+ environments_dir = db_dir / "environments"
69
+
70
+ # Check if already initialized
71
+ if db_dir.exists():
72
+ console.print(
73
+ "[yellow]⚠️ Project already exists. Some files may be overwritten.[/yellow]"
74
+ )
75
+ if not typer.confirm("Continue?"):
76
+ raise typer.Exit()
77
+
78
+ # Create directories
79
+ schema_dir.mkdir(parents=True, exist_ok=True)
80
+ (seeds_dir / "common").mkdir(parents=True, exist_ok=True)
81
+ (seeds_dir / "development").mkdir(parents=True, exist_ok=True)
82
+ (seeds_dir / "test").mkdir(parents=True, exist_ok=True)
83
+ migrations_dir.mkdir(parents=True, exist_ok=True)
84
+ environments_dir.mkdir(parents=True, exist_ok=True)
85
+
86
+ # Create example schema directory structure
87
+ (schema_dir / "00_common").mkdir(exist_ok=True)
88
+ (schema_dir / "10_tables").mkdir(exist_ok=True)
89
+
90
+ # Create example schema file
91
+ example_schema = schema_dir / "00_common" / "extensions.sql"
92
+ example_schema.write_text(
93
+ """-- PostgreSQL extensions
94
+ -- Add commonly used extensions here
95
+
96
+ -- Example:
97
+ -- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
98
+ -- CREATE EXTENSION IF NOT EXISTS "pg_trgm";
99
+ """
100
+ )
101
+
102
+ # Create example table
103
+ example_table = schema_dir / "10_tables" / "example.sql"
104
+ example_table.write_text(
105
+ """-- Example table
106
+ -- Replace with your actual schema
107
+
108
+ CREATE TABLE IF NOT EXISTS users (
109
+ id SERIAL PRIMARY KEY,
110
+ username TEXT NOT NULL UNIQUE,
111
+ email TEXT NOT NULL UNIQUE,
112
+ created_at TIMESTAMP DEFAULT NOW()
113
+ );
114
+ """
115
+ )
116
+
117
+ # Create example seed file
118
+ example_seed = seeds_dir / "common" / "00_example.sql"
119
+ example_seed.write_text(
120
+ """-- Common seed data
121
+ -- These records are included in all non-production environments
122
+
123
+ -- Example: Test users
124
+ -- INSERT INTO users (username, email) VALUES
125
+ -- ('admin', 'admin@example.com'),
126
+ -- ('editor', 'editor@example.com'),
127
+ -- ('reader', 'reader@example.com');
128
+ """
129
+ )
130
+
131
+ # Create local environment config
132
+ local_config = environments_dir / "local.yaml"
133
+ local_config.write_text(
134
+ """# Local development environment configuration
135
+
136
+ name: local
137
+ include_dirs:
138
+ - db/schema/00_common
139
+ - db/schema/10_tables
140
+ exclude_dirs: []
141
+
142
+ database:
143
+ host: localhost
144
+ port: 5432
145
+ database: myapp_local
146
+ user: postgres
147
+ password: postgres
148
+ """
149
+ )
150
+
151
+ # Create README
152
+ readme = db_dir / "README.md"
153
+ readme.write_text(
154
+ """# Database Schema
155
+
156
+ This directory contains your database schema and migrations.
157
+
158
+ ## Directory Structure
159
+
160
+ - `schema/` - DDL files organized by category
161
+ - `00_common/` - Extensions, types, functions
162
+ - `10_tables/` - Table definitions
163
+ - `migrations/` - Python migration files
164
+ - `environments/` - Environment-specific configurations
165
+
166
+ ## Quick Start
167
+
168
+ 1. Edit schema files in `schema/`
169
+ 2. Generate migrations: `confiture migrate diff old.sql new.sql --generate`
170
+ 3. Apply migrations: `confiture migrate up`
171
+
172
+ ## Learn More
173
+
174
+ Documentation: https://github.com/evoludigit/confiture
175
+ """
176
+ )
177
+
178
+ console.print("[green]✅ Confiture project initialized successfully![/green]")
179
+ console.print(f"\n📁 Created structure in: {path.absolute()}")
180
+ console.print("\n📝 Next steps:")
181
+ console.print(" 1. Edit your schema files in db/schema/")
182
+ console.print(" 2. Configure environments in db/environments/")
183
+ console.print(" 3. Run 'confiture migrate diff' to detect changes")
184
+
185
+ except Exception as e:
186
+ console.print(f"[red]❌ Error initializing project: {e}[/red]")
187
+ raise typer.Exit(1) from e
188
+
189
+
190
+ @app.command()
191
+ def build(
192
+ env: str = typer.Option(
193
+ "local",
194
+ "--env",
195
+ "-e",
196
+ help="Environment to build (references db/environments/{env}.yaml)",
197
+ ),
198
+ output: Path = typer.Option(
199
+ None,
200
+ "--output",
201
+ "-o",
202
+ help="Output file path (default: db/generated/schema_{env}.sql)",
203
+ ),
204
+ project_dir: Path = typer.Option(
205
+ Path("."),
206
+ "--project-dir",
207
+ help="Project directory (default: current directory)",
208
+ ),
209
+ show_hash: bool = typer.Option(
210
+ False,
211
+ "--show-hash",
212
+ help="Display schema hash after build",
213
+ ),
214
+ schema_only: bool = typer.Option(
215
+ False,
216
+ "--schema-only",
217
+ help="Build schema only, exclude seed data",
218
+ ),
219
+ ) -> None:
220
+ """Build complete schema from DDL files.
221
+
222
+ This command builds a complete schema by concatenating all SQL files
223
+ from the db/schema/ directory in deterministic order. This is the
224
+ fastest way to create or recreate a database from scratch.
225
+
226
+ The build process:
227
+ 1. Reads environment configuration (db/environments/{env}.yaml)
228
+ 2. Discovers all .sql files in configured include_dirs
229
+ 3. Concatenates files in alphabetical order
230
+ 4. Adds metadata headers (environment, file count, timestamp)
231
+ 5. Writes to output file (default: db/generated/schema_{env}.sql)
232
+
233
+ Examples:
234
+ # Build local environment schema
235
+ confiture build
236
+
237
+ # Build for specific environment
238
+ confiture build --env production
239
+
240
+ # Custom output location
241
+ confiture build --output /tmp/schema.sql
242
+
243
+ # Show hash for change detection
244
+ confiture build --show-hash
245
+ """
246
+ try:
247
+ # Create schema builder
248
+ builder = SchemaBuilder(env=env, project_dir=project_dir)
249
+
250
+ # Override to exclude seeds if --schema-only is specified
251
+ if schema_only:
252
+ builder.include_dirs = [d for d in builder.include_dirs if "seed" not in str(d).lower()]
253
+ # Recalculate base_dir after filtering
254
+ if builder.include_dirs:
255
+ builder.base_dir = builder._find_common_parent(builder.include_dirs)
256
+
257
+ # Set default output path if not specified
258
+ if output is None:
259
+ output_dir = project_dir / "db" / "generated"
260
+ output_dir.mkdir(parents=True, exist_ok=True)
261
+ output = output_dir / f"schema_{env}.sql"
262
+
263
+ # Build schema
264
+ console.print(f"[cyan]🔨 Building schema for environment: {env}[/cyan]")
265
+
266
+ sql_files = builder.find_sql_files()
267
+ console.print(f"[cyan]📄 Found {len(sql_files)} SQL files[/cyan]")
268
+
269
+ schema = builder.build(output_path=output)
270
+
271
+ # Success message
272
+ console.print("[green]✅ Schema built successfully![/green]")
273
+ console.print(f"\n📁 Output: {output.absolute()}")
274
+ console.print(f"📏 Size: {len(schema):,} bytes")
275
+ console.print(f"📊 Files: {len(sql_files)}")
276
+
277
+ # Show hash if requested
278
+ if show_hash:
279
+ schema_hash = builder.compute_hash()
280
+ console.print(f"🔐 Hash: {schema_hash}")
281
+
282
+ console.print("\n💡 Next steps:")
283
+ console.print(f" • Apply schema: psql -f {output}")
284
+ console.print(" • Or use: confiture migrate up")
285
+
286
+ except FileNotFoundError as e:
287
+ console.print(f"[red]❌ File not found: {e}[/red]")
288
+ console.print("\n💡 Tip: Run 'confiture init' to create project structure")
289
+ raise typer.Exit(1) from e
290
+ except Exception as e:
291
+ console.print(f"[red]❌ Error building schema: {e}[/red]")
292
+ raise typer.Exit(1) from e
293
+
294
+
295
+ # Create migrate subcommand group
296
+ migrate_app = typer.Typer(help="Migration commands")
297
+ app.add_typer(migrate_app, name="migrate")
298
+
299
+
300
+ @migrate_app.command("status")
301
+ def migrate_status(
302
+ migrations_dir: Path = typer.Option(
303
+ Path("db/migrations"),
304
+ "--migrations-dir",
305
+ help="Migrations directory",
306
+ ),
307
+ config: Path = typer.Option(
308
+ None,
309
+ "--config",
310
+ "-c",
311
+ help="Configuration file (optional, to show applied status)",
312
+ ),
313
+ ) -> None:
314
+ """Show migration status.
315
+
316
+ If config is provided, shows which migrations are applied vs pending.
317
+ """
318
+ try:
319
+ if not migrations_dir.exists():
320
+ console.print("[yellow]No migrations directory found.[/yellow]")
321
+ console.print(f"Expected: {migrations_dir.absolute()}")
322
+ return
323
+
324
+ # Find migration files
325
+ migration_files = sorted(migrations_dir.glob("*.py"))
326
+
327
+ if not migration_files:
328
+ console.print("[yellow]No migrations found.[/yellow]")
329
+ return
330
+
331
+ # Get applied migrations from database if config provided
332
+ applied_versions = set()
333
+ if config and config.exists():
334
+ try:
335
+ from confiture.core.connection import create_connection, load_config
336
+ from confiture.core.migrator import Migrator
337
+
338
+ config_data = load_config(config)
339
+ conn = create_connection(config_data)
340
+ migrator = Migrator(connection=conn)
341
+ migrator.initialize()
342
+ applied_versions = set(migrator.get_applied_versions())
343
+ conn.close()
344
+ except Exception as e:
345
+ console.print(f"[yellow]⚠️ Could not connect to database: {e}[/yellow]")
346
+ console.print("[yellow]Showing file list only (status unknown)[/yellow]\n")
347
+
348
+ # Display migrations in a table
349
+ table = Table(title="Migrations")
350
+ table.add_column("Version", style="cyan")
351
+ table.add_column("Name", style="green")
352
+ table.add_column("Status", style="yellow")
353
+
354
+ pending_count = 0
355
+ applied_count = 0
356
+
357
+ for migration_file in migration_files:
358
+ # Extract version and name from filename (e.g., "001_add_users.py")
359
+ parts = migration_file.stem.split("_", 1)
360
+ version = parts[0] if len(parts) > 0 else "???"
361
+ name = parts[1] if len(parts) > 1 else migration_file.stem
362
+
363
+ # Determine status
364
+ if applied_versions:
365
+ if version in applied_versions:
366
+ status = "[green]✅ applied[/green]"
367
+ applied_count += 1
368
+ else:
369
+ status = "[yellow]⏳ pending[/yellow]"
370
+ pending_count += 1
371
+ else:
372
+ status = "unknown"
373
+
374
+ table.add_row(version, name, status)
375
+
376
+ console.print(table)
377
+ console.print(f"\n📊 Total: {len(migration_files)} migrations", end="")
378
+ if applied_versions:
379
+ console.print(f" ({applied_count} applied, {pending_count} pending)")
380
+ else:
381
+ console.print()
382
+
383
+ except Exception as e:
384
+ console.print(f"[red]❌ Error: {e}[/red]")
385
+ raise typer.Exit(1) from e
386
+
387
+
388
+ @migrate_app.command("generate")
389
+ def migrate_generate(
390
+ name: str = typer.Argument(..., help="Migration name (snake_case)"),
391
+ migrations_dir: Path = typer.Option(
392
+ Path("db/migrations"),
393
+ "--migrations-dir",
394
+ help="Migrations directory",
395
+ ),
396
+ ) -> None:
397
+ """Generate a new migration file.
398
+
399
+ Creates an empty migration template with the given name.
400
+ """
401
+ try:
402
+ # Ensure migrations directory exists
403
+ migrations_dir.mkdir(parents=True, exist_ok=True)
404
+
405
+ # Generate migration file template
406
+ generator = MigrationGenerator(migrations_dir=migrations_dir)
407
+
408
+ # For empty migration, create a template manually
409
+ version = generator._get_next_version()
410
+ class_name = generator._to_class_name(name)
411
+ filename = f"{version}_{name}.py"
412
+ filepath = migrations_dir / filename
413
+
414
+ # Create template
415
+ template = f'''"""Migration: {name}
416
+
417
+ Version: {version}
418
+ """
419
+
420
+ from confiture.models.migration import Migration
421
+
422
+
423
+ class {class_name}(Migration):
424
+ """Migration: {name}."""
425
+
426
+ version = "{version}"
427
+ name = "{name}"
428
+
429
+ def up(self) -> None:
430
+ """Apply migration."""
431
+ # TODO: Add your SQL statements here
432
+ # Example:
433
+ # self.execute("CREATE TABLE users (id SERIAL PRIMARY KEY)")
434
+ pass
435
+
436
+ def down(self) -> None:
437
+ """Rollback migration."""
438
+ # TODO: Add your rollback SQL statements here
439
+ # Example:
440
+ # self.execute("DROP TABLE users")
441
+ pass
442
+ '''
443
+
444
+ filepath.write_text(template)
445
+
446
+ console.print("[green]✅ Migration generated successfully![/green]")
447
+ # Use plain print to avoid Rich wrapping long paths
448
+ print(f"\n📄 File: {filepath.absolute()}")
449
+ console.print("\n✏️ Edit the migration file to add your SQL statements.")
450
+
451
+ except Exception as e:
452
+ console.print(f"[red]❌ Error generating migration: {e}[/red]")
453
+ raise typer.Exit(1) from e
454
+
455
+
456
+ @migrate_app.command("diff")
457
+ def migrate_diff(
458
+ old_schema: Path = typer.Argument(..., help="Old schema file"),
459
+ new_schema: Path = typer.Argument(..., help="New schema file"),
460
+ generate: bool = typer.Option(
461
+ False,
462
+ "--generate",
463
+ help="Generate migration from diff",
464
+ ),
465
+ name: str = typer.Option(
466
+ None,
467
+ "--name",
468
+ help="Migration name (required with --generate)",
469
+ ),
470
+ migrations_dir: Path = typer.Option(
471
+ Path("db/migrations"),
472
+ "--migrations-dir",
473
+ help="Migrations directory",
474
+ ),
475
+ ) -> None:
476
+ """Compare two schema files and show differences.
477
+
478
+ Optionally generate a migration file from the diff.
479
+ """
480
+ try:
481
+ # Validate files exist
482
+ if not old_schema.exists():
483
+ console.print(f"[red]❌ Old schema file not found: {old_schema}[/red]")
484
+ raise typer.Exit(1)
485
+
486
+ if not new_schema.exists():
487
+ console.print(f"[red]❌ New schema file not found: {new_schema}[/red]")
488
+ raise typer.Exit(1)
489
+
490
+ # Read schemas
491
+ old_sql = old_schema.read_text()
492
+ new_sql = new_schema.read_text()
493
+
494
+ # Compare schemas
495
+ differ = SchemaDiffer()
496
+ diff = differ.compare(old_sql, new_sql)
497
+
498
+ # Display diff
499
+ if not diff.has_changes():
500
+ console.print("[green]✅ No changes detected. Schemas are identical.[/green]")
501
+ return
502
+
503
+ console.print("[cyan]📊 Schema differences detected:[/cyan]\n")
504
+
505
+ # Display changes in a table
506
+ table = Table()
507
+ table.add_column("Type", style="yellow")
508
+ table.add_column("Details", style="white")
509
+
510
+ for change in diff.changes:
511
+ table.add_row(change.type, str(change))
512
+
513
+ console.print(table)
514
+ console.print(f"\n📈 Total changes: {len(diff.changes)}")
515
+
516
+ # Generate migration if requested
517
+ if generate:
518
+ if not name:
519
+ console.print("[red]❌ Migration name is required when using --generate[/red]")
520
+ console.print(
521
+ "Usage: confiture migrate diff old.sql new.sql --generate --name migration_name"
522
+ )
523
+ raise typer.Exit(1)
524
+
525
+ # Ensure migrations directory exists
526
+ migrations_dir.mkdir(parents=True, exist_ok=True)
527
+
528
+ # Generate migration
529
+ generator = MigrationGenerator(migrations_dir=migrations_dir)
530
+ migration_file = generator.generate(diff, name=name)
531
+
532
+ console.print(f"\n[green]✅ Migration generated: {migration_file.name}[/green]")
533
+
534
+ except Exception as e:
535
+ console.print(f"[red]❌ Error: {e}[/red]")
536
+ raise typer.Exit(1) from e
537
+
538
+
539
+ @migrate_app.command("up")
540
+ def migrate_up(
541
+ migrations_dir: Path = typer.Option(
542
+ Path("db/migrations"),
543
+ "--migrations-dir",
544
+ help="Migrations directory",
545
+ ),
546
+ config: Path = typer.Option(
547
+ Path("db/environments/local.yaml"),
548
+ "--config",
549
+ "-c",
550
+ help="Configuration file",
551
+ ),
552
+ target: str = typer.Option(
553
+ None,
554
+ "--target",
555
+ "-t",
556
+ help="Target migration version (applies all if not specified)",
557
+ ),
558
+ ) -> None:
559
+ """Apply pending migrations.
560
+
561
+ Applies all pending migrations up to the target version (or all if no target).
562
+ """
563
+ from confiture.core.connection import (
564
+ create_connection,
565
+ get_migration_class,
566
+ load_config,
567
+ load_migration_module,
568
+ )
569
+ from confiture.core.migrator import Migrator
570
+
571
+ try:
572
+ # Load configuration
573
+ config_data = load_config(config)
574
+
575
+ # Create database connection
576
+ conn = create_connection(config_data)
577
+
578
+ # Create migrator
579
+ migrator = Migrator(connection=conn)
580
+ migrator.initialize()
581
+
582
+ # Find pending migrations
583
+ pending_migrations = migrator.find_pending(migrations_dir=migrations_dir)
584
+
585
+ if not pending_migrations:
586
+ console.print("[green]✅ No pending migrations. Database is up to date.[/green]")
587
+ conn.close()
588
+ return
589
+
590
+ console.print(f"[cyan]📦 Found {len(pending_migrations)} pending migration(s)[/cyan]\n")
591
+
592
+ # Apply migrations
593
+ applied_count = 0
594
+ for migration_file in pending_migrations:
595
+ # Load migration module
596
+ module = load_migration_module(migration_file)
597
+ migration_class = get_migration_class(module)
598
+
599
+ # Create migration instance
600
+ migration = migration_class(connection=conn)
601
+
602
+ # Check target
603
+ if target and migration.version > target:
604
+ console.print(f"[yellow]⏭️ Skipping {migration.version} (after target)[/yellow]")
605
+ break
606
+
607
+ # Apply migration
608
+ console.print(
609
+ f"[cyan]⚡ Applying {migration.version}_{migration.name}...[/cyan]", end=" "
610
+ )
611
+ migrator.apply(migration)
612
+ console.print("[green]✅[/green]")
613
+ applied_count += 1
614
+
615
+ console.print(f"\n[green]✅ Successfully applied {applied_count} migration(s)![/green]")
616
+ conn.close()
617
+
618
+ except Exception as e:
619
+ console.print(f"[red]❌ Error: {e}[/red]")
620
+ raise typer.Exit(1) from e
621
+
622
+
623
+ @migrate_app.command("down")
624
+ def migrate_down(
625
+ migrations_dir: Path = typer.Option(
626
+ Path("db/migrations"),
627
+ "--migrations-dir",
628
+ help="Migrations directory",
629
+ ),
630
+ config: Path = typer.Option(
631
+ Path("db/environments/local.yaml"),
632
+ "--config",
633
+ "-c",
634
+ help="Configuration file",
635
+ ),
636
+ steps: int = typer.Option(
637
+ 1,
638
+ "--steps",
639
+ "-n",
640
+ help="Number of migrations to rollback",
641
+ ),
642
+ ) -> None:
643
+ """Rollback applied migrations.
644
+
645
+ Rolls back the last N applied migrations (default: 1).
646
+ """
647
+ from confiture.core.connection import (
648
+ create_connection,
649
+ get_migration_class,
650
+ load_config,
651
+ load_migration_module,
652
+ )
653
+ from confiture.core.migrator import Migrator
654
+
655
+ try:
656
+ # Load configuration
657
+ config_data = load_config(config)
658
+
659
+ # Create database connection
660
+ conn = create_connection(config_data)
661
+
662
+ # Create migrator
663
+ migrator = Migrator(connection=conn)
664
+ migrator.initialize()
665
+
666
+ # Get applied migrations
667
+ applied_versions = migrator.get_applied_versions()
668
+
669
+ if not applied_versions:
670
+ console.print("[yellow]⚠️ No applied migrations to rollback.[/yellow]")
671
+ conn.close()
672
+ return
673
+
674
+ # Get migrations to rollback (last N)
675
+ versions_to_rollback = applied_versions[-steps:]
676
+
677
+ console.print(f"[cyan]📦 Rolling back {len(versions_to_rollback)} migration(s)[/cyan]\n")
678
+
679
+ # Rollback migrations in reverse order
680
+ rolled_back_count = 0
681
+ for version in reversed(versions_to_rollback):
682
+ # Find migration file
683
+ migration_files = migrator.find_migration_files(migrations_dir=migrations_dir)
684
+ migration_file = None
685
+ for mf in migration_files:
686
+ if migrator._version_from_filename(mf.name) == version:
687
+ migration_file = mf
688
+ break
689
+
690
+ if not migration_file:
691
+ console.print(f"[red]❌ Migration file for version {version} not found[/red]")
692
+ continue
693
+
694
+ # Load migration module
695
+ module = load_migration_module(migration_file)
696
+ migration_class = get_migration_class(module)
697
+
698
+ # Create migration instance
699
+ migration = migration_class(connection=conn)
700
+
701
+ # Rollback migration
702
+ console.print(
703
+ f"[cyan]⚡ Rolling back {migration.version}_{migration.name}...[/cyan]", end=" "
704
+ )
705
+ migrator.rollback(migration)
706
+ console.print("[green]✅[/green]")
707
+ rolled_back_count += 1
708
+
709
+ console.print(
710
+ f"\n[green]✅ Successfully rolled back {rolled_back_count} migration(s)![/green]"
711
+ )
712
+ conn.close()
713
+
714
+ except Exception as e:
715
+ console.print(f"[red]❌ Error: {e}[/red]")
716
+ raise typer.Exit(1) from e
717
+
718
+
719
+ if __name__ == "__main__":
720
+ app()