simple-module-core 0.0.27__tar.gz → 0.0.29__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 (55) hide show
  1. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/PKG-INFO +1 -1
  2. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/pyproject.toml +1 -1
  3. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/__init__.py +6 -0
  4. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/__main__.py +10 -1
  5. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/discovery.py +65 -0
  6. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/i18n.py +28 -9
  7. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/module.py +9 -0
  8. simple_module_core-0.0.29/tests/test_auth_provider_selection.py +133 -0
  9. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_i18n.py +23 -0
  10. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/.gitignore +0 -0
  11. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/LICENSE +0 -0
  12. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/README.md +0 -0
  13. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/design_packs.py +0 -0
  14. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/diagnostics/__init__.py +0 -0
  15. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/diagnostics/_coupling.py +0 -0
  16. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/diagnostics/_css.py +0 -0
  17. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/diagnostics/_i18n.py +0 -0
  18. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  19. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  20. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/diagnostics/_migration.py +0 -0
  21. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/diagnostics/_module.py +0 -0
  22. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/diagnostics/_pages.py +0 -0
  23. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/diagnostics/_runner.py +0 -0
  24. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/diagnostics/_types.py +0 -0
  25. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/dotenv.py +0 -0
  26. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/environments.py +0 -0
  27. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/events.py +0 -0
  28. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/exceptions.py +0 -0
  29. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/feature_flags.py +0 -0
  30. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/health.py +0 -0
  31. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/menu.py +0 -0
  32. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/permissions.py +0 -0
  33. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/public_routes.py +0 -0
  34. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/py.typed +0 -0
  35. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/services.py +0 -0
  36. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/simple_module_core/versioning.py +0 -0
  37. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_css_diagnostics.py +0 -0
  38. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_css_scanner.py +0 -0
  39. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_design_packs.py +0 -0
  40. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_diagnostics.py +0 -0
  41. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_discovery.py +0 -0
  42. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_dotenv.py +0 -0
  43. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_environments.py +0 -0
  44. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_events.py +0 -0
  45. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_feature_flags.py +0 -0
  46. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_feature_flags_decorator.py +0 -0
  47. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_health_registry.py +0 -0
  48. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_i18n_diagnostics.py +0 -0
  49. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_menu.py +0 -0
  50. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_module_base.py +0 -0
  51. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_module_diagnostics.py +0 -0
  52. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_permissions.py +0 -0
  53. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_public_routes.py +0 -0
  54. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_services.py +0 -0
  55. {simple_module_core-0.0.27 → simple_module_core-0.0.29}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_core
3
- Version: 0.0.27
3
+ Version: 0.0.29
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.27"
3
+ version = "0.0.29"
4
4
  description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -8,8 +8,11 @@ from simple_module_core.diagnostics import (
8
8
  run_diagnostics,
9
9
  )
10
10
  from simple_module_core.discovery import (
11
+ DEFAULT_AUTH_PROVIDER,
11
12
  discover_modules,
12
13
  get_module_package_name,
14
+ resolve_auth_provider,
15
+ select_auth_provider,
13
16
  topological_sort,
14
17
  )
15
18
  from simple_module_core.events import Event, EventBus
@@ -39,6 +42,7 @@ from simple_module_core.services import Services
39
42
  from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework_compatibility
40
43
 
