simple-module-settings 0.0.1__py3-none-any.whl
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.
- settings/__init__.py +1 -0
- settings/_module_settings.py +169 -0
- settings/cli.py +78 -0
- settings/constants.py +115 -0
- settings/contracts/__init__.py +24 -0
- settings/contracts/accessor.py +236 -0
- settings/contracts/events.py +20 -0
- settings/contracts/registry.py +66 -0
- settings/contracts/schemas.py +154 -0
- settings/deps.py +56 -0
- settings/endpoints/__init__.py +0 -0
- settings/endpoints/api.py +202 -0
- settings/endpoints/module_api.py +102 -0
- settings/endpoints/views.py +120 -0
- settings/env_vars.py +18 -0
- settings/hydrate.py +63 -0
- settings/locales/en.json +71 -0
- settings/models.py +50 -0
- settings/module.py +76 -0
- settings/module_registry.py +33 -0
- settings/package.json +16 -0
- settings/pages/Browse.tsx +103 -0
- settings/pages/Create.tsx +113 -0
- settings/pages/Edit.tsx +102 -0
- settings/pages/ModulesEdit.tsx +74 -0
- settings/pages/components/FieldInput.tsx +96 -0
- settings/pages/components/ModuleForm.tsx +157 -0
- settings/pages/components/ValueInput.tsx +92 -0
- settings/pages/routes.ts +7 -0
- settings/py.typed +0 -0
- settings/registration.py +34 -0
- settings/reload.py +58 -0
- settings/service.py +162 -0
- settings/services.py +36 -0
- settings/settings.py +11 -0
- settings/store.py +61 -0
- simple_module_settings-0.0.1.dist-info/METADATA +69 -0
- simple_module_settings-0.0.1.dist-info/RECORD +41 -0
- simple_module_settings-0.0.1.dist-info/WHEEL +4 -0
- simple_module_settings-0.0.1.dist-info/entry_points.txt +5 -0
- simple_module_settings-0.0.1.dist-info/licenses/LICENSE +21 -0
settings/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Settings module."""
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Autodiscover per-module pydantic ``BaseSettings`` attached to ``app.state``.
|
|
2
|
+
|
|
3
|
+
Each module stores a services dataclass on ``app.state.<package>`` whose
|
|
4
|
+
``.settings`` attribute is a pydantic ``BaseSettings`` subclass.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI
|
|
14
|
+
from pydantic_settings import BaseSettings
|
|
15
|
+
|
|
16
|
+
from settings.env_vars import env_prefix_for
|
|
17
|
+
from settings.hydrate import value_type_for_field
|
|
18
|
+
|
|
19
|
+
# We intentionally DON'T match the bare word "token" (would mask
|
|
20
|
+
# `verification_token_lifetime_seconds` — just an int) or "key" alone (would
|
|
21
|
+
# mask `s3_bucket_key_prefix`). Only fragments that actually indicate material.
|
|
22
|
+
_SECRET_PATTERNS = re.compile(
|
|
23
|
+
r"(password|secret|api[_-]?key|private[_-]?key|token[_-]?secret)", re.I
|
|
24
|
+
)
|
|
25
|
+
SECRET_MASK = "••••••••"
|
|
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
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ModuleSettingField:
|
|
35
|
+
name: str
|
|
36
|
+
env_var: str
|
|
37
|
+
value: Any
|
|
38
|
+
default: Any
|
|
39
|
+
description: str
|
|
40
|
+
is_secret: bool
|
|
41
|
+
type: str
|
|
42
|
+
requires_restart: bool
|
|
43
|
+
group: str | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class ModuleSettingsView:
|
|
48
|
+
module_name: str
|
|
49
|
+
package: str
|
|
50
|
+
env_prefix: str
|
|
51
|
+
class_name: str
|
|
52
|
+
fields: list[ModuleSettingField]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _mask(value: Any) -> Any:
|
|
56
|
+
if value in (None, "", [], {}):
|
|
57
|
+
return value
|
|
58
|
+
return SECRET_MASK
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _package_of(mod: Any) -> str:
|
|
62
|
+
return type(mod).__module__.split(".", 1)[0]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_settings(app: FastAPI, package: str) -> BaseSettings | None:
|
|
66
|
+
"""Return the ``BaseSettings`` instance attached to ``app.state.<package>``.
|
|
67
|
+
|
|
68
|
+
The services dataclass exposes it as ``.settings``. We also accept the
|
|
69
|
+
rare case where the module stashes the settings object directly.
|
|
70
|
+
"""
|
|
71
|
+
services = getattr(app.state, package, None)
|
|
72
|
+
if services is None:
|
|
73
|
+
return None
|
|
74
|
+
if isinstance(services, BaseSettings):
|
|
75
|
+
return services
|
|
76
|
+
inner = getattr(services, "settings", None)
|
|
77
|
+
return inner if isinstance(inner, BaseSettings) else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _field_view(name: str, settings: BaseSettings, prefix: str) -> ModuleSettingField:
|
|
81
|
+
cls = type(settings)
|
|
82
|
+
info = cls.model_fields[name]
|
|
83
|
+
raw_value = getattr(settings, name)
|
|
84
|
+
secret = is_secret_field(name)
|
|
85
|
+
extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
|
|
86
|
+
return ModuleSettingField(
|
|
87
|
+
name=name,
|
|
88
|
+
env_var=f"{prefix}{name.upper()}",
|
|
89
|
+
value=_mask(raw_value) if secret else raw_value,
|
|
90
|
+
default=_mask(info.default) if secret else info.default,
|
|
91
|
+
description=info.description or "",
|
|
92
|
+
is_secret=secret,
|
|
93
|
+
type=value_type_for_field(cls, name),
|
|
94
|
+
requires_restart=bool(extra.get("requires_restart", False)),
|
|
95
|
+
group=extra.get("group"),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def collect_module_settings(app: FastAPI) -> list[ModuleSettingsView]:
|
|
100
|
+
"""Return a sorted, serializable view of every module's BaseSettings.
|
|
101
|
+
|
|
102
|
+
Folds in both ``app.state.sm.modules`` (plugin modules) and additional
|
|
103
|
+
packages registered via ``app.state.settings.module_registry`` (e.g.
|
|
104
|
+
``"host"``) that aren't backed by a ``ModuleBase`` instance.
|
|
105
|
+
"""
|
|
106
|
+
views: list[ModuleSettingsView] = []
|
|
107
|
+
seen: set[str] = set()
|
|
108
|
+
|
|
109
|
+
for mod in getattr(app.state.sm, "modules", ()):
|
|
110
|
+
package = _package_of(mod)
|
|
111
|
+
settings = _extract_settings(app, package)
|
|
112
|
+
if settings is None:
|
|
113
|
+
continue
|
|
114
|
+
views.append(_build_view(mod.meta.name, package, settings))
|
|
115
|
+
seen.add(package)
|
|
116
|
+
|
|
117
|
+
settings_services = getattr(app.state, "settings", None)
|
|
118
|
+
registry = getattr(settings_services, "module_registry", None)
|
|
119
|
+
if registry is not None:
|
|
120
|
+
for package in registry.all_packages():
|
|
121
|
+
if package in seen:
|
|
122
|
+
continue
|
|
123
|
+
settings = _extract_settings(app, package)
|
|
124
|
+
if settings is None:
|
|
125
|
+
continue
|
|
126
|
+
views.append(_build_view(package.title(), package, settings))
|
|
127
|
+
seen.add(package)
|
|
128
|
+
|
|
129
|
+
views.sort(key=lambda v: v.module_name)
|
|
130
|
+
return views
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_view(module_name: str, package: str, settings: BaseSettings) -> ModuleSettingsView:
|
|
134
|
+
prefix = env_prefix_for(package)
|
|
135
|
+
fields = [_field_view(name, settings, prefix) for name in type(settings).model_fields]
|
|
136
|
+
return ModuleSettingsView(
|
|
137
|
+
module_name=module_name,
|
|
138
|
+
package=package,
|
|
139
|
+
env_prefix=prefix,
|
|
140
|
+
class_name=type(settings).__name__,
|
|
141
|
+
fields=fields,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
|
|
146
|
+
"""Convert dataclass views to plain dicts for Inertia props."""
|
|
147
|
+
return [
|
|
148
|
+
{
|
|
149
|
+
"module_name": v.module_name,
|
|
150
|
+
"package": v.package,
|
|
151
|
+
"env_prefix": v.env_prefix,
|
|
152
|
+
"class_name": v.class_name,
|
|
153
|
+
"fields": [
|
|
154
|
+
{
|
|
155
|
+
"name": f.name,
|
|
156
|
+
"env_var": f.env_var,
|
|
157
|
+
"value": f.value,
|
|
158
|
+
"default": f.default,
|
|
159
|
+
"description": f.description,
|
|
160
|
+
"is_secret": f.is_secret,
|
|
161
|
+
"type": f.type,
|
|
162
|
+
"requires_restart": f.requires_restart,
|
|
163
|
+
"group": f.group,
|
|
164
|
+
}
|
|
165
|
+
for f in v.fields
|
|
166
|
+
],
|
|
167
|
+
}
|
|
168
|
+
for v in views
|
|
169
|
+
]
|
settings/cli.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""``sm-settings`` CLI — currently only ``import-from-env``.
|
|
2
|
+
|
|
3
|
+
One-shot migration: walks every registered module's ``BaseSettings`` and,
|
|
4
|
+
for each field whose legacy ``SM_<PREFIX>_<FIELD>`` env var is set, writes
|
|
5
|
+
a SYSTEM-scoped override into the Settings store.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
|
|
16
|
+
from settings.constants import MODULE_PACKAGE
|
|
17
|
+
from settings.env_vars import env_prefix_for
|
|
18
|
+
from settings.hydrate import value_type_for_field
|
|
19
|
+
from settings.store import SettingsStore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def import_from_env_impl(app: FastAPI, store: SettingsStore) -> int:
|
|
23
|
+
"""Write a SYSTEM override for every ``SM_<PREFIX>_<FIELD>`` env var set.
|
|
24
|
+
|
|
25
|
+
Returns the count of overrides written. Env vars that don't match a
|
|
26
|
+
registered field are ignored.
|
|
27
|
+
"""
|
|
28
|
+
registry = getattr(app.state, MODULE_PACKAGE).module_registry
|
|
29
|
+
count = 0
|
|
30
|
+
for package, cls in registry.items():
|
|
31
|
+
prefix = env_prefix_for(package)
|
|
32
|
+
for field_name in cls.model_fields:
|
|
33
|
+
raw = os.environ.get(f"{prefix}{field_name.upper()}")
|
|
34
|
+
if raw is None:
|
|
35
|
+
continue
|
|
36
|
+
vtype = value_type_for_field(cls, field_name)
|
|
37
|
+
await store.set_override(package, field_name, raw, vtype)
|
|
38
|
+
count += 1
|
|
39
|
+
return count
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main() -> int:
|
|
43
|
+
"""Console-script entry point for ``sm-settings``.
|
|
44
|
+
|
|
45
|
+
Supports a single subcommand: ``import-from-env``.
|
|
46
|
+
"""
|
|
47
|
+
argv = sys.argv[1:]
|
|
48
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
49
|
+
print("Usage: sm-settings import-from-env")
|
|
50
|
+
return 0 if argv else 1
|
|
51
|
+
if argv[0] != "import-from-env":
|
|
52
|
+
print(f"Unknown command: {argv[0]}", file=sys.stderr)
|
|
53
|
+
print("Usage: sm-settings import-from-env", file=sys.stderr)
|
|
54
|
+
return 2
|
|
55
|
+
|
|
56
|
+
from simple_module_hosting.app_builder import create_app
|
|
57
|
+
from simple_module_hosting.settings import Settings
|
|
58
|
+
|
|
59
|
+
from settings.service import SettingService
|
|
60
|
+
|
|
61
|
+
app = create_app(Settings())
|
|
62
|
+
|
|
63
|
+
async def run() -> int:
|
|
64
|
+
async with (
|
|
65
|
+
app.router.lifespan_context(app),
|
|
66
|
+
app.state.sm.db.session_factory() as session,
|
|
67
|
+
):
|
|
68
|
+
store = SettingsStore(SettingService(session))
|
|
69
|
+
n = await import_from_env_impl(app, store)
|
|
70
|
+
await session.commit()
|
|
71
|
+
print(f"Imported {n} override(s) from environment.")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
return asyncio.run(run())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
sys.exit(main())
|
settings/constants.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Centralized constants for the Settings module.
|
|
2
|
+
|
|
3
|
+
Keeps module-level strings (route prefixes, permission ids, table names,
|
|
4
|
+
menu metadata, error messages, field limits, env prefix, i18n namespace,
|
|
5
|
+
scope identifiers) in one place so nothing is duplicated in Python or
|
|
6
|
+
inline-literal'd at call sites.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
# ── Module identity ──────────────────────────────────────────────────
|
|
14
|
+
MODULE_NAME: Final = "Settings"
|
|
15
|
+
MODULE_PACKAGE: Final = "settings"
|
|
16
|
+
ENV_PREFIX: Final = "SM_SETTINGS_"
|
|
17
|
+
LOCALE_NAMESPACE: Final = MODULE_PACKAGE
|
|
18
|
+
|
|
19
|
+
# ── Scopes ───────────────────────────────────────────────────────────
|
|
20
|
+
# Precedence high → low when resolving a key: USER > TENANT > SYSTEM.
|
|
21
|
+
SCOPE_SYSTEM: Final = "system"
|
|
22
|
+
SCOPE_TENANT: Final = "tenant"
|
|
23
|
+
SCOPE_USER: Final = "user"
|
|
24
|
+
ALL_SCOPES: Final = (SCOPE_SYSTEM, SCOPE_TENANT, SCOPE_USER)
|
|
25
|
+
# scope_id is empty for SYSTEM; empty string (not NULL) so composite unique
|
|
26
|
+
# works uniformly on SQLite and PostgreSQL (NULL breaks uniqueness on PG).
|
|
27
|
+
SYSTEM_SCOPE_ID: Final = ""
|
|
28
|
+
|
|
29
|
+
# ── Value types ──────────────────────────────────────────────────────
|
|
30
|
+
# Values are always stored as strings; ``value_type`` tells consumers how
|
|
31
|
+
# to interpret the bytes and lets the UI pick the right input control.
|
|
32
|
+
VALUE_TYPE_STRING: Final = "string"
|
|
33
|
+
VALUE_TYPE_BOOL: Final = "bool"
|
|
34
|
+
VALUE_TYPE_INT: Final = "int"
|
|
35
|
+
VALUE_TYPE_FLOAT: Final = "float"
|
|
36
|
+
VALUE_TYPE_JSON: Final = "json"
|
|
37
|
+
ALL_VALUE_TYPES: Final = (
|
|
38
|
+
VALUE_TYPE_STRING,
|
|
39
|
+
VALUE_TYPE_BOOL,
|
|
40
|
+
VALUE_TYPE_INT,
|
|
41
|
+
VALUE_TYPE_FLOAT,
|
|
42
|
+
VALUE_TYPE_JSON,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# ── Routing ──────────────────────────────────────────────────────────
|
|
46
|
+
API_PREFIX: Final = "/api/settings"
|
|
47
|
+
VIEW_PREFIX: Final = "/settings"
|
|
48
|
+
VIEW_CREATE_PATH: Final = "/create"
|
|
49
|
+
VIEW_EDIT_PATH: Final = "/{setting_id}/edit"
|
|
50
|
+
VIEW_MODULES_PATH: Final = "/modules"
|
|
51
|
+
API_BY_ID_PATH: Final = "/{setting_id}"
|
|
52
|
+
API_BY_KEY_PATH: Final = "/by-key/{key}"
|
|
53
|
+
API_RESOLVE_PATH: Final = "/resolve/{key}"
|
|
54
|
+
API_SYSTEM_PATH: Final = "/system/{key}"
|
|
55
|
+
API_TENANT_PATH: Final = "/tenant/{scope_id}/{key}"
|
|
56
|
+
API_USER_PATH: Final = "/user/{scope_id}/{key}"
|
|
57
|
+
|
|
58
|
+
# ── Menu ─────────────────────────────────────────────────────────────
|
|
59
|
+
MENU_LABEL: Final = MODULE_NAME
|
|
60
|
+
MENU_URL: Final = VIEW_PREFIX
|
|
61
|
+
MENU_ICON: Final = "settings"
|
|
62
|
+
MENU_ORDER: Final = 30
|
|
63
|
+
|
|
64
|
+
# ── Permissions ──────────────────────────────────────────────────────
|
|
65
|
+
PERM_GROUP: Final = MODULE_NAME
|
|
66
|
+
PERM_VIEW: Final = "settings.view"
|
|
67
|
+
PERM_CREATE: Final = "settings.create"
|
|
68
|
+
PERM_EDIT: Final = "settings.edit"
|
|
69
|
+
PERM_DELETE: Final = "settings.delete"
|
|
70
|
+
ALL_PERMISSIONS: Final = (PERM_VIEW, PERM_CREATE, PERM_EDIT, PERM_DELETE)
|
|
71
|
+
|
|
72
|
+
# ── Database ─────────────────────────────────────────────────────────
|
|
73
|
+
DB_SCHEMA: Final = MODULE_PACKAGE
|
|
74
|
+
TABLE_SETTING: Final = "settings_setting"
|
|
75
|
+
UQ_SCOPE_KEY: Final = "uq_settings_setting_scope_scope_id_key"
|
|
76
|
+
|
|
77
|
+
# ── Field limits ─────────────────────────────────────────────────────
|
|
78
|
+
KEY_MAX_LENGTH: Final = 200
|
|
79
|
+
VALUE_MAX_LENGTH: Final = 4000
|
|
80
|
+
DESCRIPTION_MAX_LENGTH: Final = 2000
|
|
81
|
+
SCOPE_MAX_LENGTH: Final = 10
|
|
82
|
+
SCOPE_ID_MAX_LENGTH: Final = 255
|
|
83
|
+
VALUE_TYPE_MAX_LENGTH: Final = 10
|
|
84
|
+
|
|
85
|
+
# ── Inertia page component names ─────────────────────────────────────
|
|
86
|
+
PAGE_BROWSE: Final = f"{MODULE_NAME}/Browse"
|
|
87
|
+
PAGE_CREATE: Final = f"{MODULE_NAME}/Create"
|
|
88
|
+
PAGE_EDIT: Final = f"{MODULE_NAME}/Edit"
|
|
89
|
+
PAGE_MODULES_EDIT: Final = f"{MODULE_NAME}/ModulesEdit"
|
|
90
|
+
|
|
91
|
+
# ── Inertia prop keys ────────────────────────────────────────────────
|
|
92
|
+
PROP_SETTINGS: Final = "settings"
|
|
93
|
+
PROP_SETTING: Final = "setting"
|
|
94
|
+
PROP_MODULES: Final = "modules"
|
|
95
|
+
PROP_ERROR: Final = "error"
|
|
96
|
+
|
|
97
|
+
# ── User-facing error messages ───────────────────────────────────────
|
|
98
|
+
ERR_SETTING_NOT_FOUND: Final = "Setting not found"
|
|
99
|
+
ERR_KEY_ALREADY_EXISTS: Final = "Setting key already exists"
|
|
100
|
+
ERR_SYSTEM_SCOPE_NO_ID: Final = "system scope must not have a scope_id"
|
|
101
|
+
ERR_SCOPED_REQUIRES_ID: Final = "tenant/user scope requires a scope_id"
|
|
102
|
+
ERR_UNKNOWN_SCOPE: Final = "unknown scope"
|
|
103
|
+
ERR_VALUE_MISMATCH: Final = "value does not parse as declared value_type"
|
|
104
|
+
|
|
105
|
+
# ── HTTP ─────────────────────────────────────────────────────────────
|
|
106
|
+
STATUS_CREATED: Final = 201
|
|
107
|
+
STATUS_NO_CONTENT: Final = 204
|
|
108
|
+
STATUS_NOT_FOUND: Final = 404
|
|
109
|
+
STATUS_CONFLICT: Final = 409
|
|
110
|
+
|
|
111
|
+
# ── Query parameter names ────────────────────────────────────────────
|
|
112
|
+
QP_USER_ID: Final = "user_id"
|
|
113
|
+
QP_TENANT_ID: Final = "tenant_id"
|
|
114
|
+
QP_SCOPE: Final = "scope"
|
|
115
|
+
QP_SCOPE_ID: Final = "scope_id"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Settings contracts — public interface for other modules."""
|
|
2
|
+
|
|
3
|
+
from settings.contracts.accessor import SettingsAccessor
|
|
4
|
+
from settings.contracts.registry import SettingDefinition, SettingsRegistry
|
|
5
|
+
from settings.contracts.schemas import (
|
|
6
|
+
SettingCreate,
|
|
7
|
+
SettingOut,
|
|
8
|
+
SettingScope,
|
|
9
|
+
SettingUpdate,
|
|
10
|
+
SettingUpsert,
|
|
11
|
+
SettingValueType,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"SettingCreate",
|
|
16
|
+
"SettingDefinition",
|
|
17
|
+
"SettingOut",
|
|
18
|
+
"SettingScope",
|
|
19
|
+
"SettingUpdate",
|
|
20
|
+
"SettingUpsert",
|
|
21
|
+
"SettingValueType",
|
|
22
|
+
"SettingsAccessor",
|
|
23
|
+
"SettingsRegistry",
|
|
24
|
+
]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""High-level read/write facade over ``SettingService`` for consumer modules.
|
|
2
|
+
|
|
3
|
+
Consumers depend on this instead of ``SettingService`` directly so they get:
|
|
4
|
+
- automatic USER > TENANT > SYSTEM resolution bound to the request context,
|
|
5
|
+
- typed getters (`get_bool`, `get_int`, `get_float`, `get_json`) that cast
|
|
6
|
+
the stored string representation,
|
|
7
|
+
- fallback to the registered default in ``SettingsRegistry`` when a key is
|
|
8
|
+
unset at every scope.
|
|
9
|
+
|
|
10
|
+
Example in another module's endpoint:
|
|
11
|
+
|
|
12
|
+
from settings.contracts import SettingsDep
|
|
13
|
+
|
|
14
|
+
@router.get("/whatever")
|
|
15
|
+
async def handler(settings: SettingsDep):
|
|
16
|
+
if await settings.get_bool("orders.bulk_import", default=False):
|
|
17
|
+
...
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
25
|
+
|
|
26
|
+
from settings.constants import SYSTEM_SCOPE_ID
|
|
27
|
+
from settings.contracts.registry import SettingsRegistry
|
|
28
|
+
from settings.contracts.schemas import (
|
|
29
|
+
BOOL_LITERALS_FALSE,
|
|
30
|
+
BOOL_LITERALS_TRUE,
|
|
31
|
+
SettingOut,
|
|
32
|
+
SettingScope,
|
|
33
|
+
SettingUpsert,
|
|
34
|
+
SettingValueType,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from settings.service import SettingService
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Unset:
|
|
42
|
+
"""Typed sentinel for ``bind``'s "no override" marker."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_UNSET: Final = _Unset()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cast_bool(raw: str, default: Any) -> Any:
|
|
49
|
+
lowered = raw.strip().lower()
|
|
50
|
+
if lowered in BOOL_LITERALS_TRUE:
|
|
51
|
+
return True
|
|
52
|
+
if lowered in BOOL_LITERALS_FALSE:
|
|
53
|
+
return False
|
|
54
|
+
return default
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cast_int(raw: str, default: Any) -> Any:
|
|
58
|
+
try:
|
|
59
|
+
return int(raw)
|
|
60
|
+
except (TypeError, ValueError):
|
|
61
|
+
return default
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cast_float(raw: str, default: Any) -> Any:
|
|
65
|
+
try:
|
|
66
|
+
return float(raw)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
return default
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cast_json(raw: str, default: Any) -> Any:
|
|
72
|
+
try:
|
|
73
|
+
return json.loads(raw)
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
return default
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_CASTERS: Final[dict[SettingValueType, Callable[[str, Any], Any]]] = {
|
|
79
|
+
SettingValueType.BOOL: _cast_bool,
|
|
80
|
+
SettingValueType.INT: _cast_int,
|
|
81
|
+
SettingValueType.FLOAT: _cast_float,
|
|
82
|
+
SettingValueType.JSON: _cast_json,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SettingsAccessor:
|
|
87
|
+
"""Request-scoped facade over ``SettingService``.
|
|
88
|
+
|
|
89
|
+
Bound to an optional ``user_id`` + ``tenant_id`` so ``get`` and its
|
|
90
|
+
typed variants resolve via USER > TENANT > SYSTEM automatically.
|
|
91
|
+
``SettingService`` (the implementation) is still reachable for admin
|
|
92
|
+
flows that need unbound operations.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
__slots__ = ("_registry", "_svc", "_tenant_id", "_user_id")
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
service: SettingService,
|
|
100
|
+
registry: SettingsRegistry | None = None,
|
|
101
|
+
*,
|
|
102
|
+
user_id: str | None = None,
|
|
103
|
+
tenant_id: str | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
self._svc = service
|
|
106
|
+
self._registry = registry
|
|
107
|
+
self._user_id = user_id
|
|
108
|
+
self._tenant_id = tenant_id
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def service(self) -> SettingService:
|
|
112
|
+
return self._svc
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def registry(self) -> SettingsRegistry | None:
|
|
116
|
+
return self._registry
|
|
117
|
+
|
|
118
|
+
def bind(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
user_id: str | None | _Unset = _UNSET,
|
|
122
|
+
tenant_id: str | None | _Unset = _UNSET,
|
|
123
|
+
) -> SettingsAccessor:
|
|
124
|
+
"""Return a new accessor with the given user/tenant overrides.
|
|
125
|
+
|
|
126
|
+
Pass ``None`` explicitly to clear that side of the context;
|
|
127
|
+
omit the argument to preserve the current value. ``bind()`` with
|
|
128
|
+
no arguments returns a shallow copy bound to the same context.
|
|
129
|
+
"""
|
|
130
|
+
return SettingsAccessor(
|
|
131
|
+
self._svc,
|
|
132
|
+
self._registry,
|
|
133
|
+
user_id=self._user_id if isinstance(user_id, _Unset) else user_id,
|
|
134
|
+
tenant_id=self._tenant_id if isinstance(tenant_id, _Unset) else tenant_id,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# ── Typed reads ─────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
async def get(self, key: str, default: str | None = None) -> str | None:
|
|
140
|
+
"""Resolve a key as a string, walking USER > TENANT > SYSTEM.
|
|
141
|
+
|
|
142
|
+
Falls back to the explicit ``default`` argument, then the
|
|
143
|
+
registered default in ``SettingsRegistry`` if present.
|
|
144
|
+
"""
|
|
145
|
+
value = await self._svc.get_resolved_value(
|
|
146
|
+
key, user_id=self._user_id, tenant_id=self._tenant_id
|
|
147
|
+
)
|
|
148
|
+
if value is not None:
|
|
149
|
+
return value
|
|
150
|
+
if default is not None:
|
|
151
|
+
return default
|
|
152
|
+
if self._registry is not None:
|
|
153
|
+
definition = self._registry.get(key)
|
|
154
|
+
if definition is not None:
|
|
155
|
+
return definition.default
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
async def get_str(self, key: str, default: str = "") -> str:
|
|
159
|
+
value = await self.get(key)
|
|
160
|
+
return value if value is not None else default
|
|
161
|
+
|
|
162
|
+
async def get_bool(self, key: str, default: bool = False) -> bool:
|
|
163
|
+
raw = await self.get(key)
|
|
164
|
+
return default if raw is None else _cast_bool(raw, default)
|
|
165
|
+
|
|
166
|
+
async def get_int(self, key: str, default: int = 0) -> int:
|
|
167
|
+
raw = await self.get(key)
|
|
168
|
+
return default if raw is None else _cast_int(raw, default)
|
|
169
|
+
|
|
170
|
+
async def get_float(self, key: str, default: float = 0.0) -> float:
|
|
171
|
+
raw = await self.get(key)
|
|
172
|
+
return default if raw is None else _cast_float(raw, default)
|
|
173
|
+
|
|
174
|
+
async def get_json(self, key: str, default: Any = None) -> Any:
|
|
175
|
+
raw = await self.get(key)
|
|
176
|
+
return default if raw is None else _cast_json(raw, default)
|
|
177
|
+
|
|
178
|
+
async def get_typed(self, key: str, default: Any = None) -> Any:
|
|
179
|
+
"""Resolve a key and cast based on the stored ``value_type``.
|
|
180
|
+
|
|
181
|
+
Dispatches by the declared type of the row that wins resolution;
|
|
182
|
+
falls back to the raw string for ``STRING`` or when no row exists.
|
|
183
|
+
Useful for generic admin views that don't know each key's type at
|
|
184
|
+
compile time.
|
|
185
|
+
"""
|
|
186
|
+
out = await self._svc.resolve(key, user_id=self._user_id, tenant_id=self._tenant_id)
|
|
187
|
+
if out is None:
|
|
188
|
+
return default
|
|
189
|
+
caster = _CASTERS.get(out.value_type)
|
|
190
|
+
return out.value if caster is None else caster(out.value, default)
|
|
191
|
+
|
|
192
|
+
# ── Writes ──────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
async def set_system(
|
|
195
|
+
self,
|
|
196
|
+
key: str,
|
|
197
|
+
value: str,
|
|
198
|
+
value_type: SettingValueType | None = None,
|
|
199
|
+
description: str | None = None,
|
|
200
|
+
) -> SettingOut:
|
|
201
|
+
return await self._svc.upsert_scoped(
|
|
202
|
+
SettingScope.SYSTEM,
|
|
203
|
+
SYSTEM_SCOPE_ID,
|
|
204
|
+
key,
|
|
205
|
+
SettingUpsert(value=value, value_type=value_type, description=description),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
async def set_tenant(
|
|
209
|
+
self,
|
|
210
|
+
tenant_id: str,
|
|
211
|
+
key: str,
|
|
212
|
+
value: str,
|
|
213
|
+
value_type: SettingValueType | None = None,
|
|
214
|
+
description: str | None = None,
|
|
215
|
+
) -> SettingOut:
|
|
216
|
+
return await self._svc.upsert_scoped(
|
|
217
|
+
SettingScope.TENANT,
|
|
218
|
+
tenant_id,
|
|
219
|
+
key,
|
|
220
|
+
SettingUpsert(value=value, value_type=value_type, description=description),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def set_user(
|
|
224
|
+
self,
|
|
225
|
+
user_id: str,
|
|
226
|
+
key: str,
|
|
227
|
+
value: str,
|
|
228
|
+
value_type: SettingValueType | None = None,
|
|
229
|
+
description: str | None = None,
|
|
230
|
+
) -> SettingOut:
|
|
231
|
+
return await self._svc.upsert_scoped(
|
|
232
|
+
SettingScope.USER,
|
|
233
|
+
user_id,
|
|
234
|
+
key,
|
|
235
|
+
SettingUpsert(value=value, value_type=value_type, description=description),
|
|
236
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Domain events published by the Settings module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from simple_module_core.events import Event
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class SettingsReloaded(Event):
|
|
12
|
+
"""Fired after a module's BaseSettings has been reloaded from the DB.
|
|
13
|
+
|
|
14
|
+
Subscribers that cached stateful handles built from settings (SMTP client,
|
|
15
|
+
Celery app config, middleware) can rebuild when ``package`` matches their
|
|
16
|
+
own.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
package: str
|
|
20
|
+
changed: tuple[str, ...]
|