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.
Files changed (344) hide show
  1. python_platform/__init__.py +445 -0
  2. python_platform/application/__init__.py +6 -0
  3. python_platform/application/build_spec.py +37 -0
  4. python_platform/application/builder.py +260 -0
  5. python_platform/application/composition.py +219 -0
  6. python_platform/application/runtime.py +546 -0
  7. python_platform/application/runtime_hooks.py +327 -0
  8. python_platform/application/state.py +216 -0
  9. python_platform/application_services/__init__.py +54 -0
  10. python_platform/application_services/catalog.py +500 -0
  11. python_platform/application_services/contracts.py +188 -0
  12. python_platform/application_services/dispatcher.py +423 -0
  13. python_platform/application_services/errors.py +89 -0
  14. python_platform/application_services/execution.py +199 -0
  15. python_platform/application_services/interceptors.py +44 -0
  16. python_platform/application_services/invocation.py +427 -0
  17. python_platform/application_services/policies.py +46 -0
  18. python_platform/application_services/seeding.py +72 -0
  19. python_platform/application_services/signature.py +49 -0
  20. python_platform/application_services/validation.py +84 -0
  21. python_platform/auditing/__init__.py +12 -0
  22. python_platform/auditing/contracts.py +39 -0
  23. python_platform/auditing/control.py +48 -0
  24. python_platform/auditing/sqlalchemy/__init__.py +6 -0
  25. python_platform/auditing/sqlalchemy/migrations/0001_platform_auditing.py +45 -0
  26. python_platform/auditing/sqlalchemy/migrations/__init__.py +1 -0
  27. python_platform/auditing/sqlalchemy/models.py +31 -0
  28. python_platform/auditing/sqlalchemy/module.py +32 -0
  29. python_platform/auditing/sqlalchemy/store.py +63 -0
  30. python_platform/authorization/__init__.py +38 -0
  31. python_platform/authorization/catalog.py +63 -0
  32. python_platform/authorization/contracts.py +241 -0
  33. python_platform/authorization/definition_discovery.py +81 -0
  34. python_platform/authorization/definitions.py +43 -0
  35. python_platform/authorization/errors.py +40 -0
  36. python_platform/background_execution/__init__.py +15 -0
  37. python_platform/background_execution/application.py +41 -0
  38. python_platform/background_execution/child.py +123 -0
  39. python_platform/background_execution/contracts.py +39 -0
  40. python_platform/background_execution/lifecycle.py +22 -0
  41. python_platform/background_execution/local.py +127 -0
  42. python_platform/background_execution/locks.py +31 -0
  43. python_platform/background_execution/management.py +17 -0
  44. python_platform/background_execution/module.py +10 -0
  45. python_platform/background_execution/permissions.py +14 -0
  46. python_platform/background_execution/processes.py +331 -0
  47. python_platform/background_jobs/__init__.py +31 -0
  48. python_platform/background_jobs/catalog.py +287 -0
  49. python_platform/background_jobs/contracts.py +202 -0
  50. python_platform/background_jobs/errors.py +28 -0
  51. python_platform/background_jobs/execution.py +201 -0
  52. python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
  53. python_platform/background_jobs/pgqueuer/__init__.py +6 -0
  54. python_platform/background_jobs/pgqueuer/enqueue.py +82 -0
  55. python_platform/background_jobs/pgqueuer/migrations/0001_pgqueuer_1_3_2.py +63 -0
  56. python_platform/background_jobs/pgqueuer/migrations/__init__.py +1 -0
  57. python_platform/background_jobs/pgqueuer/module.py +102 -0
  58. python_platform/background_jobs/pgqueuer/options.py +70 -0
  59. python_platform/background_jobs/pgqueuer/runtime.py +479 -0
  60. python_platform/background_jobs/pgqueuer/sql/pgqueuer_1_3_2_install.sql +118 -0
  61. python_platform/background_jobs/pgqueuer/supervision.py +201 -0
  62. python_platform/background_workers/__init__.py +22 -0
  63. python_platform/background_workers/catalog.py +96 -0
  64. python_platform/background_workers/contracts.py +124 -0
  65. python_platform/background_workers/errors.py +20 -0
  66. python_platform/background_workers/execution.py +133 -0
  67. python_platform/background_workers/runtime.py +203 -0
  68. python_platform/caching/__init__.py +11 -0
  69. python_platform/caching/catalog.py +73 -0
  70. python_platform/caching/contracts.py +73 -0
  71. python_platform/caching/errors.py +20 -0
  72. python_platform/cli/__init__.py +87 -0
  73. python_platform/cli/errors.py +5 -0
  74. python_platform/cli/project.py +69 -0
  75. python_platform/cli/runtime.py +68 -0
  76. python_platform/configuration/__init__.py +21 -0
  77. python_platform/configuration/composition.py +65 -0
  78. python_platform/configuration/contracts.py +68 -0
  79. python_platform/configuration/dotenv_source.py +51 -0
  80. python_platform/configuration/environment_source.py +38 -0
  81. python_platform/configuration/immutability.py +124 -0
  82. python_platform/configuration/input_shape.py +58 -0
  83. python_platform/configuration/merge.py +103 -0
  84. python_platform/configuration/root.py +215 -0
  85. python_platform/configuration/sources.py +519 -0
  86. python_platform/configuration/values.py +427 -0
  87. python_platform/configuration/yaml_source.py +51 -0
  88. python_platform/developer_kit/__init__.py +1 -0
  89. python_platform/developer_kit/generation.py +182 -0
  90. python_platform/developer_kit/project_metadata.py +45 -0
  91. python_platform/developer_kit/source.py +53 -0
  92. python_platform/developer_kit/templates/module/cookiecutter.json +1 -0
  93. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/__init__.py.jinja +1 -0
  94. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/__init__.py.jinja +1 -0
  95. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/cache.py.jinja +10 -0
  96. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/events.py.jinja +24 -0
  97. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration.py.jinja +46 -0
  98. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +29 -0
  99. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options.py.jinja +8 -0
  100. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/orders.py.jinja +81 -0
  101. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/tasks.py.jinja +86 -0
  102. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/__init__.py.jinja +1 -0
  103. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/module.py.jinja +10 -0
  104. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/orders.py.jinja +36 -0
  105. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/__init__.py.jinja +1 -0
  106. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +10 -0
  107. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/orders.py.jinja +57 -0
  108. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repository.py.jinja +12 -0
  109. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding.py.jinja +19 -0
  110. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings.py.jinja +16 -0
  111. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/__init__.py.jinja +1 -0
  112. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/definitions.py.jinja +24 -0
  113. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +7 -0
  114. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions.py.jinja +15 -0
  115. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/__init__.py.jinja +1 -0
  116. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/files.py.jinja +74 -0
  117. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +58 -0
  118. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/realtime.py.jinja +37 -0
  119. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/__init__.py.jinja +1 -0
  120. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/migrations/__init__.py.jinja +1 -0
  121. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models.py.jinja +26 -0
  122. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +32 -0
  123. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repository.py.jinja +49 -0
  124. python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +18 -0
  125. python_platform/developer_kit/templates/project/cookiecutter.json +6 -0
  126. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.dockerignore +8 -0
  127. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.env.jinja +13 -0
  128. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.gitignore +6 -0
  129. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.python-version +1 -0
  130. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/Dockerfile +22 -0
  131. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +86 -0
  132. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/app.yaml.jinja +16 -0
  133. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.dev.yaml.jinja +24 -0
  134. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.production.yaml.jinja +13 -0
  135. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/pyproject.toml.jinja +28 -0
  136. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/__init__.py.jinja +1 -0
  137. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/main.py.jinja +52 -0
  138. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/module.py.jinja +38 -0
  139. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/conftest.py.jinja +8 -0
  140. python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/host/test_http.py.jinja +101 -0
  141. python_platform/developer_kit/wiring.py +85 -0
  142. python_platform/diagnostics/__init__.py +23 -0
  143. python_platform/diagnostics/journal.py +174 -0
  144. python_platform/diagnostics/model.py +83 -0
  145. python_platform/diagnostics/source.py +25 -0
  146. python_platform/distributed_lock/__init__.py +6 -0
  147. python_platform/distributed_lock/contracts.py +20 -0
  148. python_platform/distributed_lock/options.py +19 -0
  149. python_platform/domain/__init__.py +13 -0
  150. python_platform/domain/aggregates.py +64 -0
  151. python_platform/domain/entities.py +33 -0
  152. python_platform/domain/errors.py +12 -0
  153. python_platform/domain/value_objects.py +31 -0
  154. python_platform/errors/__init__.py +75 -0
  155. python_platform/errors/base.py +13 -0
  156. python_platform/errors/business.py +53 -0
  157. python_platform/errors/configuration.py +119 -0
  158. python_platform/errors/diagnostics.py +12 -0
  159. python_platform/errors/lifecycle.py +164 -0
  160. python_platform/errors/modularity.py +100 -0
  161. python_platform/errors/services.py +100 -0
  162. python_platform/events/__init__.py +25 -0
  163. python_platform/events/aggregate.py +39 -0
  164. python_platform/events/catalog.py +136 -0
  165. python_platform/events/contracts.py +39 -0
  166. python_platform/events/contribution.py +128 -0
  167. python_platform/events/discovery.py +105 -0
  168. python_platform/events/errors.py +48 -0
  169. python_platform/events/runtime.py +192 -0
  170. python_platform/fastapi/__init__.py +49 -0
  171. python_platform/fastapi/action.py +175 -0
  172. python_platform/fastapi/adapter.py +381 -0
  173. python_platform/fastapi/application_services.py +647 -0
  174. python_platform/fastapi/background.py +42 -0
  175. python_platform/fastapi/contracts.py +278 -0
  176. python_platform/fastapi/errors.py +32 -0
  177. python_platform/fastapi/filters.py +111 -0
  178. python_platform/fastapi/health.py +44 -0
  179. python_platform/fastapi/http.py +55 -0
  180. python_platform/fastapi/http_router.py +212 -0
  181. python_platform/fastapi/manual_action.py +105 -0
  182. python_platform/fastapi/middleware.py +93 -0
  183. python_platform/fastapi/parameters.py +97 -0
  184. python_platform/fastapi/realtime/__init__.py +13 -0
  185. python_platform/fastapi/realtime/authentication.py +181 -0
  186. python_platform/fastapi/realtime/connection.py +166 -0
  187. python_platform/fastapi/realtime/module.py +41 -0
  188. python_platform/fastapi/realtime/options.py +50 -0
  189. python_platform/fastapi/realtime/runtime.py +423 -0
  190. python_platform/fastapi/request_context.py +244 -0
  191. python_platform/fastapi/route_integrity.py +88 -0
  192. python_platform/fastapi/routing.py +410 -0
  193. python_platform/fastapi/server.py +181 -0
  194. python_platform/fastapi/settings.py +40 -0
  195. python_platform/fastapi/tracing.py +62 -0
  196. python_platform/fastapi/transfer.py +128 -0
  197. python_platform/fastapi/upload_limits.py +63 -0
  198. python_platform/fastapi/uploads.py +94 -0
  199. python_platform/hosted_services/__init__.py +29 -0
  200. python_platform/hosted_services/bridge.py +420 -0
  201. python_platform/hosted_services/catalog.py +259 -0
  202. python_platform/hosted_services/contracts.py +55 -0
  203. python_platform/hosted_services/errors.py +53 -0
  204. python_platform/hosted_services/options.py +17 -0
  205. python_platform/hosted_services/runtime.py +267 -0
  206. python_platform/hosted_services/state.py +40 -0
  207. python_platform/hosting/__init__.py +4 -0
  208. python_platform/hosting/instance.py +40 -0
  209. python_platform/identity/__init__.py +73 -0
  210. python_platform/identity/application.py +287 -0
  211. python_platform/identity/contracts.py +283 -0
  212. python_platform/identity/errors.py +28 -0
  213. python_platform/identity/http_api.py +90 -0
  214. python_platform/identity/module.py +46 -0
  215. python_platform/identity/passwords.py +34 -0
  216. python_platform/identity/permissions.py +14 -0
  217. python_platform/identity/services.py +108 -0
  218. python_platform/identity/sqlalchemy/__init__.py +5 -0
  219. python_platform/identity/sqlalchemy/migrations/0001_platform_identity.py +151 -0
  220. python_platform/identity/sqlalchemy/migrations/0002_physical_delete.py +42 -0
  221. python_platform/identity/sqlalchemy/migrations/__init__.py +1 -0
  222. python_platform/identity/sqlalchemy/models.py +85 -0
  223. python_platform/identity/sqlalchemy/module.py +62 -0
  224. python_platform/identity/sqlalchemy/stores.py +526 -0
  225. python_platform/identity/tokens.py +106 -0
  226. python_platform/invocation/__init__.py +3 -0
  227. python_platform/invocation/callables.py +171 -0
  228. python_platform/invocation/contracts.py +33 -0
  229. python_platform/invocation/entries.py +54 -0
  230. python_platform/invocation/function_runtime.py +48 -0
  231. python_platform/invocation/interception.py +184 -0
  232. python_platform/lifecycle/__init__.py +25 -0
  233. python_platform/lifecycle/composition.py +35 -0
  234. python_platform/lifecycle/context.py +31 -0
  235. python_platform/lifecycle/runtime.py +43 -0
  236. python_platform/lifecycle/state.py +20 -0
  237. python_platform/modularity/__init__.py +15 -0
  238. python_platform/modularity/contracts.py +90 -0
  239. python_platform/modularity/discovery.py +143 -0
  240. python_platform/modularity/graph.py +334 -0
  241. python_platform/modularity/registry.py +100 -0
  242. python_platform/modularity/selection.py +16 -0
  243. python_platform/notifications/__init__.py +19 -0
  244. python_platform/notifications/catalog.py +46 -0
  245. python_platform/notifications/contracts.py +107 -0
  246. python_platform/observability/__init__.py +5 -0
  247. python_platform/observability/context.py +43 -0
  248. python_platform/observability/export.py +73 -0
  249. python_platform/observability/formatting.py +89 -0
  250. python_platform/observability/logging.py +118 -0
  251. python_platform/observability/options.py +45 -0
  252. python_platform/observability/tracing.py +80 -0
  253. python_platform/options/__init__.py +19 -0
  254. python_platform/options/aliases.py +331 -0
  255. python_platform/options/contribution.py +62 -0
  256. python_platform/options/immutability.py +48 -0
  257. python_platform/options/input_keys.py +214 -0
  258. python_platform/options/issues.py +335 -0
  259. python_platform/options/models.py +114 -0
  260. python_platform/options/registry.py +280 -0
  261. python_platform/options/schema.py +488 -0
  262. python_platform/options/validation.py +120 -0
  263. python_platform/py.typed +0 -0
  264. python_platform/realtime/__init__.py +12 -0
  265. python_platform/realtime/contracts.py +28 -0
  266. python_platform/realtime/diagnostics.py +37 -0
  267. python_platform/realtime/messages.py +113 -0
  268. python_platform/redis/__init__.py +6 -0
  269. python_platform/redis/distributed_lock.py +212 -0
  270. python_platform/redis/lease_lock.py +36 -0
  271. python_platform/redis/module.py +50 -0
  272. python_platform/redis/notification_runtime.py +182 -0
  273. python_platform/redis/notifications.py +25 -0
  274. python_platform/redis/options.py +35 -0
  275. python_platform/redis/runtime.py +116 -0
  276. python_platform/services/__init__.py +39 -0
  277. python_platform/services/application_bindings.py +221 -0
  278. python_platform/services/arbitration.py +381 -0
  279. python_platform/services/binding.py +102 -0
  280. python_platform/services/contribution.py +438 -0
  281. python_platform/services/convention.py +292 -0
  282. python_platform/services/convention_contracts.py +94 -0
  283. python_platform/services/exposure.py +23 -0
  284. python_platform/services/framework_provider.py +178 -0
  285. python_platform/services/native_graph.py +172 -0
  286. python_platform/services/provider.py +204 -0
  287. python_platform/services/registration.py +101 -0
  288. python_platform/services/runtime.py +298 -0
  289. python_platform/settings/__init__.py +30 -0
  290. python_platform/settings/application.py +95 -0
  291. python_platform/settings/binding.py +33 -0
  292. python_platform/settings/catalog.py +121 -0
  293. python_platform/settings/changes.py +14 -0
  294. python_platform/settings/contracts.py +115 -0
  295. python_platform/settings/definition_discovery.py +71 -0
  296. python_platform/settings/definitions.py +41 -0
  297. python_platform/settings/errors.py +22 -0
  298. python_platform/settings/handlers.py +43 -0
  299. python_platform/settings/management.py +57 -0
  300. python_platform/settings/manager.py +89 -0
  301. python_platform/settings/module.py +7 -0
  302. python_platform/settings/notifications.py +39 -0
  303. python_platform/settings/permissions.py +14 -0
  304. python_platform/settings/provider.py +74 -0
  305. python_platform/settings/refresh.py +140 -0
  306. python_platform/settings/refresh_module.py +45 -0
  307. python_platform/settings/sqlalchemy/__init__.py +5 -0
  308. python_platform/settings/sqlalchemy/migrations/0001_platform_settings.py +31 -0
  309. python_platform/settings/sqlalchemy/migrations/0002_physical_delete.py +32 -0
  310. python_platform/settings/sqlalchemy/migrations/0003_version_tokens.py +39 -0
  311. python_platform/settings/sqlalchemy/migrations/__init__.py +1 -0
  312. python_platform/settings/sqlalchemy/models.py +36 -0
  313. python_platform/settings/sqlalchemy/module.py +36 -0
  314. python_platform/settings/sqlalchemy/store.py +86 -0
  315. python_platform/settings/store.py +16 -0
  316. python_platform/settings/values.py +39 -0
  317. python_platform/sqlalchemy/__init__.py +51 -0
  318. python_platform/sqlalchemy/alembic_runtime/__init__.py +1 -0
  319. python_platform/sqlalchemy/alembic_runtime/env.py +27 -0
  320. python_platform/sqlalchemy/alembic_runtime/script.py.mako +14 -0
  321. python_platform/sqlalchemy/auditing.py +48 -0
  322. python_platform/sqlalchemy/errors.py +85 -0
  323. python_platform/sqlalchemy/metadata.py +538 -0
  324. python_platform/sqlalchemy/migration.py +346 -0
  325. python_platform/sqlalchemy/module.py +48 -0
  326. python_platform/sqlalchemy/module_migration.py +148 -0
  327. python_platform/sqlalchemy/options.py +66 -0
  328. python_platform/sqlalchemy/repository.py +33 -0
  329. python_platform/sqlalchemy/runtime.py +85 -0
  330. python_platform/sqlalchemy/session_provider.py +45 -0
  331. python_platform/sqlalchemy/unit_of_work.py +97 -0
  332. python_platform/testing/__init__.py +5 -0
  333. python_platform/testing/runtime.py +112 -0
  334. python_platform/unit_of_work/__init__.py +14 -0
  335. python_platform/unit_of_work/contracts.py +223 -0
  336. python_platform/unit_of_work/errors.py +27 -0
  337. python_platform/unit_of_work/manager.py +210 -0
  338. python_platform/unit_of_work/options.py +92 -0
  339. python_platform-0.1.1.dist-info/METADATA +122 -0
  340. python_platform-0.1.1.dist-info/RECORD +344 -0
  341. python_platform-0.1.1.dist-info/WHEEL +4 -0
  342. python_platform-0.1.1.dist-info/entry_points.txt +9 -0
  343. python_platform-0.1.1.dist-info/licenses/LICENSE +7 -0
  344. python_platform-0.1.1.dist-info/licenses/src/python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
