simple-module-settings 0.0.35__tar.gz → 0.0.37__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 (116) hide show
  1. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/.gitignore +1 -0
  2. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/PKG-INFO +4 -4
  3. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/pyproject.toml +4 -4
  4. simple_module_settings-0.0.37/settings/_announce.py +36 -0
  5. simple_module_settings-0.0.37/settings/_listing.py +121 -0
  6. simple_module_settings-0.0.37/settings/_managed_keys.py +59 -0
  7. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/_module_settings.py +40 -16
  8. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/_module_settings_props.py +25 -1
  9. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/_secrets.py +43 -3
  10. simple_module_settings-0.0.37/settings/_unique_write.py +29 -0
  11. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/cli.py +20 -3
  12. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/constants.py +23 -1
  13. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/contracts/__init__.py +2 -1
  14. simple_module_settings-0.0.37/settings/contracts/invalidation.py +39 -0
  15. simple_module_settings-0.0.37/settings/contracts/registry.py +121 -0
  16. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/deps.py +10 -1
  17. simple_module_settings-0.0.37/settings/endpoints/_create_form.py +34 -0
  18. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/endpoints/api.py +36 -2
  19. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/endpoints/module_api.py +17 -3
  20. simple_module_settings-0.0.37/settings/endpoints/tenant_api.py +88 -0
  21. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/endpoints/views.py +20 -32
  22. simple_module_settings-0.0.37/settings/errors.py +20 -0
  23. simple_module_settings-0.0.37/settings/hydrate.py +223 -0
  24. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/locales/en.json +11 -2
  25. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/module.py +17 -0
  26. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/module_registry.py +3 -0
  27. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/Browse.tsx +4 -1
  28. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/ModulesEdit.tsx +15 -5
  29. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/FieldInput.tsx +42 -11
  30. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/FieldSource.tsx +5 -5
  31. simple_module_settings-0.0.37/settings/pages/components/JsonInput.tsx +77 -0
  32. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/ModuleFieldRow.tsx +18 -2
  33. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/ModuleForm.tsx +29 -9
  34. simple_module_settings-0.0.37/settings/pages/components/validation-errors.test.ts +32 -0
  35. simple_module_settings-0.0.37/settings/pages/components/validation-errors.ts +45 -0
  36. simple_module_settings-0.0.37/settings/reload.py +158 -0
  37. simple_module_settings-0.0.37/settings/service.py +216 -0
  38. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/store.py +42 -10
  39. simple_module_settings-0.0.37/settings/tenant_scope.py +130 -0
  40. simple_module_settings-0.0.37/settings/tenant_view.py +82 -0
  41. simple_module_settings-0.0.37/tests/test_clear_route_per_scope.py +64 -0
  42. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_cli_import.py +46 -0
  43. simple_module_settings-0.0.37/tests/test_duplicate_and_race.py +113 -0
  44. simple_module_settings-0.0.37/tests/test_env_pinned.py +121 -0
  45. simple_module_settings-0.0.37/tests/test_hydrate.py +229 -0
  46. simple_module_settings-0.0.37/tests/test_hydrate_related_fields.py +53 -0
  47. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_module_api.py +5 -2
  48. simple_module_settings-0.0.37/tests/test_module_api_json_fields.py +129 -0
  49. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_api.py +12 -0
  50. simple_module_settings-0.0.37/tests/test_settings_default_clears_override.py +80 -0
  51. simple_module_settings-0.0.37/tests/test_settings_env_unprefixed.py +49 -0
  52. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_field_sources.py +4 -4
  53. simple_module_settings-0.0.37/tests/test_settings_secret_json_roundtrip.py +63 -0
  54. simple_module_settings-0.0.37/tests/test_settings_typed_secret_masking.py +97 -0
  55. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_store_filters.py +13 -5
  56. simple_module_settings-0.0.37/tests/test_tenant_settings.py +255 -0
  57. simple_module_settings-0.0.37/tests/test_upload_key_delete.py +152 -0
  58. simple_module_settings-0.0.37/tests-js/JsonFields.test.tsx +127 -0
  59. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests-js/ModuleForm.test.tsx +54 -1
  60. simple_module_settings-0.0.37/tests-js/ModulesEdit.test.tsx +86 -0
  61. simple_module_settings-0.0.35/settings/contracts/registry.py +0 -66
  62. simple_module_settings-0.0.35/settings/hydrate.py +0 -63
  63. simple_module_settings-0.0.35/settings/reload.py +0 -63
  64. simple_module_settings-0.0.35/settings/service.py +0 -270
  65. simple_module_settings-0.0.35/tests/test_hydrate.py +0 -65
  66. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/LICENSE +0 -0
  67. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/README.md +0 -0
  68. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/package.json +0 -0
  69. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/__init__.py +0 -0
  70. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/_field_meta.py +0 -0
  71. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/_row_masking.py +0 -0
  72. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/browse_query.py +0 -0
  73. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/contracts/accessor.py +0 -0
  74. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/contracts/events.py +0 -0
  75. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/contracts/schemas.py +0 -0
  76. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/endpoints/__init__.py +0 -0
  77. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/env_vars.py +0 -0
  78. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/known_keys.py +0 -0
  79. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/models.py +0 -0
  80. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/Create.tsx +0 -0
  81. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/Edit.tsx +0 -0
  82. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/KeyField.tsx +0 -0
  83. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/ResolvedValue.tsx +0 -0
  84. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/ScopeTabs.tsx +0 -0
  85. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/StoreCards.tsx +0 -0
  86. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/StoreTable.tsx +0 -0
  87. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/TestConnectionButton.tsx +0 -0
  88. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/ValueInput.tsx +0 -0
  89. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/components/last-test.ts +0 -0
  90. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/routes.ts +0 -0
  91. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/pages/types.ts +0 -0
  92. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/py.typed +0 -0
  93. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/registration.py +0 -0
  94. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/services.py +0 -0
  95. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/settings/settings.py +0 -0
  96. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_concurrent_overrides.py +0 -0
  97. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_known_keys_meta.py +0 -0
  98. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_module_registry.py +0 -0
  99. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_module_settings.py +0 -0
  100. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_module_settings_render.py +0 -0
  101. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_module_settings_serialize.py +0 -0
  102. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_registration.py +0 -0
  103. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_reload.py +0 -0
  104. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_accessor.py +0 -0
  105. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_events.py +0 -0
  106. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_module.py +0 -0
  107. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_schemas.py +0 -0
  108. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_secret_masking.py +0 -0
  109. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_secret_urls.py +0 -0
  110. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_service.py +0 -0
  111. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_settings_view_authz.py +0 -0
  112. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_store.py +0 -0
  113. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_store_secret_masking.py +0 -0
  114. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests/test_testable_checks.py +0 -0
  115. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tests-js/ScopeTabs.test.tsx +0 -0
  116. {simple_module_settings-0.0.35 → simple_module_settings-0.0.37}/tsconfig.json +0 -0
