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,88 @@
|
|
|
1
|
+
"""安装完成后的 FastAPI route surface 完整性快照。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Protocol, cast
|
|
8
|
+
|
|
9
|
+
from fastapi.routing import APIRoute, APIWebSocketRoute, iter_route_contexts
|
|
10
|
+
from starlette.routing import BaseRoute, WebSocketRoute
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _RouteFingerprintView(Protocol):
|
|
14
|
+
operation_id: str | None
|
|
15
|
+
unique_id: str
|
|
16
|
+
include_in_schema: bool
|
|
17
|
+
summary: str | None
|
|
18
|
+
description: str
|
|
19
|
+
tags: list[object]
|
|
20
|
+
responses: dict[int | str, dict[str, Any]]
|
|
21
|
+
response_model: Any
|
|
22
|
+
status_code: int | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class _RouteFingerprintEntry:
|
|
27
|
+
original_route_id: int
|
|
28
|
+
route_type: str
|
|
29
|
+
path: str | None
|
|
30
|
+
path_format: str | None
|
|
31
|
+
methods: tuple[str, ...]
|
|
32
|
+
endpoint_id: int | None
|
|
33
|
+
dependency_ids: tuple[int, ...]
|
|
34
|
+
schema_signature: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
RouteFingerprint = tuple[_RouteFingerprintEntry, ...]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _route_fingerprint(routes: Sequence[BaseRoute]) -> RouteFingerprint:
|
|
41
|
+
entries: list[_RouteFingerprintEntry] = []
|
|
42
|
+
for context in iter_route_contexts(routes):
|
|
43
|
+
original_route = context.original_route
|
|
44
|
+
route_view = cast(_RouteFingerprintView, context)
|
|
45
|
+
methods = context.methods or set()
|
|
46
|
+
websocket_route = original_route if isinstance(original_route, WebSocketRoute) else None
|
|
47
|
+
endpoint = context.endpoint or getattr(websocket_route, "endpoint", None)
|
|
48
|
+
path = websocket_route.path if websocket_route is not None else context.path
|
|
49
|
+
path_format = (
|
|
50
|
+
websocket_route.path_format if websocket_route is not None else context.path_format
|
|
51
|
+
)
|
|
52
|
+
schema_signature = ""
|
|
53
|
+
dependency_ids: tuple[int, ...] = ()
|
|
54
|
+
if isinstance(original_route, APIRoute):
|
|
55
|
+
schema_signature = repr(
|
|
56
|
+
(
|
|
57
|
+
route_view.operation_id,
|
|
58
|
+
route_view.unique_id,
|
|
59
|
+
route_view.include_in_schema,
|
|
60
|
+
route_view.summary,
|
|
61
|
+
route_view.description,
|
|
62
|
+
route_view.tags,
|
|
63
|
+
route_view.responses,
|
|
64
|
+
route_view.response_model,
|
|
65
|
+
route_view.status_code,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
elif isinstance(original_route, APIWebSocketRoute):
|
|
69
|
+
dependency_ids = tuple(
|
|
70
|
+
id(dependency.call)
|
|
71
|
+
for dependency in original_route.dependant.dependencies
|
|
72
|
+
if dependency.call is not None
|
|
73
|
+
)
|
|
74
|
+
entries.append(
|
|
75
|
+
_RouteFingerprintEntry(
|
|
76
|
+
original_route_id=id(original_route),
|
|
77
|
+
route_type=(
|
|
78
|
+
f"{type(original_route).__module__}:{type(original_route).__qualname__}"
|
|
79
|
+
),
|
|
80
|
+
path=path,
|
|
81
|
+
path_format=path_format,
|
|
82
|
+
methods=tuple(sorted(method.upper() for method in methods)),
|
|
83
|
+
endpoint_id=id(endpoint) if endpoint is not None else None,
|
|
84
|
+
dependency_ids=dependency_ids,
|
|
85
|
+
schema_signature=schema_signature,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return tuple(entries)
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Native FastAPI route 展开之上的 contribution plan、冲突与 schema 门禁。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Protocol, cast
|
|
11
|
+
|
|
12
|
+
from dishka.integrations.fastapi import DishkaRoute
|
|
13
|
+
from fastapi import APIRouter, FastAPI
|
|
14
|
+
from fastapi.routing import APIRoute, APIWebSocketRoute, RouteContext, iter_route_contexts
|
|
15
|
+
from starlette.routing import BaseRoute, WebSocketRoute
|
|
16
|
+
|
|
17
|
+
from ..application import Application
|
|
18
|
+
from ..diagnostics import SourceLocation
|
|
19
|
+
from ..modularity import ModuleKey
|
|
20
|
+
from .application_services import (
|
|
21
|
+
_ROUTE_SOURCE_ATTRIBUTE,
|
|
22
|
+
_application_service_routers,
|
|
23
|
+
)
|
|
24
|
+
from .contracts import (
|
|
25
|
+
RouterCatalog,
|
|
26
|
+
RouterRegistration,
|
|
27
|
+
WebSocketRouteRegistration,
|
|
28
|
+
_routers_for,
|
|
29
|
+
)
|
|
30
|
+
from .errors import FastApiAdapterError, RouteConflictError
|
|
31
|
+
from .health import _create_health_router
|
|
32
|
+
from .http_router import HttpRouter
|
|
33
|
+
from .manual_action import _ActionRoute
|
|
34
|
+
from .realtime import websocket_connection
|
|
35
|
+
|
|
36
|
+
_PATH_PARAMETER = re.compile(r"\{[^{}:]+(?P<converter>:[^{}]+)?\}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _OpenApiRouteView(Protocol):
|
|
40
|
+
operation_id: str | None
|
|
41
|
+
unique_id: str
|
|
42
|
+
include_in_schema: bool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _RouteKind(Enum):
|
|
46
|
+
HTTP = "http"
|
|
47
|
+
WEBSOCKET = "websocket"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class _RouteCandidate:
|
|
52
|
+
kind: _RouteKind
|
|
53
|
+
owner: ModuleKey | None
|
|
54
|
+
source: SourceLocation
|
|
55
|
+
path: str
|
|
56
|
+
normalized_path: str
|
|
57
|
+
methods: tuple[str, ...]
|
|
58
|
+
operation_id: str | None
|
|
59
|
+
include_in_schema: bool
|
|
60
|
+
http_sources: tuple[tuple[str, str], ...] = ()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class _RouteInstallationPlan:
|
|
65
|
+
routers: tuple[APIRouter, ...]
|
|
66
|
+
health_router: APIRouter
|
|
67
|
+
catalog: RouterCatalog
|
|
68
|
+
schema_candidates: tuple[_RouteCandidate, ...]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build_route_plan(app: FastAPI, application: Application) -> _RouteInstallationPlan:
|
|
72
|
+
host_candidates = _route_candidates(
|
|
73
|
+
app.routes,
|
|
74
|
+
owner=None,
|
|
75
|
+
owner_label="fastapi-host",
|
|
76
|
+
require_managed=False,
|
|
77
|
+
)
|
|
78
|
+
preview = _preview_router(app)
|
|
79
|
+
contributed_candidates: list[_RouteCandidate] = []
|
|
80
|
+
routers: list[APIRouter] = []
|
|
81
|
+
|
|
82
|
+
# 手工 Router 永远先于 convention route,避免 module graph 偶然改变公开匹配顺序。
|
|
83
|
+
manual_routers = tuple(
|
|
84
|
+
(descriptor.key, router)
|
|
85
|
+
for descriptor in application.modules.dependency_order
|
|
86
|
+
for router in _routers_for(descriptor.module_type)
|
|
87
|
+
)
|
|
88
|
+
generated_routers = tuple(
|
|
89
|
+
(item.owner, item.router) for item in _application_service_routers(application)
|
|
90
|
+
)
|
|
91
|
+
for owner, router in (*manual_routers, *generated_routers):
|
|
92
|
+
if isinstance(router, HttpRouter):
|
|
93
|
+
router = router._compile(application._container_for_integration())
|
|
94
|
+
for context in iter_route_contexts(router.routes):
|
|
95
|
+
route = context.original_route
|
|
96
|
+
if isinstance(route, _ActionRoute):
|
|
97
|
+
unknown = tuple(
|
|
98
|
+
permission
|
|
99
|
+
for permission in route._definition.authorization.permissions
|
|
100
|
+
if permission not in application.permission_catalog
|
|
101
|
+
)
|
|
102
|
+
if unknown:
|
|
103
|
+
raise FastApiAdapterError(
|
|
104
|
+
f"HTTP action references unknown permissions: {unknown}"
|
|
105
|
+
)
|
|
106
|
+
routers.append(router)
|
|
107
|
+
route_start = len(preview.routes)
|
|
108
|
+
preview.include_router(router)
|
|
109
|
+
contributed_candidates.extend(
|
|
110
|
+
_route_candidates(
|
|
111
|
+
preview.routes[route_start:],
|
|
112
|
+
owner=owner,
|
|
113
|
+
owner_label=str(owner),
|
|
114
|
+
require_managed=True,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
health_router = _create_health_router(application)
|
|
119
|
+
health_route_start = len(preview.routes)
|
|
120
|
+
preview.include_router(health_router)
|
|
121
|
+
health_candidates = _route_candidates(
|
|
122
|
+
preview.routes[health_route_start:],
|
|
123
|
+
owner=None,
|
|
124
|
+
owner_label="python-platform-health",
|
|
125
|
+
require_managed=False,
|
|
126
|
+
)
|
|
127
|
+
schema_candidates = tuple(
|
|
128
|
+
candidate
|
|
129
|
+
for candidate in (*contributed_candidates, *health_candidates)
|
|
130
|
+
if candidate.kind is _RouteKind.HTTP and candidate.include_in_schema
|
|
131
|
+
)
|
|
132
|
+
_validate_route_conflicts((*host_candidates, *contributed_candidates, *health_candidates))
|
|
133
|
+
|
|
134
|
+
registrations = tuple(
|
|
135
|
+
RouterRegistration(
|
|
136
|
+
owner=_required_owner(candidate),
|
|
137
|
+
source=candidate.source,
|
|
138
|
+
path=candidate.path,
|
|
139
|
+
methods=candidate.methods,
|
|
140
|
+
operation_id=_required_operation_id(candidate),
|
|
141
|
+
http_sources=candidate.http_sources,
|
|
142
|
+
order=order,
|
|
143
|
+
)
|
|
144
|
+
for order, candidate in enumerate(
|
|
145
|
+
item for item in contributed_candidates if item.kind is _RouteKind.HTTP
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
websocket_registrations = tuple(
|
|
149
|
+
WebSocketRouteRegistration(
|
|
150
|
+
owner=_required_owner(candidate),
|
|
151
|
+
source=candidate.source,
|
|
152
|
+
path=candidate.path,
|
|
153
|
+
order=order,
|
|
154
|
+
)
|
|
155
|
+
for order, candidate in enumerate(
|
|
156
|
+
item for item in contributed_candidates if item.kind is _RouteKind.WEBSOCKET
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return _RouteInstallationPlan(
|
|
160
|
+
routers=tuple(routers),
|
|
161
|
+
health_router=health_router,
|
|
162
|
+
catalog=RouterCatalog(registrations, websocket_registrations),
|
|
163
|
+
schema_candidates=schema_candidates,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _preview_router(app: FastAPI) -> APIRouter:
|
|
168
|
+
# 让 FastAPI 自己计算 include context 与 effective operation ID,不复制其展开算法。
|
|
169
|
+
return APIRouter(
|
|
170
|
+
prefix=app.router.prefix,
|
|
171
|
+
tags=list(app.router.tags),
|
|
172
|
+
dependencies=list(app.router.dependencies),
|
|
173
|
+
default_response_class=app.router.default_response_class,
|
|
174
|
+
responses=dict(app.router.responses),
|
|
175
|
+
deprecated=app.router.deprecated,
|
|
176
|
+
include_in_schema=app.router.include_in_schema,
|
|
177
|
+
generate_unique_id_function=app.router.generate_unique_id_function,
|
|
178
|
+
dependency_overrides_provider=app,
|
|
179
|
+
strict_content_type=app.router.strict_content_type,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _route_candidates(
|
|
184
|
+
routes: Sequence[BaseRoute],
|
|
185
|
+
*,
|
|
186
|
+
owner: ModuleKey | None,
|
|
187
|
+
owner_label: str,
|
|
188
|
+
require_managed: bool,
|
|
189
|
+
) -> tuple[_RouteCandidate, ...]:
|
|
190
|
+
candidates: list[_RouteCandidate] = []
|
|
191
|
+
for context in iter_route_contexts(routes):
|
|
192
|
+
original_route = context.original_route
|
|
193
|
+
if isinstance(original_route, WebSocketRoute):
|
|
194
|
+
candidates.append(
|
|
195
|
+
_websocket_route_candidate(
|
|
196
|
+
context,
|
|
197
|
+
owner=owner,
|
|
198
|
+
owner_label=owner_label,
|
|
199
|
+
require_managed=require_managed,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
continue
|
|
203
|
+
if require_managed and not isinstance(original_route, DishkaRoute):
|
|
204
|
+
raise FastApiAdapterError(
|
|
205
|
+
f"{owner_label} contributes unsupported {type(original_route).__name__}; "
|
|
206
|
+
"HTTP routes must be APIRoute instances created with DishkaRoute"
|
|
207
|
+
)
|
|
208
|
+
if not isinstance(original_route, APIRoute):
|
|
209
|
+
# Host-native Mount/plain routes remain Host-owned but不进入 Module catalog。
|
|
210
|
+
if require_managed:
|
|
211
|
+
raise FastApiAdapterError(
|
|
212
|
+
f"{owner_label} contributes unsupported {type(original_route).__name__}"
|
|
213
|
+
)
|
|
214
|
+
candidate = _plain_http_candidate(context, owner=owner, owner_label=owner_label)
|
|
215
|
+
if candidate is not None:
|
|
216
|
+
candidates.append(candidate)
|
|
217
|
+
continue
|
|
218
|
+
candidate = _api_route_candidate(context, owner=owner, owner_label=owner_label)
|
|
219
|
+
candidates.append(candidate)
|
|
220
|
+
return tuple(candidates)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _websocket_route_candidate(
|
|
224
|
+
context: RouteContext,
|
|
225
|
+
*,
|
|
226
|
+
owner: ModuleKey | None,
|
|
227
|
+
owner_label: str,
|
|
228
|
+
require_managed: bool,
|
|
229
|
+
) -> _RouteCandidate:
|
|
230
|
+
route = context.original_route
|
|
231
|
+
path = getattr(route, "path", None)
|
|
232
|
+
if not isinstance(path, str) or not path:
|
|
233
|
+
raise FastApiAdapterError(f"{owner_label} contains a WebSocket route without path")
|
|
234
|
+
managed_dependency_count = 0
|
|
235
|
+
if isinstance(route, APIWebSocketRoute):
|
|
236
|
+
managed_dependency_count = sum(
|
|
237
|
+
1
|
|
238
|
+
for dependency in route.dependant.dependencies
|
|
239
|
+
if dependency.call is websocket_connection
|
|
240
|
+
)
|
|
241
|
+
if require_managed and managed_dependency_count != 1:
|
|
242
|
+
raise FastApiAdapterError(
|
|
243
|
+
f"{owner_label} WebSocket route {path!r} must declare Depends(websocket_connection)"
|
|
244
|
+
)
|
|
245
|
+
return _RouteCandidate(
|
|
246
|
+
kind=_RouteKind.WEBSOCKET,
|
|
247
|
+
owner=owner,
|
|
248
|
+
source=_source_for_context(context, owner_label),
|
|
249
|
+
path=path,
|
|
250
|
+
normalized_path=_normalize_path(path),
|
|
251
|
+
methods=(),
|
|
252
|
+
operation_id=None,
|
|
253
|
+
include_in_schema=False,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _plain_http_candidate(
|
|
258
|
+
context: RouteContext,
|
|
259
|
+
*,
|
|
260
|
+
owner: ModuleKey | None,
|
|
261
|
+
owner_label: str,
|
|
262
|
+
) -> _RouteCandidate | None:
|
|
263
|
+
path = context.path
|
|
264
|
+
methods = context.methods
|
|
265
|
+
if path is None or not methods:
|
|
266
|
+
return None
|
|
267
|
+
source = _source_for_context(context, owner_label)
|
|
268
|
+
return _RouteCandidate(
|
|
269
|
+
kind=_RouteKind.HTTP,
|
|
270
|
+
owner=owner,
|
|
271
|
+
source=source,
|
|
272
|
+
path=path,
|
|
273
|
+
normalized_path=_normalize_path(path),
|
|
274
|
+
methods=tuple(sorted(method.upper() for method in methods)),
|
|
275
|
+
operation_id=None,
|
|
276
|
+
include_in_schema=False,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _api_route_candidate(
|
|
281
|
+
context: RouteContext,
|
|
282
|
+
*,
|
|
283
|
+
owner: ModuleKey | None,
|
|
284
|
+
owner_label: str,
|
|
285
|
+
) -> _RouteCandidate:
|
|
286
|
+
path = context.path
|
|
287
|
+
methods = context.methods
|
|
288
|
+
if path is None or not methods:
|
|
289
|
+
raise FastApiAdapterError(f"{owner_label} contains an APIRoute without path or methods")
|
|
290
|
+
route_view = cast(_OpenApiRouteView, context)
|
|
291
|
+
operation_id = route_view.operation_id or route_view.unique_id
|
|
292
|
+
if not isinstance(operation_id, str) or not operation_id:
|
|
293
|
+
raise FastApiAdapterError(f"{owner_label} route {path!r} has no effective operation ID")
|
|
294
|
+
return _RouteCandidate(
|
|
295
|
+
kind=_RouteKind.HTTP,
|
|
296
|
+
owner=owner,
|
|
297
|
+
source=_source_for_context(context, owner_label),
|
|
298
|
+
path=path,
|
|
299
|
+
normalized_path=_normalize_path(path),
|
|
300
|
+
methods=tuple(sorted(method.upper() for method in methods)),
|
|
301
|
+
operation_id=operation_id,
|
|
302
|
+
include_in_schema=route_view.include_in_schema,
|
|
303
|
+
http_sources=tuple(
|
|
304
|
+
getattr(
|
|
305
|
+
cast(APIRoute, context.original_route).endpoint,
|
|
306
|
+
"__python_platform_http_sources__",
|
|
307
|
+
{},
|
|
308
|
+
).items()
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _source_for_context(context: RouteContext, owner_label: str) -> SourceLocation:
|
|
314
|
+
endpoint = context.endpoint or getattr(context.original_route, "endpoint", None)
|
|
315
|
+
file_name: str | None = None
|
|
316
|
+
line: int | None = None
|
|
317
|
+
symbol: str | None = None
|
|
318
|
+
if endpoint is not None:
|
|
319
|
+
declared_source = getattr(endpoint, _ROUTE_SOURCE_ATTRIBUTE, None)
|
|
320
|
+
if isinstance(declared_source, SourceLocation):
|
|
321
|
+
return declared_source
|
|
322
|
+
symbol = getattr(endpoint, "__qualname__", getattr(endpoint, "__name__", None))
|
|
323
|
+
try:
|
|
324
|
+
file_name = inspect.getsourcefile(endpoint)
|
|
325
|
+
_, line = inspect.getsourcelines(endpoint)
|
|
326
|
+
except (OSError, TypeError):
|
|
327
|
+
pass
|
|
328
|
+
return SourceLocation(owner=owner_label, file=file_name, line=line, symbol=symbol)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _normalize_path(path: str) -> str:
|
|
332
|
+
def replace_parameter(match: re.Match[str]) -> str:
|
|
333
|
+
converter = match.group("converter") or ""
|
|
334
|
+
return "{" + converter + "}"
|
|
335
|
+
|
|
336
|
+
return _PATH_PARAMETER.sub(replace_parameter, path)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _validate_route_conflicts(candidates: Sequence[_RouteCandidate]) -> None:
|
|
340
|
+
by_method_path: dict[tuple[str, str], _RouteCandidate] = {}
|
|
341
|
+
by_websocket_path: dict[str, _RouteCandidate] = {}
|
|
342
|
+
by_operation_id: dict[str, _RouteCandidate] = {}
|
|
343
|
+
for candidate in candidates:
|
|
344
|
+
if candidate.kind is _RouteKind.WEBSOCKET:
|
|
345
|
+
existing_websocket = by_websocket_path.get(candidate.normalized_path)
|
|
346
|
+
if existing_websocket is not None:
|
|
347
|
+
raise RouteConflictError(
|
|
348
|
+
conflict_kind="WebSocket path template",
|
|
349
|
+
value=candidate.normalized_path,
|
|
350
|
+
sources=(existing_websocket.source, candidate.source),
|
|
351
|
+
)
|
|
352
|
+
by_websocket_path[candidate.normalized_path] = candidate
|
|
353
|
+
continue
|
|
354
|
+
for method in candidate.methods:
|
|
355
|
+
method_path = (method, candidate.normalized_path)
|
|
356
|
+
existing_path = by_method_path.get(method_path)
|
|
357
|
+
if existing_path is not None:
|
|
358
|
+
raise RouteConflictError(
|
|
359
|
+
conflict_kind="method/path template",
|
|
360
|
+
value=f"{method} {candidate.normalized_path}",
|
|
361
|
+
sources=(existing_path.source, candidate.source),
|
|
362
|
+
)
|
|
363
|
+
by_method_path[method_path] = candidate
|
|
364
|
+
|
|
365
|
+
if not candidate.include_in_schema or candidate.operation_id is None:
|
|
366
|
+
continue
|
|
367
|
+
existing_operation = by_operation_id.get(candidate.operation_id)
|
|
368
|
+
if existing_operation is not None:
|
|
369
|
+
raise RouteConflictError(
|
|
370
|
+
conflict_kind="OpenAPI operation ID",
|
|
371
|
+
value=candidate.operation_id,
|
|
372
|
+
sources=(existing_operation.source, candidate.source),
|
|
373
|
+
)
|
|
374
|
+
by_operation_id[candidate.operation_id] = candidate
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _required_owner(candidate: _RouteCandidate) -> ModuleKey:
|
|
378
|
+
if candidate.owner is None:
|
|
379
|
+
raise AssertionError("Module route candidate lost its owner")
|
|
380
|
+
return candidate.owner
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _required_operation_id(candidate: _RouteCandidate) -> str:
|
|
384
|
+
if candidate.operation_id is None:
|
|
385
|
+
raise AssertionError("Module route candidate lost its operation ID")
|
|
386
|
+
return candidate.operation_id
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _verify_native_openapi(app: FastAPI, candidates: Sequence[_RouteCandidate]) -> None:
|
|
390
|
+
schema = app.openapi()
|
|
391
|
+
raw_paths = schema.get("paths")
|
|
392
|
+
if not isinstance(raw_paths, Mapping):
|
|
393
|
+
raise FastApiAdapterError("FastAPI native OpenAPI schema has no paths mapping")
|
|
394
|
+
for candidate in candidates:
|
|
395
|
+
if candidate.kind is not _RouteKind.HTTP:
|
|
396
|
+
raise AssertionError("WebSocket route cannot enter OpenAPI verification")
|
|
397
|
+
path_item = raw_paths.get(candidate.path)
|
|
398
|
+
if not isinstance(path_item, Mapping):
|
|
399
|
+
raise FastApiAdapterError(
|
|
400
|
+
f"FastAPI native OpenAPI omitted contributed path {candidate.path!r}"
|
|
401
|
+
)
|
|
402
|
+
for method in candidate.methods:
|
|
403
|
+
operation = path_item.get(method.lower())
|
|
404
|
+
if not isinstance(operation, Mapping) or operation.get("operationId") != (
|
|
405
|
+
candidate.operation_id
|
|
406
|
+
):
|
|
407
|
+
raise FastApiAdapterError(
|
|
408
|
+
f"FastAPI native OpenAPI omitted {method} {candidate.path} "
|
|
409
|
+
f"operation {candidate.operation_id!r}"
|
|
410
|
+
)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Uvicorn 进程输出 owner:覆盖 factory、reload child 与 Application 关闭之后。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import multiprocessing
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from multiprocessing.synchronize import Event
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from uvicorn import Config, Server
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from uvicorn.supervisors.basereload import BaseReload as ChangeReload
|
|
20
|
+
else:
|
|
21
|
+
from uvicorn.supervisors import ChangeReload
|
|
22
|
+
|
|
23
|
+
from ..background_execution.locks import _check_previous_owners
|
|
24
|
+
from ..hosting import ENVIRONMENT_VARIABLE
|
|
25
|
+
from ..hosting.instance import _instance_lock
|
|
26
|
+
from ..observability.formatting import _SafeTextFormatter
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _BootstrapFilter(logging.Filter):
|
|
30
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
31
|
+
# Application handler 接管期间不重复输出;关闭移除后自动恢复早期输出。
|
|
32
|
+
return not any(
|
|
33
|
+
getattr(item, "_application_logging", False)
|
|
34
|
+
for handler in logging.getLogger().handlers
|
|
35
|
+
for item in handler.filters
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@contextmanager
|
|
40
|
+
def _host_logging() -> Iterator[None]:
|
|
41
|
+
root = logging.getLogger()
|
|
42
|
+
if any(
|
|
43
|
+
isinstance(item, _BootstrapFilter) for handler in root.handlers for item in handler.filters
|
|
44
|
+
):
|
|
45
|
+
yield
|
|
46
|
+
return
|
|
47
|
+
handler = logging.StreamHandler()
|
|
48
|
+
handler.setFormatter(_SafeTextFormatter("%(levelname)s %(name)s %(message)s"))
|
|
49
|
+
handler.addFilter(_BootstrapFilter())
|
|
50
|
+
previous_level = root.level
|
|
51
|
+
root.setLevel(logging.INFO)
|
|
52
|
+
root.addHandler(handler)
|
|
53
|
+
try:
|
|
54
|
+
yield
|
|
55
|
+
except Exception:
|
|
56
|
+
# 不让 multiprocessing 或解释器在安全 handler 退出后再次打印原始 traceback。
|
|
57
|
+
logging.getLogger(__name__).exception("Host execution failed")
|
|
58
|
+
raise SystemExit(3) from None
|
|
59
|
+
finally:
|
|
60
|
+
root.removeHandler(handler)
|
|
61
|
+
handler.close()
|
|
62
|
+
root.setLevel(previous_level)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _HostServer(Server):
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
config: Config,
|
|
69
|
+
shutdown_requested: Event | None = None,
|
|
70
|
+
*,
|
|
71
|
+
project: Path,
|
|
72
|
+
environment: str,
|
|
73
|
+
) -> None:
|
|
74
|
+
super().__init__(config)
|
|
75
|
+
self._shutdown_requested = shutdown_requested
|
|
76
|
+
self._project = project
|
|
77
|
+
self._environment = environment
|
|
78
|
+
|
|
79
|
+
def run(self, sockets: list[socket.socket] | None = None) -> None:
|
|
80
|
+
# 原生 reload 使用 spawn;bound target 在每个子进程单独建立输出生命周期。
|
|
81
|
+
with _host_logging():
|
|
82
|
+
super().run(sockets=sockets)
|
|
83
|
+
|
|
84
|
+
async def serve(self, sockets: list[socket.socket] | None = None) -> None:
|
|
85
|
+
# 每次正式 Host/reload child 均在 factory 前核对,包括新目录已移除全部任务的情况。
|
|
86
|
+
_check_previous_owners(self._project, self._environment)
|
|
87
|
+
shutdown_requested = self._shutdown_requested
|
|
88
|
+
if shutdown_requested is None:
|
|
89
|
+
await super().serve(sockets)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
async def watch_shutdown() -> None:
|
|
93
|
+
while not shutdown_requested.is_set():
|
|
94
|
+
await asyncio.sleep(self.config.reload_delay)
|
|
95
|
+
self.should_exit = True
|
|
96
|
+
|
|
97
|
+
# Uvicorn 的 Windows reload 依赖 console Ctrl+C 广播。显式的父子请求只改变
|
|
98
|
+
# 原生 should_exit,避免影响同一 console 中其他任务,也保留完整 ASGI 清理。
|
|
99
|
+
watcher = asyncio.create_task(watch_shutdown())
|
|
100
|
+
try:
|
|
101
|
+
await super().serve(sockets)
|
|
102
|
+
finally:
|
|
103
|
+
watcher.cancel()
|
|
104
|
+
await asyncio.gather(watcher, return_exceptions=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _HostReload(ChangeReload):
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
config: Config,
|
|
111
|
+
server: _HostServer,
|
|
112
|
+
sockets: list[socket.socket],
|
|
113
|
+
shutdown_requested: Event,
|
|
114
|
+
) -> None:
|
|
115
|
+
super().__init__(config, target=server.run, sockets=sockets)
|
|
116
|
+
self._shutdown_requested = shutdown_requested
|
|
117
|
+
|
|
118
|
+
def run(self) -> None:
|
|
119
|
+
try:
|
|
120
|
+
super().run()
|
|
121
|
+
finally:
|
|
122
|
+
# 原生监测循环异常时也必须等待子进程;否则退出父进程的 with 会过早释放位置。
|
|
123
|
+
process = getattr(self, "process", None)
|
|
124
|
+
if process is not None and process.is_alive():
|
|
125
|
+
self.shutdown()
|
|
126
|
+
|
|
127
|
+
def restart(self) -> None:
|
|
128
|
+
# 旧进程尚未退出就一直占位;不靠超时/PID 猜测,更不强杀后立即补位。
|
|
129
|
+
self._shutdown_requested.set()
|
|
130
|
+
self.process.join()
|
|
131
|
+
self._shutdown_requested.clear()
|
|
132
|
+
# 原生 startup 仍负责 spawn、信号接线和 socket 传递,不复制进程创建实现。
|
|
133
|
+
self.startup()
|
|
134
|
+
|
|
135
|
+
def shutdown(self) -> None:
|
|
136
|
+
self._shutdown_requested.set()
|
|
137
|
+
super().shutdown()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _run_host(
|
|
141
|
+
app: str,
|
|
142
|
+
*,
|
|
143
|
+
host: str,
|
|
144
|
+
port: int,
|
|
145
|
+
reload: bool = False,
|
|
146
|
+
project: Path | None = None,
|
|
147
|
+
environment: str | None = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
# 保留原生 Config/Server/ChangeReload,平台仅补进程输出边界,不接管 ASGI 生命周期。
|
|
150
|
+
# reload 的父进程持锁直到所有子进程 join;子进程不重复占用主 Host 位置。
|
|
151
|
+
# 此边界早于 factory 加载和运行期初始化,DB CLI/TestApplication 不经过这里。
|
|
152
|
+
project = Path.cwd() if project is None else project
|
|
153
|
+
environment = (
|
|
154
|
+
environment if environment is not None else os.getenv(ENVIRONMENT_VARIABLE, "development")
|
|
155
|
+
)
|
|
156
|
+
instance = _instance_lock(project, environment)
|
|
157
|
+
with _host_logging(), instance:
|
|
158
|
+
config = Config(
|
|
159
|
+
app,
|
|
160
|
+
factory=True,
|
|
161
|
+
host=host,
|
|
162
|
+
port=port,
|
|
163
|
+
reload=reload,
|
|
164
|
+
log_config=None,
|
|
165
|
+
access_log=False,
|
|
166
|
+
)
|
|
167
|
+
shutdown_requested = (
|
|
168
|
+
multiprocessing.get_context("spawn").Event() if config.should_reload else None
|
|
169
|
+
)
|
|
170
|
+
server = _HostServer(config, shutdown_requested, project=project, environment=environment)
|
|
171
|
+
if config.should_reload:
|
|
172
|
+
sock = config.bind_socket()
|
|
173
|
+
try:
|
|
174
|
+
assert shutdown_requested is not None
|
|
175
|
+
_HostReload(config, server, [sock], shutdown_requested).run()
|
|
176
|
+
finally:
|
|
177
|
+
sock.close()
|
|
178
|
+
else:
|
|
179
|
+
server.run()
|
|
180
|
+
if not server.started:
|
|
181
|
+
raise SystemExit(3)
|