python-platform 0.1.1__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_platform/__init__.py +445 -0
- python_platform/application/__init__.py +6 -0
- python_platform/application/build_spec.py +37 -0
- python_platform/application/builder.py +260 -0
- python_platform/application/composition.py +219 -0
- python_platform/application/runtime.py +546 -0
- python_platform/application/runtime_hooks.py +327 -0
- python_platform/application/state.py +216 -0
- python_platform/application_services/__init__.py +54 -0
- python_platform/application_services/catalog.py +500 -0
- python_platform/application_services/contracts.py +188 -0
- python_platform/application_services/dispatcher.py +423 -0
- python_platform/application_services/errors.py +89 -0
- python_platform/application_services/execution.py +199 -0
- python_platform/application_services/interceptors.py +44 -0
- python_platform/application_services/invocation.py +427 -0
- python_platform/application_services/policies.py +46 -0
- python_platform/application_services/seeding.py +72 -0
- python_platform/application_services/signature.py +49 -0
- python_platform/application_services/validation.py +84 -0
- python_platform/auditing/__init__.py +12 -0
- python_platform/auditing/contracts.py +39 -0
- python_platform/auditing/control.py +48 -0
- python_platform/auditing/sqlalchemy/__init__.py +6 -0
- python_platform/auditing/sqlalchemy/migrations/0001_platform_auditing.py +45 -0
- python_platform/auditing/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/auditing/sqlalchemy/models.py +31 -0
- python_platform/auditing/sqlalchemy/module.py +32 -0
- python_platform/auditing/sqlalchemy/store.py +63 -0
- python_platform/authorization/__init__.py +38 -0
- python_platform/authorization/catalog.py +63 -0
- python_platform/authorization/contracts.py +241 -0
- python_platform/authorization/definition_discovery.py +81 -0
- python_platform/authorization/definitions.py +43 -0
- python_platform/authorization/errors.py +40 -0
- python_platform/background_execution/__init__.py +15 -0
- python_platform/background_execution/application.py +41 -0
- python_platform/background_execution/child.py +123 -0
- python_platform/background_execution/contracts.py +39 -0
- python_platform/background_execution/lifecycle.py +22 -0
- python_platform/background_execution/local.py +127 -0
- python_platform/background_execution/locks.py +31 -0
- python_platform/background_execution/management.py +17 -0
- python_platform/background_execution/module.py +10 -0
- python_platform/background_execution/permissions.py +14 -0
- python_platform/background_execution/processes.py +331 -0
- python_platform/background_jobs/__init__.py +31 -0
- python_platform/background_jobs/catalog.py +287 -0
- python_platform/background_jobs/contracts.py +202 -0
- python_platform/background_jobs/errors.py +28 -0
- python_platform/background_jobs/execution.py +201 -0
- python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
- python_platform/background_jobs/pgqueuer/__init__.py +6 -0
- python_platform/background_jobs/pgqueuer/enqueue.py +82 -0
- python_platform/background_jobs/pgqueuer/migrations/0001_pgqueuer_1_3_2.py +63 -0
- python_platform/background_jobs/pgqueuer/migrations/__init__.py +1 -0
- python_platform/background_jobs/pgqueuer/module.py +102 -0
- python_platform/background_jobs/pgqueuer/options.py +70 -0
- python_platform/background_jobs/pgqueuer/runtime.py +479 -0
- python_platform/background_jobs/pgqueuer/sql/pgqueuer_1_3_2_install.sql +118 -0
- python_platform/background_jobs/pgqueuer/supervision.py +201 -0
- python_platform/background_workers/__init__.py +22 -0
- python_platform/background_workers/catalog.py +96 -0
- python_platform/background_workers/contracts.py +124 -0
- python_platform/background_workers/errors.py +20 -0
- python_platform/background_workers/execution.py +133 -0
- python_platform/background_workers/runtime.py +203 -0
- python_platform/caching/__init__.py +11 -0
- python_platform/caching/catalog.py +73 -0
- python_platform/caching/contracts.py +73 -0
- python_platform/caching/errors.py +20 -0
- python_platform/cli/__init__.py +87 -0
- python_platform/cli/errors.py +5 -0
- python_platform/cli/project.py +69 -0
- python_platform/cli/runtime.py +68 -0
- python_platform/configuration/__init__.py +21 -0
- python_platform/configuration/composition.py +65 -0
- python_platform/configuration/contracts.py +68 -0
- python_platform/configuration/dotenv_source.py +51 -0
- python_platform/configuration/environment_source.py +38 -0
- python_platform/configuration/immutability.py +124 -0
- python_platform/configuration/input_shape.py +58 -0
- python_platform/configuration/merge.py +103 -0
- python_platform/configuration/root.py +215 -0
- python_platform/configuration/sources.py +519 -0
- python_platform/configuration/values.py +427 -0
- python_platform/configuration/yaml_source.py +51 -0
- python_platform/developer_kit/__init__.py +1 -0
- python_platform/developer_kit/generation.py +182 -0
- python_platform/developer_kit/project_metadata.py +45 -0
- python_platform/developer_kit/source.py +53 -0
- python_platform/developer_kit/templates/module/cookiecutter.json +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/cache.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/events.py.jinja +24 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration.py.jinja +46 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +29 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options.py.jinja +8 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/orders.py.jinja +81 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/tasks.py.jinja +86 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/module.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/orders.py.jinja +36 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/orders.py.jinja +57 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repository.py.jinja +12 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding.py.jinja +19 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings.py.jinja +16 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/definitions.py.jinja +24 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +7 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions.py.jinja +15 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/files.py.jinja +74 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +58 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/realtime.py.jinja +37 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/migrations/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models.py.jinja +26 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +32 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repository.py.jinja +49 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +18 -0
- python_platform/developer_kit/templates/project/cookiecutter.json +6 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.dockerignore +8 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.env.jinja +13 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.gitignore +6 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.python-version +1 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/Dockerfile +22 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +86 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/app.yaml.jinja +16 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.dev.yaml.jinja +24 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.production.yaml.jinja +13 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/pyproject.toml.jinja +28 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/main.py.jinja +52 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/module.py.jinja +38 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/conftest.py.jinja +8 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/host/test_http.py.jinja +101 -0
- python_platform/developer_kit/wiring.py +85 -0
- python_platform/diagnostics/__init__.py +23 -0
- python_platform/diagnostics/journal.py +174 -0
- python_platform/diagnostics/model.py +83 -0
- python_platform/diagnostics/source.py +25 -0
- python_platform/distributed_lock/__init__.py +6 -0
- python_platform/distributed_lock/contracts.py +20 -0
- python_platform/distributed_lock/options.py +19 -0
- python_platform/domain/__init__.py +13 -0
- python_platform/domain/aggregates.py +64 -0
- python_platform/domain/entities.py +33 -0
- python_platform/domain/errors.py +12 -0
- python_platform/domain/value_objects.py +31 -0
- python_platform/errors/__init__.py +75 -0
- python_platform/errors/base.py +13 -0
- python_platform/errors/business.py +53 -0
- python_platform/errors/configuration.py +119 -0
- python_platform/errors/diagnostics.py +12 -0
- python_platform/errors/lifecycle.py +164 -0
- python_platform/errors/modularity.py +100 -0
- python_platform/errors/services.py +100 -0
- python_platform/events/__init__.py +25 -0
- python_platform/events/aggregate.py +39 -0
- python_platform/events/catalog.py +136 -0
- python_platform/events/contracts.py +39 -0
- python_platform/events/contribution.py +128 -0
- python_platform/events/discovery.py +105 -0
- python_platform/events/errors.py +48 -0
- python_platform/events/runtime.py +192 -0
- python_platform/fastapi/__init__.py +49 -0
- python_platform/fastapi/action.py +175 -0
- python_platform/fastapi/adapter.py +381 -0
- python_platform/fastapi/application_services.py +647 -0
- python_platform/fastapi/background.py +42 -0
- python_platform/fastapi/contracts.py +278 -0
- python_platform/fastapi/errors.py +32 -0
- python_platform/fastapi/filters.py +111 -0
- python_platform/fastapi/health.py +44 -0
- python_platform/fastapi/http.py +55 -0
- python_platform/fastapi/http_router.py +212 -0
- python_platform/fastapi/manual_action.py +105 -0
- python_platform/fastapi/middleware.py +93 -0
- python_platform/fastapi/parameters.py +97 -0
- python_platform/fastapi/realtime/__init__.py +13 -0
- python_platform/fastapi/realtime/authentication.py +181 -0
- python_platform/fastapi/realtime/connection.py +166 -0
- python_platform/fastapi/realtime/module.py +41 -0
- python_platform/fastapi/realtime/options.py +50 -0
- python_platform/fastapi/realtime/runtime.py +423 -0
- python_platform/fastapi/request_context.py +244 -0
- python_platform/fastapi/route_integrity.py +88 -0
- python_platform/fastapi/routing.py +410 -0
- python_platform/fastapi/server.py +181 -0
- python_platform/fastapi/settings.py +40 -0
- python_platform/fastapi/tracing.py +62 -0
- python_platform/fastapi/transfer.py +128 -0
- python_platform/fastapi/upload_limits.py +63 -0
- python_platform/fastapi/uploads.py +94 -0
- python_platform/hosted_services/__init__.py +29 -0
- python_platform/hosted_services/bridge.py +420 -0
- python_platform/hosted_services/catalog.py +259 -0
- python_platform/hosted_services/contracts.py +55 -0
- python_platform/hosted_services/errors.py +53 -0
- python_platform/hosted_services/options.py +17 -0
- python_platform/hosted_services/runtime.py +267 -0
- python_platform/hosted_services/state.py +40 -0
- python_platform/hosting/__init__.py +4 -0
- python_platform/hosting/instance.py +40 -0
- python_platform/identity/__init__.py +73 -0
- python_platform/identity/application.py +287 -0
- python_platform/identity/contracts.py +283 -0
- python_platform/identity/errors.py +28 -0
- python_platform/identity/http_api.py +90 -0
- python_platform/identity/module.py +46 -0
- python_platform/identity/passwords.py +34 -0
- python_platform/identity/permissions.py +14 -0
- python_platform/identity/services.py +108 -0
- python_platform/identity/sqlalchemy/__init__.py +5 -0
- python_platform/identity/sqlalchemy/migrations/0001_platform_identity.py +151 -0
- python_platform/identity/sqlalchemy/migrations/0002_physical_delete.py +42 -0
- python_platform/identity/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/identity/sqlalchemy/models.py +85 -0
- python_platform/identity/sqlalchemy/module.py +62 -0
- python_platform/identity/sqlalchemy/stores.py +526 -0
- python_platform/identity/tokens.py +106 -0
- python_platform/invocation/__init__.py +3 -0
- python_platform/invocation/callables.py +171 -0
- python_platform/invocation/contracts.py +33 -0
- python_platform/invocation/entries.py +54 -0
- python_platform/invocation/function_runtime.py +48 -0
- python_platform/invocation/interception.py +184 -0
- python_platform/lifecycle/__init__.py +25 -0
- python_platform/lifecycle/composition.py +35 -0
- python_platform/lifecycle/context.py +31 -0
- python_platform/lifecycle/runtime.py +43 -0
- python_platform/lifecycle/state.py +20 -0
- python_platform/modularity/__init__.py +15 -0
- python_platform/modularity/contracts.py +90 -0
- python_platform/modularity/discovery.py +143 -0
- python_platform/modularity/graph.py +334 -0
- python_platform/modularity/registry.py +100 -0
- python_platform/modularity/selection.py +16 -0
- python_platform/notifications/__init__.py +19 -0
- python_platform/notifications/catalog.py +46 -0
- python_platform/notifications/contracts.py +107 -0
- python_platform/observability/__init__.py +5 -0
- python_platform/observability/context.py +43 -0
- python_platform/observability/export.py +73 -0
- python_platform/observability/formatting.py +89 -0
- python_platform/observability/logging.py +118 -0
- python_platform/observability/options.py +45 -0
- python_platform/observability/tracing.py +80 -0
- python_platform/options/__init__.py +19 -0
- python_platform/options/aliases.py +331 -0
- python_platform/options/contribution.py +62 -0
- python_platform/options/immutability.py +48 -0
- python_platform/options/input_keys.py +214 -0
- python_platform/options/issues.py +335 -0
- python_platform/options/models.py +114 -0
- python_platform/options/registry.py +280 -0
- python_platform/options/schema.py +488 -0
- python_platform/options/validation.py +120 -0
- python_platform/py.typed +0 -0
- python_platform/realtime/__init__.py +12 -0
- python_platform/realtime/contracts.py +28 -0
- python_platform/realtime/diagnostics.py +37 -0
- python_platform/realtime/messages.py +113 -0
- python_platform/redis/__init__.py +6 -0
- python_platform/redis/distributed_lock.py +212 -0
- python_platform/redis/lease_lock.py +36 -0
- python_platform/redis/module.py +50 -0
- python_platform/redis/notification_runtime.py +182 -0
- python_platform/redis/notifications.py +25 -0
- python_platform/redis/options.py +35 -0
- python_platform/redis/runtime.py +116 -0
- python_platform/services/__init__.py +39 -0
- python_platform/services/application_bindings.py +221 -0
- python_platform/services/arbitration.py +381 -0
- python_platform/services/binding.py +102 -0
- python_platform/services/contribution.py +438 -0
- python_platform/services/convention.py +292 -0
- python_platform/services/convention_contracts.py +94 -0
- python_platform/services/exposure.py +23 -0
- python_platform/services/framework_provider.py +178 -0
- python_platform/services/native_graph.py +172 -0
- python_platform/services/provider.py +204 -0
- python_platform/services/registration.py +101 -0
- python_platform/services/runtime.py +298 -0
- python_platform/settings/__init__.py +30 -0
- python_platform/settings/application.py +95 -0
- python_platform/settings/binding.py +33 -0
- python_platform/settings/catalog.py +121 -0
- python_platform/settings/changes.py +14 -0
- python_platform/settings/contracts.py +115 -0
- python_platform/settings/definition_discovery.py +71 -0
- python_platform/settings/definitions.py +41 -0
- python_platform/settings/errors.py +22 -0
- python_platform/settings/handlers.py +43 -0
- python_platform/settings/management.py +57 -0
- python_platform/settings/manager.py +89 -0
- python_platform/settings/module.py +7 -0
- python_platform/settings/notifications.py +39 -0
- python_platform/settings/permissions.py +14 -0
- python_platform/settings/provider.py +74 -0
- python_platform/settings/refresh.py +140 -0
- python_platform/settings/refresh_module.py +45 -0
- python_platform/settings/sqlalchemy/__init__.py +5 -0
- python_platform/settings/sqlalchemy/migrations/0001_platform_settings.py +31 -0
- python_platform/settings/sqlalchemy/migrations/0002_physical_delete.py +32 -0
- python_platform/settings/sqlalchemy/migrations/0003_version_tokens.py +39 -0
- python_platform/settings/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/settings/sqlalchemy/models.py +36 -0
- python_platform/settings/sqlalchemy/module.py +36 -0
- python_platform/settings/sqlalchemy/store.py +86 -0
- python_platform/settings/store.py +16 -0
- python_platform/settings/values.py +39 -0
- python_platform/sqlalchemy/__init__.py +51 -0
- python_platform/sqlalchemy/alembic_runtime/__init__.py +1 -0
- python_platform/sqlalchemy/alembic_runtime/env.py +27 -0
- python_platform/sqlalchemy/alembic_runtime/script.py.mako +14 -0
- python_platform/sqlalchemy/auditing.py +48 -0
- python_platform/sqlalchemy/errors.py +85 -0
- python_platform/sqlalchemy/metadata.py +538 -0
- python_platform/sqlalchemy/migration.py +346 -0
- python_platform/sqlalchemy/module.py +48 -0
- python_platform/sqlalchemy/module_migration.py +148 -0
- python_platform/sqlalchemy/options.py +66 -0
- python_platform/sqlalchemy/repository.py +33 -0
- python_platform/sqlalchemy/runtime.py +85 -0
- python_platform/sqlalchemy/session_provider.py +45 -0
- python_platform/sqlalchemy/unit_of_work.py +97 -0
- python_platform/testing/__init__.py +5 -0
- python_platform/testing/runtime.py +112 -0
- python_platform/unit_of_work/__init__.py +14 -0
- python_platform/unit_of_work/contracts.py +223 -0
- python_platform/unit_of_work/errors.py +27 -0
- python_platform/unit_of_work/manager.py +210 -0
- python_platform/unit_of_work/options.py +92 -0
- python_platform-0.1.1.dist-info/METADATA +122 -0
- python_platform-0.1.1.dist-info/RECORD +344 -0
- python_platform-0.1.1.dist-info/WHEEL +4 -0
- python_platform-0.1.1.dist-info/entry_points.txt +9 -0
- python_platform-0.1.1.dist-info/licenses/LICENSE +7 -0
- python_platform-0.1.1.dist-info/licenses/src/python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""Options model schema、alias path 与 secret path contract validation。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import types
|
|
6
|
+
from collections.abc import Mapping, MutableMapping, MutableSequence, MutableSet
|
|
7
|
+
from typing import Annotated, Any, Literal, Union, cast, get_args, get_origin
|
|
8
|
+
|
|
9
|
+
from pydantic import AliasChoices, AliasPath, BaseModel, Secret, SecretBytes, SecretStr
|
|
10
|
+
from pydantic.fields import FieldInfo
|
|
11
|
+
from pydantic_settings import BaseSettings
|
|
12
|
+
|
|
13
|
+
from ..configuration.values import canonical_path
|
|
14
|
+
from ..errors import InvalidOptionDefinitionError
|
|
15
|
+
from ..modularity import ModuleDescriptor
|
|
16
|
+
from .models import _OptionDefinition
|
|
17
|
+
|
|
18
|
+
_MUTABLE_ANNOTATION_ORIGINS = (
|
|
19
|
+
list,
|
|
20
|
+
dict,
|
|
21
|
+
set,
|
|
22
|
+
MutableMapping,
|
|
23
|
+
MutableSequence,
|
|
24
|
+
MutableSet,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_definition(
|
|
29
|
+
candidate: _OptionDefinition[BaseModel],
|
|
30
|
+
*,
|
|
31
|
+
owner: ModuleDescriptor,
|
|
32
|
+
kind: str,
|
|
33
|
+
) -> _OptionDefinition[BaseModel]:
|
|
34
|
+
model_type = candidate.model_type
|
|
35
|
+
if not isinstance(model_type, type) or not issubclass(model_type, BaseModel):
|
|
36
|
+
raise InvalidOptionDefinitionError(
|
|
37
|
+
owner=owner.key,
|
|
38
|
+
model_type=None,
|
|
39
|
+
option_kind=kind,
|
|
40
|
+
reason="model_type must be a Pydantic BaseModel class",
|
|
41
|
+
)
|
|
42
|
+
try:
|
|
43
|
+
section = ".".join(canonical_path(candidate.section))
|
|
44
|
+
if not isinstance(candidate.secret_paths, frozenset):
|
|
45
|
+
raise TypeError("secret_paths must be frozenset")
|
|
46
|
+
_validate_model_schema(model_type, seen=set())
|
|
47
|
+
expanded_secret_paths: set[tuple[str, ...]] = set()
|
|
48
|
+
for secret_path in candidate.secret_paths:
|
|
49
|
+
expanded_secret_paths.update(
|
|
50
|
+
_expand_declared_secret_path(
|
|
51
|
+
model_type,
|
|
52
|
+
canonical_path(secret_path),
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
secret_paths = frozenset(".".join(path) for path in expanded_secret_paths)
|
|
56
|
+
except (TypeError, ValueError) as original_error:
|
|
57
|
+
raise InvalidOptionDefinitionError(
|
|
58
|
+
owner=owner.key,
|
|
59
|
+
model_type=model_type,
|
|
60
|
+
option_kind=kind,
|
|
61
|
+
reason=str(original_error),
|
|
62
|
+
) from original_error
|
|
63
|
+
return _OptionDefinition(
|
|
64
|
+
model_type=model_type,
|
|
65
|
+
section=section,
|
|
66
|
+
secret_paths=secret_paths,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _validate_model_schema(
|
|
71
|
+
model_type: type[BaseModel],
|
|
72
|
+
*,
|
|
73
|
+
seen: set[type[BaseModel]],
|
|
74
|
+
) -> None:
|
|
75
|
+
if model_type in seen:
|
|
76
|
+
return
|
|
77
|
+
seen.add(model_type)
|
|
78
|
+
# 在绑定及任何 default factory 运行前检查完整模型图,嵌套模型也不能另读配置源。
|
|
79
|
+
if issubclass(model_type, BaseSettings):
|
|
80
|
+
raise ValueError("Options must not load independent settings sources")
|
|
81
|
+
if model_type.model_config.get("frozen") is not True:
|
|
82
|
+
raise ValueError(f"nested model {model_type.__qualname__} must set frozen=True")
|
|
83
|
+
if model_type.model_config.get("validate_default") is not True:
|
|
84
|
+
raise ValueError(f"nested model {model_type.__qualname__} must set validate_default=True")
|
|
85
|
+
if model_type.__private_attributes__:
|
|
86
|
+
# Pydantic frozen 不阻止 PrivateAttr 重新赋值,发布 snapshot 时不能接受它。
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"model {model_type.__qualname__} must not declare mutable private attributes"
|
|
89
|
+
)
|
|
90
|
+
if model_type.model_config.get("extra") == "allow":
|
|
91
|
+
raise ValueError(f"model {model_type.__qualname__} must not allow mutable extra fields")
|
|
92
|
+
validation_paths: dict[str, tuple[tuple[str, ...], ...]] = {}
|
|
93
|
+
for field_name, field in model_type.model_fields.items():
|
|
94
|
+
if _canonical_path_part(field_name) != field_name:
|
|
95
|
+
raise ValueError(f"field name {field_name!r} must already be canonical snake_case")
|
|
96
|
+
_validate_field_aliases(field)
|
|
97
|
+
validation_paths[field_name] = _field_validation_paths(
|
|
98
|
+
model_type,
|
|
99
|
+
field_name,
|
|
100
|
+
field,
|
|
101
|
+
)
|
|
102
|
+
_validate_annotation(
|
|
103
|
+
field.annotation,
|
|
104
|
+
path=f"{model_type.__qualname__}.{field_name}",
|
|
105
|
+
seen=seen,
|
|
106
|
+
discriminator=field.discriminator,
|
|
107
|
+
)
|
|
108
|
+
_validate_model_input_paths(model_type, validation_paths)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _validate_annotation(
|
|
112
|
+
annotation: object,
|
|
113
|
+
*,
|
|
114
|
+
path: str,
|
|
115
|
+
seen: set[type[BaseModel]],
|
|
116
|
+
discriminator: object = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
if annotation in (Any, object):
|
|
119
|
+
raise TypeError(f"{path} must not use Any/object")
|
|
120
|
+
origin = get_origin(annotation)
|
|
121
|
+
if _is_mutable_annotation(annotation, origin):
|
|
122
|
+
raise TypeError(f"{path} uses a mutable container annotation")
|
|
123
|
+
if origin is Annotated:
|
|
124
|
+
annotated = get_args(annotation)
|
|
125
|
+
if annotated:
|
|
126
|
+
_validate_annotation(
|
|
127
|
+
annotated[0],
|
|
128
|
+
path=path,
|
|
129
|
+
seen=seen,
|
|
130
|
+
discriminator=discriminator or _annotation_discriminator(annotation),
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
if origin in (Union, types.UnionType):
|
|
134
|
+
model_choices = _direct_model_types(annotation)
|
|
135
|
+
if (
|
|
136
|
+
len(model_choices) > 1
|
|
137
|
+
and any(
|
|
138
|
+
_model_contains_competing_aliases(model_choice, seen=set())
|
|
139
|
+
for model_choice in model_choices
|
|
140
|
+
)
|
|
141
|
+
and not isinstance(discriminator, str)
|
|
142
|
+
):
|
|
143
|
+
raise TypeError(
|
|
144
|
+
f"{path} must use a string discriminator when union branches contain "
|
|
145
|
+
"competing validation aliases"
|
|
146
|
+
)
|
|
147
|
+
_validate_annotation_arguments(get_args(annotation), path=path, seen=seen)
|
|
148
|
+
return
|
|
149
|
+
if origin is Literal:
|
|
150
|
+
return
|
|
151
|
+
model_type = _model_annotation(annotation)
|
|
152
|
+
if model_type is not None:
|
|
153
|
+
_validate_model_schema(model_type, seen=seen)
|
|
154
|
+
return
|
|
155
|
+
_validate_annotation_arguments(get_args(annotation), path=path, seen=seen)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validate_annotation_arguments(
|
|
159
|
+
arguments: tuple[object, ...],
|
|
160
|
+
*,
|
|
161
|
+
path: str,
|
|
162
|
+
seen: set[type[BaseModel]],
|
|
163
|
+
) -> None:
|
|
164
|
+
for argument in arguments:
|
|
165
|
+
if argument is not Ellipsis:
|
|
166
|
+
_validate_annotation(argument, path=path, seen=seen)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _is_mutable_annotation(annotation: object, origin: object) -> bool:
|
|
170
|
+
return annotation in _MUTABLE_ANNOTATION_ORIGINS or origin in _MUTABLE_ANNOTATION_ORIGINS
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _model_annotation(annotation: object) -> type[BaseModel] | None:
|
|
174
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
175
|
+
return annotation
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _direct_model_types(annotation: object) -> tuple[type[BaseModel], ...]:
|
|
180
|
+
origin = get_origin(annotation)
|
|
181
|
+
if origin is Annotated:
|
|
182
|
+
arguments = get_args(annotation)
|
|
183
|
+
return _direct_model_types(arguments[0]) if arguments else ()
|
|
184
|
+
if origin in (Union, types.UnionType):
|
|
185
|
+
models: list[type[BaseModel]] = []
|
|
186
|
+
for argument in get_args(annotation):
|
|
187
|
+
model_type = _model_annotation(argument)
|
|
188
|
+
if model_type is not None and model_type not in models:
|
|
189
|
+
models.append(model_type)
|
|
190
|
+
return tuple(models)
|
|
191
|
+
model_type = _model_annotation(annotation)
|
|
192
|
+
return (model_type,) if model_type is not None else ()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _annotation_discriminator(annotation: object) -> object:
|
|
196
|
+
if get_origin(annotation) is not Annotated:
|
|
197
|
+
return None
|
|
198
|
+
for metadata in get_args(annotation)[1:]:
|
|
199
|
+
if isinstance(metadata, FieldInfo) and metadata.discriminator is not None:
|
|
200
|
+
return metadata.discriminator
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _model_contains_competing_aliases(
|
|
205
|
+
model_type: type[BaseModel],
|
|
206
|
+
*,
|
|
207
|
+
seen: set[type[BaseModel]],
|
|
208
|
+
) -> bool:
|
|
209
|
+
if model_type in seen:
|
|
210
|
+
return False
|
|
211
|
+
branch_seen = {*seen, model_type}
|
|
212
|
+
for field_name, field in model_type.model_fields.items():
|
|
213
|
+
if len(_field_validation_paths(model_type, field_name, field)) > 1:
|
|
214
|
+
return True
|
|
215
|
+
if _annotation_contains_competing_aliases(field.annotation, seen=branch_seen):
|
|
216
|
+
return True
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _annotation_contains_competing_aliases(
|
|
221
|
+
annotation: object,
|
|
222
|
+
*,
|
|
223
|
+
seen: set[type[BaseModel]],
|
|
224
|
+
) -> bool:
|
|
225
|
+
model_type = _model_annotation(annotation)
|
|
226
|
+
if model_type is not None:
|
|
227
|
+
return _model_contains_competing_aliases(model_type, seen=seen)
|
|
228
|
+
return any(
|
|
229
|
+
_annotation_contains_competing_aliases(argument, seen=seen)
|
|
230
|
+
for argument in get_args(annotation)
|
|
231
|
+
if argument is not Ellipsis
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _definition_secret_paths(definition: _OptionDefinition[BaseModel]) -> frozenset[str]:
|
|
236
|
+
paths = set(definition.secret_paths)
|
|
237
|
+
paths.update(_model_secret_paths(definition.model_type, prefix=(), seen=set()))
|
|
238
|
+
return frozenset(paths)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _model_secret_paths(
|
|
242
|
+
model_type: type[BaseModel],
|
|
243
|
+
*,
|
|
244
|
+
prefix: tuple[str, ...],
|
|
245
|
+
seen: set[type[BaseModel]],
|
|
246
|
+
) -> set[str]:
|
|
247
|
+
if model_type in seen:
|
|
248
|
+
return set()
|
|
249
|
+
branch_seen = {*seen, model_type}
|
|
250
|
+
paths: set[str] = set()
|
|
251
|
+
for field_name, field in model_type.model_fields.items():
|
|
252
|
+
field_paths = tuple(
|
|
253
|
+
(*prefix, *relative_path) for relative_path in _field_input_paths(field_name, field)
|
|
254
|
+
)
|
|
255
|
+
if _annotation_contains_secret(
|
|
256
|
+
field.annotation
|
|
257
|
+
) or _annotation_has_secret_in_dynamic_mapping(field.annotation):
|
|
258
|
+
paths.update(".".join(field_path) for field_path in field_paths)
|
|
259
|
+
for nested_type in _nested_model_types(field.annotation):
|
|
260
|
+
for field_path in field_paths:
|
|
261
|
+
if nested_type in branch_seen:
|
|
262
|
+
# 递归 model 深度无上限,递归入口整体脱敏才是 fail-safe。
|
|
263
|
+
paths.add(".".join(field_path))
|
|
264
|
+
else:
|
|
265
|
+
paths.update(
|
|
266
|
+
_model_secret_paths(
|
|
267
|
+
nested_type,
|
|
268
|
+
prefix=field_path,
|
|
269
|
+
seen=branch_seen,
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
return paths
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _expand_declared_secret_path(
|
|
276
|
+
model_type: type[BaseModel],
|
|
277
|
+
path: tuple[str, ...],
|
|
278
|
+
) -> set[tuple[str, ...]]:
|
|
279
|
+
expanded: set[tuple[str, ...]] = set()
|
|
280
|
+
for field_name, field in model_type.model_fields.items():
|
|
281
|
+
input_paths = _field_input_paths(field_name, field)
|
|
282
|
+
for input_path in input_paths:
|
|
283
|
+
if path[: len(input_path)] != input_path:
|
|
284
|
+
continue
|
|
285
|
+
remaining = path[len(input_path) :]
|
|
286
|
+
if not remaining:
|
|
287
|
+
expanded.update(input_paths)
|
|
288
|
+
continue
|
|
289
|
+
if _annotation_accepts_dynamic_mapping_path(field.annotation):
|
|
290
|
+
expanded.update((*candidate, *remaining) for candidate in input_paths)
|
|
291
|
+
continue
|
|
292
|
+
for nested_type in _nested_model_types(field.annotation):
|
|
293
|
+
try:
|
|
294
|
+
nested_paths = _expand_declared_secret_path(nested_type, remaining)
|
|
295
|
+
except ValueError:
|
|
296
|
+
continue
|
|
297
|
+
expanded.update(
|
|
298
|
+
(*candidate, *nested_path)
|
|
299
|
+
for candidate in input_paths
|
|
300
|
+
for nested_path in nested_paths
|
|
301
|
+
)
|
|
302
|
+
if not expanded:
|
|
303
|
+
raise ValueError(
|
|
304
|
+
f"secret path {'.'.join(path)!r} does not resolve to an Options input field"
|
|
305
|
+
)
|
|
306
|
+
return expanded
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _annotation_accepts_dynamic_mapping_path(annotation: object) -> bool:
|
|
310
|
+
origin = get_origin(annotation)
|
|
311
|
+
if origin is Mapping:
|
|
312
|
+
return True
|
|
313
|
+
if origin is Annotated:
|
|
314
|
+
arguments = get_args(annotation)
|
|
315
|
+
return bool(arguments) and _annotation_accepts_dynamic_mapping_path(arguments[0])
|
|
316
|
+
if origin in (Union, types.UnionType):
|
|
317
|
+
return any(_annotation_accepts_dynamic_mapping_path(item) for item in get_args(annotation))
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _field_input_paths(
|
|
322
|
+
field_name: str,
|
|
323
|
+
field: FieldInfo,
|
|
324
|
+
) -> tuple[tuple[str, ...], ...]:
|
|
325
|
+
paths: set[tuple[str, ...]] = {(_canonical_path_part(field_name),)}
|
|
326
|
+
paths.update(_validation_alias_paths(field.validation_alias))
|
|
327
|
+
if isinstance(field.alias, str):
|
|
328
|
+
paths.update(_validation_alias_paths(field.alias))
|
|
329
|
+
return tuple(sorted(paths))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _field_validation_paths(
|
|
333
|
+
model_type: type[BaseModel],
|
|
334
|
+
field_name: str,
|
|
335
|
+
field: FieldInfo,
|
|
336
|
+
) -> tuple[tuple[str, ...], ...]:
|
|
337
|
+
paths: list[tuple[str, ...]] = []
|
|
338
|
+
if model_type.model_config.get("validate_by_alias") is not False:
|
|
339
|
+
validation_alias = field.validation_alias
|
|
340
|
+
if validation_alias is None and isinstance(field.alias, str):
|
|
341
|
+
validation_alias = field.alias
|
|
342
|
+
for path in _ordered_validation_alias_paths(validation_alias):
|
|
343
|
+
if path not in paths:
|
|
344
|
+
paths.append(path)
|
|
345
|
+
if model_type.model_config.get("validate_by_name") is True or not paths:
|
|
346
|
+
name_path = (_canonical_path_part(field_name),)
|
|
347
|
+
if name_path not in paths:
|
|
348
|
+
paths.append(name_path)
|
|
349
|
+
return tuple(paths)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _validation_alias_paths(alias: object) -> set[tuple[str, ...]]:
|
|
353
|
+
return set(_ordered_validation_alias_paths(alias))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _ordered_validation_alias_paths(alias: object) -> tuple[tuple[str, ...], ...]:
|
|
357
|
+
candidates: tuple[object, ...]
|
|
358
|
+
if isinstance(alias, AliasChoices):
|
|
359
|
+
candidates = tuple(alias.choices)
|
|
360
|
+
elif alias is None:
|
|
361
|
+
candidates = ()
|
|
362
|
+
else:
|
|
363
|
+
candidates = (alias,)
|
|
364
|
+
paths: list[tuple[str, ...]] = []
|
|
365
|
+
for candidate in candidates:
|
|
366
|
+
path = _canonical_validation_alias(candidate)
|
|
367
|
+
if path not in paths:
|
|
368
|
+
paths.append(path)
|
|
369
|
+
return tuple(paths)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _canonical_validation_alias(alias: object) -> tuple[str, ...]:
|
|
373
|
+
if isinstance(alias, str):
|
|
374
|
+
return (_canonical_alias_part(alias),)
|
|
375
|
+
if isinstance(alias, AliasPath):
|
|
376
|
+
if not alias.path or any(not isinstance(item, str) for item in alias.path):
|
|
377
|
+
raise ValueError("validation AliasPath must contain only configuration key segments")
|
|
378
|
+
return tuple(_canonical_alias_part(cast(str, part)) for part in alias.path)
|
|
379
|
+
raise TypeError("validation alias must be str, AliasPath or AliasChoices")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _validate_field_aliases(field: FieldInfo) -> None:
|
|
383
|
+
try:
|
|
384
|
+
_validation_alias_paths(field.validation_alias)
|
|
385
|
+
if isinstance(field.alias, str):
|
|
386
|
+
_validation_alias_paths(field.alias)
|
|
387
|
+
except (TypeError, ValueError) as original_error:
|
|
388
|
+
raise ValueError(
|
|
389
|
+
f"field validation alias is not a configuration path: {original_error}"
|
|
390
|
+
) from original_error
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _validate_model_input_paths(
|
|
394
|
+
model_type: type[BaseModel],
|
|
395
|
+
paths_by_field: Mapping[str, tuple[tuple[str, ...], ...]],
|
|
396
|
+
) -> None:
|
|
397
|
+
items = tuple(paths_by_field.items())
|
|
398
|
+
for field_name, paths in items:
|
|
399
|
+
for index, path in enumerate(paths):
|
|
400
|
+
for other in paths[index + 1 :]:
|
|
401
|
+
if _paths_overlap(path, other):
|
|
402
|
+
raise ValueError(
|
|
403
|
+
f"field {model_type.__qualname__}.{field_name} has overlapping "
|
|
404
|
+
"validation input paths"
|
|
405
|
+
)
|
|
406
|
+
for index, (field_name, paths) in enumerate(items):
|
|
407
|
+
for other_name, other_paths in items[index + 1 :]:
|
|
408
|
+
if len(paths) == 1 and len(other_paths) == 1:
|
|
409
|
+
continue
|
|
410
|
+
if any(_paths_overlap(path, other) for path in paths for other in other_paths):
|
|
411
|
+
raise ValueError(
|
|
412
|
+
f"fields {model_type.__qualname__}.{field_name} and {other_name} have "
|
|
413
|
+
"overlapping validation input paths"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _paths_overlap(left: tuple[str, ...], right: tuple[str, ...]) -> bool:
|
|
418
|
+
shared = min(len(left), len(right))
|
|
419
|
+
return left[:shared] == right[:shared]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _canonical_path_part(part: str) -> str:
|
|
423
|
+
canonical = canonical_path(part)
|
|
424
|
+
if len(canonical) != 1:
|
|
425
|
+
raise ValueError("validation alias path segment must be a single configuration key")
|
|
426
|
+
return canonical[0]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _canonical_alias_part(part: str) -> str:
|
|
430
|
+
canonical = _canonical_path_part(part)
|
|
431
|
+
if canonical != part:
|
|
432
|
+
raise ValueError("validation alias segments must already be canonical snake_case")
|
|
433
|
+
return canonical
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _annotation_contains_secret(annotation: object) -> bool:
|
|
437
|
+
if (
|
|
438
|
+
annotation in (SecretStr, SecretBytes)
|
|
439
|
+
or get_origin(annotation) is Secret
|
|
440
|
+
or (
|
|
441
|
+
isinstance(annotation, type)
|
|
442
|
+
and issubclass(annotation, (Secret, SecretStr, SecretBytes))
|
|
443
|
+
)
|
|
444
|
+
):
|
|
445
|
+
return True
|
|
446
|
+
return any(_annotation_contains_secret(argument) for argument in get_args(annotation))
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _annotation_has_secret_in_dynamic_mapping(annotation: object) -> bool:
|
|
450
|
+
origin = get_origin(annotation)
|
|
451
|
+
if origin is Mapping:
|
|
452
|
+
return any(
|
|
453
|
+
_annotation_contains_secret_deep(argument, seen=set())
|
|
454
|
+
for argument in get_args(annotation)
|
|
455
|
+
)
|
|
456
|
+
return any(
|
|
457
|
+
_annotation_has_secret_in_dynamic_mapping(argument) for argument in get_args(annotation)
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _annotation_contains_secret_deep(
|
|
462
|
+
annotation: object,
|
|
463
|
+
*,
|
|
464
|
+
seen: set[type[BaseModel]],
|
|
465
|
+
) -> bool:
|
|
466
|
+
if _annotation_contains_secret(annotation):
|
|
467
|
+
return True
|
|
468
|
+
model_type = _model_annotation(annotation)
|
|
469
|
+
if model_type is not None:
|
|
470
|
+
if model_type in seen:
|
|
471
|
+
return False
|
|
472
|
+
branch_seen = {*seen, model_type}
|
|
473
|
+
return any(
|
|
474
|
+
_annotation_contains_secret_deep(field.annotation, seen=branch_seen)
|
|
475
|
+
for field in model_type.model_fields.values()
|
|
476
|
+
)
|
|
477
|
+
return any(
|
|
478
|
+
_annotation_contains_secret_deep(argument, seen=seen) for argument in get_args(annotation)
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _nested_model_types(annotation: object) -> tuple[type[BaseModel], ...]:
|
|
483
|
+
nested: list[type[BaseModel]] = []
|
|
484
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
485
|
+
nested.append(annotation)
|
|
486
|
+
for argument in get_args(annotation):
|
|
487
|
+
nested.extend(_nested_model_types(argument))
|
|
488
|
+
return tuple(nested)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""原生 Pydantic 验证边界:每个 Union 候选只解释自己的模型字段。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from functools import partial
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from pydantic_core import SchemaValidator, core_schema
|
|
11
|
+
|
|
12
|
+
from ..configuration.values import normalize_mapping
|
|
13
|
+
from .immutability import _merge_patch_input
|
|
14
|
+
from .input_keys import _InputReference, _mapping_input_paths
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _validate_input(
|
|
18
|
+
model: type[BaseModel], value: object, layers: tuple[tuple[object, bool], ...]
|
|
19
|
+
) -> BaseModel:
|
|
20
|
+
# 不修改 model 的共享 schema/validator。所有原生 Union 模式、discriminator、
|
|
21
|
+
# alias、约束和用户 validator 都保留,只在候选模型输入边界加入字段归一化。
|
|
22
|
+
inputs = _InputNormalization()
|
|
23
|
+
inputs.index(value, layers)
|
|
24
|
+
schema = cast(
|
|
25
|
+
core_schema.CoreSchema, _input_schema(model.__pydantic_core_schema__, inputs.normalize)
|
|
26
|
+
)
|
|
27
|
+
# 原生重建开关禁止回用 class 上的旧 validator,否则会跳过此次输入 schema。
|
|
28
|
+
return cast(BaseModel, SchemaValidator(schema, _use_prebuilt=False).validate_python(value))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _input_schema(value: Any, normalize: Callable[[type[BaseModel], object], object]) -> Any:
|
|
32
|
+
if isinstance(value, list):
|
|
33
|
+
return [_input_schema(child, normalize) for child in value]
|
|
34
|
+
if isinstance(value, tuple):
|
|
35
|
+
return tuple(_input_schema(child, normalize) for child in value)
|
|
36
|
+
if not isinstance(value, dict):
|
|
37
|
+
return value
|
|
38
|
+
copied = {
|
|
39
|
+
name: child
|
|
40
|
+
if name in ("default", "metadata", "config", "serialization")
|
|
41
|
+
else _input_schema(child, normalize)
|
|
42
|
+
for name, child in value.items()
|
|
43
|
+
}
|
|
44
|
+
if value.get("type") == "model":
|
|
45
|
+
model = value["cls"]
|
|
46
|
+
reference = copied.pop("ref", None)
|
|
47
|
+
return core_schema.no_info_before_validator_function(
|
|
48
|
+
partial(normalize, model), copied, ref=reference
|
|
49
|
+
)
|
|
50
|
+
return copied
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _InputNormalization:
|
|
54
|
+
"""一次验证拥有的输入层索引;未标记 Union 在候选入口才按来源合并字段。"""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self._inputs: dict[int, tuple[object, tuple[tuple[object, bool], ...]]] = {}
|
|
58
|
+
|
|
59
|
+
def index(self, value: object, layers: tuple[tuple[object, bool], ...]) -> None:
|
|
60
|
+
if isinstance(value, Mapping):
|
|
61
|
+
# scalar/sequence 覆盖会截断更早的 mapping;来源顺序已由 Configuration 确定。
|
|
62
|
+
start = next(
|
|
63
|
+
(
|
|
64
|
+
i + 1
|
|
65
|
+
for i in range(len(layers) - 1, -1, -1)
|
|
66
|
+
if not isinstance(layers[i][0], Mapping)
|
|
67
|
+
),
|
|
68
|
+
0,
|
|
69
|
+
)
|
|
70
|
+
layers = layers[start:]
|
|
71
|
+
self._inputs[id(value)] = (value, layers)
|
|
72
|
+
for key, child in value.items():
|
|
73
|
+
children = tuple(
|
|
74
|
+
(layer[key], merge)
|
|
75
|
+
for layer, merge in layers
|
|
76
|
+
if isinstance(layer, Mapping) and key in layer
|
|
77
|
+
)
|
|
78
|
+
self.index(child, children)
|
|
79
|
+
elif isinstance(value, tuple):
|
|
80
|
+
for i, child in enumerate(value):
|
|
81
|
+
last, merge = layers[-1] if layers else ((), False)
|
|
82
|
+
self.index(
|
|
83
|
+
child, ((last[i], merge),) if isinstance(last, tuple) and i < len(last) else ()
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def normalize(self, model: type[BaseModel], value: object) -> object:
|
|
87
|
+
if not isinstance(value, Mapping):
|
|
88
|
+
return value
|
|
89
|
+
values = cast(Mapping[str, object], value)
|
|
90
|
+
layers = self._inputs.get(id(value), (value, ()))[1]
|
|
91
|
+
reference = _InputReference(tuple(layer for layer, _ in layers)) if layers else values
|
|
92
|
+
try:
|
|
93
|
+
normalized = normalize_mapping(
|
|
94
|
+
values, mapping_paths=_mapping_input_paths(model, values, reference)
|
|
95
|
+
)
|
|
96
|
+
except ValueError:
|
|
97
|
+
if not layers:
|
|
98
|
+
raise
|
|
99
|
+
else:
|
|
100
|
+
if normalized == values:
|
|
101
|
+
# 已归一化的边界直接保留 alias 仲裁结果与子节点身份,不再解释来源。
|
|
102
|
+
return value
|
|
103
|
+
if not layers:
|
|
104
|
+
return normalized
|
|
105
|
+
normalized_layers = tuple(
|
|
106
|
+
(
|
|
107
|
+
normalize_mapping(
|
|
108
|
+
cast(Mapping[str, object], layer),
|
|
109
|
+
mapping_paths=_mapping_input_paths(model, layer, reference),
|
|
110
|
+
merge_fields=merge,
|
|
111
|
+
),
|
|
112
|
+
merge,
|
|
113
|
+
)
|
|
114
|
+
for layer, merge in layers
|
|
115
|
+
)
|
|
116
|
+
merged: dict[str, object] = {}
|
|
117
|
+
for layer, _ in normalized_layers:
|
|
118
|
+
merged = _merge_patch_input(merged, layer)
|
|
119
|
+
self.index(merged, normalized_layers)
|
|
120
|
+
return merged
|
python_platform/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Provider-neutral realtime public capability。"""
|
|
2
|
+
|
|
3
|
+
from .contracts import RealtimePublisher
|
|
4
|
+
from .diagnostics import RealtimeDeliveryReport, RealtimeSnapshot
|
|
5
|
+
from .messages import RealtimeEnvelope
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"RealtimeDeliveryReport",
|
|
9
|
+
"RealtimeEnvelope",
|
|
10
|
+
"RealtimePublisher",
|
|
11
|
+
"RealtimeSnapshot",
|
|
12
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Realtime publisher contract;delivery 只表示 Application-local queue acceptance。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from .diagnostics import RealtimeDeliveryReport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class RealtimePublisher(Protocol):
|
|
14
|
+
async def send_to_connection(
|
|
15
|
+
self,
|
|
16
|
+
connection_id: UUID,
|
|
17
|
+
*,
|
|
18
|
+
message_type: str,
|
|
19
|
+
payload: Mapping[str, object],
|
|
20
|
+
) -> RealtimeDeliveryReport: ...
|
|
21
|
+
|
|
22
|
+
async def send_to_user(
|
|
23
|
+
self,
|
|
24
|
+
user_id: UUID,
|
|
25
|
+
*,
|
|
26
|
+
message_type: str,
|
|
27
|
+
payload: Mapping[str, object],
|
|
28
|
+
) -> RealtimeDeliveryReport: ...
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""不暴露连接 identity、user list、token 或 payload 的 realtime diagnostics。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class RealtimeDeliveryReport:
|
|
10
|
+
matched: int
|
|
11
|
+
enqueued: int
|
|
12
|
+
dropped: int
|
|
13
|
+
|
|
14
|
+
def __post_init__(self) -> None:
|
|
15
|
+
counts = (self.matched, self.enqueued, self.dropped)
|
|
16
|
+
if any(not isinstance(item, int) or isinstance(item, bool) or item < 0 for item in counts):
|
|
17
|
+
raise ValueError("realtime delivery counts must be non-negative integers")
|
|
18
|
+
if self.enqueued + self.dropped != self.matched:
|
|
19
|
+
raise ValueError("realtime delivery counts must partition matched connections")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class RealtimeSnapshot:
|
|
24
|
+
accepting: bool
|
|
25
|
+
active_connections: int
|
|
26
|
+
active_users: int
|
|
27
|
+
enqueued: int
|
|
28
|
+
dropped: int
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if not isinstance(self.accepting, bool):
|
|
32
|
+
raise TypeError("realtime accepting must be bool")
|
|
33
|
+
counts = (self.active_connections, self.active_users, self.enqueued, self.dropped)
|
|
34
|
+
if any(not isinstance(item, int) or isinstance(item, bool) or item < 0 for item in counts):
|
|
35
|
+
raise ValueError("realtime snapshot counts must be non-negative integers")
|
|
36
|
+
if self.active_users > self.active_connections:
|
|
37
|
+
raise ValueError("realtime active users cannot exceed active connections")
|