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/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
+ ]