41
44
  __all__ = [
45
+ "DEFAULT_AUTH_PROVIDER",
42
46
  "FRAMEWORK_API_VERSION",
43
47
  "CircularDependencyError",
44
48
  "DesignPack",
@@ -77,6 +81,8 @@ __all__ = [
77
81
  "is_flag_enabled",
78
82
  "print_diagnostics",
79
83
  "require_flag",
84
+ "resolve_auth_provider",
80
85
  "run_diagnostics",
86
+ "select_auth_provider",
81
87
  "topological_sort",
82
88
  ]
@@ -25,7 +25,12 @@ from simple_module_core.diagnostics import (
25
25
  print_diagnostics,
26
26
  run_diagnostics,
27
27
  )
28
- from simple_module_core.discovery import discover_modules, topological_sort
28
+ from simple_module_core.discovery import (
29
+ discover_modules,
30
+ resolve_auth_provider,
31
+ select_auth_provider,
32
+ topological_sort,
33
+ )
29
34
  from simple_module_core.dotenv import parse_dotenv
30
35
  from simple_module_core.exceptions import InvalidModuleError
31
36
 
@@ -100,6 +105,10 @@ def main() -> int:
100
105
  print("No modules discovered. Is the project installed (`uv sync --all-packages`)?")
101
106
  return 0
102
107
 
108
+ # Mirror the host: only the configured auth provider is active, so doctor
109
+ # reports on the same module set the app actually boots with.
110
+ modules = select_auth_provider(modules, resolve_auth_provider())
111
+
103
112
  # Topological sort surfaces CircularDependencyError early.
104
113
  modules = topological_sort(modules)
105
114
 
@@ -6,6 +6,7 @@ import logging
6
6
  from collections.abc import Sequence
7
7
  from importlib.metadata import entry_points
8
8
 
9
+ from simple_module_core.dotenv import env_str, load_dotenv_into_environ
9
10
  from simple_module_core.exceptions import CircularDependencyError, InvalidModuleError
10
11
  from simple_module_core.module import ModuleBase, ModuleMeta
11
12
 
@@ -13,6 +14,9 @@ logger = logging.getLogger(__name__)
13
14
 
14
15
  ENTRY_POINT_GROUP = "simple_module"
15
16
 
17
+ DEFAULT_AUTH_PROVIDER = "users"
18
+ """Auth provider activated when several are installed — see select_auth_provider."""
19
+
16
20
 
17
21
  def get_module_package_name(module: ModuleBase) -> str:
18
22
  """Return the top-level Python package a module instance belongs to.
@@ -121,6 +125,67 @@ def discover_modules(
121
125
  return modules
122
126
 
123
127
 
128
+ def resolve_auth_provider() -> str:
129
+ """Return the configured auth provider name, honoring ``.env``.
130
+
131
+ For tools that run outside the host process (``make doctor``, ``smpy host
132
+ gen-pages``) and so have no ``BootstrapSettings`` to read from. The host
133
+ passes ``settings.auth_provider`` to :func:`select_auth_provider` instead.
134
+ """
135
+ load_dotenv_into_environ()
136
+ return env_str("SM_AUTH_PROVIDER", DEFAULT_AUTH_PROVIDER)
137
+
138
+
139
+ def select_auth_provider(
140
+ modules: Sequence[ModuleBase],
141
+ preferred: str = DEFAULT_AUTH_PROVIDER,
142
+ *,
143
+ strict: bool = False,
144
+ ) -> list[ModuleBase]:
145
+ """Drop every auth provider except ``preferred`` when several are installed.
146
+
147
+ ``users`` and ``keycloak`` both claim ``app.state.auth.auth_provider``, so
148
+ only one can be active — that's what SM020 reports. A dev workspace has
149
+ both installed (``uv sync --all-packages`` installs every member), which
150
+ would otherwise fail the boot on a fresh clone. Selecting one here keeps
151
+ the alternative provider installed and testable but inert.
152
+
153
+ Nothing is dropped when fewer than two providers are present — a host that
154
+ installs only ``keycloak`` keeps it whatever ``preferred`` says.
155
+
156
+ A ``preferred`` that names none of the installed providers is a
157
+ misconfiguration (typically a typo). Leaving every provider mounted means
158
+ two modules write ``app.state.auth.auth_provider`` and the topological
159
+ order silently decides the winner, so this always warns, and with
160
+ ``strict`` raises :class:`InvalidModuleError`. Callers in production
161
+ should pass ``strict=True``: SM020 would catch this, but diagnostics run
162
+ in development only, so nothing else reports it in a deployed app.
163
+ """
164
+ providers = [m for m in modules if getattr(m, "_is_auth_provider", False)]
165
+ if len(providers) < 2:
166
+ return list(modules)
167
+
168
+ installed = ", ".join(m.meta.name for m in providers)
169
+ keep = next((m for m in providers if m.meta.name.lower() == preferred.lower()), None)
170
+ if keep is None:
171
+ msg = (
172
+ f"SM_AUTH_PROVIDER={preferred!r} matches none of the installed auth "
173
+ f"providers ({installed}); all of them stay mounted and the last one "
174
+ "registered wins"
175
+ )
176
+ if strict:
177
+ raise InvalidModuleError(msg)
178
+ logger.warning("%s — set it to one of the names above", msg)
179
+ return list(modules)
180
+
181
+ logger.info(
182
+ "Auth provider '%s' active (SM_AUTH_PROVIDER); skipping also-installed: %s",
183
+ keep.meta.name,
184
+ ", ".join(m.meta.name for m in providers if m is not keep),
185
+ )
186
+ return [m for m in modules if m is keep or not getattr(m, "_is_auth_provider", False)]
187
+
188
+
124
189
  def topological_sort(modules: Sequence[ModuleBase]) -> list[ModuleBase]:
125
190
  """Sort modules so dependencies come before dependents.
126
191
 
@@ -78,7 +78,7 @@ class I18nRegistry:
78
78
  def __init__(self, default_locale: str, supported_locales: list[str]) -> None:
79
79
  self.default_locale = default_locale
80
80
  self.supported_locales = list(supported_locales)
81
- self._sources: list[tuple[str, Path]] = []
81
+ self._sources: list[tuple[str, Path, str]] = []
82
82
  self._messages: dict[str, dict[str, str]] = {}
83
83
  # Immutable views into ``_messages`` — handed out by ``messages()`` to
84
84
  # avoid a per-call dict copy. Rebuilt whenever ``load()`` runs.
@@ -88,15 +88,22 @@ class I18nRegistry:
88
88
  # avoids per-request dict copies that used to dominate allocations on the
89
89
  # Inertia render path.
90
90
  self._message_snapshots: dict[str, dict[str, str]] = {}
91
+ self._public_snapshots: dict[str, dict[str, str]] = {}
91
92
  self._available_locales: tuple[str, ...] = ()
92
93
  self._available_locales_list: list[str] = []
93
94
  self._empty_view: MappingProxyType[str, str] = MappingProxyType({})
94
95
  self._empty_snapshot: dict[str, str] = {}
95
96
  self._loaded = False
96
97
 
97
- def add_source(self, namespace: str, locale_dir: Path) -> None:
98
- """Queue a module's locale directory for loading under a namespace."""
99
- self._sources.append((namespace, Path(locale_dir)))
98
+ def add_source(self, namespace: str, locale_dir: Path, *, audience: str = "public") -> None:
99
+ """Queue a module's locale directory for loading under a namespace.
100
+
101
+ ``audience="admin"`` keeps the namespace out of the public snapshot
102
+ (:meth:`messages_snapshot` with ``include_admin=False``) so catalogs
103
+ for login-gated UI aren't shipped to anonymous visitors. Server-side
104
+ lookups (:meth:`messages`) always see every namespace.
105
+ """
106
+ self._sources.append((namespace, Path(locale_dir), audience))
100
107
 
101
108
  def load(self) -> None:
102
109
  """Read and flatten all registered JSON files.
@@ -105,8 +112,11 @@ class I18nRegistry:
105
112
  warning but do not raise. Malformed JSON raises ValueError.
106
113
  """
107
114
  self._messages = {locale: {} for locale in self.supported_locales}
115
+ public_messages: dict[str, dict[str, str]] = {
116
+ locale: {} for locale in self.supported_locales
117
+ }
108
118
 
109
- for namespace, locale_dir in self._sources:
119
+ for namespace, locale_dir, audience in self._sources:
110
120
  for locale in self.supported_locales:
111
121
  path = locale_dir / f"{locale}.json"
112
122
  if not path.is_file():
@@ -124,6 +134,8 @@ class I18nRegistry:
124
134
  raise ValueError(f"{path} must contain a JSON object at the top level")
125
135
  flat = flatten_messages(raw, prefix=namespace)
126
136
  self._messages[locale].update(flat)
137
+ if audience != "admin":
138
+ public_messages[locale].update(flat)
127
139
 
128
140
  # Cache the derived views now that loading is complete. Downstream
129
141
  # (middleware, translator, switcher) reads these on every request.
@@ -131,8 +143,10 @@ class I18nRegistry:
131
143
  locale: MappingProxyType(msgs) for locale, msgs in self._messages.items()
132
144
  }