@@ -0,0 +1,423 @@
1
+ """Application-owned WebSocket registry、writer 与 shutdown runtime。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from typing import cast
10
+ from uuid import UUID, uuid4
11
+
12
+ from dishka import AsyncContainer
13
+ from fastapi import WebSocket
14
+
15
+ from ...application_services.invocation import _InvocationRuntime
16
+ from ...authorization import AccessTokenAuthenticator, CurrentUser, UnauthenticatedError
17
+ from ...options import Options
18
+ from ...realtime import (
19
+ RealtimeDeliveryReport,
20
+ RealtimeEnvelope,
21
+ RealtimePublisher,
22
+ RealtimeSnapshot,
23
+ )
24
+ from ...realtime.messages import _encode_envelope
25
+ from .connection import (
26
+ _PLATFORM_PROBLEM_MESSAGE_TYPE,
27
+ WebSocketConnection,
28
+ _WebSocketCloseCode,
29
+ )
30
+ from .options import FastApiRealtimeOptions
31
+
32
+ _LOGGER = logging.getLogger(__name__)
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class _TextFrame:
37
+ text: str
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class _CloseFrame:
42
+ code: _WebSocketCloseCode
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class _ConnectionState:
47
+ connection_id: UUID
48
+ websocket: WebSocket
49
+ authenticator: AccessTokenAuthenticator
50
+ access_token: str
51
+ current_user: CurrentUser
52
+ correlation_id: str
53
+ outbound: asyncio.Queue[_TextFrame | _CloseFrame]
54
+ endpoint_task: asyncio.Task[object]
55
+ closed: asyncio.Event
56
+ writer_task: asyncio.Task[None] | None = None
57
+ watchdog_task: asyncio.Task[None] | None = None
58
+ consecutive_invalid_messages: int = 0
59
+ closing: bool = False
60
+ peer_disconnected: bool = False
61
+ close_code: int = int(_WebSocketCloseCode.NORMAL)
62
+
63
+
64
+ class _RealtimeAdmissionError(Exception):
65
+ def __init__(self, code: _WebSocketCloseCode) -> None:
66
+ self.code = code
67
+ super().__init__(code.name)
68
+
69
+
70
+ class _FastApiRealtimeRuntime:
71
+ """每个 Application 独占的连接 owner;不存在进程级 registry。"""
72
+
73
+ __slots__ = (
74
+ "_accepting",
75
+ "_connections",
76
+ "_dropped",
77
+ "_enqueued",
78
+ "_invocations",
79
+ "_lock",
80
+ "_options",
81
+ "_user_connections",
82
+ )
83
+
84
+ def __init__(
85
+ self,
86
+ options: Options[FastApiRealtimeOptions],
87
+ invocations: _InvocationRuntime,
88
+ ) -> None:
89
+ self._options = options.value
90
+ self._invocations = invocations
91
+ self._accepting = False
92
+ self._connections: dict[UUID, _ConnectionState] = {}
93
+ self._user_connections: dict[UUID, set[UUID]] = {}
94
+ self._lock = asyncio.Lock()
95
+ self._enqueued = 0
96
+ self._dropped = 0
97
+
98
+ @property
99
+ def options(self) -> FastApiRealtimeOptions:
100
+ return self._options
101
+
102
+ async def start_accepting(self) -> None:
103
+ async with self._lock:
104
+ self._accepting = True
105
+
106
+ async def connect(
107
+ self,
108
+ websocket: WebSocket,
109
+ session: AsyncContainer,
110
+ authenticator: AccessTokenAuthenticator,
111
+ access_token: str,
112
+ current_user: CurrentUser,
113
+ correlation_id: str,
114
+ ) -> WebSocketConnection:
115
+ if not current_user.is_authenticated or current_user.user_id is None:
116
+ raise _RealtimeAdmissionError(_WebSocketCloseCode.POLICY_VIOLATION)
117
+ endpoint_task = asyncio.current_task()
118
+ if endpoint_task is None:
119
+ raise RuntimeError("managed WebSocket requires an asyncio Task")
120
+ state = _ConnectionState(
121
+ connection_id=uuid4(),
122
+ websocket=websocket,
123
+ authenticator=authenticator,
124
+ access_token=access_token,
125
+ current_user=current_user,
126
+ correlation_id=correlation_id,
127
+ # 多出的一个槽只保留给 close frame;业务容量仍严格等于 Options。
128
+ outbound=asyncio.Queue(maxsize=self._options.outbound_queue_capacity + 1),
129
+ endpoint_task=cast(asyncio.Task[object], endpoint_task),
130
+ closed=asyncio.Event(),
131
+ )
132
+ async with self._lock:
133
+ if not self._accepting:
134
+ raise _RealtimeAdmissionError(_WebSocketCloseCode.APPLICATION_SHUTDOWN)
135
+ user_connections = self._user_connections.setdefault(current_user.user_id, set())
136
+ if (
137
+ len(self._connections) >= self._options.max_connections
138
+ or len(user_connections) >= self._options.max_connections_per_user
139
+ ):
140
+ if not user_connections:
141
+ self._user_connections.pop(current_user.user_id, None)
142
+ raise _RealtimeAdmissionError(_WebSocketCloseCode.TRY_AGAIN_LATER)
143
+ self._connections[state.connection_id] = state
144
+ user_connections.add(state.connection_id)
145
+ try:
146
+ await websocket.accept()
147
+ except BaseException:
148
+ await self.disconnect(state.connection_id)
149
+ raise
150
+ state.writer_task = asyncio.create_task(
151
+ self._writer(state),
152
+ name=f"python-platform-ws-writer-{state.connection_id}",
153
+ )
154
+ state.watchdog_task = asyncio.create_task(
155
+ self._watchdog(state),
156
+ name=f"python-platform-ws-watchdog-{state.connection_id}",
157
+ )
158
+ return WebSocketConnection(self, state, session)
159
+
160
+ async def disconnect(self, connection_id: UUID) -> None:
161
+ async with self._lock:
162
+ state = self._connections.pop(connection_id, None)
163
+ if state is None:
164
+ return
165
+ user_id = state.current_user.user_id
166
+ if user_id is not None:
167
+ user_connections = self._user_connections.get(user_id)
168
+ if user_connections is not None:
169
+ user_connections.discard(connection_id)
170
+ if not user_connections:
171
+ self._user_connections.pop(user_id, None)
172
+ state.closing = True
173
+ current_task = asyncio.current_task()
174
+ tasks = tuple(
175
+ task
176
+ for task in (state.writer_task, state.watchdog_task)
177
+ if task is not None and task is not current_task and not task.done()
178
+ )
179
+ for task in tasks:
180
+ task.cancel()
181
+ if tasks:
182
+ await asyncio.gather(*tasks, return_exceptions=True)
183
+ state.access_token = ""
184
+ state.closed.set()
185
+
186
+ async def finish(
187
+ self,
188
+ connection_id: UUID,
189
+ *,
190
+ code: _WebSocketCloseCode = _WebSocketCloseCode.NORMAL,
191
+ ) -> None:
192
+ async with self._lock:
193
+ state = self._connections.get(connection_id)
194
+ if state is None:
195
+ return
196
+ if state.peer_disconnected:
197
+ await self.disconnect(connection_id)
198
+ return
199
+ await self._request_close(state, code)
200
+ writer = state.writer_task
201
+ if writer is not None and not writer.done():
202
+ try:
203
+ await asyncio.wait_for(
204
+ asyncio.shield(writer),
205
+ timeout=self._options.shutdown_timeout_seconds,
206
+ )
207
+ except TimeoutError:
208
+ writer.cancel()
209
+ await asyncio.gather(writer, return_exceptions=True)
210
+ await self.disconnect(connection_id)
211
+
212
+ async def send_to_connection(
213
+ self,
214
+ connection_id: UUID,
215
+ *,
216
+ message_type: str,
217
+ payload: Mapping[str, object],
218
+ ) -> RealtimeDeliveryReport:
219
+ async with self._lock:
220
+ state = self._connections.get(connection_id)
221
+ states = () if state is None else (state,)
222
+ return await self._send(states, message_type=message_type, payload=payload)
223
+
224
+ async def send_to_user(
225
+ self,
226
+ user_id: UUID,
227
+ *,
228
+ message_type: str,
229
+ payload: Mapping[str, object],
230
+ ) -> RealtimeDeliveryReport:
231
+ async with self._lock:
232
+ connection_ids = tuple(self._user_connections.get(user_id, ()))
233
+ states = tuple(
234
+ state
235
+ for connection_id in connection_ids
236
+ if (state := self._connections.get(connection_id)) is not None
237
+ )
238
+ return await self._send(states, message_type=message_type, payload=payload)
239
+
240
+ async def snapshot(self) -> RealtimeSnapshot:
241
+ async with self._lock:
242
+ return RealtimeSnapshot(
243
+ accepting=self._accepting,
244
+ active_connections=len(self._connections),
245
+ active_users=len(self._user_connections),
246
+ enqueued=self._enqueued,
247
+ dropped=self._dropped,
248
+ )
249
+
250
+ async def shutdown(self) -> None:
251
+ async with self._lock:
252
+ self._accepting = False
253
+ states = tuple(self._connections.values())
254
+ for state in states:
255
+ await self._request_close(state, _WebSocketCloseCode.APPLICATION_SHUTDOWN)
256
+ waiters = tuple(asyncio.create_task(state.closed.wait()) for state in states)
257
+ if waiters:
258
+ _, pending = await asyncio.wait(
259
+ waiters,
260
+ timeout=self._options.shutdown_timeout_seconds,
261
+ )
262
+ for waiter in pending:
263
+ waiter.cancel()
264
+ await asyncio.gather(*pending, return_exceptions=True)
265
+ remaining = tuple(state for state in states if not state.closed.is_set())
266
+ for state in remaining:
267
+ if not state.endpoint_task.done():
268
+ state.endpoint_task.cancel()
269
+ if remaining:
270
+ await asyncio.gather(
271
+ *(state.endpoint_task for state in remaining),
272
+ return_exceptions=True,
273
+ )
274
+ for state in remaining:
275
+ await self.disconnect(state.connection_id)
276
+
277
+ async def _send(
278
+ self,
279
+ states: tuple[_ConnectionState, ...],
280
+ *,
281
+ message_type: str,
282
+ payload: Mapping[str, object],
283
+ ) -> RealtimeDeliveryReport:
284
+ correlation_id = self._invocations.correlation_id() or str(uuid4())
285
+ text = _encode_envelope(
286
+ RealtimeEnvelope(
287
+ message_type=message_type,
288
+ correlation_id=correlation_id,
289
+ payload=payload,
290
+ )
291
+ )
292
+ if not states:
293
+ return RealtimeDeliveryReport(matched=0, enqueued=0, dropped=0)
294
+ enqueued = 0
295
+ dropped = 0
296
+ for state in states:
297
+ if state.closing:
298
+ dropped += 1
299
+ continue
300
+ try:
301
+ if state.outbound.qsize() >= self._options.outbound_queue_capacity:
302
+ raise asyncio.QueueFull
303
+ state.outbound.put_nowait(_TextFrame(text))
304
+ enqueued += 1
305
+ except asyncio.QueueFull:
306
+ dropped += 1
307
+ await self._request_close(
308
+ state,
309
+ _WebSocketCloseCode.TRY_AGAIN_LATER,
310
+ discard_pending=True,
311
+ )
312
+ async with self._lock:
313
+ self._enqueued += enqueued
314
+ self._dropped += dropped
315
+ return RealtimeDeliveryReport(
316
+ matched=len(states),
317
+ enqueued=enqueued,
318
+ dropped=dropped,
319
+ )
320
+
321
+ async def _enqueue_problem(
322
+ self,
323
+ state: _ConnectionState,
324
+ *,
325
+ code: str,
326
+ status: int,
327
+ ) -> RealtimeDeliveryReport:
328
+ return await self._send(
329
+ (state,),
330
+ message_type=_PLATFORM_PROBLEM_MESSAGE_TYPE,
331
+ payload={"code": code, "status": status},
332
+ )
333
+
334
+ async def _request_close(
335
+ self,
336
+ state: _ConnectionState,
337
+ code: _WebSocketCloseCode,
338
+ *,
339
+ discard_pending: bool = False,
340
+ ) -> None:
341
+ if state.closing:
342
+ return
343
+ state.closing = True
344
+ state.close_code = int(code)
345
+ evicted = 0
346
+ if discard_pending:
347
+ while True:
348
+ try:
349
+ frame = state.outbound.get_nowait()
350
+ except asyncio.QueueEmpty:
351
+ break
352
+ if isinstance(frame, _TextFrame):
353
+ evicted += 1
354
+ state.outbound.put_nowait(_CloseFrame(code))
355
+ if evicted:
356
+ async with self._lock:
357
+ self._dropped += evicted
358
+
359
+ async def _refresh_identity(self, state: _ConnectionState) -> CurrentUser:
360
+ try:
361
+ refreshed = await state.authenticator.authenticate_access_token(state.access_token)
362
+ except Exception as error:
363
+ _log_connection_failure("authentication", error)
364
+ await self._request_close(state, _WebSocketCloseCode.POLICY_VIOLATION)
365
+ raise UnauthenticatedError from None
366
+ if (
367
+ not refreshed.is_authenticated
368
+ or refreshed.user_id != state.current_user.user_id
369
+ or refreshed.session_id != state.current_user.session_id
370
+ ):
371
+ await self._request_close(state, _WebSocketCloseCode.POLICY_VIOLATION)
372
+ raise UnauthenticatedError
373
+ state.current_user = refreshed
374
+ return refreshed
375
+
376
+ async def _writer(self, state: _ConnectionState) -> None:
377
+ try:
378
+ while True:
379
+ frame = await state.outbound.get()
380
+ if isinstance(frame, _TextFrame):
381
+ await state.websocket.send_text(frame.text)
382
+ continue
383
+ await state.websocket.close(code=int(frame.code))
384
+ return
385
+ except asyncio.CancelledError:
386
+ raise
387
+ except Exception as error:
388
+ _log_connection_failure("writer", error)
389
+ state.closing = True
390
+ if not state.peer_disconnected and not state.endpoint_task.done():
391
+ state.endpoint_task.cancel()
392
+
393
+ async def _watchdog(self, state: _ConnectionState) -> None:
394
+ try:
395
+ while not state.closing:
396
+ try:
397
+ await asyncio.wait_for(
398
+ state.closed.wait(),
399
+ timeout=self._options.authentication_refresh_seconds,
400
+ )
401
+ return
402
+ except TimeoutError:
403
+ pass
404
+ try:
405
+ await self._refresh_identity(state)
406
+ except UnauthenticatedError:
407
+ return
408
+ except asyncio.CancelledError:
409
+ raise
410
+
411
+
412
+ def _realtime_publisher(runtime: _FastApiRealtimeRuntime) -> RealtimePublisher:
413
+ return runtime
414
+
415
+
416
+ def _log_connection_failure(component: str, error: Exception) -> None:
417
+ _LOGGER.warning(
418
+ "managed WebSocket operation failed",
419
+ extra={
420
+ "realtime_component": component,
421
+ "realtime_exception_type": type(error).__name__,
422
+ },
423
+ )
@@ -0,0 +1,244 @@
1
+ """HTTP bearer authentication、task-local identity 与 request audit bridge。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from datetime import UTC, datetime
8
+ from typing import cast
9
+ from uuid import uuid4
10
+
11
+ from anyio import CancelScope
12
+ from dishka import AsyncContainer
13
+ from dishka import Scope as DIScope
14
+ from dishka.exceptions import NoFactoryError
15
+ from dishka.integrations.starlette import ContainerMiddleware
16
+ from starlette.requests import Request
17
+ from starlette.responses import JSONResponse
18
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
19
+
20
+ from ..application import Application
21
+ from ..application_services.invocation import (
22
+ _close_action,
23
+ _InvocationRuntime,
24
+ _raise_scope_failure,
25
+ )
26
+ from ..auditing import AuditSink, RequestAuditRecord
27
+ from ..authorization import AccessTokenAuthenticator, CurrentUser, UnauthenticatedError
28
+ from ..invocation.contracts import _is_safe_correlation_id
29
+ from ..unit_of_work import UnitOfWorkManager
30
+ from .errors import FastApiAdapterError
31
+
32
+ _LOGGER = logging.getLogger(__name__)
33
+ _CORRELATION_HEADER = b"x-correlation-id"
34
+ _AUTHORIZATION_HEADER = b"authorization"
35
+
36
+
37
+ class _RequestScopeMiddleware:
38
+ """唯一 lease 包住唯一 REQUEST;原生 ASGI 链在相同 task 解析与关闭资源。"""
39
+
40
+ def __init__(self, app: ASGIApp, *, application: Application) -> None:
41
+ self._app = app
42
+ self._application = application
43
+ self._other = ContainerMiddleware(app)
44
+
45
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
46
+ if scope["type"] != "http":
47
+ await self._other(scope, receive, send)
48
+ return
49
+ async with self._application._http_request_lease_for_integration():
50
+ request = Request(scope, receive, send)
51
+ container = self._application._container_for_integration()(
52
+ {Request: request}, scope=DIScope.REQUEST
53
+ )
54
+ request.state.dishka_container = container
55
+ primary: BaseException | None = None
56
+ try:
57
+ await self._app(scope, receive, send)
58
+ except BaseException as error:
59
+ primary = error
60
+ finally:
61
+ # AnyIO 的 level cancellation 不能反复打断资源 finalizer;asyncio 重复取消
62
+ # 仍由既有 scope close 监督处理。lease 要等所有清理完成后才释放。
63
+ with CancelScope(shield=True):
64
+ primary, failures = await _close_action(container, primary)
65
+ request.state.dishka_container = None
66
+ _raise_scope_failure(primary, failures, False)
67
+
68
+
69
+ class _RequestTaskMiddleware:
70
+ """原生 middleware 下游的可信 task 入口,不迁移任何活动数据库状态。"""
71
+
72
+ def __init__(self, app: ASGIApp) -> None:
73
+ self._app = app
74
+
75
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
76
+ if scope["type"] != "http":
77
+ await self._app(scope, receive, send)
78
+ return
79
+ request = Request(scope)
80
+ container = cast(AsyncContainer, request.state.dishka_container)
81
+ runtime = await container.get(_InvocationRuntime)
82
+ manager = await container.get(UnitOfWorkManager)
83
+ if manager.current is not None:
84
+ raise FastApiAdapterError("middleware must finish its UnitOfWork before call_next")
85
+ user, correlation_id = request.state.python_platform_identity
86
+ with runtime.trusted_http_task(container, user, correlation_id, scope["method"]):
87
+ await self._app(scope, receive, send)
88
+
89
+
90
+ class _RequestContextMiddleware:
91
+ __slots__ = ("_app", "_application")
92
+
93
+ def __init__(self, app: ASGIApp, *, application: Application) -> None:
94
+ self._app = app
95
+ self._application = application
96
+
97
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
98
+ if scope["type"] != "http":
99
+ await self._app(scope, receive, send)
100
+ return
101
+
102
+ correlation_id = _correlation_id(scope)
103
+ occurred_at = datetime.now(UTC)
104
+ started_ns = time.perf_counter_ns()
105
+ current_user = CurrentUser.anonymous()
106
+ status_code = 500
107
+ failure: BaseException | None = None
108
+
109
+ async def send_with_context(message: Message) -> None:
110
+ nonlocal status_code
111
+ if message["type"] == "http.response.start":
112
+ status_code = cast(int, message["status"])
113
+ headers = list(message.get("headers", ()))
114
+ if not any(name.lower() == _CORRELATION_HEADER for name, _ in headers):
115
+ headers.append((_CORRELATION_HEADER, correlation_id.encode("ascii")))
116
+ message = {**message, "headers": headers}
117
+ await send(message)
118
+
119
+ # lease 必须包住下游 Dishka REQUEST scope;stop 会等到 response、两级 audit 与
120
+ # REQUEST finalizer 全部完成后才开始 Module shutdown/root close。
121
+ container = cast(AsyncContainer, Request(scope).state.dishka_container)
122
+ runtime = await container.get(_InvocationRuntime)
123
+ with runtime.call_scope(container), runtime.http_scope(scope["method"]):
124
+ try:
125
+ with runtime.authenticating():
126
+ current_user = await self._authenticate(scope)
127
+ Request(scope).state.python_platform_identity = (current_user, correlation_id)
128
+ with runtime.trusted_http_task(
129
+ container, current_user, correlation_id, scope["method"]
130
+ ):
131
+ await self._app(scope, receive, send_with_context)
132
+ except UnauthenticatedError as original_error:
133
+ failure = original_error
134
+ status_code = 401
135
+ response = JSONResponse(
136
+ status_code=401,
137
+ content={"detail": "Not authenticated"},
138
+ headers={"WWW-Authenticate": "Bearer"},
139
+ )
140
+ await response(scope, receive, send_with_context)
141
+ except BaseException as original_error:
142
+ failure = original_error
143
+ raise
144
+ finally:
145
+ await self._write_audit(
146
+ scope,
147
+ current_user=current_user,
148
+ correlation_id=correlation_id,
149
+ occurred_at=occurred_at,
150
+ status_code=status_code,
151
+ duration_ns=time.perf_counter_ns() - started_ns,
152
+ failure=failure
153
+ or getattr(Request(scope).state, "python_platform_transfer_failure", None),
154
+ )
155
+
156
+ async def _authenticate(self, scope: Scope) -> CurrentUser:
157
+ authorization = _header(scope, _AUTHORIZATION_HEADER)
158
+ if authorization is None:
159
+ return CurrentUser.anonymous()
160
+ scheme, separator, token = authorization.partition(" ")
161
+ if scheme.casefold() != "bearer" or not separator or not token or token != token.strip():
162
+ raise UnauthenticatedError
163
+ try:
164
+ authenticator = cast(
165
+ AccessTokenAuthenticator,
166
+ await cast(AsyncContainer, Request(scope).state.dishka_container).get(
167
+ AccessTokenAuthenticator
168
+ ),
169
+ )
170
+ except NoFactoryError:
171
+ # Host 收到认证凭据却没有验证 authority 时必须 fail closed。
172
+ raise UnauthenticatedError from None
173
+ return await authenticator.authenticate_access_token(token)
174
+
175
+ async def _write_audit(
176
+ self,
177
+ scope: Scope,
178
+ *,
179
+ current_user: CurrentUser,
180
+ correlation_id: str,
181
+ occurred_at: datetime,
182
+ status_code: int,
183
+ duration_ns: int,
184
+ failure: BaseException | None,
185
+ ) -> None:
186
+ try:
187
+ container = self._application._container_for_integration()
188
+ except RuntimeError:
189
+ return
190
+ try:
191
+ sink = await container.get(AuditSink)
192
+ except NoFactoryError:
193
+ return
194
+ except Exception as audit_error:
195
+ _log_audit_failure(audit_error, route_template="<unresolved>")
196
+ return
197
+ route = scope.get("route")
198
+ route_path = getattr(route, "path", None)
199
+ route_template = route_path if isinstance(route_path, str) else "<unmatched>"
200
+ try:
201
+ await sink.write_request(
202
+ RequestAuditRecord(
203
+ occurred_at=occurred_at,
204
+ user_id=current_user.user_id,
205
+ session_id=current_user.session_id,
206
+ correlation_id=correlation_id,
207
+ route_template=route_template,
208
+ status_code=status_code,
209
+ duration_ns=duration_ns,
210
+ exception_type=None if failure is None else type(failure).__name__,
211
+ )
212
+ )
213
+ except Exception as audit_error:
214
+ _log_audit_failure(audit_error, route_template=route_template)
215
+
216
+
217
+ def _correlation_id(scope: Scope) -> str:
218
+ supplied = _header(scope, _CORRELATION_HEADER)
219
+ if _is_safe_correlation_id(supplied):
220
+ return supplied
221
+ return str(uuid4())
222
+
223
+
224
+ def _header(scope: Scope, name: bytes) -> str | None:
225
+ headers = cast(tuple[tuple[bytes, bytes], ...], scope.get("headers", ()))
226
+ for header_name, raw_value in headers:
227
+ if header_name.lower() == name:
228
+ try:
229
+ return raw_value.decode("latin-1")
230
+ except UnicodeDecodeError:
231
+ return None
232
+ return None
233
+
234
+
235
+ def _log_audit_failure(error: Exception, *, route_template: str) -> None:
236
+ # 仅记录静态类型与 route template;禁止输出 provider exception 文本。
237
+ _LOGGER.error(
238
+ "request audit write failed",
239
+ exc_info=error,
240
+ extra={
241
+ "audit_exception_type": type(error).__name__,
242
+ "audit_route_template": route_template,
243
+ },
244
+ )