simple-module-settings 0.0.36__tar.gz → 0.0.37__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 (114) hide show
  1. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/.gitignore +1 -0
  2. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/PKG-INFO +4 -4
  3. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/pyproject.toml +4 -4
  4. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_module_settings.py +40 -16
  5. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_module_settings_props.py +2 -0
  6. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_secrets.py +43 -3
  7. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/module_api.py +17 -3
  8. simple_module_settings-0.0.37/settings/hydrate.py +223 -0
  9. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/locales/en.json +9 -2
  10. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/module_registry.py +3 -0
  11. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/FieldInput.tsx +42 -11
  12. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/FieldSource.tsx +5 -5
  13. simple_module_settings-0.0.37/settings/pages/components/JsonInput.tsx +77 -0
  14. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/ModuleFieldRow.tsx +18 -2
  15. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/ModuleForm.tsx +26 -6
  16. simple_module_settings-0.0.37/settings/pages/components/validation-errors.test.ts +32 -0
  17. simple_module_settings-0.0.37/settings/pages/components/validation-errors.ts +45 -0
  18. simple_module_settings-0.0.37/settings/reload.py +158 -0
  19. simple_module_settings-0.0.37/tests/test_env_pinned.py +121 -0
  20. simple_module_settings-0.0.37/tests/test_hydrate.py +229 -0
  21. simple_module_settings-0.0.37/tests/test_hydrate_related_fields.py +53 -0
  22. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_module_api.py +5 -2
  23. simple_module_settings-0.0.37/tests/test_module_api_json_fields.py +129 -0
  24. simple_module_settings-0.0.37/tests/test_settings_default_clears_override.py +80 -0
  25. simple_module_settings-0.0.37/tests/test_settings_env_unprefixed.py +49 -0
  26. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_field_sources.py +4 -4
  27. simple_module_settings-0.0.37/tests/test_settings_secret_json_roundtrip.py +63 -0
  28. simple_module_settings-0.0.37/tests/test_settings_typed_secret_masking.py +97 -0
  29. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_store_filters.py +13 -5
  30. simple_module_settings-0.0.37/tests-js/JsonFields.test.tsx +127 -0
  31. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests-js/ModuleForm.test.tsx +54 -1
  32. simple_module_settings-0.0.36/settings/hydrate.py +0 -82
  33. simple_module_settings-0.0.36/settings/reload.py +0 -63
  34. simple_module_settings-0.0.36/tests/test_hydrate.py +0 -91
  35. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/LICENSE +0 -0
  36. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/README.md +0 -0
  37. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/package.json +0 -0
  38. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/__init__.py +0 -0
  39. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_announce.py +0 -0
  40. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_field_meta.py +0 -0
  41. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_listing.py +0 -0
  42. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_managed_keys.py +0 -0
  43. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_row_masking.py +0 -0
  44. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_unique_write.py +0 -0
  45. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/browse_query.py +0 -0
  46. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/cli.py +0 -0
  47. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/constants.py +0 -0
  48. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/contracts/__init__.py +0 -0
  49. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/contracts/accessor.py +0 -0
  50. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/contracts/events.py +0 -0
  51. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/contracts/invalidation.py +0 -0
  52. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/contracts/registry.py +0 -0
  53. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/contracts/schemas.py +0 -0
  54. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/deps.py +0 -0
  55. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/__init__.py +0 -0
  56. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/_create_form.py +0 -0
  57. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/api.py +0 -0
  58. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/tenant_api.py +0 -0
  59. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/views.py +0 -0
  60. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/env_vars.py +0 -0
  61. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/errors.py +0 -0
  62. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/known_keys.py +0 -0
  63. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/models.py +0 -0
  64. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/module.py +0 -0
  65. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/Browse.tsx +0 -0
  66. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/Create.tsx +0 -0
  67. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/Edit.tsx +0 -0
  68. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/ModulesEdit.tsx +0 -0
  69. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/KeyField.tsx +0 -0
  70. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/ResolvedValue.tsx +0 -0
  71. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/ScopeTabs.tsx +0 -0
  72. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/StoreCards.tsx +0 -0
  73. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/StoreTable.tsx +0 -0
  74. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/TestConnectionButton.tsx +0 -0
  75. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/ValueInput.tsx +0 -0
  76. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/last-test.ts +0 -0
  77. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/routes.ts +0 -0
  78. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/types.ts +0 -0
  79. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/py.typed +0 -0
  80. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/registration.py +0 -0
  81. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/service.py +0 -0
  82. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/services.py +0 -0
  83. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/settings.py +0 -0
  84. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/store.py +0 -0
  85. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/tenant_scope.py +0 -0
  86. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/tenant_view.py +0 -0
  87. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_clear_route_per_scope.py +0 -0
  88. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_cli_import.py +0 -0
  89. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_concurrent_overrides.py +0 -0
  90. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_duplicate_and_race.py +0 -0
  91. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_known_keys_meta.py +0 -0
  92. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_module_registry.py +0 -0
  93. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_module_settings.py +0 -0
  94. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_module_settings_render.py +0 -0
  95. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_module_settings_serialize.py +0 -0
  96. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_registration.py +0 -0
  97. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_reload.py +0 -0
  98. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_accessor.py +0 -0
  99. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_api.py +0 -0
  100. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_events.py +0 -0
  101. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_module.py +0 -0
  102. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_schemas.py +0 -0
  103. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_secret_masking.py +0 -0
  104. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_secret_urls.py +0 -0
  105. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_service.py +0 -0
  106. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_view_authz.py +0 -0
  107. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_store.py +0 -0
  108. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_store_secret_masking.py +0 -0
  109. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_tenant_settings.py +0 -0
  110. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_testable_checks.py +0 -0
  111. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_upload_key_delete.py +0 -0
  112. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests-js/ModulesEdit.test.tsx +0 -0
  113. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests-js/ScopeTabs.test.tsx +0 -0
  114. {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tsconfig.json +0 -0
@@ -64,3 +64,4 @@ host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
66
  .verify/
67
+ /openapi.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_settings
3
- Version: 0.0.36
3
+ Version: 0.0.37
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.36
25
- Requires-Dist: simple-module-db==0.0.36
26
- Requires-Dist: simple-module-hosting==0.0.36
24
+ Requires-Dist: simple-module-core==0.0.37
25
+ Requires-Dist: simple-module-db==0.0.37
26
+ Requires-Dist: simple-module-hosting==0.0.37
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.36"
3
+ version = "0.0.37"
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.36",
25
- "simple_module_db==0.0.36",
26
- "simple_module_hosting==0.0.36",
24
+ "simple_module_core==0.0.37",
25
+ "simple_module_db==0.0.37",
26
+ "simple_module_hosting==0.0.37",
27
27
  "typer>=0.12",
28
28
  ]
29
29
 
@@ -19,6 +19,7 @@ from settings._field_meta import choices_for, env_readable_var, resolve_default
19
19
  from settings._secrets import ( # noqa: F401
20
20
  _NEVER_SECRET_TYPES,
21
21
  SECRET_MASK,
22
+ annotation_holds_secret,
22
23
  conceals_secret,
23
24
  embeds_credential,
24
25
  is_named_secret,
@@ -26,7 +27,7 @@ from settings._secrets import ( # noqa: F401
26
27
  mask,
27
28
  )
28
29
  from settings.env_vars import env_prefix_for
29
- from settings.hydrate import value_type_for_field
30
+ from settings.hydrate import env_supplied_fields, input_type_for_field, value_type_for_field
30
31
  from settings.service import SettingService
31
32
 
32
33
 
@@ -71,22 +72,28 @@ class ModuleSettingField:
71
72
  """
72
73
  choices: list[str] | None = None
73
74
  """Closed set of accepted values, or ``None`` when the field is free text."""
75
+ input_type: str = ""
76
+ """Editor control: ``type`` with an ``X | None`` unwrapped to ``X``."""
77
+ nullable: bool = False
78
+ """``None`` is a valid value; the editor sends it for an empty input."""
74
79
 
75
80
  @property
76
81
  def source(self) -> str:
77
82
  """Where the live value came from: ``db``, ``env`` or ``default``.
78
83
 
79
- Mirrors the precedence in ``hydrate_settings``: DB overrides are passed
80
- to the constructor explicitly, so they beat anything pydantic reads for
81
- fields left unset, which in turn beats the field default. ``env`` only
82
- appears for settings classes that actually declare an ``env_prefix``;
83
- see :attr:`env_set`. Showing this is the difference between "why is
84
- this not taking effect" being a five-minute question and an afternoon.
84
+ Mirrors the precedence in ``hydrate_settings``: env → DB → default
85
+ (GH #486). A stored override for a field the environment sets is
86
+ ignored, so ``env`` wins even when ``db_override`` is true. ``env`` only
87
+ appears for settings classes that declare an ``env_prefix`` (or name
88
+ the field in ``env_seeded_fields``) — the same rule
89
+ ``hydrate.env_supplied_fields`` applies; see :attr:`env_set`. Showing
90
+ this is the difference between "why is this not taking effect" being a
91
+ five-minute question and an afternoon.
85
92
  """
86
- if self.db_override:
87
- return "db"
88
93
  if self.env_set:
89
94
  return "env"
95
+ if self.db_override:
96
+ return "db"
90
97
  return "default"
91
98
 
92
99
 
@@ -126,28 +133,41 @@ def _field_view(
126
133
  settings: BaseSettings,
127
134
  prefix: str,
128
135
  overridden: frozenset[str] = frozenset(),
136
+ env_supplied: frozenset[str] | None = None,
129
137
  ) -> ModuleSettingField:
130
138
  cls = type(settings)
139
+ if env_supplied is None:
140
+ env_supplied = env_supplied_fields(cls)
131
141
  info = cls.model_fields[name]
132
142
  raw_value = getattr(settings, name)
133
143
  value_type = value_type_for_field(cls, name)
144
+ input_type, nullable = input_type_for_field(cls, name)
134
145
  named_secret = is_named_secret(name, value_type)
135
146
  extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
136
147
  default = resolve_default(info)
137
148
  env_var = f"{prefix}{name.upper()}"
138
149
  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
150
+ # The same answer ``build_settings`` acts on (GH #486), so a value supplied
151
+ # by the class's ``.env``, a secrets file or an alias is labelled "env"
152
+ # here exactly when hydration lets it beat a stored override.
153
+ env_set = name in env_supplied
154
+ raw_env = os.environ.get(live_env_var) if env_set and live_env_var is not None else None
155
+
156
+ # Declared as ``SecretStr`` (also inside ``X | None`` or a container):
157
+ # masked whatever the field is called, since the store holds it in clear.
158
+ typed_secret = annotation_holds_secret(info.annotation)
143
159
 
144
160
  def hidden(value: Any) -> bool:
145
- return conceals_secret(name, value, value_type)
161
+ return typed_secret or conceals_secret(name, value, value_type)
146
162
 
147
163
  # ``is_secret`` drives the editor's input type and the "reveal" affordance,
148
164
  # so it has to be true when *any* of the three readings is being hidden —
149
165
  # 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))
166
+ secret = (
167
+ typed_secret
168
+ or named_secret
169
+ or any(embeds_credential(v) for v in (raw_value, default, raw_env))
170
+ )
151
171
  return ModuleSettingField(
152
172
  name=name,
153
173
  env_var=env_var,
@@ -166,6 +186,8 @@ def _field_view(
166
186
  # Resolved value panel's env row into a row of dots for every key.
167
187
  env_value=(mask(raw_env) if hidden(raw_env) else raw_env) if raw_env is not None else None,
168
188
  choices=choices_for(info),
189
+ input_type=input_type,
190
+ nullable=nullable,
169
191
  )
170
192
 
171
193
 
@@ -228,8 +250,10 @@ def _build_view(
228
250
  ) -> ModuleSettingsView:
229
251
  prefix = env_prefix_for(package)
230
252
  overridden = (overrides or {}).get(package, frozenset())
253
+ from_env = env_supplied_fields(type(settings))
231
254
  fields = [
232
- _field_view(name, settings, prefix, overridden) for name in type(settings).model_fields
255
+ _field_view(name, settings, prefix, overridden, from_env)
256
+ for name in type(settings).model_fields
233
257
  ]
234
258
  return ModuleSettingsView(
235
259
  module_name=module_name,
@@ -49,6 +49,8 @@ def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
49
49
  # Turns a pattern-constrained string into a select rather
50
50
  # than a text box whose only feedback on a typo is a 422.
51
51
  "choices": f.choices,
52
+ "input_type": f.input_type or f.type,
53
+ "nullable": f.nullable,
52
54
  }
53
55
  for f in v.fields
54
56
  ],
@@ -9,9 +9,11 @@ be reasoned about without a FastAPI app in scope. Re-exported from
9
9
  from __future__ import annotations
10
10
 
11
11
  import re
12
- from typing import Any
12
+ from typing import Any, get_args, get_origin
13
13
  from urllib.parse import urlsplit
14
14
 
15
+ from pydantic import BaseModel, SecretBytes, SecretStr
16
+
15
17
  # We intentionally DON'T match the bare word "token" (would mask
16
18
  # `verification_token_lifetime_seconds` — just an int) or "key" alone (would
17
19
  # mask `s3_bucket_key_prefix`). Only fragments that actually indicate material.
@@ -25,6 +27,41 @@ SECRET_MASK = "••••••••"
25
27
  _NEVER_SECRET_TYPES = frozenset({"int", "float", "bool"})
26
28
 
27
29
 
30
+ # ``<package>.<field>`` keys whose declared type holds a ``SecretStr`` /
31
+ # ``SecretBytes``. Filled by ``ModuleSettingsRegistry.register`` so the row
32
+ # browser, tenant view and known-keys list — which see only a key and a stored
33
+ # string — mask them too. Only ever grows; keys are package-qualified.
34
+ _TYPED_SECRET_KEYS: set[str] = set()
35
+
36
+
37
+ def annotation_holds_secret(annotation: Any, _seen: frozenset[type] = frozenset()) -> bool:
38
+ """True if a ``SecretStr``/``SecretBytes`` sits anywhere in ``annotation``.
39
+
40
+ Walks unions (``SecretStr | None``), containers (``dict[str, SecretStr]``)
41
+ and nested models. Since json fields store the real secret (GH #487
42
+ review), a field typed this way must be masked whatever its name.
43
+ """
44
+ if get_origin(annotation) is None and isinstance(annotation, type):
45
+ if issubclass(annotation, SecretStr | SecretBytes):
46
+ return True
47
+ if issubclass(annotation, BaseModel) and annotation not in _seen:
48
+ seen = _seen | {annotation}
49
+ return any(
50
+ annotation_holds_secret(f.annotation, seen)
51
+ for f in annotation.model_fields.values()
52
+ )
53
+ return any(annotation_holds_secret(arg, _seen) for arg in get_args(annotation))
54
+
55
+
56
+ def register_typed_secrets(package: str, cls: type[BaseModel]) -> None:
57
+ """Record which of ``cls``'s fields are secrets by type."""
58
+ _TYPED_SECRET_KEYS.update(
59
+ f"{package}.{name}"
60
+ for name, info in cls.model_fields.items()
61
+ if annotation_holds_secret(info.annotation)
62
+ )
63
+
64
+
28
65
  def is_secret_field(name: str) -> bool:
29
66
  """True if a field name suggests it holds credential material."""
30
67
  return bool(_SECRET_PATTERNS.search(name))
@@ -49,11 +86,14 @@ def is_named_secret(name: str, value_type: str | None = None) -> bool:
49
86
  def conceals_secret(name: str, value: Any, value_type: str | None = None) -> bool:
50
87
  """True if this name/value pair must be masked on the way out.
51
88
 
52
- The one rule every read path shares: mask per *value* as well as per name,
89
+ The one rule every read path shares: mask per *type* (a registered
90
+ ``<package>.<field>`` declared as a secret), per *value* and per name,
53
91
  so a DSN carrying a password is hidden even on a field called
54
92
  ``broker_url``, while that field's password-free default is still shown.
55
93
  """
56
- return is_named_secret(name, value_type) or embeds_credential(value)
94
+ return (
95
+ name in _TYPED_SECRET_KEYS or is_named_secret(name, value_type) or embeds_credential(value)
96
+ )
57
97
 
58
98
 
59
99
  def embeds_credential(value: Any) -> bool:
@@ -11,6 +11,7 @@ from typing import Any
11
11
 
12
12
  from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response, status
13
13
  from pydantic import ValidationError
14
+ from simple_module_hosting.i18n_deps import TranslatorDep
14
15
  from simple_module_hosting.permissions import RequiresPermission
15
16
 
16
17
  from settings._module_settings import (
@@ -23,7 +24,7 @@ from settings.constants import MODULE_PACKAGE, PERM_DELETE, PERM_EDIT, PERM_VIEW
23
24
  from settings.contracts.events import SettingsReloaded
24
25
  from settings.deps import get_setting_service
25
26
  from settings.hydrate import hydrate_settings
26
- from settings.reload import apply_changes_and_reload
27
+ from settings.reload import EnvPinnedError, apply_changes_and_reload
27
28
  from settings.service import SettingService
28
29
  from settings.store import SettingsStore
29
30
 
@@ -90,6 +91,7 @@ async def update_module(
90
91
  package: str,
91
92
  changes: dict[str, Any],
92
93
  request: Request,
94
+ t: TranslatorDep,
93
95
  service: SettingService = Depends(get_setting_service),
94
96
  ) -> dict[str, Any]:
95
97
  registry = getattr(request.app.state, MODULE_PACKAGE).module_registry
@@ -116,9 +118,21 @@ async def update_module(
116
118
  except ValidationError as exc:
117
119
  # ``exc.errors()`` still leaves ``ctx.error`` holding the raw Python
118
120
  # exception (not JSON-serializable). Round-trip through pydantic's
119
- # own JSON encoder to get clean, serializable errors.
120
- clean = json.loads(exc.json(include_url=False))
121
+ # own JSON encoder to get clean, serializable errors. ``input`` is
122
+ # dropped: it echoes the submitted value, which may be a secret.
123
+ clean = json.loads(exc.json(include_url=False, include_input=False))
121
124
  raise HTTPException(status_code=422, detail=clean) from exc
125
+ except EnvPinnedError as exc:
126
+ # Shaped like a pydantic 422 so the editor shows it on the field.
127
+ detail = [
128
+ {
129
+ "loc": ["body", name],
130
+ "msg": t.t("settings.modules_form.env_pinned"),
131
+ "type": "env_pinned",
132
+ }
133
+ for name in exc.fields
134
+ ]
135
+ raise HTTPException(status_code=422, detail=detail) from exc
122
136
  except KeyError as exc:
123
137
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
124
138
 
@@ -0,0 +1,223 @@
1
+ """Resolve a module's BaseSettings from DB overrides + pydantic defaults.
2
+
3
+ A field's declared Python type maps to one of the five ``value_type`` labels
4
+ understood by SettingsStore (``string | bool | int | float | json``). The
5
+ hydrator reads overrides, parses each according to its stored ``value_type``,
6
+ and constructs the BaseSettings — pydantic enforces field validators and any
7
+ ``@model_validator`` hooks.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import types
15
+ from typing import Union, get_args, get_origin
16
+
17
+ from pydantic import BaseModel, ValidationError
18
+ from pydantic_core import ErrorDetails
19
+ from pydantic_settings import (
20
+ BaseSettings,
21
+ DotEnvSettingsSource,
22
+ EnvSettingsSource,
23
+ InitSettingsSource,
24
+ SecretsSettingsSource,
25
+ )
26
+ from sqlalchemy.orm import Session
27
+
28
+ from settings.store import SettingsStore, get_overrides_sync
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _SCALAR_TYPES = {bool: "bool", int: "int", float: "float"}
33
+
34
+ #: ``ctx`` key on a validation error listing the *other* fields a cross-field
35
+ #: rule depends on; raise it with ``PydanticCustomError(type, msg, {key: [...]})``.
36
+ #: Lenient hydration drops those when the reported field is not from the DB.
37
+ RELATED_FIELDS_CTX = "related_fields"
38
+
39
+
40
+ def value_type_for_field(cls: type[BaseSettings], field_name: str) -> str:
41
+ """Return the ``value_type`` label for a field based on its annotation.
42
+
43
+ - ``bool`` → ``"bool"``
44
+ - ``int`` → ``"int"``
45
+ - ``float`` → ``"float"``
46
+ - ``str`` and enums → ``"string"``
47
+ - ``list``, ``dict``, and other container types → ``"json"``
48
+ """
49
+ info = cls.model_fields[field_name]
50
+ ann = info.annotation
51
+ origin = get_origin(ann)
52
+ if origin is not None:
53
+ return "json"
54
+ return _SCALAR_TYPES.get(ann, "string")
55
+
56
+
57
+ def _holds_structure(ann: object) -> bool:
58
+ """A bare ``dict``/``list``/model: not editable as a single-line string."""
59
+ return isinstance(ann, type) and issubclass(
60
+ ann, dict | list | set | frozenset | tuple | BaseModel
61
+ )
62
+
63
+
64
+ def input_type_for_field(cls: type[BaseSettings], field_name: str) -> tuple[str, bool]:
65
+ """``(input_type, nullable)`` — which editor control a field needs.
66
+
67
+ Storage stays on :func:`value_type_for_field`, which files every union
68
+ under ``json``. That is right for the store and wrong for the form:
69
+ ``int | None`` wants a number box where empty means ``None``, not a JSON
70
+ textarea that shows the literal text ``null`` (GH #487).
71
+ """
72
+ ann = cls.model_fields[field_name].annotation
73
+ if get_origin(ann) not in (Union, types.UnionType):
74
+ return value_type_for_field(cls, field_name), False
75
+ args = get_args(ann)
76
+ rest = [a for a in args if a is not type(None)]
77
+ nullable = len(rest) < len(args)
78
+ if len(rest) == 1 and get_origin(rest[0]) is None:
79
+ only = rest[0]
80
+ if _holds_structure(only):
81
+ return "json", nullable
82
+ return _SCALAR_TYPES.get(only, "string"), nullable
83
+ return "json", nullable
84
+
85
+
86
+ def _parse(raw: str, value_type: str):
87
+ if value_type == "bool":
88
+ return raw.lower() in ("1", "true", "yes", "on")
89
+ if value_type == "int":
90
+ return int(raw)
91
+ if value_type == "float":
92
+ return float(raw)
93
+ if value_type == "json":
94
+ return json.loads(raw)
95
+ return raw
96
+
97
+
98
+ def env_supplied_fields(cls: type[BaseSettings]) -> frozenset[str]:
99
+ """Fields the class's own non-init sources (env, ``.env``, secrets) answer.
100
+
101
+ Asks the sources ``cls`` actually installs, so a ``DbBackedSettings``
102
+ (init-only) reports nothing and an ``env_prefix`` class reports exactly the
103
+ ``<PREFIX><FIELD>`` names that are set. Deliberately not ``cls()``: a
104
+ model validator can reject the env-only view (a production broker check)
105
+ that the DB-merged one would pass.
106
+
107
+ A class with no ``env_prefix`` reports nothing from its sources: a plain
108
+ ``BaseSettings`` reads bare, case-insensitive names, so a field called
109
+ ``user``, ``home`` or ``path`` would be pinned by the process environment
110
+ and every admin edit refused. Only its ``env_seeded_fields`` count.
111
+ """
112
+ found: set[str] = set()
113
+ if cls.model_config.get("env_prefix"):
114
+ found.update(_source_supplied_fields(cls))
115
+ # A class may also read env through a ``default_factory`` (one shared
116
+ # variable seeding several fields), which no source can see. It names
117
+ # those fields itself, so env still beats the DB for them.
118
+ seeded = getattr(cls, "env_seeded_fields", None)
119
+ if callable(seeded):
120
+ found.update(seeded())
121
+ return frozenset(found) & cls.model_fields.keys()
122
+
123
+
124
+ def _source_supplied_fields(cls: type[BaseSettings]) -> set[str]:
125
+ sources = cls.settings_customise_sources(
126
+ cls,
127
+ init_settings=InitSettingsSource(cls, init_kwargs={}),
128
+ env_settings=EnvSettingsSource(cls),
129
+ dotenv_settings=DotEnvSettingsSource(cls),
130
+ file_secret_settings=SecretsSettingsSource(cls),
131
+ )
132
+ by_key = {(info.alias or name): name for name, info in cls.model_fields.items()}
133
+ found: set[str] = set()
134
+ for source in sources:
135
+ if isinstance(source, InitSettingsSource):
136
+ continue
137
+ found.update(by_key.get(key, key) for key in source())
138
+ return found
139
+
140
+
141
+ def build_settings[T: BaseSettings](
142
+ cls: type[T], raw_overrides: dict[str, tuple[str, str]], *, lenient: bool = False
143
+ ) -> T:
144
+ """Construct ``cls`` from ``{field: (raw, value_type)}``, skipping unknown fields.
145
+
146
+ Shared by the async and sync hydrators so parsing cannot drift.
147
+
148
+ Strict by default: an invalid stored value raises, which is what a settings
149
+ write relies on to answer 422. ``lenient=True`` is for *boot* paths (web
150
+ lifespan, workers): a row that was stored before a validator tightened must
151
+ not take the app down, nor discard the module's other, valid overrides. Each
152
+ offending field is logged and ignored (it falls back to env/default).
153
+
154
+ Precedence is env → DB → default (GH #486). pydantic-settings ranks init
155
+ kwargs above its env source, so a stored override for a field the
156
+ environment sets is dropped here rather than passed in.
157
+ """
158
+ from_env = env_supplied_fields(cls)
159
+ parsed: dict[str, object] = {}
160
+ for field_name, (raw, vtype) in raw_overrides.items():
161
+ if field_name not in cls.model_fields or field_name in from_env:
162
+ continue
163
+ try:
164
+ parsed[field_name] = _parse(raw, vtype)
165
+ except ValueError:
166
+ if not lenient:
167
+ raise
168
+ logger.error(
169
+ "Ignoring stored %s.%s: cannot parse as %s", cls.__name__, field_name, vtype
170
+ )
171
+ if not lenient:
172
+ return cls(**parsed)
173
+ try:
174
+ return cls(**parsed)
175
+ except ValidationError as exc:
176
+ culprits = {name: err["type"] for err in exc.errors() for name in _culprits(err, parsed)}
177
+ if not culprits:
178
+ raise
179
+ for name in sorted(culprits):
180
+ logger.error(
181
+ "Ignoring stored value for %s.%s: it no longer validates (%s)",
182
+ cls.__name__,
183
+ name,
184
+ # error *type* only: a validator's message may echo the value (a secret)
185
+ culprits[name],
186
+ )
187
+ return cls(**{k: v for k, v in parsed.items() if k not in culprits})
188
+
189
+
190
+ def _culprits(error: ErrorDetails, parsed: dict[str, object]) -> list[str]:
191
+ """The stored keys to drop so that ``error`` goes away.
192
+
193
+ The field the error is reported on, when its value came from the DB.
194
+ Otherwise — that field is env-pinned or defaulted — the stored fields the
195
+ validator names under ``ctx[RELATED_FIELDS_CTX]``: a cross-field rule
196
+ reported on one field (so the editor shows it there) whose other half came
197
+ from the DB, which is the only half lenient hydration can drop (#548).
198
+ """
199
+ loc = str(error["loc"][0]) if error["loc"] else None
200
+ if loc in parsed:
201
+ return [loc]
202
+ related = (error.get("ctx") or {}).get(RELATED_FIELDS_CTX, ())
203
+ return [name for name in related if name in parsed]
204
+
205
+
206
+ async def hydrate_settings[T: BaseSettings](
207
+ cls: type[T], store: SettingsStore, package: str, *, lenient: bool = False
208
+ ) -> T:
209
+ """Construct ``cls`` with DB overrides merged over pydantic defaults."""
210
+ return build_settings(cls, await store.get_overrides(package), lenient=lenient)
211
+
212
+
213
+ def hydrate_settings_sync[T: BaseSettings](
214
+ cls: type[T], session: Session, package: str, *, lenient: bool = False
215
+ ) -> T:
216
+ """Sync :func:`hydrate_settings` for Celery workers and other non-async code.
217
+
218
+ Workers never run the hosting lifespan, so a bare ``cls()`` there returns
219
+ pydantic defaults (``DbBackedSettings`` ignores the environment on purpose).
220
+ Takes a plain sync ``Session`` and applies the same SYSTEM-scope overrides
221
+ with the same ``value_type`` parsing as the web process.
222
+ """
223
+ return build_settings(cls, get_overrides_sync(session, package), lenient=lenient)
@@ -119,7 +119,7 @@
119
119
  "env_var_hint": "Environment variable name for this field, used by \"smpy settings import-from-env\"",
120
120
  "env_var_not_read_hint": "{env_var} is a label only — this module reads its settings from the database, never from the environment",
121
121
  "source_db": "overridden in DB",
122
- "source_db_over_env": "overridden in DB · shadows env",
122
+ "source_db_ignored_for_env": "stored in DB · env var wins",
123
123
  "secret_write_only": "write-only · never returned",
124
124
  "head_title": "Module settings",
125
125
  "managed_title": "Managed on its own page",
@@ -130,9 +130,16 @@
130
130
  "save": "Save",
131
131
  "saving": "Saving…",
132
132
  "saved_toast": "Settings saved",
133
+ "validation_failed": "These settings could not be saved",
134
+ "save_failed": "Saving failed. Try again.",
133
135
  "requires_restart": "Requires restart",
134
136
  "reset_to_default": "Revert",
135
- "set_new_value": "Set new value"
137
+ "set_new_value": "Set new value",
138
+ "invalid_json": "Not valid JSON — it will be rejected on save.",
139
+ "not_set": "Not set (default)",
140
+ "option_on": "On",
141
+ "option_off": "Off",
142
+ "env_pinned": "Set by an environment variable, which takes precedence. Change it there."
136
143
  },
137
144
  "nav": {
138
145
  "settings": "Settings"
@@ -11,6 +11,8 @@ from dataclasses import dataclass, field
11
11
 
12
12
  from pydantic_settings import BaseSettings
13
13
 
14
+ from settings._secrets import register_typed_secrets
15
+
14
16
 
15
17
  @dataclass(slots=True)
16
18
  class ModuleSettingsRegistry:
@@ -28,6 +30,7 @@ class ModuleSettingsRegistry:
28
30
  if package in self._classes:
29
31
  raise ValueError(f"{package!r} already registered")
30
32
  self._classes[package] = cls
33
+ register_typed_secrets(package, cls)
31
34
  if manage_url:
32
35
  self._manage_urls[package] = manage_url
33
36