133
145
  # Plain-dict snapshots for serialization callers. ``dict(msgs)`` runs
134
- # once here rather than on every Inertia render.
146
+ # once here rather than on every Inertia render. The public variant
147
+ # (admin namespaces excluded) is what anonymous visitors receive.
135
148
  self._message_snapshots = {locale: dict(msgs) for locale, msgs in self._messages.items()}
149
+ self._public_snapshots = public_messages
136
150
  self._available_locales = tuple(locale for locale, msgs in self._messages.items() if msgs)
137
151
  self._available_locales_list = list(self._available_locales)
138
152
  self._loaded = True
@@ -165,7 +179,7 @@ class I18nRegistry:
165
179
  return self._empty_view
166
180
  return MappingProxyType(raw)
167
181
 
168
- def messages_snapshot(self, locale: str) -> dict[str, str]:
182
+ def messages_snapshot(self, locale: str, *, include_admin: bool = True) -> dict[str, str]:
169
183
  """Plain-dict snapshot for callers that JSON-serialize the result.
170
184
 
171
185
  Built once at :meth:`load` time and handed out by reference on every
@@ -173,12 +187,17 @@ class I18nRegistry:
173
187
  corrupts subsequent responses. Used by the Inertia shared-props builder
174
188
  where it sits on the request hot path; prior to this method,
175
189
  ``dict(messages(locale))`` per request was the top own-code allocator.
190
+
191
+ ``include_admin=False`` returns the variant without ``audience="admin"``
192
+ namespaces — what anonymous visitors are served.
176
193
  """
