simple-module-settings 0.0.36__tar.gz → 0.0.38__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.
Files changed (127) hide show
  1. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/.gitignore +2 -2
  2. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/PKG-INFO +6 -6
  3. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/README.md +2 -2
  4. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/package.json +1 -1
  5. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/pyproject.toml +4 -4
  6. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/_announce.py +2 -1
  7. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/_field_meta.py +6 -1
  8. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/_listing.py +3 -8
  9. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/_module_settings.py +53 -24
  10. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/_module_settings_props.py +2 -0
  11. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/_row_masking.py +5 -4
  12. simple_module_settings-0.0.38/settings/_secrets.py +17 -0
  13. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/_unique_write.py +1 -2
  14. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/cli.py +4 -4
  15. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/constants.py +25 -33
  16. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/contracts/accessor.py +2 -1
  17. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/contracts/registry.py +3 -0
  18. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/contracts/schemas.py +5 -7
  19. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/endpoints/api.py +1 -1
  20. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/endpoints/module_api.py +22 -7
  21. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/endpoints/views.py +2 -2
  22. simple_module_settings-0.0.38/settings/hydrate.py +36 -0
  23. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/known_keys.py +1 -1
  24. simple_module_settings-0.0.38/settings/locales/de.json +152 -0
  25. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/locales/en.json +12 -3
  26. simple_module_settings-0.0.38/settings/models.py +12 -0
  27. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/module.py +15 -12
  28. simple_module_settings-0.0.38/settings/module_registry.py +17 -0
  29. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/Browse.tsx +0 -1
  30. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/ModulesEdit.tsx +0 -1
  31. simple_module_settings-0.0.38/settings/pages/api.generated.ts +1043 -0
  32. simple_module_settings-0.0.38/settings/pages/api.ts +9 -0
  33. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/components/FieldInput.tsx +42 -11
  34. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/components/FieldSource.tsx +5 -5
  35. simple_module_settings-0.0.38/settings/pages/components/JsonInput.tsx +77 -0
  36. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/components/ModuleFieldRow.tsx +18 -2
  37. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/components/ModuleForm.tsx +41 -14
  38. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/components/TestConnectionButton.tsx +10 -9
  39. simple_module_settings-0.0.38/settings/pages/components/validation-errors.test.ts +32 -0
  40. simple_module_settings-0.0.38/settings/pages/components/validation-errors.ts +45 -0
  41. simple_module_settings-0.0.38/settings/registration.py +17 -0
  42. simple_module_settings-0.0.38/settings/reload.py +158 -0
  43. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/service.py +4 -8
  44. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/services.py +6 -4
  45. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/store.py +13 -41
  46. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/tenant_view.py +5 -2
  47. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_clear_route_per_scope.py +1 -1
  48. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_cli_import.py +9 -8
  49. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_duplicate_and_race.py +1 -1
  50. simple_module_settings-0.0.38/tests/test_env_pinned.py +121 -0
  51. simple_module_settings-0.0.38/tests/test_hydrate.py +230 -0
  52. simple_module_settings-0.0.38/tests/test_hydrate_related_fields.py +53 -0
  53. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_known_keys_meta.py +12 -12
  54. simple_module_settings-0.0.38/tests/test_lenient_related_fields_validators.py +110 -0
  55. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_module_api.py +5 -2
  56. simple_module_settings-0.0.38/tests/test_module_api_json_fields.py +129 -0
  57. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_module_registry.py +1 -1
  58. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_module_settings.py +14 -14
  59. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_registration.py +2 -3
  60. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_reload.py +1 -1
  61. simple_module_settings-0.0.38/tests/test_settings_default_clears_override.py +80 -0
  62. simple_module_settings-0.0.38/tests/test_settings_env_unprefixed.py +49 -0
  63. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_settings_field_sources.py +4 -4
  64. simple_module_settings-0.0.38/tests/test_settings_framework_reexports.py +48 -0
  65. simple_module_settings-0.0.38/tests/test_settings_group_metadata.py +30 -0
  66. simple_module_settings-0.0.38/tests/test_settings_host_restart_flags.py +22 -0
  67. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_settings_schemas.py +1 -1
  68. simple_module_settings-0.0.38/tests/test_settings_secret_json_roundtrip.py +63 -0
  69. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_settings_secret_masking.py +2 -1
  70. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_settings_secret_urls.py +4 -4
  71. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_settings_service.py +1 -1
  72. simple_module_settings-0.0.38/tests/test_settings_typed_secret_masking.py +97 -0
  73. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_store_filters.py +16 -8
  74. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_store_secret_masking.py +3 -3
  75. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_testable_checks.py +5 -5
  76. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_upload_key_delete.py +1 -1
  77. simple_module_settings-0.0.38/tests-js/JsonFields.test.tsx +127 -0
  78. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests-js/ModuleForm.test.tsx +64 -10
  79. simple_module_settings-0.0.38/tests-js/RestartHint.test.tsx +57 -0
  80. simple_module_settings-0.0.38/tests-js/settings_TestConnectionButton.test.tsx +47 -0
  81. simple_module_settings-0.0.36/settings/_secrets.py +0 -97
  82. simple_module_settings-0.0.36/settings/hydrate.py +0 -82
  83. simple_module_settings-0.0.36/settings/models.py +0 -50
  84. simple_module_settings-0.0.36/settings/module_registry.py +0 -50
  85. simple_module_settings-0.0.36/settings/registration.py +0 -40
  86. simple_module_settings-0.0.36/settings/reload.py +0 -63
  87. simple_module_settings-0.0.36/tests/test_hydrate.py +0 -91
  88. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/LICENSE +0 -0
  89. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/__init__.py +0 -0
  90. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/_managed_keys.py +0 -0
  91. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/browse_query.py +0 -0
  92. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/contracts/__init__.py +0 -0
  93. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/contracts/events.py +0 -0
  94. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/contracts/invalidation.py +0 -0
  95. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/deps.py +0 -0
  96. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/endpoints/__init__.py +0 -0
  97. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/endpoints/_create_form.py +0 -0
  98. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/endpoints/tenant_api.py +0 -0
  99. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/env_vars.py +0 -0
  100. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/errors.py +0 -0
  101. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/Create.tsx +0 -0
  102. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/Edit.tsx +0 -0
  103. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/components/KeyField.tsx +0 -0
  104. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/components/ResolvedValue.tsx +0 -0
  105. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/components/ScopeTabs.tsx +0 -0
  106. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/components/StoreCards.tsx +0 -0
  107. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/components/StoreTable.tsx +0 -0
  108. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/components/ValueInput.tsx +0 -0
  109. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/components/last-test.ts +0 -0
  110. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/routes.ts +0 -0
  111. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/pages/types.ts +0 -0
  112. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/py.typed +0 -0
  113. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/settings.py +0 -0
  114. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/settings/tenant_scope.py +0 -0
  115. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_concurrent_overrides.py +0 -0
  116. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_module_settings_render.py +0 -0
  117. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_module_settings_serialize.py +0 -0
  118. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_settings_accessor.py +0 -0
  119. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_settings_api.py +0 -0
  120. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_settings_events.py +0 -0
  121. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_settings_module.py +0 -0
  122. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_settings_view_authz.py +0 -0
  123. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_store.py +0 -0
  124. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests/test_tenant_settings.py +0 -0
  125. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests-js/ModulesEdit.test.tsx +0 -0
  126. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tests-js/ScopeTabs.test.tsx +0 -0
  127. {simple_module_settings-0.0.36 → simple_module_settings-0.0.38}/tsconfig.json +0 -0
