simple-module-core 0.0.34__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.34 → simple_module_core-0.0.35}/PKG-INFO +1 -1
  2. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/pyproject.toml +1 -1
  3. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/__init__.py +8 -0
  4. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_module.py +4 -0
  5. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_pages.py +15 -1
  6. simple_module_core-0.0.35/simple_module_core/diagnostics/_tenancy.py +108 -0
  7. simple_module_core-0.0.35/simple_module_core/invalidation.py +280 -0
  8. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/module.py +18 -0
  9. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/services.py +8 -1
  10. simple_module_core-0.0.35/tests/test_diagnostics_tenancy.py +115 -0
  11. simple_module_core-0.0.35/tests/test_invalidation.py +261 -0
  12. simple_module_core-0.0.35/tests/test_invalidation_handlers.py +62 -0
  13. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_sm003_page_render.py +18 -0
  14. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/.gitignore +0 -0
  15. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/LICENSE +0 -0
  16. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/README.md +0 -0
  17. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/__main__.py +0 -0
  18. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/audit_links.py +0 -0
  19. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/csp.py +0 -0
  20. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/design_packs.py +0 -0
  21. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/__init__.py +0 -0
  22. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_coupling.py +0 -0
  23. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_css.py +0 -0
  24. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_i18n.py +0 -0
  25. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  26. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  27. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_migration.py +0 -0
  28. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_runner.py +0 -0
  29. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_types.py +0 -0
  30. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/discovery.py +0 -0
  31. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/dotenv.py +0 -0
  32. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/environments.py +0 -0
  33. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/events.py +0 -0
  34. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/exceptions.py +0 -0
  35. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/feature_flags.py +0 -0
  36. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/health.py +0 -0
  37. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/i18n.py +0 -0
  38. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/menu.py +0 -0
  39. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/module_meta.py +0 -0
  40. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/permissions.py +0 -0
  41. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/public_routes.py +0 -0
  42. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/py.typed +0 -0
  43. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/redirect_safety.py +0 -0
  44. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/settings_base.py +0 -0
  45. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/setup_steps.py +0 -0
  46. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/versioning.py +0 -0
  47. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_audit_links.py +0 -0
  48. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_auth_provider_selection.py +0 -0
  49. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_csp_registry.py +0 -0
  50. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_css_diagnostics.py +0 -0
  51. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_css_scanner.py +0 -0
  52. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_design_packs.py +0 -0
  53. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_diagnostics.py +0 -0
  54. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_discovery.py +0 -0
  55. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_dotenv.py +0 -0
  56. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_environments.py +0 -0
  57. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_events.py +0 -0
  58. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_feature_flags.py +0 -0
  59. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_feature_flags_decorator.py +0 -0
  60. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_health_registry.py +0 -0
  61. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_i18n.py +0 -0
  62. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_i18n_diagnostics.py +0 -0
  63. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_menu.py +0 -0
  64. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_module_base.py +0 -0
  65. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_module_diagnostics.py +0 -0
  66. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_permissions.py +0 -0
  67. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_public_routes.py +0 -0
  68. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_redirect_safety.py +0 -0
  69. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_services.py +0 -0
  70. {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_settings_base.py +0 -0
  71. {simple_module_core-0.0.34 → 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.34
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.34"
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",
@@ -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
@@ -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"]
@@ -0,0 +1,280 @@
1
+ """Cache-invalidation fan-out: in-process by default, cross-process when a
2
+ transport is installed.
3
+
4
+ Several modules keep a per-process cache of a row that is cheap to read and
5
+ expensive to read *often* — ``users`` caches ``User.session_version`` (the
6
+ revocation counter checked on nearly every authenticated request) and
7
+ ``file_storage`` caches its aggregate size, both behind a 30-second TTL. They
8
+ share one failure shape: the worker that performed the write drops its own entry
9
+ immediately, and **every other worker keeps serving the stale value until its own
10
+ entry expires**.
11
+
12
+ ``settings`` has the same shape without the TTL — ``apply_changes_and_reload``
13
+ hot-swaps the settings object in the worker that handled the save and announces it
14
+ on the in-process :class:`~simple_module_core.events.EventBus`, so the others serve
15
+ the old configuration until restart. It needs a re-read rather than an eviction, so
16
+ it is a consumer this bus makes *possible* rather than one it already serves.
17
+
18
+ The obvious fix — publish the write on Redis pub/sub so every worker drops its
19
+ entry at once — was not reachable from any of those modules. Redis belongs to
20
+ the ``background_tasks`` plugin, so ``users`` would have to open a second
21
+ connection with its own settings that can disagree with it; :class:`EventBus`
22
+ is deliberately in-process; and ``SM009`` makes a ``framework`` → plugin import
23
+ an error, so the framework cannot borrow the plugin's client either (GH #318).
24
+
25
+ This module is the missing middle. The framework owns the *bus* — the channel
26
+ registry, the wire format and the origin filtering — and knows nothing about
27
+ how a message reaches another process. Whoever owns a shared backend installs
28
+ an :class:`InvalidationTransport` on it; ``background_tasks`` does exactly that
29
+ with the Redis connection it already configures. With no transport installed
30
+ the bus is honest in-process pub/sub, which is precisely today's behaviour: the
31
+ publishing worker sees the invalidation, the others wait out their TTL.
32
+
33
+ What this is *not*: a message queue. There is no persistence, no retry, no
34
+ delivery guarantee. A dropped message costs staleness for the rest of a cache's
35
+ TTL, never correctness of the cache's contents — so every consumer must keep a
36
+ TTL as its floor and treat the bus as an accelerator. Anything that needs
37
+ delivery guarantees wants a Celery task, not this.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import inspect
43
+ import json
44
+ import logging
45
+ import uuid
46
+ from collections.abc import Awaitable, Callable
47
+ from dataclasses import dataclass
48
+ from typing import Protocol, runtime_checkable
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ __all__ = [
53
+ "WIRE_VERSION",
54
+ "Invalidation",
55
+ "InvalidationBus",
56
+ "InvalidationHandler",
57
+ "InvalidationTransport",
58
+ ]
59
+
60
+ WIRE_VERSION = 1
61
+ """Bumped when :meth:`Invalidation.to_wire` changes shape.
62
+
63
+ A message carrying any other version is dropped rather than guessed at, so a
64
+ rolling deploy in which half the workers run new code degrades to "no
65
+ cross-process invalidation between the two halves" instead of to a decode
66
+ exception in the listener loop.
67
+ """
68
+
69
+ type InvalidationHandler = Callable[["Invalidation"], Awaitable[None] | None]
70
+ """Sync or async. The caches this exists for are plain dicts, so demanding
71
+ ``async def`` for a one-line ``pop`` would be ceremony."""
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class Invalidation:
76
+ """One "this cached thing changed" notice.
77
+
78
+ Deliberately not a :class:`~simple_module_core.events.Event`: a domain event
79
+ describes something that happened in the business ("an order was placed")
80
+ and its handlers may do anything, while this describes something that
81
+ stopped being true in a *cache* and its handlers may only forget. Sharing
82
+ the type would invite a handler that writes to the database on a message
83
+ that carries no delivery guarantee.
84
+ """
85
+
86
+ #: Who cares. Convention is ``<module>.<cache>`` — ``users.session_version``.
87
+ channel: str
88
+ #: Which entry, or ``None`` for "the whole channel". A string because it has
89
+ #: to survive JSON; a consumer keyed by something else converts on the way
90
+ #: back in (see ``users.session_revocation``).
91
+ key: str | None = None
92
+ #: The publishing process's :attr:`InvalidationBus.origin`. Lets a bus
93
+ #: ignore its own broadcast coming back off the transport, which it has
94
+ #: already applied locally.
95
+ origin: str = ""
96
+
97
+ def to_wire(self) -> str:
98
+ """JSON, because a transport is free to be anything that moves text."""
99
+ return json.dumps(
100
+ {"v": WIRE_VERSION, "channel": self.channel, "key": self.key, "origin": self.origin}
101
+ )
102
+
103
+ @classmethod
104
+ def from_wire(cls, message: str | bytes) -> Invalidation | None:
105
+ """Parse a wire message, or ``None`` when it isn't one of ours.
106
+
107
+ Returns rather than raises on anything unexpected. A Redis database is
108
+ routinely shared — with another app, with a stray ``redis-cli publish``,
109
+ with the previous version of this code — and a listener loop that dies
110
+ on one malformed payload takes cross-process invalidation down for the
111
+ life of the process. Logged at debug: on a shared database this is
112
+ normal traffic, not a fault.
113
+ """
114
+ try:
115
+ data = json.loads(message)
116
+ if not isinstance(data, dict) or data.get("v") != WIRE_VERSION:
117
+ logger.debug("Ignoring invalidation message with unknown shape: %r", message)
118
+ return None
119
+ channel = data["channel"]
120
+ key = data.get("key")
121
+ if not isinstance(channel, str) or not isinstance(key, str | None):
122
+ logger.debug("Ignoring invalidation message with bad field types: %r", message)
123
+ return None
124
+ return cls(channel=channel, key=key, origin=str(data.get("origin") or ""))
125
+ except Exception:
126
+ logger.debug("Ignoring undecodable invalidation message: %r", message)
127
+ return None
128
+
129
+
130
+ @runtime_checkable
131
+ class InvalidationTransport(Protocol):
132
+ """Moves an encoded :class:`Invalidation` to the other processes.
133
+
134
+ One method, because the inbound direction is the transport's own business:
135
+ it decides how it listens (a pub/sub subscription, a poll, a test double
136
+ calling straight through) and hands whatever it receives to
137
+ :meth:`InvalidationBus.deliver`, which owns decoding and the origin filter.
138
+ """
139
+
140
+ async def publish(self, message: str) -> None:
141
+ """Broadcast *message* to every other process. May raise."""
142
+ ...
143
+
144
+
145
+ class InvalidationBus:
146
+ """Channel registry plus fan-out. One per process, on ``app.state.sm``.
147
+
148
+ Subscribers register in ``ModuleBase.register_invalidations``; a transport is
149
+ installed later, in whichever module owns the shared backend, during its
150
+ ``on_startup``. The two are independent on purpose — subscriptions are what
151
+ make the bus useful in a single process, and the transport only widens their
152
+ reach.
153
+ """
154
+
155
+ def __init__(self, origin: str | None = None) -> None:
156
+ #: Identifies this process for the lifetime of the process. Random
157
+ #: rather than the pid: pids repeat across containers, and two workers
158
+ #: that believe they share an origin would each ignore the other's
159
+ #: broadcasts — the exact bug this class exists to fix, but silent.
160
+ self.origin = origin or uuid.uuid4().hex
161
+ self._handlers: dict[str, list[InvalidationHandler]] = {}
162
+ self._transport: InvalidationTransport | None = None
163
+
164
+ # ── subscription ───────────────────────────────────────
165
+
166
+ def subscribe(self, channel: str, handler: InvalidationHandler) -> None:
167
+ """Run *handler* for every invalidation on *channel*, local or remote."""
168
+ self._handlers.setdefault(channel, []).append(handler)
169
+ logger.debug(
170
+ "Subscribed %s to invalidation channel %r",
171
+ getattr(handler, "__qualname__", repr(handler)),
172
+ channel,
173
+ )
174
+
175
+ @property
176
+ def channels(self) -> tuple[str, ...]:
177
+ """Channels with at least one subscriber, for diagnostics and tests."""
178
+ return tuple(sorted(self._handlers))
179
+
180
+ # ── transport ──────────────────────────────────────────
181
+
182
+ def set_transport(self, transport: InvalidationTransport) -> None:
183
+ """Install the shared backend.
184
+
185
+ Replacing one is almost certainly a mistake, so it says so. The bus can
186
+ only forget the old transport's reference — it cannot stop the listener
187
+ that transport is running, and two live listeners on one channel deliver
188
+ every remote message to this bus twice. Whoever owns the transport has to
189
+ ``stop()`` the old one; ``background_tasks`` does that in ``on_shutdown``.
190
+ """
191
+ if self._transport is not None:
192
+ logger.warning(
193
+ "Replacing invalidation transport %s with %s — the old one's listener "
194
+ "is not stopped by this call and will keep delivering, so every remote "
195
+ "message will be applied twice",
196
+ type(self._transport).__name__,
197
+ type(transport).__name__,
198
+ )
199
+ self._transport = transport
200
+ logger.info("Invalidation transport installed: %s", type(transport).__name__)
201
+
202
+ def clear_transport(self) -> None:
203
+ """Drop back to in-process only — the owning module's ``on_shutdown``."""
204
+ self._transport = None
205
+
206
+ @property
207
+ def has_transport(self) -> bool:
208
+ """Whether invalidations currently reach other processes."""
209
+ return self._transport is not None
210
+
211
+ # ── publishing ─────────────────────────────────────────
212
+
213
+ async def publish(self, channel: str, key: str | None = None) -> None:
214
+ """Apply locally, then broadcast.
215
+
216
+ Local first, and a transport failure never propagates: the caller is
217
+ typically finishing a write it has already committed — a password change
218
+ that bumped ``session_version`` — and turning "Redis is unreachable"
219
+ into a 500 on that request would trade a bounded staleness window for an
220
+ outright failure. Logged at error, because the operator needs to know
221
+ the fan-out is silently degraded to per-process.
222
+ """
223
+ invalidation = Invalidation(channel=channel, key=key, origin=self.origin)
224
+ await self._dispatch(invalidation)
225
+ transport = self._transport
226
+ if transport is None:
227
+ return
228
+ try:
229
+ await transport.publish(invalidation.to_wire())
230
+ except Exception:
231
+ logger.exception(
232
+ "Invalidation broadcast failed for channel %r; other workers keep "
233
+ "their cached value until it expires",
234
+ channel,
235
+ )
236
+
237
+ async def deliver(self, message: str | bytes) -> None:
238
+ """Apply a message that arrived from another process.
239
+
240
+ Called by the transport. Messages this process published are dropped —
241
+ :meth:`publish` already ran the handlers, and running them twice would
242
+ be harmless for a ``pop`` but is not a property a handler should have to
243
+ have.
244
+ """
245
+ invalidation = Invalidation.from_wire(message)
246
+ if invalidation is None:
247
+ return
248
+ if invalidation.origin and invalidation.origin == self.origin:
249
+ return
250
+ await self._dispatch(invalidation)
251
+
252
+ async def _dispatch(self, invalidation: Invalidation) -> None:
253
+ """Run every handler on the channel, isolating failures.
254
+
255
+ Sequential rather than ``gather``: handlers are microsecond-scale cache
256
+ evictions, and a channel with enough of them for concurrency to matter
257
+ would be a design smell. A failing handler must not stop the others —
258
+ one module's broken eviction is not a reason for another's cache to stay
259
+ stale.
260
+ """
261
+ # A snapshot, not the live list. A handler is free to call
262
+ # :meth:`subscribe` — nothing forbids it, and a module that lazily
263
+ # registers a cache would — and appending to the list being iterated
264
+ # would feed the new handler straight back into this loop. Because these
265
+ # handlers are typically *sync*, that loop contains no await point, so it
266
+ # does not merely recurse: it wedges the whole event loop, with
267
+ # ``asyncio.timeout`` unable to fire and the ``except`` below logging
268
+ # nothing. Measured at 3.1 million invocations from a single ``publish``
269
+ # before the copy was added.
270
+ for handler in tuple(self._handlers.get(invalidation.channel, ())):
271
+ try:
272
+ result = handler(invalidation)
273
+ if inspect.isawaitable(result):
274
+ await result
275
+ except Exception:
276
+ logger.exception(
277
+ "Invalidation handler %s failed for channel %r",
278
+ getattr(handler, "__qualname__", repr(handler)),
279
+ invalidation.channel,
280
+ )
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
17
17
  from simple_module_core.events import EventBus
18
18
  from simple_module_core.feature_flags import FeatureFlagRegistry
19
19
  from simple_module_core.health import HealthRegistry
20
+ from simple_module_core.invalidation import InvalidationBus
20
21
  from simple_module_core.menu import MenuRegistry
21
22
  from simple_module_core.permissions import PermissionRegistry
22
23
  from simple_module_core.public_routes import PublicRouteRegistry
@@ -48,6 +49,7 @@ class ModuleBase(ABC):
48
49
  - register_permissions(registry)
49
50
  - register_feature_flags(registry)
50
51
  - register_event_handlers(bus)
52
+ - register_invalidations(bus, app)
51
53
  - register_health_checks(registry)
52
54
 
53
55
  Phase 5: Database
@@ -116,6 +118,22 @@ class ModuleBase(ABC):
116
118
  framework's engine instead of building their own.
117
119
  """
118
120
 
121
+ def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None:
122
+ """Subscribe this module's per-process caches to their channels.
123
+
124
+ Handlers may only *forget* — drop a cache entry, clear a dict. The bus
125
+ carries no delivery guarantee, so a handler that wrote to the database
126
+ would do it an unknowable number of times::
127
+
128
+ def register_invalidations(self, bus, app):
129
+ bus.subscribe("orders.totals", lambda inv: _TOTALS.pop(inv.key, None))
130
+
131
+ Publishing needs no hook — reach the same bus at
132
+ ``request.app.state.sm.invalidation`` from a ``db.on_commit`` callback.
133
+ Cross-process delivery depends on a transport being installed, so every
134
+ cache keeps its TTL as a floor. See ``docs/framework/invalidation.md``.
135
+ """
136
+
119
137
  def register_health_checks(self, registry: HealthRegistry) -> None:
120
138
  """Contribute health checks for the ``/health/ready`` endpoint."""
121
139
 
@@ -16,12 +16,14 @@ from dataclasses import dataclass, field
16
16
  from datetime import UTC, datetime
17
17
  from typing import TYPE_CHECKING
18
18
 
19
+ from simple_module_core.invalidation import InvalidationBus
20
+
19
21
  if TYPE_CHECKING:
20
22
  from collections.abc import Callable
21
23
 
22
- from inertia import InertiaConfig
23
24
  from simple_module_db.session import DatabaseState
24
25
  from simple_module_hosting.settings import Settings
26
+ from simple_module_inertia import InertiaConfig
25
27
 
26
28
  from simple_module_core.audit_links import AuditLinkRegistry
27
29
  from simple_module_core.design_packs import DesignPackRegistry
@@ -90,6 +92,11 @@ class Services:
90
92
  i18n_registry: I18nRegistry
91
93
  inertia_config: InertiaConfig
92
94
  modules: tuple[ModuleBase, ...]
95
+ #: Cross-process cache invalidation. Defaulted for the same reason as
96
+ #: ``diagnostics``: an app assembled outside ``create_app`` (tests, a
97
+ #: single-module harness) gets a working in-process bus with no
98
+ #: subscribers rather than a ``TypeError`` at construction.
99
+ invalidation: InvalidationBus = field(default_factory=InvalidationBus)
93
100
  #: Defaulted so an app built outside ``create_app`` still has a holder to
94
101
  #: read; that one simply reports the checks as unsupported.
95
102
  diagnostics: DiagnosticsState = field(default_factory=DiagnosticsState)
@@ -0,0 +1,115 @@
1
+ """SM024 — unique keys on tenant-scoped tables must include tenant_id."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from simple_module_core.diagnostics._tenancy import check_tenant_unique_keys
6
+ from sqlalchemy import Column, Index, Integer, MetaData, String, Table, UniqueConstraint, func
7
+
8
+
9
+ def _codes(table: Table) -> list[str]:
10
+ return [d.code for d in check_tenant_unique_keys([table], "demo")]
11
+
12
+
13
+ def test_global_unique_column_on_tenant_table_warns():
14
+ t = Table(
15
+ "demo_a",
16
+ MetaData(),
17
+ Column("id", Integer, primary_key=True),
18
+ Column("tenant_id", String(50)),
19
+ Column("slug", String(50), unique=True),
20
+ )
21
+ assert _codes(t) == ["SM024"]
22
+
23
+
24
+ def test_per_tenant_unique_index_is_fine():
25
+ t = Table(
26
+ "demo_b",
27
+ MetaData(),
28
+ Column("id", Integer, primary_key=True),
29
+ Column("tenant_id", String(50)),
30
+ Column("slug", String(50)),
31
+ Index("ix_demo_b_tenant_slug", "tenant_id", "slug", unique=True),
32
+ UniqueConstraint("tenant_id", "id"),
33
+ )
34
+ assert _codes(t) == []
35
+
36
+
37
+ def test_unique_index_without_tenant_warns():
38
+ t = Table(
39
+ "demo_c",
40
+ MetaData(),
41
+ Column("id", Integer, primary_key=True),
42
+ Column("tenant_id", String(50)),
43
+ Column("locale", String(8)),
44
+ Column("slug", String(50)),
45
+ Index("ix_demo_c_locale_slug", "locale", "slug", unique=True),
46
+ )
47
+ diags = check_tenant_unique_keys([t], "demo")
48
+ assert [d.code for d in diags] == ["SM024"]
49
+ assert "ix_demo_c_locale_slug" in diags[0].message
50
+
51
+
52
+ def test_expression_index_counts_as_global():
53
+ t = Table(
54
+ "demo_d",
55
+ MetaData(),
56
+ Column("id", Integer, primary_key=True),
57
+ Column("tenant_id", String(50)),
58
+ Column("email", String(50)),
59
+ )
60
+ Index("ix_demo_d_email_lower", func.lower(t.c.email), unique=True)
61
+ assert _codes(t) == ["SM024"]
62
+
63
+
64
+ def test_non_tenant_table_is_ignored():
65
+ t = Table(
66
+ "demo_e",
67
+ MetaData(),
68
+ Column("id", Integer, primary_key=True),
69
+ Column("slug", String(50), unique=True),
70
+ )
71
+ assert _codes(t) == []
72
+
73
+
74
+ def test_module_tables_only_counts_mixin_models(monkeypatch):
75
+ import sys
76
+ import types
77
+
78
+ from simple_module_core.diagnostics._tenancy import module_tables
79
+
80
+ class MultiTenantMixin: # stands in for simple_module_db's, matched by name
81
+ pass
82
+
83
+ md = MetaData()
84
+ scoped = type(
85
+ "Scoped",
86
+ (MultiTenantMixin,),
87
+ {
88
+ "__table__": Table(
89
+ "demo_scoped",
90
+ md,
91
+ Column("id", Integer, primary_key=True),
92
+ Column("tenant_id", String(5)),
93
+ )
94
+ },
95
+ )
96
+ plain = type(
97
+ "Plain",
98
+ (),
99
+ {
100
+ "__table__": Table(
101
+ "demo_plain",
102
+ md,
103
+ Column("id", Integer, primary_key=True),
104
+ Column("tenant_id", String(5)),
105
+ )
106
+ },
107
+ )
108
+ pkg = types.ModuleType("sm024pkg")
109
+ models = types.ModuleType("sm024pkg.models")
110
+ models.Scoped, models.Plain = scoped, plain
111
+ monkeypatch.setitem(sys.modules, "sm024pkg", pkg)
112
+ monkeypatch.setitem(sys.modules, "sm024pkg.models", models)
113
+
114
+ mod_cls = type("DemoModule", (), {"__module__": "sm024pkg.module"})
115
+ assert [t.name for t in module_tables(mod_cls())] == ["demo_scoped"]
@@ -0,0 +1,261 @@
1
+ """The invalidation bus: local fan-out, the wire, and the origin filter.
2
+
3
+ The properties worth pinning down are the ones a consumer's correctness rests
4
+ on. A revocation that reaches the publishing worker but silently skips the
5
+ others is the bug GH #318 is about, and a listener that dies on one malformed
6
+ payload reintroduces it for the life of the process.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pytest
12
+ from simple_module_core.invalidation import WIRE_VERSION, Invalidation, InvalidationBus
13
+
14
+
15
+ class _RecordingTransport:
16
+ """Captures what would have gone on the wire."""
17
+
18
+ def __init__(self) -> None:
19
+ self.sent: list[str] = []
20
+
21
+ async def publish(self, message: str) -> None:
22
+ self.sent.append(message)
23
+
24
+
25
+ class _BrokenTransport:
26
+ async def publish(self, message: str) -> None:
27
+ raise ConnectionError("redis is down")
28
+
29
+
30
+ class TestLocalFanOut:
31
+ async def test_publish_runs_subscribers_on_the_channel(self):
32
+ bus = InvalidationBus()
33
+ seen: list[Invalidation] = []
34
+ bus.subscribe("users.session_version", seen.append)
35
+
36
+ await bus.publish("users.session_version", key="abc")
37
+
38
+ assert [(i.channel, i.key) for i in seen] == [("users.session_version", "abc")]
39
+
40
+ async def test_other_channels_are_untouched(self):
41
+ bus = InvalidationBus()
42
+ calls: list[str] = []
43
+ bus.subscribe("a", lambda inv: calls.append("a"))
44
+ bus.subscribe("b", lambda inv: calls.append("b"))
45
+
46
+ await bus.publish("a")
47
+
48
+ assert calls == ["a"]
49
+
50
+ async def test_async_and_sync_handlers_both_run(self):
51
+ bus = InvalidationBus()
52
+ calls: list[str] = []
53
+
54
+ async def async_handler(inv):
55
+ calls.append("async")
56
+
57
+ bus.subscribe("c", async_handler)
58
+ bus.subscribe("c", lambda inv: calls.append("sync"))
59
+
60
+ await bus.publish("c")
61
+
62
+ assert sorted(calls) == ["async", "sync"]
63
+
64
+ async def test_one_failing_handler_does_not_starve_the_others(self):
65
+ """One module's broken eviction must not leave another's cache stale."""
66
+ bus = InvalidationBus()
67
+ calls: list[str] = []
68
+
69
+ def boom(inv):
70
+ raise RuntimeError("bad handler")
71
+
72
+ bus.subscribe("c", boom)
73
+ bus.subscribe("c", lambda inv: calls.append("ran"))
74
+
75
+ await bus.publish("c")
76
+
77
+ assert calls == ["ran"]
78
+
79
+ async def test_publish_with_no_subscribers_is_a_no_op(self):
80
+ await InvalidationBus().publish("nobody.listening")
81
+
82
+ def test_channels_reports_what_has_subscribers(self):
83
+ bus = InvalidationBus()
84
+ bus.subscribe("z", lambda inv: None)
85
+ bus.subscribe("a", lambda inv: None)
86
+ assert bus.channels == ("a", "z")
87
+
88
+
89
+ class TestTransport:
90
+ async def test_publish_forwards_the_encoded_invalidation(self):
91
+ bus = InvalidationBus()
92
+ transport = _RecordingTransport()
93
+ bus.set_transport(transport)
94
+
95
+ await bus.publish("users.session_version", key="u1")
96
+
97
+ assert len(transport.sent) == 1
98
+ decoded = Invalidation.from_wire(transport.sent[0])
99
+ assert decoded is not None
100
+ assert (decoded.channel, decoded.key, decoded.origin) == (
101
+ "users.session_version",
102
+ "u1",
103
+ bus.origin,
104
+ )
105
+
106
+ async def test_no_transport_means_local_only(self):
107
+ bus = InvalidationBus()
108
+ assert bus.has_transport is False
109
+ seen: list[Invalidation] = []
110
+ bus.subscribe("c", seen.append)
111
+ await bus.publish("c")
112
+ assert len(seen) == 1
113
+
114
+ async def test_a_broken_transport_still_applies_locally(self):
115
+ """The caller has already committed the write; a dead Redis is not a 500."""
116
+ bus = InvalidationBus()
117
+ bus.set_transport(_BrokenTransport())
118
+ seen: list[Invalidation] = []
119
+ bus.subscribe("c", seen.append)
120
+
121
+ await bus.publish("c", key="k")
122
+
123
+ assert len(seen) == 1
124
+
125
+ async def test_clear_transport_drops_back_to_in_process(self):
126
+ bus = InvalidationBus()
127
+ transport = _RecordingTransport()
128
+ bus.set_transport(transport)
129
+ bus.clear_transport()
130
+
131
+ await bus.publish("c")
132
+
133
+ assert transport.sent == []
134
+ assert bus.has_transport is False
135
+
136
+
137
+ class TestDeliver:
138
+ async def test_a_remote_message_runs_the_handlers(self):
139
+ publisher = InvalidationBus()
140
+ subscriber = InvalidationBus()
141
+ seen: list[Invalidation] = []
142
+ subscriber.subscribe("users.session_version", seen.append)
143
+
144
+ transport = _RecordingTransport()
145
+ publisher.set_transport(transport)
146
+ await publisher.publish("users.session_version", key="u9")
147
+ await subscriber.deliver(transport.sent[0])
148
+
149
+ assert [i.key for i in seen] == ["u9"]
150
+
151
+ async def test_a_bus_ignores_its_own_broadcast_coming_back(self):
152
+ """Publish already ran the handlers; the echo must not run them twice."""
153
+ bus = InvalidationBus()
154
+ transport = _RecordingTransport()
155
+ bus.set_transport(transport)
156
+ seen: list[Invalidation] = []
157
+ bus.subscribe("c", seen.append)
158
+
159
+ await bus.publish("c", key="k")
160
+ await bus.deliver(transport.sent[0])
161
+
162
+ assert len(seen) == 1
163
+
164
+ async def test_bytes_are_accepted(self):
165
+ """redis-py yields bytes unless the client decodes responses."""
166
+ bus = InvalidationBus()
167
+ seen: list[Invalidation] = []
168
+ bus.subscribe("c", seen.append)
169
+
170
+ await bus.deliver(Invalidation(channel="c", key="k", origin="elsewhere").to_wire().encode())
171
+
172
+ assert [i.key for i in seen] == ["k"]
173
+
174
+ @pytest.mark.parametrize(
175
+ "message",
176
+ [
177
+ b"not json at all",
178
+ b"[]",
179
+ b'{"v": 999, "channel": "c", "key": "k"}',
180
+ b'{"channel": "c"}',
181
+ b'{"v": 1, "channel": 7, "key": "k"}',
182
+ b'{"v": 1, "channel": "c", "key": 7}',
183
+ ],
184
+ )
185
+ async def test_junk_is_dropped_rather_than_raised(self, message):
186
+ """A shared Redis server carries other people's traffic."""
187
+ bus = InvalidationBus()
188
+ seen: list[Invalidation] = []
189
+ bus.subscribe("c", seen.append)
190
+
191
+ await bus.deliver(message)
192
+
193
+ assert seen == []
194
+
195
+ async def test_a_key_less_message_survives_the_round_trip(self):
196
+ """``key=None`` means "forget the whole channel" and must stay None."""
197
+ bus = InvalidationBus()
198
+ seen: list[Invalidation] = []
199
+ bus.subscribe("c", seen.append)
200
+
201
+ await bus.deliver(Invalidation(channel="c", origin="elsewhere").to_wire())
202
+
203
+ assert [i.key for i in seen] == [None]
204
+
205
+
206
+ class TestRelaying:
207
+ """A worker relays what it publishes even when it caches nothing itself."""
208
+
209
+ async def test_a_channel_with_no_local_subscribers_still_broadcasts(self):
210
+ """Otherwise a heterogeneous fleet loses messages by luck of routing.
211
+
212
+ The worker that serves a revocation need not be one that caches the
213
+ counter — a module can publish for caches that only *other* modules keep.
214
+ If the transport were skipped when nothing local listens, whether the
215
+ other workers heard would depend on which worker got the request.
216
+ """
217
+ bus = InvalidationBus()
218
+ transport = _RecordingTransport()
219
+ bus.set_transport(transport)
220
+
221
+ await bus.publish("nobody.local", key="k")
222
+
223
+ assert len(transport.sent) == 1
224
+
225
+
226
+ class TestUnknownChannels:
227
+ async def test_a_remote_message_for_an_unsubscribed_channel_is_a_no_op(self):
228
+ """A shared transport carries every channel; most are not ours."""
229
+ bus = InvalidationBus()
230
+ seen: list[Invalidation] = []
231
+ bus.subscribe("mine", seen.append)
232
+
233
+ await bus.deliver(Invalidation(channel="theirs", key="k", origin="other").to_wire())
234
+
235
+ assert seen == []
236
+
237
+ async def test_non_utf8_bytes_are_dropped_rather_than_raised(self):
238
+ """``json.loads`` raises ``UnicodeDecodeError`` on these, not ValueError.
239
+
240
+ A listener that died on one such frame would take cross-process
241
+ invalidation down for the life of the process, and a shared Redis
242
+ database is exactly where arbitrary bytes come from.
243
+ """
244
+ bus = InvalidationBus()
245
+ seen: list[Invalidation] = []
246
+ bus.subscribe("c", seen.append)
247
+
248
+ await bus.deliver(b"\xff\xfe\x00not json")
249
+
250
+ assert seen == []
251
+
252
+
253
+ class TestOrigin:
254
+ def test_two_buses_do_not_share_an_origin(self):
255
+ """A shared origin would make each ignore the other — silently."""
256
+ assert InvalidationBus().origin != InvalidationBus().origin
257
+
258
+ def test_wire_carries_the_current_version(self):
259
+ import json
260
+
261
+ assert json.loads(Invalidation(channel="c").to_wire())["v"] == WIRE_VERSION
@@ -0,0 +1,62 @@
1
+ """Handler-registration hazards, split from ``test_invalidation.py``.
2
+
3
+ That file asks whether the bus delivers the right message to the right handler;
4
+ this one asks what happens when the set of handlers changes while the bus is
5
+ walking it. Different question, and together they crossed the 300-line cap.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+
12
+ from simple_module_core.invalidation import InvalidationBus
13
+
14
+
15
+ class TestReentrantSubscribe:
16
+ """A handler that subscribes during dispatch must not wedge the event loop.
17
+
18
+ ``_dispatch`` used to iterate the live handler list, so a handler that
19
+ re-armed itself appended to the list being walked. With sync handlers that
20
+ loop has no await point, so it did not merely recurse — it took the event
21
+ loop with it, and neither ``asyncio.timeout`` nor the per-handler ``except``
22
+ could intervene. Found by exploratory QA at 3.1 million invocations from one
23
+ ``publish``; the fix is to iterate a snapshot.
24
+ """
25
+
26
+ async def test_a_self_rearming_handler_terminates(self):
27
+ bus = InvalidationBus()
28
+ calls: list[int] = []
29
+
30
+ def rearm(inv):
31
+ calls.append(1)
32
+ bus.subscribe("orders.totals", rearm)
33
+
34
+ bus.subscribe("orders.totals", rearm)
35
+
36
+ # A real timeout, because the failure mode is a hang rather than a wrong
37
+ # answer. It can only fire if the loop yields, which is exactly what the
38
+ # bug prevented — so on a regression this test times out and fails.
39
+ async with asyncio.timeout(10):
40
+ await bus.publish("orders.totals", key="k")
41
+
42
+ assert calls == [1], "the handler ran more than once within a single publish"
43
+
44
+ async def test_a_handler_added_during_dispatch_waits_for_the_next_publish(self):
45
+ """The snapshot's other consequence, stated so it is not a surprise."""
46
+ bus = InvalidationBus()
47
+ calls: list[str] = []
48
+
49
+ def adder(inv):
50
+ calls.append("adder")
51
+ bus.subscribe("c", lambda i: calls.append("added"))
52
+
53
+ bus.subscribe("c", adder)
54
+
55
+ async with asyncio.timeout(10):
56
+ await bus.publish("c")
57
+ assert calls == ["adder"]
58
+
59
+ calls.clear()
60
+ async with asyncio.timeout(10):
61
+ await bus.publish("c")
62
+ assert "added" in calls
@@ -108,6 +108,24 @@ class TestSm003PageRenderResolution:
108
108
  mod = _FakeModule(meta=_FakeMeta(name="M"))
109
109
  assert "admin/Browse" in find_render_calls(mod, src_dir) # pyright: ignore[reportArgumentType]
110
110
 
111
+ async def test_colocated_test_file_is_not_flagged_as_orphan_page(self, tmp_path: Path):
112
+ """A ``*.test.tsx``/``*.spec.tsx`` beside a page is a test, not a page.
113
+
114
+ Regression test: ``collect_tsx_pages`` globbed ``pages/**/*.tsx``
115
+ with no exclusion, so a colocated ``Browse.test.tsx`` (the same
116
+ pattern Vite's import.meta.glob now excludes) was reported as an
117
+ SM003 orphan page since nothing renders "M/Browse.test".
118
+ """
119
+ src_dir = tmp_path / "m" / "m"
120
+ (src_dir / "pages").mkdir(parents=True)
121
+ (src_dir / "pages" / "Browse.tsx").write_text("export default function B() {}")
122
+ (src_dir / "pages" / "Browse.test.tsx").write_text("test('renders', () => {})")
123
+ (src_dir / "pages" / "Browse.spec.tsx").write_text("test('renders', () => {})")
124
+ (src_dir / "endpoints.py").write_text(
125
+ 'async def view(inertia):\n return await inertia.render("M/Browse", {})\n'
126
+ )
127
+ assert self._diags(src_dir, "M") == []
128
+
111
129
  async def test_fstring_with_unknown_name_stays_flagged(self, tmp_path: Path):
112
130
  """An f-string over a runtime value is not static — don't guess."""
113
131
  src_dir = tmp_path / "m" / "m"