177
- snapshot = self._message_snapshots.get(locale)
194
+ pool = self._message_snapshots if include_admin else self._public_snapshots
195
+ snapshot = pool.get(locale)
178
196
  if snapshot is not None:
179
197
  return snapshot
180
198
  # Fallback for tests that skip ``load()``: synthesize the snapshot on
181
- # demand from whatever ``_messages`` holds.
199
+ # demand from whatever ``_messages`` holds (audience information only
200
+ # exists for sources that went through ``load()``).
182
201
  raw = self._messages.get(locale)
183
202
  if raw is None:
184
203
  return self._empty_snapshot
@@ -35,6 +35,15 @@ class ModuleMeta:
35
35
  installed ``simple_module_core.FRAMEWORK_API_VERSION`` does not satisfy it.
36
36
  When ``None``, no compatibility check is performed (legacy modules).
37
37
  """
38
+ i18n_audience: str = "public"
39
+ """Who this module's locale catalog is shipped to: ``"public"`` or ``"admin"``.
40
+
41
+ ``"public"`` (the default) ships the catalog in every Inertia payload.
42
+ ``"admin"`` ships it only to authenticated users — declare it on modules
43
+ whose UI sits entirely behind login (settings, permissions, dashboards) so
44
+ anonymous visitors don't download admin form labels on every public page.
45
+ The catalog is always available server-side (``Translator``) either way.
46
+ """
38
47
 
39
48
 
40
49
  class ModuleBase(ABC):
@@ -0,0 +1,133 @@
1
+ """Tests for select_auth_provider — picking one of several installed providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+
8
+ import pytest
9
+ from simple_module_core.discovery import (
10
+ DEFAULT_AUTH_PROVIDER,
11
+ resolve_auth_provider,
12
+ select_auth_provider,
13
+ )
14
+ from simple_module_core.exceptions import InvalidModuleError
15
+ from simple_module_core.module import ModuleBase, ModuleMeta
16
+
17
+
18
+ class FakeUsers(ModuleBase):
19
+ meta = ModuleMeta(name="Users")
20
+ _is_auth_provider = True
21
+
22
+
23
+ class FakeKeycloak(ModuleBase):
24
+ meta = ModuleMeta(name="Keycloak")
25
+ _is_auth_provider = True
26
+
27
+
28
+ class FakeDashboard(ModuleBase):
29
+ meta = ModuleMeta(name="Dashboard")
30
+
31
+
32
+ def _names(modules):
33
+ return [m.meta.name for m in modules]
34
+
35
+
36
+ class TestSelectAuthProvider:
37
+ def test_default_keeps_users_and_drops_keycloak(self):
38
+ """Both installed (the dev workspace) → the default provider wins, no SM020."""
39
+ result = select_auth_provider([FakeUsers(), FakeKeycloak(), FakeDashboard()])
40
+ assert _names(result) == ["Users", "Dashboard"]
41
+
42
+ def test_preferred_keycloak_drops_users(self):
43
+ result = select_auth_provider([FakeUsers(), FakeKeycloak()], "keycloak")
44
+ assert _names(result) == ["Keycloak"]
45
+
46
+ def test_match_is_case_insensitive(self):
47
+ result = select_auth_provider([FakeUsers(), FakeKeycloak()], "KeyCloak")
48
+ assert _names(result) == ["Keycloak"]
49
+
50
+ def test_non_provider_modules_keep_their_order(self):
51
+ modules = [FakeDashboard(), FakeKeycloak(), FakeUsers()]
52
+ assert _names(select_auth_provider(modules)) == ["Dashboard", "Users"]
53
+
54
+ def test_lone_provider_survives_a_mismatched_preference(self):
55
+ """A keycloak-only host keeps keycloak even at the default preference."""
56
+ result = select_auth_provider([FakeKeycloak(), FakeDashboard()], "users")
57
+ assert _names(result) == ["Keycloak", "Dashboard"]
58
+
59
+ def test_no_providers_is_untouched(self):
60
+ """SM021 territory — nothing to select, and nothing to drop."""
61
+ assert _names(select_auth_provider([FakeDashboard()])) == ["Dashboard"]
62
+
63
+ def test_unknown_preference_keeps_every_provider(self):
64
+ """Naming a provider that isn't installed must not silently pick one."""
65
+ result = select_auth_provider([FakeUsers(), FakeKeycloak()], "oidc")
66
+ assert _names(result) == ["Users", "Keycloak"]
67
+
68
+ def test_unknown_preference_warns(self, caplog):
69
+ """SM020 only runs in development — a typo must not pass in silence."""
70
+ with caplog.at_level(logging.WARNING, logger="simple_module_core.discovery"):
71
+ select_auth_provider([FakeUsers(), FakeKeycloak()], "keycloack")
72
+ warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
73
+ assert warnings, "an unrecognised provider name logged nothing"
74
+ assert "keycloack" in warnings[0].getMessage()
75
+
76
+ def test_unknown_preference_raises_when_strict(self):
77
+ """Production boots strict, where mounting both providers is not viable."""
78
+ with pytest.raises(InvalidModuleError, match="keycloack"):
79
+ select_auth_provider([FakeUsers(), FakeKeycloak()], "keycloack", strict=True)
80
+
81
+ def test_strict_is_quiet_on_a_valid_selection(self):
82
+ result = select_auth_provider([FakeUsers(), FakeKeycloak()], "users", strict=True)
83
+ assert _names(result) == ["Users"]
84
+
85
+ def test_strict_ignores_a_mismatch_when_only_one_is_installed(self):
86
+ """Nothing is ambiguous with a single provider, so strict must not raise."""
87
+ result = select_auth_provider([FakeKeycloak()], "oidc", strict=True)
88
+ assert _names(result) == ["Keycloak"]
89
+
90
+ def test_selection_is_logged(self, caplog):
91
+ with caplog.at_level(logging.INFO, logger="simple_module_core.discovery"):
92
+ select_auth_provider([FakeUsers(), FakeKeycloak()])
93
+ assert any("Keycloak" in rec.getMessage() for rec in caplog.records)
94
+
95
+ def test_input_is_not_mutated(self):
96
+ modules = [FakeUsers(), FakeKeycloak()]
97
+ select_auth_provider(modules)
98
+ assert len(modules) == 2
99
+
100
+
101
+ class TestResolveAuthProvider:
102
+ @pytest.fixture(autouse=True)
103
+ def _isolated_environ(self, monkeypatch, tmp_path):
104
+ """Swap in a throwaway ``os.environ``.
105
+
106
+ ``resolve_auth_provider`` merges ``.env`` into the real environment via
107
+ ``setdefault``, which ``monkeypatch.delenv(raising=False)`` can't undo —
108
+ it records nothing for a key that was already absent. Without this the
109
+ provider chosen here would leak into every later test in the session.
110
+ """
111
+ monkeypatch.setattr(os, "environ", dict(os.environ))
112
+ monkeypatch.setenv("SM_PROJECT_ROOT", str(tmp_path))
113
+ monkeypatch.delenv("SM_AUTH_PROVIDER", raising=False)
114
+
115
+ def test_defaults_to_users(self):
116
+ assert resolve_auth_provider() == DEFAULT_AUTH_PROVIDER
117
+
118
+ def test_reads_the_env_var(self, monkeypatch):
119
+ monkeypatch.setenv("SM_AUTH_PROVIDER", "keycloak")
120
+ assert resolve_auth_provider() == "keycloak"
121
+
122
+ def test_blank_falls_back_to_the_default(self, monkeypatch):
123
+ monkeypatch.setenv("SM_AUTH_PROVIDER", " ")
124
+ assert resolve_auth_provider() == DEFAULT_AUTH_PROVIDER
125
+
126
+ def test_reads_dotenv_when_env_is_unset(self, tmp_path):
127
+ (tmp_path / ".env").write_text("SM_AUTH_PROVIDER=keycloak\n", encoding="utf-8")
128
+ assert resolve_auth_provider() == "keycloak"
129
+
130
+ def test_real_environment_wins_over_dotenv(self, monkeypatch, tmp_path):
131
+ (tmp_path / ".env").write_text("SM_AUTH_PROVIDER=keycloak\n", encoding="utf-8")
132
+ monkeypatch.setenv("SM_AUTH_PROVIDER", "users")
133
+ assert resolve_auth_provider() == "users"
@@ -65,6 +65,29 @@ class TestI18nRegistry:
65
65
  reg.load()
