simple-module-settings 0.0.32__tar.gz → 0.0.34__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 (98) hide show
  1. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/PKG-INFO +4 -4
  2. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/package.json +3 -1
  3. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/pyproject.toml +4 -4
  4. simple_module_settings-0.0.34/settings/_field_meta.py +81 -0
  5. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/_module_settings.py +71 -106
  6. simple_module_settings-0.0.34/settings/_module_settings_props.py +56 -0
  7. simple_module_settings-0.0.34/settings/_row_masking.py +68 -0
  8. simple_module_settings-0.0.34/settings/_secrets.py +97 -0
  9. simple_module_settings-0.0.34/settings/browse_query.py +67 -0
  10. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/constants.py +29 -1
  11. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/endpoints/module_api.py +47 -8
  12. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/endpoints/views.py +64 -44
  13. simple_module_settings-0.0.34/settings/known_keys.py +86 -0
  14. simple_module_settings-0.0.34/settings/locales/en.json +141 -0
  15. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/module.py +32 -1
  16. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/module_registry.py +19 -2
  17. simple_module_settings-0.0.34/settings/pages/Browse.tsx +174 -0
  18. simple_module_settings-0.0.34/settings/pages/Create.tsx +163 -0
  19. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/pages/Edit.tsx +6 -22
  20. simple_module_settings-0.0.34/settings/pages/ModulesEdit.tsx +147 -0
  21. simple_module_settings-0.0.34/settings/pages/components/FieldInput.tsx +152 -0
  22. simple_module_settings-0.0.34/settings/pages/components/FieldSource.tsx +77 -0
  23. simple_module_settings-0.0.34/settings/pages/components/KeyField.tsx +142 -0
  24. simple_module_settings-0.0.34/settings/pages/components/ModuleFieldRow.tsx +69 -0
  25. simple_module_settings-0.0.34/settings/pages/components/ModuleForm.tsx +179 -0
  26. simple_module_settings-0.0.34/settings/pages/components/ResolvedValue.tsx +89 -0
  27. simple_module_settings-0.0.34/settings/pages/components/ScopeTabs.tsx +37 -0
  28. simple_module_settings-0.0.34/settings/pages/components/StoreCards.tsx +93 -0
  29. simple_module_settings-0.0.34/settings/pages/components/StoreTable.tsx +117 -0
  30. simple_module_settings-0.0.34/settings/pages/components/TestConnectionButton.tsx +122 -0
  31. simple_module_settings-0.0.34/settings/pages/components/ValueInput.tsx +87 -0
  32. simple_module_settings-0.0.34/settings/pages/components/last-test.ts +48 -0
  33. simple_module_settings-0.0.34/settings/pages/routes.ts +10 -0
  34. simple_module_settings-0.0.34/settings/pages/types.ts +25 -0
  35. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/registration.py +8 -2
  36. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/reload.py +7 -2
  37. simple_module_settings-0.0.34/settings/service.py +270 -0
  38. simple_module_settings-0.0.34/settings/settings.py +15 -0
  39. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/store.py +10 -3
  40. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_cli_import.py +8 -0
  41. simple_module_settings-0.0.34/tests/test_known_keys_meta.py +211 -0
  42. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_module_api.py +22 -0
  43. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_module_settings.py +1 -0
  44. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_module_settings_render.py +55 -3
  45. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_module_settings_serialize.py +1 -1
  46. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_field_sources.py +55 -4
  47. simple_module_settings-0.0.34/tests/test_settings_secret_masking.py +186 -0
  48. simple_module_settings-0.0.34/tests/test_settings_secret_urls.py +162 -0
  49. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_view_authz.py +2 -2
  50. simple_module_settings-0.0.34/tests/test_store_filters.py +226 -0
  51. simple_module_settings-0.0.34/tests/test_store_secret_masking.py +231 -0
  52. simple_module_settings-0.0.34/tests/test_testable_checks.py +65 -0
  53. simple_module_settings-0.0.34/tests-js/ModuleForm.test.tsx +237 -0
  54. simple_module_settings-0.0.34/tests-js/ScopeTabs.test.tsx +67 -0
  55. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tsconfig.json +1 -1
  56. simple_module_settings-0.0.32/settings/locales/en.json +0 -82
  57. simple_module_settings-0.0.32/settings/pages/Browse.tsx +0 -157
  58. simple_module_settings-0.0.32/settings/pages/Create.tsx +0 -164
  59. simple_module_settings-0.0.32/settings/pages/ModulesEdit.tsx +0 -113
  60. simple_module_settings-0.0.32/settings/pages/components/FieldInput.tsx +0 -102
  61. simple_module_settings-0.0.32/settings/pages/components/FieldSource.tsx +0 -59
  62. simple_module_settings-0.0.32/settings/pages/components/KeyField.tsx +0 -104
  63. simple_module_settings-0.0.32/settings/pages/components/ModuleForm.tsx +0 -167
  64. simple_module_settings-0.0.32/settings/pages/components/TestConnectionButton.tsx +0 -70
  65. simple_module_settings-0.0.32/settings/pages/components/ValueInput.tsx +0 -92
  66. simple_module_settings-0.0.32/settings/pages/routes.ts +0 -10
  67. simple_module_settings-0.0.32/settings/service.py +0 -162
  68. simple_module_settings-0.0.32/settings/settings.py +0 -11
  69. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/.gitignore +0 -0
  70. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/LICENSE +0 -0
  71. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/README.md +0 -0
  72. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/__init__.py +0 -0
  73. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/cli.py +0 -0
  74. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/contracts/__init__.py +0 -0
  75. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/contracts/accessor.py +0 -0
  76. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/contracts/events.py +0 -0
  77. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/contracts/registry.py +0 -0
  78. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/contracts/schemas.py +0 -0
  79. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/deps.py +0 -0
  80. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/endpoints/__init__.py +0 -0
  81. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/endpoints/api.py +0 -0
  82. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/env_vars.py +0 -0
  83. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/hydrate.py +0 -0
  84. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/models.py +0 -0
  85. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/py.typed +0 -0
  86. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/settings/services.py +0 -0
  87. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_concurrent_overrides.py +0 -0
  88. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_hydrate.py +0 -0
  89. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_module_registry.py +0 -0
  90. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_registration.py +0 -0
  91. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_reload.py +0 -0
  92. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_accessor.py +0 -0
  93. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_api.py +0 -0
  94. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_events.py +0 -0
  95. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_module.py +0 -0
  96. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_schemas.py +0 -0
  97. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_settings_service.py +0 -0
  98. {simple_module_settings-0.0.32 → simple_module_settings-0.0.34}/tests/test_store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_settings