@@ -64,3 +64,4 @@ host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
66
  .verify/
67
+ /openapi.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_settings
3
- Version: 0.0.35
3
+ Version: 0.0.37
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.35
25
- Requires-Dist: simple-module-db==0.0.35
26
- Requires-Dist: simple-module-hosting==0.0.35
24
+ Requires-Dist: simple-module-core==0.0.37
25
+ Requires-Dist: simple-module-db==0.0.37
26
+ Requires-Dist: simple-module-hosting==0.0.37
27
27
  Requires-Dist: typer>=0.12
28
28
  Description-Content-Type: text/markdown
29
29
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_settings"
3
- version = "0.0.35"
3
+ version = "0.0.37"
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.35",
25
- "simple_module_db==0.0.35",
26
- "simple_module_hosting==0.0.35",
24
+ "simple_module_core==0.0.37",
25
+ "simple_module_db==0.0.37",
26
+ "simple_module_hosting==0.0.37",
27
27
  "typer>=0.12",
28
28
  ]
29
29
 
@@ -0,0 +1,36 @@
1
+ """Publish a per-(tenant, key) invalidation after a settings write commits.
2
+
3
+ Kept out of ``service.py`` (and its 300-line budget). See
4
+ ``settings.contracts.invalidation`` for the key format.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from simple_module_db.callbacks import register_on_commit
12
+
13
+ from settings.constants import INVALIDATION_CHANNEL, SCOPE_SYSTEM, SCOPE_TENANT
14
+ from settings.contracts.invalidation import invalidation_key
15
+
16
+ if TYPE_CHECKING:
17
+ from simple_module_core.invalidation import InvalidationBus
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+
20
+
21
+ def announce(
22
+ db: AsyncSession, bus: InvalidationBus | None, scope: str, scope_id: str, key: str
23
+ ) -> None:
24
+ """Queue the notice for this session's commit; user-scope writes have no consumer.
25
+
26
+ After commit, not inline: a consumer that drops its entry before the row is
27
+ durable re-reads the *old* value and caches it for a full TTL.
28
+ """
29
+ if bus is None or scope not in (SCOPE_SYSTEM, SCOPE_TENANT):
30
+ return
31
+ wire_key = invalidation_key(scope_id if scope == SCOPE_TENANT else None, key)
32
+
33
+ async def publish() -> None:
34
+ await bus.publish(INVALIDATION_CHANNEL, key=wire_key)
35
+
36
+ register_on_commit(db, publish)
@@ -0,0 +1,121 @@
1
+ """Read-side listing queries of :class:`SettingService`, split out for size."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from simple_module_db import LIKE_ESCAPE_CHAR, like_contains_pattern
6
+ from sqlalchemy import func, select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ 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
+ )
16
+ from settings.contracts.schemas import SettingOut, SettingScope
17
+ from settings.models import Setting
18
+
19
+
20
+ class SettingListing:
21
+ """Mixin: the ``list_*`` / ``count_by_scope`` queries. Needs ``self.db``."""
22
+
23
+ db: AsyncSession
24
+
25
+ # ── Listing ─────────────────────────────────────────────────────
26
+
27
+ async def list_all(self) -> list[SettingOut]:
28
+ result = await self.db.execute(
29
+ select(Setting).order_by(Setting.scope, Setting.scope_id, Setting.key)
30
+ )
31
+ return [out(row) for row in result.scalars()]
32
+
33
+ async def list_filtered(
34
+ self,
35
+ scope: SettingScope | None = None,
36
+ q: str | None = None,
37
+ page: int = 1,
38
+ per_page: int = DEFAULT_PER_PAGE,
39
+ ) -> tuple[list[SettingOut], int]:
40
+ """One page of rows plus the unpaged total for the same filters.
41
+
42
+ The browse screen used to receive every row and filter in the browser,
43
+ which made the payload, the render and find-in-page all scale with the
44
+ whole table instead of with what was asked for. ``q`` matches the key
45
+ only — the search box says "Search keys…", and quietly matching values
46
+ would surface rows whose key has nothing to do with the query.
47
+ """
48
+ conditions = self._filter_conditions(scope, q)
49
+ total = await self.db.scalar(select(func.count()).select_from(Setting).where(*conditions))
50
+ stmt = (
51
+ select(Setting)
52
+ .where(*conditions)
53
+ .order_by(Setting.scope, Setting.scope_id, Setting.key)
54
+ .offset(max(page - 1, 0) * per_page)
55
+ .limit(per_page)
56
+ )
57
+ result = await self.db.execute(stmt)
58
+ return [out(row) for row in result.scalars()], int(total or 0)
59
+
60
+ async def count_by_scope(self, q: str | None = None) -> dict[str, int]:
61
+ """Per-scope tallies for the filter tabs, plus ``all``.
62
+
63
+ Every scope is named even at zero: a tab that disappears when its count
64
+ drops to nothing moves the other tabs under the cursor mid-search.
65
+ The scope filter itself is deliberately not applied — the tabs describe
66
+ what each of them *would* show, so selecting one must not zero the rest.
67
+ """
68
+ conditions = self._filter_conditions(None, q)
69
+ stmt = select(Setting.scope, func.count()).where(*conditions).group_by(Setting.scope)
70
+ result = await self.db.execute(stmt)
71
+ tallies = {str(scope): int(count) for scope, count in result.all()}
72
+ counts = {name: tallies.get(name, 0) for name in ALL_SCOPES}
73
+ return {SCOPE_ALL: sum(counts.values()), **counts}
74
+
75
+ @staticmethod
76
+ def _filter_conditions(scope: SettingScope | None, q: str | None) -> list:
77
+ conditions = []
78
+ if scope is not None:
79
+ conditions.append(Setting.scope == scope.value)
80
+ needle = (q or "").strip()
81
+ if needle:
82
+ # Setting keys are full of underscores, and `_` is a LIKE wildcard:
83
+ # unescaped, a search for "smtp_host" also matches "smtpXhost", and
84
+ # a stray "%" matches the entire table. ``ilike`` is emulated by
85
+ # SQLAlchemy on SQLite (lower() on both sides), so one expression
86
+ # is case-insensitive on both databases.
87
+ conditions.append(
88
+ Setting.key.ilike(like_contains_pattern(needle), escape=LIKE_ESCAPE_CHAR)
89
+ )
90
+ return conditions
91
+
92
+ async def list_by_scope(
93
+ self, scope: SettingScope, scope_id: str = SYSTEM_SCOPE_ID
94
+ ) -> list[SettingOut]:
95
+ result = await self.db.execute(self._scope_stmt(scope, scope_id))
96
+ return [out(row) for row in result.scalars()]
97
+
98
+ async def list_by_scope_unmasked(
99
+ self, scope: SettingScope, scope_id: str = SYSTEM_SCOPE_ID
100
+ ) -> list[SettingOut]:
101
+ """The same rows with their real values, for code that *applies* them.
102
+
103
+ The masking in :func:`_out` is for the screens. Hydration is not a
104
+ screen: ``SettingsStore`` feeds these values back into the live module
105
+ settings objects at boot, so a masked read writes a row of dots over the
106
+ real secret — a mailer that cannot authenticate, and a
107
+ ``reset_password_token_secret`` that no longer verifies the tokens it
108
+ signed. This is the one read that must see through the mask, and it is
109
+ spelled out rather than reached by passing a flag so that every caller
110
+ of it is one grep away.
111
+ """
112
+ result = await self.db.execute(self._scope_stmt(scope, scope_id))
113
+ return [SettingOut.model_validate(row) for row in result.scalars()]
114
+
115
+ @staticmethod
116
+ def _scope_stmt(scope: SettingScope, scope_id: str):
117
+ return (
118
+ select(Setting)
119
+ .where(Setting.scope == scope.value, Setting.scope_id == scope_id)
120
+ .order_by(Setting.key)
121
+ )
@@ -0,0 +1,59 @@
1
+ """Rows of a key set by upload are not deleted by a bare row delete.
2
+
3
+ A key declaring ``SettingDefinition.clear_via`` is a file the owner's upload
4
+ route stores and reaps. Deleting the row any other way leaves the stored file
5
+ behind, so :meth:`SettingService.delete` / ``delete_scoped`` refuse it with
6
+ :class:`ManagedKeyError` **for every scope** — the rule lives in the service so
7
+ a route (or a module calling the service directly) cannot forget it.
8
+
9
+ Two ways through, both deliberate:
10
+
11
+ * the owner passes ``as_owner=True`` after taking responsibility for the file
12
+ (branding's clear routes reap it);
13
+ * a TENANT row whose tenant no longer exists has no live owner left to clear
14
+ it, so a platform operator may delete it by hand. SYSTEM and USER rows get no
15
+ such exception: a SYSTEM row always has a live owner (the platform's own
16
+ upload route), and a USER row of a managed key is cleared through the owner
17
+ too. The guard is only as wide as the registry the service was built with;
18
+ a service built without one (``SettingService(db)``) guards nothing.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Awaitable, Callable
24
+
25
+ from settings.constants import ERR_MANAGED_KEY_DELETE
26
+ from settings.contracts.registry import SettingsRegistry, clear_route
27
+ from settings.contracts.schemas import SettingScope
28
+
29
+ TenantIsLive = Callable[[str], Awaitable[bool]]
30
+
31
+
32
+ class ManagedKeyError(Exception):
33
+ """The key is cleared through ``clear_via``, not by deleting its row."""
34
+
35
+ def __init__(self, key: str, clear_via: str) -> None:
36
+ self.key = key
37
+ self.clear_via = clear_via
38
+ super().__init__(ERR_MANAGED_KEY_DELETE.format(clear_via=clear_via))
39
+
40
+
41
+ async def ensure_deletable(
42
+ registry: SettingsRegistry | None,
43
+ tenant_is_live: TenantIsLive | None,
44
+ scope: str,
45
+ scope_id: str,
46
+ key: str,
47
+ ) -> None:
48
+ """Raise :class:`ManagedKeyError` unless the row may be deleted generically."""
49
+ definition = registry.get(key) if registry is not None else None
50
+ if definition is None or not definition.clear_via:
51
+ return
52
+ orphaned = (
53
+ scope == SettingScope.TENANT.value
54
+ and tenant_is_live is not None
55
+ and not await tenant_is_live(scope_id)
56
+ )
57
+ if orphaned:
58
+ return
59
+ raise ManagedKeyError(key, clear_route(definition, scope))
@@ -19,6 +19,7 @@ from settings._field_meta import choices_for, env_readable_var, resolve_default
19
19
  from settings._secrets import ( # noqa: F401
20
20
  _NEVER_SECRET_TYPES,
21
21
  SECRET_MASK,
22
+ annotation_holds_secret,
22
23
  conceals_secret,
23
24
  embeds_credential,
24
25
  is_named_secret,
@@ -26,7 +27,7 @@ from settings._secrets import ( # noqa: F401
26
27
  mask,
27
28
  )
28
29
  from settings.env_vars import env_prefix_for
29
- from settings.hydrate import value_type_for_field
30
+ from settings.hydrate import env_supplied_fields, input_type_for_field, value_type_for_field
30
31
  from settings.service import SettingService
31
32
 
32
33
 
@@ -71,22 +72,28 @@ class ModuleSettingField:
71
72
  """
