simple-module-settings 0.0.31__tar.gz → 0.0.32__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 (64) hide show
  1. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/PKG-INFO +4 -4
  2. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/pyproject.toml +4 -4
  3. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/_module_settings.py +26 -3
  4. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/endpoints/module_api.py +10 -2
  5. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/endpoints/views.py +23 -20
  6. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/pages/ModulesEdit.tsx +3 -1
  7. simple_module_settings-0.0.32/tests/test_module_settings_render.py +90 -0
  8. simple_module_settings-0.0.32/tests/test_module_settings_serialize.py +92 -0
  9. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/.gitignore +0 -0
  10. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/LICENSE +0 -0
  11. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/README.md +0 -0
  12. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/package.json +0 -0
  13. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/__init__.py +0 -0
  14. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/cli.py +0 -0
  15. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/constants.py +0 -0
  16. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/contracts/__init__.py +0 -0
  17. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/contracts/accessor.py +0 -0
  18. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/contracts/events.py +0 -0
  19. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/contracts/registry.py +0 -0
  20. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/contracts/schemas.py +0 -0
  21. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/deps.py +0 -0
  22. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/endpoints/__init__.py +0 -0
  23. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/endpoints/api.py +0 -0
  24. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/env_vars.py +0 -0
  25. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/hydrate.py +0 -0
  26. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/locales/en.json +0 -0
  27. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/models.py +0 -0
  28. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/module.py +0 -0
  29. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/module_registry.py +0 -0
  30. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/pages/Browse.tsx +0 -0
  31. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/pages/Create.tsx +0 -0
  32. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/pages/Edit.tsx +0 -0
  33. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/pages/components/FieldInput.tsx +0 -0
  34. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/pages/components/FieldSource.tsx +0 -0
  35. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/pages/components/KeyField.tsx +0 -0
  36. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/pages/components/ModuleForm.tsx +0 -0
  37. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/pages/components/TestConnectionButton.tsx +0 -0
  38. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/pages/components/ValueInput.tsx +0 -0
  39. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/pages/routes.ts +0 -0
  40. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/py.typed +0 -0
  41. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/registration.py +0 -0
  42. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/reload.py +0 -0
  43. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/service.py +0 -0
  44. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/services.py +0 -0
  45. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/settings.py +0 -0
  46. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/settings/store.py +0 -0
  47. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_cli_import.py +0 -0
  48. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_concurrent_overrides.py +0 -0
  49. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_hydrate.py +0 -0
  50. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_module_api.py +0 -0
  51. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_module_registry.py +0 -0
  52. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_module_settings.py +0 -0
  53. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_registration.py +0 -0
  54. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_reload.py +0 -0
  55. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_settings_accessor.py +0 -0
  56. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_settings_api.py +0 -0
  57. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_settings_events.py +0 -0
  58. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_settings_field_sources.py +0 -0
  59. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_settings_module.py +0 -0
  60. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_settings_schemas.py +0 -0
  61. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_settings_service.py +0 -0
  62. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_settings_view_authz.py +0 -0
  63. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tests/test_store.py +0 -0
  64. {simple_module_settings-0.0.31 → simple_module_settings-0.0.32}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_settings
3
- Version: 0.0.31
3
+ Version: 0.0.32
4
4
  Summary: Runtime settings UI — modules plug their own settings panels into a shared admin view
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -21,9 +21,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
21
21
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: simple-module-core==0.0.31
25
- Requires-Dist: simple-module-db==0.0.31
26
- Requires-Dist: simple-module-hosting==0.0.31
24
+ Requires-Dist: simple-module-core==0.0.32
25
+ Requires-Dist: simple-module-db==0.0.32
26
+ Requires-Dist: simple-module-hosting==0.0.32
27
27
  Requires-Dist: typer>=0.12
28
28
  Description-Content-Type: text/markdown
29
29
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_settings"
3
- version = "0.0.31"
3
+ version = "0.0.32"
4
4
  description = "Runtime settings UI — modules plug their own settings panels into a shared admin view"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,9 +21,9 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.31",
25
- "simple_module_db==0.0.31",
26
- "simple_module_hosting==0.0.31",
24
+ "simple_module_core==0.0.32",
25
+ "simple_module_db==0.0.32",
26
+ "simple_module_hosting==0.0.32",
27
27
  "typer>=0.12",
28
28
  ]
29
29
 
@@ -12,10 +12,12 @@ from dataclasses import dataclass
12
12
  from typing import Any
13
13
 
14
14
  from fastapi import FastAPI
15
+ from fastapi.encoders import jsonable_encoder
15
16
  from pydantic_settings import BaseSettings
16
17
 
17
18
  from settings.env_vars import env_prefix_for
18
19
  from settings.hydrate import value_type_for_field