3
- Version: 0.0.32
3
+ Version: 0.0.34
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.32
25
- Requires-Dist: simple-module-db==0.0.32
26
- Requires-Dist: simple-module-hosting==0.0.32
24
+ Requires-Dist: simple-module-core==0.0.34
25
+ Requires-Dist: simple-module-db==0.0.34
26
+ Requires-Dist: simple-module-hosting==0.0.34
27
27
  Requires-Dist: typer>=0.12
28
28
  Description-Content-Type: text/markdown
29
29
 
@@ -12,5 +12,7 @@
12
12
  "devDependencies": {
13
13
  "@simple-module-py/tsconfig": "*"
14
14
  },
15
- "dependencies": {}
15
+ "dependencies": {
16
+ "sonner": "^2.0.7"
17
+ }
16
18
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_settings"
3
- version = "0.0.32"
3
+ version = "0.0.34"
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.32",
25
- "simple_module_db==0.0.32",
26
- "simple_module_hosting==0.0.32",
24
+ "simple_module_core==0.0.34",
25
+ "simple_module_db==0.0.34",
26
+ "simple_module_hosting==0.0.34",
27
27
  "typer>=0.12",
28
28
  ]
29
29
 
@@ -0,0 +1,81 @@
1
+ """Pure metadata readers for a single pydantic settings field.
2
+
3
+ Split out of ``_module_settings`` so that file keeps one job — walking the app
4
+ for settings classes — while the fiddly "what did pydantic actually record for
5
+ this field?" questions live together and are testable without a FastAPI app.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any
12
+
13
+ from pydantic_settings import BaseSettings
14
+
15
+ # ``^(console|smtp)$`` and nothing more adventurous: an alternation of literals
16
+ # anchored at both ends is the only pattern shape that maps cleanly onto a
17
+ # select. Anything else (character classes, quantifiers, sub-groups) stays a
18
+ # free-text input rather than becoming a list that silently omits valid values.
19
+ _ENUM_PATTERN = re.compile(r"^\^\(([^()|]+(?:\|[^()|]+)+)\)\$$")
20
+
21
+
22
+ def resolve_default(info) -> Any:
23
+ """Return the effective default, handling ``default_factory`` fields.
24
+
25
+ Pydantic sets ``info.default`` to ``PydanticUndefined`` when
26
+ ``default_factory`` is used. We call the factory to get the concrete
27
+ default so the settings UI can serialize it.
28
+ """
29
+ from pydantic_core import PydanticUndefined
30
+
31
+ if info.default is not PydanticUndefined:
32
+ return info.default
33
+ if info.default_factory is not None:
34
+ return info.default_factory()
35
+ return None
36
+
37
+
38
+ def env_readable_var(settings: BaseSettings, name: str) -> str | None:
39
+ """Env var pydantic would actually read for ``name``, or ``None``.
40
+
41
+ ``env_var`` on the view is a *label* — the ``SM_<PACKAGE>_<FIELD>`` name the
42
+ ``smpy settings import-from-env`` CLI looks for, kept from before settings
43
+ moved into the DB. It is not evidence that pydantic reads it: the bundled
44
+ module settings classes subclass ``DbBackedSettings``, which drops the env
45
+ source entirely, so ``SM_FILE_STORAGE_BACKEND`` has no effect on
46
+ ``FileStorageSettings()``. (Until GH #283 those classes subclassed
47
+ ``BaseSettings`` and merely omitted ``env_prefix``, which left pydantic
48
+ reading each field from its *bare* name instead of not at all.) Deriving
49
+ env-readability from the class's own ``env_prefix`` keeps the "From
50
+ environment" badge honest, and works as-is for the classes that do declare
51
+ one — the host's ``Settings`` (``SM_``), ``BackgroundTasksSettings``
52
+ (``SM_BG_TASKS_``), and every module built from the scaffold, whose
53
+ template ships ``env_prefix="SM_<PACKAGE>_"``.
54
+ """
55
+ env_prefix = str(type(settings).model_config.get("env_prefix") or "")
56
+ if not env_prefix:
57
+ return None
58
+ return f"{env_prefix}{name.upper()}"
59
+
60
+
61
+ def choices_for(info) -> list[str] | None:
62
+ """The closed set of values this field accepts, or ``None`` if open.
63
+
64
+ Read from an explicit ``json_schema_extra={"choices": [...]}`` first, then
65
+ inferred from a simple alternation ``pattern``. A field constrained to
66
+ ``console`` or ``smtp`` renders as a select; without this it is a text box
67
+ whose only feedback on a typo is a 422 after Save.
68
+ """
69
+ extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
70
+ declared = extra.get("choices")
71
+ if isinstance(declared, (list, tuple)) and declared:
72
+ return [str(choice) for choice in declared]
73
+
74
+ for constraint in info.metadata:
75
+ pattern = getattr(constraint, "pattern", None)
76
+ if not isinstance(pattern, str):
77
+ continue
78
+ match = _ENUM_PATTERN.match(pattern)
79
+ if match:
80
+ return match.group(1).split("|")
81
+ return None
@@ -7,31 +7,28 @@ Each module stores a services dataclass on ``app.state.<package>`` whose
7
7
  from __future__ import annotations
8
8
 
9
9
  import os
10
- import re
11
10
  from dataclasses import dataclass
12
11
  from typing import Any
13
12
 
14
13
  from fastapi import FastAPI
15
- from fastapi.encoders import jsonable_encoder
16
14
  from pydantic_settings import BaseSettings
17
15
 
16
+ from settings._field_meta import choices_for, env_readable_var, resolve_default
17
+
18
+ # Re-exported: callers reach for ``SECRET_MASK`` / ``is_secret_field`` here.
19
+ from settings._secrets import ( # noqa: F401
20
+ _NEVER_SECRET_TYPES,
21
+ SECRET_MASK,
22
+ conceals_secret,
23
+ embeds_credential,
24
+ is_named_secret,
25
+ is_secret_field,
26
+ mask,
27
+ )
18
28
  from settings.env_vars import env_prefix_for
19
29
  from settings.hydrate import value_type_for_field
20
30
  from settings.service import SettingService
21
31
 
22
- # We intentionally DON'T match the bare word "token" (would mask
23
- # `verification_token_lifetime_seconds` — just an int) or "key" alone (would
24
- # mask `s3_bucket_key_prefix`). Only fragments that actually indicate material.
25
- _SECRET_PATTERNS = re.compile(
26
- r"(password|secret|api[_-]?key|private[_-]?key|token[_-]?secret)", re.I
27
- )
28
- SECRET_MASK = "••••••••"
29
-
30
-
31
- def is_secret_field(name: str) -> bool:
32
- """True if a field name suggests it holds credential material."""
33
- return bool(_SECRET_PATTERNS.search(name))
34
-
35
32
 
36
33
  @dataclass(frozen=True, slots=True)
37
34
  class ModuleSettingField:
@@ -58,6 +55,22 @@ class ModuleSettingField:
58
55
  """