72
73
  choices: list[str] | None = None
73
74
  """Closed set of accepted values, or ``None`` when the field is free text."""
75
+ input_type: str = ""
76
+ """Editor control: ``type`` with an ``X | None`` unwrapped to ``X``."""
77
+ nullable: bool = False
78
+ """``None`` is a valid value; the editor sends it for an empty input."""
74
79
 
75
80
  @property
76
81
  def source(self) -> str:
77
82
  """Where the live value came from: ``db``, ``env`` or ``default``.
78
83
 
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.
84
+ Mirrors the precedence in ``hydrate_settings``: env → DB → default
85
+ (GH #486). A stored override for a field the environment sets is
86
+ ignored, so ``env`` wins even when ``db_override`` is true. ``env`` only
87
+ appears for settings classes that declare an ``env_prefix`` (or name
88
+ the field in ``env_seeded_fields``) — the same rule
89
+ ``hydrate.env_supplied_fields`` applies; see :attr:`env_set`. Showing
90
+ this is the difference between "why is this not taking effect" being a
91
+ five-minute question and an afternoon.
85
92
  """
86
- if self.db_override:
87
- return "db"
88
93
  if self.env_set:
89
94
  return "env"
95
+ if self.db_override:
96
+ return "db"
90
97
  return "default"
