duo-orm 0.1.0__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.
- duo_orm/__init__.py +82 -0
- duo_orm/basemodel.py +146 -0
- duo_orm/db.py +251 -0
- duo_orm/exceptions.py +84 -0
- duo_orm/executor.py +282 -0
- duo_orm/migrations/__init__.py +1 -0
- duo_orm/migrations/cli.py +164 -0
- duo_orm/migrations/config.py +239 -0
- duo_orm/patch.py +73 -0
- duo_orm/query.py +630 -0
- duo_orm/session.py +36 -0
- duo_orm-0.1.0.dist-info/METADATA +396 -0
- duo_orm-0.1.0.dist-info/RECORD +16 -0
- duo_orm-0.1.0.dist-info/WHEEL +5 -0
- duo_orm-0.1.0.dist-info/entry_points.txt +2 -0
- duo_orm-0.1.0.dist-info/top_level.txt +1 -0
duo_orm/executor.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# duo_orm/executor.py
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import func, select, literal
|
|
4
|
+
from sqlalchemy import delete as sa_delete
|
|
5
|
+
from sqlalchemy import update as sa_update
|
|
6
|
+
|
|
7
|
+
from .exceptions import ObjectNotFoundError, MultipleObjectsFoundError
|
|
8
|
+
from .session import active_session_var, is_async_context
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_db_from_query(query_builder):
|
|
12
|
+
"""Internal helper to get the db object from a QueryBuilder."""
|
|
13
|
+
if not hasattr(query_builder, "db") or not query_builder.db:
|
|
14
|
+
raise RuntimeError("Database not configured for this query.")
|
|
15
|
+
return query_builder.db
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_db_from_instance(instance):
|
|
19
|
+
"""Internal helper to get the db object from a model instance."""
|
|
20
|
+
if not hasattr(instance.__class__, "_db") or not instance.__class__._db:
|
|
21
|
+
raise RuntimeError(
|
|
22
|
+
"Database not configured for this model. "
|
|
23
|
+
"Ensure your model inherits from a db.Model class."
|
|
24
|
+
)
|
|
25
|
+
return instance.__class__._db
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_db_from_class(cls):
|
|
29
|
+
"""Internal helper to get the db object from a model class."""
|
|
30
|
+
if not hasattr(cls, "_db") or not cls._db:
|
|
31
|
+
raise RuntimeError(
|
|
32
|
+
"Database not configured for this model. "
|
|
33
|
+
"Ensure your model inherits from a db.Model class."
|
|
34
|
+
)
|
|
35
|
+
return cls._db
|
|
36
|
+
|
|
37
|
+
def _resolve_db_target(*, query_builder=None, instance=None, cls=None):
|
|
38
|
+
if query_builder is not None:
|
|
39
|
+
return _get_db_from_query(query_builder)
|
|
40
|
+
if instance is not None:
|
|
41
|
+
return _get_db_from_instance(instance)
|
|
42
|
+
if cls is not None:
|
|
43
|
+
return _get_db_from_class(cls)
|
|
44
|
+
raise RuntimeError("Missing context for resolving database target.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _run_with_session(
|
|
48
|
+
*,
|
|
49
|
+
query_builder=None,
|
|
50
|
+
instance=None,
|
|
51
|
+
cls=None,
|
|
52
|
+
transactional: bool = False,
|
|
53
|
+
work_sync,
|
|
54
|
+
work_async,
|
|
55
|
+
):
|
|
56
|
+
active_session = active_session_var.get(None)
|
|
57
|
+
|
|
58
|
+
if is_async_context():
|
|
59
|
+
|
|
60
|
+
async def _execute_async():
|
|
61
|
+
if active_session is not None:
|
|
62
|
+
return await work_async(active_session)
|
|
63
|
+
db = _resolve_db_target(query_builder=query_builder, instance=instance, cls=cls)
|
|
64
|
+
async with db.async_session_factory() as session:
|
|
65
|
+
if transactional:
|
|
66
|
+
async with session.begin():
|
|
67
|
+
return await work_async(session)
|
|
68
|
+
return await work_async(session)
|
|
69
|
+
|
|
70
|
+
return _execute_async()
|
|
71
|
+
|
|
72
|
+
if active_session is not None:
|
|
73
|
+
return work_sync(active_session)
|
|
74
|
+
|
|
75
|
+
db = _resolve_db_target(query_builder=query_builder, instance=instance, cls=cls)
|
|
76
|
+
with db.sync_session_factory() as session:
|
|
77
|
+
if transactional:
|
|
78
|
+
with session.begin():
|
|
79
|
+
return work_sync(session)
|
|
80
|
+
return work_sync(session)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- READ OPERATIONS ---
|
|
84
|
+
|
|
85
|
+
def _first(query_builder):
|
|
86
|
+
"""Handles fetching the first record."""
|
|
87
|
+
stmt = query_builder._statement
|
|
88
|
+
|
|
89
|
+
def _sync(session):
|
|
90
|
+
result = session.execute(stmt)
|
|
91
|
+
return result.unique().scalars().first()
|
|
92
|
+
|
|
93
|
+
async def _async(session):
|
|
94
|
+
result = await session.execute(stmt)
|
|
95
|
+
return result.unique().scalars().first()
|
|
96
|
+
|
|
97
|
+
return _run_with_session(
|
|
98
|
+
query_builder=query_builder,
|
|
99
|
+
work_sync=_sync,
|
|
100
|
+
work_async=_async,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _all(query_builder):
|
|
104
|
+
"""Handles fetching all records."""
|
|
105
|
+
stmt = query_builder._statement
|
|
106
|
+
|
|
107
|
+
def _sync(session):
|
|
108
|
+
result = session.execute(stmt)
|
|
109
|
+
return result.unique().scalars().all()
|
|
110
|
+
|
|
111
|
+
async def _async(session):
|
|
112
|
+
result = await session.execute(stmt)
|
|
113
|
+
return result.unique().scalars().all()
|
|
114
|
+
|
|
115
|
+
return _run_with_session(
|
|
116
|
+
query_builder=query_builder,
|
|
117
|
+
work_sync=_sync,
|
|
118
|
+
work_async=_async,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _count(query_builder):
|
|
122
|
+
"""Handles counting the number of records for a query."""
|
|
123
|
+
count_stmt = func.count().select().select_from(query_builder._statement.alias("subquery"))
|
|
124
|
+
|
|
125
|
+
def _sync(session):
|
|
126
|
+
result = session.execute(count_stmt)
|
|
127
|
+
return result.scalar_one()
|
|
128
|
+
|
|
129
|
+
async def _async(session):
|
|
130
|
+
result = await session.execute(count_stmt)
|
|
131
|
+
return result.scalar_one()
|
|
132
|
+
|
|
133
|
+
return _run_with_session(
|
|
134
|
+
query_builder=query_builder,
|
|
135
|
+
work_sync=_sync,
|
|
136
|
+
work_async=_async,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _one(query_builder):
|
|
140
|
+
"""Fetches exactly one record, raising if zero or multiple are found."""
|
|
141
|
+
stmt = query_builder._statement.limit(2)
|
|
142
|
+
|
|
143
|
+
def _handle_rows(rows):
|
|
144
|
+
if not rows:
|
|
145
|
+
raise ObjectNotFoundError(f"No {query_builder._model_cls.__name__} matches the query.")
|
|
146
|
+
if len(rows) > 1:
|
|
147
|
+
raise MultipleObjectsFoundError(
|
|
148
|
+
f"Multiple {query_builder._model_cls.__name__} instances match the query."
|
|
149
|
+
)
|
|
150
|
+
return rows[0]
|
|
151
|
+
|
|
152
|
+
def _sync(session):
|
|
153
|
+
result = session.execute(stmt)
|
|
154
|
+
rows = result.unique().scalars().all()
|
|
155
|
+
return _handle_rows(rows)
|
|
156
|
+
|
|
157
|
+
async def _async(session):
|
|
158
|
+
result = await session.execute(stmt)
|
|
159
|
+
rows = result.unique().scalars().all()
|
|
160
|
+
return _handle_rows(rows)
|
|
161
|
+
|
|
162
|
+
return _run_with_session(
|
|
163
|
+
query_builder=query_builder,
|
|
164
|
+
work_sync=_sync,
|
|
165
|
+
work_async=_async,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _exists(query_builder):
|
|
169
|
+
"""Returns True if the query matches at least one record."""
|
|
170
|
+
exists_expr = query_builder._statement.exists()
|
|
171
|
+
# SQL Server (and some dialects) do not support `SELECT EXISTS (...)` directly.
|
|
172
|
+
# Use `SELECT 1 WHERE EXISTS (...)` which is portable.
|
|
173
|
+
exists_stmt = select(literal(True)).where(exists_expr).limit(1)
|
|
174
|
+
def _sync(session):
|
|
175
|
+
result = session.execute(exists_stmt)
|
|
176
|
+
return bool(result.scalar())
|
|
177
|
+
|
|
178
|
+
async def _async(session):
|
|
179
|
+
result = await session.execute(exists_stmt)
|
|
180
|
+
return bool(result.scalar())
|
|
181
|
+
|
|
182
|
+
return _run_with_session(
|
|
183
|
+
query_builder=query_builder,
|
|
184
|
+
work_sync=_sync,
|
|
185
|
+
work_async=_async,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --- WRITE OPERATIONS (Refactored) ---
|
|
190
|
+
|
|
191
|
+
def _save(instance):
|
|
192
|
+
"""Handles saving (INSERT/UPDATE) a model instance."""
|
|
193
|
+
def _sync(session):
|
|
194
|
+
session.add(instance)
|
|
195
|
+
session.flush()
|
|
196
|
+
|
|
197
|
+
async def _async(session):
|
|
198
|
+
session.add(instance)
|
|
199
|
+
await session.flush()
|
|
200
|
+
|
|
201
|
+
return _run_with_session(
|
|
202
|
+
instance=instance,
|
|
203
|
+
transactional=True,
|
|
204
|
+
work_sync=_sync,
|
|
205
|
+
work_async=_async,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _delete_instance(instance):
|
|
209
|
+
"""Handles deleting a model instance."""
|
|
210
|
+
def _sync(session):
|
|
211
|
+
session.delete(instance)
|
|
212
|
+
session.flush()
|
|
213
|
+
|
|
214
|
+
async def _async(session):
|
|
215
|
+
session.delete(instance)
|
|
216
|
+
await session.flush()
|
|
217
|
+
|
|
218
|
+
return _run_with_session(
|
|
219
|
+
instance=instance,
|
|
220
|
+
transactional=True,
|
|
221
|
+
work_sync=_sync,
|
|
222
|
+
work_async=_async,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _bulk_create(cls, instances):
|
|
226
|
+
"""Handles bulk creating model instances."""
|
|
227
|
+
def _sync(session):
|
|
228
|
+
session.add_all(instances)
|
|
229
|
+
session.flush()
|
|
230
|
+
|
|
231
|
+
async def _async(session):
|
|
232
|
+
session.add_all(instances)
|
|
233
|
+
await session.flush()
|
|
234
|
+
|
|
235
|
+
return _run_with_session(
|
|
236
|
+
cls=cls,
|
|
237
|
+
transactional=True,
|
|
238
|
+
work_sync=_sync,
|
|
239
|
+
work_async=_async,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _update(query_builder, **values):
|
|
243
|
+
"""Handles bulk updates for a query."""
|
|
244
|
+
update_stmt = sa_update(query_builder._model_cls).values(**values)
|
|
245
|
+
where_clause = query_builder._statement.whereclause
|
|
246
|
+
if where_clause is not None:
|
|
247
|
+
update_stmt = update_stmt.where(where_clause)
|
|
248
|
+
def _sync(session):
|
|
249
|
+
session.execute(update_stmt)
|
|
250
|
+
session.flush()
|
|
251
|
+
|
|
252
|
+
async def _async(session):
|
|
253
|
+
await session.execute(update_stmt)
|
|
254
|
+
await session.flush()
|
|
255
|
+
|
|
256
|
+
return _run_with_session(
|
|
257
|
+
query_builder=query_builder,
|
|
258
|
+
transactional=True,
|
|
259
|
+
work_sync=_sync,
|
|
260
|
+
work_async=_async,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _delete(query_builder):
|
|
264
|
+
"""Handles bulk deletes for a query."""
|
|
265
|
+
delete_stmt = sa_delete(query_builder._model_cls)
|
|
266
|
+
where_clause = query_builder._statement.whereclause
|
|
267
|
+
if where_clause is not None:
|
|
268
|
+
delete_stmt = delete_stmt.where(where_clause)
|
|
269
|
+
def _sync(session):
|
|
270
|
+
session.execute(delete_stmt)
|
|
271
|
+
session.flush()
|
|
272
|
+
|
|
273
|
+
async def _async(session):
|
|
274
|
+
await session.execute(delete_stmt)
|
|
275
|
+
await session.flush()
|
|
276
|
+
|
|
277
|
+
return _run_with_session(
|
|
278
|
+
query_builder=query_builder,
|
|
279
|
+
transactional=True,
|
|
280
|
+
work_sync=_sync,
|
|
281
|
+
work_async=_async,
|
|
282
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This file makes the 'migrations' directory a Python sub-package.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# duo_orm/migrations/cli.py
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from alembic import command
|
|
5
|
+
from alembic.util import CommandError
|
|
6
|
+
from .config import (
|
|
7
|
+
_get_config,
|
|
8
|
+
_generate_files,
|
|
9
|
+
_resolve_layout,
|
|
10
|
+
_persist_pyproject_config,
|
|
11
|
+
get_alembic_config,
|
|
12
|
+
DB_OBJECT_NAME,
|
|
13
|
+
load_template,
|
|
14
|
+
)
|
|
15
|
+
from duo_orm.exceptions import ConfigurationError
|
|
16
|
+
|
|
17
|
+
# 1. Main CLI group
|
|
18
|
+
@click.group()
|
|
19
|
+
def cli():
|
|
20
|
+
"""The main entry point for the duo-orm CLI."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
# 2. New top-level 'init' command
|
|
24
|
+
@cli.command()
|
|
25
|
+
@click.option(
|
|
26
|
+
"--dir",
|
|
27
|
+
"orm_dir",
|
|
28
|
+
default=None,
|
|
29
|
+
help="Override the target directory for database/migrations/models (defaults to db/).",
|
|
30
|
+
)
|
|
31
|
+
def init(orm_dir):
|
|
32
|
+
"""Initializes a complete, opinionated database structure."""
|
|
33
|
+
click.echo("-> Initializing complete database structure...")
|
|
34
|
+
try:
|
|
35
|
+
project_root, config = _get_config(override_dir=orm_dir)
|
|
36
|
+
relative_dir = config["duo_orm_dir"]
|
|
37
|
+
base_dir, migrations_dir, module_path = _resolve_layout(project_root, relative_dir)
|
|
38
|
+
|
|
39
|
+
# --- Scaffolding logic ---
|
|
40
|
+
|
|
41
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
# Ensure parent directories are packages
|
|
44
|
+
if base_dir != project_root:
|
|
45
|
+
for p in base_dir.parents:
|
|
46
|
+
if p == project_root:
|
|
47
|
+
break
|
|
48
|
+
(p / "__init__.py").touch(exist_ok=True)
|
|
49
|
+
|
|
50
|
+
(base_dir / "__init__.py").touch(exist_ok=True)
|
|
51
|
+
|
|
52
|
+
# A. Scaffold migrations environment
|
|
53
|
+
if not migrations_dir.exists():
|
|
54
|
+
_generate_files(base_dir, module_path, DB_OBJECT_NAME, config["version_table"])
|
|
55
|
+
click.secho(f"✅ Created migration environment at: {migrations_dir}", fg="green")
|
|
56
|
+
else:
|
|
57
|
+
click.secho(f"Migration environment at {migrations_dir} already exists. Skipping.", fg="yellow")
|
|
58
|
+
|
|
59
|
+
# B. Scaffold database.py and models directory
|
|
60
|
+
db_file_path = base_dir / "database.py"
|
|
61
|
+
if not db_file_path.exists():
|
|
62
|
+
template = load_template("database.py.tpl")
|
|
63
|
+
db_file_path.write_text(template)
|
|
64
|
+
click.secho(f"✅ Created database entrypoint at: {db_file_path}", fg="green")
|
|
65
|
+
else:
|
|
66
|
+
click.secho(f"Database entrypoint at {db_file_path} already exists. Skipping.", fg="yellow")
|
|
67
|
+
|
|
68
|
+
models_dir = db_file_path.parent / "models"
|
|
69
|
+
models_dir.mkdir(exist_ok=True)
|
|
70
|
+
(models_dir / "__init__.py").touch(exist_ok=True)
|
|
71
|
+
click.secho(f"✅ Ensured models directory exists at: {models_dir}", fg="green")
|
|
72
|
+
|
|
73
|
+
# Persist the resolved directory so future commands can infer it without flags.
|
|
74
|
+
_persist_pyproject_config(project_root, relative_dir, config.get("version_table"))
|
|
75
|
+
|
|
76
|
+
click.secho("\nProject initialization complete!", fg="green")
|
|
77
|
+
click.echo("Next steps:")
|
|
78
|
+
click.echo(f"1. Define your models in the '{models_dir}' directory.")
|
|
79
|
+
click.echo("2. Run 'duo-orm migration create \"initial models\"' to create your first migration.")
|
|
80
|
+
|
|
81
|
+
except Exception as e:
|
|
82
|
+
raise click.ClickException(f"Error during initialization: {e}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# 3. 'migration' subcommand group
|
|
86
|
+
@cli.group(name="migration")
|
|
87
|
+
def migration_group():
|
|
88
|
+
"""Manages database schema migrations."""
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
# 4. Add existing commands to the 'migration' group
|
|
92
|
+
@migration_group.command()
|
|
93
|
+
@click.argument("message")
|
|
94
|
+
@click.option(
|
|
95
|
+
"--dir",
|
|
96
|
+
"orm_dir",
|
|
97
|
+
default=None,
|
|
98
|
+
help="Override the target directory (defaults to db/ or pyproject config).",
|
|
99
|
+
)
|
|
100
|
+
def create(message, orm_dir):
|
|
101
|
+
"""Creates a new migration file with autogenerated changes."""
|
|
102
|
+
click.echo("-> Creating new migration...")
|
|
103
|
+
try:
|
|
104
|
+
alembic_cfg = get_alembic_config(override_dir=orm_dir)
|
|
105
|
+
command.revision(alembic_cfg, message=message, autogenerate=True)
|
|
106
|
+
click.secho("✅ New migration file created.", fg="green")
|
|
107
|
+
except (CommandError, ConfigurationError) as e:
|
|
108
|
+
raise click.ClickException(f"Error creating migration: {e}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@migration_group.command()
|
|
112
|
+
@click.argument("revision", default="head")
|
|
113
|
+
@click.option(
|
|
114
|
+
"--dir",
|
|
115
|
+
"orm_dir",
|
|
116
|
+
default=None,
|
|
117
|
+
help="Override the target directory (defaults to db/ or pyproject config).",
|
|
118
|
+
)
|
|
119
|
+
def upgrade(revision, orm_dir):
|
|
120
|
+
"""Applies migrations to the database."""
|
|
121
|
+
click.echo(f"-> Applying migrations to revision: {revision}")
|
|
122
|
+
try:
|
|
123
|
+
alembic_cfg = get_alembic_config(override_dir=orm_dir)
|
|
124
|
+
command.upgrade(alembic_cfg, revision)
|
|
125
|
+
click.secho("✅ Migrations applied successfully.", fg="green")
|
|
126
|
+
except (CommandError, ConfigurationError) as e:
|
|
127
|
+
raise click.ClickException(f"Error applying migrations: {e}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@migration_group.command(context_settings={"ignore_unknown_options": True, "allow_extra_args": True})
|
|
131
|
+
@click.argument("revision", default=None, required=False)
|
|
132
|
+
@click.option(
|
|
133
|
+
"--dir",
|
|
134
|
+
"orm_dir",
|
|
135
|
+
default=None,
|
|
136
|
+
help="Override the target directory (defaults to db/ or pyproject config).",
|
|
137
|
+
)
|
|
138
|
+
@click.pass_context
|
|
139
|
+
def downgrade(ctx, revision, orm_dir):
|
|
140
|
+
"""Reverts migrations."""
|
|
141
|
+
revision = revision or (ctx.args[0] if ctx.args else "-1")
|
|
142
|
+
click.echo(f"-> Downgrading migrations to revision: {revision}")
|
|
143
|
+
try:
|
|
144
|
+
alembic_cfg = get_alembic_config(override_dir=orm_dir)
|
|
145
|
+
command.downgrade(alembic_cfg, revision)
|
|
146
|
+
click.secho("✅ Migrations reverted successfully.", fg="green")
|
|
147
|
+
except (CommandError, ConfigurationError) as e:
|
|
148
|
+
raise click.ClickException(f"Error reverting migrations: {e}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@migration_group.command()
|
|
152
|
+
@click.option(
|
|
153
|
+
"--dir",
|
|
154
|
+
"orm_dir",
|
|
155
|
+
default=None,
|
|
156
|
+
help="Override the target directory (defaults to db/ or pyproject config).",
|
|
157
|
+
)
|
|
158
|
+
def history(orm_dir):
|
|
159
|
+
"""Shows the migration history."""
|
|
160
|
+
try:
|
|
161
|
+
alembic_cfg = get_alembic_config(override_dir=orm_dir)
|
|
162
|
+
command.history(alembic_cfg)
|
|
163
|
+
except (CommandError, ConfigurationError) as e:
|
|
164
|
+
raise click.ClickException(f"Error showing history: {e}")
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# duo_orm/migrations/config.py
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import importlib.resources
|
|
5
|
+
import sys
|
|
6
|
+
from importlib.util import module_from_spec, spec_from_file_location
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Tuple, Optional
|
|
10
|
+
|
|
11
|
+
import toml
|
|
12
|
+
from alembic.config import Config
|
|
13
|
+
|
|
14
|
+
from duo_orm.exceptions import ConfigurationError
|
|
15
|
+
from duo_orm.db import Database
|
|
16
|
+
|
|
17
|
+
DEFAULT_ORM_DIR = "db"
|
|
18
|
+
DB_OBJECT_NAME = "db"
|
|
19
|
+
DEFAULT_VERSION_TABLE = "alembic_version"
|
|
20
|
+
_ENV_VERSION_TABLE_REGEX = re.compile(
|
|
21
|
+
r'version_table\s*=\s*config\.get_main_option\("version_table",\s*"(?P<name>[A-Za-z_][A-Za-z0-9_]*)"\)'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_project_root() -> Path:
|
|
26
|
+
"""Finds the project root by looking for pyproject.toml."""
|
|
27
|
+
current_dir = Path.cwd()
|
|
28
|
+
for parent in [current_dir] + list(current_dir.parents):
|
|
29
|
+
if (parent / "pyproject.toml").exists():
|
|
30
|
+
return parent
|
|
31
|
+
raise FileNotFoundError("Could not find project root (pyproject.toml).")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize_dir(value: str | None) -> str:
|
|
35
|
+
raw = (value or DEFAULT_ORM_DIR).strip()
|
|
36
|
+
if not raw or raw == ".":
|
|
37
|
+
raw = DEFAULT_ORM_DIR
|
|
38
|
+
rel_path = Path(raw)
|
|
39
|
+
if rel_path.is_absolute():
|
|
40
|
+
raise ConfigurationError("duo_orm_dir must be a relative path within the project.")
|
|
41
|
+
parts = [part for part in rel_path.parts if part not in ("", ".")]
|
|
42
|
+
if not parts:
|
|
43
|
+
parts = [DEFAULT_ORM_DIR]
|
|
44
|
+
for part in parts:
|
|
45
|
+
if not part.isidentifier():
|
|
46
|
+
raise ConfigurationError(
|
|
47
|
+
f"Invalid path segment '{part}' in duo_orm_dir. Use valid Python identifiers."
|
|
48
|
+
)
|
|
49
|
+
return "/".join(parts)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _slugify_repo_name(path: Path) -> str:
|
|
53
|
+
"""Convert a repository directory name to a snake_case-ish slug."""
|
|
54
|
+
name = path.name
|
|
55
|
+
slug = re.sub(r"[^0-9A-Za-z]+", "_", name).strip("_").lower()
|
|
56
|
+
return slug or "duo_orm"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _normalize_version_table(value: Optional[str], project_root: Path) -> str:
|
|
60
|
+
raw = (value or "").strip()
|
|
61
|
+
if not raw:
|
|
62
|
+
return DEFAULT_VERSION_TABLE
|
|
63
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", raw):
|
|
64
|
+
raise ConfigurationError(
|
|
65
|
+
"version_table must be a valid SQL identifier (letters, digits, underscore, not starting with a digit)."
|
|
66
|
+
)
|
|
67
|
+
return raw
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _detect_version_table_from_env(migrations_dir: Path) -> Optional[str]:
|
|
71
|
+
env_path = migrations_dir / "env.py"
|
|
72
|
+
if not env_path.exists():
|
|
73
|
+
return None
|
|
74
|
+
match = _ENV_VERSION_TABLE_REGEX.search(env_path.read_text())
|
|
75
|
+
if match:
|
|
76
|
+
return match.group("name")
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resolve_layout(project_root: Path, relative_dir: str) -> Tuple[Path, Path, str]:
|
|
81
|
+
rel_path = Path(relative_dir)
|
|
82
|
+
base_dir = (project_root / rel_path).resolve()
|
|
83
|
+
module_path = relative_dir.replace("/", ".")
|
|
84
|
+
migrations_dir = base_dir / "migrations"
|
|
85
|
+
return base_dir, migrations_dir, module_path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_config(found_root: Optional[Path] = None, override_dir: Optional[str] = None) -> Tuple[Path, Dict[str, Any]]:
|
|
89
|
+
"""
|
|
90
|
+
Finds and parses the user's configuration from pyproject.toml, with optional
|
|
91
|
+
overrides from CLI flags/env vars.
|
|
92
|
+
"""
|
|
93
|
+
root = found_root
|
|
94
|
+
raw_dir = override_dir
|
|
95
|
+
if root is None:
|
|
96
|
+
try:
|
|
97
|
+
root = _get_project_root()
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
root = Path.cwd()
|
|
100
|
+
pyproject_path = root / "pyproject.toml"
|
|
101
|
+
orm_config: Dict[str, Any] = {}
|
|
102
|
+
if pyproject_path.exists():
|
|
103
|
+
config_data = toml.load(pyproject_path)
|
|
104
|
+
orm_config = config_data.get("tool", {}).get("duo-orm") or {}
|
|
105
|
+
if raw_dir is None:
|
|
106
|
+
raw_dir = orm_config.get("duo_orm_dir")
|
|
107
|
+
normalized_dir = _normalize_dir(raw_dir)
|
|
108
|
+
orm_config["duo_orm_dir"] = normalized_dir
|
|
109
|
+
orm_config["version_table"] = _normalize_version_table(orm_config.get("version_table"), root)
|
|
110
|
+
return root, orm_config
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _persist_pyproject_config(project_root: Path, relative_dir: str, version_table: Optional[str] = None):
|
|
114
|
+
pyproject_path = project_root / "pyproject.toml"
|
|
115
|
+
data: Dict[str, Any] = {}
|
|
116
|
+
if pyproject_path.exists():
|
|
117
|
+
data = toml.load(pyproject_path)
|
|
118
|
+
tool_section = data.setdefault("tool", {})
|
|
119
|
+
orm_section = tool_section.setdefault("duo-orm", {})
|
|
120
|
+
orm_section["duo_orm_dir"] = relative_dir
|
|
121
|
+
if version_table:
|
|
122
|
+
orm_section["version_table"] = version_table
|
|
123
|
+
pyproject_path.write_text(toml.dumps(data))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_template(filename: str) -> str:
|
|
127
|
+
template_path = importlib.resources.files("duo_orm.migrations").joinpath("templates", filename)
|
|
128
|
+
return template_path.read_text()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _generate_files(base_dir: Path, module_path: str, db_object_name: str, version_table: str):
|
|
132
|
+
"""Creates the directories and files for the migration environment."""
|
|
133
|
+
migrations_dir = base_dir / "migrations"
|
|
134
|
+
versions_dir = migrations_dir / "versions"
|
|
135
|
+
versions_dir.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
|
|
137
|
+
# 1. Read our internal templates
|
|
138
|
+
template_path = importlib.resources.files("duo_orm.migrations").joinpath(
|
|
139
|
+
"templates"
|
|
140
|
+
)
|
|
141
|
+
ini_template = (template_path / "alembic.ini.tpl").read_text()
|
|
142
|
+
env_template = (template_path / "env.py.tpl").read_text()
|
|
143
|
+
|
|
144
|
+
# 2. Write the managed alembic.ini
|
|
145
|
+
(migrations_dir / "alembic.ini").write_text(ini_template)
|
|
146
|
+
|
|
147
|
+
# 3. Customize and write the smart env.py
|
|
148
|
+
env_py_content = env_template.format(
|
|
149
|
+
db_object_module=f"{module_path}.database",
|
|
150
|
+
db_object_name=db_object_name,
|
|
151
|
+
version_table=version_table,
|
|
152
|
+
)
|
|
153
|
+
(migrations_dir / "env.py").write_text(env_py_content)
|
|
154
|
+
|
|
155
|
+
# 4. Copy the script.py.mako template from the installed Alembic package
|
|
156
|
+
try:
|
|
157
|
+
mako_content = (
|
|
158
|
+
importlib.resources.files("alembic")
|
|
159
|
+
.joinpath("templates/generic/script.py.mako")
|
|
160
|
+
.read_text()
|
|
161
|
+
)
|
|
162
|
+
(migrations_dir / "script.py.mako").write_text(mako_content)
|
|
163
|
+
except (ImportError, AttributeError):
|
|
164
|
+
# This is a fallback and should rarely happen.
|
|
165
|
+
raise RuntimeError("Could not locate the alembic package to copy templates.")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_alembic_config(override_dir: Optional[str] = None) -> Config:
|
|
169
|
+
"""
|
|
170
|
+
Creates a complete Alembic Config object programmatically.
|
|
171
|
+
This is used by all CLI commands to run Alembic.
|
|
172
|
+
"""
|
|
173
|
+
project_root, user_config = _get_config(override_dir=override_dir)
|
|
174
|
+
base_dir, migrations_dir, module_path = _resolve_layout(project_root, user_config["duo_orm_dir"])
|
|
175
|
+
|
|
176
|
+
# If the user didn't specify a version_table, try to infer the default from an existing env.py
|
|
177
|
+
if user_config.get("version_table") == DEFAULT_VERSION_TABLE:
|
|
178
|
+
detected = _detect_version_table_from_env(migrations_dir)
|
|
179
|
+
if detected:
|
|
180
|
+
user_config["version_table"] = detected
|
|
181
|
+
|
|
182
|
+
# This is the path to the INI file inside the user's project
|
|
183
|
+
config_file_path = str(migrations_dir / "alembic.ini")
|
|
184
|
+
|
|
185
|
+
# Ensure project root is importable so db package can be found
|
|
186
|
+
project_root_str = str(project_root)
|
|
187
|
+
if project_root_str not in sys.path:
|
|
188
|
+
sys.path.insert(0, project_root_str)
|
|
189
|
+
|
|
190
|
+
# Alembic's Config object reads the INI file as a base
|
|
191
|
+
config = Config(config_file_path)
|
|
192
|
+
|
|
193
|
+
# Dynamically import the user's configured database object (fresh each time)
|
|
194
|
+
db_object = None
|
|
195
|
+
try:
|
|
196
|
+
module_name = f"{module_path}.database"
|
|
197
|
+
importlib.invalidate_caches()
|
|
198
|
+
if module_name in sys.modules:
|
|
199
|
+
sys.modules.pop(module_name)
|
|
200
|
+
module = importlib.import_module(module_name)
|
|
201
|
+
sys.modules[module_name] = module
|
|
202
|
+
db_object = getattr(module, DB_OBJECT_NAME)
|
|
203
|
+
except (ImportError, AttributeError):
|
|
204
|
+
db_file = base_dir / "database.py"
|
|
205
|
+
module_name = f"{module_path}.database"
|
|
206
|
+
spec = spec_from_file_location(module_name, db_file)
|
|
207
|
+
if spec and spec.loader:
|
|
208
|
+
module = module_from_spec(spec)
|
|
209
|
+
sys.modules[module_name] = module
|
|
210
|
+
spec.loader.exec_module(module) # type: ignore[attr-defined]
|
|
211
|
+
db_object = getattr(module, DB_OBJECT_NAME, None)
|
|
212
|
+
|
|
213
|
+
if not isinstance(db_object, Database):
|
|
214
|
+
raise ConfigurationError(
|
|
215
|
+
f"Could not import Database instance from '{module_path}.database'."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if not isinstance(db_object, Database):
|
|
219
|
+
raise ConfigurationError(
|
|
220
|
+
f"The object '{DB_OBJECT_NAME}' in '{module_path}.database' is not a duo_orm.Database instance."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Programmatically set the URL and metadata for Alembic's environment
|
|
224
|
+
config.set_main_option("sqlalchemy.url", str(db_object.url))
|
|
225
|
+
config.set_main_option("version_table", user_config["version_table"])
|
|
226
|
+
config.attributes["target_metadata"] = db_object.metadata
|
|
227
|
+
|
|
228
|
+
return config
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
__all__ = [
|
|
232
|
+
"_get_config",
|
|
233
|
+
"_generate_files",
|
|
234
|
+
"_resolve_layout",
|
|
235
|
+
"_persist_pyproject_config",
|
|
236
|
+
"get_alembic_config",
|
|
237
|
+
"DEFAULT_ORM_DIR",
|
|
238
|
+
"DB_OBJECT_NAME",
|
|
239
|
+
]
|