simple-module-core 0.0.33__tar.gz → 0.0.35__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 (71) hide show
  1. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/PKG-INFO +1 -1
  2. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/pyproject.toml +1 -1
  3. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/__init__.py +8 -0
  4. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/__main__.py +19 -9
  5. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/audit_links.py +19 -4
  6. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_i18n.py +27 -3
  7. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_module.py +4 -0
  8. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_pages.py +15 -1
  9. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_runner.py +7 -5
  10. simple_module_core-0.0.35/simple_module_core/diagnostics/_tenancy.py +108 -0
  11. simple_module_core-0.0.35/simple_module_core/invalidation.py +280 -0
  12. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/module.py +18 -0
  13. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/permissions.py +18 -0
  14. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/services.py +8 -1
  15. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_audit_links.py +19 -0
  16. simple_module_core-0.0.35/tests/test_diagnostics_tenancy.py +115 -0
  17. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_i18n_diagnostics.py +40 -0
  18. simple_module_core-0.0.35/tests/test_invalidation.py +261 -0
  19. simple_module_core-0.0.35/tests/test_invalidation_handlers.py +62 -0
  20. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_permissions.py +29 -1
  21. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_sm003_page_render.py +18 -0
  22. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/.gitignore +0 -0
  23. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/LICENSE +0 -0
  24. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/README.md +0 -0
  25. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/csp.py +0 -0
  26. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/design_packs.py +0 -0
  27. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/diagnostics/__init__.py +0 -0
  28. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_coupling.py +0 -0
  29. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_css.py +0 -0
  30. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  31. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  32. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_migration.py +0 -0
  33. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_types.py +0 -0
  34. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/discovery.py +0 -0
  35. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/dotenv.py +0 -0
  36. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/environments.py +0 -0
  37. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/events.py +0 -0
  38. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/exceptions.py +0 -0
  39. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/feature_flags.py +0 -0
  40. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/health.py +0 -0
  41. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/i18n.py +0 -0
  42. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/menu.py +0 -0
  43. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/module_meta.py +0 -0
  44. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/public_routes.py +0 -0
  45. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/py.typed +0 -0
  46. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/redirect_safety.py +0 -0
  47. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/settings_base.py +0 -0
  48. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/setup_steps.py +0 -0
  49. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/simple_module_core/versioning.py +0 -0
  50. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_auth_provider_selection.py +0 -0
  51. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_csp_registry.py +0 -0
  52. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_css_diagnostics.py +0 -0
  53. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_css_scanner.py +0 -0
  54. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_design_packs.py +0 -0
  55. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_diagnostics.py +0 -0
  56. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_discovery.py +0 -0
  57. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_dotenv.py +0 -0
  58. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_environments.py +0 -0
  59. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_events.py +0 -0
  60. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_feature_flags.py +0 -0
  61. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_feature_flags_decorator.py +0 -0
  62. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_health_registry.py +0 -0
  63. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_i18n.py +0 -0
  64. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_menu.py +0 -0
  65. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_module_base.py +0 -0
  66. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_module_diagnostics.py +0 -0
  67. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_public_routes.py +0 -0
  68. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_redirect_safety.py +0 -0
  69. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_services.py +0 -0
  70. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_settings_base.py +0 -0
  71. {simple_module_core-0.0.33 → simple_module_core-0.0.35}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_core
3
- Version: 0.0.33
3
+ Version: 0.0.35
4
4
  Summary: Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_core"
3
- version = "0.0.33"
3
+ version = "0.0.35"
4
4
  description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -36,6 +36,11 @@ from simple_module_core.feature_flags import (
36
36
  )
37
37
  from simple_module_core.health import HealthCheck, HealthCheckResult, HealthRegistry, HealthStatus
38
38
  from simple_module_core.i18n import I18nRegistry, Translator
39
+ from simple_module_core.invalidation import (
40
+ Invalidation,
41
+ InvalidationBus,
42
+ InvalidationTransport,
43
+ )
39
44
  from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
40
45
  from simple_module_core.module import ModuleBase, ModuleMeta
41
46
  from simple_module_core.permissions import PermissionRegistry