91
98
 
92
99
 
@@ -126,28 +133,41 @@ def _field_view(
126
133
  settings: BaseSettings,
127
134
  prefix: str,
128
135
  overridden: frozenset[str] = frozenset(),
136
+ env_supplied: frozenset[str] | None = None,
129
137
  ) -> ModuleSettingField:
130
138
  cls = type(settings)
139
+ if env_supplied is None:
140
+ env_supplied = env_supplied_fields(cls)
131
141
  info = cls.model_fields[name]
132
142
  raw_value = getattr(settings, name)
133
143
  value_type = value_type_for_field(cls, name)
144
+ input_type, nullable = input_type_for_field(cls, name)
134
145
  named_secret = is_named_secret(name, value_type)
135
146
  extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
136
147
  default = resolve_default(info)
137
148
  env_var = f"{prefix}{name.upper()}"
138
149
  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
150
+ # The same answer ``build_settings`` acts on (GH #486), so a value supplied
151
+ # by the class's ``.env``, a secrets file or an alias is labelled "env"
152
+ # here exactly when hydration lets it beat a stored override.
153
+ env_set = name in env_supplied
154
+ raw_env = os.environ.get(live_env_var) if env_set and live_env_var is not None else None
155
+
156
+ # Declared as ``SecretStr`` (also inside ``X | None`` or a container):
157
+ # masked whatever the field is called, since the store holds it in clear.
158
+ typed_secret = annotation_holds_secret(info.annotation)
143
159
 
