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,113 @@
1
+ """Provider-neutral realtime wire envelope 与 JSON boundary。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime
11
+ from types import MappingProxyType
12
+ from typing import Final, Literal
13
+ from uuid import UUID, uuid4
14
+
15
+ from ..invocation.contracts import _is_safe_correlation_id
16
+
17
+ _MESSAGE_TYPE_PATTERN = re.compile(r"^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*\.v[1-9][0-9]*$")
18
+ _REALTIME_SCHEMA_VERSION: Final = 1
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class RealtimeEnvelope:
23
+ """不可变、可序列化且不携带 transport/provider object 的消息。"""
24
+
25
+ message_type: str
26
+ correlation_id: str
27
+ payload: Mapping[str, object]
28
+ schema_version: Literal[1] = _REALTIME_SCHEMA_VERSION
29
+ message_id: UUID = field(default_factory=uuid4)
30
+ occurred_at: datetime = field(default_factory=lambda: datetime.now(UTC))
31
+
32
+ def __post_init__(self) -> None:
33
+ if self.schema_version != _REALTIME_SCHEMA_VERSION:
34
+ raise ValueError(f"realtime schema_version must be {_REALTIME_SCHEMA_VERSION}")
35
+ if not isinstance(self.message_id, UUID):
36
+ raise TypeError("realtime message_id must be UUID")
37
+ if (
38
+ not isinstance(self.message_type, str)
39
+ or _MESSAGE_TYPE_PATTERN.fullmatch(self.message_type) is None
40
+ ):
41
+ raise ValueError("realtime message_type must be a versioned dotted name")
42
+ if not _is_safe_correlation_id(self.correlation_id):
43
+ raise ValueError("realtime correlation_id must be a safe non-empty identifier")
44
+ if not isinstance(self.occurred_at, datetime) or self.occurred_at.utcoffset() is None:
45
+ raise ValueError("realtime occurred_at must be timezone-aware")
46
+ if self.occurred_at.utcoffset() != UTC.utcoffset(self.occurred_at):
47
+ raise ValueError("realtime occurred_at must be UTC")
48
+ if not isinstance(self.payload, Mapping):
49
+ raise TypeError("realtime payload must be a JSON object")
50
+ frozen = _freeze_json_mapping(self.payload, active=set())
51
+ object.__setattr__(self, "payload", frozen)
52
+
53
+
54
+ def _encode_envelope(envelope: RealtimeEnvelope) -> str:
55
+ occurred_at = envelope.occurred_at.isoformat().replace("+00:00", "Z")
56
+ wire = {
57
+ "schema_version": envelope.schema_version,
58
+ "message_id": str(envelope.message_id),
59
+ "message_type": envelope.message_type,
60
+ "occurred_at": occurred_at,
61
+ "correlation_id": envelope.correlation_id,
62
+ "payload": _thaw_json_value(envelope.payload),
63
+ }
64
+ return json.dumps(wire, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
65
+
66
+
67
+ def _freeze_json_mapping(
68
+ value: Mapping[str, object],
69
+ *,
70
+ active: set[int],
71
+ ) -> Mapping[str, object]:
72
+ identity = id(value)
73
+ if identity in active:
74
+ raise ValueError("realtime payload cannot contain cycles")
75
+ active.add(identity)
76
+ try:
77
+ frozen: dict[str, object] = {}
78
+ for key, item in value.items():
79
+ if not isinstance(key, str):
80
+ raise TypeError("realtime payload object keys must be strings")
81
+ frozen[key] = _freeze_json_value(item, active=active)
82
+ return MappingProxyType(frozen)
83
+ finally:
84
+ active.remove(identity)
85
+
86
+
87
+ def _freeze_json_value(value: object, *, active: set[int]) -> object:
88
+ if value is None or isinstance(value, (str, bool, int)):
89
+ return value
90
+ if isinstance(value, float):
91
+ if not math.isfinite(value):
92
+ raise ValueError("realtime payload numbers must be finite")
93
+ return value
94
+ if isinstance(value, Mapping):
95
+ return _freeze_json_mapping(value, active=active)
96
+ if isinstance(value, (list, tuple)):
97
+ identity = id(value)
98
+ if identity in active:
99
+ raise ValueError("realtime payload cannot contain cycles")
100
+ active.add(identity)
101
+ try:
102
+ return tuple(_freeze_json_value(item, active=active) for item in value)
103
+ finally:
104
+ active.remove(identity)
105
+ raise TypeError("realtime payload contains a non-JSON value")
106
+
107
+
108
+ def _thaw_json_value(value: object) -> object:
109
+ if isinstance(value, Mapping):
110
+ return {key: _thaw_json_value(item) for key, item in value.items()}
111
+ if isinstance(value, tuple):
112
+ return [_thaw_json_value(item) for item in value]
113
+ return value
@@ -0,0 +1,6 @@
1
+ """Optional Redis distributed cache provider。"""
2
+
3
+ from .module import RedisConnectionModule, RedisDistributedLockModule, RedisModule
4
+ from .options import RedisOptions
5
+
6
+ __all__ = ("RedisConnectionModule", "RedisDistributedLockModule", "RedisModule", "RedisOptions")
@@ -0,0 +1,212 @@
1
+ """原生 Redis Lock 的接线与每次获取独立的业务取消/资源 owner。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator, Awaitable
7
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
8
+ from typing import TypeVar, cast
9
+ from uuid import uuid4
10
+
11
+ from anyio import CancelScope
12
+ from redis.exceptions import LockNotOwnedError
13
+
14
+ from ..distributed_lock import DistributedLockError, DistributedLockOptions
15
+ from ..options import Options
16
+ from .lease_lock import _LeaseLock
17
+ from .options import RedisOptions
18
+ from .runtime import _RedisClientOwner
19
+
20
+ _T = TypeVar("_T")
21
+
22
+
23
+ class RedisDistributedLock:
24
+ """APP 服务只持有连接和冻结配置;可变租约不在不同获取之间共享。"""
25
+
26
+ def __init__(
27
+ self,
28
+ client: _RedisClientOwner,
29
+ options: Options[DistributedLockOptions],
30
+ redis_options: Options[RedisOptions],
31
+ ) -> None:
32
+ self._client = client.client
33
+ self._options = options.value
34
+ self._prefix = redis_options.value.key_prefix
35
+
36
+ def acquire(self, key: str, /) -> AbstractAsyncContextManager[bool]:
37
+ if not isinstance(key, str) or not key or key != key.strip():
38
+ raise ValueError("Distributed lock key must be a nonempty trimmed string")
39
+ native = self._client.lock(
40
+ f"{self._prefix}:locks:{key}",
41
+ timeout=self._options.lease_timeout.total_seconds(),
42
+ blocking_timeout=self._options.wait_timeout.total_seconds(),
43
+ thread_local=False,
44
+ lock_class=_LeaseLock,
45
+ )
46
+ return _RedisLockLease(key, cast(_LeaseLock, native), self._options).hold()
47
+
48
+
49
+ class _RedisLockLease:
50
+ def __init__(self, key: str, native: _LeaseLock, options: DistributedLockOptions) -> None:
51
+ self._key = key
52
+ self._lock = native
53
+ self._options = options
54
+ self._stop = asyncio.Event()
55
+ self._active = False
56
+ self._lease_errors: list[BaseException] = []
57
+ self._cancellation: asyncio.CancelledError | None = None
58
+
59
+ @asynccontextmanager
60
+ async def hold(self) -> AsyncIterator[bool]:
61
+ # 显式 token 仍交给原生 Lua 仲裁;响应丢失时也只能条件释放本次 token,不能 DEL key。
62
+ token = uuid4().hex.encode()
63
+ try:
64
+ acquired = await self._wait_owned(
65
+ asyncio.ensure_future(cast(Awaitable[bool], self._lock.acquire(token=token)))
66
+ )
67
+ if acquired and asyncio.get_running_loop().time() >= self._lock.deadline:
68
+ raise TimeoutError("Acquire response arrived after the conservative lease deadline")
69
+ except BaseException as error:
70
+ failures = [self._native_error("acquire", error)]
71
+ try:
72
+ await self._wait_owned(asyncio.create_task(self._lock.do_release(token)))
73
+ except LockNotOwnedError:
74
+ # 获取未成功或响应不确定时,无本次 token 是合法结果;原始失败仍然传播。
75
+ pass
76
+ except BaseException as cleanup_error:
77
+ failures.append(self._native_error("release", cleanup_error))
78
+ failure = _lock_failure(failures, self._cancellation)
79
+ raise failure from failure.__cause__
80
+
81
+ if not acquired:
82
+ self._raise_failures(None, [])
83
+ yield False
84
+ return
85
+
86
+ body_error: BaseException | None = None
87
+ failures = []
88
+ renewal: asyncio.Task[None] | None = None
89
+ try:
90
+ self._raise_failures(None, [])
91
+ # 上下文进入、业务与退出属于同一 task;业务自身的 shield / 线程等待保持原生语义。
92
+ with CancelScope() as scope:
93
+ self._active = True
94
+ renewal = asyncio.create_task(
95
+ self._renew(scope), name="python-platform-lock-renewal"
96
+ )
97
+ try:
98
+ yield True
99
+ except BaseException as error:
100
+ body_error = error
101
+ raise
102
+ except BaseException as error:
103
+ body_error = error
104
+ finally:
105
+ # body 的 finally 已退出,才停续租并释放锁。I/O 尚未结束时不与 release 并发。
106
+ self._active = False
107
+ self._stop.set()
108
+ if renewal is not None:
109
+ await self._wait_owned(renewal)
110
+ failures.extend(self._lease_errors)
111
+ try:
112
+ await self._wait_owned(asyncio.create_task(self._lock.release()))
113
+ except BaseException as cleanup_error:
114
+ failures.append(self._native_error("release", cleanup_error))
115
+ self._raise_failures(body_error, failures)
116
+
117
+ async def _renew(self, scope: CancelScope) -> None:
118
+ interval = self._options.renewal_interval.total_seconds()
119
+ loop = asyncio.get_running_loop()
120
+ try:
121
+ while True:
122
+ deadline = self._lock.deadline
123
+ delay = max(0, min(self._lock.lease_started + interval, deadline) - loop.time())
124
+ if self._stop.is_set():
125
+ return
126
+ try:
127
+ await asyncio.wait_for(self._stop.wait(), delay)
128
+ return
129
+ except TimeoutError:
130
+ pass
131
+ remaining = deadline - loop.time()
132
+ if remaining <= 0:
133
+ raise TimeoutError("Lease expired before renewal")
134
+ request = asyncio.ensure_future(self._lock.reacquire())
135
+ done, _ = await asyncio.wait((request,), timeout=remaining)
136
+ if not done or loop.time() >= deadline:
137
+ # 旧截止时间不能被迟到的成功响应延长。先撤销业务许可,再等 I/O 收口;
138
+ # 不 cancel 请求后立即 release,避免旧续租与 Token 释放并发。
139
+ self._fail_lease(scope, TimeoutError("Renewal exceeded the lease deadline"))
140
+ try:
141
+ await request
142
+ except BaseException as error:
143
+ self._fail_lease(scope, error)
144
+ return
145
+ request.result()
146
+ except BaseException as error:
147
+ self._fail_lease(scope, error)
148
+
149
+ def _fail_lease(self, scope: CancelScope, error: BaseException) -> None:
150
+ self._lease_errors.append(self._native_error("renew", error))
151
+ if self._active:
152
+ scope.cancel()
153
+
154
+ async def _wait_owned(self, future: asyncio.Future[_T]) -> _T:
155
+ # caller 取消不能遗弃正在获取/释放的原生 I/O。记录取消,确定 Token 结果并清理后再传播。
156
+ try:
157
+ return await asyncio.shield(future)
158
+ except asyncio.CancelledError as cancellation:
159
+ if self._cancellation is None:
160
+ self._cancellation = cancellation
161
+ # 外层 AnyIO level cancellation 会在每次 await 重发;捕获后必须 shield 收尾,
162
+ # 否则 asyncio.shield 的反复等待会形成忙循环。raw task.cancel 仍被记录并等待。
163
+ with CancelScope(shield=True):
164
+ while not future.done():
165
+ try:
166
+ await asyncio.shield(future)
167
+ except asyncio.CancelledError:
168
+ # 首次取消已保存;再次 raw cancel 也不能遗弃这个 I/O owner。
169
+ continue
170
+ return future.result()
171
+
172
+ def _native_error(self, operation: str, error: BaseException) -> BaseException:
173
+ if not isinstance(error, Exception):
174
+ return error
175
+ failure = DistributedLockError(
176
+ key=self._key, operation=operation, error_type=type(error).__name__
177
+ )
178
+ failure.__cause__ = error
179
+ return failure
180
+
181
+ def _raise_failures(
182
+ self, body_error: BaseException | None, failures: list[BaseException]
183
+ ) -> None:
184
+ cancellation = self._cancellation
185
+ if isinstance(body_error, asyncio.CancelledError):
186
+ cancellation = body_error
187
+ elif body_error is not None:
188
+ if not failures:
189
+ if cancellation is None:
190
+ # 成功清理时保持业务异常原有 cause,不用 from None 覆盖调用者的诊断链。
191
+ raise body_error
192
+ raise _lock_failure([body_error, cancellation])
193
+ failures.insert(0, body_error)
194
+ if failures:
195
+ raise _lock_failure(failures, cancellation)
196
+ if cancellation is not None:
197
+ raise cancellation
198
+
199
+
200
+ def _lock_failure(
201
+ failures: list[BaseException], cancellation: asyncio.CancelledError | None = None
202
+ ) -> BaseException:
203
+ if cancellation is not None:
204
+ # 取消放在聚合 cause,原生 I/O 的 cause 留在各失败上,避免相互覆盖。
205
+ group = BaseExceptionGroup("Distributed lock operation and cleanup failed", tuple(failures))
206
+ group.__cause__ = cancellation
207
+ return group
208
+ return (
209
+ failures[0]
210
+ if len(failures) == 1
211
+ else BaseExceptionGroup("Distributed lock operation and cleanup failed", tuple(failures))
212
+ )
@@ -0,0 +1,36 @@
1
+ """redis-py 原生扩展点:记录成功请求的起点,不接管重试或 Token Lua。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Literal
7
+
8
+ from redis.asyncio.lock import Lock
9
+
10
+
11
+ class _LeaseLock(Lock):
12
+ _lease_started: float
13
+
14
+ @property
15
+ def lease_started(self) -> float:
16
+ return self._lease_started
17
+
18
+ @property
19
+ def deadline(self) -> float:
20
+ # 与原生 SET PX / reacquire Lua 使用的毫秒截断保持一致。
21
+ assert self.timeout is not None
22
+ return self._lease_started + int(self.timeout * 1000) / 1000
23
+
24
+ async def do_acquire(self, token: str | bytes) -> bool:
25
+ started = asyncio.get_running_loop().time()
26
+ acquired = await super().do_acquire(token)
27
+ if acquired:
28
+ # 竞争失败及原生 retry sleep 不消耗下一次成功获取的租期。
29
+ self._lease_started = started
30
+ return acquired
31
+
32
+ async def do_reacquire(self) -> Literal[True]:
33
+ started = asyncio.get_running_loop().time()
34
+ result = await super().do_reacquire()
35
+ self._lease_started = started
36
+ return result
@@ -0,0 +1,50 @@
1
+ """Host 显式选择的 Redis Module。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dishka import Provider, Scope
6
+
7
+ from ..caching import DistributedCache
8
+ from ..distributed_lock import DistributedLock, DistributedLockOptions
9
+ from ..lifecycle import ConfigureContext, InitializeContext
10
+ from ..modularity import AppModule
11
+ from .distributed_lock import RedisDistributedLock
12
+ from .options import RedisOptions
13
+ from .runtime import RedisDistributedCache, _redis_client, _RedisClientOwner
14
+
15
+
16
+ class RedisConnectionModule(AppModule):
17
+ """共享原生连接生命周期;缓存和通知自行决定连接故障的业务语义。"""
18
+
19
+ def configure(self, context: ConfigureContext) -> None:
20
+ context.configure(RedisOptions, section="redis", secret_paths=frozenset({"url"}))
21
+ provider = Provider()
22
+ provider.provide(_redis_client, provides=_RedisClientOwner, scope=Scope.APP)
23
+ context.services.contribute(provider, reason="Application Redis connection")
24
+
25
+
26
+ class RedisModule(AppModule):
27
+ dependencies = (RedisConnectionModule,)
28
+
29
+ def configure(self, context: ConfigureContext) -> None:
30
+ provider = Provider()
31
+ provider.provide(
32
+ RedisDistributedCache,
33
+ provides=DistributedCache,
34
+ scope=Scope.APP,
35
+ )
36
+ context.services.contribute(provider, reason="Redis typed distributed cache")
37
+
38
+ async def initialize(self, context: InitializeContext) -> None:
39
+ client = await context.container.get(_RedisClientOwner)
40
+ await client.ping()
41
+
42
+
43
+ class RedisDistributedLockModule(AppModule):
44
+ dependencies = (RedisConnectionModule,)
45
+
46
+ def configure(self, context: ConfigureContext) -> None:
47
+ context.configure(DistributedLockOptions, section="distributed_lock")
48
+ provider = Provider()
49
+ provider.provide(RedisDistributedLock, provides=DistributedLock, scope=Scope.APP)
50
+ context.services.contribute(provider, reason="Redis renewable distributed lock")
@@ -0,0 +1,182 @@
1
+ """原生 Redis Pub/Sub 的 typed 边界与 Application-owned 接收生命周期。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from collections.abc import AsyncIterator, Awaitable, Callable
8
+ from contextlib import asynccontextmanager, suppress
9
+ from dataclasses import dataclass
10
+ from typing import Any, ClassVar, TypeVar, cast
11
+
12
+ from pydantic import ConfigDict, Field
13
+ from redis.exceptions import RedisError
14
+
15
+ from ..application_services.invocation import _InvocationRuntime
16
+ from ..hosted_services import HostedService, HostedServiceContext
17
+ from ..lifecycle import ShutdownReason
18
+ from ..notifications import (
19
+ NotificationCatalog,
20
+ NotificationDefinition,
21
+ NotificationDefinitionError,
22
+ NotificationPayloadError,
23
+ NotificationPublishStatus,
24
+ )
25
+ from ..observability.context import capture_trace_context, invocation_span
26
+ from ..options import BaseOptions, Options
27
+ from .options import RedisOptions
28
+ from .runtime import _RedisClientOwner
29
+
30
+ _T = TypeVar("_T")
31
+ _LOGGER = logging.getLogger(__name__)
32
+
33
+
34
+ class NotificationOptions(BaseOptions):
35
+ model_config = ConfigDict(extra="forbid")
36
+
37
+ max_message_bytes: int = Field(default=65536, ge=1)
38
+ reconnect_interval_seconds: float = Field(default=1.0, gt=0)
39
+ publish_timeout_seconds: float = Field(default=5.0, gt=0)
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class _Subscriber:
44
+ handler: Callable[[Any], Awaitable[None]]
45
+ on_reconnect: Callable[[], None] | None
46
+
47
+
48
+ class RedisNotifications(HostedService):
49
+ run_in_background_processes: ClassVar[bool] = True
50
+
51
+ def __init__(
52
+ self,
53
+ client: _RedisClientOwner,
54
+ catalog: NotificationCatalog,
55
+ options: Options[NotificationOptions],
56
+ redis_options: Options[RedisOptions],
57
+ runtime: _InvocationRuntime,
58
+ ) -> None:
59
+ self._client = client.client
60
+ self._catalog = catalog
61
+ self._options = options.value
62
+ self._runtime = runtime
63
+ self._channels = {
64
+ f"{redis_options.value.key_prefix}:notifications:{definition.name}": definition
65
+ for definition in catalog.definitions
66
+ }
67
+ self._prefix = f"{redis_options.value.key_prefix}:notifications:"
68
+ self._subscribers: dict[str, dict[object, _Subscriber]] = {
69
+ channel: {} for channel in self._channels
70
+ }
71
+ self._connected: set[str] = set()
72
+ self._outage_reported = False
73
+ self._task: asyncio.Task[None] | None = None
74
+
75
+ async def start(self, context: HostedServiceContext) -> None:
76
+ self._task = asyncio.create_task(self._listen(), name=context.name)
77
+ # 接收/重连由组件拥有;启动完成不等同于 Redis 订阅已确认。
78
+
79
+ async def stop(self, reason: ShutdownReason) -> None:
80
+ task = self._task
81
+ if task is not None:
82
+ task.cancel()
83
+ with suppress(asyncio.CancelledError):
84
+ await task
85
+ self._connected.clear()
86
+
87
+ async def publish(
88
+ self, definition: NotificationDefinition[_T], payload: _T
89
+ ) -> NotificationPublishStatus:
90
+ self._catalog.require_owned(definition)
91
+ with invocation_span(self._runtime.tracer, f"publish {definition.name}"):
92
+ encoded = definition._encode(payload, capture_trace_context(self._runtime.tracer))
93
+ if len(encoded) > self._options.max_message_bytes:
94
+ raise NotificationPayloadError("Notification exceeds configured message size")
95
+ try:
96
+ await asyncio.wait_for(
97
+ self._client.publish(self._prefix + definition.name, encoded),
98
+ timeout=self._options.publish_timeout_seconds,
99
+ )
100
+ except (TimeoutError, RedisError):
101
+ self._report_outage()
102
+ return NotificationPublishStatus.UNAVAILABLE
103
+ return NotificationPublishStatus.PUBLISHED
104
+
105
+ @asynccontextmanager
106
+ async def subscribe(
107
+ self,
108
+ definition: NotificationDefinition[_T],
109
+ handler: Callable[[_T], Awaitable[None]],
110
+ *,
111
+ on_reconnect: Callable[[], None] | None = None,
112
+ ) -> AsyncIterator[None]:
113
+ self._catalog.require_owned(definition)
114
+ if not callable(handler) or (on_reconnect is not None and not callable(on_reconnect)):
115
+ raise NotificationDefinitionError("Notification callbacks must be callable")
116
+ channel = self._prefix + definition.name
117
+ token = object()
118
+ subscriber = _Subscriber(cast(Callable[[Any], Awaitable[None]], handler), on_reconnect)
119
+ self._subscribers[channel][token] = subscriber
120
+ try:
121
+ if channel in self._connected and on_reconnect is not None:
122
+ on_reconnect()
123
+ yield
124
+ finally:
125
+ self._subscribers[channel].pop(token)
126
+
127
+ async def _listen(self) -> None:
128
+ if not self._channels:
129
+ await asyncio.Future[None]()
130
+ while True:
131
+ try:
132
+ async with self._client.pubsub() as pubsub:
133
+ await pubsub.subscribe(*self._channels)
134
+ while True:
135
+ message = await pubsub.get_message(
136
+ ignore_subscribe_messages=False, timeout=1
137
+ )
138
+ if message is not None:
139
+ await self._receive(message)
140
+ except RedisError:
141
+ self._connected.clear()
142
+ self._report_outage()
143
+ await asyncio.sleep(self._options.reconnect_interval_seconds)
144
+
145
+ async def _receive(self, message: dict[str, Any]) -> None:
146
+ channel = message["channel"].decode("utf-8")
147
+ if message["type"] == "subscribe":
148
+ self._connected.add(channel)
149
+ self._outage_reported = False
150
+ for subscriber in tuple(self._subscribers[channel].values()):
151
+ if subscriber.on_reconnect is not None:
152
+ try:
153
+ subscriber.on_reconnect()
154
+ except Exception as error:
155
+ _LOGGER.error(
156
+ "Notification reconnect handler failed: %s", type(error).__name__
157
+ )
158
+ elif message["type"] == "message":
159
+ await self._deliver(channel, message["data"])
160
+
161
+ async def _deliver(self, channel: str, encoded: bytes) -> None:
162
+ definition = self._channels[channel]
163
+ try:
164
+ if len(encoded) > self._options.max_message_bytes:
165
+ raise NotificationPayloadError("Notification exceeds configured message size")
166
+ payload, carrier = definition._decode(encoded)
167
+ except NotificationPayloadError:
168
+ _LOGGER.warning("Invalid notification envelope ignored")
169
+ return
170
+ with invocation_span(self._runtime.tracer, f"receive {definition.name}", carrier=carrier):
171
+ for token, subscriber in tuple(self._subscribers[channel].items()):
172
+ if token not in self._subscribers[channel]:
173
+ continue
174
+ try:
175
+ await subscriber.handler(payload)
176
+ except Exception as error:
177
+ _LOGGER.error("Notification handler failed: %s", type(error).__name__)
178
+
179
+ def _report_outage(self) -> None:
180
+ if not self._outage_reported:
181
+ _LOGGER.warning("Redis notifications temporarily unavailable")
182
+ self._outage_reported = True
@@ -0,0 +1,25 @@
1
+ """Host 按需启用 Redis 通知;复用原生 Hosted Service 监督。"""
2
+
3
+ from dishka import Provider, Scope
4
+
5
+ from ..lifecycle import ConfigureContext
6
+ from ..modularity import AppModule
7
+ from ..notifications import Notifications
8
+ from .module import RedisConnectionModule
9
+ from .notification_runtime import NotificationOptions, RedisNotifications
10
+
11
+
12
+ class RedisNotificationsModule(AppModule):
13
+ dependencies = (RedisConnectionModule,)
14
+ hosted_services = (RedisNotifications,)
15
+
16
+ def configure(self, context: ConfigureContext) -> None:
17
+ context.configure(NotificationOptions, section="notifications")
18
+ provider = Provider()
19
+ provider.provide(_notifications, provides=Notifications, scope=Scope.APP)
20
+ context.services.contribute(provider, reason="Redis typed Pub/Sub")
21
+
22
+
23
+ def _notifications(runtime: RedisNotifications) -> Notifications:
24
+ # 正式 catalog 只接纳 provide;同一实例承担通知契约与 Hosted Service 生命周期。
25
+ return runtime
@@ -0,0 +1,35 @@
1
+ """Redis connection 与统一 key prefix Options。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import SecretStr, field_validator
6
+
7
+ from ..options import BaseOptions
8
+
9
+
10
+ class RedisOptions(BaseOptions):
11
+ url: SecretStr
12
+ key_prefix: str = "python-platform"
13
+
14
+ @field_validator("url")
15
+ @classmethod
16
+ def _validate_url(cls, value: SecretStr) -> SecretStr:
17
+ raw = value.get_secret_value()
18
+ scheme, separator, remainder = raw.partition("://")
19
+ if scheme not in {"redis", "rediss"} or not separator or not remainder:
20
+ raise ValueError("Redis URL must use redis:// or rediss://")
21
+ return value
22
+
23
+ @field_validator("key_prefix")
24
+ @classmethod
25
+ def _validate_key_prefix(cls, value: str) -> str:
26
+ if (
27
+ not value
28
+ or value != value.strip()
29
+ or value.startswith(":")
30
+ or value.endswith(":")
31
+ or "::" in value
32
+ or len(value) > 128
33
+ ):
34
+ raise ValueError("Redis key_prefix must be a trimmed identifier")
35
+ return value