Skip to content

Commit 589668b

Browse files
committed
Fix timestamp handling
Convert numerical values to and from datetime objects. Includes a new data migration feature and a migration to convert numerical TAG fields to NUMERIC. Moves OM CLI commands into a new top-level `om` command while preserving backwards compat for old `migrate` command.
1 parent 7e08669 commit 589668b

File tree

12 files changed

+2407
-6
lines changed

12 files changed

+2407
-6
lines changed

aredis_om/cli/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# CLI package

aredis_om/cli/main.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""
2+
Redis-OM CLI - Main entry point for the async 'om' command.
3+
"""
4+
5+
import click
6+
7+
from ..model.cli.migrate import migrate
8+
from ..model.cli.migrate_data import migrate_data
9+
10+
11+
@click.group()
12+
@click.version_option()
13+
def om():
14+
"""Redis-OM Python CLI - Object mapping and migrations for Redis."""
15+
pass
16+
17+
18+
# Add subcommands
19+
om.add_command(migrate)
20+
om.add_command(migrate_data, name="migrate-data")
21+
22+
23+
if __name__ == "__main__":
24+
om()

aredis_om/model/cli/migrate_data.py

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
"""
2+
Async CLI for Redis-OM data migrations.
3+
4+
This module provides command-line interface for managing data migrations
5+
in Redis-OM Python applications.
6+
"""
7+
8+
import asyncio
9+
import os
10+
from pathlib import Path
11+
12+
import click
13+
14+
from ..migrations.data_migrator import DataMigrationError, DataMigrator
15+
16+
17+
def run_async(coro):
18+
"""Helper to run async functions in Click commands."""
19+
try:
20+
loop = asyncio.get_event_loop()
21+
if loop.is_running():
22+
# We're in an async context, create a new loop
23+
import concurrent.futures
24+
25+
with concurrent.futures.ThreadPoolExecutor() as executor:
26+
future = executor.submit(asyncio.run, coro)
27+
return future.result()
28+
else:
29+
return loop.run_until_complete(coro)
30+
except RuntimeError:
31+
# No event loop exists, create one
32+
return asyncio.run(coro)
33+
34+
35+
@click.group()
36+
def migrate_data():
37+
"""Manage data migrations for Redis-OM models."""
38+
pass
39+
40+
41+
@migrate_data.command()
42+
@click.option(
43+
"--migrations-dir",
44+
default="migrations",
45+
help="Directory containing migration files (default: migrations)",
46+
)
47+
@click.option("--module", help="Python module containing migrations")
48+
def status(migrations_dir: str, module: str):
49+
"""Show current migration status."""
50+
51+
async def _status():
52+
try:
53+
migrator = DataMigrator(
54+
migrations_dir=migrations_dir if not module else None,
55+
migration_module=module,
56+
)
57+
58+
status_info = await migrator.status()
59+
60+
click.echo("Migration Status:")
61+
click.echo(f" Total migrations: {status_info['total_migrations']}")
62+
click.echo(f" Applied: {status_info['applied_count']}")
63+
click.echo(f" Pending: {status_info['pending_count']}")
64+
65+
if status_info["pending_migrations"]:
66+
click.echo("\nPending migrations:")
67+
for migration_id in status_info["pending_migrations"]:
68+
click.echo(f"- {migration_id}")
69+
70+
if status_info["applied_migrations"]:
71+
click.echo("\nApplied migrations:")
72+
for migration_id in status_info["applied_migrations"]:
73+
click.echo(f"- {migration_id}")
74+
75+
except Exception as e:
76+
click.echo(f"Error: {e}", err=True)
77+
raise click.Abort()
78+
79+
run_async(_status())
80+
81+
82+
@migrate_data.command()
83+
@click.option(
84+
"--migrations-dir",
85+
default="migrations",
86+
help="Directory containing migration files (default: migrations)",
87+
)
88+
@click.option("--module", help="Python module containing migrations")
89+
@click.option(
90+
"--dry-run", is_flag=True, help="Show what would be done without applying changes"
91+
)
92+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
93+
@click.option("--limit", type=int, help="Limit number of migrations to run")
94+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
95+
def run(
96+
migrations_dir: str,
97+
module: str,
98+
dry_run: bool,
99+
verbose: bool,
100+
limit: int,
101+
yes: bool,
102+
):
103+
"""Run pending migrations."""
104+
105+
async def _run():
106+
try:
107+
migrator = DataMigrator(
108+
migrations_dir=migrations_dir if not module else None,
109+
migration_module=module,
110+
)
111+
112+
# Get pending migrations for confirmation
113+
pending = await migrator.get_pending_migrations()
114+
115+
if not pending:
116+
if verbose:
117+
click.echo("No pending migrations found.")
118+
return
119+
120+
count_to_run = len(pending)
121+
if limit:
122+
count_to_run = min(count_to_run, limit)
123+
pending = pending[:limit]
124+
125+
if dry_run:
126+
click.echo(f"Would run {count_to_run} migration(s):")
127+
for migration in pending:
128+
click.echo(f"- {migration.migration_id}: {migration.description}")
129+
return
130+
131+
# Confirm unless --yes is specified
132+
if not yes:
133+
migration_list = "\n".join(f"- {m.migration_id}" for m in pending)
134+
if not click.confirm(
135+
f"Run {count_to_run} migration(s)?\n{migration_list}"
136+
):
137+
click.echo("Aborted.")
138+
return
139+
140+
# Run migrations
141+
count = await migrator.run_migrations(
142+
dry_run=False, limit=limit, verbose=verbose
143+
)
144+
145+
if verbose:
146+
click.echo(f"Successfully applied {count} migration(s).")
147+
148+
except DataMigrationError as e:
149+
click.echo(f"Migration error: {e}", err=True)
150+
raise click.Abort()
151+
except Exception as e:
152+
click.echo(f"Error: {e}", err=True)
153+
raise click.Abort()
154+
155+
run_async(_run())
156+
157+
158+
@migrate_data.command()
159+
@click.argument("name")
160+
@click.option(
161+
"--migrations-dir",
162+
default="migrations",
163+
help="Directory to create migration in (default: migrations)",
164+
)
165+
def create(name: str, migrations_dir: str):
166+
"""Create a new migration file."""
167+
168+
async def _create():
169+
try:
170+
migrator = DataMigrator(migrations_dir=migrations_dir)
171+
filepath = await migrator.create_migration_file(name, migrations_dir)
172+
click.echo(f"Created migration: {filepath}")
173+
174+
except Exception as e:
175+
click.echo(f"Error creating migration: {e}", err=True)
176+
raise click.Abort()
177+
178+
run_async(_create())
179+
180+
181+
@migrate_data.command()
182+
@click.argument("migration_id")
183+
@click.option(
184+
"--migrations-dir",
185+
default="migrations",
186+
help="Directory containing migration files (default: migrations)",
187+
)
188+
@click.option("--module", help="Python module containing migrations")
189+
@click.option(
190+
"--dry-run", is_flag=True, help="Show what would be done without applying changes"
191+
)
192+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
193+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
194+
def rollback(
195+
migration_id: str,
196+
migrations_dir: str,
197+
module: str,
198+
dry_run: bool,
199+
verbose: bool,
200+
yes: bool,
201+
):
202+
"""Rollback a specific migration."""
203+
204+
async def _rollback():
205+
try:
206+
migrator = DataMigrator(
207+
migrations_dir=migrations_dir if not module else None,
208+
migration_module=module,
209+
)
210+
211+
# Check if migration exists and is applied
212+
all_migrations = await migrator.discover_migrations()
213+
applied_migrations = await migrator.get_applied_migrations()
214+
215+
if migration_id not in all_migrations:
216+
click.echo(f"Migration '{migration_id}' not found.", err=True)
217+
raise click.Abort()
218+
219+
if migration_id not in applied_migrations:
220+
click.echo(f"Migration '{migration_id}' is not applied.", err=True)
221+
return
222+
223+
migration = all_migrations[migration_id]
224+
225+
if dry_run:
226+
click.echo(f"Would rollback migration: {migration_id}")
227+
click.echo(f"Description: {migration.description}")
228+
return
229+
230+
# Confirm unless --yes is specified
231+
if not yes:
232+
if not click.confirm(f"Rollback migration '{migration_id}'?"):
233+
click.echo("Aborted.")
234+
return
235+
236+
# Attempt rollback
237+
success = await migrator.rollback_migration(
238+
migration_id, dry_run=False, verbose=verbose
239+
)
240+
241+
if success:
242+
if verbose:
243+
click.echo(f"Successfully rolled back migration: {migration_id}")
244+
else:
245+
click.echo(
246+
f"Migration '{migration_id}' does not support rollback.", err=True
247+
)
248+
249+
except DataMigrationError as e:
250+
click.echo(f"Migration error: {e}", err=True)
251+
raise click.Abort()
252+
except Exception as e:
253+
click.echo(f"Error: {e}", err=True)
254+
raise click.Abort()
255+
256+
run_async(_rollback())
257+
258+
259+
if __name__ == "__main__":
260+
migrate_data()

0 commit comments

Comments
 (0)