144
160
  def hidden(value: Any) -> bool:
145
- return conceals_secret(name, value, value_type)
161
+ return typed_secret or conceals_secret(name, value, value_type)
146
162
 
147
163
  # ``is_secret`` drives the editor's input type and the "reveal" affordance,
148
164
  # so it has to be true when *any* of the three readings is being hidden —
149
165
  # 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))
166
+ secret = (
167
+ typed_secret
168
+ or named_secret
169
+ or any(embeds_credential(v) for v in (raw_value, default, raw_env))
170
+ )
151
171
  return ModuleSettingField(
152
172
  name=name,
153
173
  env_var=env_var,
@@ -166,6 +186,8 @@ def _field_view(
166
186
  # Resolved value panel's env row into a row of dots for every key.
167
187
  env_value=(mask(raw_env) if hidden(raw_env) else raw_env) if raw_env is not None else None,
168
188
  choices=choices_for(info),
189
+ input_type=input_type,
190
+ nullable=nullable,
169
191
  )
170
192
 
171
193
 
@@ -228,8 +250,10 @@ def _build_view(
228
250
  ) -> ModuleSettingsView:
229
251
  prefix = env_prefix_for(package)
230
252
  overridden = (overrides or {}).get(package, frozenset())
253
+ from_env = env_supplied_fields(type(settings))
231
254
  fields = [
232
- _field_view(name, settings, prefix, overridden) for name in type(settings).model_fields
255
+ _field_view(name, settings, prefix, overridden, from_env)
256
+ for name in type(settings).model_fields
233
257
  ]
234
258
  return ModuleSettingsView(
235
259
  module_name=module_name,
@@ -9,9 +9,10 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Any
11
11
 
12
+ from fastapi import FastAPI
12
13
  from fastapi.encoders import jsonable_encoder
13
14
 
14
- from settings._module_settings import ModuleSettingsView
15
+ from settings._module_settings import ModuleSettingsView, _package_of
15
16
 
16
17
 
17
18
  def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
@@ -48,9 +49,32 @@ def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
48
49
  # Turns a pattern-constrained string into a select rather
49
50
  # than a text box whose only feedback on a typo is a 422.
50
51
  "choices": f.choices,
52
+ "input_type": f.input_type or f.type,
53
+ "nullable": f.nullable,
51
54
  }
52
55
  for f in v.fields
53
56
  ],