59
56
  db_override: bool = False
60
57
  """A stored setting overrides this field."""
58
+ env_readable: bool = False
59
+ """The declaring class reads env at all, i.e. it declares an ``env_prefix``.
60
+
61
+ :attr:`env_set` collapses two very different answers into one ``False``:
62
+ "there is no env fallback for this field" and "there is one and nobody set
63
+ it". The Resolved value panel has to tell them apart, or it labels a row
64
+ "env fallback" for a class that will never look at the environment.
65
+ """
66
+ env_value: str | None = None
67
+ """The env var's current contents, masked for secrets, or ``None``.
68
+
69
+ Only populated when the field is genuinely env-readable *and* set — it is
70
+ the fallback the panel shows, not a second copy of the live value.
71
+ """
72
+ choices: list[str] | None = None
73
+ """Closed set of accepted values, or ``None`` when the field is free text."""
61
74
 
62
75
  @property
63
76
  def source(self) -> str:
@@ -84,12 +97,9 @@ class ModuleSettingsView:
84
97
  env_prefix: str
85
98
  class_name: str
86
99
  fields: list[ModuleSettingField]
87
-
88
-
89
- def _mask(value: Any) -> Any:
90
- if value in (None, "", [], {}):
91
- return value
92
- return SECRET_MASK
100
+ manage_url: str | None = None
101
+ """The module's own management page. When set, the generic editor renders
102
+ a link there instead of a second editor for the same fields."""
93
103
 
