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,19 @@
|
|
|
1
|
+
"""独立 typed 通知契约;provider 由 Host 选择。"""
|
|
2
|
+
|
|
3
|
+
from .catalog import NotificationCatalog
|
|
4
|
+
from .contracts import (
|
|
5
|
+
NotificationDefinition,
|
|
6
|
+
NotificationDefinitionError,
|
|
7
|
+
NotificationPayloadError,
|
|
8
|
+
NotificationPublishStatus,
|
|
9
|
+
Notifications,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = (
|
|
13
|
+
"NotificationCatalog",
|
|
14
|
+
"NotificationDefinition",
|
|
15
|
+
"NotificationDefinitionError",
|
|
16
|
+
"NotificationPayloadError",
|
|
17
|
+
"NotificationPublishStatus",
|
|
18
|
+
"Notifications",
|
|
19
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""从已解析 Module 冻结通知声明;不依赖 Redis 或 Settings。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..modularity import ModuleRegistry
|
|
10
|
+
from .contracts import NotificationDefinition, NotificationDefinitionError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotificationCatalog:
|
|
14
|
+
__slots__ = ("_definitions",)
|
|
15
|
+
|
|
16
|
+
def __init__(self, definitions: tuple[NotificationDefinition[Any], ...]) -> None:
|
|
17
|
+
indexed: dict[str, NotificationDefinition[Any]] = {}
|
|
18
|
+
for definition in definitions:
|
|
19
|
+
if not isinstance(definition, NotificationDefinition) or definition.name in indexed:
|
|
20
|
+
raise NotificationDefinitionError(
|
|
21
|
+
"Notification declarations must have unique valid names"
|
|
22
|
+
)
|
|
23
|
+
indexed[definition.name] = definition
|
|
24
|
+
self._definitions: Mapping[str, NotificationDefinition[Any]] = MappingProxyType(indexed)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def definitions(self) -> tuple[NotificationDefinition[Any], ...]:
|
|
28
|
+
return tuple(self._definitions.values())
|
|
29
|
+
|
|
30
|
+
def require_owned(self, definition: NotificationDefinition[Any]) -> None:
|
|
31
|
+
if self._definitions.get(definition.name) is not definition:
|
|
32
|
+
raise NotificationDefinitionError(
|
|
33
|
+
"Notification definition is not owned by this Application"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_notification_catalog(registry: ModuleRegistry) -> NotificationCatalog:
|
|
38
|
+
definitions: list[NotificationDefinition[Any]] = []
|
|
39
|
+
for descriptor in registry.dependency_order:
|
|
40
|
+
declared = descriptor.module_type.notification_definitions
|
|
41
|
+
if not isinstance(declared, tuple):
|
|
42
|
+
raise NotificationDefinitionError(
|
|
43
|
+
"Module notification_definitions must be an immutable tuple"
|
|
44
|
+
)
|
|
45
|
+
definitions.extend(declared)
|
|
46
|
+
return NotificationCatalog(tuple(definitions))
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Typed Pub/Sub 契约;发布结果不代表送达或组件应用确认。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
7
|
+
from contextlib import AbstractAsyncContextManager
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Generic, Protocol, TypeVar, cast
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
12
|
+
|
|
13
|
+
from ..configuration.immutability import (
|
|
14
|
+
_serialization_value,
|
|
15
|
+
freeze_typed_value,
|
|
16
|
+
validate_immutable_annotation,
|
|
17
|
+
)
|
|
18
|
+
from ..errors import FrameworkError
|
|
19
|
+
|
|
20
|
+
_T = TypeVar("_T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NotificationDefinitionError(FrameworkError):
|
|
24
|
+
"""声明重复、类型不合法或引用不属于当前 Application。"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NotificationPayloadError(FrameworkError):
|
|
28
|
+
"""只暴露安全错误类型,不携带原始消息。"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NotificationPublishStatus(str, Enum):
|
|
32
|
+
NOT_CONFIGURED = "not_configured"
|
|
33
|
+
PUBLISHED = "published"
|
|
34
|
+
UNAVAILABLE = "unavailable"
|
|
35
|
+
REJECTED = "rejected"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _NotificationEnvelope(BaseModel, Generic[_T]):
|
|
39
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
40
|
+
|
|
41
|
+
payload: _T
|
|
42
|
+
traceparent: str | None = Field(default=None, max_length=512)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NotificationDefinition(Generic[_T]):
|
|
46
|
+
__slots__ = ("_adapter", "_name", "_value_type")
|
|
47
|
+
|
|
48
|
+
def __init__(self, name: str, value_type: type[_T]) -> None:
|
|
49
|
+
if (
|
|
50
|
+
not isinstance(name, str)
|
|
51
|
+
or re.fullmatch(r"[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*", name) is None
|
|
52
|
+
):
|
|
53
|
+
raise NotificationDefinitionError(
|
|
54
|
+
"Notification name must be a canonical dotted identifier"
|
|
55
|
+
)
|
|
56
|
+
try:
|
|
57
|
+
validate_immutable_annotation(value_type)
|
|
58
|
+
self._adapter = TypeAdapter(_NotificationEnvelope[value_type]) # type: ignore[valid-type]
|
|
59
|
+
except (TypeError, ValueError):
|
|
60
|
+
raise NotificationDefinitionError(
|
|
61
|
+
"Notification payload type must support immutable JSON values"
|
|
62
|
+
) from None
|
|
63
|
+
self._name = name
|
|
64
|
+
self._value_type = value_type
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def name(self) -> str:
|
|
68
|
+
return self._name
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def value_type(self) -> type[_T]:
|
|
72
|
+
return self._value_type
|
|
73
|
+
|
|
74
|
+
def _encode(self, payload: _T, carrier: Mapping[str, str]) -> bytes:
|
|
75
|
+
try:
|
|
76
|
+
payload = cast(_T, freeze_typed_value(payload, allow_secrets=True))
|
|
77
|
+
envelope = self._adapter.validate_python(
|
|
78
|
+
{"payload": payload, "traceparent": carrier.get("traceparent")}
|
|
79
|
+
)
|
|
80
|
+
return self._adapter.dump_json(
|
|
81
|
+
cast(_NotificationEnvelope[_T], _serialization_value(envelope))
|
|
82
|
+
)
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
raise NotificationPayloadError("Invalid notification payload") from None
|
|
85
|
+
|
|
86
|
+
def _decode(self, encoded: bytes) -> tuple[_T, Mapping[str, str]]:
|
|
87
|
+
try:
|
|
88
|
+
envelope = self._adapter.validate_json(encoded)
|
|
89
|
+
value = cast(_T, freeze_typed_value(envelope.payload, allow_secrets=True))
|
|
90
|
+
carrier = {} if envelope.traceparent is None else {"traceparent": envelope.traceparent}
|
|
91
|
+
return value, carrier
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
raise NotificationPayloadError("Invalid notification envelope") from None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Notifications(Protocol):
|
|
97
|
+
async def publish(
|
|
98
|
+
self, definition: NotificationDefinition[_T], payload: _T
|
|
99
|
+
) -> NotificationPublishStatus: ...
|
|
100
|
+
|
|
101
|
+
def subscribe(
|
|
102
|
+
self,
|
|
103
|
+
definition: NotificationDefinition[_T],
|
|
104
|
+
handler: Callable[[_T], Awaitable[None]],
|
|
105
|
+
*,
|
|
106
|
+
on_reconnect: Callable[[], None] | None = None,
|
|
107
|
+
) -> AbstractAsyncContextManager[None]: ...
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""调用追踪边界;基础安装不加载 OpenTelemetry。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from opentelemetry.trace import Tracer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@contextmanager
|
|
14
|
+
def invocation_span(
|
|
15
|
+
tracer: Tracer | None, name: str, *, carrier: Mapping[str, str] | None = None
|
|
16
|
+
) -> Iterator[None]:
|
|
17
|
+
if tracer is None:
|
|
18
|
+
yield
|
|
19
|
+
return
|
|
20
|
+
from opentelemetry.trace import StatusCode
|
|
21
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
22
|
+
|
|
23
|
+
context = None if carrier is None else TraceContextTextMapPropagator().extract(carrier)
|
|
24
|
+
# 异常类型由现有业务/任务边界处理,不复制可能含凭据的 exception.message。
|
|
25
|
+
with tracer.start_as_current_span(
|
|
26
|
+
name, context=context, record_exception=False, set_status_on_exception=False
|
|
27
|
+
) as span:
|
|
28
|
+
try:
|
|
29
|
+
yield
|
|
30
|
+
except BaseException:
|
|
31
|
+
span.set_status(StatusCode.ERROR)
|
|
32
|
+
raise
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def capture_trace_context(tracer: Tracer | None) -> dict[str, str]:
|
|
36
|
+
if tracer is None:
|
|
37
|
+
return {}
|
|
38
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
39
|
+
|
|
40
|
+
carrier: dict[str, str] = {}
|
|
41
|
+
TraceContextTextMapPropagator().inject(carrier)
|
|
42
|
+
# envelope 只传播最小父链路;不传播 baggage、tracestate、身份或任意 header。
|
|
43
|
+
return {key: value for key, value in carrier.items() if key == "traceparent"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""OTLP 之前的安全数据边界,复用原生 exporter、batching 与 HTTP 传输。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
9
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
10
|
+
from opentelemetry.semconv.trace import SpanAttributes
|
|
11
|
+
from opentelemetry.trace import Link, Status
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
# 这是导出的公开隐私契约;不按业务字段、模块或调用者维护第二份允许清单。
|
|
15
|
+
_SAFE_ATTRIBUTES = frozenset(
|
|
16
|
+
{
|
|
17
|
+
SpanAttributes.HTTP_METHOD,
|
|
18
|
+
SpanAttributes.HTTP_ROUTE,
|
|
19
|
+
SpanAttributes.HTTP_STATUS_CODE,
|
|
20
|
+
"http.request.method",
|
|
21
|
+
"http.response.status_code",
|
|
22
|
+
"error.type",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _SafeSpanExporter(SpanExporter):
|
|
28
|
+
def __init__(self, exporter: SpanExporter) -> None:
|
|
29
|
+
self._exporter = exporter
|
|
30
|
+
|
|
31
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
32
|
+
try:
|
|
33
|
+
return self._exporter.export(tuple(_safe_span(span) for span in spans))
|
|
34
|
+
except Exception as error:
|
|
35
|
+
_LOGGER.warning("Trace export failed (%s)", type(error).__name__)
|
|
36
|
+
return SpanExportResult.FAILURE
|
|
37
|
+
|
|
38
|
+
def shutdown(self) -> None:
|
|
39
|
+
self._exporter.shutdown()
|
|
40
|
+
|
|
41
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
42
|
+
return self._exporter.force_flush(timeout_millis)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _safe_span(span: ReadableSpan) -> ReadableSpan:
|
|
46
|
+
# instrumentation 会自动产生 URL、exception event/status description;只转换导出副本,
|
|
47
|
+
# 不修改 SDK 正在使用的 span,也不让原始请求/任务值越过这个输出边界。
|
|
48
|
+
return ReadableSpan(
|
|
49
|
+
name=span.name,
|
|
50
|
+
context=span.context,
|
|
51
|
+
parent=span.parent,
|
|
52
|
+
resource=span.resource,
|
|
53
|
+
attributes={
|
|
54
|
+
key: value for key, value in (span.attributes or {}).items() if key in _SAFE_ATTRIBUTES
|
|
55
|
+
},
|
|
56
|
+
events=(),
|
|
57
|
+
links=tuple(Link(link.context) for link in span.links),
|
|
58
|
+
kind=span.kind,
|
|
59
|
+
status=Status(span.status.status_code),
|
|
60
|
+
start_time=span.start_time,
|
|
61
|
+
end_time=span.end_time,
|
|
62
|
+
instrumentation_scope=span.instrumentation_scope,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _ExportDiagnosticFilter(logging.Filter):
|
|
67
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
68
|
+
# 官方 HTTP exporter 的 reason 来自远端响应/连接异常,不应作为日志正文输出。
|
|
69
|
+
record.msg = "OTLP exporter diagnostic"
|
|
70
|
+
record.args = ()
|
|
71
|
+
record.exc_info = None
|
|
72
|
+
record.exc_text = None
|
|
73
|
+
return True
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""标准库安全输出边界,启动期与 Application 共用;不加载 logging extra。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import traceback
|
|
9
|
+
from copy import copy
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_DIAGNOSTIC_FIELDS = (
|
|
14
|
+
"background_worker",
|
|
15
|
+
"background_exception_type",
|
|
16
|
+
"audit_exception_type",
|
|
17
|
+
"audit_contract",
|
|
18
|
+
"audit_method",
|
|
19
|
+
"audit_route_template",
|
|
20
|
+
)
|
|
21
|
+
_HTTP_DIAGNOSTIC_LOGGERS = ("httpx", "httpcore", "uvicorn.access")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _safe_exception(info: Any) -> str:
|
|
25
|
+
# walk_tb 不取源码行或 locals;异常正文、notes 永远不进入输出。
|
|
26
|
+
if info[1] is None:
|
|
27
|
+
return "No active exception"
|
|
28
|
+
lines: list[str] = []
|
|
29
|
+
seen: set[int] = set()
|
|
30
|
+
|
|
31
|
+
def visit(
|
|
32
|
+
error: BaseException,
|
|
33
|
+
relation: str,
|
|
34
|
+
depth: int,
|
|
35
|
+
trace: TracebackType | None,
|
|
36
|
+
) -> None:
|
|
37
|
+
prefix = " " * depth + relation
|
|
38
|
+
if depth >= 32:
|
|
39
|
+
lines.append(prefix + "<exception depth limit>")
|
|
40
|
+
return
|
|
41
|
+
if id(error) in seen:
|
|
42
|
+
lines.append(prefix + "<exception reference cycle>")
|
|
43
|
+
return
|
|
44
|
+
seen.add(id(error))
|
|
45
|
+
lines.append(prefix + type(error).__qualname__)
|
|
46
|
+
for frame, lineno in traceback.walk_tb(trace):
|
|
47
|
+
lines.append(
|
|
48
|
+
f"{' ' * (depth + 1)}{frame.f_code.co_filename}:{lineno} in {frame.f_code.co_name}"
|
|
49
|
+
)
|
|
50
|
+
if error.__cause__ is not None:
|
|
51
|
+
visit(error.__cause__, "cause: ", depth + 1, error.__cause__.__traceback__)
|
|
52
|
+
elif error.__context__ is not None and not error.__suppress_context__:
|
|
53
|
+
visit(error.__context__, "context: ", depth + 1, error.__context__.__traceback__)
|
|
54
|
+
if isinstance(error, BaseExceptionGroup):
|
|
55
|
+
for index, child in enumerate(error.exceptions):
|
|
56
|
+
visit(child, f"group[{index}]: ", depth + 1, child.__traceback__)
|
|
57
|
+
|
|
58
|
+
visit(info[1], "", 0, info[2])
|
|
59
|
+
return "\n".join(lines)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _SafeTextFormatter(logging.Formatter):
|
|
63
|
+
converter = staticmethod(time.gmtime)
|
|
64
|
+
_append_diagnostics = True
|
|
65
|
+
|
|
66
|
+
def formatException(self, ei: Any) -> str:
|
|
67
|
+
return _safe_exception(ei)
|
|
68
|
+
|
|
69
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
70
|
+
safe_record = copy(record)
|
|
71
|
+
# handler 共享 record;清空其他 handler 的缓存及原生含源码的 stack_info。
|
|
72
|
+
safe_record.exc_text = None
|
|
73
|
+
safe_record.stack_info = None
|
|
74
|
+
if any(
|
|
75
|
+
record.name == name or record.name.startswith(name + ".")
|
|
76
|
+
for name in _HTTP_DIAGNOSTIC_LOGGERS
|
|
77
|
+
):
|
|
78
|
+
safe_record.msg, safe_record.args = "HTTP transport diagnostic", ()
|
|
79
|
+
elif record.name == "uvicorn" or record.name.startswith("uvicorn."):
|
|
80
|
+
# Uvicorn 的 factory/lifespan 错误可能已把异常插入 message,不能格式化原正文。
|
|
81
|
+
safe_record.msg, safe_record.args = "ASGI server diagnostic", ()
|
|
82
|
+
if safe_record.exc_info is None and sys.exc_info()[0] is not None:
|
|
83
|
+
safe_record.exc_info = sys.exc_info()
|
|
84
|
+
rendered = super().format(safe_record)
|
|
85
|
+
if not self._append_diagnostics:
|
|
86
|
+
return rendered
|
|
87
|
+
return rendered + "".join(
|
|
88
|
+
f" {key}={record.__dict__[key]}" for key in _DIAGNOSTIC_FIELDS if key in record.__dict__
|
|
89
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Host 配置标准 logging;业务使用 logging.getLogger,不需要新的日志 API。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pythonjsonlogger.json import JsonFormatter
|
|
9
|
+
|
|
10
|
+
from python_platform import ConfigureContext
|
|
11
|
+
|
|
12
|
+
from ..application_services.invocation import _InvocationRuntime
|
|
13
|
+
from ..lifecycle import PreInitializeContext, ShutdownContext
|
|
14
|
+
from ..modularity import AppModule
|
|
15
|
+
from .formatting import _DIAGNOSTIC_FIELDS, _SafeTextFormatter
|
|
16
|
+
from .options import LoggingOptions
|
|
17
|
+
|
|
18
|
+
_LOG_FIELDS = (
|
|
19
|
+
"asctime",
|
|
20
|
+
"levelname",
|
|
21
|
+
"name",
|
|
22
|
+
"application",
|
|
23
|
+
"environment",
|
|
24
|
+
"correlation_id",
|
|
25
|
+
"trace_id",
|
|
26
|
+
"span_id",
|
|
27
|
+
"message",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ApplicationLogFilter(logging.Filter):
|
|
32
|
+
_application_logging = True
|
|
33
|
+
|
|
34
|
+
def __init__(self, runtime: _InvocationRuntime, options: LoggingOptions) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._runtime = runtime
|
|
37
|
+
self._options = options
|
|
38
|
+
|
|
39
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
40
|
+
record.application = self._options.application
|
|
41
|
+
record.environment = self._options.environment
|
|
42
|
+
record.correlation_id = self._runtime.diagnostic_correlation_id()
|
|
43
|
+
record.trace_id = None
|
|
44
|
+
record.span_id = None
|
|
45
|
+
if self._runtime.tracer is not None:
|
|
46
|
+
from opentelemetry.trace import get_current_span
|
|
47
|
+
|
|
48
|
+
context = get_current_span().get_span_context()
|
|
49
|
+
if context.is_valid:
|
|
50
|
+
record.trace_id = format(context.trace_id, "032x")
|
|
51
|
+
record.span_id = format(context.span_id, "016x")
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _SafeJsonFormatter(_SafeTextFormatter, JsonFormatter):
|
|
56
|
+
_append_diagnostics = False
|
|
57
|
+
|
|
58
|
+
def process_log_record(self, log_data: dict[str, Any]) -> dict[str, Any]:
|
|
59
|
+
# 默认不把任意 extra 字典(可能含 request/job payload)自动写入 JSON。
|
|
60
|
+
return {
|
|
61
|
+
key: value
|
|
62
|
+
for key, value in log_data.items()
|
|
63
|
+
if key in (*_LOG_FIELDS, *_DIAGNOSTIC_FIELDS, "exc_info")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class StructuredLoggingModule(AppModule):
|
|
68
|
+
def configure(self, context: ConfigureContext) -> None:
|
|
69
|
+
context.configure(LoggingOptions, section="logging")
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
self._handler: logging.StreamHandler[Any] | None = None
|
|
73
|
+
self._previous_level: int | None = None
|
|
74
|
+
self._category_levels: dict[str, int] = {}
|
|
75
|
+
|
|
76
|
+
async def pre_initialize(self, context: PreInitializeContext) -> None:
|
|
77
|
+
root = logging.getLogger()
|
|
78
|
+
if any(
|
|
79
|
+
isinstance(log_filter, ApplicationLogFilter)
|
|
80
|
+
for handler in root.handlers
|
|
81
|
+
for log_filter in handler.filters
|
|
82
|
+
):
|
|
83
|
+
raise RuntimeError("one Host must own the process logging configuration")
|
|
84
|
+
options = context.options.get(LoggingOptions)
|
|
85
|
+
runtime = await context.container.get(_InvocationRuntime)
|
|
86
|
+
handler: logging.StreamHandler[Any] = logging.StreamHandler()
|
|
87
|
+
handler.addFilter(ApplicationLogFilter(runtime, options))
|
|
88
|
+
fields = " ".join(f"%({field})s" for field in _LOG_FIELDS)
|
|
89
|
+
formatter = (
|
|
90
|
+
_SafeJsonFormatter(fields) if options.format == "json" else _SafeTextFormatter(fields)
|
|
91
|
+
)
|
|
92
|
+
handler.setFormatter(formatter)
|
|
93
|
+
handler.setLevel(
|
|
94
|
+
min(
|
|
95
|
+
logging.getLevelName(level)
|
|
96
|
+
for level in (options.level, *options.logger_levels.values())
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
for name, level in options.logger_levels.items():
|
|
100
|
+
logger = logging.getLogger(name)
|
|
101
|
+
self._category_levels[name] = logger.level
|
|
102
|
+
logger.setLevel(level)
|
|
103
|
+
self._previous_level = root.level
|
|
104
|
+
root.setLevel(options.level)
|
|
105
|
+
root.addHandler(handler)
|
|
106
|
+
self._handler = handler
|
|
107
|
+
|
|
108
|
+
async def shutdown(self, context: ShutdownContext) -> None:
|
|
109
|
+
if self._handler is not None:
|
|
110
|
+
root = logging.getLogger()
|
|
111
|
+
root.removeHandler(self._handler)
|
|
112
|
+
self._handler.close()
|
|
113
|
+
self._handler = None
|
|
114
|
+
if self._previous_level is not None:
|
|
115
|
+
root.setLevel(self._previous_level)
|
|
116
|
+
for name, level in self._category_levels.items():
|
|
117
|
+
logging.getLogger(name).setLevel(level)
|
|
118
|
+
self._category_levels.clear()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Host 显式选择日志输出及追踪导出;不从业务代码修改运行配置。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Literal
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
from pydantic import Field, model_validator
|
|
10
|
+
|
|
11
|
+
from ..options import BaseOptions
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LoggingOptions(BaseOptions):
|
|
15
|
+
application: str = Field(min_length=1)
|
|
16
|
+
environment: str = Field(min_length=1)
|
|
17
|
+
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
|
18
|
+
format: Literal["text", "json"] = "text"
|
|
19
|
+
logger_levels: Mapping[str, Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]] = Field(
|
|
20
|
+
default_factory=dict
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@model_validator(mode="after")
|
|
24
|
+
def _validate_logger_names(self) -> LoggingOptions:
|
|
25
|
+
if any(not name or name.strip() != name or name == "root" for name in self.logger_levels):
|
|
26
|
+
raise ValueError("logger_levels requires nonempty category names; root uses level")
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TracingOptions(BaseOptions):
|
|
31
|
+
service_name: str = Field(min_length=1)
|
|
32
|
+
export_enabled: bool = False
|
|
33
|
+
endpoint: str = "http://localhost:4318/v1/traces"
|
|
34
|
+
queue_size: int = Field(default=2048, ge=1)
|
|
35
|
+
batch_size: int = Field(default=512, ge=1)
|
|
36
|
+
export_timeout_seconds: float = Field(default=5, gt=0, le=30)
|
|
37
|
+
|
|
38
|
+
@model_validator(mode="after")
|
|
39
|
+
def _validate_export(self) -> TracingOptions:
|
|
40
|
+
target = urlsplit(self.endpoint)
|
|
41
|
+
if target.scheme not in ("http", "https") or not target.hostname or target.username:
|
|
42
|
+
raise ValueError("OTLP endpoint must be an HTTP URL without credentials")
|
|
43
|
+
if self.batch_size > self.queue_size:
|
|
44
|
+
raise ValueError("trace batch size cannot exceed queue size")
|
|
45
|
+
return self
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Application-owned OpenTelemetry SDK,Host 按需启用有界 OTLP 导出。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from dishka import Provider, Scope
|
|
9
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
10
|
+
from opentelemetry.sdk.resources import Resource
|
|
11
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
12
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
13
|
+
|
|
14
|
+
from ..application_services.invocation import _InvocationRuntime
|
|
15
|
+
from ..lifecycle import ConfigureContext, PreInitializeContext, ShutdownContext
|
|
16
|
+
from ..modularity import AppModule
|
|
17
|
+
from ..options import Options
|
|
18
|
+
from .export import _ExportDiagnosticFilter, _SafeSpanExporter
|
|
19
|
+
from .options import TracingOptions
|
|
20
|
+
|
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TracingModule(AppModule):
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._provider: TracerProvider | None = None
|
|
27
|
+
self._export_filter: _ExportDiagnosticFilter | None = None
|
|
28
|
+
|
|
29
|
+
def configure(self, context: ConfigureContext) -> None:
|
|
30
|
+
context.configure(TracingOptions, section="tracing")
|
|
31
|
+
provider = Provider()
|
|
32
|
+
provider.provide(self._prepare, provides=TracerProvider, scope=Scope.APP)
|
|
33
|
+
context.services.contribute(provider, reason="Application-owned OpenTelemetry provider")
|
|
34
|
+
|
|
35
|
+
def _prepare(self, options: Options[TracingOptions]) -> TracerProvider:
|
|
36
|
+
if self._provider is None:
|
|
37
|
+
# Web 安装可先取得 provider;此时没有 exporter/thread/I/O。导出只在 runtime 启动。
|
|
38
|
+
self._provider = TracerProvider(
|
|
39
|
+
resource=Resource({"service.name": options.value.service_name}),
|
|
40
|
+
shutdown_on_exit=False,
|
|
41
|
+
)
|
|
42
|
+
return self._provider
|
|
43
|
+
|
|
44
|
+
async def pre_initialize(self, context: PreInitializeContext) -> None:
|
|
45
|
+
options = context.options.get(TracingOptions)
|
|
46
|
+
provider = await context.container.get(TracerProvider)
|
|
47
|
+
runtime = await context.container.get(_InvocationRuntime)
|
|
48
|
+
runtime.tracer = provider.get_tracer(__package__)
|
|
49
|
+
if options.export_enabled:
|
|
50
|
+
self._export_filter = _ExportDiagnosticFilter()
|
|
51
|
+
logging.getLogger(OTLPSpanExporter.__module__).addFilter(self._export_filter)
|
|
52
|
+
provider.add_span_processor(
|
|
53
|
+
BatchSpanProcessor(
|
|
54
|
+
_SafeSpanExporter(
|
|
55
|
+
OTLPSpanExporter(
|
|
56
|
+
endpoint=options.endpoint, timeout=options.export_timeout_seconds
|
|
57
|
+
)
|
|
58
|
+
),
|
|
59
|
+
max_queue_size=options.queue_size,
|
|
60
|
+
max_export_batch_size=options.batch_size,
|
|
61
|
+
export_timeout_millis=options.export_timeout_seconds * 1000,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def shutdown(self, context: ShutdownContext) -> None:
|
|
66
|
+
if self._provider is None:
|
|
67
|
+
return
|
|
68
|
+
runtime = await context.container.get(_InvocationRuntime)
|
|
69
|
+
runtime.tracer = None
|
|
70
|
+
try:
|
|
71
|
+
# SDK BatchSpanProcessor 自身限制关闭等待;等待原生关闭结束再撤销输出过滤,
|
|
72
|
+
# 不取消 to_thread 留下仍在导出、却已失去生命周期 owner 的后台工作。
|
|
73
|
+
await asyncio.to_thread(self._provider.shutdown)
|
|
74
|
+
except Exception as error:
|
|
75
|
+
_LOGGER.warning("Trace shutdown failed (%s)", type(error).__name__)
|
|
76
|
+
finally:
|
|
77
|
+
if self._export_filter is not None:
|
|
78
|
+
logging.getLogger(OTLPSpanExporter.__module__).removeFilter(self._export_filter)
|
|
79
|
+
self._export_filter = None
|
|
80
|
+
self._provider = None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Typed Options public contracts and Application-internal binding owner."""
|
|
2
|
+
|
|
3
|
+
from .models import (
|
|
4
|
+
BaseOptions,
|
|
5
|
+
Options,
|
|
6
|
+
OptionsContribution,
|
|
7
|
+
OptionsSnapshot,
|
|
8
|
+
OptionsValidationIssue,
|
|
9
|
+
PreOptionsSnapshot,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = (
|
|
13
|
+
"BaseOptions",
|
|
14
|
+
"Options",
|
|
15
|
+
"OptionsContribution",
|
|
16
|
+
"OptionsSnapshot",
|
|
17
|
+
"OptionsValidationIssue",
|
|
18
|
+
"PreOptionsSnapshot",
|
|
19
|
+
)
|