54
57
  }
55
58
  for v in views
56
59
  ]
60
+
61
+
62
+ def testable_packages(app: FastAPI) -> dict[str, list[str]]:
63
+ """Package -> the names of the health checks its module registered.
64
+
65
+ "Test connection" is just that module's health checks run on demand —
66
+ reusing the registry means settings never learns what an SMTP or an S3
67
+ connection is. The names come back with the packages so the button can say
68
+ what it is about to dial ("Test mailer connection") instead of the useless
69
+ "Test connection" a bare package list can produce.
70
+ """
71
+ checks_by_owner: dict[str, list[str]] = {}
72
+ for check in app.state.sm.health_registry.all_checks:
73
+ if check.module:
74
+ checks_by_owner.setdefault(check.module, []).append(check.name)
75
+
76
+ return {
77
+ _package_of(mod): sorted(checks_by_owner[mod.meta.name])
78
+ for mod in getattr(app.state.sm, "modules", ())
79
+ if mod.meta.name in checks_by_owner
80
+ }
@@ -9,9 +9,11 @@ be reasoned about without a FastAPI app in scope. Re-exported from
9
9
  from __future__ import annotations
10
10
 
11
11
  import re
12
- from typing import Any
12
+ from typing import Any, get_args, get_origin
13
13
  from urllib.parse import urlsplit
14
14
 
15
+ from pydantic import BaseModel, SecretBytes, SecretStr
16
+
15
17
  # We intentionally DON'T match the bare word "token" (would mask
16
18
  # `verification_token_lifetime_seconds` — just an int) or "key" alone (would
17
19
  # mask `s3_bucket_key_prefix`). Only fragments that actually indicate material.
@@ -25,6 +27,41 @@ SECRET_MASK = "••••••••"
25
27
  _NEVER_SECRET_TYPES = frozenset({"int", "float", "bool"})
26
28
 
27
29
 
30
+ # ``<package>.<field>`` keys whose declared type holds a ``SecretStr`` /
31
+ # ``SecretBytes``. Filled by ``ModuleSettingsRegistry.register`` so the row
32
+ # browser, tenant view and known-keys list — which see only a key and a stored
33
+ # string — mask them too. Only ever grows; keys are package-qualified.
34
+ _TYPED_SECRET_KEYS: set[str] = set()
35
+
36
+
37
+ def annotation_holds_secret(annotation: Any, _seen: frozenset[type] = frozenset()) -> bool:
38
+ """True if a ``SecretStr``/``SecretBytes`` sits anywhere in ``annotation``.
39
+
40
+ Walks unions (``SecretStr | None``), containers (``dict[str, SecretStr]``)
41
+ and nested models. Since json fields store the real secret (GH #487
42
+ review), a field typed this way must be masked whatever its name.
43
+ """
44
+ if get_origin(annotation) is None and isinstance(annotation, type):
45
+ if issubclass(annotation, SecretStr | SecretBytes):
46
+ return True
47
+ if issubclass(annotation, BaseModel) and annotation not in _seen:
48
+ seen = _seen | {annotation}
49
+ return any(
50
+ annotation_holds_secret(f.annotation, seen)
51
+ for f in annotation.model_fields.values()
52
+ )
53
+ return any(annotation_holds_secret(arg, _seen) for arg in get_args(annotation))
54
+
55
+
56
+ def register_typed_secrets(package: str, cls: type[BaseModel]) -> None:
57
+ """Record which of ``cls``'s fields are secrets by type."""
58
+ _TYPED_SECRET_KEYS.update(
59
+ f"{package}.{name}"
60
+ for name, info in cls.model_fields.items()
61
+ if annotation_holds_secret(info.annotation)
62
+ )
63
+
64
+
28
65
  def is_secret_field(name: str) -> bool:
29
66
  """True if a field name suggests it holds credential material."""
30
67
  return bool(_SECRET_PATTERNS.search(name))
@@ -49,11 +86,14 @@ def is_named_secret(name: str, value_type: str | None = None) -> bool:
49
86
  def conceals_secret(name: str, value: Any, value_type: str | None = None) -> bool:
