simple-module-db 0.0.2__tar.gz → 0.0.4__tar.gz

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.
Files changed (21) hide show
  1. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/.gitignore +4 -0
  2. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/PKG-INFO +2 -2
  3. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/pyproject.toml +2 -2
  4. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/base.py +28 -6
  5. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/migrations.py +55 -12
  6. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/mixins.py +29 -5
  7. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/session.py +9 -4
  8. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/test_db_logging.py +0 -46
  9. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/test_migrations.py +41 -2
  10. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/LICENSE +0 -0
  11. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/README.md +0 -0
  12. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/__init__.py +0 -0
  13. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/deps.py +0 -0
  14. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/listeners.py +0 -0
  15. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/provider.py +0 -0
  16. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/py.typed +0 -0
  17. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/_models.py +0 -0
  18. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/conftest.py +0 -0
  19. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/test_base.py +0 -0
  20. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/test_multi_tenancy.py +0 -0
  21. {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/test_session.py +0 -0
@@ -36,6 +36,10 @@ uploads/
36
36
  # Vite
37
37
  host/static/dist/
38
38
 
39
+ # VitePress (docs)
40
+ docs/.vitepress/cache/
41
+ docs/.vitepress/dist/
42
+
39
43
  # Auto-generated frontend module manifest (regenerated by the host at boot
40
44
  # or via `make gen-pages`).
41
45
  host/client_app/modules.manifest.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_db
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -24,7 +24,7 @@ Requires-Python: >=3.12
24
24
  Requires-Dist: aiosqlite>=0.20
25
25
  Requires-Dist: alembic>=1.14
26
26
  Requires-Dist: asyncpg>=0.30
27
- Requires-Dist: simple-module-core==0.0.2
27
+ Requires-Dist: simple-module-core==0.0.4
28
28
  Requires-Dist: sqlalchemy[asyncio]>=2.0
29
29
  Requires-Dist: sqlmodel>=0.0.22
30
30
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_db"
3
- version = "0.0.2"
3
+ version = "0.0.4"
4
4
  description = "Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,7 +24,7 @@ dependencies = [
24
24
  "aiosqlite>=0.20",
25
25
  "alembic>=1.14",
26
26
  "asyncpg>=0.30",
27
- "simple_module_core==0.0.2",
27
+ "simple_module_core==0.0.4",
28
28
  "sqlalchemy[asyncio]>=2.0",
29
29
  "sqlmodel>=0.0.22",
30
30
  ]
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
 
7
+ from simple_module_core.dotenv import env_bool
7
8
  from sqlalchemy import MetaData
8
9
  from sqlmodel import SQLModel
9
10
 
@@ -38,14 +39,35 @@ def _register_base(base: type[SQLModel]) -> None:
38
39
  all_module_bases.append(base)
39
40
 
40
41
 
41
- def _default_provider() -> DatabaseProvider:
42
- """Resolve the active provider from ``SM_DATABASE_URL`` at import time.
42
+ def _default_schema_policy() -> DatabaseProvider:
43
+ """Resolve the schema layout to register module tables under.
43
44
 
44
- Falls back to SQLite when the variable is unset, keeping the common
45
- dev-loop happy without requiring callers to plumb the provider through.
45
+ The :class:`DatabaseProvider` enum doubles as a *schema-layout*
46
+ selector here ``POSTGRESQL`` means "give every module its own
47
+ schema (``orders.<table>``)", ``SQLITE`` means "shared public schema,
48
+ name-prefixed tables (``orders_<table>``)". The conflation is
49
+ deliberate so existing call sites keep working, but conceptually this
50
+ is "schema policy", not "what DB are we connecting to": you can run
51
+ Postgres with ``SM_SCHEMA_PER_MODULE=false`` to keep a flat layout.
52
+
53
+ Resolution order:
54
+ 1. ``SM_SCHEMA_PER_MODULE`` (authoritative when set, decoupled from URL).
55
+ 2. ``SM_DATABASE_URL`` (legacy fallback so deployments that haven't
56
+ migrated to the explicit knob keep working).
57
+ 3. ``SQLITE`` (shared schema, the safe default).
46
58
  """
59
+ explicit = os.environ.get("SM_SCHEMA_PER_MODULE")
60
+ if explicit is not None:
61
+ return (
62
+ DatabaseProvider.POSTGRESQL
63
+ if env_bool("SM_SCHEMA_PER_MODULE")
64
+ else DatabaseProvider.SQLITE
65
+ )
66
+
47
67
  url = os.environ.get("SM_DATABASE_URL", "")
48
- return detect_provider(url) if url else DatabaseProvider.SQLITE
68
+ if url:
69
+ return detect_provider(url)
70
+ return DatabaseProvider.SQLITE
49
71
 
50
72
 
51
73
  def create_module_base(
@@ -67,7 +89,7 @@ def create_module_base(
67
89
  concrete table classes declare ``table=True`` and inherit from it.
68
90
  """
