diff --git a/framework/db/simple_module_db/migrations.py b/framework/db/simple_module_db/migrations.py index 951ba9a5..b96b2a99 100644 --- a/framework/db/simple_module_db/migrations.py +++ b/framework/db/simple_module_db/migrations.py @@ -66,7 +66,11 @@ def build_module_metadata(modules: Sequence[ModuleBase] | None = None) -> MetaDa return combined -def make_include_object(metadata: MetaData) -> IncludeObjectFn: +def make_include_object( + metadata: MetaData, + *, + ignore_unmodeled_fks: bool = True, +) -> IncludeObjectFn: """Return an Alembic ``include_object`` filter scoped to the module tables. Call as ``context.configure(..., include_object=make_include_object(meta))``. @@ -74,6 +78,15 @@ def make_include_object(metadata: MetaData) -> IncludeObjectFn: autogenerate from diffing — and potentially dropping — tables that exist in the database but aren't owned by any installed module (e.g. a user table added by the host developer outside the module system). + + :param ignore_unmodeled_fks: When ``True`` (default), foreign-key + constraints that exist in the live DB but are absent from the SQLModel + metadata are skipped instead of being emitted as ``op.drop_constraint`` + in the autogenerated migration. The framework's recommended pattern + declares cross-module FKs at the migration level only (no Python-level + relationship between modules), so those constraints look "unmodeled" to + Alembic on every autogen run and would otherwise be silently dropped. + Set to ``False`` to recover the prior, drop-on-sight behaviour. """ allowlist = {t.name for t in metadata.tables.values()} @@ -86,6 +99,8 @@ def include_object( ) -> bool: if type_ == "table": return name in allowlist + if ignore_unmodeled_fks and type_ == "foreign_key_constraint" and compare_to is None: + return False parent_table = getattr(object, "table", None) if parent_table is not None: return parent_table.name in allowlist diff --git a/framework/db/tests/test_migrations.py b/framework/db/tests/test_migrations.py index cfd4e176..1295fdc0 100644 --- a/framework/db/tests/test_migrations.py +++ b/framework/db/tests/test_migrations.py @@ -54,3 +54,42 @@ async def test_include_object_allowlist_filters_unknown_tables(self): "unrelated_host_table", MetaData(), Column("id", Integer, primary_key=True) ) assert include(stranger, "unrelated_host_table", "table", False, None) is False + + async def test_include_object_skips_unmodeled_cross_module_fks_by_default(self): + """Cross-module FKs declared at the migration level only (no SQLModel + relationship) appear in the live DB but never in target metadata. + Alembic passes ``compare_to=None`` for live-only constraints and would + emit ``op.drop_constraint``; the default filter must drop those + constraint-level diffs to avoid destroying real FKs on every autogen. + """ + from simple_module_db.migrations import build_module_metadata, make_include_object + from sqlalchemy import Column, ForeignKeyConstraint, Integer, MetaData, Table + + metadata = build_module_metadata() + include = make_include_object(metadata) + strict = make_include_object(metadata, ignore_unmodeled_fks=False) + + known = next(iter(metadata.tables.values())) + scratch = MetaData() + local = Table(known.name, scratch, Column("id", Integer, primary_key=True)) + fk = ForeignKeyConstraint([local.c.id], ["other_module.other.id"], name="fk_xmod") + local.append_constraint(fk) + + assert include(fk, "fk_xmod", "foreign_key_constraint", True, None) is False + assert strict(fk, "fk_xmod", "foreign_key_constraint", True, None) is True + + stranger_meta = MetaData() + stranger = Table( + "totally_unknown_table", + stranger_meta, + Column("id", Integer, primary_key=True), + Column("ref", Integer), + ) + stranger_fk = ForeignKeyConstraint( + [stranger.c.ref], ["something.else.id"], name="fk_stranger" + ) + stranger.append_constraint(stranger_fk) + assert ( + include(stranger_fk, "fk_stranger", "foreign_key_constraint", False, stranger_fk) + is False + ) diff --git a/framework/hosting/pyproject.toml b/framework/hosting/pyproject.toml index bd71d15f..d793cdd4 100644 --- a/framework/hosting/pyproject.toml +++ b/framework/hosting/pyproject.toml @@ -33,6 +33,9 @@ dependencies = [ "uvicorn[standard]>=0.34", ] +[project.scripts] +sm-host = "simple_module_hosting.host_cli:app" + [project.entry-points."simple_module_cli.cli_plugins"] host = "simple_module_hosting.host_cli:app" diff --git a/framework/hosting/simple_module_hosting/__main__.py b/framework/hosting/simple_module_hosting/__main__.py new file mode 100644 index 00000000..ee048ef7 --- /dev/null +++ b/framework/hosting/simple_module_hosting/__main__.py @@ -0,0 +1,14 @@ +"""Module entry point: ``python -m simple_module_hosting`` invokes the host CLI. + +Without this, ``python -m simple_module_hosting.host_cli`` would import the +module without running the Typer app — silently no-op'ing commands like +``gen-pages``. Provides the same Typer ``app`` callable that the +``simple_module_cli.cli_plugins`` entry point exposes as ``sm host``. +""" + +from __future__ import annotations + +from simple_module_hosting.host_cli import app + +if __name__ == "__main__": + app() diff --git a/framework/hosting/simple_module_hosting/host_cli.py b/framework/hosting/simple_module_hosting/host_cli.py index b15086de..e3f49c72 100644 --- a/framework/hosting/simple_module_hosting/host_cli.py +++ b/framework/hosting/simple_module_hosting/host_cli.py @@ -116,3 +116,7 @@ def sync_js_deps( return result = subprocess.run(cmd, cwd=repo_root, check=False) raise typer.Exit(code=result.returncode) + + +if __name__ == "__main__": + app() diff --git a/framework/hosting/tests/test_host_cli.py b/framework/hosting/tests/test_host_cli.py index f5a8b7ac..984ac25e 100644 --- a/framework/hosting/tests/test_host_cli.py +++ b/framework/hosting/tests/test_host_cli.py @@ -2,8 +2,11 @@ from __future__ import annotations +import subprocess +import sys from pathlib import Path +import pytest import typer from simple_module_hosting.host_cli import app from typer.testing import CliRunner @@ -26,3 +29,22 @@ def test_gen_pages_errors_on_missing_client_app(tmp_path: Path) -> None: result = runner.invoke(app, ["gen-pages", "--host-dir", str(tmp_path / "does-not-exist")]) assert result.exit_code != 0 assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower() + + +@pytest.mark.parametrize( + "module_target", + ["simple_module_hosting", "simple_module_hosting.host_cli"], +) +def test_python_dash_m_invocation_runs_cli(module_target: str) -> None: + """Both ``python -m simple_module_hosting`` and ``...host_cli`` must invoke + the Typer app — without ``__main__.py`` / a ``__name__ == "__main__"`` block + these silently no-op'd, breaking the documented ``gen-pages`` workflow. + """ + result = subprocess.run( + [sys.executable, "-m", module_target, "--help"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert "gen-pages" in result.stdout