@@ -66,6 +71,9 @@ __all__ = [
66
71
  "HealthStatus",
67
72
  "I18nRegistry",
68
73
  "InvalidModuleError",
74
+ "Invalidation",
75
+ "InvalidationBus",
76
+ "InvalidationTransport",
69
77
  "LabelResolver",
70
78
  "MenuItem",
71
79
  "MenuRegistry",
@@ -7,10 +7,14 @@ Usage::
7
7
 
8
8
  Exits with status 1 if any ERROR-level diagnostics are reported.
9
9
 
10
- i18n checks are included when ``SM_I18N_SUPPORTED_LOCALES`` is set in env
11
- (or ``.env``). Host-level ``host/locales/`` and shared ``packages/ui/locales/``
12
- are picked up relative to the project root (``SM_PROJECT_ROOT``, else the
13
- directory of the discovered ``.env``, else the current working dir).
10
+ i18n checks always run. ``SM_I18N_SUPPORTED_LOCALES`` (env or ``.env``)
11
+ declares the locale set this install promises, which also enables SM013 for a
12
+ namespace that never shipped one; with it unset, each namespace is checked
13
+ against the locale files it actually has, so key drift between two shipped
14
+ translations is still caught. Host-level ``host/locales/`` and shared
15
+ ``packages/ui/locales/`` are picked up relative to the project root
16
+ (``SM_PROJECT_ROOT``, else the directory of the discovered ``.env``, else the
17
+ current working dir).
14
18
  """
15
19
 
16
20
  from __future__ import annotations
@@ -36,8 +40,14 @@ from simple_module_core.dotenv import find_env_file, parse_dotenv
36
40
  from simple_module_core.exceptions import InvalidModuleError
37
41
 
38
42
 
39
- def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
40
- """Return ``(supported_locales, default_locale)`` or ``(None, None)`` if unset.
43
+ def _load_i18n_settings_from_env() -> tuple[list[str] | None, str]:
44
+ """Return ``(supported_locales, default_locale)``.
45
+
46
+ ``supported_locales`` is ``None`` when ``SM_I18N_SUPPORTED_LOCALES`` is
47
+ unset or unparseable — the diagnostic then falls back to the locale files
48
+ each namespace ships rather than running no locale checks at all. The
49
+ default locale always has a value, because a parity comparison needs to
50
+ know which side is the source of truth.
41
51
 
42
52
  Reads env vars directly to avoid a dependency on ``simple_module_hosting``.
43
53
  Honors ``.env`` by merging it into ``os.environ`` if present (pydantic-
@@ -46,9 +56,10 @@ def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
46
56
  for key, value in parse_dotenv().items():
47
57
  os.environ.setdefault(key, value)
48
58
 
59
+ default = os.environ.get("SM_I18N_DEFAULT_LOCALE", "en")
49
60
  supported_raw = os.environ.get("SM_I18N_SUPPORTED_LOCALES")
50
61
  if not supported_raw:
51
- return None, None
62
+ return None, default
52
63
 
53
64
  try:
54
65
  supported = json.loads(supported_raw)
@@ -57,9 +68,8 @@ def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
57
68
  supported = [s.strip() for s in supported_raw.split(",") if s.strip()]
58
69
 
59
70
  if not isinstance(supported, list) or not supported:
60
- return None, None
71
+ return None, default
61
72
 
62
- default = os.environ.get("SM_I18N_DEFAULT_LOCALE", "en")
63
73
  return supported, default
64
74
 
65
75
 
@@ -11,10 +11,15 @@ A module declares where its rows live via
11
11
  collects them into one registry at boot and stores it on
12
12
  ``app.state.sm.audit_links``.
13
13
 
14
- **The registry maps model class names to URL templates, nothing more.** It does
15
- not verify the row exists or that the reader may open it — following a link to a
16
- deleted record lands on that screen's own 404, and permissions are enforced by
17
- the target route as usual.
14
+ **A link is a route, not an authorisation.** The registry does not verify the
15
+ row exists or that the reader may open it — following a link to a deleted
16
+ record lands on that screen's own 404, and permissions are enforced by the
17
+ target route as usual.
18
+
19
+ The one exception is ``label_permission``: the *name* of a row travels in the
20
+ audit payload itself, so no downstream route ever gets the chance to refuse it.
21
+ A module whose row names disclose something it gates elsewhere says so there,
22
+ and readers without that permission see the id instead.
18
23
  """
19
24
 
20
25
  from __future__ import annotations
@@ -76,6 +81,15 @@ class AuditLink:
76
81
  a closure, and two boots would build two unequal objects out of one
77
82
  module — a registry conflict is about two modules claiming one
78
83
  entity type, which the identity fields already express.
84
+ label_permission: Permission a reader must hold before
85
+ ``label_resolver`` is *run* for them. Empty — the default — means
86
+ the name is safe for anyone allowed to read the audit trail at all.
87
+ Set it when naming the row discloses something the module gates
88
+ elsewhere: ``users`` names an account by ``full_name or email``, so
89
+ without this an ``audit_log.view`` grant is a back door onto the
90
+ user directory that ``users.manage`` guards at the front. Denied
91
+ readers still see the row, the action and the id — the audit trail
92
+ stays complete, it just stops volunteering the name.
79
93
  """
80
94
 
81
95
  entity_type: str
@@ -84,6 +98,7 @@ class AuditLink:
84
98
  label_key: str = ""
85
99
  table_name: str | None = None
86
100
  label_resolver: LabelResolver | None = field(default=None, compare=False)
101
+ label_permission: str = ""
87
102
 
88
103
  def __post_init__(self) -> None:
89
104
  if self.url_template and _ID_PLACEHOLDER not in self.url_template:
@@ -25,19 +25,28 @@ class I18nDiagnostics:
25
25
 
26
26
  def __init__(
27
27
  self,
28
- supported_locales: list[str],
28
+ supported_locales: list[str] | None,
29
29
  default_locale: str,
30
30
  extra_sources: list[tuple[str, str, Path]] | None = None,
31
31
  ) -> None:
32
32
  """Build the diagnostic.
33
33
 
34
+ ``supported_locales`` is the set of locales this install promises to
35
+ ship. ``None`` means *nobody declared one* — each namespace is then
36
+ checked against the locale files it actually has on disk. SM013 is
37
+ skipped in that mode (a locale nothing promised cannot be missing),
38
+ but SM014/SM015 still hold every shipped translation to the default
39
+ locale's key set. Without this, an install that never set
40
+ ``SM_I18N_SUPPORTED_LOCALES`` ran no locale checks at all and drift
41
+ accumulated with nothing to flag it.
42
+
34
43
  ``extra_sources`` is an optional list of ``(reporter_name, namespace,
35
44
  locale_dir)`` triples for locale directories that aren't owned by any
36
45
  ``ModuleBase`` instance — notably the host's ``host/locales/`` and
37
46
  the shared ``packages/ui/locales/``. ``reporter_name`` is used as the
38
47
  ``module_name`` field on findings for display purposes.
39
48
  """
40
- self.supported_locales = list(supported_locales)
49
+ self.supported_locales = None if supported_locales is None else list(supported_locales)
41
50
  self.default_locale = default_locale
42
51
  self.extra_sources = list(extra_sources or [])
43
52
 
@@ -55,10 +64,14 @@ class I18nDiagnostics:
55
64
  ) -> list[Diagnostic]:
56
65
  findings: list[Diagnostic] = []
57
66
  per_locale_keys: dict[str, set[str]] = {}
67
+ declared = self.supported_locales is not None
68
+ locales = self.supported_locales if declared else self._locales_on_disk(locale_dir)
58
69
 
59
- for locale in self.supported_locales:
70
+ for locale in locales or ():
60
71
  path = locale_dir / f"{locale}.json"
61
72
  if not path.is_file():
73
+ if not declared:
74
+ continue
62
75
  findings.append(
63
76
  Diagnostic(
64
77
  level=DiagnosticLevel.WARNING,
@@ -119,3 +132,14 @@ class I18nDiagnostics:
119
132
  )
120
133
  )
121
134
  return findings
135
+
136
+ @staticmethod
137
+ def _locales_on_disk(locale_dir: Path) -> list[str]:
138
+ """Locale tags a directory actually ships, from its ``<tag>.json`` files.
139
+
140
+ The default locale sorts first only by accident of the alphabet, which
141
+ does not matter: the parity comparison below looks it up by name.
142
+ """
143
+ if not locale_dir.is_dir():
144
+ return []
145
+ return sorted(path.stem for path in locale_dir.glob("*.json"))
@@ -11,6 +11,7 @@ from simple_module_core.diagnostics._css import check_module_css
11
11
  from simple_module_core.diagnostics._inertia_api import check_inertia_api_calls
12
12
  from simple_module_core.diagnostics._js_workspace import check_js_workspace_files
13
13
  from simple_module_core.diagnostics._pages import check_pages, find_render_calls
14
+ from simple_module_core.diagnostics._tenancy import check_tenant_unique_keys, module_tables
14
15
  from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
15
16
 
16
17
  if TYPE_CHECKING:
@@ -29,6 +30,8 @@ class ModuleDiagnostics:
29
30
  diagnostics.extend(self._check_views_without_menu(modules))
30
31
  diagnostics.extend(self._check_auth_provider_conflict(modules))
31
32
  diagnostics.extend(check_framework_module_coupling(modules))
33
+ for mod in modules:
34
+ diagnostics.extend(check_tenant_unique_keys(module_tables(mod), mod.meta.name))
32
35
 
33
36
  # File-based checks (need to find module source directories)
34
37
  for mod in modules:
@@ -95,6 +98,7 @@ class ModuleDiagnostics:
95
98
  "register_permissions",
96
99
  "register_feature_flags",
97
100
  "register_event_handlers",
101
+ "register_invalidations",
98
102
  "register_middleware",
99
103
  "register_health_checks",
100
104
  "register_public_routes",
@@ -89,6 +89,15 @@ def _iter_render_components(tree: ast.Module, consts: dict[str, str]) -> list[st
89
89
  return found
90
90
 
91
91
 
92
+ #: A ``*.test.tsx``/``*.spec.tsx`` beside a page is a test, not a page —
93
+ #: mirrors ``simple_module_hosting.page_globs.TEST_FILE_SUFFIXES``, which
94
+ #: excludes the same files from the Vite glob. Duplicated rather than
95
+ #: imported: ``simple_module_hosting`` depends on ``simple_module_core``,
96
+ #: not the other way around, so importing it here would invert the package
97
+ #: layering.
98
+ _TEST_FILE_SUFFIXES = (".test.tsx", ".spec.tsx")
99
+
100
+
92
101
  def collect_tsx_pages(pages_dir: Path) -> set[str]:
93
102
  """Collect .tsx page identifiers relative to pages_dir, without extension.
94
103
 
@@ -96,10 +105,15 @@ def collect_tsx_pages(pages_dir: Path) -> set[str]:
96
105
  directly against inertia.render("Module/Sub/Page") keys. Subdirectories
97
106
  whose names start with a lowercase letter (``components/``, ``hooks/``,
98
107
  ...) are treated as helper folders, not Inertia page roots, matching the
99
- PascalCase convention Inertia uses.
108
+ PascalCase convention Inertia uses. Files ending in ``.test.tsx`` or
109
+ ``.spec.tsx`` are colocated tests, not pages, and are excluded the same
110
+ way the Vite glob excludes them — otherwise a colocated test with no
111
+ matching ``inertia.render()`` call is flagged as an SM003 orphan page.
100
112
  """
101
113
  pages: set[str] = set()
102
114
  for f in pages_dir.rglob("*.tsx"):
115
+ if f.name.endswith(_TEST_FILE_SUFFIXES):
116
+ continue
103
117
  rel = f.relative_to(pages_dir)
104
118
  if any(part[:1].islower() for part in rel.parts[:-1]):
105
119
  continue
@@ -30,18 +30,20 @@ def run_diagnostics(
30
30
  """Convenience function to run all diagnostics.
31
31
 
32
32
  When ``migration_state`` is provided, also runs migration diagnostics.
33
- When ``i18n_supported_locales`` and ``i18n_default_locale`` are provided,
34
- also runs i18n locale coverage diagnostics. ``i18n_extra_sources`` lets
35
- callers include host/ui locale dirs that aren't owned by a ``ModuleBase``.
33
+ ``i18n_default_locale`` turns on the locale checks; ``i18n_supported_locales``
34
+ is the declared locale set, and leaving it empty runs those checks against
35
+ whatever locale files are on disk instead of skipping them entirely (see
36
+ :class:`I18nDiagnostics`). ``i18n_extra_sources`` lets callers include
37
+ host/ui locale dirs that aren't owned by a ``ModuleBase``.
36
38
  """
37
39
  diagnostics = ModuleDiagnostics().run(modules)
38
40
 
39
- if i18n_supported_locales and i18n_default_locale:
41
+ if i18n_default_locale:
40
42
  from simple_module_core.diagnostics._i18n import I18nDiagnostics
41
43
 
42
44
  diagnostics.extend(
43
45
  I18nDiagnostics(
44
- supported_locales=i18n_supported_locales,
46
+ supported_locales=i18n_supported_locales or None,
45
47
  default_locale=i18n_default_locale,
46
48
  extra_sources=i18n_extra_sources,
47
49
  ).run(modules)
@@ -0,0 +1,108 @@
1
+ """SM024: a unique key on a tenant-scoped table that ignores the tenant.
2
+
3
+ On a ``MultiTenantMixin`` table every business key is per tenant. A unique
4
+ constraint without ``tenant_id`` in it means the first tenant to claim a value
5
+ (a slug, a filename, a name) locks every other tenant out of it — and the
6
+ resulting IntegrityError tells the second tenant the value exists elsewhere.
7
+
8
+ Duck-typed on SQLAlchemy ``Table`` objects (core does not depend on
9
+ SQLAlchemy). Only tables of models that inherit ``MultiTenantMixin`` count: a
10
+ plain ``tenant_id`` column (``users_user``'s legacy one, the ``tenants``
11
+ registry's own tables) carries no isolation and no per-tenant key rule.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ import logging
18
+ from collections.abc import Iterable, Iterator
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
22
+
23
+ if TYPE_CHECKING:
24
+ from simple_module_core.module import ModuleBase
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ TENANT_COLUMN = "tenant_id"
29
+
30
+
31
+ def _unique_keys(table: Any) -> Iterator[tuple[str, set[str]]]:
32
+ """Yield ``(label, column names)`` for every unique key except the PK."""
33
+ for col in table.columns:
34
+ if col.unique and not col.primary_key:
35
+ yield f"column '{col.name}'", {col.name}
36
+ for constraint in table.constraints:
37
+ if type(constraint).__name__ == "UniqueConstraint":
38
+ cols = {c.name for c in constraint.columns}
39
+ yield f"unique constraint {sorted(cols)}", cols
40
+ for index in table.indexes:
41
+ if index.unique:
42
+ cols = {c.name for c in index.columns}
43
+ label = f"unique index '{index.name}'"
44
+ # Expression indexes (lower(email)) expose no plain columns; treat
45
+ # them as not containing the tenant — they are just as global.
46
+ yield label, cols
47
+
48
+
49
+ def check_tenant_unique_keys(tables: Iterable[Any], module_name: str) -> list[Diagnostic]:
50
+ diags: list[Diagnostic] = []
51
+ for table in tables:
52
+ if TENANT_COLUMN not in table.columns:
53
+ continue
54
+ reported: set[frozenset[str]] = set()
55
+ for label, cols in _unique_keys(table):
56
+ # ``unique=True`` surfaces both as a column flag and as a
57
+ # UniqueConstraint; report each key once. Expression indexes have
58
+ # no plain columns and are always reported.
59
+ key = frozenset(cols)
60
+ if TENANT_COLUMN in cols or (cols and key in reported):
61
+ continue
62
+ reported.add(key)
63
+ diags.append(
64
+ Diagnostic(
65
+ level=DiagnosticLevel.WARNING,
66
+ code="SM024",
67
+ message=(
68
+ f"Tenant-scoped table '{table.name}' has a {label} that does "
69
+ f"not include '{TENANT_COLUMN}' — two tenants cannot both own a value"
70
+ ),
71
+ module_name=module_name,
72
+ suggestion=(
73
+ "Make the key per tenant: add tenant_id to the constraint, "
74
+ "e.g. Index(..., 'tenant_id', 'slug', unique=True)"
75
+ ),
76
+ )
77
+ )
78
+ return diags
79
+
80
+
81
+ def _is_tenant_scoped(cls: type) -> bool:
82
+ return any(base.__name__ == "MultiTenantMixin" for base in cls.__mro__[1:])
83
+
84
+
85
+ def module_tables(mod: ModuleBase) -> list[Any]:
86
+ """Tables of the ``MultiTenantMixin`` models in the module's ``models``."""
87
+ pkg = type(mod).__module__.rsplit(".", 1)[0]
88
+ try:
89
+ models = importlib.import_module(f"{pkg}.models")
90
+ except ModuleNotFoundError:
91
+ return []
92
+ except Exception: # pragma: no cover - a broken models module fails elsewhere, loudly
93
+ logger.debug("Could not import %s.models for SM024", pkg, exc_info=True)
94
+ return []
95
+ seen: dict[str, Any] = {}
96
+ for value in vars(models).values():
97
+ table = getattr(value, "__table__", None)
98
+ if (
99
+ isinstance(value, type)
100
+ and table is not None
101
+ and hasattr(table, "columns")
102
+ and _is_tenant_scoped(value)
103
+ ):
104
+ seen.setdefault(table.name, table)
105
+ return list(seen.values())
106
+
107
+
108
+ __all__ = ["check_tenant_unique_keys", "module_tables"]