simple-module-hosting 0.0.28__tar.gz → 0.0.30__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 (65) hide show
  1. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/PKG-INFO +3 -3
  2. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/pyproject.toml +3 -3
  3. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/app_builder.py +8 -1
  4. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/assets.py +64 -10
  5. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/bootstrap_settings.py +22 -0
  6. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/host_cli.py +5 -3
  7. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_app.py +7 -7
  8. simple_module_hosting-0.0.30/tests/test_auth_provider_setting.py +59 -0
  9. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/.gitignore +0 -0
  10. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/LICENSE +0 -0
  11. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/README.md +0 -0
  12. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/__init__.py +0 -0
  13. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/__main__.py +0 -0
  14. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/_error_handlers.py +0 -0
  15. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/_host_services.py +0 -0
  16. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/_hydrate_step.py +0 -0
  17. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/_inertia_setup.py +0 -0
  18. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/_inertia_shared.py +0 -0
  19. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/_observability.py +0 -0
  20. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/_phase_helpers.py +0 -0
  21. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/health.py +0 -0
  22. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/host_settings.py +0 -0
  23. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/i18n_deps.py +0 -0
  24. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/i18n_manifest.py +0 -0
  25. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/i18n_middleware.py +0 -0
  26. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/inertia_deps.py +0 -0
  27. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/inertia_utils.py +0 -0
  28. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/logging.py +0 -0
  29. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/manifest.py +0 -0
  30. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/middleware.py +0 -0
  31. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/migrations.py +0 -0
  32. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/permissions.py +0 -0
  33. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/py.typed +0 -0
  34. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/redirects.py +0 -0
  35. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/settings.py +0 -0
  36. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/shared_props.py +0 -0
  37. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/simple_module_hosting/static_files.py +0 -0
  38. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_branding_head.py +0 -0
  39. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_check_migrations.py +0 -0
  40. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_design_packs_wiring.py +0 -0
  41. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_error_page_shared_props.py +0 -0
  42. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_health.py +0 -0
  43. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_host_cli.py +0 -0
  44. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_hosting_permissions.py +0 -0
  45. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_i18n_manifest.py +0 -0
  46. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_inertia_i18n_shared_props.py +0 -0
  47. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_inertia_manifest.py +0 -0
  48. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_inertia_shared_providers.py +0 -0
  49. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_lifespan_order.py +0 -0
  50. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_locale_middleware.py +0 -0
  51. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_logging.py +0 -0
  52. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_manifest.py +0 -0
  53. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_menu_urls_are_canonical.py +0 -0
  54. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_middleware_order.py +0 -0
  55. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_precompressed_static.py +0 -0
  56. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_proxy_headers.py +0 -0
  57. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_redirects.py +0 -0
  58. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_response_compression.py +0 -0
  59. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_session_cookie_security.py +0 -0
  60. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_settings_i18n.py +0 -0
  61. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_settings_secrets.py +0 -0
  62. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_static_caching.py +0 -0
  63. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_strict_discovery_wiring.py +0 -0
  64. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_tenant_middleware.py +0 -0
  65. {simple_module_hosting-0.0.28 → simple_module_hosting-0.0.30}/tests/test_translator_dep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_hosting
3
- Version: 0.0.28
3
+ Version: 0.0.30
4
4
  Summary: FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -27,8 +27,8 @@ Requires-Dist: fastapi>=0.115
27
27
  Requires-Dist: httpx>=0.27
28
28
  Requires-Dist: itsdangerous>=2.0
29
29
  Requires-Dist: jinja2>=3.1
30
- Requires-Dist: simple-module-core==0.0.28
31
- Requires-Dist: simple-module-db==0.0.28
30
+ Requires-Dist: simple-module-core==0.0.30
31
+ Requires-Dist: simple-module-db==0.0.30
32
32
  Requires-Dist: starlette>=0.44
33
33
  Requires-Dist: tomlkit>=0.13
34
34
  Requires-Dist: uvicorn[standard]>=0.34
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_hosting"
3
- version = "0.0.28"
3
+ version = "0.0.30"
4
4
  description = "FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -30,8 +30,8 @@ dependencies = [