50
87
  """True if this name/value pair must be masked on the way out.
51
88
 
52
- The one rule every read path shares: mask per *value* as well as per name,
89
+ The one rule every read path shares: mask per *type* (a registered
90
+ ``<package>.<field>`` declared as a secret), per *value* and per name,
53
91
  so a DSN carrying a password is hidden even on a field called
54
92
  ``broker_url``, while that field's password-free default is still shown.
55
93
  """
56
- return is_named_secret(name, value_type) or embeds_credential(value)
94
+ return (
95
+ name in _TYPED_SECRET_KEYS or is_named_secret(name, value_type) or embeds_credential(value)
96
+ )
57
97
 
58
98
 
59
99
  def embeds_credential(value: Any) -> bool:
@@ -0,0 +1,29 @@
1
+ """Race-safe inserts for the (scope, scope_id, key) unique key.
2
+
3
+ A read-then-insert cannot be made safe by reading harder: two requests can both
4
+ see "no row" and both insert. The unique constraint is the arbiter, so the
5
+ insert runs in a savepoint — a loser's ``IntegrityError`` rolls back only the
6
+ savepoint, leaving the request's transaction (and its session) usable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from sqlalchemy.exc import IntegrityError
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from settings.models import Setting
15
+
16
+
17
+ class DuplicateSettingError(Exception):
18
+ """A row with this (scope, scope_id, key) already exists."""
19
+
20
+
21
+ async def insert_if_free(db: AsyncSession, entity: Setting) -> bool:
22
+ """Insert ``entity``; ``False`` when its (scope, scope_id, key) is already taken."""
23
+ try:
24
+ async with db.begin_nested():
25
+ db.add(entity)
26
+ await db.flush()
27
+ except IntegrityError:
28
+ return False
29
+ return True
@@ -9,9 +9,11 @@ from __future__ import annotations
9
9
 
10
10
  import asyncio
11
11
  import os
12
+ from collections.abc import Mapping
12
13
 
13
14
  import typer
14
15
  from fastapi import FastAPI
16
+ from simple_module_core.dotenv import find_env_file, parse_dotenv
15
17
 
16
18
  from settings.constants import MODULE_PACKAGE
17
19
  from settings.env_vars import env_prefix_for
@@ -21,14 +23,24 @@ from settings.store import SettingsStore
21
23
  app = typer.Typer(help="Settings module administration.", no_args_is_help=True)
22
24
 
23
25
 
24
- async def import_from_env_impl(app_inst: FastAPI, store: SettingsStore) -> int:
25
- """Write a SYSTEM override for every ``SM_<PREFIX>_<FIELD>`` env var set."""
26
+ async def import_from_env_impl(
27
+ app_inst: FastAPI,
28
+ store: SettingsStore,
29
+ environ: Mapping[str, str] | None = None,
30
+ ) -> int:
31
+ """Write a SYSTEM override for every ``SM_<PREFIX>_<FIELD>`` var set.
32
+
33
+ Default source is the project ``.env`` overlaid by the process environment
34
+ (real env wins). ``os.environ`` is never mutated.
35
+ """
36
+ if environ is None:
37
+ environ = {**parse_dotenv(find_env_file()), **os.environ}
26
38
  registry = getattr(app_inst.state, MODULE_PACKAGE).module_registry
27
39
  count = 0
28
40
  for package, cls in registry.items():
29
41
  prefix = env_prefix_for(package)
30
42
  for field_name in cls.model_fields:
31
- raw = os.environ.get(f"{prefix}{field_name.upper()}")
43
+ raw = environ.get(f"{prefix}{field_name.upper()}")
32
44
  if raw is None:
33
45
  continue
34
46
  vtype = value_type_for_field(cls, field_name)
@@ -56,6 +68,11 @@ def import_from_env() -> None:
56
68
  n = await import_from_env_impl(fastapi_app, store)
57
69
  await session.commit()
58
70
  typer.echo(f"Imported {n} override(s) from environment.")
71
+ if n == 0 and find_env_file().is_file():
72
+ typer.echo(
73
+ "Hint: variables must be named SM_<MODULE>_<FIELD> "
74
+ "(e.g. SM_USERS_SMTP_PORT); none in the environment or .env matched."
75
+ )
59
76
  return 0
60
77
 
61
78
  raise typer.Exit(code=asyncio.run(run()))
@@ -70,6 +70,11 @@ API_RESOLVE_PATH: Final = "/resolve/{key}"
70
70
  API_SYSTEM_PATH: Final = "/system/{key}"
71
71
  API_TENANT_PATH: Final = "/tenant/{scope_id}/{key}"
72
72
  API_USER_PATH: Final = "/user/{scope_id}/{key}"
73
+ # The active tenant's own overrides (#382). The tenant comes from
74
+ # ``request.state.tenant_id`` — never from the URL — so "current" is a literal
75
+ # segment, registered ahead of ``API_TENANT_PATH`` so it is not read as an id.
76
+ API_TENANT_CURRENT_PATH: Final = "/tenant/current"
77
+ API_TENANT_CURRENT_KEY_PATH: Final = "/tenant/current/{key}"
73
78
 
74
79
  # ── Menu ─────────────────────────────────────────────────────────────
75
80
  MENU_LABEL: Final = MODULE_NAME
@@ -85,7 +90,17 @@ PERM_VIEW: Final = "settings.view"
85
90
  PERM_CREATE: Final = "settings.create"
86
91
  PERM_EDIT: Final = "settings.edit"
87
92
  PERM_DELETE: Final = "settings.delete"
88
- ALL_PERMISSIONS: Final = (PERM_VIEW, PERM_CREATE, PERM_EDIT, PERM_DELETE)
93
+ # Edit the *active* tenant's overrides of ``tenant_overridable`` keys — and
94
+ # nothing else. Mapped onto ``tenant:owner`` / ``tenant:admin``; the four
95
+ # above stay platform-operator permissions (system scope, any tenant's scope).
96
+ PERM_TENANT_EDIT: Final = "settings.tenant.edit"
97
+ ALL_PERMISSIONS: Final = (PERM_VIEW, PERM_CREATE, PERM_EDIT, PERM_DELETE, PERM_TENANT_EDIT)
98
+
99
+ # ── Cache invalidation ───────────────────────────────────────────────
100
+ # Published (after commit) for every SYSTEM / TENANT write, keyed per
101
+ # (tenant, key) — see ``settings.contracts.invalidation``. Settings caches
102
+ # nothing itself; consumers holding per-tenant resolved values subscribe.
103
+ INVALIDATION_CHANNEL: Final = "settings.values"
89
104
 
90
105
  # ── Database ─────────────────────────────────────────────────────────
91
106
  DB_SCHEMA: Final = MODULE_PACKAGE
@@ -130,16 +145,23 @@ MAX_PER_PAGE: Final = 200
130
145
  # ── User-facing error messages ───────────────────────────────────────
131
146
  ERR_SETTING_NOT_FOUND: Final = "Setting not found"
132
147
  ERR_KEY_ALREADY_EXISTS: Final = "Setting key already exists"
148
+ ERR_SETTING_EXISTS: Final = "A setting with this scope and key already exists"
133
149
  ERR_SYSTEM_SCOPE_NO_ID: Final = "system scope must not have a scope_id"
134
150
  ERR_SCOPED_REQUIRES_ID: Final = "tenant/user scope requires a scope_id"
135
151
  ERR_UNKNOWN_SCOPE: Final = "unknown scope"
136
152
  ERR_VALUE_MISMATCH: Final = "value does not parse as declared value_type"
153
+ ERR_UNKNOWN_TENANT: Final = "Unknown tenant"
154
+ ERR_NO_ACTIVE_TENANT: Final = "No active organisation for this request"
155
+ ERR_MANAGED_KEY_DELETE: Final = "This setting is managed elsewhere; clear it at {clear_via}"
156
+ ERR_NOT_TENANT_OVERRIDABLE: Final = "This setting cannot be changed per organisation"
137
157
 
138
158
  # ── HTTP ─────────────────────────────────────────────────────────────
139
159
  STATUS_CREATED: Final = 201
140
160
  STATUS_NO_CONTENT: Final = 204
161
+ STATUS_FORBIDDEN: Final = 403
141
162
  STATUS_NOT_FOUND: Final = 404
142
163
  STATUS_CONFLICT: Final = 409
164
+ STATUS_UNPROCESSABLE: Final = 422
143
165
 
144
166
  # ── Query parameter names ────────────────────────────────────────────
145
167
  QP_USER_ID: Final = "user_id"
@@ -1,7 +1,7 @@
1
1
  """Settings contracts — public interface for other modules."""
2
2
 
3
3
  from settings.contracts.accessor import SettingsAccessor
4
- from settings.contracts.registry import SettingDefinition, SettingsRegistry
4
+ from settings.contracts.registry import SettingDefinition, SettingsRegistry, clear_route
5
5
  from settings.contracts.schemas import (
6
6
  SettingCreate,
7
7
  SettingOut,
@@ -21,4 +21,5 @@ __all__ = [
21
21
  "SettingValueType",
22
22
  "SettingsAccessor",
23
23
  "SettingsRegistry",
24
+ "clear_route",
24
25
  ]