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 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, ...]