Skip to content

Commit 815567e

Browse files
committed
Expand migrations CLI to support create/run/rollback/status
1 parent 7372fce commit 815567e

28 files changed

+977
-120
lines changed

.codespellrc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[codespell]
2+
skip = .git,poetry.lock,*.pyc,__pycache__
3+
ignore-words-list = redis,migrator,datetime,timestamp,asyncio,redisearch,pydantic,ulid,hnsw

.pre-commit-config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
repos:
2+
- repo: https://github.com/codespell-project/codespell
3+
rev: v2.2.6
4+
hooks:
5+
- id: codespell
6+
args: [--write-changes]
7+
exclude: ^(poetry\.lock|\.git/|docs/.*\.md)$

aredis_om/cli/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Redis-OM CLI - Main entry point for the async 'om' command.
2+
Redis OMCLI - Main entry point for the async 'om' command.
33
"""
44

55
import click
@@ -11,7 +11,7 @@
1111
@click.group()
1212
@click.version_option()
1313
def om():
14-
"""Redis-OM Python CLI - Object mapping and migrations for Redis."""
14+
"""Redis OM Python CLI - Object mapping and migrations for Redis."""
1515
pass
1616

1717

aredis_om/model/cli/migrate.py

Lines changed: 198 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,204 @@
1+
import asyncio
2+
import os
3+
14
import click
25

3-
from aredis_om.model.migrations.migrator import Migrator
6+
from aredis_om.model.migrations.schema_migrator import SchemaMigrator
7+
from aredis_om.settings import get_root_migrations_dir
8+
9+
10+
def run_async(coro):
11+
"""Run an async coroutine in an isolated event loop to avoid interfering with pytest loops."""
12+
import concurrent.futures
13+
14+
with concurrent.futures.ThreadPoolExecutor() as executor:
15+
future = executor.submit(asyncio.run, coro)
16+
return future.result()
17+
18+
19+
@click.group()
20+
def migrate():
21+
"""Manage schema migrations for Redis OM models."""
22+
pass
23+
24+
25+
@migrate.command()
26+
@click.option("--migrations-dir", help="Directory containing schema migration files")
27+
def status(migrations_dir: str | None):
28+
"""Show current schema migration status from files."""
29+
30+
async def _status():
31+
dir_path = migrations_dir or os.path.join(
32+
get_root_migrations_dir(), "schema-migrations"
33+
)
34+
migrator = SchemaMigrator(migrations_dir=dir_path)
35+
status_info = await migrator.status()
36+
37+
click.echo("Schema Migration Status:")
38+
click.echo(f" Total migrations: {status_info['total_migrations']}")
39+
click.echo(f" Applied: {status_info['applied_count']}")
40+
click.echo(f" Pending: {status_info['pending_count']}")
41+
42+
if status_info["pending_migrations"]:
43+
click.echo("\nPending migrations:")
44+
for migration_id in status_info["pending_migrations"]:
45+
click.echo(f"- {migration_id}")
46+
47+
if status_info["applied_migrations"]:
48+
click.echo("\nApplied migrations:")
49+
for migration_id in status_info["applied_migrations"]:
50+
click.echo(f"- {migration_id}")
51+
52+
run_async(_status())
53+
54+
55+
@migrate.command()
56+
@click.option("--migrations-dir", help="Directory containing schema migration files")
57+
@click.option(
58+
"--dry-run", is_flag=True, help="Show what would be done without applying changes"
59+
)
60+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
61+
@click.option("--limit", type=int, help="Limit number of migrations to run")
62+
@click.option(
63+
"--yes",
64+
"-y",
65+
is_flag=True,
66+
help="Skip confirmation prompt to create directory or run",
67+
)
68+
def run(
69+
migrations_dir: str | None,
70+
dry_run: bool,
71+
verbose: bool,
72+
limit: int | None,
73+
yes: bool,
74+
):
75+
"""Run pending schema migrations from files."""
76+
77+
async def _run():
78+
dir_path = migrations_dir or os.path.join(
79+
get_root_migrations_dir(), "schema-migrations"
80+
)
81+
82+
if not os.path.exists(dir_path):
83+
if yes or click.confirm(
84+
f"Create schema migrations directory at '{dir_path}'?"
85+
):
86+
os.makedirs(dir_path, exist_ok=True)
87+
else:
88+
click.echo("Aborted.")
89+
return
90+
91+
migrator = SchemaMigrator(migrations_dir=dir_path)
92+
93+
# Show list for confirmation
94+
if not dry_run and not yes:
95+
status_info = await migrator.status()
96+
if status_info["pending_migrations"]:
97+
listing = "\n".join(
98+
f"- {m}"
99+
for m in status_info["pending_migrations"][
100+
: (limit or len(status_info["pending_migrations"]))
101+
]
102+
)
103+
if not click.confirm(
104+
f"Run {min(limit or len(status_info['pending_migrations']), len(status_info['pending_migrations']))} migration(s)?\n{listing}"
105+
):
106+
click.echo("Aborted.")
107+
return
108+
109+
count = await migrator.run(dry_run=dry_run, limit=limit, verbose=verbose)
110+
if verbose and not dry_run:
111+
click.echo(f"Successfully applied {count} migration(s).")
112+
113+
run_async(_run())
114+
115+
116+
@migrate.command()
117+
@click.argument("name")
118+
@click.option("--migrations-dir", help="Directory to create migration in")
119+
@click.option(
120+
"--yes", "-y", is_flag=True, help="Skip confirmation prompt to create directory"
121+
)
122+
def create(name: str, migrations_dir: str | None, yes: bool):
123+
"""Create a new schema migration snapshot file from current pending operations."""
124+
125+
async def _create():
126+
dir_path = migrations_dir or os.path.join(
127+
get_root_migrations_dir(), "schema-migrations"
128+
)
129+
130+
if not os.path.exists(dir_path):
131+
if yes or click.confirm(
132+
f"Create schema migrations directory at '{dir_path}'?"
133+
):
134+
os.makedirs(dir_path, exist_ok=True)
135+
else:
136+
click.echo("Aborted.")
137+
return
138+
139+
migrator = SchemaMigrator(migrations_dir=dir_path)
140+
filepath = await migrator.create_migration_file(name)
141+
if filepath:
142+
click.echo(f"Created migration: {filepath}")
143+
else:
144+
click.echo("No pending schema changes detected. Nothing to snapshot.")
145+
146+
run_async(_create())
147+
148+
149+
@migrate.command()
150+
@click.argument("migration_id")
151+
@click.option("--migrations-dir", help="Directory containing schema migration files")
152+
@click.option(
153+
"--dry-run", is_flag=True, help="Show what would be done without applying changes"
154+
)
155+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
156+
@click.option(
157+
"--yes",
158+
"-y",
159+
is_flag=True,
160+
help="Skip confirmation prompt to create directory or run",
161+
)
162+
def rollback(
163+
migration_id: str,
164+
migrations_dir: str | None,
165+
dry_run: bool,
166+
verbose: bool,
167+
yes: bool,
168+
):
169+
"""Rollback a specific schema migration by ID."""
170+
171+
async def _rollback():
172+
dir_path = migrations_dir or os.path.join(
173+
get_root_migrations_dir(), "schema-migrations"
174+
)
175+
176+
if not os.path.exists(dir_path):
177+
if yes or click.confirm(
178+
f"Create schema migrations directory at '{dir_path}'?"
179+
):
180+
os.makedirs(dir_path, exist_ok=True)
181+
else:
182+
click.echo("Aborted.")
183+
return
4184

185+
migrator = SchemaMigrator(migrations_dir=dir_path)
5186

6-
@click.command()
7-
@click.option("--module", default="aredis_om")
8-
def migrate(module: str):
9-
migrator = Migrator(module)
10-
migrator.detect_migrations()
187+
if not yes and not dry_run:
188+
if not click.confirm(f"Rollback migration '{migration_id}'?"):
189+
click.echo("Aborted.")
190+
return
11191

12-
if migrator.migrations:
13-
print("Pending migrations:")
14-
for migration in migrator.migrations:
15-
print(migration)
192+
success = await migrator.rollback(
193+
migration_id, dry_run=dry_run, verbose=verbose
194+
)
195+
if success:
196+
if verbose:
197+
click.echo(f"Successfully rolled back migration: {migration_id}")
198+
else:
199+
click.echo(
200+
f"Migration '{migration_id}' does not support rollback or is not applied.",
201+
err=True,
202+
)
16203

17-
if input("Run migrations? (y/n) ") == "y":
18-
migrator.run()
204+
run_async(_rollback())

0 commit comments

Comments
 (0)