30
30
  # it directly — out-of-tree hosts have nothing else pulling it in.
31
31
  "itsdangerous>=2.0",
32
32
  "jinja2>=3.1",
33
- "simple_module_core==0.0.28",
34
- "simple_module_db==0.0.28",
33
+ "simple_module_core==0.0.30",
34
+ "simple_module_db==0.0.30",
35
35
  "starlette>=0.44",
36
36
  "tomlkit>=0.13",
37
37
  "uvicorn[standard]>=0.34",
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from fastapi import FastAPI
13
13
  from simple_module_core.design_packs import DesignPackRegistry
14
14
  from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
15
- from simple_module_core.discovery import discover_modules, topological_sort
15
+ from simple_module_core.discovery import discover_modules, select_auth_provider, topological_sort
16
16
  from simple_module_core.events import EventBus
17
17
  from simple_module_core.feature_flags import FeatureFlagRegistry
18
18
  from simple_module_core.health import HealthRegistry
@@ -120,6 +120,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
120
120
  enabled=settings.modules_enabled,
121
121
  strict=not settings.is_development,
122
122
  )
123
+ # Two auth providers can be installed at once (they are in this workspace);
124
+ # only the configured one is activated. See select_auth_provider / SM020.
125
+ # Strict outside development: diagnostics don't run there, so an
126
+ # unrecognised name would otherwise mount both providers unreported.
127
+ modules = select_auth_provider(
128
+ modules, settings.auth_provider, strict=not settings.is_development
129
+ )
123
130
  modules = topological_sort(modules)
