simple-module-settings 0.0.32__tar.gz → 0.0.33__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.32 → simple_module_settings-0.0.33}/PKG-INFO +4 -4
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/package.json +3 -1
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/pyproject.toml +4 -4
- simple_module_settings-0.0.33/settings/_field_meta.py +81 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/_module_settings.py +79 -106
- simple_module_settings-0.0.33/settings/_module_settings_props.py +56 -0
- simple_module_settings-0.0.33/settings/_secrets.py +60 -0
- simple_module_settings-0.0.33/settings/browse_query.py +49 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/constants.py +29 -1
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/endpoints/module_api.py +24 -4
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/endpoints/views.py +61 -44
- simple_module_settings-0.0.33/settings/known_keys.py +83 -0
- simple_module_settings-0.0.33/settings/locales/en.json +141 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/module.py +32 -1
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/module_registry.py +19 -2
- simple_module_settings-0.0.33/settings/pages/Browse.tsx +171 -0
- simple_module_settings-0.0.33/settings/pages/Create.tsx +163 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/pages/Edit.tsx +6 -22
- simple_module_settings-0.0.33/settings/pages/ModulesEdit.tsx +147 -0
- simple_module_settings-0.0.33/settings/pages/components/FieldInput.tsx +152 -0
- simple_module_settings-0.0.33/settings/pages/components/FieldSource.tsx +77 -0
- simple_module_settings-0.0.33/settings/pages/components/KeyField.tsx +144 -0
- simple_module_settings-0.0.33/settings/pages/components/ModuleFieldRow.tsx +69 -0
- simple_module_settings-0.0.33/settings/pages/components/ModuleForm.tsx +179 -0
- simple_module_settings-0.0.33/settings/pages/components/ResolvedValue.tsx +89 -0
- simple_module_settings-0.0.33/settings/pages/components/ScopeTabs.tsx +37 -0
- simple_module_settings-0.0.33/settings/pages/components/StoreCards.tsx +93 -0
- simple_module_settings-0.0.33/settings/pages/components/StoreTable.tsx +117 -0
- simple_module_settings-0.0.33/settings/pages/components/TestConnectionButton.tsx +122 -0
- simple_module_settings-0.0.33/settings/pages/components/ValueInput.tsx +87 -0
- simple_module_settings-0.0.33/settings/pages/components/last-test.ts +48 -0
- simple_module_settings-0.0.33/settings/pages/routes.ts +10 -0
- simple_module_settings-0.0.33/settings/pages/types.ts +25 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/registration.py +8 -2
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/reload.py +7 -2
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/service.py +121 -14
- simple_module_settings-0.0.33/settings/settings.py +15 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_cli_import.py +8 -0
- simple_module_settings-0.0.33/tests/test_known_keys_meta.py +214 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_module_api.py +22 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_module_settings.py +1 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_module_settings_render.py +55 -3
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_module_settings_serialize.py +1 -1
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_field_sources.py +55 -4
- simple_module_settings-0.0.33/tests/test_settings_secret_masking.py +186 -0
- simple_module_settings-0.0.33/tests/test_settings_secret_urls.py +119 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_view_authz.py +2 -2
- simple_module_settings-0.0.33/tests/test_store_filters.py +200 -0
- simple_module_settings-0.0.33/tests/test_testable_checks.py +65 -0
- simple_module_settings-0.0.33/tests-js/ModuleForm.test.tsx +237 -0
- simple_module_settings-0.0.33/tests-js/ScopeTabs.test.tsx +67 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tsconfig.json +1 -1
- simple_module_settings-0.0.32/settings/locales/en.json +0 -82
- simple_module_settings-0.0.32/settings/pages/Browse.tsx +0 -157
- simple_module_settings-0.0.32/settings/pages/Create.tsx +0 -164
- simple_module_settings-0.0.32/settings/pages/ModulesEdit.tsx +0 -113
- simple_module_settings-0.0.32/settings/pages/components/FieldInput.tsx +0 -102
- simple_module_settings-0.0.32/settings/pages/components/FieldSource.tsx +0 -59
- simple_module_settings-0.0.32/settings/pages/components/KeyField.tsx +0 -104
- simple_module_settings-0.0.32/settings/pages/components/ModuleForm.tsx +0 -167
- simple_module_settings-0.0.32/settings/pages/components/TestConnectionButton.tsx +0 -70
- simple_module_settings-0.0.32/settings/pages/components/ValueInput.tsx +0 -92
- simple_module_settings-0.0.32/settings/pages/routes.ts +0 -10
- simple_module_settings-0.0.32/settings/settings.py +0 -11
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/.gitignore +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/LICENSE +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/README.md +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/__init__.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/cli.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/contracts/__init__.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/contracts/accessor.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/contracts/events.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/contracts/registry.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/contracts/schemas.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/deps.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/endpoints/__init__.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/endpoints/api.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/env_vars.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/hydrate.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/models.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/py.typed +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/services.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/store.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_concurrent_overrides.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_hydrate.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_module_registry.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_registration.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_reload.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_accessor.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_api.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_events.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_module.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_schemas.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_service.py +0 -0
- {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/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.
|
|
3
|
+
Version: 0.0.33
|
|
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.33
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.33
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.33
|
|
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.33"
|
|
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.33",
|
|
25
|
+
"simple_module_db==0.0.33",
|
|
26
|
+
"simple_module_hosting==0.0.33",
|
|
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
|
{simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/_module_settings.py
RENAMED
|
@@ -7,31 +7,26 @@ 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
|
+
embeds_credential,
|
|
23
|
+
is_secret_field,
|
|
24
|
+
mask,
|
|
25
|
+
)
|
|
18
26
|
from settings.env_vars import env_prefix_for
|
|
19
27
|
from settings.hydrate import value_type_for_field
|
|
20
28
|
from settings.service import SettingService
|
|
21
29
|
|
|
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
30
|
|
|
36
31
|
@dataclass(frozen=True, slots=True)
|
|
37
32
|
class ModuleSettingField:
|
|
@@ -58,6 +53,22 @@ class ModuleSettingField:
|
|
|
58
53
|
"""
|
|
59
54
|
db_override: bool = False
|
|
60
55
|
"""A stored setting overrides this field."""
|
|
56
|
+
env_readable: bool = False
|
|
57
|
+
"""The declaring class reads env at all, i.e. it declares an ``env_prefix``.
|
|
58
|
+
|
|
59
|
+
:attr:`env_set` collapses two very different answers into one ``False``:
|
|
60
|
+
"there is no env fallback for this field" and "there is one and nobody set
|
|
61
|
+
it". The Resolved value panel has to tell them apart, or it labels a row
|
|
62
|
+
"env fallback" for a class that will never look at the environment.
|
|
63
|
+
"""
|
|
64
|
+
env_value: str | None = None
|
|
65
|
+
"""The env var's current contents, masked for secrets, or ``None``.
|
|
66
|
+
|
|
67
|
+
Only populated when the field is genuinely env-readable *and* set — it is
|
|
68
|
+
the fallback the panel shows, not a second copy of the live value.
|
|
69
|
+
"""
|
|
70
|
+
choices: list[str] | None = None
|
|
71
|
+
"""Closed set of accepted values, or ``None`` when the field is free text."""
|
|
61
72
|
|
|
62
73
|
@property
|
|
63
74
|
def source(self) -> str:
|
|
@@ -84,12 +95,9 @@ class ModuleSettingsView:
|
|
|
84
95
|
env_prefix: str
|
|
85
96
|
class_name: str
|
|
86
97
|
fields: list[ModuleSettingField]
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if value in (None, "", [], {}):
|
|
91
|
-
return value
|
|
92
|
-
return SECRET_MASK
|
|
98
|
+
manage_url: str | None = None
|
|
99
|
+
"""The module's own management page. When set, the generic editor renders
|
|
100
|
+
a link there instead of a second editor for the same fields."""
|
|
93
101
|
|
|
94
102
|
|
|
95
103
|
def _package_of(mod: Any) -> str:
|
|
@@ -111,42 +119,6 @@ def _extract_settings(app: FastAPI, package: str) -> BaseSettings | None:
|
|
|
111
119
|
return inner if isinstance(inner, BaseSettings) else None
|
|
112
120
|
|
|
113
121
|
|
|
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
122
|
def _field_view(
|
|
151
123
|
name: str,
|
|
152
124
|
settings: BaseSettings,
|
|
@@ -156,23 +128,52 @@ def _field_view(
|
|
|
156
128
|
cls = type(settings)
|
|
157
129
|
info = cls.model_fields[name]
|
|
158
130
|
raw_value = getattr(settings, name)
|
|
159
|
-
|
|
131
|
+
value_type = value_type_for_field(cls, name)
|
|
132
|
+
# A numeric field whose name merely contains a secret-ish word was being
|
|
133
|
+
# masked and made uneditable — `reset_password_token_lifetime_seconds` is
|
|
134
|
+
# an int, but it matches on "password" exactly as the real secrets do.
|
|
135
|
+
# Phrased as "exempt the types that cannot hold a credential" rather than
|
|
136
|
+
# "mask only strings" so the failure direction is safe: an unexpected type
|
|
137
|
+
# (e.g. `str | None`, which resolves to "json") stays masked instead of
|
|
138
|
+
# silently exposing a secret.
|
|
139
|
+
named_secret = value_type not in _NEVER_SECRET_TYPES and is_secret_field(name)
|
|
160
140
|
extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
|
|
161
|
-
default =
|
|
141
|
+
default = resolve_default(info)
|
|
162
142
|
env_var = f"{prefix}{name.upper()}"
|
|
163
|
-
live_env_var =
|
|
143
|
+
live_env_var = env_readable_var(settings, name)
|
|
144
|
+
env_set = live_env_var is not None and live_env_var in os.environ
|
|
145
|
+
# ``live_env_var`` is not None whenever env_set is True, but the narrowing
|
|
146
|
+
# is spelled out so a future edit cannot turn this into ``os.environ[None]``.
|
|
147
|
+
raw_env = os.environ[live_env_var] if env_set and live_env_var is not None else None
|
|
148
|
+
|
|
149
|
+
def hidden(value: Any) -> bool:
|
|
150
|
+
"""Mask per *value*, not just per name: a DSN carrying a password is a
|
|
151
|
+
secret even on a field called ``broker_url``, while the same field's
|
|
152
|
+
password-free default is still worth showing."""
|
|
153
|
+
return named_secret or embeds_credential(value)
|
|
154
|
+
|
|
155
|
+
# ``is_secret`` drives the editor's input type and the "reveal" affordance,
|
|
156
|
+
# so it has to be true when *any* of the three readings is being hidden —
|
|
157
|
+
# otherwise the form offers to edit a value it is showing as dots.
|
|
158
|
+
secret = named_secret or any(embeds_credential(v) for v in (raw_value, default, raw_env))
|
|
164
159
|
return ModuleSettingField(
|
|
165
160
|
name=name,
|
|
166
161
|
env_var=env_var,
|
|
167
|
-
value=
|
|
168
|
-
default=
|
|
162
|
+
value=mask(raw_value) if hidden(raw_value) else raw_value,
|
|
163
|
+
default=mask(default) if hidden(default) else default,
|
|
169
164
|
description=info.description or "",
|
|
170
165
|
is_secret=secret,
|
|
171
|
-
type=
|
|
166
|
+
type=value_type,
|
|
172
167
|
requires_restart=bool(extra.get("requires_restart", False)),
|
|
173
168
|
group=extra.get("group"),
|
|
174
|
-
env_set=
|
|
169
|
+
env_set=env_set,
|
|
175
170
|
db_override=name in overridden,
|
|
171
|
+
env_readable=live_env_var is not None,
|
|
172
|
+
# Masked on the same rule as ``value`` and ``default``: a secret stays
|
|
173
|
+
# hidden, everything else is shown. Masking unconditionally turned the
|
|
174
|
+
# Resolved value panel's env row into a row of dots for every key.
|
|
175
|
+
env_value=(mask(raw_env) if hidden(raw_env) else raw_env) if raw_env is not None else None,
|
|
176
|
+
choices=choices_for(info),
|
|
176
177
|
)
|
|
177
178
|
|
|
178
179
|
|
|
@@ -194,16 +195,22 @@ def collect_module_settings(
|
|
|
194
195
|
views: list[ModuleSettingsView] = []
|
|
195
196
|
seen: set[str] = set()
|
|
196
197
|
|
|
198
|
+
settings_services = getattr(app.state, "settings", None)
|
|
199
|
+
registry = getattr(settings_services, "module_registry", None)
|
|
200
|
+
|
|
201
|
+
def _manage_url(package: str) -> str | None:
|
|
202
|
+
return registry.manage_url(package) if registry is not None else None
|
|
203
|
+
|
|
197
204
|
for mod in getattr(app.state.sm, "modules", ()):
|
|
198
205
|
package = _package_of(mod)
|
|
199
206
|
settings = _extract_settings(app, package)
|
|
200
207
|
if settings is None:
|
|
201
208
|
continue
|
|
202
|
-
views.append(
|
|
209
|
+
views.append(
|
|
210
|
+
_build_view(mod.meta.name, package, settings, by_package, _manage_url(package))
|
|
211
|
+
)
|
|
203
212
|
seen.add(package)
|
|
204
213
|
|
|
205
|
-
settings_services = getattr(app.state, "settings", None)
|
|
206
|
-
registry = getattr(settings_services, "module_registry", None)
|
|
207
214
|
if registry is not None:
|
|
208
215
|
for package in registry.all_packages():
|
|
209
216
|
if package in seen:
|
|
@@ -211,7 +218,9 @@ def collect_module_settings(
|
|
|
211
218
|
settings = _extract_settings(app, package)
|
|
212
219
|
if settings is None:
|
|
213
220
|
continue
|
|
214
|
-
views.append(
|
|
221
|
+
views.append(
|
|
222
|
+
_build_view(package.title(), package, settings, by_package, _manage_url(package))
|
|
223
|
+
)
|
|
215
224
|
seen.add(package)
|
|
216
225
|
|
|
217
226
|
views.sort(key=lambda v: v.module_name)
|
|
@@ -223,6 +232,7 @@ def _build_view(
|
|
|
223
232
|
package: str,
|
|
224
233
|
settings: BaseSettings,
|
|
225
234
|
overrides: dict[str, frozenset[str]] | None = None,
|
|
235
|
+
manage_url: str | None = None,
|
|
226
236
|
) -> ModuleSettingsView:
|
|
227
237
|
prefix = env_prefix_for(package)
|
|
228
238
|
overridden = (overrides or {}).get(package, frozenset())
|
|
@@ -235,6 +245,7 @@ def _build_view(
|
|
|
235
245
|
env_prefix=prefix,
|
|
236
246
|
class_name=type(settings).__name__,
|
|
237
247
|
fields=fields,
|
|
248
|
+
manage_url=manage_url,
|
|
238
249
|
)
|
|
239
250
|
|
|
240
251
|
|
|
@@ -249,41 +260,3 @@ async def overrides_by_package(service: SettingService) -> dict[str, frozenset[s
|
|
|
249
260
|
from settings.store import SettingsStore
|
|
250
261
|
|
|
251
262
|
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,60 @@
|
|
|
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 embeds_credential(value: Any) -> bool:
|
|
34
|
+
"""True if ``value`` is a URL carrying a password in its authority.
|
|
35
|
+
|
|
36
|
+
The name rule alone cannot see these: ``broker_url``, ``result_backend``,
|
|
37
|
+
``redis_url`` and ``database_url`` match nothing in
|
|
38
|
+
:data:`_SECRET_PATTERNS`, yet a production DSN is precisely where the
|
|
39
|
+
Redis and Postgres passwords live — and those fields are ``env_readable``,
|
|
40
|
+
so the value reached both the module editor and the ``known_keys``
|
|
41
|
+
suggestion list in clear text.
|
|
42
|
+
|
|
43
|
+
Judged on the value rather than the name because the name is the thing
|
|
44
|
+
that was wrong. A DSN without a password stays visible: hiding
|
|
45
|
+
``redis://localhost:6379/0`` helps nobody debug why the queue is idle.
|
|
46
|
+
"""
|
|
47
|
+
if not isinstance(value, str) or "://" not in value:
|
|
48
|
+
return False
|
|
49
|
+
try:
|
|
50
|
+
return bool(urlsplit(value).password)
|
|
51
|
+
except ValueError:
|
|
52
|
+
# A malformed authority (an unclosed IPv6 literal, say) is not a
|
|
53
|
+
# credential, but it must not take the settings screen down either.
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def mask(value: Any) -> Any:
|
|
58
|
+
if value in (None, "", [], {}):
|
|
59
|
+
return value
|
|
60
|
+
return SECRET_MASK
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Turn the store screen's query string into filters the service can run.
|
|
2
|
+
|
|
3
|
+
Every parameter here arrives from a url an admin may have typed, bookmarked or
|
|
4
|
+
kept open while the data moved underneath it. None of those are client errors
|
|
5
|
+
worth a 422 page — a broken link should still render the table — so each one
|
|
6
|
+
falls back to a sane value rather than rejecting the request. Clamping lives
|
|
7
|
+
here rather than in the view so the ``filters``/``pagination`` props can echo
|
|
8
|
+
exactly what the query ran with.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from settings.constants import ALL_SCOPES, DEFAULT_PER_PAGE, MAX_PER_PAGE, SCOPE_ALL
|
|
16
|
+
from settings.contracts.schemas import SettingScope
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class BrowseQuery:
|
|
21
|
+
"""Validated, clamped filters for one render of the raw store."""
|
|
22
|
+
|
|
23
|
+
scope: str
|
|
24
|
+
"""A tab value: ``all`` or one of the real scopes."""
|
|
25
|
+
q: str
|
|
26
|
+
page: int
|
|
27
|
+
per_page: int
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def scope_filter(self) -> SettingScope | None:
|
|
31
|
+
"""The scope to filter on, or ``None`` for the ``all`` tab."""
|
|
32
|
+
return None if self.scope == SCOPE_ALL else SettingScope(self.scope)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _int_or(raw: str, fallback: int) -> int:
|
|
36
|
+
try:
|
|
37
|
+
return int(raw)
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
return fallback
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse(scope: str, q: str, page: str, per_page: str) -> BrowseQuery:
|
|
43
|
+
"""Read the four query params, substituting defaults for anything unusable."""
|
|
44
|
+
return BrowseQuery(
|
|
45
|
+
scope=scope if scope in ALL_SCOPES else SCOPE_ALL,
|
|
46
|
+
q=q,
|
|
47
|
+
page=max(_int_or(page, 1), 1),
|
|
48
|
+
per_page=max(1, min(_int_or(per_page, DEFAULT_PER_PAGE), MAX_PER_PAGE)),
|
|
49
|
+
)
|
|
@@ -42,9 +42,19 @@ ALL_VALUE_TYPES: Final = (
|
|
|
42
42
|
VALUE_TYPE_JSON,
|
|
43
43
|
)
|
|
44
44
|
|
|
45
|
+
# ── Secrets ──────────────────────────────────────────────────────────
|
|
46
|
+
# Keys whose stored value must never leave the settings API or the raw
|
|
47
|
+
# key/value browse screen. ``host.secret_key`` is the session-signing key: the
|
|
48
|
+
# hosting layer generates and persists it here when SM_SECRET_KEY is unset, and
|
|
49
|
+
# anyone who can read it can forge a session cookie for any account. Masked on
|
|
50
|
+
# the way out only — the boot-time reader goes straight to SQL, so hiding it
|
|
51
|
+
# here costs nothing.
|
|
52
|
+
SENSITIVE_KEYS: Final = frozenset({"host.secret_key"})
|
|
53
|
+
SENSITIVE_PLACEHOLDER: Final = "********"
|
|
54
|
+
|
|
45
55
|
# ── Routing ──────────────────────────────────────────────────────────
|
|
46
56
|
API_PREFIX: Final = "/api/settings"
|
|
47
|
-
VIEW_PREFIX: Final = "/settings"
|
|
57
|
+
VIEW_PREFIX: Final = "/admin/settings"
|
|
48
58
|
VIEW_CREATE_PATH: Final = "/create"
|
|
49
59
|
VIEW_EDIT_PATH: Final = "/{setting_id}/edit"
|
|
50
60
|
VIEW_MODULES_PATH: Final = "/modules"
|
|
@@ -101,6 +111,21 @@ PROP_SETTINGS: Final = "settings"
|
|
|
101
111
|
PROP_SETTING: Final = "setting"
|
|
102
112
|
PROP_MODULES: Final = "modules"
|
|
103
113
|
PROP_ERROR: Final = "error"
|
|
114
|
+
PROP_PAGINATION: Final = "pagination"
|
|
115
|
+
PROP_COUNTS: Final = "counts"
|
|
116
|
+
PROP_FILTERS: Final = "filters"
|
|
117
|
+
PROP_KNOWN_KEYS: Final = "known_keys"
|
|
118
|
+
PROP_TESTABLE: Final = "testable"
|
|
119
|
+
|
|
120
|
+
# ── Store listing (scope tabs + search + paging) ─────────────────────
|
|
121
|
+
# The table is server-filtered: the scope tabs report counts the client can no
|
|
122
|
+
# longer compute once it holds a single page, and an install with a few
|
|
123
|
+
# thousand overrides must not ship all of them to render twenty.
|
|
124
|
+
SCOPE_ALL: Final = "all"
|
|
125
|
+
"""Sentinel for "no scope filter" — a tab value, never a stored scope."""
|
|
126
|
+
|
|
127
|
+
DEFAULT_PER_PAGE: Final = 20
|
|
128
|
+
MAX_PER_PAGE: Final = 200
|
|
104
129
|
|
|
105
130
|
# ── User-facing error messages ───────────────────────────────────────
|
|
106
131
|
ERR_SETTING_NOT_FOUND: Final = "Setting not found"
|
|
@@ -121,3 +146,6 @@ QP_USER_ID: Final = "user_id"
|
|
|
121
146
|
QP_TENANT_ID: Final = "tenant_id"
|
|
122
147
|
QP_SCOPE: Final = "scope"
|
|
123
148
|
QP_SCOPE_ID: Final = "scope_id"
|
|
149
|
+
QP_Q: Final = "q"
|
|
150
|
+
QP_PAGE: Final = "page"
|
|
151
|
+
QP_PER_PAGE: Final = "per_page"
|