@@ -7,8 +7,6 @@ build/
7
7
  .venv/
8
8
  *.egg
9
9
 
10
- # UV
11
- uv.lock
12
10
 
13
11
  # Node
14
12
  node_modules/
@@ -64,3 +62,5 @@ host/client_app/.playwright-cli/*
64
62
  .superpowers/
65
63
  .qa/
66
64
  .verify/
65
+ /openapi.json
66
+ qa-shots/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_settings
3
- Version: 0.0.36
3
+ Version: 0.0.38
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.36
25
- Requires-Dist: simple-module-db==0.0.36
26
- Requires-Dist: simple-module-hosting==0.0.36
24
+ Requires-Dist: simple-module-core==0.0.38
25
+ Requires-Dist: simple-module-db==0.0.38
26
+ Requires-Dist: simple-module-hosting==0.0.38
27
27
  Requires-Dist: typer>=0.12
28
28
  Description-Content-Type: text/markdown
29
29
 
@@ -55,7 +55,7 @@ class OrdersModule(ModuleBase):
55
55
  meta = ModuleMeta(name="orders")
56
56
 
57
57
  def register_settings(self, app: FastAPI) -> None:
58
- from settings.registration import register_module_settings
58
+ from simple_module_core.module_settings import register_module_settings
59
59
  from orders.settings import OrdersSettings
60
60
  from orders.services import OrdersServices
61
61
 
@@ -64,7 +64,7 @@ class OrdersModule(ModuleBase):
64
64
  )
65
65
  ```
66
66
 
67
- That surfaces the module's settings fields under `/settings/modules`, hydrated from the DB store at startup. The settings class is a `pydantic_settings.BaseSettings` with the module's `SM_<PREFIX>_*` env-var defaults.
67
+ That surfaces the module's settings fields under `/admin/settings/`, hydrated from the DB store at startup. The settings class is normally a `simple_module_core.settings_base.DbBackedSettings` (DB is the only source). Registration and hydration live in the framework (#500); this module adds the admin editor, hot reload and the key/value API. `settings.registration`, `settings.module_registry` and `settings.hydrate` are deprecated re-exports.
68
68
 
69
69
  ## Depends on
70
70
 
@@ -26,7 +26,7 @@ class OrdersModule(ModuleBase):
26
26
  meta = ModuleMeta(name="orders")
27
27
 
28
28
  def register_settings(self, app: FastAPI) -> None:
29
- from settings.registration import register_module_settings
29
+ from simple_module_core.module_settings import register_module_settings
30
30
  from orders.settings import OrdersSettings
31
31
  from orders.services import OrdersServices
32
32
 
@@ -35,7 +35,7 @@ class OrdersModule(ModuleBase):
35
35
  )
36
36
  ```
37
37
 
38
- That surfaces the module's settings fields under `/settings/modules`, hydrated from the DB store at startup. The settings class is a `pydantic_settings.BaseSettings` with the module's `SM_<PREFIX>_*` env-var defaults.
38
+ That surfaces the module's settings fields under `/admin/settings/`, hydrated from the DB store at startup. The settings class is normally a `simple_module_core.settings_base.DbBackedSettings` (DB is the only source). Registration and hydration live in the framework (#500); this module adds the admin editor, hot reload and the key/value API. `settings.registration`, `settings.module_registry` and `settings.hydrate` are deprecated re-exports.
39
39
 
40
40
  ## Depends on
41
41
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-module-py/settings",
3
- "version": "0.1.0",
3
+ "version": "0.0.38",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the Settings module",
6
6
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_settings"
3
- version = "0.0.36"
3
+ version = "0.0.38"
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.36",
25
- "simple_module_db==0.0.36",
26
- "simple_module_hosting==0.0.36",
24
+ "simple_module_core==0.0.38",
25
+ "simple_module_db==0.0.38",
26
+ "simple_module_hosting==0.0.38",
27
27
  "typer>=0.12",
28
28
  ]
29
29
 
@@ -9,8 +9,9 @@ from __future__ import annotations
9
9
  from typing import TYPE_CHECKING
10
10
 
11
11
  from simple_module_db.callbacks import register_on_commit
12
+ from simple_module_db.stored_settings import SCOPE_SYSTEM, SCOPE_TENANT
12
13
 
13
- from settings.constants import INVALIDATION_CHANNEL, SCOPE_SYSTEM, SCOPE_TENANT
14
+ from settings.constants import INVALIDATION_CHANNEL
14
15
  from settings.contracts.invalidation import invalidation_key
15
16
 
16
17
  if TYPE_CHECKING:
@@ -8,7 +8,7 @@ this field?" questions live together and are testable without a FastAPI app.
8
8
  from __future__ import annotations
9
9
 
10
10
  import re
11
- from typing import Any
11
+ from typing import Any, Literal, get_args, get_origin
12
12
 
13
13
  from pydantic_settings import BaseSettings
14
14
 
@@ -71,6 +71,11 @@ def choices_for(info) -> list[str] | None:
71
71
  if isinstance(declared, (list, tuple)) and declared:
72
72
  return [str(choice) for choice in declared]
73
73
 
74
+ if get_origin(info.annotation) is Literal:
75
+ options = get_args(info.annotation)
76
+ if options and all(isinstance(o, str) for o in options):
77
+ return list(options)
78
+
74
79
  for constraint in info.metadata:
75
80
  pattern = getattr(constraint, "pattern", None)
76
81
  if not isinstance(pattern, str):
@@ -3,18 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from simple_module_db import LIKE_ESCAPE_CHAR, like_contains_pattern
6
+ from simple_module_db.stored_settings import SYSTEM_SCOPE_ID, Setting
6
7
  from sqlalchemy import func, select
7
8
  from sqlalchemy.ext.asyncio import AsyncSession
8
9
 
9
10
  from settings._row_masking import out
10
- from settings.constants import (
11
- ALL_SCOPES,
12
- DEFAULT_PER_PAGE,
13
- SCOPE_ALL,
14
- SYSTEM_SCOPE_ID,
15
- )
11
+ from settings.constants import ALL_SCOPES, DEFAULT_PER_PAGE, SCOPE_ALL
16
12
  from settings.contracts.schemas import SettingOut, SettingScope
17
- from settings.models import Setting
18
13
 
19
14
 
20
15
  class SettingListing:
@@ -116,6 +111,6 @@ class SettingListing:
116
111
  def _scope_stmt(scope: SettingScope, scope_id: str):
117
112
  return (
118
113
  select(Setting)
119
- .where(Setting.scope == scope.value, Setting.scope_id == scope_id)
114
+ .where(Setting.scope == scope.value, Setting.scope_id == scope_id) # ty: ignore[invalid-argument-type]
120
115
  .order_by(Setting.key)
121
116
  )
@@ -12,21 +12,27 @@ from typing import Any
12
12
 
13
13
  from fastapi import FastAPI
14
14
  from pydantic_settings import BaseSettings
15
-
16
- from settings._field_meta import choices_for, env_readable_var, resolve_default
15
+ from simple_module_core.module_settings import get_module_settings_registry
17
16
 
18
17
  # Re-exported: callers reach for ``SECRET_MASK`` / ``is_secret_field`` here.
19
- from settings._secrets import ( # noqa: F401
18
+ from simple_module_core.secret_fields import ( # noqa: F401
20
19
  _NEVER_SECRET_TYPES,
21
20
  SECRET_MASK,
21
+ annotation_holds_secret,
22
22
  conceals_secret,
23
23
  embeds_credential,
24
24
  is_named_secret,
25
25
  is_secret_field,
26
26
  mask,
27
27
  )
28
+ from simple_module_core.settings_hydrate import (
29
+ env_supplied_fields,
30
+ input_type_for_field,
31
+ value_type_for_field,
32
+ )
33
+
34
+ from settings._field_meta import choices_for, env_readable_var, resolve_default
28
35
  from settings.env_vars import env_prefix_for
29
- from settings.hydrate import value_type_for_field
30
36
  from settings.service import SettingService
31
37
 
32
38
 
@@ -48,7 +54,7 @@ class ModuleSettingField:
48
54
  The bundled module settings classes declare no ``env_prefix`` — they are
49
55
  constructed from pydantic defaults and hydrated from the DB — so their
50
56
  ``SM_*`` vars are never consulted. Reporting a leftover
51
- ``SM_USERS_SMTP_HOST`` as the live source would invert the very question
57
+ ``SM_MAIL_SMTP_HOST`` as the live source would invert the very question
52
58
  this screen answers. Classes that *do* declare one (the host's ``Settings``
53
59
  with ``SM_``, and anything from the module scaffold) report ``env`` for
54
60
  real, because for them pydantic really does read it.
@@ -71,22 +77,28 @@ class ModuleSettingField:
71
77
  """
72
78
  choices: list[str] | None = None
73
79
  """Closed set of accepted values, or ``None`` when the field is free text."""
80
+ input_type: str = ""
81
+ """Editor control: ``type`` with an ``X | None`` unwrapped to ``X``."""
82
+ nullable: bool = False
83
+ """``None`` is a valid value; the editor sends it for an empty input."""
74
84
 
75
85
  @property
76
86
  def source(self) -> str:
77
87
  """Where the live value came from: ``db``, ``env`` or ``default``.
78
88
 
79
- Mirrors the precedence in ``hydrate_settings``: DB overrides are passed
80
- to the constructor explicitly, so they beat anything pydantic reads for
81
- fields left unset, which in turn beats the field default. ``env`` only
82
- appears for settings classes that actually declare an ``env_prefix``;
83
- see :attr:`env_set`. Showing this is the difference between "why is
84
- this not taking effect" being a five-minute question and an afternoon.
89
+ Mirrors the precedence in ``hydrate_settings``: env → DB → default
90
+ (GH #486). A stored override for a field the environment sets is
91
+ ignored, so ``env`` wins even when ``db_override`` is true. ``env`` only
92
+ appears for settings classes that declare an ``env_prefix`` (or name
93
+ the field in ``env_seeded_fields``) — the same rule
94
+ ``hydrate.env_supplied_fields`` applies; see :attr:`env_set`. Showing
95
+ this is the difference between "why is this not taking effect" being a
96
+ five-minute question and an afternoon.
85
97
  """
86
- if self.db_override:
87
- return "db"
88
98
  if self.env_set:
89
99
  return "env"
100
+ if self.db_override:
101
+ return "db"
90
102
  return "default"
91
103
 
92
104
 
@@ -126,28 +138,42 @@ def _field_view(
126
138
  settings: BaseSettings,
127
139
  prefix: str,
128
140
  overridden: frozenset[str] = frozenset(),
141
+ env_supplied: frozenset[str] | None = None,
129
142
  ) -> ModuleSettingField:
130
143
  cls = type(settings)
144
+ if env_supplied is None:
145
+ env_supplied = env_supplied_fields(cls)
131
146
  info = cls.model_fields[name]
132
147
  raw_value = getattr(settings, name)
133
148
  value_type = value_type_for_field(cls, name)
149
+ input_type, nullable = input_type_for_field(cls, name)
134
150
  named_secret = is_named_secret(name, value_type)
135
151
  extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
136
152
  default = resolve_default(info)
137
153
  env_var = f"{prefix}{name.upper()}"
138
154
  live_env_var = env_readable_var(settings, name)
139
- env_set = live_env_var is not None and live_env_var in os.environ
140
- # ``live_env_var`` is not None whenever env_set is True, but the narrowing
141
- # is spelled out so a future edit cannot turn this into ``os.environ[None]``.
142
- raw_env = os.environ[live_env_var] if env_set and live_env_var is not None else None
155
+ # The same answer ``build_settings`` acts on (GH #486), so a value supplied
156
+ # by the class's ``.env``, a secrets file or an alias is labelled "env"
157
+ # here exactly when hydration lets it beat a stored override.
158
+ env_set = name in env_supplied
159
+ raw_env = os.environ.get(live_env_var) if env_set and live_env_var is not None else None
160
+
161
+ # Declared as ``SecretStr`` (also inside ``X | None`` or a container):
162
+ # masked whatever the field is called, since the store holds it in clear.
163
+ typed_secret = annotation_holds_secret(info.annotation)
143
164
 
144
165
  def hidden(value: Any) -> bool:
145
- return conceals_secret(name, value, value_type)
166
+ return typed_secret or conceals_secret(name, value, value_type)
146
167
 
147
168
  # ``is_secret`` drives the editor's input type and the "reveal" affordance,
148
169
  # so it has to be true when *any* of the three readings is being hidden —
149
170
  # otherwise the form offers to edit a value it is showing as dots.
150
- secret = named_secret or any(embeds_credential(v) for v in (raw_value, default, raw_env))
171
+ secret = (
172
+ typed_secret
173
+ or named_secret
174
+ or any(embeds_credential(v) for v in (raw_value, default, raw_env))
175
+ )
176
+ group = extra.get("group")
151
177
  return ModuleSettingField(
152
178
  name=name,
153
179
  env_var=env_var,
@@ -157,7 +183,7 @@ def _field_view(
157
183
  is_secret=secret,
158
184
  type=value_type,
159
185
  requires_restart=bool(extra.get("requires_restart", False)),
160
- group=extra.get("group"),
186
+ group=group if isinstance(group, str) else None,
161
187
  env_set=env_set,
162
188
  db_override=name in overridden,
163
189
  env_readable=live_env_var is not None,
@@ -166,6 +192,8 @@ def _field_view(
166
192
  # Resolved value panel's env row into a row of dots for every key.
167
193
  env_value=(mask(raw_env) if hidden(raw_env) else raw_env) if raw_env is not None else None,
168
194
  choices=choices_for(info),
195
+ input_type=input_type,
196
+ nullable=nullable,
169
197
  )
170
198
 
171
199
 
@@ -176,7 +204,7 @@ def collect_module_settings(
176
204
  """Return a sorted, serializable view of every module's BaseSettings.
177
205
 
178
206
  Folds in both ``app.state.sm.modules`` (plugin modules) and additional
179
- packages registered via ``app.state.settings.module_registry`` (e.g.
207
+ packages registered in the framework's module-settings registry (e.g.
180
208
  ``"host"``) that aren't backed by a ``ModuleBase`` instance.
181
209
 
182
210
  ``overrides`` maps package -> field names carrying a stored override. It
@@ -187,8 +215,7 @@ def collect_module_settings(
187
215
  views: list[ModuleSettingsView] = []
188
216
  seen: set[str] = set()
189
217
 
190
- settings_services = getattr(app.state, "settings", None)
191
- registry = getattr(settings_services, "module_registry", None)
218
+ registry = get_module_settings_registry(app)
192
219
 
193
220
  def _manage_url(package: str) -> str | None:
194
221
  return registry.manage_url(package) if registry is not None else None
@@ -228,8 +255,10 @@ def _build_view(
228
255
  ) -> ModuleSettingsView:
229
256
  prefix = env_prefix_for(package)
230
257
  overridden = (overrides or {}).get(package, frozenset())
258
+ from_env = env_supplied_fields(type(settings))
231
259
  fields = [
232
- _field_view(name, settings, prefix, overridden) for name in type(settings).model_fields
260
+ _field_view(name, settings, prefix, overridden, from_env)
261
+ for name in type(settings).model_fields
233
262
  ]
234
263
  return ModuleSettingsView(
235
264
  module_name=module_name,
@@ -49,6 +49,8 @@ def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
49
49
  # Turns a pattern-constrained string into a select rather
50
50
  # than a text box whose only feedback on a typo is a 422.
51
51
  "choices": f.choices,
52
+ "input_type": f.input_type or f.type,
53
+ "nullable": f.nullable,
52
54
  }
53
55
  for f in v.fields
54
56
  ],
@@ -2,17 +2,18 @@
2
2
 
3
3
  Split out of ``service`` so the "is this a secret, and is this write the mask
4
4
  being echoed back?" question lives in one file, next to the field-level rules in
5
- ``_secrets`` that it delegates to. ``service`` imports these; nothing else
5
+ ``simple_module_core.secret_fields`` that it delegates to. ``service`` imports these; nothing else
6
6
  should need to, because the point of funnelling every read through ``out`` is
7
7
  that there is no second way to serialize a row.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from settings._secrets import conceals_secret
12
+ from simple_module_core.secret_fields import conceals_secret
13
+ from simple_module_db.stored_settings import Setting
14
+
13
15
  from settings.constants import SENSITIVE_KEYS, SENSITIVE_PLACEHOLDER
14
16
  from settings.contracts.schemas import SettingOut
15
- from settings.models import Setting
16
17
 
17
18
 
18
19
  def is_masked(entity: Setting) -> bool:
@@ -21,7 +22,7 @@ def is_masked(entity: Setting) -> bool:
21
22
  :data:`SENSITIVE_KEYS` names the one key the hosting layer owns
22
23
  (``host.secret_key``). Everything else is judged by the same name/value
23
24
  rule the module editor uses, because the store is the *same data* seen
24
- through a different screen: an override named ``users.smtp_password``, or
25
+ through a different screen: an override named ``mail.smtp_password``, or
25
26
  any override holding a ``postgresql://user:pw@host/db``, was rendered in
26
27
  clear text in the browse table and pre-filled into the edit form purely
27
28
  because the allowlist had a single entry in it.
@@ -0,0 +1,17 @@
1
+ """Re-export of :mod:`simple_module_core.secret_fields` (moved there in #500)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from simple_module_core.secret_fields import ( # noqa: F401
6
+ _NEVER_SECRET_TYPES,
7
+ _SECRET_PATTERNS,
8
+ _TYPED_SECRET_KEYS,
9
+ SECRET_MASK,
10
+ annotation_holds_secret,
11
+ conceals_secret,
12
+ embeds_credential,
13
+ is_named_secret,
14
+ is_secret_field,
15
+ mask,
16
+ register_typed_secrets,
17
+ )
@@ -8,11 +8,10 @@ savepoint, leaving the request's transaction (and its session) usable.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ from simple_module_db.stored_settings import Setting
11
12
  from sqlalchemy.exc import IntegrityError
12
13
  from sqlalchemy.ext.asyncio import AsyncSession
13
14
 
14
- from settings.models import Setting
15
-
16
15
 
17
16
  class DuplicateSettingError(Exception):
18
17
  """A row with this (scope, scope_id, key) already exists."""
@@ -14,10 +14,10 @@ from collections.abc import Mapping
14
14
  import typer
15
15
  from fastapi import FastAPI
16
16
  from simple_module_core.dotenv import find_env_file, parse_dotenv
17
+ from simple_module_core.module_settings import module_settings_registry
18
+ from simple_module_core.settings_hydrate import value_type_for_field
17
19
 
18
- from settings.constants import MODULE_PACKAGE
19
20
  from settings.env_vars import env_prefix_for
20
- from settings.hydrate import value_type_for_field
21
21
  from settings.store import SettingsStore
22
22
 
23
23
  app = typer.Typer(help="Settings module administration.", no_args_is_help=True)
@@ -35,7 +35,7 @@ async def import_from_env_impl(
35
35
  """
36
36
  if environ is None:
37
37
  environ = {**parse_dotenv(find_env_file()), **os.environ}
38
- registry = getattr(app_inst.state, MODULE_PACKAGE).module_registry
38
+ registry = module_settings_registry(app_inst)
39
39
  count = 0
40
40
  for package, cls in registry.items():
41
41
  prefix = env_prefix_for(package)
@@ -71,7 +71,7 @@ def import_from_env() -> None:
71
71
  if n == 0 and find_env_file().is_file():
72
72
  typer.echo(
73
73
  "Hint: variables must be named SM_<MODULE>_<FIELD> "
74
- "(e.g. SM_USERS_SMTP_PORT); none in the environment or .env matched."
74
+ "(e.g. SM_MAIL_SMTP_PORT); none in the environment or .env matched."
75
75
  )
76
76
  return 0
77
77
 
@@ -10,6 +10,28 @@ from __future__ import annotations
10
10
 
11
11
  from typing import Final
12
12
 
13
+ # Re-exported (#500): these now live with the table in the framework.
14
+ from simple_module_db.stored_settings import ( # noqa: F401
15
+ ALL_VALUE_TYPES,
16
+ DESCRIPTION_MAX_LENGTH,
17
+ KEY_MAX_LENGTH,
18
+ SCOPE_ID_MAX_LENGTH,
19
+ SCOPE_MAX_LENGTH,
20
+ SCOPE_SYSTEM,
21
+ SCOPE_TENANT,
22
+ SCOPE_USER,
23
+ SYSTEM_SCOPE_ID,
24
+ TABLE_SETTING,
25
+ UQ_SCOPE_KEY,
26
+ VALUE_MAX_LENGTH,
27
+ VALUE_TYPE_BOOL,
28
+ VALUE_TYPE_FLOAT,
29
+ VALUE_TYPE_INT,
30
+ VALUE_TYPE_JSON,
31
+ VALUE_TYPE_MAX_LENGTH,
32
+ VALUE_TYPE_STRING,
33
+ )
34
+
13
35
  # ── Module identity ──────────────────────────────────────────────────
14
36
  MODULE_NAME: Final = "Settings"
15
37
  MODULE_PACKAGE: Final = "settings"
@@ -17,30 +39,10 @@ ENV_PREFIX: Final = "SM_SETTINGS_"
17
39
  LOCALE_NAMESPACE: Final = MODULE_PACKAGE
18
40
 
19
41
  # ── 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"
42
+ # Scope, value-type, table and field-limit constants moved to
43
+ # ``simple_module_db.stored_settings`` with the table itself (#500); they are
44
+ # re-exported from the import block at the top of this file.
24
45
  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
46
 
45
47
  # ── Secrets ──────────────────────────────────────────────────────────
46
48
  # Keys whose stored value must never leave the settings API or the raw
@@ -104,16 +106,6 @@ INVALIDATION_CHANNEL: Final = "settings.values"
104
106
 
105
107
  # ── Database ─────────────────────────────────────────────────────────
106
108
  DB_SCHEMA: Final = MODULE_PACKAGE
107
- TABLE_SETTING: Final = "settings_setting"
108
- UQ_SCOPE_KEY: Final = "uq_settings_setting_scope_scope_id_key"
109
-
110
- # ── Field limits ─────────────────────────────────────────────────────
111
- KEY_MAX_LENGTH: Final = 200
112
- VALUE_MAX_LENGTH: Final = 4000
113
- DESCRIPTION_MAX_LENGTH: Final = 2000
114
- SCOPE_MAX_LENGTH: Final = 10
115
- SCOPE_ID_MAX_LENGTH: Final = 255
116
- VALUE_TYPE_MAX_LENGTH: Final = 10
117
109
 
118
110
  # ── Inertia page component names ─────────────────────────────────────
119
111
  PAGE_BROWSE: Final = f"{MODULE_NAME}/Browse"
@@ -23,7 +23,8 @@ import json
23
23
  from collections.abc import Callable
24
24
  from typing import TYPE_CHECKING, Any, Final
25
25
 
26
- from settings.constants import SYSTEM_SCOPE_ID
26
+ from simple_module_db.stored_settings import SYSTEM_SCOPE_ID
27
+
27
28
  from settings.contracts.registry import SettingsRegistry
28
29
  from settings.contracts.schemas import (
29
30
  BOOL_LITERALS_FALSE,
@@ -58,6 +58,8 @@ class SettingDefinition:
58
58
  of offering a text box for an opaque id. ``description_key`` is the i18n
59
59
  key the tenant settings page translates ``description`` with (the English
60
60
  ``description`` stays the fallback when the key is missing or unset).
61
+ ``label_key`` does the same for the row's title (the raw ``key`` is the
62
+ fallback).
61
63
  ``clear_via`` names the route that owns clearing the key (e.g. an upload
62
64
  route that also reaps the stored file): the generic DELETE routes answer
63
65
  422 pointing there while the tenant exists. A platform operator may still
@@ -76,6 +78,7 @@ class SettingDefinition:
76
78
  clear_via: str | Mapping[SettingScope, str] = ""
77
79
  upload_url: str = ""
78
80
  description_key: str = ""
81
+ label_key: str = ""
79
82
 
80
83
 
81
84
  def clear_route(definition: SettingDefinition, scope: SettingScope | str) -> str:
@@ -7,13 +7,8 @@ from datetime import datetime
7
7
  from enum import StrEnum
8
8
 
9
9
  from pydantic import ConfigDict, model_validator
10
- from sqlmodel import Field, SQLModel
11
-
12
- from settings.constants import (
10
+ from simple_module_db.stored_settings import (
13
11
  DESCRIPTION_MAX_LENGTH,
14
- ERR_SCOPED_REQUIRES_ID,
15
- ERR_SYSTEM_SCOPE_NO_ID,
16
- ERR_VALUE_MISMATCH,
17
12
  KEY_MAX_LENGTH,
18
13
  SCOPE_ID_MAX_LENGTH,
19
14
  SCOPE_SYSTEM,
@@ -27,6 +22,9 @@ from settings.constants import (
27
22
  VALUE_TYPE_JSON,
28
23
  VALUE_TYPE_STRING,
29
24
  )
25
+ from sqlmodel import Field, SQLModel
26
+
27
+ from settings.constants import ERR_SCOPED_REQUIRES_ID, ERR_SYSTEM_SCOPE_NO_ID, ERR_VALUE_MISMATCH
30
28
 
31
29
  _BOOL_LITERALS = frozenset(
32
30
  {"true", "false", "1", "0", "t", "f", "yes", "no", "y", "n", "on", "off"}
@@ -92,7 +90,7 @@ def _validate_value_matches_type(value: str, value_type: SettingValueType) -> No
92
90
  class SettingOut(SQLModel):
93
91
  """A setting returned by the API."""
94
92
 
95
- model_config = ConfigDict(from_attributes=True)
93
+ model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment]
96
94
 
97
95
  id: int
98
96
  scope: SettingScope
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from fastapi import APIRouter, Depends, HTTPException, Query, Request
6
+ from simple_module_db.stored_settings import SYSTEM_SCOPE_ID
6
7
  from simple_module_hosting.permissions import RequiresPermission
7
8
 
8
9
  from settings._unique_write import DuplicateSettingError
@@ -28,7 +29,6 @@ from settings.constants import (
28
29
  STATUS_NO_CONTENT,
29
30
  STATUS_NOT_FOUND,
30
31
  STATUS_UNPROCESSABLE,
31
- SYSTEM_SCOPE_ID,
32
32
  )
33
33
  from settings.contracts.schemas import (
34
34
  SettingCreate,