66
66
  assert sorted(reg.available_locales()) == ["en", "es"]
67
67
 
68
+ def test_admin_sources_are_excluded_from_the_public_snapshot(self, tmp_path: Path) -> None:
69
+ """Anonymous visitors must not download catalogs for login-gated UI."""
70
+ self._write_locale(tmp_path / "pages", "en", {"title": "Pages"})
71
+ self._write_locale(tmp_path / "settings", "en", {"form": {"key": "Key"}})
72
+ reg = I18nRegistry(default_locale="en", supported_locales=["en"])
73
+ reg.add_source("pages", tmp_path / "pages")
74
+ reg.add_source("settings", tmp_path / "settings", audience="admin")
75
+ reg.load()
76
+ assert reg.messages_snapshot("en", include_admin=False) == {"pages.title": "Pages"}
77
+ # The full snapshot and server-side lookups still see everything.
78
+ assert reg.messages_snapshot("en") == {
79
+ "pages.title": "Pages",
80
+ "settings.form.key": "Key",
81
+ }
82
+ assert reg.messages("en")["settings.form.key"] == "Key"
83
+
84
+ def test_public_snapshot_defaults_to_everything(self, tmp_path: Path) -> None:
85
+ self._write_locale(tmp_path / "p", "en", {"title": "Products"})
86
+ reg = I18nRegistry(default_locale="en", supported_locales=["en"])
87
+ reg.add_source("products", tmp_path / "p")
88
+ reg.load()
89
+ assert reg.messages_snapshot("en", include_admin=False) == reg.messages_snapshot("en")
90
+
68
91
  def test_missing_locale_file_is_warning_not_error(
69
92
  self, tmp_path: Path, caplog: pytest.LogCaptureFixture
70
93
  ) -> None: