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.
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/.gitignore +1 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/PKG-INFO +4 -4
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/pyproject.toml +4 -4
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_module_settings.py +40 -16
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_module_settings_props.py +2 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_secrets.py +43 -3
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/module_api.py +17 -3
- simple_module_settings-0.0.37/settings/hydrate.py +223 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/locales/en.json +9 -2
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/module_registry.py +3 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/FieldInput.tsx +42 -11
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/FieldSource.tsx +5 -5
- simple_module_settings-0.0.37/settings/pages/components/JsonInput.tsx +77 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/ModuleFieldRow.tsx +18 -2
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/ModuleForm.tsx +26 -6
- simple_module_settings-0.0.37/settings/pages/components/validation-errors.test.ts +32 -0
- simple_module_settings-0.0.37/settings/pages/components/validation-errors.ts +45 -0
- simple_module_settings-0.0.37/settings/reload.py +158 -0
- simple_module_settings-0.0.37/tests/test_env_pinned.py +121 -0
- simple_module_settings-0.0.37/tests/test_hydrate.py +229 -0
- simple_module_settings-0.0.37/tests/test_hydrate_related_fields.py +53 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_module_api.py +5 -2
- simple_module_settings-0.0.37/tests/test_module_api_json_fields.py +129 -0
- simple_module_settings-0.0.37/tests/test_settings_default_clears_override.py +80 -0
- simple_module_settings-0.0.37/tests/test_settings_env_unprefixed.py +49 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_field_sources.py +4 -4
- simple_module_settings-0.0.37/tests/test_settings_secret_json_roundtrip.py +63 -0
- simple_module_settings-0.0.37/tests/test_settings_typed_secret_masking.py +97 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_store_filters.py +13 -5
- simple_module_settings-0.0.37/tests-js/JsonFields.test.tsx +127 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests-js/ModuleForm.test.tsx +54 -1
- simple_module_settings-0.0.36/settings/hydrate.py +0 -82
- simple_module_settings-0.0.36/settings/reload.py +0 -63
- simple_module_settings-0.0.36/tests/test_hydrate.py +0 -91
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/LICENSE +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/README.md +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/package.json +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/__init__.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_announce.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_field_meta.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_listing.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_managed_keys.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_row_masking.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_unique_write.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/browse_query.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/cli.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/constants.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/contracts/__init__.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/contracts/accessor.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/contracts/events.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/contracts/invalidation.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/contracts/registry.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/contracts/schemas.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/deps.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/__init__.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/_create_form.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/api.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/tenant_api.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/views.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/env_vars.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/errors.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/known_keys.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/models.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/module.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/Browse.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/Create.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/Edit.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/ModulesEdit.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/KeyField.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/ResolvedValue.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/ScopeTabs.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/StoreCards.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/StoreTable.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/TestConnectionButton.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/ValueInput.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/components/last-test.ts +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/routes.ts +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/pages/types.ts +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/py.typed +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/registration.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/service.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/services.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/settings.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/store.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/tenant_scope.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/tenant_view.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_clear_route_per_scope.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_cli_import.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_concurrent_overrides.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_duplicate_and_race.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_known_keys_meta.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_module_registry.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_module_settings.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_module_settings_render.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_module_settings_serialize.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_registration.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_reload.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_accessor.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_api.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_events.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_module.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_schemas.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_secret_masking.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_secret_urls.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_service.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_settings_view_authz.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_store.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_store_secret_masking.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_tenant_settings.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_testable_checks.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests/test_upload_key_delete.py +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests-js/ModulesEdit.test.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tests-js/ScopeTabs.test.tsx +0 -0
- {simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/tsconfig.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_settings
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
25
|
-
Requires-Dist: simple-module-db==0.0.
|
|
26
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
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.
|
|
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.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
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
|
|
{simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_module_settings.py
RENAMED
|
@@ -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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
appears for settings classes that
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
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 =
|
|
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
|
|
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,
|
{simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/_module_settings_props.py
RENAMED
|
@@ -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 *
|
|
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
|
|
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:
|
{simple_module_settings-0.0.36 → simple_module_settings-0.0.37}/settings/endpoints/module_api.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
"
|
|
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
|
|