simple-module-settings 0.0.1__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.1/.gitignore +59 -0
- simple_module_settings-0.0.1/LICENSE +21 -0
- simple_module_settings-0.0.1/PKG-INFO +69 -0
- simple_module_settings-0.0.1/README.md +41 -0
- simple_module_settings-0.0.1/package.json +16 -0
- simple_module_settings-0.0.1/pyproject.toml +56 -0
- simple_module_settings-0.0.1/settings/__init__.py +1 -0
- simple_module_settings-0.0.1/settings/_module_settings.py +169 -0
- simple_module_settings-0.0.1/settings/cli.py +78 -0
- simple_module_settings-0.0.1/settings/constants.py +115 -0
- simple_module_settings-0.0.1/settings/contracts/__init__.py +24 -0
- simple_module_settings-0.0.1/settings/contracts/accessor.py +236 -0
- simple_module_settings-0.0.1/settings/contracts/events.py +20 -0
- simple_module_settings-0.0.1/settings/contracts/registry.py +66 -0
- simple_module_settings-0.0.1/settings/contracts/schemas.py +154 -0
- simple_module_settings-0.0.1/settings/deps.py +56 -0
- simple_module_settings-0.0.1/settings/endpoints/__init__.py +0 -0
- simple_module_settings-0.0.1/settings/endpoints/api.py +202 -0
- simple_module_settings-0.0.1/settings/endpoints/module_api.py +102 -0
- simple_module_settings-0.0.1/settings/endpoints/views.py +120 -0
- simple_module_settings-0.0.1/settings/env_vars.py +18 -0
- simple_module_settings-0.0.1/settings/hydrate.py +63 -0
- simple_module_settings-0.0.1/settings/locales/en.json +71 -0
- simple_module_settings-0.0.1/settings/models.py +50 -0
- simple_module_settings-0.0.1/settings/module.py +76 -0
- simple_module_settings-0.0.1/settings/module_registry.py +33 -0
- simple_module_settings-0.0.1/settings/pages/Browse.tsx +103 -0
- simple_module_settings-0.0.1/settings/pages/Create.tsx +113 -0
- simple_module_settings-0.0.1/settings/pages/Edit.tsx +102 -0
- simple_module_settings-0.0.1/settings/pages/ModulesEdit.tsx +74 -0
- simple_module_settings-0.0.1/settings/pages/components/FieldInput.tsx +96 -0
- simple_module_settings-0.0.1/settings/pages/components/ModuleForm.tsx +157 -0
- simple_module_settings-0.0.1/settings/pages/components/ValueInput.tsx +92 -0
- simple_module_settings-0.0.1/settings/pages/routes.ts +7 -0
- simple_module_settings-0.0.1/settings/py.typed +0 -0
- simple_module_settings-0.0.1/settings/registration.py +34 -0
- simple_module_settings-0.0.1/settings/reload.py +58 -0
- simple_module_settings-0.0.1/settings/service.py +162 -0
- simple_module_settings-0.0.1/settings/services.py +36 -0
- simple_module_settings-0.0.1/settings/settings.py +11 -0
- simple_module_settings-0.0.1/settings/store.py +61 -0
- simple_module_settings-0.0.1/tests/test_cli_import.py +44 -0
- simple_module_settings-0.0.1/tests/test_hydrate.py +65 -0
- simple_module_settings-0.0.1/tests/test_module_api.py +56 -0
- simple_module_settings-0.0.1/tests/test_module_registry.py +39 -0
- simple_module_settings-0.0.1/tests/test_module_settings.py +64 -0
- simple_module_settings-0.0.1/tests/test_registration.py +51 -0
- simple_module_settings-0.0.1/tests/test_reload.py +98 -0
- simple_module_settings-0.0.1/tests/test_settings_accessor.py +179 -0
- simple_module_settings-0.0.1/tests/test_settings_api.py +140 -0
- simple_module_settings-0.0.1/tests/test_settings_events.py +18 -0
- simple_module_settings-0.0.1/tests/test_settings_module.py +48 -0
- simple_module_settings-0.0.1/tests/test_settings_schemas.py +107 -0
- simple_module_settings-0.0.1/tests/test_settings_service.py +141 -0
- simple_module_settings-0.0.1/tests/test_store.py +56 -0
- simple_module_settings-0.0.1/tsconfig.json +11 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
# UV
|
|
11
|
+
uv.lock
|
|
12
|
+
|
|
13
|
+
# Node
|
|
14
|
+
node_modules/
|
|
15
|
+
|
|
16
|
+
# IDE
|
|
17
|
+
.idea/
|
|
18
|
+
.vscode/
|
|
19
|
+
*.swp
|
|
20
|
+
*.swo
|
|
21
|
+
|
|
22
|
+
# Environment
|
|
23
|
+
.env
|
|
24
|
+
|
|
25
|
+
# Database
|
|
26
|
+
*.db
|
|
27
|
+
*.sqlite3
|
|
28
|
+
|
|
29
|
+
# Module-managed runtime state (e.g. uploaded dataset files,
|
|
30
|
+
# default storage_dir for SM_DATASETS_STORAGE_DIR).
|
|
31
|
+
var/
|
|
32
|
+
|
|
33
|
+
# file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
|
|
34
|
+
uploads/
|
|
35
|
+
|
|
36
|
+
# Vite
|
|
37
|
+
host/static/dist/
|
|
38
|
+
|
|
39
|
+
# Auto-generated frontend module manifest (regenerated by the host at boot
|
|
40
|
+
# or via `make gen-pages`).
|
|
41
|
+
host/client_app/modules.manifest.json
|
|
42
|
+
host/client_app/modules.generated.ts
|
|
43
|
+
host/client_app/modules.generated.css
|
|
44
|
+
|
|
45
|
+
# Worktrees
|
|
46
|
+
.worktrees/
|
|
47
|
+
|
|
48
|
+
# Performance profiles
|
|
49
|
+
.memray/
|
|
50
|
+
.benchmarks/
|
|
51
|
+
|
|
52
|
+
# OS
|
|
53
|
+
.DS_Store
|
|
54
|
+
Thumbs.db
|
|
55
|
+
|
|
56
|
+
.playwright-cli/*
|
|
57
|
+
.playwright-mcp/*
|
|
58
|
+
host/client_app/.playwright-cli/*
|
|
59
|
+
.superpowers/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anto Subash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple_module_settings
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Runtime settings UI — modules plug their own settings panels into a shared admin view
|
|
5
|
+
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
|
+
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
7
|
+
Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Anto Subash <antosubash@live.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: admin,configuration,settings,simple-module
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: simple-module-core==0.0.1
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.1
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.1
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# simple_module_settings
|
|
30
|
+
|
|
31
|
+
Runtime settings UI for [simple_module](https://github.com/antosubash/simple_module_python) apps. Other modules plug their own settings panels into a shared admin view — one page per module tab — without the host having to know about them.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install simple_module_settings
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## What it provides
|
|
40
|
+
|
|
41
|
+
- `/settings` admin page aggregating every installed module's settings panel.
|
|
42
|
+
- `register_settings_panel()` hook — a module declares `{title, inertia_page, requires_permission}`; `simple_module_settings` renders the tab.
|
|
43
|
+
- DB-backed runtime settings table (separate from env-var-driven `SM_*` settings) for values admins change at runtime.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
Register a panel:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
class OrdersModule(ModuleBase):
|
|
51
|
+
meta = ModuleMeta(name="orders")
|
|
52
|
+
|
|
53
|
+
def register_settings_panel(self):
|
|
54
|
+
return {
|
|
55
|
+
"title": "Orders",
|
|
56
|
+
"inertia_page": "Orders/SettingsPanel",
|
|
57
|
+
"requires_permission": "orders.manage",
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
That adds an **Orders** tab at `/settings`. The rendered page is a regular Inertia page authored inside the `orders` module.
|
|
62
|
+
|
|
63
|
+
## Depends on
|
|
64
|
+
|
|
65
|
+
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# simple_module_settings
|
|
2
|
+
|
|
3
|
+
Runtime settings UI for [simple_module](https://github.com/antosubash/simple_module_python) apps. Other modules plug their own settings panels into a shared admin view — one page per module tab — without the host having to know about them.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install simple_module_settings
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What it provides
|
|
12
|
+
|
|
13
|
+
- `/settings` admin page aggregating every installed module's settings panel.
|
|
14
|
+
- `register_settings_panel()` hook — a module declares `{title, inertia_page, requires_permission}`; `simple_module_settings` renders the tab.
|
|
15
|
+
- DB-backed runtime settings table (separate from env-var-driven `SM_*` settings) for values admins change at runtime.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Register a panel:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
class OrdersModule(ModuleBase):
|
|
23
|
+
meta = ModuleMeta(name="orders")
|
|
24
|
+
|
|
25
|
+
def register_settings_panel(self):
|
|
26
|
+
return {
|
|
27
|
+
"title": "Orders",
|
|
28
|
+
"inertia_page": "Orders/SettingsPanel",
|
|
29
|
+
"requires_permission": "orders.manage",
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That adds an **Orders** tab at `/settings`. The rendered page is a regular Inertia page authored inside the `orders` module.
|
|
34
|
+
|
|
35
|
+
## Depends on
|
|
36
|
+
|
|
37
|
+
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-module-py/settings",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Frontend assets for the Settings module",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.0.0",
|
|
8
|
+
"react-dom": "^19.0.0",
|
|
9
|
+
"@inertiajs/react": "^2.0.0",
|
|
10
|
+
"@simple-module-py/ui": "*"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@simple-module-py/tsconfig": "*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {}
|
|
16
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "simple_module_settings"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Runtime settings UI — modules plug their own settings panels into a shared admin view"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
|
|
10
|
+
keywords = ["simple-module", "settings", "admin", "configuration"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Framework :: FastAPI",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"simple_module_core==0.0.1",
|
|
25
|
+
"simple_module_db==0.0.1",
|
|
26
|
+
"simple_module_hosting==0.0.1",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.entry-points.simple_module]
|
|
30
|
+
settings = "settings.module:SettingsModule"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
sm-settings = "settings.cli:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/antosubash/simple_module_python"
|
|
37
|
+
Repository = "https://github.com/antosubash/simple_module_python"
|
|
38
|
+
Issues = "https://github.com/antosubash/simple_module_python/issues"
|
|
39
|
+
Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md"
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["hatchling"]
|
|
43
|
+
build-backend = "hatchling.build"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["settings"]
|
|
47
|
+
|
|
48
|
+
# Ship the module-root package.json inside the wheel so the host can
|
|
49
|
+
# discover JS deps via importlib.resources after a pip install.
|
|
50
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
51
|
+
"package.json" = "settings/package.json"
|
|
52
|
+
|
|
53
|
+
[tool.uv.sources]
|
|
54
|
+
simple_module_core = { workspace = true }
|
|
55
|
+
simple_module_db = { workspace = true }
|
|
56
|
+
simple_module_hosting = { workspace = true }
|
|
@@ -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
|
+
]
|
|
@@ -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())
|
|
@@ -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
|
+
]
|