69
91
  if provider is None:
70
- provider = _default_provider()
92
+ provider = _default_schema_policy()
71
93
 
72
94
  cache_key = f"{module_name}:{provider}"
73
95
  if cache_key in _base_cache:
@@ -17,8 +17,10 @@ from __future__ import annotations
17
17
  import importlib
18
18
  import logging
19
19
  from collections.abc import Callable, Sequence
20
+ from enum import StrEnum
20
21
  from typing import Literal
21
22
 
23
+ import sqlalchemy as sa
22
24
  from simple_module_core import ModuleBase
23
25
  from simple_module_core.discovery import discover_modules, get_module_package_name
24
26
  from sqlalchemy import MetaData
@@ -65,7 +67,11 @@ def build_module_metadata(modules: Sequence[ModuleBase] | None = None) -> MetaDa
65
67
  return combined
66
68
 
67
69
 
68
- def make_include_object(metadata: MetaData) -> IncludeObjectFn:
70
+ def make_include_object(
71
+ metadata: MetaData,
72
+ *,
73
+ ignore_unmodeled_fks: bool = True,
74
+ ) -> IncludeObjectFn:
69
75
  """Return an Alembic ``include_object`` filter scoped to the module tables.
70
76
 
71
77
  Call as ``context.configure(..., include_object=make_include_object(meta))``.
@@ -73,6 +79,15 @@ def make_include_object(metadata: MetaData) -> IncludeObjectFn:
73
79
  autogenerate from diffing — and potentially dropping — tables that exist
74
80
  in the database but aren't owned by any installed module (e.g. a user
75
81
  table added by the host developer outside the module system).
82
+
83
+ :param ignore_unmodeled_fks: When ``True`` (default), foreign-key
84
+ constraints that exist in the live DB but are absent from the SQLModel
85
+ metadata are skipped instead of being emitted as ``op.drop_constraint``
86
+ in the autogenerated migration. The framework's recommended pattern
87
+ declares cross-module FKs at the migration level only (no Python-level
88
+ relationship between modules), so those constraints look "unmodeled" to
89
+ Alembic on every autogen run and would otherwise be silently dropped.
90
+ Set to ``False`` to recover the prior, drop-on-sight behaviour.
76
91
  """
77
92
  allowlist = {t.name for t in metadata.tables.values()}
78
93
 
@@ -85,6 +100,8 @@ def make_include_object(metadata: MetaData) -> IncludeObjectFn:
85
100
  ) -> bool:
86
101
  if type_ == "table":
87
102
  return name in allowlist
103
+ if ignore_unmodeled_fks and type_ == "foreign_key_constraint" and compare_to is None:
104
+ return False
88
105
  parent_table = getattr(object, "table", None)
89
106
  if parent_table is not None:
90
107
  return parent_table.name in allowlist
@@ -94,19 +111,45 @@ def make_include_object(metadata: MetaData) -> IncludeObjectFn:
94
111
 
95
112
 
96
113
  def render_item(type_, obj, autogen_context):
