python-ddd-framework 0.7.3__py3-none-any.whl → 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_ddd_framework/__init__.py +2 -2
- python_ddd_framework/configuration/root.py +2 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +6 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +1 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +6 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/backend/src/host/module.py.jinja +0 -4
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +6 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +62 -2
- python_ddd_framework/settings/__init__.py +3 -7
- python_ddd_framework/settings/application.py +53 -21
- python_ddd_framework/settings/binding.py +14 -0
- python_ddd_framework/settings/catalog.py +4 -59
- python_ddd_framework/settings/contracts.py +73 -2
- python_ddd_framework/settings/management.py +3 -13
- python_ddd_framework/settings/manager.py +17 -34
- python_ddd_framework/settings/module.py +21 -9
- python_ddd_framework/settings/provider.py +10 -3
- python_ddd_framework/settings/values.py +0 -8
- {python_ddd_framework-0.7.3.dist-info → python_ddd_framework-0.9.0.dist-info}/METADATA +27 -3
- {python_ddd_framework-0.7.3.dist-info → python_ddd_framework-0.9.0.dist-info}/RECORD +24 -31
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/setting_handlers/__init__.py.jinja +0 -1
- python_ddd_framework/settings/changes.py +0 -14
- python_ddd_framework/settings/declarations.py +0 -18
- python_ddd_framework/settings/handlers.py +0 -43
- python_ddd_framework/settings/notifications.py +0 -41
- python_ddd_framework/settings/refresh.py +0 -140
- python_ddd_framework/settings/refresh_module.py +0 -52
- {python_ddd_framework-0.7.3.dist-info → python_ddd_framework-0.9.0.dist-info}/WHEEL +0 -0
- {python_ddd_framework-0.7.3.dist-info → python_ddd_framework-0.9.0.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.7.3.dist-info → python_ddd_framework-0.9.0.dist-info}/licenses/LICENSE +0 -0
- {python_ddd_framework-0.7.3.dist-info → python_ddd_framework-0.9.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
python_ddd_framework/__init__.py
CHANGED
|
@@ -280,10 +280,10 @@ from .settings import (
|
|
|
280
280
|
SettingDefinitionContext,
|
|
281
281
|
SettingDefinitionError,
|
|
282
282
|
SettingDefinitionProvider,
|
|
283
|
+
SettingManagementAccess,
|
|
283
284
|
SettingManager,
|
|
284
285
|
SettingProvider,
|
|
285
286
|
)
|
|
286
|
-
from .settings.declarations import SettingHandlers
|
|
287
287
|
from .settings.module import SettingsModule
|
|
288
288
|
from .unit_of_work import (
|
|
289
289
|
UnitOfWork,
|
|
@@ -527,7 +527,7 @@ __all__ = (
|
|
|
527
527
|
"SettingDefinitionContext",
|
|
528
528
|
"SettingDefinitionError",
|
|
529
529
|
"SettingDefinitionProvider",
|
|
530
|
-
"
|
|
530
|
+
"SettingManagementAccess",
|
|
531
531
|
"SettingManager",
|
|
532
532
|
"SettingProvider",
|
|
533
533
|
"SettingsModule",
|
|
@@ -32,6 +32,12 @@ All responsibility directories and package markers are retained for navigation.
|
|
|
32
32
|
5. Conventional HTTP exposure can discover the new public service contracts. Add manual routers only where the transport requires them; keep use-case rules out of HTTP. Register workers, jobs, hosted services, schedules, or event handlers explicitly when required.
|
|
33
33
|
6. Update this responsibility record and run the smallest relevant existing checks. New tests and infrastructure validation require the task's authorization; neither an empty test directory nor successful composition proves business correctness.
|
|
34
34
|
|
|
35
|
+
## Global settings
|
|
36
|
+
|
|
37
|
+
When a use case requires a global setting, place its `SettingDefinition` and synchronous definition provider under `domain/settings`, add SettingsModule to the Domain Module dependencies, and include the package in discovery. Callers import the same definition and inject SettingProvider; authorized writes use SettingManager with the version read by the caller. Provider selection and database setup belong to the Host.
|
|
38
|
+
|
|
39
|
+
Follow the application's [Global settings recipe](../../../../docs/development.md#global-settings) for configuration fallback, management permissions, and update/reset requests. Record this module's actual setting names, business use, and effective timing here when implemented. The skeleton has no business settings or observers; reset clears an override, and the framework does not apply values to running components.
|
|
40
|
+
|
|
35
41
|
## Persistence and verification
|
|
36
42
|
|
|
37
43
|
An empty skeleton retains model and migration registration but has no business tables or revisions. Migration commands still use the configured database; an empty registered Base is different from a module with no model registration. Add actual models before generating a business revision, and review it before applying:
|
python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md
CHANGED
|
@@ -23,6 +23,7 @@ Use the [official CLI entry points](docs/development.md#cli-operations) for oper
|
|
|
23
23
|
## Preserve ownership and contracts
|
|
24
24
|
|
|
25
25
|
- The Host composes modules, selects providers, and supplies configuration. Keep business state and rules in their owning modules.
|
|
26
|
+
- Global Settings definitions belong to their Domain Module; services own when values are read and applied. Follow the project's [Settings contract](docs/architecture.md#global-settings-ownership) and [usage recipe](docs/development.md#global-settings), preserving source, authorization, and version rules. Do not assume a component-refresh or notification service exists.
|
|
26
27
|
- Domain and application contracts stay independent of Host, HTTP, and persistence implementations. Cross-module calls use explicitly declared dependencies and public contracts; do not access another module's implementation, ORM models, or tables directly.
|
|
27
28
|
- Use framework application services, repositories, Options, permissions, units of work, events, jobs, and lifecycle hooks before adding custom infrastructure. Do not introduce a parallel DI container, service locator, transaction manager, or wrapper without a missing business capability.
|
|
28
29
|
- Keep public contracts, mutable state, orchestration, and external I/O under clear owners. Split for independent responsibilities, not a fixed line count. Avoid generic `common`, `utils`, or `helpers` collections and speculative abstractions.
|
python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md
CHANGED
|
@@ -87,6 +87,12 @@ pddd inspect --environment development
|
|
|
87
87
|
|
|
88
88
|
Inspection composes and closes a new Application without starting it. It does not query an existing process, and its input snapshot is not the final Options view. Configuration, workers, jobs, real-time communication, and logging are covered in the [development guide](docs/development.md).
|
|
89
89
|
|
|
90
|
+
## Use global settings
|
|
91
|
+
|
|
92
|
+
The Host includes Settings persistence and management HTTP. After database setup, define each required setting in its owning module and grant management read/write permissions to the intended identity. Follow the self-contained [Global settings recipe](docs/development.md#global-settings) for definition discovery, YAML/environment fallback, service injection, and HTTP read/update/reset.
|
|
93
|
+
|
|
94
|
+
Settings stores global values; business code decides when they take effect. Reset clears a runtime override and resumes configuration/default fallback. Source restrictions, visibility, authorization, and version checks remain enforced. No business settings or automatic component-refresh workflow are generated.
|
|
95
|
+
|
|
90
96
|
## Test and package
|
|
91
97
|
|
|
92
98
|
```sh
|
|
@@ -21,8 +21,6 @@ from python_ddd_framework.observability.logging import StructuredLoggingModule
|
|
|
21
21
|
from python_ddd_framework.observability.tracing import TracingModule
|
|
22
22
|
from python_ddd_framework.redis import RedisDistributedLockModule, RedisModule
|
|
23
23
|
from python_ddd_framework.redis.notifications import RedisNotificationsModule
|
|
24
|
-
from python_ddd_framework.settings.notifications import SettingsNotificationsModule
|
|
25
|
-
from python_ddd_framework.settings.refresh_module import SettingsRefreshModule
|
|
26
24
|
from python_ddd_framework.settings.sqlalchemy import SqlAlchemyRuntimeSettingsModule
|
|
27
25
|
|
|
28
26
|
|
|
@@ -36,8 +34,6 @@ class {{ cookiecutter.class_prefix }}HostModule(AppModule):
|
|
|
36
34
|
RedisModule,
|
|
37
35
|
RedisDistributedLockModule,
|
|
38
36
|
RedisNotificationsModule,
|
|
39
|
-
SettingsNotificationsModule,
|
|
40
|
-
SettingsRefreshModule,
|
|
41
37
|
PgQueuerBackgroundJobsModule,
|
|
42
38
|
BackgroundManagementHttpApiModule,
|
|
43
39
|
StructuredLoggingModule,
|
|
@@ -83,6 +83,12 @@ These constraints apply when a capability models aggregates. The module README o
|
|
|
83
83
|
| Cross-aggregate consistency | Before implementing a write spanning aggregates, identify the participating owners, what commits atomically, and any later effects. Record concurrency, failure-after-commit, duplicate/retry, and idempotency behavior. A local UoW may coordinate multiple aggregates when that is the confirmed requirement; do not impose one aggregate per transaction mechanically. Cross-module collaboration still uses public contracts, and local events do not promise durable distributed delivery. |
|
|
84
84
|
| Business evidence | For each changed invariant, the module README must identify its rule owner, business operation, and a concrete rejection example, linking the relevant source and verification. Record unverified cases and open decisions explicitly. Directory placement, type checks, and passing unrelated tests do not by themselves prove a business rule. |
|
|
85
85
|
|
|
86
|
+
## Global settings ownership
|
|
87
|
+
|
|
88
|
+
Settings owns global definitions, reads, conditional writes, and clearing overrides. Domain Modules contribute definitions under `domain/settings`; the Host selects persistence and management HTTP. Business services decide when to read and apply values. Source precedence is runtime override, Host configuration, then definition default, filtered by the definition's allowed sources.
|
|
89
|
+
|
|
90
|
+
Management visibility and permissions govern the generic management service. Internal business services own their authorization; all writes still enforce definition ownership, value validation, and version checks. Settings commits independently of an outer business UoW, and reset retains a fresh version record. It does not own component refresh, notifications, reconciliation, retries, or application status. Follow the [usage recipe](development.md#global-settings) for concrete declarations and calls.
|
|
91
|
+
|
|
86
92
|
## Calls, scopes, and transactions
|
|
87
93
|
|
|
88
94
|
A managed service call passes through authorization, validation, ACTION scope, unit-of-work handling, and the final implementation. HTTP and direct service invocation share this pipeline. Inject the public service contract; do not construct implementations to bypass it. A self-call is an ordinary Python call and does not re-enter the pipeline.
|
|
@@ -128,7 +128,7 @@ For `basic`, keep public interfaces in `contracts/` and implementations in `serv
|
|
|
128
128
|
| `domain_shared` | `constants`, `enums`, `errors`, `permissions`, `messages`, `value_objects` |
|
|
129
129
|
| `domain` | `entities`, `value_objects`, `services`, `repositories`, `events`, `settings`, `seeding` |
|
|
130
130
|
| `application_contracts` | `services`, `integration_services`, `inputs`, `views` |
|
|
131
|
-
| `application` | `services`, `integration_services`, `background_jobs`, `background_workers`, `hosted_services`, `event_handlers`, `interceptors`, `caching`, `options
|
|
131
|
+
| `application` | `services`, `integration_services`, `background_jobs`, `background_workers`, `hosted_services`, `event_handlers`, `interceptors`, `caching`, `options` |
|
|
132
132
|
| `sqlalchemy` | `models`, `repositories`, `migrations` |
|
|
133
133
|
| `http_api` | `routers`, `filters`, `websockets`, `models` |
|
|
134
134
|
|
|
@@ -238,7 +238,67 @@ orders:
|
|
|
238
238
|
|
|
239
239
|
The corresponding environment key is `PDDD_ORDERS__ALLOW_BACKGROUND_APPROVAL`. `PDDD_HTTP__API_PREFIX` changes the business API prefix. `{{ cookiecutter.host_environment_variable }}` selects the Host environment and is separate from business configuration. Values that affect composition require the framework's pre-configuration phase; do not read unbound Options during construction.
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
### Global settings
|
|
242
|
+
|
|
243
|
+
Settings defines, reads, writes, and clears global runtime overrides. The following is an illustrative recipe, not a generated business feature. Add a definition and its provider under the owning module's `domain/settings/worker_settings.py` only when the setting is required:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
from python_ddd_framework.settings import (
|
|
247
|
+
SettingDefinition,
|
|
248
|
+
SettingDefinitionContext,
|
|
249
|
+
SettingDefinitionProvider,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
WORKER_LIMIT = SettingDefinition("worker.limit", int, default=10)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class WorkerSettingDefinitionProvider(SettingDefinitionProvider):
|
|
256
|
+
def define(self, context: SettingDefinitionContext) -> None:
|
|
257
|
+
context.add(WORKER_LIMIT)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Add `SettingsModule` from `python_ddd_framework` to the existing Domain Module's dependencies, and include its package in `scan_packages`; retain the other dependencies and lifecycle hooks. Import this same definition in callers instead of constructing another object with the same name. Store credentials in Host configuration, not Settings.
|
|
261
|
+
|
|
262
|
+
By default, values resolve from runtime override, Host configuration, then definition default. An optional Host value in `backend/app.development.yaml` looks like this:
|
|
263
|
+
|
|
264
|
+
```yaml
|
|
265
|
+
settings:
|
|
266
|
+
worker:
|
|
267
|
+
limit: 12
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The default environment key is `PDDD_SETTINGS__WORKER__LIMIT`. Without an override the value is 12, or 10 if that configuration is absent. Writing 20 makes it the override; reset resumes the configured 12. Configuration is frozen at startup: restart the Host after changing YAML/environment inputs. Reset does not reload files, and writing the default value still creates an override.
|
|
271
|
+
|
|
272
|
+
Inject `SettingProvider` for reads and `SettingManager` into an authorized service for writes. In an existing async service method, with `reader` and `manager` already injected:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
limit = await reader.get(WORKER_LIMIT)
|
|
276
|
+
current = await reader.snapshot(WORKER_LIMIT)
|
|
277
|
+
saved = await manager.update(WORKER_LIMIT, 20, expected_version=current.version)
|
|
278
|
+
# saved is SettingSnapshot[int]: name, value, source, version.
|
|
279
|
+
reset = await manager.reset(WORKER_LIMIT, expected_version=saved.version)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
This demonstrates an immediate write/reset sequence. Interactive editing must retain the version the operator actually read, rather than fetch a fresh token just before saving. A conflict requires re-reading and a caller decision, not an automatic retry. Application services can also use `self.setting_provider` during managed invocation, not construction. Storage errors propagate; if no allowed source has a value, `snapshot` reports UNSET and `get` fails.
|
|
283
|
+
|
|
284
|
+
The generated Host selects `SqlAlchemyRuntimeSettingsModule` and `SettingsManagementHttpApiModule`. A custom Host must select them when it needs persistent overrides and management HTTP. Configure its database and run `pddd db upgrade --module settings` as part of database setup; startup does not migrate. Without a store binding, reads can use configuration/defaults but Manager is not registered.
|
|
285
|
+
|
|
286
|
+
With the default API prefix, use these operations after authenticating and granting the management permissions:
|
|
287
|
+
|
|
288
|
+
| Operation | Request |
|
|
289
|
+
| --- | --- |
|
|
290
|
+
| List visible settings | `GET /api/settings` |
|
|
291
|
+
| Read a setting and its version | `GET /api/settings/worker.limit` |
|
|
292
|
+
| Save an override | `PUT /api/settings/worker.limit`, body `{"value": 20, "expected_version": "<version from read>"}` |
|
|
293
|
+
| Clear an override | `POST /api/settings/worker.limit/reset`, body `{"expected_version": "<latest version>"}` |
|
|
294
|
+
|
|
295
|
+
Pass JSON null for `expected_version` only when the read returned null. Reads and writes return `name`, `value`, `source`, and `version`; reset retains a new version token. HTTP 409 means a version conflict. Writes commit independently of the caller's business transaction, and a successful response confirms persistence only.
|
|
296
|
+
|
|
297
|
+
Definitions may restrict `allowed_sources` without changing their fixed order. Excluding CONFIGURATION rejects matching Host inputs, so remove the YAML/environment setting too. `management_access` defaults to READ_WRITE; READ_ONLY rejects generic writes/reset, while HIDDEN omits the setting from lists and returns not found for individual management operations. Reads require `settings.read`; writes/reset require both `settings.read` and `settings.write`. Optional `read_permission`/`write_permission` values add per-setting requirements: declare those permissions under `domain_shared/permissions`, scan their PermissionDefinitionProvider, and depend on AuthorizationModule. Declaring a permission does not grant it. Internal services own their authorization; Manager still enforces ownership, sources, types, and versions.
|
|
298
|
+
|
|
299
|
+
Business code decides when to read and apply values. Settings provides no component refresh, change notification, reconciliation, retry, or application status. Generic Notifications and HostedService remain independent capabilities.
|
|
300
|
+
|
|
301
|
+
### Inspect configuration
|
|
242
302
|
|
|
243
303
|
Inspect configuration sources and service registrations with:
|
|
244
304
|
|
|
@@ -1,30 +1,26 @@
|
|
|
1
1
|
"""Global Runtime Settings 的 typed contract 与 Application-local catalog。"""
|
|
2
2
|
|
|
3
3
|
from .catalog import SettingCatalog
|
|
4
|
-
from .contracts import SettingDefinition
|
|
4
|
+
from .contracts import SettingDefinition, SettingManagementAccess
|
|
5
5
|
from .definitions import SettingDefinitionContext, SettingDefinitionProvider
|
|
6
6
|
from .errors import RuntimeSettingError, SettingDefinitionError
|
|
7
|
-
from .handlers import SettingApplicationStatus, SettingHandler, SettingHandlerDefinition
|
|
8
7
|
from .manager import SettingManager
|
|
9
8
|
from .provider import SettingProvider
|
|
10
9
|
from .store import RuntimeSettingStore
|
|
11
|
-
from .values import SettingSnapshot, SettingValueSource,
|
|
10
|
+
from .values import SettingSnapshot, SettingValueSource, StoredSetting
|
|
12
11
|
|
|
13
12
|
__all__ = (
|
|
14
13
|
"RuntimeSettingError",
|
|
15
14
|
"RuntimeSettingStore",
|
|
16
|
-
"SettingApplicationStatus",
|
|
17
15
|
"SettingCatalog",
|
|
18
16
|
"SettingDefinition",
|
|
19
17
|
"SettingDefinitionContext",
|
|
20
18
|
"SettingDefinitionError",
|
|
21
19
|
"SettingDefinitionProvider",
|
|
22
|
-
"
|
|
23
|
-
"SettingHandlerDefinition",
|
|
20
|
+
"SettingManagementAccess",
|
|
24
21
|
"SettingManager",
|
|
25
22
|
"SettingProvider",
|
|
26
23
|
"SettingSnapshot",
|
|
27
24
|
"SettingValueSource",
|
|
28
|
-
"SettingWriteOutcome",
|
|
29
25
|
"StoredSetting",
|
|
30
26
|
)
|
|
@@ -5,22 +5,25 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from typing import Any, cast
|
|
7
7
|
|
|
8
|
+
from dishka import AsyncContainer
|
|
8
9
|
from pydantic import JsonValue
|
|
9
10
|
|
|
10
11
|
from ..application_services import ApplicationService, application_service
|
|
11
|
-
from ..authorization import authorize
|
|
12
|
+
from ..authorization import PermissionDeniedError, PermissionName, authorize
|
|
13
|
+
from ..authorization.checking import _check_authorization
|
|
14
|
+
from ..authorization.contracts import _AuthorizationRequirement
|
|
12
15
|
from ..configuration.immutability import _serialization_value
|
|
13
16
|
from ..errors import BusinessError, BusinessErrorDefinition, BusinessErrorField
|
|
14
17
|
from ..errors.base import ResourceNotFoundError
|
|
18
|
+
from ..invocation.runtime import _InvocationRuntime
|
|
15
19
|
from .catalog import SettingCatalog
|
|
16
|
-
from .contracts import SettingDefinition
|
|
20
|
+
from .contracts import SettingDefinition, SettingManagementAccess
|
|
17
21
|
from .management import (
|
|
18
22
|
SETTINGS_READ,
|
|
19
23
|
SETTINGS_WRITE,
|
|
20
24
|
ResetSettingCommand,
|
|
21
25
|
SettingsManagementApplicationService,
|
|
22
26
|
SettingView,
|
|
23
|
-
SettingWriteResult,
|
|
24
27
|
UpdateSettingCommand,
|
|
25
28
|
)
|
|
26
29
|
from .manager import SettingManager
|
|
@@ -32,27 +35,41 @@ _VALUE_FIELD = BusinessErrorField(UpdateSettingCommand, "value")
|
|
|
32
35
|
|
|
33
36
|
@application_service(provides=SettingsManagementApplicationService)
|
|
34
37
|
class _SettingsManagementApplicationService(ApplicationService):
|
|
35
|
-
def __init__(
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
catalog: SettingCatalog,
|
|
41
|
+
settings: SettingManager,
|
|
42
|
+
container: AsyncContainer,
|
|
43
|
+
runtime: _InvocationRuntime,
|
|
44
|
+
) -> None:
|
|
36
45
|
self._catalog = catalog
|
|
37
46
|
self._settings = settings
|
|
47
|
+
self._container = container
|
|
48
|
+
self._runtime = runtime
|
|
38
49
|
|
|
39
50
|
@authorize(SETTINGS_READ)
|
|
40
51
|
async def get_list(self) -> tuple[SettingView, ...]:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
result: list[SettingView] = []
|
|
53
|
+
for definition in self._catalog.definitions:
|
|
54
|
+
if definition.management_access is SettingManagementAccess.HIDDEN:
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
await self._check_permissions(definition.read_permission)
|
|
58
|
+
except PermissionDeniedError:
|
|
59
|
+
continue
|
|
60
|
+
result.append(_view(definition, await self.setting_provider.snapshot(definition)))
|
|
61
|
+
return tuple(result)
|
|
47
62
|
|
|
48
63
|
@authorize(SETTINGS_READ)
|
|
49
64
|
async def get(self, name: str) -> SettingView:
|
|
50
65
|
definition = self._definition(name)
|
|
66
|
+
await self._check_permissions(definition.read_permission)
|
|
51
67
|
return _view(definition, await self.setting_provider.snapshot(definition))
|
|
52
68
|
|
|
53
|
-
@authorize(SETTINGS_WRITE)
|
|
54
|
-
async def update(self, name: str, command: UpdateSettingCommand) ->
|
|
69
|
+
@authorize(SETTINGS_READ, SETTINGS_WRITE)
|
|
70
|
+
async def update(self, name: str, command: UpdateSettingCommand) -> SettingView:
|
|
55
71
|
definition = self._definition(name)
|
|
72
|
+
await self._check_write(definition)
|
|
56
73
|
try:
|
|
57
74
|
# JSON 数组/日期须由 Pydantic 的 JSON 模式解释,不能误用 strict Python 输入语义。
|
|
58
75
|
value = definition._validate_json(json.dumps(command.value, allow_nan=False))
|
|
@@ -61,24 +78,39 @@ class _SettingsManagementApplicationService(ApplicationService):
|
|
|
61
78
|
saved = await self._settings.update(
|
|
62
79
|
definition, value, expected_version=command.expected_version
|
|
63
80
|
)
|
|
64
|
-
return
|
|
65
|
-
setting=_view(definition, saved.snapshot), notification=saved.notification
|
|
66
|
-
)
|
|
81
|
+
return _view(definition, saved)
|
|
67
82
|
|
|
68
|
-
@authorize(SETTINGS_WRITE)
|
|
69
|
-
async def reset(self, name: str, command: ResetSettingCommand) ->
|
|
83
|
+
@authorize(SETTINGS_READ, SETTINGS_WRITE)
|
|
84
|
+
async def reset(self, name: str, command: ResetSettingCommand) -> SettingView:
|
|
70
85
|
definition = self._definition(name)
|
|
86
|
+
await self._check_write(definition)
|
|
71
87
|
saved = await self._settings.reset(definition, expected_version=command.expected_version)
|
|
72
|
-
return
|
|
73
|
-
setting=_view(definition, saved.snapshot), notification=saved.notification
|
|
74
|
-
)
|
|
88
|
+
return _view(definition, saved)
|
|
75
89
|
|
|
76
90
|
def _definition(self, name: str) -> SettingDefinition[Any]:
|
|
77
91
|
definition = self._catalog.get(name)
|
|
78
|
-
if definition is None:
|
|
92
|
+
if definition is None or definition.management_access is SettingManagementAccess.HIDDEN:
|
|
79
93
|
raise ResourceNotFoundError("Setting not found")
|
|
80
94
|
return definition
|
|
81
95
|
|
|
96
|
+
async def _check_write(self, definition: SettingDefinition[Any]) -> None:
|
|
97
|
+
if (
|
|
98
|
+
definition.management_access is not SettingManagementAccess.READ_WRITE
|
|
99
|
+
or SettingValueSource.RUNTIME not in definition.allowed_sources
|
|
100
|
+
):
|
|
101
|
+
raise PermissionDeniedError(permission=str(SETTINGS_WRITE))
|
|
102
|
+
# 写入响应包含完整快照;读权限须在校验设置值及执行保存前成立。
|
|
103
|
+
await self._check_permissions(definition.read_permission, definition.write_permission)
|
|
104
|
+
|
|
105
|
+
async def _check_permissions(self, *permissions: PermissionName | None) -> None:
|
|
106
|
+
required = tuple(dict.fromkeys(item for item in permissions if item is not None))
|
|
107
|
+
if required:
|
|
108
|
+
await _check_authorization(
|
|
109
|
+
self._container,
|
|
110
|
+
self._runtime.current_user,
|
|
111
|
+
_AuthorizationRequirement(True, required, False),
|
|
112
|
+
)
|
|
113
|
+
|
|
82
114
|
|
|
83
115
|
def _view(definition: SettingDefinition[Any], snapshot: SettingSnapshot[Any]) -> SettingView:
|
|
84
116
|
# HTTP DTO 是独立 JSON 副本;不会把内部不可变快照的容器直接交给调用方。
|
|
@@ -6,9 +6,11 @@ from dishka import Provider, Scope
|
|
|
6
6
|
|
|
7
7
|
from ..configuration import ConfigurationRoot
|
|
8
8
|
from .catalog import SettingCatalog
|
|
9
|
+
from .errors import SettingDefinitionError
|
|
9
10
|
from .manager import SettingManager
|
|
10
11
|
from .provider import SettingProvider
|
|
11
12
|
from .store import RuntimeSettingStore
|
|
13
|
+
from .values import SettingValueSource
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
def _provide_settings(
|
|
@@ -18,6 +20,18 @@ def _provide_settings(
|
|
|
18
20
|
*,
|
|
19
21
|
has_store: bool,
|
|
20
22
|
) -> None:
|
|
23
|
+
# 检查冻结的输入层,不能让存储覆盖或后续配置遮住被禁止的配置贡献。
|
|
24
|
+
for definition in catalog.definitions:
|
|
25
|
+
path = f"settings.{definition.name}"
|
|
26
|
+
if (
|
|
27
|
+
SettingValueSource.CONFIGURATION not in definition.allowed_sources
|
|
28
|
+
and configuration._section_inputs(path)
|
|
29
|
+
):
|
|
30
|
+
raise SettingDefinitionError(
|
|
31
|
+
setting=definition.name,
|
|
32
|
+
reason=f"configuration source is not allowed at {path}",
|
|
33
|
+
source=catalog.sources.get(definition.name),
|
|
34
|
+
)
|
|
21
35
|
if has_store:
|
|
22
36
|
|
|
23
37
|
def reader(store: RuntimeSettingStore) -> SettingProvider:
|
|
@@ -4,28 +4,21 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import Iterator, Mapping
|
|
6
6
|
from types import MappingProxyType
|
|
7
|
-
from typing import
|
|
7
|
+
from typing import Any
|
|
8
8
|
|
|
9
9
|
from ..diagnostics import SourceLocation
|
|
10
|
-
from ..modularity import ModuleRegistry
|
|
11
10
|
from ..modularity.discovery import _DiscoveredType
|
|
12
11
|
from .contracts import SettingDefinition
|
|
13
|
-
from .declarations import SettingHandlers
|
|
14
12
|
from .definition_discovery import _collect_setting_definitions
|
|
15
13
|
from .errors import SettingDefinitionError
|
|
16
|
-
from .handlers import SettingHandlerDefinition
|
|
17
|
-
|
|
18
|
-
if TYPE_CHECKING:
|
|
19
|
-
from ..services.contribution import ServiceContributionRegistry
|
|
20
14
|
|
|
21
15
|
|
|
22
16
|
class SettingCatalog:
|
|
23
|
-
__slots__ = ("_definitions", "
|
|
17
|
+
__slots__ = ("_definitions", "_sources")
|
|
24
18
|
|
|
25
19
|
def __init__(
|
|
26
20
|
self,
|
|
27
21
|
definitions: tuple[SettingDefinition[Any], ...],
|
|
28
|
-
handlers: tuple[SettingHandlerDefinition[Any], ...] = (),
|
|
29
22
|
*,
|
|
30
23
|
sources: Mapping[str, SourceLocation] | None = None,
|
|
31
24
|
) -> None:
|
|
@@ -45,26 +38,12 @@ class SettingCatalog:
|
|
|
45
38
|
self._definitions: Mapping[str, SettingDefinition[Any]] = MappingProxyType(
|
|
46
39
|
dict(sorted(indexed.items()))
|
|
47
40
|
)
|
|
48
|
-
seen: set[tuple[str, type[object]]] = set()
|
|
49
|
-
for handler in handlers:
|
|
50
|
-
if not isinstance(handler, SettingHandlerDefinition):
|
|
51
|
-
raise SettingDefinitionError(setting="handler", reason="invalid handler definition")
|
|
52
|
-
self.require_owned(handler.setting)
|
|
53
|
-
key = (handler.setting.name, handler.handler_type)
|
|
54
|
-
if key in seen:
|
|
55
|
-
raise SettingDefinitionError(setting=key[0], reason="handler is declared twice")
|
|
56
|
-
seen.add(key)
|
|
57
|
-
self._handlers = handlers
|
|
58
41
|
self._sources = MappingProxyType(dict(sources or {}))
|
|
59
42
|
|
|
60
43
|
@property
|
|
61
44
|
def sources(self) -> Mapping[str, SourceLocation]:
|
|
62
45
|
return self._sources
|
|
63
46
|
|
|
64
|
-
@property
|
|
65
|
-
def handlers(self) -> tuple[SettingHandlerDefinition[Any], ...]:
|
|
66
|
-
return self._handlers
|
|
67
|
-
|
|
68
47
|
@property
|
|
69
48
|
def definitions(self) -> tuple[SettingDefinition[Any], ...]:
|
|
70
49
|
return tuple(self._definitions.values())
|
|
@@ -83,40 +62,6 @@ class SettingCatalog:
|
|
|
83
62
|
)
|
|
84
63
|
|
|
85
64
|
|
|
86
|
-
def _build_setting_catalog(
|
|
87
|
-
registry: ModuleRegistry, discoveries: tuple[_DiscoveredType, ...]
|
|
88
|
-
) -> SettingCatalog:
|
|
65
|
+
def _build_setting_catalog(discoveries: tuple[_DiscoveredType, ...]) -> SettingCatalog:
|
|
89
66
|
definitions, sources = _collect_setting_definitions(discoveries)
|
|
90
|
-
|
|
91
|
-
for descriptor, declaration in registry.declarations(SettingHandlers):
|
|
92
|
-
declared_handlers = declaration.definitions
|
|
93
|
-
if not isinstance(declared_handlers, tuple):
|
|
94
|
-
raise SettingDefinitionError(
|
|
95
|
-
setting=str(descriptor.key), reason="setting_handler_definitions must be a tuple"
|
|
96
|
-
)
|
|
97
|
-
handlers.extend(declared_handlers)
|
|
98
|
-
return SettingCatalog(definitions, tuple(handlers), sources=sources)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _contribute_setting_handler_defaults(
|
|
102
|
-
registry: ModuleRegistry,
|
|
103
|
-
contributions: ServiceContributionRegistry,
|
|
104
|
-
) -> None:
|
|
105
|
-
from dishka import Provider, Scope
|
|
106
|
-
|
|
107
|
-
# 声明只拥有 handler 与 setting 的关系;实例和依赖仍交给原生 Dishka APP binding。
|
|
108
|
-
registered: set[type[object]] = set()
|
|
109
|
-
for descriptor, declaration in registry.declarations(SettingHandlers):
|
|
110
|
-
for definition in declaration.definitions:
|
|
111
|
-
handler_type = definition.handler_type
|
|
112
|
-
if handler_type in registered:
|
|
113
|
-
continue
|
|
114
|
-
registered.add(handler_type)
|
|
115
|
-
provider = Provider()
|
|
116
|
-
provider.provide(handler_type, scope=Scope.APP)
|
|
117
|
-
contributions._add_framework_default(
|
|
118
|
-
provider=provider,
|
|
119
|
-
owner=descriptor.key,
|
|
120
|
-
source=descriptor.declaration_source,
|
|
121
|
-
reason=f"Settings handler default for {definition.setting.name}",
|
|
122
|
-
)
|
|
67
|
+
return SettingCatalog(definitions, sources=sources)
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from enum import Enum
|
|
5
6
|
from typing import Generic, TypeVar, cast, get_args, get_origin
|
|
6
7
|
|
|
7
8
|
from pydantic import BaseModel, Secret, SecretBytes, SecretStr, TypeAdapter, ValidationError
|
|
8
9
|
|
|
10
|
+
from ..authorization import PermissionName
|
|
9
11
|
from ..configuration.immutability import (
|
|
10
12
|
_serialization_value,
|
|
11
13
|
freeze_typed_value,
|
|
@@ -13,16 +15,65 @@ from ..configuration.immutability import (
|
|
|
13
15
|
)
|
|
14
16
|
from ..configuration.values import _looks_secret_name, canonical_path
|
|
15
17
|
from .errors import RuntimeSettingError, SettingDefinitionError
|
|
18
|
+
from .values import SettingValueSource
|
|
16
19
|
|
|
17
20
|
_T = TypeVar("_T")
|
|
18
21
|
_MISSING = object()
|
|
22
|
+
_ALL_VALUE_SOURCES = frozenset(
|
|
23
|
+
source for source in SettingValueSource if source is not SettingValueSource.UNSET
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SettingManagementAccess(str, Enum):
|
|
28
|
+
"""只限制通用管理入口;内部读写仍遵守设置自身的来源与值契约。"""
|
|
29
|
+
|
|
30
|
+
HIDDEN = "hidden"
|
|
31
|
+
READ_ONLY = "read_only"
|
|
32
|
+
READ_WRITE = "read_write"
|
|
19
33
|
|
|
20
34
|
|
|
21
35
|
class SettingDefinition(Generic[_T]):
|
|
22
|
-
__slots__ = (
|
|
36
|
+
__slots__ = (
|
|
37
|
+
"_adapter",
|
|
38
|
+
"_allowed_sources",
|
|
39
|
+
"_default",
|
|
40
|
+
"_management_access",
|
|
41
|
+
"_name",
|
|
42
|
+
"_read_permission",
|
|
43
|
+
"_value_type",
|
|
44
|
+
"_write_permission",
|
|
45
|
+
)
|
|
23
46
|
|
|
24
|
-
def __init__(
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
name: str,
|
|
50
|
+
value_type: type[_T],
|
|
51
|
+
*,
|
|
52
|
+
default: object = _MISSING,
|
|
53
|
+
allowed_sources: frozenset[SettingValueSource] = _ALL_VALUE_SOURCES,
|
|
54
|
+
management_access: SettingManagementAccess = SettingManagementAccess.READ_WRITE,
|
|
55
|
+
read_permission: PermissionName | None = None,
|
|
56
|
+
write_permission: PermissionName | None = None,
|
|
57
|
+
) -> None:
|
|
25
58
|
_validate_setting_name(name)
|
|
59
|
+
if (
|
|
60
|
+
not isinstance(allowed_sources, frozenset)
|
|
61
|
+
or not allowed_sources
|
|
62
|
+
or any(
|
|
63
|
+
not isinstance(source, SettingValueSource) or source is SettingValueSource.UNSET
|
|
64
|
+
for source in allowed_sources
|
|
65
|
+
)
|
|
66
|
+
):
|
|
67
|
+
raise SettingDefinitionError(
|
|
68
|
+
setting=name, reason="allowed_sources must contain value sources, not UNSET"
|
|
69
|
+
)
|
|
70
|
+
if not isinstance(management_access, SettingManagementAccess):
|
|
71
|
+
raise SettingDefinitionError(setting=name, reason="invalid management_access")
|
|
72
|
+
for permission in (read_permission, write_permission):
|
|
73
|
+
if permission is not None and not isinstance(permission, PermissionName):
|
|
74
|
+
raise SettingDefinitionError(
|
|
75
|
+
setting=name, reason="permissions must be PermissionName values"
|
|
76
|
+
)
|
|
26
77
|
if not isinstance(value_type, type):
|
|
27
78
|
raise SettingDefinitionError(setting=name, reason="value_type must be a runtime type")
|
|
28
79
|
if _contains_secret_type(value_type):
|
|
@@ -46,6 +97,10 @@ class SettingDefinition(Generic[_T]):
|
|
|
46
97
|
self._value_type = value_type
|
|
47
98
|
self._adapter = adapter
|
|
48
99
|
self._default = validated_default
|
|
100
|
+
self._allowed_sources = allowed_sources
|
|
101
|
+
self._management_access = management_access
|
|
102
|
+
self._read_permission = read_permission
|
|
103
|
+
self._write_permission = write_permission
|
|
49
104
|
|
|
50
105
|
@property
|
|
51
106
|
def name(self) -> str:
|
|
@@ -59,6 +114,22 @@ class SettingDefinition(Generic[_T]):
|
|
|
59
114
|
def has_default(self) -> bool:
|
|
60
115
|
return self._default is not _MISSING
|
|
61
116
|
|
|
117
|
+
@property
|
|
118
|
+
def allowed_sources(self) -> frozenset[SettingValueSource]:
|
|
119
|
+
return self._allowed_sources
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def management_access(self) -> SettingManagementAccess:
|
|
123
|
+
return self._management_access
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def read_permission(self) -> PermissionName | None:
|
|
127
|
+
return self._read_permission
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def write_permission(self) -> PermissionName | None:
|
|
131
|
+
return self._write_permission
|
|
132
|
+
|
|
62
133
|
def _validate_python(self, value: object) -> _T:
|
|
63
134
|
return cast(
|
|
64
135
|
_T, freeze_typed_value(self._adapter.validate_python(value), allow_secrets=False)
|
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import
|
|
5
|
+
from typing import Protocol
|
|
6
6
|
from uuid import UUID
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel, ConfigDict, JsonValue
|
|
9
9
|
|
|
10
10
|
from ..application_services import ApplicationServiceContract
|
|
11
11
|
from ..authorization import PermissionName
|
|
12
|
-
from ..notifications import NotificationPublishStatus
|
|
13
12
|
from .values import SettingValueSource
|
|
14
13
|
|
|
15
14
|
SETTINGS_READ = PermissionName("settings.read")
|
|
@@ -36,15 +35,6 @@ class SettingView(BaseModel):
|
|
|
36
35
|
value: JsonValue
|
|
37
36
|
source: SettingValueSource
|
|
38
37
|
version: UUID | None
|
|
39
|
-
effect: str = "New operations read the saved value; running components apply it separately."
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class SettingWriteResult(BaseModel):
|
|
43
|
-
model_config = ConfigDict(frozen=True)
|
|
44
|
-
|
|
45
|
-
saved: Literal[True] = True
|
|
46
|
-
setting: SettingView
|
|
47
|
-
notification: NotificationPublishStatus
|
|
48
38
|
|
|
49
39
|
|
|
50
40
|
class SettingsManagementApplicationService(ApplicationServiceContract, Protocol):
|
|
@@ -52,6 +42,6 @@ class SettingsManagementApplicationService(ApplicationServiceContract, Protocol)
|
|
|
52
42
|
|
|
53
43
|
async def get(self, name: str) -> SettingView: ...
|
|
54
44
|
|
|
55
|
-
async def update(self, name: str, command: UpdateSettingCommand) ->
|
|
45
|
+
async def update(self, name: str, command: UpdateSettingCommand) -> SettingView: ...
|
|
56
46
|
|
|
57
|
-
async def reset(self, name: str, command: ResetSettingCommand) ->
|
|
47
|
+
async def reset(self, name: str, command: ResetSettingCommand) -> SettingView: ...
|