94
104
 
95
105
  def _package_of(mod: Any) -> str:
@@ -111,42 +121,6 @@ def _extract_settings(app: FastAPI, package: str) -> BaseSettings | None:
111
121
  return inner if isinstance(inner, BaseSettings) else None
112
122
 
113
123
 
114
- def _resolve_default(info) -> Any:
115
- """Return the effective default, handling ``default_factory`` fields.
116
-
117
- Pydantic sets ``info.default`` to ``PydanticUndefined`` when
118
- ``default_factory`` is used. We call the factory to get the concrete
119
- default so the settings UI can serialize it.
120
- """
121
- from pydantic_core import PydanticUndefined
122
-
123
- if info.default is not PydanticUndefined:
124
- return info.default
125
- if info.default_factory is not None:
126
- return info.default_factory()
127
- return None
128
-
129
-
130
- def _env_readable_var(settings: BaseSettings, name: str) -> str | None:
131
- """Env var pydantic would actually read for ``name``, or ``None``.
132
-
133
- ``env_var`` on the view is a *label* — the ``SM_<PACKAGE>_<FIELD>`` name the
134
- ``smpy settings import-from-env`` CLI looks for, kept from before settings
135
- moved into the DB. It is not evidence that pydantic reads it: the bundled
136
- module settings classes declare ``SettingsConfigDict(extra="ignore")`` with
137
- no ``env_prefix``, so ``SM_FILE_STORAGE_BACKEND`` has no effect on
138
- ``FileStorageSettings()``. Deriving env-readability from the class's own
139
- ``env_prefix`` keeps the "From environment" badge honest, and works as-is
140
- for the classes that do declare one — the host's ``Settings`` (``SM_``) and
141
- every module built from the scaffold, whose template ships
142
- ``env_prefix="SM_<PACKAGE>_"``.
143
- """
144
- env_prefix = str(type(settings).model_config.get("env_prefix") or "")
145
- if not env_prefix:
146
- return None
147
- return f"{env_prefix}{name.upper()}"
148
-
149
-
150
124
  def _field_view(
151
125
  name: str,
152
126
  settings: BaseSettings,
@@ -156,23 +130,42 @@ def _field_view(
156
130
  cls = type(settings)
157
131
  info = cls.model_fields[name]
158
132
  raw_value = getattr(settings, name)
159
- secret = is_secret_field(name)
133
+ value_type = value_type_for_field(cls, name)
134
+ named_secret = is_named_secret(name, value_type)
160
135
  extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
161
- default = _resolve_default(info)
136
+ default = resolve_default(info)
162
137
  env_var = f"{prefix}{name.upper()}"
163
- live_env_var = _env_readable_var(settings, name)
138
+ live_env_var = env_readable_var(settings, name)
139
+ env_set = live_env_var is not None and live_env_var in os.environ
140
+ # ``live_env_var`` is not None whenever env_set is True, but the narrowing
141
+ # is spelled out so a future edit cannot turn this into ``os.environ[None]``.
142
+ raw_env = os.environ[live_env_var] if env_set and live_env_var is not None else None
143
+
144
+ def hidden(value: Any) -> bool:
145
+ return conceals_secret(name, value, value_type)
146
+
147
+ # ``is_secret`` drives the editor's input type and the "reveal" affordance,
148
+ # so it has to be true when *any* of the three readings is being hidden —
149
+ # otherwise the form offers to edit a value it is showing as dots.
150
+ secret = named_secret or any(embeds_credential(v) for v in (raw_value, default, raw_env))
164
151
  return ModuleSettingField(
165
152
  name=name,
166
153
  env_var=env_var,
167
- value=_mask(raw_value) if secret else raw_value,
168
- default=_mask(default) if secret else default,
154
+ value=mask(raw_value) if hidden(raw_value) else raw_value,
155
+ default=mask(default) if hidden(default) else default,
169
156
  description=info.description or "",
170
157
  is_secret=secret,
171
- type=value_type_for_field(cls, name),
158
+ type=value_type,
172
159
  requires_restart=bool(extra.get("requires_restart", False)),
173
160
  group=extra.get("group"),
174
- env_set=live_env_var is not None and live_env_var in os.environ,
161
+ env_set=env_set,
175
162
  db_override=name in overridden,
163
+ env_readable=live_env_var is not None,
164
+ # Masked on the same rule as ``value`` and ``default``: a secret stays
165
+ # hidden, everything else is shown. Masking unconditionally turned the
166
+ # Resolved value panel's env row into a row of dots for every key.
167
+ env_value=(mask(raw_env) if hidden(raw_env) else raw_env) if raw_env is not None else None,
168
+ choices=choices_for(info),
176
169
  )
177
170
 
178
171
 
@@ -194,16 +187,22 @@ def collect_module_settings(
194
187
  views: list[ModuleSettingsView] = []
195
188
  seen: set[str] = set()
196
189
 
190
+ settings_services = getattr(app.state, "settings", None)
191
+ registry = getattr(settings_services, "module_registry", None)
192
+
193
+ def _manage_url(package: str) -> str | None:
194
+ return registry.manage_url(package) if registry is not None else None
195
+
197
196
  for mod in getattr(app.state.sm, "modules", ()):
198
197
  package = _package_of(mod)
199
198
  settings = _extract_settings(app, package)
200
199
  if settings is None:
201
200
  continue
202
- views.append(_build_view(mod.meta.name, package, settings, by_package))
201
+ views.append(
202
+ _build_view(mod.meta.name, package, settings, by_package, _manage_url(package))
203
+ )
203
204
  seen.add(package)
204
205
 
205
- settings_services = getattr(app.state, "settings", None)
206
- registry = getattr(settings_services, "module_registry", None)
207
206
  if registry is not None:
208
207
  for package in registry.all_packages():
209
208
  if package in seen:
@@ -211,7 +210,9 @@ def collect_module_settings(
211
210
  settings = _extract_settings(app, package)
212
211
  if settings is None:
213
212
  continue
214
- views.append(_build_view(package.title(), package, settings, by_package))
213
+ views.append(
214
+ _build_view(package.title(), package, settings, by_package, _manage_url(package))
215
+ )
215
216
  seen.add(package)
216
217
 
217
218
  views.sort(key=lambda v: v.module_name)
@@ -223,6 +224,7 @@ def _build_view(
223
224
  package: str,
224
225
  settings: BaseSettings,
225
226
  overrides: dict[str, frozenset[str]] | None = None,
227
+ manage_url: str | None = None,
226
228
  ) -> ModuleSettingsView:
227
229
  prefix = env_prefix_for(package)
228
230
  overridden = (overrides or {}).get(package, frozenset())
@@ -235,6 +237,7 @@ def _build_view(
235
237
  env_prefix=prefix,
236
238
  class_name=type(settings).__name__,
237
239
  fields=fields,
240
+ manage_url=manage_url,
238
241
  )
239
242
 
240
243
 
@@ -249,41 +252,3 @@ async def overrides_by_package(service: SettingService) -> dict[str, frozenset[s
249
252
  from settings.store import SettingsStore
250
253
 
251
254
  return await SettingsStore(service).all_override_fields()
252
-
253
-
254
- def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
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
- """
264
- return [
265
- {
266
- "module_name": v.module_name,
267
- "package": v.package,
268
- "env_prefix": v.env_prefix,
269
- "class_name": v.class_name,
270
- "fields": [
271
- {
272
- "name": f.name,
273
- "env_var": f.env_var,
274
- "value": jsonable_encoder(f.value),
275
- "default": jsonable_encoder(f.default),
276
- "description": f.description,
277
- "is_secret": f.is_secret,
278
- "type": f.type,
279
- "requires_restart": f.requires_restart,
280
- "group": f.group,
281
- "env_set": f.env_set,
282
- "db_override": f.db_override,
283
- "source": f.source,
284
- }
285
- for f in v.fields
286
- ],
287
- }
288
- for v in views
289
- ]
@@ -0,0 +1,56 @@
1
+ """Serialize module-settings views into Inertia props.
2
+
3
+ Split from ``_module_settings`` (collection) so each file keeps one
4
+ responsibility: that one discovers and shapes the views, this one is the
5
+ boundary where a settings object stops being Python and becomes a prop.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from fastapi.encoders import jsonable_encoder
13
+
14
+ from settings._module_settings import ModuleSettingsView
15
+
16
+
17
+ def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
18
+ """Convert dataclass views to plain dicts for Inertia props.
19
+
20
+ Field values arrive as whatever type the module declared — pydantic has
21
+ already coerced ``media_root: Path`` to a ``PosixPath``, ``timeout:
22
+ timedelta`` to a ``timedelta`` — and this screen reflects every installed
23
+ module's settings, so the set of types is open-ended by design. They are
24
+ encoded here rather than handed on as-is.
25
+ """
26
+ return [
27
+ {
28
+ "module_name": v.module_name,
29
+ "package": v.package,
30
+ "env_prefix": v.env_prefix,
31
+ "class_name": v.class_name,
32
+ "manage_url": v.manage_url,
33
+ "fields": [
34
+ {
35
+ "name": f.name,
36
+ "env_var": f.env_var,
37
+ "value": jsonable_encoder(f.value),
38
+ "default": jsonable_encoder(f.default),
39
+ "description": f.description,
40
+ "is_secret": f.is_secret,
41
+ "type": f.type,
42
+ "requires_restart": f.requires_restart,
43
+ "group": f.group,
44
+ "env_set": f.env_set,
45
+ "env_readable": f.env_readable,
46
+ "db_override": f.db_override,
47
+ "source": f.source,
48
+ # Turns a pattern-constrained string into a select rather
49
+ # than a text box whose only feedback on a typo is a 422.
50
+ "choices": f.choices,
51
+ }
52
+ for f in v.fields
53
+ ],
54
+ }
55
+ for v in views
56
+ ]
@@ -0,0 +1,68 @@
1
+ """Whether a stored row's value may be shown, and how the mask is honoured.
2
+
3
+ Split out of ``service`` so the "is this a secret, and is this write the mask
4
+ being echoed back?" question lives in one file, next to the field-level rules in
5
+ ``_secrets`` that it delegates to. ``service`` imports these; nothing else
6
+ should need to, because the point of funnelling every read through ``out`` is
7
+ that there is no second way to serialize a row.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from settings._secrets import conceals_secret
13
+ from settings.constants import SENSITIVE_KEYS, SENSITIVE_PLACEHOLDER
14
+ from settings.contracts.schemas import SettingOut
15
+ from settings.models import Setting
16
+
17
+
18
+ def is_masked(entity: Setting) -> bool:
19
+ """Whether this row's stored value must not leave the service.
20
+
21
+ :data:`SENSITIVE_KEYS` names the one key the hosting layer owns
22
+ (``host.secret_key``). Everything else is judged by the same name/value
23
+ rule the module editor uses, because the store is the *same data* seen
24
+ through a different screen: an override named ``users.smtp_password``, or
25
+ any override holding a ``postgresql://user:pw@host/db``, was rendered in
26
+ clear text in the browse table and pre-filled into the edit form purely
27
+ because the allowlist had a single entry in it.
28
+ """
29
+ return entity.key in SENSITIVE_KEYS or conceals_secret(
30
+ entity.key, entity.value, entity.value_type
31
+ )
32
+
33
+
34
+ def out(entity: Setting) -> SettingOut:
35
+ """Serialize a row, masking values that must not leave the service.
36
+
37
+ Every read path funnels through here so a secret cannot be read back by
38
+ listing it, resolving it, or fetching it by id.
39
+ """
40
+ out = SettingOut.model_validate(entity)
41
+ if is_masked(entity):
42
+ return out.model_copy(update={"value": SENSITIVE_PLACEHOLDER})
43
+ return out
44
+
45
+
46
+ def is_placeholder_write(entity: Setting, value: object) -> bool:
47
+ """Whether this write is the mask being echoed back, not a real new value.
48
+
49
+ The admin edit form GETs the row, pre-fills its input from the response,
50
+ and PUTs it back. For a masked row that response carries ``"********"``, so
51
+ an admin who opens the row and clicks Save — without touching the field —
52
+ would otherwise overwrite a real credential with a fixed, publicly-known
53
+ string. On ``host.secret_key`` that silently invalidates every session and
54
+ makes every future cookie forgeable.
55
+
56
+ Gated on the stored row rather than on the sentinel alone, so a non-secret
57
+ row whose real value happens to be eight asterisks still saves.
58
+
59
+ Treated as "leave it alone" rather than rejected, so the rest of the form
60
+ still saves and an admin who genuinely types a new value can still set one.
61
+ """
62
+ return is_masked(entity) and value == SENSITIVE_PLACEHOLDER
63
+
64
+
65
+ def drop_placeholder_write(entity: Setting, changes: dict) -> None:
66
+ """Strip a masked-value echo out of an update payload, in place."""
67
+ if "value" in changes and is_placeholder_write(entity, changes["value"]):
68
+ del changes["value"]
@@ -0,0 +1,97 @@
1
+ """What counts as a secret on a settings field, and how one is hidden.
2
+
3
+ Split out of ``_module_settings`` so the "should this value be shown?"
4
+ question — a name rule, a value rule and one mask — lives in one file and can
5
+ be reasoned about without a FastAPI app in scope. Re-exported from
6
+ ``_module_settings``, which is where the rest of the codebase imports it from.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Any
13
+ from urllib.parse import urlsplit
14
+
15
+ # We intentionally DON'T match the bare word "token" (would mask
16
+ # `verification_token_lifetime_seconds` — just an int) or "key" alone (would
17
+ # mask `s3_bucket_key_prefix`). Only fragments that actually indicate material.
18
+ _SECRET_PATTERNS = re.compile(
19
+ r"(password|secret|api[_-]?key|private[_-]?key|token[_-]?secret)", re.I
20
+ )
21
+ SECRET_MASK = "••••••••"
22
+
23
+ # Value types that cannot carry credential material, so a secret-ish *name*
24
+ # on one of them is a false positive rather than something to hide.
25
+ _NEVER_SECRET_TYPES = frozenset({"int", "float", "bool"})
26
+
27
+
28
+ def is_secret_field(name: str) -> bool:
29
+ """True if a field name suggests it holds credential material."""
30
+ return bool(_SECRET_PATTERNS.search(name))
31
+
32
+
33
+ def is_named_secret(name: str, value_type: str | None = None) -> bool:
34
+ """True if the *name* marks this field as credential material.
35
+
36
+ ``value_type`` exempts the types that cannot hold one, so a numeric field
37
+ whose name merely contains a secret-ish word is not masked and made
38
+ uneditable — ``reset_password_token_lifetime_seconds`` is an int, but it
39
+ matches on "password" exactly as the real secrets do. Phrased as "exempt
40
+ the types that cannot hold a credential" rather than "mask only strings" so
41
+ the failure direction is safe: an unexpected type (``str | None`` resolves
42
+ to "json") stays masked instead of silently exposing a secret.
43
+ """
44
+ if value_type in _NEVER_SECRET_TYPES:
45
+ return False
46
+ return is_secret_field(name)
47
+
48
+
49
+ def conceals_secret(name: str, value: Any, value_type: str | None = None) -> bool:
50
+ """True if this name/value pair must be masked on the way out.
51
+
52
+ The one rule every read path shares: mask per *value* as well as per name,
53
+ so a DSN carrying a password is hidden even on a field called
54
+ ``broker_url``, while that field's password-free default is still shown.
55
+ """
56
+ return is_named_secret(name, value_type) or embeds_credential(value)
57
+
58
+
59
+ def embeds_credential(value: Any) -> bool:
60
+ """True if ``value`` carries a password in a URL authority.
61
+
62
+ The name rule alone cannot see these: ``broker_url``, ``result_backend``,
63
+ ``redis_url`` and ``database_url`` match nothing in
64
+ :data:`_SECRET_PATTERNS`, yet a production DSN is precisely where the
65
+ Redis and Postgres passwords live — and those fields are ``env_readable``,
66
+ so the value reached both the module editor and the ``known_keys``
67
+ suggestion list in clear text.
68
+
69
+ Judged on the value rather than the name because the name is the thing
70
+ that was wrong. A DSN without a password stays visible: hiding
71
+ ``redis://localhost:6379/0`` helps nobody debug why the queue is idle.
72
+
73
+ Containers are walked rather than dismissed: a ``list[str]`` of broker
74
+ URLs or a ``dict`` of per-tenant DSNs holds exactly the same material as
75
+ the bare string, and returning False for anything non-``str`` meant one
76
+ such field would have been shown in full.
77
+ """
78
+ if isinstance(value, str):
79
+ if "://" not in value:
80
+ return False
81
+ try:
82
+ return bool(urlsplit(value).password)
83
+ except ValueError:
84
+ # A malformed authority (an unclosed IPv6 literal, say) is not a
85
+ # credential, but it must not take the settings screen down either.
86
+ return False
87
+ if isinstance(value, (list, tuple, set, frozenset)):
88
+ return any(embeds_credential(item) for item in value)
89
+ if isinstance(value, dict):
90
+ return any(embeds_credential(item) for item in value.values())
91
+ return False
92
+
93
+
94
+ def mask(value: Any) -> Any:
95
+ if value in (None, "", [], {}):
96
+ return value
97
+ return SECRET_MASK