97
- """Alembic ``render_item`` callback that collapses SQLModel's ``AutoString``.
98
-
99
- Without this, autogenerate emits ``sqlmodel.sql.sqltypes.AutoString(...)``
100
- in generated migrations but does not add the corresponding
101
- ``import sqlmodel``, so the migration fails with ``NameError`` on apply.
102
- ``AutoString`` is a thin wrapper over ``String``, so collapsing it here
103
- keeps migrations self-contained without losing semantics.
114
+ """Alembic ``render_item`` callback for SQLModel + extension types.
115
+
116
+ * Collapses SQLModel's ``AutoString`` to ``sa.String`` so migrations don't
117
+ need to ``import sqlmodel``.
118
+ * Renders Python ``StrEnum`` columns with ``values_callable`` so the
119
+ Postgres enum labels match the lowercase ``StrEnum`` values rather than
120
+ SQLAlchemy's default of using uppercase attribute names. This means raw
121
+ SQL like ``WHERE status = 'ready'`` actually works against the live DB.
122
+ * Adds the necessary imports for ``fastapi_users_db_sqlalchemy.generics``
123
+ and ``geoalchemy2`` types (rendered by their own classes elsewhere) so
124
+ the generated migration is importable.
104
125
 
105
126
  Pass to :func:`alembic.context.configure` as ``render_item=render_item``.
106
127
  """
107
- if type_ == "type" and type(obj).__name__ == "AutoString":
128
+ if type_ != "type":
129
+ return False
130
+ cls_name = type(obj).__name__
131
+ cls_module = type(obj).__module__ or ""
132
+ imports = autogen_context.imports if autogen_context is not None else None
133
+
134
+ if cls_name == "AutoString":
108
135
  length = getattr(obj, "length", None)
109
- if length is not None:
110
- return f"sa.String(length={length})"
111
- return "sa.String()"
136
+ return f"sa.String(length={length})" if length is not None else "sa.String()"
137
+
138
+ if imports is not None and cls_module.startswith("fastapi_users_db_sqlalchemy"):
139
+ imports.add("import fastapi_users_db_sqlalchemy.generics")
140
+ if imports is not None and cls_module.startswith("geoalchemy2"):
141
+ imports.add("import geoalchemy2")
142
+
143
+ # Match by isinstance, not class-name string — a user-defined ``Enum``
144
+ # class elsewhere in the project would otherwise be matched here and
145
+ # render with ``values_callable`` against an unrelated ``enum_class``.
146
+ if isinstance(obj, sa.Enum):
147
+ python_type = getattr(obj, "enum_class", None)
148
+ if isinstance(python_type, type) and issubclass(python_type, StrEnum):
149
+ name = getattr(obj, "name", None) or python_type.__name__.lower()
150
+ members = ", ".join(repr(m.value) for m in python_type)
151
+ return (
152
+ f"sa.Enum({members}, name={name!r}, values_callable=lambda e: [m.value for m in e])"
153
+ )
154
+
112
155
  return False # let alembic use its default rendering
@@ -8,20 +8,30 @@ SQLModel constructs a fresh ``Column`` per concrete subclass; sharing a single
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from datetime import datetime
11
+ from datetime import UTC, datetime
12
12
 
13
13
  from sqlalchemy import DateTime, func
14
14
  from sqlmodel import Field, SQLModel
15
15
 
16
16
 
17
+ def _utcnow() -> datetime:
18
+ """Timezone-aware UTC ``now`` for the Python-side default of ``created_at``."""
19
+ return datetime.now(UTC)
20
+
21
+
17
22
  class AuditMixin(SQLModel):
18
23
  """Adds created_at, updated_at, created_by, updated_by fields.
19
24
 
