simple-module-settings 0.0.38__tar.gz → 0.0.40__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.38 → simple_module_settings-0.0.40}/PKG-INFO +5 -5
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/package.json +1 -1
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/pyproject.toml +5 -5
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/_listing.py +2 -1
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/_managed_keys.py +2 -1
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/_unique_write.py +3 -1
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/deps.py +2 -1
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/endpoints/module_api.py +2 -8
- simple_module_settings-0.0.40/settings/exceptions.py +5 -0
- simple_module_settings-0.0.40/settings/invalidation.py +70 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/module.py +14 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/ModulesEdit.tsx +5 -1
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/ModuleForm.tsx +20 -8
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/TestConnectionButton.tsx +16 -4
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/reload.py +11 -7
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/service.py +4 -3
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/store.py +4 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_reload.py +17 -1
- simple_module_settings-0.0.40/tests/test_settings_commit_invalidation.py +99 -0
- simple_module_settings-0.0.40/tests/test_settings_domain_errors.py +10 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_secret_json_roundtrip.py +7 -0
- simple_module_settings-0.0.40/tests-js/settings_ModuleFormRevert.test.tsx +88 -0
- simple_module_settings-0.0.40/tests-js/settings_TestConnectionButton.test.tsx +111 -0
- simple_module_settings-0.0.38/tests-js/settings_TestConnectionButton.test.tsx +0 -47
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/.gitignore +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/LICENSE +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/README.md +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/__init__.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/_announce.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/_field_meta.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/_module_settings.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/_module_settings_props.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/_row_masking.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/_secrets.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/browse_query.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/cli.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/constants.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/contracts/__init__.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/contracts/accessor.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/contracts/events.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/contracts/invalidation.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/contracts/registry.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/contracts/schemas.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/endpoints/__init__.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/endpoints/_create_form.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/endpoints/api.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/endpoints/tenant_api.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/endpoints/views.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/env_vars.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/errors.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/hydrate.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/known_keys.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/locales/de.json +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/locales/en.json +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/models.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/module_registry.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/Browse.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/Create.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/Edit.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/api.generated.ts +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/api.ts +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/FieldInput.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/FieldSource.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/JsonInput.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/KeyField.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/ModuleFieldRow.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/ResolvedValue.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/ScopeTabs.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/StoreCards.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/StoreTable.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/ValueInput.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/last-test.ts +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/validation-errors.test.ts +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/components/validation-errors.ts +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/routes.ts +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/types.ts +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/py.typed +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/registration.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/services.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/settings.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/tenant_scope.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/tenant_view.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_clear_route_per_scope.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_cli_import.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_concurrent_overrides.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_duplicate_and_race.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_env_pinned.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_hydrate.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_hydrate_related_fields.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_known_keys_meta.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_lenient_related_fields_validators.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_module_api.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_module_api_json_fields.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_module_registry.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_module_settings.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_module_settings_render.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_module_settings_serialize.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_registration.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_accessor.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_api.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_default_clears_override.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_env_unprefixed.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_events.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_field_sources.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_framework_reexports.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_group_metadata.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_host_restart_flags.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_module.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_schemas.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_secret_masking.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_secret_urls.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_service.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_typed_secret_masking.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_settings_view_authz.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_store.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_store_filters.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_store_secret_masking.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_tenant_settings.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_testable_checks.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests/test_upload_key_delete.py +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests-js/JsonFields.test.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests-js/ModuleForm.test.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests-js/ModulesEdit.test.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests-js/RestartHint.test.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tests-js/ScopeTabs.test.tsx +0 -0
- {simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/tsconfig.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_settings
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.40
|
|
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,10 +21,10 @@ 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.
|
|
25
|
-
Requires-Dist: simple-module-db==0.0.
|
|
26
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
27
|
-
Requires-Dist: typer
|
|
24
|
+
Requires-Dist: simple-module-core==0.0.40
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.40
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.40
|
|
27
|
+
Requires-Dist: typer<0.28,>=0.12
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
29
29
|
|
|
30
30
|
# simple_module_settings
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_settings"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.40"
|
|
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,10 +21,10 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
27
|
-
"typer>=0.12",
|
|
24
|
+
"simple_module_core==0.0.40",
|
|
25
|
+
"simple_module_db==0.0.40",
|
|
26
|
+
"simple_module_hosting==0.0.40",
|
|
27
|
+
"typer>=0.12,<0.28",
|
|
28
28
|
]
|
|
29
29
|
|
|
30
30
|
[project.entry-points.simple_module]
|
|
@@ -6,6 +6,7 @@ from simple_module_db import LIKE_ESCAPE_CHAR, like_contains_pattern
|
|
|
6
6
|
from simple_module_db.stored_settings import SYSTEM_SCOPE_ID, Setting
|
|
7
7
|
from sqlalchemy import func, select
|
|
8
8
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
from sqlmodel import col
|
|
9
10
|
|
|
10
11
|
from settings._row_masking import out
|
|
11
12
|
from settings.constants import ALL_SCOPES, DEFAULT_PER_PAGE, SCOPE_ALL
|
|
@@ -111,6 +112,6 @@ class SettingListing:
|
|
|
111
112
|
def _scope_stmt(scope: SettingScope, scope_id: str):
|
|
112
113
|
return (
|
|
113
114
|
select(Setting)
|
|
114
|
-
.where(Setting.scope == scope.value, Setting.scope_id == scope_id)
|
|
115
|
+
.where(col(Setting.scope) == scope.value, col(Setting.scope_id) == scope_id)
|
|
115
116
|
.order_by(Setting.key)
|
|
116
117
|
)
|
|
@@ -25,11 +25,12 @@ from collections.abc import Awaitable, Callable
|
|
|
25
25
|
from settings.constants import ERR_MANAGED_KEY_DELETE
|
|
26
26
|
from settings.contracts.registry import SettingsRegistry, clear_route
|
|
27
27
|
from settings.contracts.schemas import SettingScope
|
|
28
|
+
from settings.exceptions import SettingsError
|
|
28
29
|
|
|
29
30
|
TenantIsLive = Callable[[str], Awaitable[bool]]
|
|
30
31
|
|
|
31
32
|
|
|
32
|
-
class ManagedKeyError(
|
|
33
|
+
class ManagedKeyError(SettingsError):
|
|
33
34
|
"""The key is cleared through ``clear_via``, not by deleting its row."""
|
|
34
35
|
|
|
35
36
|
def __init__(self, key: str, clear_via: str) -> None:
|
|
@@ -12,8 +12,10 @@ from simple_module_db.stored_settings import Setting
|
|
|
12
12
|
from sqlalchemy.exc import IntegrityError
|
|
13
13
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
14
14
|
|
|
15
|
+
from settings.exceptions import SettingsError
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
|
|
18
|
+
class DuplicateSettingError(SettingsError):
|
|
17
19
|
"""A row with this (scope, scope_id, key) already exists."""
|
|
18
20
|
|
|
19
21
|
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
from typing import Annotated
|
|
12
12
|
|
|
13
13
|
from fastapi import Depends, Request
|
|
14
|
+
from simple_module_core.app_state import get_services
|
|
14
15
|
from simple_module_db.deps import get_db
|
|
15
16
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
17
|
|
|
@@ -25,7 +26,7 @@ async def get_setting_service(
|
|
|
25
26
|
request: Request,
|
|
26
27
|
db: AsyncSession = Depends(get_db),
|
|
27
28
|
) -> SettingService:
|
|
28
|
-
bus = getattr(
|
|
29
|
+
bus = getattr(get_services(request.app), "invalidation", None)
|
|
29
30
|
|
|
30
31
|
async def tenant_is_live(tenant_id: str) -> bool:
|
|
31
32
|
return await is_known_tenant(request, tenant_id)
|
{simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/endpoints/module_api.py
RENAMED
|
@@ -12,7 +12,6 @@ from typing import Any
|
|
|
12
12
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response, status
|
|
13
13
|
from pydantic import ValidationError
|
|
14
14
|
from simple_module_core.module_settings import module_settings_registry
|
|
15
|
-
from simple_module_core.settings_hydrate import hydrate_settings
|
|
16
15
|
from simple_module_hosting.i18n_deps import TranslatorDep
|
|
17
16
|
from simple_module_hosting.permissions import RequiresPermission
|
|
18
17
|
|
|
@@ -23,8 +22,8 @@ from settings._module_settings import (
|
|
|
23
22
|
)
|
|
24
23
|
from settings._module_settings_props import serialize
|
|
25
24
|
from settings.constants import PERM_DELETE, PERM_EDIT, PERM_VIEW
|
|
26
|
-
from settings.contracts.events import SettingsReloaded
|
|
27
25
|
from settings.deps import get_setting_service
|
|
26
|
+
from settings.invalidation import reload_on_commit
|
|
28
27
|
from settings.reload import EnvPinnedError, apply_changes_and_reload
|
|
29
28
|
from settings.service import SettingService
|
|
30
29
|
from settings.store import SettingsStore
|
|
@@ -162,11 +161,6 @@ async def clear_module_field(
|
|
|
162
161
|
store = SettingsStore(service)
|
|
163
162
|
await store.clear_override(package, field)
|
|
164
163
|
|
|
165
|
-
|
|
166
|
-
services = getattr(request.app.state, package)
|
|
167
|
-
services.settings = hydrated
|
|
168
|
-
|
|
169
|
-
bus = request.app.state.sm.event_bus
|
|
170
|
-
await bus.publish(SettingsReloaded(package=package, changed=(field,)))
|
|
164
|
+
reload_on_commit(request.app, request.app.state.sm.event_bus, store, package)
|
|
171
165
|
|
|
172
166
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Rehydrate module settings and notify local consumers only after durable writes."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from simple_module_core.app_state import get_services
|
|
7
|
+
from simple_module_core.events import EventBus
|
|
8
|
+
from simple_module_core.invalidation import InvalidationBus
|
|
9
|
+
from simple_module_core.invalidation_refresh import InvalidationRefresh
|
|
10
|
+
from simple_module_core.module_settings import module_settings_registry
|
|
11
|
+
from simple_module_core.settings_hydrate import hydrate_settings
|
|
12
|
+
from simple_module_db.stored_settings import settings_table_exists
|
|
13
|
+
|
|
14
|
+
from settings.contracts.events import SettingsReloaded
|
|
15
|
+
from settings.store import SettingsStore
|
|
16
|
+
|
|
17
|
+
CHANNEL = "settings.modules"
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def reload_package(app: FastAPI, bus: EventBus, store: SettingsStore, package: str) -> None:
|
|
22
|
+
cls = module_settings_registry(app).get(package)
|
|
23
|
+
if cls is None:
|
|
24
|
+
return
|
|
25
|
+
services = getattr(app.state, package, None)
|
|
26
|
+
if services is None:
|
|
27
|
+
return
|
|
28
|
+
hydrated = await hydrate_settings(cls, store, package, lenient=True)
|
|
29
|
+
changed = tuple(
|
|
30
|
+
sorted(
|
|
31
|
+
name
|
|
32
|
+
for name in cls.model_fields
|
|
33
|
+
if getattr(services.settings, name) != getattr(hydrated, name)
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
services.settings = hydrated
|
|
37
|
+
if changed:
|
|
38
|
+
await bus.publish(SettingsReloaded(package=package, changed=changed))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reload_on_commit(app: FastAPI, bus: EventBus, store: SettingsStore, package: str) -> None:
|
|
42
|
+
async def committed() -> None:
|
|
43
|
+
invalidation = getattr(get_services(app), "invalidation", None)
|
|
44
|
+
if invalidation is None:
|
|
45
|
+
await reload_package(app, bus, store, package)
|
|
46
|
+
else:
|
|
47
|
+
await invalidation.publish(CHANNEL, key=package)
|
|
48
|
+
|
|
49
|
+
store.on_commit(committed)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def subscribe(bus: InvalidationBus, app: FastAPI) -> InvalidationRefresh:
|
|
53
|
+
from settings.service import SettingService
|
|
54
|
+
|
|
55
|
+
async def reload(key: str | None) -> None:
|
|
56
|
+
async with app.state.sm.db.session_factory() as db:
|
|
57
|
+
if not await settings_table_exists(db): # fresh install, before migrations
|
|
58
|
+
return
|
|
59
|
+
store = SettingsStore(SettingService(db))
|
|
60
|
+
packages = [key] if key is not None else module_settings_registry(app).all_packages()
|
|
61
|
+
for package in packages:
|
|
62
|
+
# One package's failed read or invalid cross-field rule must not
|
|
63
|
+
# starve the packages after it on every later cycle.
|
|
64
|
+
try:
|
|
65
|
+
await reload_package(app, app.state.sm.event_bus, store, package)
|
|
66
|
+
except Exception:
|
|
67
|
+
logger.exception("Reloading %s settings failed; keeping current ones", package)
|
|
68
|
+
await db.rollback()
|
|
69
|
+
|
|
70
|
+
return InvalidationRefresh(bus, CHANNEL, reload)
|
|
@@ -29,6 +29,7 @@ from settings.constants import (
|
|
|
29
29
|
)
|
|
30
30
|
|
|
31
31
|
if TYPE_CHECKING:
|
|
32
|
+
from simple_module_core.invalidation import InvalidationBus
|
|
32
33
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
33
34
|
|
|
34
35
|
|
|
@@ -79,6 +80,19 @@ class SettingsModule(ModuleBase):
|
|
|
79
80
|
# Self-register so the UI lists our own settings alongside other modules.
|
|
80
81
|
services.module_registry.register(MODULE_PACKAGE, SettingsSettings)
|
|
81
82
|
|
|
83
|
+
def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None:
|
|
84
|
+
from settings.invalidation import subscribe
|
|
85
|
+
|
|
86
|
+
self._refresh = subscribe(bus, app)
|
|
87
|
+
|
|
88
|
+
async def on_startup(self, app: FastAPI) -> None:
|
|
89
|
+
if hasattr(self, "_refresh"):
|
|
90
|
+
self._refresh.start()
|
|
91
|
+
|
|
92
|
+
async def on_shutdown(self, app: FastAPI) -> None:
|
|
93
|
+
if hasattr(self, "_refresh"):
|
|
94
|
+
await self._refresh.stop()
|
|
95
|
+
|
|
82
96
|
def register_exception_handlers(self, app: FastAPI) -> None:
|
|
83
97
|
from settings.errors import install_exception_handlers
|
|
84
98
|
|
{simple_module_settings-0.0.38 → simple_module_settings-0.0.40}/settings/pages/ModulesEdit.tsx
RENAMED
|
@@ -142,7 +142,11 @@ function ModulesEdit({ modules, testable = {} }: Props) {
|
|
|
142
142
|
</div>
|
|
143
143
|
</Card>
|
|
144
144
|
) : current ? (
|
|
145
|
-
<ModuleForm
|
|
145
|
+
<ModuleForm
|
|
146
|
+
key={current.package}
|
|
147
|
+
module={current}
|
|
148
|
+
checks={testable[current.package] ?? []}
|
|
149
|
+
/>
|
|
146
150
|
) : (
|
|
147
151
|
<p className="text-muted-foreground">{t(keys.settings.modules.empty_title)}</p>
|
|
148
152
|
)}
|
|
@@ -42,18 +42,23 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
|
|
|
42
42
|
return o;
|
|
43
43
|
}, [m.fields]);
|
|
44
44
|
|
|
45
|
-
const [values,
|
|
46
|
-
const [baseline, setBaseline] = useState<Record<string, unknown>>(initial);
|
|
45
|
+
const [{ values, baseline }, setBuffer] = useState({ values: initial, baseline: initial });
|
|
47
46
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
48
47
|
const [busy, setBusy] = useState(false);
|
|
49
48
|
const [saved, setSaved] = useState(false);
|
|
50
49
|
const [formErrors, setFormErrors] = useState<string[]>([]);
|
|
51
50
|
|
|
52
|
-
//
|
|
53
|
-
// switch, or server-reloaded props after a save/reset).
|
|
51
|
+
// Refresh saved values without discarding drafts in other fields (#637).
|
|
54
52
|
useEffect(() => {
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
setBuffer((prev) => ({
|
|
54
|
+
values: Object.fromEntries(
|
|
55
|
+
Object.entries(initial).map(([name, value]) => [
|
|
56
|
+
name,
|
|
57
|
+
notEqual(prev.values[name], prev.baseline[name]) ? prev.values[name] : value,
|
|
58
|
+
]),
|
|
59
|
+
),
|
|
60
|
+
baseline: initial,
|
|
61
|
+
}));
|
|
57
62
|
setErrors({});
|
|
58
63
|
setSaved(false);
|
|
59
64
|
}, [initial]);
|
|
@@ -107,7 +112,7 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
|
|
|
107
112
|
} else {
|
|
108
113
|
// Keeping the form mounted lets the confirmation remain visible instead
|
|
109
114
|
// of being discarded by an immediate Inertia reload.
|
|
110
|
-
|
|
115
|
+
setBuffer((prev) => ({ ...prev, baseline: { ...values } }));
|
|
111
116
|
setSaved(true);
|
|
112
117
|
}
|
|
113
118
|
setBusy(false);
|
|
@@ -121,6 +126,10 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
|
|
|
121
126
|
params: { path: { package: m.package, field: name } },
|
|
122
127
|
}),
|
|
123
128
|
);
|
|
129
|
+
setBuffer((prev) => ({
|
|
130
|
+
...prev,
|
|
131
|
+
values: { ...prev.values, [name]: prev.baseline[name] },
|
|
132
|
+
}));
|
|
124
133
|
router.reload({ only: ['modules'] });
|
|
125
134
|
}
|
|
126
135
|
|
|
@@ -187,7 +196,10 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
|
|
|
187
196
|
onReset={() => onReset(f.name)}
|
|
188
197
|
onChange={(name, v) => {
|
|
189
198
|
setSaved(false);
|
|
190
|
-
|
|
199
|
+
setBuffer((prev) => ({
|
|
200
|
+
...prev,
|
|
201
|
+
values: { ...prev.values, [name]: v },
|
|
202
|
+
}));
|
|
191
203
|
}}
|
|
192
204
|
/>
|
|
193
205
|
))}
|
|
@@ -3,7 +3,7 @@ import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
|
3
3
|
import { useRelativeTime } from '@simple-module-py/ui/hooks/use-relative-time';
|
|
4
4
|
import { apiFetch } from '@simple-module-py/ui/lib/api';
|
|
5
5
|
import { CheckCircle2, PlugZap, XCircle } from 'lucide-react';
|
|
6
|
-
import { useEffect, useState } from 'react';
|
|
6
|
+
import { useEffect, useRef, useState } from 'react';
|
|
7
7
|
import { ROUTES } from '../routes';
|
|
8
8
|
import { type LastTest, readLastTest, writeLastTest } from './last-test';
|
|
9
9
|
|
|
@@ -35,9 +35,19 @@ export function TestConnectionButton({ pkg, checks }: Props) {
|
|
|
35
35
|
const [error, setError] = useState<string | null>(null);
|
|
36
36
|
const [last, setLast] = useState<LastTest | null>(null);
|
|
37
37
|
|
|
38
|
+
const requestId = useRef(0);
|
|
39
|
+
|
|
38
40
|
// Read on mount rather than during render: the result is per-tab session
|
|
39
41
|
// state, and touching sessionStorage while rendering breaks SSR hydration.
|
|
40
|
-
useEffect(() =>
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
setLast(readLastTest(pkg));
|
|
44
|
+
setResults(null);
|
|
45
|
+
setError(null);
|
|
46
|
+
setBusy(false);
|
|
47
|
+
return () => {
|
|
48
|
+
requestId.current++;
|
|
49
|
+
};
|
|
50
|
+
}, [pkg]);
|
|
41
51
|
|
|
42
52
|
// "users.mailer" → "mailer". Naming the check is the difference between
|
|
43
53
|
// "Test connection" and knowing an SMTP dial is about to happen; with two
|
|
@@ -50,6 +60,7 @@ export function TestConnectionButton({ pkg, checks }: Props) {
|
|
|
50
60
|
: t(keys.settings.modules.test_connection_generic);
|
|
51
61
|
|
|
52
62
|
async function run() {
|
|
63
|
+
const id = ++requestId.current;
|
|
53
64
|
setBusy(true);
|
|
54
65
|
setError(null);
|
|
55
66
|
setResults(null);
|
|
@@ -57,8 +68,9 @@ export function TestConnectionButton({ pkg, checks }: Props) {
|
|
|
57
68
|
const result = await apiFetch<{ checks?: CheckResult[] }>(ROUTES.testConnection(pkg), {
|
|
58
69
|
method: 'POST',
|
|
59
70
|
});
|
|
60
|
-
if (
|
|
61
|
-
|
|
71
|
+
if (id !== requestId.current) return;
|
|
72
|
+
const checkResults = result.data?.checks;
|
|
73
|
+
if (result.ok && Array.isArray(checkResults) && checkResults.length > 0) {
|
|
62
74
|
setResults(checkResults);
|
|
63
75
|
setLast(
|
|
64
76
|
writeLastTest(
|
|
@@ -11,9 +11,13 @@ from pydantic_core import to_jsonable_python
|
|
|
11
11
|
from pydantic_settings import BaseSettings
|
|
12
12
|
from simple_module_core.events import EventBus
|
|
13
13
|
from simple_module_core.module_settings import module_settings_registry
|
|
14
|
-
from simple_module_core.settings_hydrate import
|
|
14
|
+
from simple_module_core.settings_hydrate import (
|
|
15
|
+
env_supplied_fields,
|
|
16
|
+
hydrate_settings,
|
|
17
|
+
value_type_for_field,
|
|
18
|
+
)
|
|
15
19
|
|
|
16
|
-
from settings.
|
|
20
|
+
from settings.invalidation import reload_on_commit
|
|
17
21
|
from settings.store import SettingsStore
|
|
18
22
|
|
|
19
23
|
|
|
@@ -87,7 +91,7 @@ async def apply_changes_and_reload(
|
|
|
87
91
|
package: str,
|
|
88
92
|
changes: dict[str, Any],
|
|
89
93
|
) -> BaseSettings:
|
|
90
|
-
"""Validate
|
|
94
|
+
"""Validate and persist; reload and publish ``SettingsReloaded`` after commit."""
|
|
91
95
|
registry = module_settings_registry(app)
|
|
92
96
|
cls = registry.get(package)
|
|
93
97
|
if cls is None:
|
|
@@ -97,8 +101,8 @@ async def apply_changes_and_reload(
|
|
|
97
101
|
if unknown:
|
|
98
102
|
raise KeyError(f"Unknown field(s) for {package!r}: {sorted(unknown)}")
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
current =
|
|
104
|
+
# Include prior writes in this transaction without exposing them to other requests.
|
|
105
|
+
current = await hydrate_settings(cls, store, package, lenient=True)
|
|
102
106
|
# Compare against the *dumped* current value, not the attribute: a field
|
|
103
107
|
# typed as a list of models (branding's ``footer_links``) holds model
|
|
104
108
|
# instances, while ``changes`` carries the plain dicts a DTO dumps to. The
|
|
@@ -118,6 +122,7 @@ async def apply_changes_and_reload(
|
|
|
118
122
|
await store.clear_override(package, name)
|
|
119
123
|
|
|
120
124
|
if not diff:
|
|
125
|
+
reload_on_commit(app, bus, store, package)
|
|
121
126
|
return current
|
|
122
127
|
|
|
123
128
|
# Only a field that would actually change is refused: a client that echoes
|
|
@@ -153,6 +158,5 @@ async def apply_changes_and_reload(
|
|
|
153
158
|
except ValidationError as exc:
|
|
154
159
|
raise _relocate(exc, field_name) from exc
|
|
155
160
|
|
|
156
|
-
|
|
157
|
-
await bus.publish(SettingsReloaded(package=package, changed=tuple(sorted(diff))))
|
|
161
|
+
reload_on_commit(app, bus, store, package)
|
|
158
162
|
return validated
|
|
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
|
|
|
7
7
|
from simple_module_db.stored_settings import SYSTEM_SCOPE_ID, VALUE_TYPE_STRING, Setting
|
|
8
8
|
from sqlalchemy import select
|
|
9
9
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
from sqlmodel import col
|
|
10
11
|
|
|
11
12
|
from settings._announce import announce
|
|
12
13
|
from settings._listing import SettingListing
|
|
@@ -204,9 +205,9 @@ class SettingService(SettingListing):
|
|
|
204
205
|
|
|
205
206
|
async def _find(self, scope: SettingScope, scope_id: str, key: str) -> Setting | None:
|
|
206
207
|
stmt = select(Setting).where(
|
|
207
|
-
Setting.scope == scope.value,
|
|
208
|
-
Setting.scope_id == scope_id,
|
|
209
|
-
Setting.key == key,
|
|
208
|
+
col(Setting.scope) == scope.value,
|
|
209
|
+
col(Setting.scope_id) == scope_id,
|
|
210
|
+
col(Setting.key) == key,
|
|
210
211
|
)
|
|
211
212
|
result = await self.db.execute(stmt)
|
|
212
213
|
return result.scalar_one_or_none()
|
|
@@ -18,6 +18,7 @@ see ``_module_settings._field_view``.
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
+
from simple_module_db.callbacks import OnCommitCallback
|
|
21
22
|
from simple_module_db.stored_settings import ( # noqa: F401 (re-exported, #500)
|
|
22
23
|
SYSTEM_SCOPE_ID,
|
|
23
24
|
get_overrides_sync,
|
|
@@ -41,6 +42,9 @@ class SettingsStore:
|
|
|
41
42
|
def __init__(self, service: SettingService) -> None:
|
|
42
43
|
self._service = service
|
|
43
44
|
|
|
45
|
+
def on_commit(self, callback: OnCommitCallback) -> None:
|
|
46
|
+
self._service.db.on_commit(callback)
|
|
47
|
+
|
|
44
48
|
async def get_overrides(self, package: str) -> dict[str, tuple[str, str]]:
|
|
45
49
|
"""Return ``{field_name: (raw_value, value_type)}`` for a package."""
|
|
46
50
|
items = await self._service.list_by_scope_unmasked(SettingScope.SYSTEM, SYSTEM_SCOPE_ID)
|
|
@@ -14,6 +14,7 @@ from settings.settings import SettingsSettings
|
|
|
14
14
|
from settings.store import SettingsStore
|
|
15
15
|
from simple_module_core.events import EventBus
|
|
16
16
|
from simple_module_core.module_settings import register_module_settings
|
|
17
|
+
from simple_module_db.transaction import finalize_session
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class _UsersCfg(BaseSettings):
|
|
@@ -56,7 +57,10 @@ async def test_apply_changes_updates_app_state_and_fires_event(app_and_bus, db_s
|
|
|
56
57
|
|
|
57
58
|
assert new_settings.allow_signup is True
|
|
58
59
|
assert new_settings.smtp_port == 587
|
|
59
|
-
assert app.state.users.settings is
|
|
60
|
+
assert app.state.users.settings.allow_signup is False
|
|
61
|
+
assert received == []
|
|
62
|
+
await finalize_session(db_session)
|
|
63
|
+
assert app.state.users.settings == new_settings
|
|
60
64
|
assert received == [SettingsReloaded(package="users", changed=("allow_signup", "smtp_port"))]
|
|
61
65
|
|
|
62
66
|
persisted = await store.get_overrides("users")
|
|
@@ -96,3 +100,15 @@ async def test_apply_changes_unknown_package_raises(app_and_bus, db_session):
|
|
|
96
100
|
package="unknown",
|
|
97
101
|
changes={"x": 1},
|
|
98
102
|
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def test_multiple_settings_edits_in_one_transaction_keep_both(app_and_bus, db_session):
|
|
106
|
+
app, bus = app_and_bus
|
|
107
|
+
store = SettingsStore(SettingService(db_session))
|
|
108
|
+
await apply_changes_and_reload(app, bus, store, package="users", changes={"allow_signup": True})
|
|
109
|
+
await apply_changes_and_reload(app, bus, store, package="users", changes={"smtp_port": 587})
|
|
110
|
+
assert app.state.users.settings.allow_signup is False
|
|
111
|
+
assert app.state.users.settings.smtp_port == 25
|
|
112
|
+
await finalize_session(db_session)
|
|
113
|
+
assert app.state.users.settings.allow_signup is True
|
|
114
|
+
assert app.state.users.settings.smtp_port == 587
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Module settings and reload events only reflect committed state."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from settings.contracts.events import SettingsReloaded
|
|
5
|
+
from settings.reload import apply_changes_and_reload
|
|
6
|
+
from settings.service import SettingService
|
|
7
|
+
from settings.store import SettingsStore
|
|
8
|
+
from simple_module_core.invalidation import Invalidation
|
|
9
|
+
from simple_module_db.transaction import finalize_session, rollback_session
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize("commit", [False, True])
|
|
13
|
+
async def test_settings_and_events_wait_for_commit(app, commit):
|
|
14
|
+
received = []
|
|
15
|
+
bus = app.state.sm.event_bus
|
|
16
|
+
|
|
17
|
+
async def record(event):
|
|
18
|
+
received.append(event)
|
|
19
|
+
|
|
20
|
+
bus.subscribe(SettingsReloaded, record)
|
|
21
|
+
original = app.state.users.settings
|
|
22
|
+
wanted = not original.allow_signup
|
|
23
|
+
async with app.state.sm.db.session_factory() as db:
|
|
24
|
+
await apply_changes_and_reload(
|
|
25
|
+
app,
|
|
26
|
+
bus,
|
|
27
|
+
SettingsStore(SettingService(db)),
|
|
28
|
+
package="users",
|
|
29
|
+
changes={"allow_signup": wanted},
|
|
30
|
+
)
|
|
31
|
+
assert app.state.users.settings is original
|
|
32
|
+
assert received == []
|
|
33
|
+
if commit:
|
|
34
|
+
await finalize_session(db)
|
|
35
|
+
else:
|
|
36
|
+
await rollback_session(db)
|
|
37
|
+
assert app.state.users.settings.allow_signup is (wanted if commit else original.allow_signup)
|
|
38
|
+
assert len(received) == int(commit)
|
|
39
|
+
if commit:
|
|
40
|
+
assert received[0].changed == ("allow_signup",)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def test_remote_settings_changes_and_clears_reload_and_emit_event(app):
|
|
44
|
+
received = []
|
|
45
|
+
|
|
46
|
+
async def record(event):
|
|
47
|
+
received.append(event)
|
|
48
|
+
|
|
49
|
+
app.state.sm.event_bus.subscribe(SettingsReloaded, record)
|
|
50
|
+
original = app.state.users.settings.allow_signup
|
|
51
|
+
factory = app.state.sm.db.session_factory
|
|
52
|
+
async with factory() as db:
|
|
53
|
+
await SettingsStore(SettingService(db)).set_override(
|
|
54
|
+
"users", "allow_signup", str(not original), "bool"
|
|
55
|
+
)
|
|
56
|
+
await db.commit()
|
|
57
|
+
message = Invalidation(channel="settings.modules", key="users", origin="other")
|
|
58
|
+
await app.state.sm.invalidation.deliver(message.to_wire())
|
|
59
|
+
assert app.state.users.settings.allow_signup is not original
|
|
60
|
+
assert received[-1].changed == ("allow_signup",)
|
|
61
|
+
async with factory() as db:
|
|
62
|
+
await SettingsStore(SettingService(db)).clear_override("users", "allow_signup")
|
|
63
|
+
await db.commit()
|
|
64
|
+
await app.state.sm.invalidation.deliver(message.to_wire())
|
|
65
|
+
assert app.state.users.settings.allow_signup is original
|
|
66
|
+
assert len(received) == 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.parametrize("commit", [False, True])
|
|
70
|
+
async def test_clearing_module_setting_waits_for_commit(app, commit):
|
|
71
|
+
from settings.endpoints.module_api import clear_module_field
|
|
72
|
+
from starlette.requests import Request
|
|
73
|
+
|
|
74
|
+
original = app.state.users.settings.allow_signup
|
|
75
|
+
factory = app.state.sm.db.session_factory
|
|
76
|
+
async with factory() as db:
|
|
77
|
+
await SettingsStore(SettingService(db)).set_override(
|
|
78
|
+
"users", "allow_signup", str(not original), "bool"
|
|
79
|
+
)
|
|
80
|
+
await db.commit()
|
|
81
|
+
await app.state.sm.invalidation.publish("settings.modules", key="users")
|
|
82
|
+
received = []
|
|
83
|
+
|
|
84
|
+
async def record(event):
|
|
85
|
+
received.append(event)
|
|
86
|
+
|
|
87
|
+
app.state.sm.event_bus.subscribe(SettingsReloaded, record)
|
|
88
|
+
async with factory() as db:
|
|
89
|
+
await clear_module_field(
|
|
90
|
+
"users", "allow_signup", Request({"type": "http", "app": app}), SettingService(db)
|
|
91
|
+
)
|
|
92
|
+
assert app.state.users.settings.allow_signup is not original
|
|
93
|
+
assert received == []
|
|
94
|
+
if commit:
|
|
95
|
+
await finalize_session(db)
|
|
96
|
+
else:
|
|
97
|
+
await rollback_session(db)
|
|
98
|
+
assert app.state.users.settings.allow_signup is (original if commit else not original)
|
|
99
|
+
assert len(received) == int(commit)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Settings business failures share a transport-independent base."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_settings_errors_share_a_module_base():
|
|
5
|
+
from settings._managed_keys import ManagedKeyError
|
|
6
|
+
from settings._unique_write import DuplicateSettingError
|
|
7
|
+
from settings.exceptions import SettingsError
|
|
8
|
+
|
|
9
|
+
assert isinstance(ManagedKeyError("key", "/clear"), SettingsError)
|
|
10
|
+
assert isinstance(DuplicateSettingError("key"), SettingsError)
|
|
@@ -25,6 +25,13 @@ class _SecretCfg(BaseSettings):
|
|
|
25
25
|
class _Store:
|
|
26
26
|
def __init__(self) -> None:
|
|
27
27
|
self.rows: dict[str, tuple[str, str]] = {}
|
|
28
|
+
self.callbacks = []
|
|
29
|
+
|
|
30
|
+
def on_commit(self, callback) -> None:
|
|
31
|
+
self.callbacks.append(callback)
|
|
32
|
+
|
|
33
|
+
async def get_overrides(self, package):
|
|
34
|
+
return dict(self.rows)
|
|
28
35
|
|
|
29
36
|
async def set_override(self, package, field, raw, value_type) -> None:
|
|
30
37
|
self.rows[field] = (raw, value_type)
|