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,427 @@
|
|
|
1
|
+
"""Configuration value canonicalization、deep immutability 与统一脱敏。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import keyword
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from datetime import date, datetime, time, timedelta
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from typing import ClassVar, Final, cast
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from pydantic import Secret, SecretBytes, SecretStr
|
|
16
|
+
|
|
17
|
+
MISSING: Final = object()
|
|
18
|
+
REDACTION_MARKER: Final = "********"
|
|
19
|
+
_SECRET_NAME_SUFFIXES: Final = (
|
|
20
|
+
"access_key",
|
|
21
|
+
"api_key",
|
|
22
|
+
"apikey",
|
|
23
|
+
"credential",
|
|
24
|
+
"credentials",
|
|
25
|
+
"password",
|
|
26
|
+
"passwd",
|
|
27
|
+
"private_key",
|
|
28
|
+
"secret",
|
|
29
|
+
"token",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _ImmutableSecretMixin:
|
|
34
|
+
__slots__ = ()
|
|
35
|
+
_wrapped_secret_type: ClassVar[type[object]]
|
|
36
|
+
|
|
37
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
38
|
+
# Pydantic Secret 只保证显示脱敏,发布后还必须阻止改写其内部 payload。
|
|
39
|
+
if hasattr(self, "_secret_value"):
|
|
40
|
+
raise TypeError("published secret values are immutable")
|
|
41
|
+
super().__setattr__(name, value)
|
|
42
|
+
|
|
43
|
+
def __eq__(self, other: object) -> bool:
|
|
44
|
+
return (
|
|
45
|
+
isinstance(other, _ImmutableSecretMixin)
|
|
46
|
+
and self._wrapped_secret_type is other._wrapped_secret_type
|
|
47
|
+
and secret_payload(self) == secret_payload(other)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def __hash__(self) -> int:
|
|
51
|
+
return hash((self._wrapped_secret_type, secret_payload(self)))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _FrozenSecretStr(_ImmutableSecretMixin, SecretStr):
|
|
55
|
+
__slots__ = ()
|
|
56
|
+
_wrapped_secret_type = SecretStr
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _FrozenSecretBytes(_ImmutableSecretMixin, SecretBytes):
|
|
60
|
+
__slots__ = ()
|
|
61
|
+
_wrapped_secret_type = SecretBytes
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _FrozenSecret(_ImmutableSecretMixin, Secret[object]):
|
|
65
|
+
__slots__ = ()
|
|
66
|
+
_wrapped_secret_type = Secret
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize_mapping(
|
|
70
|
+
values: Mapping[str, object],
|
|
71
|
+
*,
|
|
72
|
+
mapping_paths: frozenset[tuple[str | int | None, ...]] = frozenset(),
|
|
73
|
+
path: tuple[str | int | None, ...] = (),
|
|
74
|
+
pending: bool = False,
|
|
75
|
+
strict_paths: frozenset[tuple[str, ...]] = frozenset(),
|
|
76
|
+
merge_fields: bool = False,
|
|
77
|
+
) -> dict[str, object]:
|
|
78
|
+
normalized: dict[str, object] = {}
|
|
79
|
+
pending_here = pending and not any(path[: len(prefix)] == prefix for prefix in strict_paths)
|
|
80
|
+
for raw_key, raw_value in values.items():
|
|
81
|
+
if not isinstance(raw_key, str):
|
|
82
|
+
raise TypeError("configuration mapping keys must be strings")
|
|
83
|
+
if path in mapping_paths:
|
|
84
|
+
key = raw_key
|
|
85
|
+
elif pending_here:
|
|
86
|
+
key = raw_key.casefold()
|
|
87
|
+
else:
|
|
88
|
+
key = canonical_segment(raw_key)
|
|
89
|
+
child_path = (*path, key)
|
|
90
|
+
child = normalize_value(
|
|
91
|
+
raw_value,
|
|
92
|
+
mapping_paths=mapping_paths,
|
|
93
|
+
path=child_path,
|
|
94
|
+
pending=pending,
|
|
95
|
+
strict_paths=strict_paths,
|
|
96
|
+
merge_fields=merge_fields,
|
|
97
|
+
)
|
|
98
|
+
if key in normalized and not pending_here:
|
|
99
|
+
_merge_source_fields(normalized, key, child, child_path, mapping_paths, merge_fields)
|
|
100
|
+
else:
|
|
101
|
+
normalized[key] = child
|
|
102
|
+
return normalized
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def normalize_value(
|
|
106
|
+
value: object,
|
|
107
|
+
*,
|
|
108
|
+
mapping_paths: frozenset[tuple[str | int | None, ...]] = frozenset(),
|
|
109
|
+
path: tuple[str | int | None, ...] = (),
|
|
110
|
+
pending: bool = False,
|
|
111
|
+
strict_paths: frozenset[tuple[str, ...]] = frozenset(),
|
|
112
|
+
merge_fields: bool = False,
|
|
113
|
+
) -> object:
|
|
114
|
+
if isinstance(value, Mapping):
|
|
115
|
+
return normalize_mapping(
|
|
116
|
+
cast(Mapping[str, object], value),
|
|
117
|
+
mapping_paths=mapping_paths,
|
|
118
|
+
path=path,
|
|
119
|
+
pending=pending,
|
|
120
|
+
strict_paths=strict_paths,
|
|
121
|
+
merge_fields=merge_fields,
|
|
122
|
+
)
|
|
123
|
+
if isinstance(value, (list, tuple)):
|
|
124
|
+
return tuple(
|
|
125
|
+
normalize_value(
|
|
126
|
+
item,
|
|
127
|
+
mapping_paths=mapping_paths,
|
|
128
|
+
path=(*path, index),
|
|
129
|
+
pending=pending,
|
|
130
|
+
strict_paths=strict_paths,
|
|
131
|
+
merge_fields=merge_fields,
|
|
132
|
+
)
|
|
133
|
+
for index, item in enumerate(value)
|
|
134
|
+
)
|
|
135
|
+
if isinstance(value, (set, frozenset)):
|
|
136
|
+
return frozenset(
|
|
137
|
+
normalize_value(
|
|
138
|
+
item,
|
|
139
|
+
mapping_paths=mapping_paths,
|
|
140
|
+
path=(*path, index),
|
|
141
|
+
pending=pending,
|
|
142
|
+
strict_paths=strict_paths,
|
|
143
|
+
merge_fields=merge_fields,
|
|
144
|
+
)
|
|
145
|
+
for index, item in enumerate(value)
|
|
146
|
+
)
|
|
147
|
+
if isinstance(value, bytearray):
|
|
148
|
+
return bytes(value)
|
|
149
|
+
if is_supported_scalar(value):
|
|
150
|
+
return value
|
|
151
|
+
raise TypeError(f"unsupported mutable or opaque configuration value {type(value).__name__}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _merge_source_fields(
|
|
155
|
+
target: dict[str, object],
|
|
156
|
+
key: str,
|
|
157
|
+
incoming: object,
|
|
158
|
+
path: tuple[str | int | None, ...],
|
|
159
|
+
mapping_paths: frozenset[tuple[str | int | None, ...]],
|
|
160
|
+
allowed: bool,
|
|
161
|
+
) -> None:
|
|
162
|
+
current = target[key]
|
|
163
|
+
if not allowed or not isinstance(current, dict) or not isinstance(incoming, Mapping):
|
|
164
|
+
raise ValueError("duplicate canonical configuration field")
|
|
165
|
+
# 原生环境来源允许 JSON 对象加嵌套变量。合并字段片段,不折叠数据键身份。
|
|
166
|
+
for name, value in incoming.items():
|
|
167
|
+
if name in current and path not in mapping_paths:
|
|
168
|
+
_merge_source_fields(current, name, value, (*path, name), mapping_paths, allowed)
|
|
169
|
+
else:
|
|
170
|
+
current[name] = value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _capture_input_mapping(values: Mapping[str, object], *, root: bool = True) -> dict[str, object]:
|
|
174
|
+
# 来源只读取一次。类型声明晚于来源读取,数据键必须在这份快照里保留原貌。
|
|
175
|
+
result: dict[str, object] = {}
|
|
176
|
+
for name, value in values.items():
|
|
177
|
+
if not isinstance(name, str):
|
|
178
|
+
raise TypeError("configuration mapping keys must be strings")
|
|
179
|
+
key = canonical_segment(name) if root else name
|
|
180
|
+
if key in result:
|
|
181
|
+
raise ValueError("duplicate canonical configuration field")
|
|
182
|
+
result[key] = _capture_input_value(value)
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _capture_input_value(value: object) -> object:
|
|
187
|
+
if isinstance(value, Mapping):
|
|
188
|
+
return _capture_input_mapping(value, root=False)
|
|
189
|
+
if isinstance(value, (list, tuple)):
|
|
190
|
+
return tuple(_capture_input_value(item) for item in value)
|
|
191
|
+
if isinstance(value, (set, frozenset)):
|
|
192
|
+
return frozenset(_capture_input_value(item) for item in value)
|
|
193
|
+
if isinstance(value, bytearray):
|
|
194
|
+
return bytes(value)
|
|
195
|
+
if is_supported_scalar(value):
|
|
196
|
+
return value
|
|
197
|
+
raise TypeError("unsupported mutable or opaque configuration value")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def freeze_value(value: object) -> object:
|
|
201
|
+
if isinstance(value, (Secret, SecretStr, SecretBytes)):
|
|
202
|
+
return _freeze_secret_value(value)
|
|
203
|
+
if isinstance(value, Mapping):
|
|
204
|
+
return MappingProxyType({key: freeze_value(item) for key, item in value.items()})
|
|
205
|
+
if isinstance(value, tuple):
|
|
206
|
+
return tuple(freeze_value(item) for item in value)
|
|
207
|
+
if isinstance(value, frozenset):
|
|
208
|
+
return frozenset(freeze_value(item) for item in value)
|
|
209
|
+
if is_supported_scalar(value):
|
|
210
|
+
return value
|
|
211
|
+
raise TypeError(f"unsupported mutable or opaque configuration value {type(value).__name__}")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _freeze_secret_value(value: object) -> object:
|
|
215
|
+
if isinstance(value, _ImmutableSecretMixin):
|
|
216
|
+
return value
|
|
217
|
+
if isinstance(value, SecretStr):
|
|
218
|
+
frozen_value: object = value.get_secret_value()
|
|
219
|
+
base_frozen_type: type[object] = _FrozenSecretStr
|
|
220
|
+
base_type: type[object] = SecretStr
|
|
221
|
+
elif isinstance(value, SecretBytes):
|
|
222
|
+
frozen_value = value.get_secret_value()
|
|
223
|
+
base_frozen_type = _FrozenSecretBytes
|
|
224
|
+
base_type = SecretBytes
|
|
225
|
+
elif isinstance(value, Secret):
|
|
226
|
+
frozen_value = freeze_value(value.get_secret_value())
|
|
227
|
+
base_frozen_type = _FrozenSecret
|
|
228
|
+
base_type = Secret
|
|
229
|
+
else:
|
|
230
|
+
raise TypeError(f"unsupported secret value {type(value).__name__}")
|
|
231
|
+
|
|
232
|
+
concrete_type = type(value)
|
|
233
|
+
frozen_type = (
|
|
234
|
+
base_frozen_type
|
|
235
|
+
if concrete_type is base_type
|
|
236
|
+
else type(
|
|
237
|
+
f"_Frozen{concrete_type.__name__}",
|
|
238
|
+
(_ImmutableSecretMixin, concrete_type),
|
|
239
|
+
{
|
|
240
|
+
"__slots__": (),
|
|
241
|
+
"__module__": __name__,
|
|
242
|
+
"_wrapped_secret_type": concrete_type,
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
factory = cast(Callable[[object], object], frozen_type)
|
|
247
|
+
return factory(frozen_value)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def is_frozen_secret(value: object) -> bool:
|
|
251
|
+
return isinstance(value, _ImmutableSecretMixin)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def secret_payload(value: object) -> object:
|
|
255
|
+
if isinstance(value, (Secret, SecretStr, SecretBytes)):
|
|
256
|
+
return value.get_secret_value()
|
|
257
|
+
raise TypeError(f"unsupported secret value {type(value).__name__}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def redact_value(
|
|
261
|
+
value: object,
|
|
262
|
+
*,
|
|
263
|
+
path: tuple[str, ...],
|
|
264
|
+
secret_paths: frozenset[str],
|
|
265
|
+
) -> tuple[object, bool]:
|
|
266
|
+
canonical = ".".join(path)
|
|
267
|
+
if (
|
|
268
|
+
canonical in secret_paths
|
|
269
|
+
or any(canonical.startswith(f"{secret_path}.") for secret_path in secret_paths)
|
|
270
|
+
or _looks_secret(path)
|
|
271
|
+
or isinstance(value, (Secret, SecretStr, SecretBytes))
|
|
272
|
+
):
|
|
273
|
+
return REDACTION_MARKER, True
|
|
274
|
+
if isinstance(value, Mapping):
|
|
275
|
+
redacted = False
|
|
276
|
+
sanitized: dict[str, object] = {}
|
|
277
|
+
for key, item in value.items():
|
|
278
|
+
sanitized_item, item_redacted = redact_value(
|
|
279
|
+
item,
|
|
280
|
+
path=(*path, key),
|
|
281
|
+
secret_paths=secret_paths,
|
|
282
|
+
)
|
|
283
|
+
sanitized[key] = sanitized_item
|
|
284
|
+
redacted = redacted or item_redacted
|
|
285
|
+
return MappingProxyType(sanitized), redacted
|
|
286
|
+
if isinstance(value, tuple):
|
|
287
|
+
items: list[object] = []
|
|
288
|
+
redacted = False
|
|
289
|
+
for item in value:
|
|
290
|
+
sanitized_item, item_redacted = redact_value(
|
|
291
|
+
item,
|
|
292
|
+
path=path,
|
|
293
|
+
secret_paths=secret_paths,
|
|
294
|
+
)
|
|
295
|
+
items.append(sanitized_item)
|
|
296
|
+
redacted = redacted or item_redacted
|
|
297
|
+
return tuple(items), redacted
|
|
298
|
+
if isinstance(value, frozenset):
|
|
299
|
+
frozen_items: list[object] = []
|
|
300
|
+
redacted = False
|
|
301
|
+
for item in value:
|
|
302
|
+
sanitized_item, item_redacted = redact_value(
|
|
303
|
+
item,
|
|
304
|
+
path=path,
|
|
305
|
+
secret_paths=secret_paths,
|
|
306
|
+
)
|
|
307
|
+
frozen_items.append(sanitized_item)
|
|
308
|
+
redacted = redacted or item_redacted
|
|
309
|
+
return frozenset(frozen_items), redacted
|
|
310
|
+
return value, False
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _looks_secret(path: tuple[str, ...]) -> bool:
|
|
314
|
+
return any(_looks_secret_name(segment) for segment in path)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _looks_secret_name(name: str) -> bool:
|
|
318
|
+
"""Configuration/Settings 共用的 secret-shaped name 单一判定权威。"""
|
|
319
|
+
|
|
320
|
+
normalized = name.casefold().replace("-", "_")
|
|
321
|
+
return any(
|
|
322
|
+
segment.endswith(_SECRET_NAME_SUFFIXES) for segment in normalized.split(".") if segment
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def is_supported_scalar(value: object) -> bool:
|
|
327
|
+
if isinstance(value, (Secret, SecretBytes, SecretStr)):
|
|
328
|
+
return True
|
|
329
|
+
if isinstance(value, Enum):
|
|
330
|
+
return _enum_member_is_deeply_immutable(value, seen=set())
|
|
331
|
+
return _is_intrinsically_immutable_scalar(value)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _enum_member_is_deeply_immutable(value: Enum, *, seen: set[int]) -> bool:
|
|
335
|
+
identity = id(value)
|
|
336
|
+
if identity in seen:
|
|
337
|
+
return True
|
|
338
|
+
member_state = vars(value)
|
|
339
|
+
if any(
|
|
340
|
+
name not in {"_name_", "_sort_order_", "_value_", "__objclass__"} for name in member_state
|
|
341
|
+
):
|
|
342
|
+
# Enum member 是跨 Application singleton;任何额外 instance state 都不可发布。
|
|
343
|
+
return False
|
|
344
|
+
return _enum_payload_is_deeply_immutable(value.value, seen={*seen, identity})
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _enum_payload_is_deeply_immutable(value: object, *, seen: set[int]) -> bool:
|
|
348
|
+
if isinstance(value, Enum):
|
|
349
|
+
return _enum_member_is_deeply_immutable(value, seen=seen)
|
|
350
|
+
if _is_intrinsically_immutable_scalar(value):
|
|
351
|
+
return True
|
|
352
|
+
identity = id(value)
|
|
353
|
+
if identity in seen:
|
|
354
|
+
return True
|
|
355
|
+
branch_seen = {*seen, identity}
|
|
356
|
+
if type(value) is tuple or _is_named_tuple_instance(value):
|
|
357
|
+
tuple_value = cast(tuple[object, ...], value)
|
|
358
|
+
return all(
|
|
359
|
+
_enum_payload_is_deeply_immutable(item, seen=branch_seen) for item in tuple_value
|
|
360
|
+
)
|
|
361
|
+
if isinstance(value, frozenset):
|
|
362
|
+
return all(_enum_payload_is_deeply_immutable(item, seen=branch_seen) for item in value)
|
|
363
|
+
# MappingProxyType 仍可能被持有原 dict 的代码改写。
|
|
364
|
+
return False
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _is_named_tuple_instance(value: object) -> bool:
|
|
368
|
+
fields = getattr(type(value), "_fields", None)
|
|
369
|
+
return (
|
|
370
|
+
isinstance(value, tuple)
|
|
371
|
+
and isinstance(fields, tuple)
|
|
372
|
+
and all(isinstance(field, str) for field in fields)
|
|
373
|
+
and not hasattr(value, "__dict__")
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _is_intrinsically_immutable_scalar(value: object) -> bool:
|
|
378
|
+
if type(value) in {
|
|
379
|
+
UUID,
|
|
380
|
+
date,
|
|
381
|
+
datetime,
|
|
382
|
+
Decimal,
|
|
383
|
+
time,
|
|
384
|
+
timedelta,
|
|
385
|
+
bool,
|
|
386
|
+
bytes,
|
|
387
|
+
float,
|
|
388
|
+
int,
|
|
389
|
+
str,
|
|
390
|
+
type(None),
|
|
391
|
+
}:
|
|
392
|
+
return True
|
|
393
|
+
return isinstance(value, Path) and type(value).__module__ == Path.__module__
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def lookup_path(tree: Mapping[str, object], parts: tuple[str, ...]) -> object:
|
|
397
|
+
current: object = tree
|
|
398
|
+
for part in parts:
|
|
399
|
+
if not isinstance(current, Mapping):
|
|
400
|
+
raise TypeError("configuration path traversed a scalar value")
|
|
401
|
+
current = current[part]
|
|
402
|
+
return current
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def canonical_path(path: str) -> tuple[str, ...]:
|
|
406
|
+
if not isinstance(path, str):
|
|
407
|
+
raise TypeError("configuration path must be a string")
|
|
408
|
+
return tuple(canonical_segment(part) for part in path.split("."))
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def canonical_segment(segment: object) -> str:
|
|
412
|
+
if not isinstance(segment, str):
|
|
413
|
+
raise TypeError("configuration key must be a string")
|
|
414
|
+
if not segment or segment != segment.strip() or "." in segment:
|
|
415
|
+
raise ValueError("configuration key segments must be non-empty and must not contain dots")
|
|
416
|
+
canonical = segment.casefold()
|
|
417
|
+
if not canonical.isidentifier() or keyword.iskeyword(canonical):
|
|
418
|
+
raise ValueError(f"configuration key {segment!r} is not a valid identifier segment")
|
|
419
|
+
return canonical
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def raw_kind(value: object) -> str:
|
|
423
|
+
if isinstance(value, Mapping):
|
|
424
|
+
return "mapping"
|
|
425
|
+
if isinstance(value, (tuple, frozenset)):
|
|
426
|
+
return "sequence"
|
|
427
|
+
return type(value).__name__
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""原生 YAML source 的安全结构边界;语法与标签仍由 PyYAML SafeLoader 解释。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from pydantic_settings import YamlConfigSettingsSource
|
|
10
|
+
from pydantic_settings.sources.types import Traversable
|
|
11
|
+
|
|
12
|
+
# PyYAML 未发布 typing;动态类型只留在原生 loader 边界,不向 Kernel 扩散。
|
|
13
|
+
yaml = import_module("yaml")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _UniqueKeySafeLoader(yaml.SafeLoader): # type: ignore[misc, name-defined]
|
|
17
|
+
def construct_mapping(self, node: Any, deep: bool = False) -> dict[Any, Any]:
|
|
18
|
+
# 在原生 merge-key 展开前检查显式重复键;合法 anchor merge 仍由原生规则处理。
|
|
19
|
+
keys: set[object] = set()
|
|
20
|
+
for key_node, _ in node.value:
|
|
21
|
+
if key_node.tag == "tag:yaml.org,2002:merge":
|
|
22
|
+
continue
|
|
23
|
+
key = self.construct_object(key_node, deep=deep)
|
|
24
|
+
if key in keys:
|
|
25
|
+
raise ValueError("duplicate YAML mapping key")
|
|
26
|
+
keys.add(key)
|
|
27
|
+
return cast(dict[Any, Any], super().construct_mapping(node, deep=deep))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _YamlConfigurationSource(YamlConfigSettingsSource):
|
|
31
|
+
def _read_file(self, file_path: Path | Traversable) -> dict[str, Any]:
|
|
32
|
+
with file_path.open(encoding="utf-8") as stream:
|
|
33
|
+
value = yaml.load(stream, Loader=_UniqueKeySafeLoader)
|
|
34
|
+
if value is None:
|
|
35
|
+
return {}
|
|
36
|
+
if not isinstance(value, dict):
|
|
37
|
+
raise ValueError("YAML configuration must be a mapping")
|
|
38
|
+
_reject_cycles(value, set())
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _reject_cycles(value: object, active: set[int]) -> None:
|
|
43
|
+
if not isinstance(value, (dict, list)):
|
|
44
|
+
return
|
|
45
|
+
identity = id(value)
|
|
46
|
+
if identity in active:
|
|
47
|
+
raise ValueError("cyclic YAML configuration")
|
|
48
|
+
active.add(identity)
|
|
49
|
+
for item in value.values() if isinstance(value, dict) else value:
|
|
50
|
+
_reject_cycles(item, active)
|
|
51
|
+
active.remove(identity)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""随框架版本发布的开发工具;运行命令不依赖本包。"""
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""包内模板 staging 校验和发布;不覆盖文件,不移动已安装环境。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import importlib.metadata as metadata
|
|
7
|
+
import importlib.resources as resources
|
|
8
|
+
import inspect
|
|
9
|
+
import keyword
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
# 官方库未发布 typing marker;仅在第三方导入边界忽略缺失的类型信息。
|
|
21
|
+
from cookiecutter.main import cookiecutter # type: ignore[import-untyped]
|
|
22
|
+
|
|
23
|
+
from ..application import Application
|
|
24
|
+
from ..cli import ENVIRONMENT_VARIABLE
|
|
25
|
+
from ..cli.errors import CommandError
|
|
26
|
+
from ..cli.project import Project, read_project
|
|
27
|
+
from .project_metadata import add_module_metadata
|
|
28
|
+
from .source import framework_source
|
|
29
|
+
from .wiring import add_dependency
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _name(value: str) -> tuple[str, str]:
|
|
33
|
+
if not re.fullmatch(r"[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*", value) or re.fullmatch(
|
|
34
|
+
r"con|prn|aux|nul|com[1-9]|lpt[1-9]", value
|
|
35
|
+
):
|
|
36
|
+
raise CommandError("Use a lowercase project/module name valid on Windows and Linux")
|
|
37
|
+
package = value.replace("-", "_")
|
|
38
|
+
if keyword.iskeyword(package):
|
|
39
|
+
raise CommandError("Project/module package name must not be a Python keyword")
|
|
40
|
+
return package, "".join(part.capitalize() for part in package.split("_"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _render(template: str, context: dict[str, Any]) -> Path:
|
|
44
|
+
staging = Path(tempfile.mkdtemp(prefix="python-platform-generation-"))
|
|
45
|
+
root = resources.files(__package__).joinpath("templates").joinpath(template)
|
|
46
|
+
with resources.as_file(root) as template_path:
|
|
47
|
+
# Cookiecutter 用相对路径加载;安装目录较深时先复制到短 staging,避免 Windows MAX_PATH。
|
|
48
|
+
source = str(template_path.resolve())
|
|
49
|
+
if os.name == "nt" and not source.startswith("\\\\?\\"):
|
|
50
|
+
source = (
|
|
51
|
+
"\\\\?\\UNC\\" + source[2:] if source.startswith("\\\\") else "\\\\?\\" + source
|
|
52
|
+
)
|
|
53
|
+
local_template = staging / "template"
|
|
54
|
+
shutil.copytree(source, local_template)
|
|
55
|
+
rendered = Path(
|
|
56
|
+
cookiecutter(
|
|
57
|
+
str(local_template),
|
|
58
|
+
no_input=True,
|
|
59
|
+
extra_context=context,
|
|
60
|
+
output_dir=str(staging / "output"),
|
|
61
|
+
default_config=True,
|
|
62
|
+
accept_hooks=False,
|
|
63
|
+
keep_project_on_failure=True,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
for path in tuple(rendered.rglob("*.jinja")):
|
|
67
|
+
path.rename(path.with_suffix(""))
|
|
68
|
+
_validate(rendered)
|
|
69
|
+
return rendered
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate(root: Path) -> None:
|
|
73
|
+
for path in root.rglob("*"):
|
|
74
|
+
if path.is_symlink():
|
|
75
|
+
raise CommandError("Generated output must not contain symbolic links")
|
|
76
|
+
if path.suffix == ".py":
|
|
77
|
+
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
78
|
+
elif path.suffix == ".toml":
|
|
79
|
+
read_project(path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def new_project(name: str, wheel: Path | None, dry_run: bool) -> None:
|
|
83
|
+
package, class_prefix = _name(name)
|
|
84
|
+
destination = Path.cwd() / name
|
|
85
|
+
if destination.exists():
|
|
86
|
+
raise CommandError(f"Destination already exists: {destination}")
|
|
87
|
+
source = framework_source(wheel)
|
|
88
|
+
if dry_run:
|
|
89
|
+
print(f"Would generate {destination} with bundled Host template")
|
|
90
|
+
return
|
|
91
|
+
distribution = metadata.distribution("python-platform")
|
|
92
|
+
generator = (distribution.read_text("WHEEL") or "").split("Generator: uv ")[-1].splitlines()[0]
|
|
93
|
+
if not re.fullmatch(r"\d+\.\d+\.\d+", generator):
|
|
94
|
+
raise CommandError("Cannot determine the framework wheel's uv build tool version")
|
|
95
|
+
staged = _render(
|
|
96
|
+
"project",
|
|
97
|
+
{
|
|
98
|
+
"project_name": name,
|
|
99
|
+
"package_name": package,
|
|
100
|
+
"class_prefix": class_prefix,
|
|
101
|
+
"framework_version": source.version,
|
|
102
|
+
"framework_source": source.uv_source,
|
|
103
|
+
"python_version": platform.python_version(),
|
|
104
|
+
"build_version": generator,
|
|
105
|
+
"host_environment_variable": ENVIRONMENT_VARIABLE,
|
|
106
|
+
"database_password": secrets.token_urlsafe(24),
|
|
107
|
+
"admin_password": secrets.token_urlsafe(24),
|
|
108
|
+
"jwt_key": secrets.token_urlsafe(48),
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
if source.wheel is not None:
|
|
112
|
+
vendor = staged / "vendor"
|
|
113
|
+
vendor.mkdir()
|
|
114
|
+
shutil.copy2(source.wheel, vendor / source.wheel.name)
|
|
115
|
+
# copytree 原生拒绝已存在目录;staging 跨 Windows 盘符,不能依赖目录 rename。
|
|
116
|
+
shutil.copytree(staged, destination)
|
|
117
|
+
print(f"Generated {destination}; staging retained at {staged}")
|
|
118
|
+
subprocess.run(["uv", "sync"], cwd=destination, check=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def add_module(name: str, dry_run: bool) -> None:
|
|
122
|
+
package, class_prefix = _name(name)
|
|
123
|
+
project = Project.current()
|
|
124
|
+
application = project.host("application").load()(environment="development")
|
|
125
|
+
if not isinstance(application, Application):
|
|
126
|
+
raise CommandError("The application entry point must return Application")
|
|
127
|
+
startup = application.modules.startup.module_type
|
|
128
|
+
if startup.__qualname__ != startup.__name__:
|
|
129
|
+
raise CommandError("Startup Module must be a top-level class for module generation")
|
|
130
|
+
source_path = Path(inspect.getfile(startup)).resolve()
|
|
131
|
+
if not source_path.is_relative_to(project.root / "src"):
|
|
132
|
+
raise CommandError(
|
|
133
|
+
"Startup Module source must belong to the current project's src directory"
|
|
134
|
+
)
|
|
135
|
+
modules = project.root / "src" / "modules"
|
|
136
|
+
destination = modules / package
|
|
137
|
+
if destination.exists():
|
|
138
|
+
raise CommandError(f"Module destination already exists: {destination}")
|
|
139
|
+
source = source_path.read_text(encoding="utf-8")
|
|
140
|
+
updated = source
|
|
141
|
+
for layer, suffix in (
|
|
142
|
+
("application", "ApplicationModule"),
|
|
143
|
+
("sqlalchemy", "SqlAlchemyModule"),
|
|
144
|
+
("http_api", "HttpApiModule"),
|
|
145
|
+
):
|
|
146
|
+
updated = add_dependency(
|
|
147
|
+
updated, startup.__name__, f"modules.{package}.{layer}.module", class_prefix + suffix
|
|
148
|
+
)
|
|
149
|
+
metadata_path = project.root / "pyproject.toml"
|
|
150
|
+
metadata_source = metadata_path.read_text(encoding="utf-8")
|
|
151
|
+
updated_metadata = add_module_metadata(metadata_source, package, class_prefix)
|
|
152
|
+
ast.parse(updated)
|
|
153
|
+
if dry_run:
|
|
154
|
+
print(
|
|
155
|
+
f"Would add {destination}, wire modules into {source_path} and update {metadata_path}"
|
|
156
|
+
)
|
|
157
|
+
return
|
|
158
|
+
staged = _render(
|
|
159
|
+
"module",
|
|
160
|
+
{
|
|
161
|
+
"module_name": package,
|
|
162
|
+
"class_prefix": class_prefix,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
# 先验证全部新文件和接线;已有 startup 文件仅在内容仍等于读取快照时替换。
|
|
166
|
+
if (
|
|
167
|
+
source_path.read_text(encoding="utf-8") != source
|
|
168
|
+
or metadata_path.read_text(encoding="utf-8") != metadata_source
|
|
169
|
+
):
|
|
170
|
+
raise CommandError("Startup source changed during generation; retry after reviewing it")
|
|
171
|
+
modules.mkdir(exist_ok=True)
|
|
172
|
+
if not modules.joinpath("__init__.py").exists():
|
|
173
|
+
modules.joinpath("__init__.py").touch()
|
|
174
|
+
shutil.copytree(staged, destination)
|
|
175
|
+
with tempfile.NamedTemporaryFile(
|
|
176
|
+
mode="w", encoding="utf-8", dir=source_path.parent, delete=False
|
|
177
|
+
) as pending:
|
|
178
|
+
pending.write(updated)
|
|
179
|
+
Path(pending.name).replace(source_path)
|
|
180
|
+
metadata_path.write_text(updated_metadata, encoding="utf-8")
|
|
181
|
+
subprocess.run(["uv", "sync"], cwd=project.root, check=True)
|
|
182
|
+
print(f"Added {package}; staging retained at {staged}")
|