20
- ``created_at`` gets a server-side default; ``updated_at`` is populated by
21
- the audit listener in :mod:`simple_module_db.listeners`.
25
+ ``created_at`` is populated both Python-side (``default_factory``) and
26
+ server-side (``server_default``) so freshly-instantiated instances can
27
+ be serialized before flush — without the Python default, accessing
28
+ ``obj.created_at`` raised ``AttributeError`` until the row hit the DB.
29
+ ``updated_at`` is populated by the audit listener in
30
+ :mod:`simple_module_db.listeners`.
22
31
  """
23
32
 
24
33
  created_at: datetime = Field(
34
+ default_factory=_utcnow,
25
35
  sa_type=DateTime(timezone=True),
26
36
  sa_column_kwargs={"server_default": func.now()},
27
37
  )
@@ -51,9 +61,23 @@ class SoftDeleteMixin(SQLModel):
51
61
 
52
62
 
53
63
  class MultiTenantMixin(SQLModel):
54
- """Adds a tenant_id column for data isolation in multi-tenant apps."""
64
+ """Adds a tenant_id column for data isolation in multi-tenant apps.
65
+
66
+ The Python-side type is optional so callers can construct an instance
67
+ inside a request scope without explicitly threading the tenant through —
68
+ the ``_before_flush_listener`` in :mod:`simple_module_db.listeners`
69
+ populates it from the ``current_tenant_id`` contextvar before the row
70
+ reaches the DB. The column itself is non-nullable, so a row inserted
71
+ outside any tenant context fails loudly at the DB rather than silently
72
+ leaking across tenants.
73
+ """
55
74
 
56
- tenant_id: str = Field(max_length=50, index=True)
75
+ tenant_id: str | None = Field(
76
+ default=None,
77
+ max_length=50,
78
+ index=True,
79
+ sa_column_kwargs={"nullable": False},
80
+ )
57
81
 
58
82
 
59
83
  class VersionedMixin(SQLModel):
@@ -33,12 +33,15 @@ def init_db(
33
33
  max_overflow: int = 20,
34
34
  pool_pre_ping: bool = True,
35
35
  pool_recycle: int = 1800,
36
+ poolclass: type | None = None,
36
37
  ) -> DatabaseState:
37
38
  """Create an async engine and session factory.
38
39
 
39
- The pool options only take effect for server-side providers (Postgres).
40
- SQLite uses SQLAlchemy's default pool (single-file, no network), so
41
- passing ``pool_size``/etc. would raise ``TypeError`` skipped below.
40
+ Pool tuning only applies to server-side providers (Postgres) — SQLite
41
+ rejects ``pool_size``/etc. Pass ``poolclass=NullPool`` from test
42
+ fixtures running against asyncpg/Postgres so pytest-asyncio's per-test
43
+ event loops don't outlive pooled connections; the pool-tuning kwargs
44
+ are ignored in that case.
42
45
 
43
46
  Returns a ``DatabaseState`` that should be stored on ``app.state.db``.