124
131
  logger.info(
125
132
  "Loaded %d module(s): %s",
@@ -13,9 +13,22 @@ A module may ship two optional stylesheets beside its ``pages/`` directory:
13
13
  utility: a module shipping a bare ``.card { padding: 0 }`` would otherwise
14
14
  silently beat ``p-4``.
15
15
 
16
- Both are referenced through a per-module Vite alias (``#module/<pkg>``) rather
17
- than a filesystem path, so the generated CSS never contains something like
18
- ``../../../.venv/lib/python3.12/site-packages/<pkg>/styles.css``.
16
+ Both are referenced by **absolute** filesystem path, exactly as the ``@source``
17
+ lines in the same file already are.
18
+
19
+ This used to emit a per-module Vite alias (``#module/<pkg>/styles.css``) to
20
+ avoid a relative path like ``../../../.venv/lib/python3.12/site-packages/<pkg>``
21
+ — but that objection only ever applied to *relative* paths, and the alias made
22
+ this generated file depend on the host resolving it. ``vite.config.ts`` is
23
+ scaffold output: it is written once into an app and then owned and edited
24
+ there, so it is versioned independently of these Python packages. A host
25
+ scaffolded before the alias existed never receives it, and a Python-only
26
+ dependency bump would emit specifiers that host cannot resolve, failing the
27
+ build with ``Can't resolve '#module/<pkg>/styles.css'`` — naming something that
28
+ appears nowhere in the app's own sources (GH issue #253).
29
+
30
+ Absolute paths keep the generated CSS self-contained: it resolves under any
31
+ ``vite.config.ts``, old or new, with no alias configured at all.
19
32
  """
20
33
 
21
34
  from __future__ import annotations
@@ -33,7 +46,7 @@ logger = logging.getLogger(__name__)
33
46
 
34
47
  THEME_CSS = "theme.css"
35
48
  STYLES_CSS = "styles.css"
36
- ALIAS_PREFIX = "#module"
49
+ PACKAGE_JSON = "package.json"
37
50
 
38
51
 
39
52
  @dataclass(frozen=True)
@@ -46,6 +59,39 @@ class ModuleAssets:
46
59
  pages_dir: Path | None
47
60
  theme_css: Path | None
48
61
  styles_css: Path | None
62
+ npm_name: str | None = None
63
+
64
+
65
+ def find_npm_name(pkg_root: Path) -> str | None:
66
+ """Return the module's npm package name, or ``None`` if it ships no JS.
67
+
68
+ Two install layouts put ``package.json`` in different places:
69
+
70
+ * **wheel** — Hatch force-includes the module-root ``package.json`` *into*
71
+ the Python package, so it lands at ``site-packages/<pkg>/package.json``.
72
+ * **editable / workspace** — it stays at the source-tree module root,
73
+ ``modules/<name>/package.json``, one level above the Python package.
74
+
75
+ The parent candidate is accepted only when that directory also holds a
76
+ ``pyproject.toml``. Without that guard a wheel-installed module would
77
+ happily read ``site-packages/package.json`` — some unrelated file that
78
+ happens to sit there — and alias the module onto a stranger's name.
79
+ """
80
+ candidates = [pkg_root / PACKAGE_JSON]
81
+ parent = pkg_root.parent
82
+ if (parent / "pyproject.toml").is_file():
83
+ candidates.append(parent / PACKAGE_JSON)
84
+ for candidate in candidates:
85
+ if not candidate.is_file():
86
+ continue
87
+ try:
88
+ name = json.loads(candidate.read_text(encoding="utf-8")).get("name")
89
+ except (OSError, ValueError):
90
+ logger.debug("Module package.json at %s is unreadable — ignoring", candidate)
91
+ continue
92
+ if isinstance(name, str) and name:
93
+ return name
94
+ return None
49
95
 
50
96
 
51
97
  def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]:
@@ -78,6 +124,7 @@ def compute_module_assets(modules: Sequence[ModuleBase]) -> list[ModuleAssets]:
78
124
  pages_dir=pages_dir.resolve() if pages_dir.is_dir() else None,
79
125
  theme_css=theme.resolve() if theme.is_file() else None,
80
126
  styles_css=styles.resolve() if styles.is_file() else None,
127
+ npm_name=find_npm_name(pkg_root),
81
128
  )
82
129
  if entry.pages_dir or entry.theme_css or entry.styles_css:
83
130
  result.append(entry)
@@ -109,19 +156,18 @@ def render_modules_css(
109
156
  ``styles.css``, so emitting an absolute one too would just duplicate it.
110
157
  ``@import`` is emitted for *every* module, in-repo and wheel alike, because
111
158
  there is no static-glob equivalent for CSS.
159
+
160
+ Every path is absolute, so nothing here depends on the host's
161
+ ``vite.config.ts`` — see the module docstring for why that matters.
112
162
  """
113
163
  source_lines = [
114
164
  f'@source "{e.pages_dir.as_posix()}/**/*.{{ts,tsx}}";'
115
165
  for e in assets
116
166
  if e.pages_dir and not in_repo(e.pages_dir)
117
167
  ]
118
- theme_lines = [
119
- f'@import "{ALIAS_PREFIX}/{e.package_name}/{THEME_CSS}";' for e in assets if e.theme_css
120
- ]
168
+ theme_lines = [f'@import "{e.theme_css.as_posix()}";' for e in assets if e.theme_css]
121
169
  style_lines = [
122
- f'@import "{ALIAS_PREFIX}/{e.package_name}/{STYLES_CSS}" layer(components);'
123
- for e in assets
124
- if e.styles_css
170
+ f'@import "{e.styles_css.as_posix()}" layer(components);' for e in assets if e.styles_css
125
171
  ]
126
172
 
127
173
  out = [_CSS_HEADER]
@@ -146,6 +192,13 @@ def render_assets_json(assets: Sequence[ModuleAssets]) -> str:
146
192
  manifest's value shape would break every downstream app at once. This file
147
193
  is purely additive, and also covers CSS-only modules that never appear in
148
194
  the pages manifest because they ship no ``pages/``.
195
+
196
+ ``npm_name`` is what lets one module import another's TS/TSX by package
197
+ name. The host aliases it onto ``package`` — the module's *Python package
198
+ directory* — and that target is forced, not chosen: a wheel contains only
199
+ ``site-packages/<pkg>/**``, so the source-tree module root simply does not
200
+ exist once installed. Anchoring the npm name there is the only mapping
201
+ that can mean the same thing in both layouts. See ``find_npm_name``.
149
202
  """
150
203
  payload = {
151
204
  e.name: {
@@ -154,6 +207,7 @@ def render_assets_json(assets: Sequence[ModuleAssets]) -> str:
154
207
  "pages": e.pages_dir.as_posix() if e.pages_dir else None,
155
208
  "theme": e.theme_css.as_posix() if e.theme_css else None,
156
209
  "styles": e.styles_css.as_posix() if e.styles_css else None,
210
+ "npm_name": e.npm_name,
157
211
  }
158
212
  for e in assets
159
213
  }
@@ -11,6 +11,7 @@ from typing import Literal
11
11
 
12
12
  from pydantic import field_validator, model_validator
13
13
  from pydantic_settings import BaseSettings, SettingsConfigDict
14
+ from simple_module_core.discovery import DEFAULT_AUTH_PROVIDER
14
15
  from simple_module_core.environments import NON_PROD_ENVIRONMENTS
15
16
 
16
17
  _PLACEHOLDER_SECRET_KEY = "change-me-in-production"
@@ -46,6 +47,14 @@ class BootstrapSettings(BaseSettings):
46
47
 
47
48
  modules_enabled: list[str] | None = None
48
49
 
50
+ auth_provider: str = DEFAULT_AUTH_PROVIDER
51
+ """Which auth provider module to activate (``SM_AUTH_PROVIDER``).
52
+
53
+ ``users`` and ``keycloak`` both provide authentication and only one may be
54
+ active at a time. When both are installed this picks the winner; the other
55
+ is skipped at discovery. Ignored when only one is installed.
56
+ """
57
+
49
58
  auth_public_paths: list[str] = []
50
59
  """Host-level anonymous-access path prefixes (``SM_AUTH_PUBLIC_PATHS``).
51
60
 
@@ -55,6 +64,19 @@ class BootstrapSettings(BaseSettings):
55
64
  ``register_public_routes`` hook, which is method-aware.
56
65
  """
57
66
 
67
+ @field_validator("auth_provider", mode="after")
68
+ @classmethod
69
+ def _normalize_auth_provider(cls, value: str) -> str:
70
+ """Strip whitespace; treat blank as unset.
71
+
72
+ ``SM_AUTH_PROVIDER=`` in a ``.env`` yields ``''``, which matches no
73
+ installed provider and would mount all of them. The out-of-process
74
+ readers (``make doctor``, ``gen-pages``) go through
75
+ ``resolve_auth_provider``, which already falls back on blank — without
76
+ this they and the host would disagree about the active provider.
77
+ """
78
+ return value.strip() or DEFAULT_AUTH_PROVIDER
79
+
58
80
  @field_validator("trusted_proxy", mode="after")
59
81
  @classmethod
60
82
  def _normalize_trusted_proxy(cls, value: str | None) -> str | None:
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
  from typing import Annotated
14
14
 
15
15
  import typer
16
- from simple_module_core import discover_modules
16
+ from simple_module_core import discover_modules, resolve_auth_provider, select_auth_provider
17
17
 
18
18
  from simple_module_hosting.manifest import (
19
19
  collect_module_js_deps,
@@ -42,7 +42,9 @@ def gen_pages(
42
42
  if not host_dir.is_dir():
43
43
  typer.echo(f"ERROR: client_app directory not found at {host_dir}", err=True)
44
44
  raise typer.Exit(code=1)
45
- modules = discover_modules()
45
+ # Skip the auth provider the host won't boot — its pages would otherwise
46
+ # ship in the bundle with no view endpoint able to render them.
47
+ modules = select_auth_provider(discover_modules(), resolve_auth_provider())
46
48
  written = write_module_pages_manifest(modules, host_dir)
47
49
  typer.echo(
48
50
  f"Module pages manifest: {len(modules)} module(s) "
@@ -70,7 +72,7 @@ def sync_js_deps(
70
72
  typer.echo(f"ERROR: client_app directory not found at {host_client_app}", err=True)
71
73
  raise typer.Exit(code=1)
72
74
 
73
- modules = discover_modules()
75
+ modules = select_auth_provider(discover_modules(), resolve_auth_provider())
74
76
  by_module = collect_module_js_deps(modules)
75
77
  if not by_module:
76
78
  typer.echo("No module JS dependencies declared.")
@@ -168,16 +168,16 @@ class TestCreateApp:
168
168
  (tmp_path / "host" / "templates").mkdir(parents=True)
169
169
  (tmp_path / "host" / "templates" / "index.html").write_text("<html></html>")
170
170
 
171
- # Exclude Keycloak — both ``users`` and ``keycloak`` are installed as
172
- # entry points in the dev workspace. SM020 fires if both are present
173
- # because the app is only meant to run with one auth provider.
174
- from simple_module_core.discovery import discover_modules
175
-
176
- all_names = [m.meta.name for m in discover_modules() if m.meta.name != "Keycloak"]
177
- app = create_app(Settings(modules_enabled=all_names))
171
+ # No modules_enabled allowlist: both auth providers are entry points in
172
+ # the dev workspace, and create_app is expected to activate the named
173
+ # one rather than fail the boot on SM020. auth_provider is passed
174
+ # explicitly so an SM_AUTH_PROVIDER in the developer's .env can't
175
+ # decide which provider this asserts on.
176
+ app = create_app(Settings(auth_provider="users"))
178
177
 
179
178
  sm = app.state.sm
180
179
  assert isinstance(sm, Services)
180
+ assert [m.meta.name for m in sm.modules].count("Keycloak") == 0
181
181
  assert sm.settings is not None
182
182
  assert sm.db is not None
183
183
  assert sm.event_bus is not None
@@ -0,0 +1,59 @@
1
+ """``SM_AUTH_PROVIDER`` normalisation on BootstrapSettings.
2
+
3
+ The host reads this through ``Settings``; ``make doctor`` and ``smpy host
4
+ gen-pages`` read it through ``simple_module_core.resolve_auth_provider``.
5
+ The two must agree on every input, or the tools report on a different module
6
+ set than the app boots with.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ import pytest
14
+ from simple_module_core.discovery import DEFAULT_AUTH_PROVIDER, resolve_auth_provider
15
+ from simple_module_hosting.settings import Settings
16
+
17
+
18
+ def _settings(**overrides) -> Settings:
19
+ return Settings(
20
+ database_url="sqlite+aiosqlite:///:memory:",
21
+ environment="testing",
22
+ secret_key="test-secret-key",
23
+ **overrides,
24
+ )
25
+
26
+
27
+ class TestAuthProviderSetting:
28
+ @pytest.fixture(autouse=True)
29
+ def _isolated_env(self, monkeypatch, tmp_path):
30
+ """Run against an empty ``.env`` and a throwaway environment.
31
+
32
+ ``Settings`` resolves ``env_file=".env"`` relative to the working
33
+ directory, so without the chdir a developer running Keycloak locally
34
+ would fail the default-value assertions below — the very coupling
35
+ these tests exist to pin down.
36
+ """
37
+ monkeypatch.setattr(os, "environ", dict(os.environ))
38
+ monkeypatch.delenv("SM_AUTH_PROVIDER", raising=False)
39
+ monkeypatch.chdir(tmp_path)
40
+
41
+ def test_defaults_to_users(self):
42
+ assert _settings().auth_provider == DEFAULT_AUTH_PROVIDER
43
+
44
+ def test_explicit_value_passes_through(self):
45
+ assert _settings(auth_provider="keycloak").auth_provider == "keycloak"
46
+
47
+ @pytest.mark.parametrize("raw", ["", " ", "\t"])
48
+ def test_blank_falls_back_to_the_default(self, raw: str):
49
+ """``SM_AUTH_PROVIDER=`` in .env yielded '', which matches no provider."""
50
+ assert _settings(auth_provider=raw).auth_provider == DEFAULT_AUTH_PROVIDER
51
+
52
+ def test_surrounding_whitespace_stripped(self):
53
+ assert _settings(auth_provider=" keycloak ").auth_provider == "keycloak"
54
+
55
+ @pytest.mark.parametrize("raw", ["", " ", "keycloak", " keycloak "])
56
+ def test_agrees_with_resolve_auth_provider(self, raw: str, monkeypatch):
57
+ """Host and out-of-process readers must land on the same name."""
58
+ monkeypatch.setenv("SM_AUTH_PROVIDER", raw)
59
+ assert _settings().auth_provider == resolve_auth_provider()