20
+ from settings.service import SettingService
19
21
 
20
22
  # We intentionally DON'T match the bare word "token" (would mask
21
23
  # `verification_token_lifetime_seconds` — just an int) or "key" alone (would
@@ -236,8 +238,29 @@ def _build_view(
236
238
  )
237
239
 
238
240
 
241
+ async def overrides_by_package(service: SettingService) -> dict[str, frozenset[str]]:
242
+ """Map package -> field names carrying a stored override.
243
+
244
+ Reads the SYSTEM scope once and buckets by key prefix. Packages with no
245
+ overrides are simply absent, which :func:`collect_module_settings` already
246
+ treats as "nothing overridden". Shared by both the Inertia view and the
247
+ JSON API so the two never drift on what "overridden" means.
248
+ """
249
+ from settings.store import SettingsStore
250
+
251
+ return await SettingsStore(service).all_override_fields()
252
+
253
+
239
254
  def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
240
- """Convert dataclass views to plain dicts for Inertia props."""
255
+ """Convert dataclass views to plain dicts for Inertia props.
256
+
257
+ Field values arrive as whatever type the module declared — pydantic has
258
+ already coerced ``media_root: Path`` to a ``PosixPath``, ``timeout:
259
+ timedelta`` to a ``timedelta`` — and this screen reflects every installed
260
+ module's settings, so the set of types is open-ended by design. They are
261
+ encoded here rather than handed on as-is: this is the boundary where a
262
+ settings object stops being Python and becomes a prop.
263
+ """
241
264
  return [
242
265
  {
243
266
  "module_name": v.module_name,
@@ -248,8 +271,8 @@ def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
248
271
  {
249
272
  "name": f.name,
250
273
  "env_var": f.env_var,
251
- "value": f.value,
252
- "default": f.default,
274
+ "value": jsonable_encoder(f.value),
275
+ "default": jsonable_encoder(f.default),
253
276
  "description": f.description,
254
277
  "is_secret": f.is_secret,
255
278
  "type": f.type,
@@ -17,6 +17,7 @@ from settings._module_settings import (
17
17
  SECRET_MASK,
18
18
  collect_module_settings,
19
19
  is_secret_field,
20
+ overrides_by_package,
20
21
  serialize,
21
22
  )
22
23
  from settings.constants import MODULE_PACKAGE, PERM_DELETE, PERM_EDIT, PERM_VIEW
@@ -47,8 +48,15 @@ def _strip_mask_sentinels(changes: dict[str, Any]) -> dict[str, Any]:
47
48
 
48
49
 
49
50
  @router.get("", dependencies=_VIEW)
50
- async def list_modules(request: Request) -> dict[str, Any]:
51
- views = collect_module_settings(request.app)
51
+ async def list_modules(
52
+ request: Request,
53
+ service: SettingService = Depends(get_setting_service),
54
+ ) -> dict[str, Any]:
55
+ # Match the Inertia ModulesEdit view: without the overrides map, every
56
+ # field's `source`/`db_override` would report env/default even when a
57
+ # stored override is actually in force.
58
+ overrides = await overrides_by_package(service)
59
+ views = collect_module_settings(request.app, overrides)
52
60
  return {"modules": serialize(views)}
53
61
 
54
62
 
@@ -9,6 +9,8 @@ strings below match what ``pages/*.tsx`` declare.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import asyncio
13
+
12
14
  from fastapi import APIRouter, Depends, HTTPException, Request
13
15
  from inertia import InertiaResponse
14
16
  from pydantic import ValidationError
@@ -17,7 +19,12 @@ from simple_module_hosting.inertia_utils import redirect_back_with_errors, valid
17
19
  from simple_module_hosting.permissions import RequiresPermission
18
20
  from starlette.responses import RedirectResponse
19
21
 
20
- from settings._module_settings import _package_of, collect_module_settings, serialize
22
+ from settings._module_settings import (
23
+ _package_of,
24
+ collect_module_settings,
25
+ overrides_by_package,
26
+ serialize,
27
+ )
21
28
  from settings.constants import (
22
29
  ERR_SETTING_NOT_FOUND,
23
30
  PERM_CREATE,
@@ -187,7 +194,7 @@ async def modules_view(
187
194
  an ``SM_*`` env var, or the field default — so a setting that "isn't taking
188
195
  effect" explains itself.
189
196
  """
190
- overrides = await _overrides_by_package(service)
197
+ overrides = await overrides_by_package(service)
191
198
  views = collect_module_settings(request.app, overrides)
192
199
  return await inertia.render(
193
200
  _PAGE_MODULES_EDIT,
@@ -200,18 +207,6 @@ async def modules_view(
200
207
  )
201
208
 
202
209
 
203
- async def _overrides_by_package(service: SettingService) -> dict[str, frozenset[str]]:
204
- """Map package -> field names carrying a stored override.
205
-
206
- Reads the SYSTEM scope once and buckets by key prefix. Packages with no
207
- overrides are simply absent, which ``collect_module_settings`` already
208
- treats as "nothing overridden".
209
- """
210
- from settings.store import SettingsStore
211
-
212
- return await SettingsStore(service).all_override_fields()
213
-
214
-
215
210
  def _testable_packages(request: Request) -> list[str]:
216
211
  """Packages whose module registered at least one health check.
217
212
 
@@ -257,13 +252,21 @@ async def test_connection(package: str, request: Request) -> dict:
257
252
  if not checks:
258
253
  raise HTTPException(status_code=404, detail=f"{owner.meta.name} has no connection to test")
259
254
 
260
- results = []
261
- for check in checks:
255
+ # Run independently of one another — a module can register several checks
256
+ # (e.g. SMTP + primary storage + backup storage), and sequentially awaiting
257
+ # each one would block the request for the sum of their latencies instead
258
+ # of the slowest one, the same reasoning dashboard.stats._run_health_checks
259
+ # already applies to probe checks.
260
+ async def _run_one(check) -> dict:
262
261
  try:
263
262
  outcome = await check.check()
264
- results.append(
265
- {"name": check.name, "status": outcome.status.value, "detail": outcome.detail or ""}
266
- )
263
+ return {
264
+ "name": check.name,
265
+ "status": outcome.status.value,
266
+ "detail": outcome.detail or "",
267
+ }
267
268
  except Exception as exc:
268
- results.append({"name": check.name, "status": "unhealthy", "detail": str(exc)})
269
+ return {"name": check.name, "status": "unhealthy", "detail": str(exc)}
270
+
271
+ results = await asyncio.gather(*[_run_one(c) for c in checks])
269
272
  return {"module": owner.meta.name, "checks": results}
@@ -36,7 +36,9 @@ function ModulesEdit({ modules, testable = [] }: Props) {
36
36
  return (
37
37
  <>
38
38
  <Head title="Modules" />
39
- <div className="flex h-[calc(100vh-64px)] bg-background">
39
+ {/* Master/detail pane fills the viewport minus the chrome above it,
40
+ whose height the layout publishes as --app-chrome-h. */}
41
+ <div className="flex h-[calc(100vh-var(--app-chrome-h))] bg-background">
40
42
  <aside className="w-72 shrink-0 border-r border-border bg-secondary/30 overflow-y-auto">
41
43
  <div className="p-3">
42
44
  <div className="relative">
@@ -0,0 +1,90 @@
1
+ """Settings → Modules must render for a module with a rich settings type.
2
+
3
+ This screen reflects *every* installed module's pydantic settings, so the value
4
+ types are open-ended — but none of the modules in this repo declare anything
5
+ the stdlib JSON encoder can't handle, which is why the failure only ever showed
6
+ up downstream. A wheel-installed module with ``media_root: Path`` was enough to
7
+ 500 the page, and only on a client-side visit: the HTML render path has its own
8
+ encoder, so reloading the same URL worked and the report read as "sometimes".
9
+
10
+ The demo module below exists to keep a non-JSON-native settings type in the
11
+ suite permanently.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ import httpx
20
+ import pytest
21
+ from fastapi import FastAPI
22
+ from pydantic_settings import BaseSettings
23
+ from simple_module_core.module import ModuleMeta
24
+
25
+ _OK = 200
26
+ _SERVER_ERROR = 500
27
+ _INERTIA = {"X-Inertia": "true", "X-Inertia-Version": "1.0"}
28
+
29
+
30
+ class _PathCfg(BaseSettings):
31
+ """Mirrors the shape that broke it: a Path with a relative default."""
32
+
33
+ media_root: Path = Path("var/demo/media")
34
+ workers: int = 2
35
+
36
+
37
+ class _PathModule:
38
+ meta = ModuleMeta(name="PathDemo")
39
+
40
+
41
+ _PathModule.__module__ = "pathdemo"
42
+
43
+
44
+ @dataclass
45
+ class _PathServices:
46
+ settings: _PathCfg
47
+
48
+
49
+ @pytest.fixture
50
+ def app_with_path_setting(app: FastAPI) -> FastAPI:
51
+ """Register the demo module against the live app the client talks to."""
52
+ app.state.settings.module_registry.register("pathdemo", _PathCfg)
53
+ app.state.pathdemo = _PathServices(settings=_PathCfg())
54
+ return app
55
+
56
+
57
+ class TestModulesScreenRenders:
58
+ async def test_client_side_visit_does_not_500(
59
+ self,
60
+ app_with_path_setting: FastAPI,
61
+ authenticated_client: httpx.AsyncClient,
62
+ ) -> None:
63
+ """The reported bug: reaching the page by clicking the sidebar link."""
64
+ resp = await authenticated_client.get("/settings/", headers=_INERTIA)
65
+
66
+ assert resp.status_code != _SERVER_ERROR
67
+ assert resp.status_code == _OK
68
+
69
+ async def test_the_path_survives_as_a_string(
70
+ self,
71
+ app_with_path_setting: FastAPI,
72
+ authenticated_client: httpx.AsyncClient,
73
+ ) -> None:
74
+ resp = await authenticated_client.get("/settings/", headers=_INERTIA)
75
+ modules = resp.json()["props"]["modules"]
76
+
77
+ demo = next(m for m in modules if m["package"] == "pathdemo")
78
+ media_root = next(f for f in demo["fields"] if f["name"] == "media_root")
79
+ assert media_root["value"] == "var/demo/media"
80
+
81
+ async def test_full_page_load_still_works(
82
+ self,
83
+ app_with_path_setting: FastAPI,
84
+ authenticated_client: httpx.AsyncClient,
85
+ ) -> None:
86
+ """The path that always worked must keep working."""
87
+ resp = await authenticated_client.get("/settings/")
88
+
89
+ assert resp.status_code == _OK
90
+ assert resp.headers["content-type"].startswith("text/html")
@@ -0,0 +1,92 @@
1
+ """Serialized module settings must be props, not live Python objects.
2
+
3
+ Settings → Modules reflects every installed module's pydantic settings, so the
4
+ value types are whatever those modules declared — pydantic has already turned
5
+ ``media_root: Path`` into a ``PosixPath`` before this screen sees it. Passing
6
+ one on untouched puts a value in the Inertia payload that only the HTML render
7
+ path can encode, so the page renders on a reload and 500s when an admin clicks
8
+ "Settings" in the sidebar.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from datetime import date
15
+ from decimal import Decimal
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import pytest
20
+ from settings._module_settings import (
21
+ ModuleSettingField,
22
+ ModuleSettingsView,
23
+ serialize,
24
+ )
25
+
26
+
27
+ def _view_with(value: Any, default: Any = "") -> ModuleSettingsView:
28
+ return ModuleSettingsView(
29
+ module_name="Demo",
30
+ package="demo",
31
+ env_prefix="SM_DEMO_",
32
+ class_name="DemoCfg",
33
+ fields=[
34
+ ModuleSettingField(
35
+ name="media_root",
36
+ env_var="SM_DEMO_MEDIA_ROOT",
37
+ value=value,
38
+ default=default,
39
+ description="",
40
+ is_secret=False,
41
+ type="Path",
42
+ requires_restart=False,
43
+ group=None,
44
+ )
45
+ ],
46
+ )
47
+
48
+
49
+ def _only_field(views: list[dict]) -> dict:
50
+ return views[0]["fields"][0]
51
+
52
+
53
+ class TestSerializeProducesJsonSafeProps:
54
+ def test_a_path_value_becomes_a_string(self) -> None:
55
+ result = serialize([_view_with(Path("var/pagebuilder/media"))])
56
+
57
+ assert _only_field(result)["value"] == "var/pagebuilder/media"
58
+
59
+ def test_a_path_default_becomes_a_string(self) -> None:
60
+ """Defaults reach the payload too — the screen shows both."""
61
+ result = serialize([_view_with("", default=Path("var/media"))])
62
+
63
+ assert _only_field(result)["default"] == "var/media"
64
+
65
+ @pytest.mark.parametrize(
66
+ "value",
67
+ [Path("var/media"), Decimal("1.5"), date(2026, 8, 21)],
68
+ ids=["path", "decimal", "date"],
69
+ )
70
+ def test_the_payload_survives_a_plain_json_dump(self, value: Any) -> None:
71
+ """The check that matters: Starlette's JSONResponse has no encoder.
72
+
73
+ Asserting on ``json.dumps`` rather than on each converted type keeps
74
+ this honest for value types no one has thought of yet.
75
+ """
76
+ result = serialize([_view_with(value)])
77
+
78
+ assert json.dumps(result)
79
+
80
+ def test_ordinary_values_are_unchanged(self) -> None:
81
+ result = serialize([_view_with(8000, default=25)])
82
+ field = _only_field(result)
83
+
84
+ assert field["value"] == 8000
85
+ assert field["default"] == 25
86
+
87
+ def test_the_rest_of_the_view_is_intact(self) -> None:
88
+ result = serialize([_view_with(Path("x"))])
89
+
90
+ assert result[0]["package"] == "demo"
91
+ assert _only_field(result)["env_var"] == "SM_DEMO_MEDIA_ROOT"
92
+ assert _only_field(result)["source"] == "default"