44
47
  """
@@ -46,9 +49,11 @@ def init_db(
46
49
 
47
50
  connect_args: dict = {}
48
51
  engine_kwargs: dict = {"echo": echo, "connect_args": connect_args}
52
+ if poolclass is not None:
53
+ engine_kwargs["poolclass"] = poolclass
49
54
  if provider == DatabaseProvider.SQLITE:
50
55
  connect_args["check_same_thread"] = False
51
- else:
56
+ elif poolclass is None:
52
57
  engine_kwargs.update(
53
58
  pool_size=pool_size,
54
59
  max_overflow=max_overflow,
@@ -4,12 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  import contextlib
6
6
  import logging
7
- from decimal import Decimal
8
7
  from unittest.mock import MagicMock
9
8
 
10
9
  from _models import _TenantBase, _TenantItem
11
10
  from simple_module_db.deps import get_db
12
- from sqlalchemy.ext.asyncio import AsyncSession
13
11
 
14
12
 
15
13
  async def _drive_get_db(db_state, populate=None):
@@ -82,47 +80,3 @@ class TestGetDbLogging:
82
80
  read_only = [r for r in records if r.message == "db.session.read_only"]
83
81
  assert len(read_only) == 1
84
82
  assert read_only[0].operation == "read_only_rollback" # type: ignore[attr-defined]
85
-
86
-
87
- class TestEntityListenerLogging:
88
- async def test_create_logs_entity_created(self, db_session: AsyncSession, caplog):
89
- """Inserting a new entity should log db.entity.created."""
90
- from products.models import Product
91
-
92
- with caplog.at_level(logging.INFO, logger="simple_module.db"):
93
- product = Product(name="Widget", price=Decimal("9.99"))
94
- db_session.add(product)
95
- await db_session.flush()
96
-
97
- created_msgs = [
98
- r
99
- for r in caplog.records
100
- if r.name == "simple_module.db" and r.message == "db.entity.created"
101
- ]
102
- assert len(created_msgs) == 1
103
- assert created_msgs[0].entity == "Product" # type: ignore[attr-defined]
104
- assert created_msgs[0].operation == "create" # type: ignore[attr-defined]
105
-
106
- async def test_update_logs_entity_updated(self, db_session: AsyncSession, caplog):
107
- """Modifying an entity should log db.entity.updated."""
108
- from products.models import Product
109
-
110
- product = Product(name="Widget", price=Decimal("9.99"))
111
- db_session.add(product)
112
- await db_session.flush()
113
-
114
- caplog.clear()
115
-
116
- product.name = "Updated Widget"
117
- with caplog.at_level(logging.INFO, logger="simple_module.db"):
118
- await db_session.flush()
119
-
120
- updated_msgs = [
121
- r
122
- for r in caplog.records
123
- if r.name == "simple_module.db" and r.message == "db.entity.updated"
124
- ]
125
- assert len(updated_msgs) == 1
126
- assert updated_msgs[0].entity == "Product" # type: ignore[attr-defined]
127
- assert updated_msgs[0].operation == "update" # type: ignore[attr-defined]
128
- assert updated_msgs[0].entity_id is not None # type: ignore[attr-defined]
@@ -17,10 +17,10 @@ class TestMigrationsHelper:
17
17
  metadata = build_module_metadata()
18
18
  table_names = set(metadata.tables.keys())
19
19
 
20
- # Products ships models and must contribute at least one table.
20
+ # Users ships models and must contribute at least one table.
21
21
  # (Dashboard is event-driven with no models; Auth's tables are
22
22
  # currently not part of this workspace's ORM surface.)
23
- assert any("product" in name.lower() for name in table_names)
23
+ assert any("user" in name.lower() for name in table_names)
24
24
  assert len(table_names) >= 1
25
25
 
26
26
  async def test_combined_metadata_only_returns_module_tables(self):
@@ -54,3 +54,42 @@ class TestMigrationsHelper:
54
54
  "unrelated_host_table", MetaData(), Column("id", Integer, primary_key=True)
55
55
  )
56
56
  assert include(stranger, "unrelated_host_table", "table", False, None) is False
57
+
58
+ async def test_include_object_skips_unmodeled_cross_module_fks_by_default(self):
59
+ """Cross-module FKs declared at the migration level only (no SQLModel
60
+ relationship) appear in the live DB but never in target metadata.
61
+ Alembic passes ``compare_to=None`` for live-only constraints and would
62
+ emit ``op.drop_constraint``; the default filter must drop those
63
+ constraint-level diffs to avoid destroying real FKs on every autogen.
64
+ """
65
+ from simple_module_db.migrations import build_module_metadata, make_include_object
66
+ from sqlalchemy import Column, ForeignKeyConstraint, Integer, MetaData, Table
67
+
68
+ metadata = build_module_metadata()
69
+ include = make_include_object(metadata)
70
+ strict = make_include_object(metadata, ignore_unmodeled_fks=False)
71
+
72
+ known = next(iter(metadata.tables.values()))
73
+ scratch = MetaData()
74
+ local = Table(known.name, scratch, Column("id", Integer, primary_key=True))
75
+ fk = ForeignKeyConstraint([local.c.id], ["other_module.other.id"], name="fk_xmod")
76
+ local.append_constraint(fk)
77
+
78
+ assert include(fk, "fk_xmod", "foreign_key_constraint", True, None) is False
79
+ assert strict(fk, "fk_xmod", "foreign_key_constraint", True, None) is True
80
+
81
+ stranger_meta = MetaData()
82
+ stranger = Table(
83
+ "totally_unknown_table",
84
+ stranger_meta,
85
+ Column("id", Integer, primary_key=True),
86
+ Column("ref", Integer),
87
+ )
88
+ stranger_fk = ForeignKeyConstraint(
89
+ [stranger.c.ref], ["something.else.id"], name="fk_stranger"
90
+ )
91
+ stranger.append_constraint(stranger_fk)
92
+ assert (
93
+ include(stranger_fk, "fk_stranger", "foreign_key_constraint", False, stranger_fk)
94
+ is False
95
+ )