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,287 @@
1
+ """Durable job versions 与 Cron schedules 的 immutable Application catalog。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Iterator, Mapping
8
+ from dataclasses import replace
9
+ from datetime import UTC, datetime
10
+ from types import MappingProxyType
11
+
12
+ from pydantic import BaseModel, ValidationError
13
+
14
+ from ..modularity import AppModule, ModuleDescriptor, ModuleKey, ModuleRegistry
15
+ from .contracts import BackgroundJobDefinition, BackgroundJobSchedule
16
+ from .errors import BackgroundJobDefinitionError, BackgroundJobPayloadError
17
+
18
+
19
+ class BackgroundJobCatalog:
20
+ __slots__ = (
21
+ "_current",
22
+ "_declarations",
23
+ "_definitions",
24
+ "_fingerprint",
25
+ "_schedules",
26
+ )
27
+
28
+ def __init__(
29
+ self,
30
+ definitions: tuple[BackgroundJobDefinition[BaseModel], ...],
31
+ schedules: tuple[BackgroundJobSchedule, ...],
32
+ *,
33
+ declarations: tuple[tuple[type[AppModule], str, tuple[object, ...]], ...],
34
+ owners: Mapping[tuple[str, int], ModuleKey],
35
+ ) -> None:
36
+ self._definitions = MappingProxyType(
37
+ {(item.name, item.version): item for item in definitions}
38
+ )
39
+ self._current = MappingProxyType({item.name: item for item in definitions if item.current})
40
+ self._schedules = MappingProxyType({item.name: item for item in schedules})
41
+ self._declarations = declarations
42
+ encoded = json.dumps(
43
+ {
44
+ "definitions": [
45
+ {
46
+ "name": item.name,
47
+ "version": item.version,
48
+ "current": item.current,
49
+ "owner": str(owners[(item.name, item.version)]),
50
+ "timeout_seconds": item.timeout.total_seconds(),
51
+ "max_attempts": item.retry_policy.max_attempts,
52
+ "initial_delay_seconds": item.retry_policy.initial_delay.total_seconds(),
53
+ "max_delay_seconds": item.retry_policy.max_delay.total_seconds(),
54
+ "backoff_multiplier": item.retry_policy.backoff_multiplier,
55
+ }
56
+ for item in definitions
57
+ ],
58
+ "schedules": [
59
+ {
60
+ "name": item.name,
61
+ "cron": item.cron,
62
+ "job_name": item.job_name,
63
+ "job_version": item.job_version,
64
+ "payload": item.payload.decode(),
65
+ }
66
+ for item in schedules
67
+ ],
68
+ },
69
+ sort_keys=True,
70
+ separators=(",", ":"),
71
+ ).encode()
72
+ self._fingerprint = hashlib.sha256(encoded).hexdigest()
73
+
74
+ @property
75
+ def definitions(self) -> tuple[BackgroundJobDefinition[BaseModel], ...]:
76
+ return tuple(self._definitions.values())
77
+
78
+ @property
79
+ def schedules(self) -> tuple[BackgroundJobSchedule, ...]:
80
+ return tuple(self._schedules.values())
81
+
82
+ @property
83
+ def fingerprint(self) -> str:
84
+ return self._fingerprint
85
+
86
+ def __iter__(self) -> Iterator[tuple[str, int]]:
87
+ return iter(self._definitions)
88
+
89
+ def get(self, name: str, version: int) -> BackgroundJobDefinition[BaseModel] | None:
90
+ return self._definitions.get((name, version))
91
+
92
+ def current(self, name: str) -> BackgroundJobDefinition[BaseModel] | None:
93
+ return self._current.get(name)
94
+
95
+ def get_schedule(self, name: str) -> BackgroundJobSchedule | None:
96
+ return self._schedules.get(name)
97
+
98
+ def owns(self, definition: BackgroundJobDefinition[BaseModel]) -> bool:
99
+ return self._definitions.get((definition.name, definition.version)) is definition
100
+
101
+ def _assert_unchanged(self) -> None:
102
+ for module_type, attribute, declared in self._declarations:
103
+ if module_type.__dict__.get(attribute, ()) is not declared:
104
+ raise BackgroundJobDefinitionError(
105
+ job=f"{module_type.__module__}.{module_type.__qualname__}",
106
+ reason=f"Module {attribute} metadata changed after build",
107
+ )
108
+
109
+
110
+ def _build_background_job_catalog(registry: ModuleRegistry) -> BackgroundJobCatalog:
111
+ builder = _BackgroundJobCatalogBuilder()
112
+ for descriptor in registry.dependency_order:
113
+ builder.add_module(descriptor)
114
+ return builder.build()
115
+
116
+
117
+ class _BackgroundJobCatalogBuilder:
118
+ """拥有 catalog build 期间的 mutable arbitration state,产物仍为 immutable。"""
119
+
120
+ __slots__ = (
121
+ "_declarations",
122
+ "_definitions",
123
+ "_name_owners",
124
+ "_owners",
125
+ "_raw_schedules",
126
+ "_schedule_owners",
127
+ )
128
+
129
+ def __init__(self) -> None:
130
+ self._definitions: list[BackgroundJobDefinition[BaseModel]] = []
131
+ self._raw_schedules: list[BackgroundJobSchedule] = []
132
+ self._owners: dict[tuple[str, int], ModuleKey] = {}
133
+ self._name_owners: dict[str, ModuleKey] = {}
134
+ self._schedule_owners: dict[str, ModuleKey] = {}
135
+ self._declarations: list[tuple[type[AppModule], str, tuple[object, ...]]] = []
136
+
137
+ def add_module(self, descriptor: ModuleDescriptor) -> None:
138
+ declared_definitions = descriptor.module_type.__dict__.get("background_job_definitions", ())
139
+ declared_schedules = descriptor.module_type.__dict__.get("background_job_schedules", ())
140
+ _validate_metadata_tuple(
141
+ declared_definitions,
142
+ owner=str(descriptor.key),
143
+ attribute="background_job_definitions",
144
+ )
145
+ _validate_metadata_tuple(
146
+ declared_schedules,
147
+ owner=str(descriptor.key),
148
+ attribute="background_job_schedules",
149
+ )
150
+ self._declarations.extend(
151
+ (
152
+ (descriptor.module_type, "background_job_definitions", declared_definitions),
153
+ (descriptor.module_type, "background_job_schedules", declared_schedules),
154
+ )
155
+ )
156
+ for definition in declared_definitions:
157
+ self._add_definition(definition, descriptor.key)
158
+ for schedule in declared_schedules:
159
+ self._add_schedule(schedule, descriptor.key)
160
+
161
+ def build(self) -> BackgroundJobCatalog:
162
+ by_name: dict[str, list[BackgroundJobDefinition[BaseModel]]] = {}
163
+ by_identity = {(item.name, item.version): item for item in self._definitions}
164
+ for definition in self._definitions:
165
+ by_name.setdefault(definition.name, []).append(definition)
166
+ for name, versions in by_name.items():
167
+ if sum(item.current for item in versions) != 1:
168
+ raise BackgroundJobDefinitionError(
169
+ job=name,
170
+ reason="job versions must declare exactly one current definition",
171
+ )
172
+ schedules = tuple(_canonicalize_schedule(item, by_identity) for item in self._raw_schedules)
173
+ return BackgroundJobCatalog(
174
+ tuple(self._definitions),
175
+ schedules,
176
+ declarations=tuple(self._declarations),
177
+ owners=self._owners,
178
+ )
179
+
180
+ def _add_definition(self, candidate: object, owner: ModuleKey) -> None:
181
+ if not isinstance(candidate, BackgroundJobDefinition):
182
+ raise BackgroundJobDefinitionError(
183
+ job=str(owner),
184
+ reason="Module background_job_definitions contains an invalid item",
185
+ )
186
+ definition = candidate
187
+ identity = (definition.name, definition.version)
188
+ if identity in self._owners:
189
+ raise BackgroundJobDefinitionError(
190
+ job=definition.name,
191
+ reason=f"version {definition.version} is declared more than once",
192
+ )
193
+ previous_owner = self._name_owners.get(definition.name)
194
+ if previous_owner is not None and previous_owner != owner:
195
+ raise BackgroundJobDefinitionError(
196
+ job=definition.name,
197
+ reason=f"all versions must be owned by {previous_owner}",
198
+ )
199
+ self._owners[identity] = owner
200
+ self._name_owners[definition.name] = owner
201
+ self._definitions.append(definition)
202
+
203
+ def _add_schedule(self, candidate: object, owner: ModuleKey) -> None:
204
+ if not isinstance(candidate, BackgroundJobSchedule):
205
+ raise BackgroundJobDefinitionError(
206
+ job=str(owner),
207
+ reason="Module background_job_schedules contains an invalid item",
208
+ )
209
+ schedule = candidate
210
+ if schedule.name in self._schedule_owners:
211
+ raise BackgroundJobDefinitionError(
212
+ job=schedule.name,
213
+ reason=f"schedule is already owned by {self._schedule_owners[schedule.name]}",
214
+ )
215
+ self._schedule_owners[schedule.name] = owner
216
+ self._raw_schedules.append(schedule)
217
+
218
+
219
+ def _validate_metadata_tuple(value: object, *, owner: str, attribute: str) -> None:
220
+ if not isinstance(value, tuple):
221
+ raise BackgroundJobDefinitionError(
222
+ job=owner,
223
+ reason=f"Module {attribute} must be an immutable tuple",
224
+ )
225
+
226
+
227
+ def _canonicalize_schedule(
228
+ schedule: BackgroundJobSchedule,
229
+ definitions: Mapping[tuple[str, int], BackgroundJobDefinition[BaseModel]],
230
+ ) -> BackgroundJobSchedule:
231
+ definition = definitions.get((schedule.job_name, schedule.job_version))
232
+ if definition is None:
233
+ raise BackgroundJobDefinitionError(
234
+ job=schedule.name,
235
+ reason="schedule target does not exist in the Application catalog",
236
+ )
237
+ cron = _normalize_cron(schedule.name, schedule.cron)
238
+ try:
239
+ payload = definition.payload_type.model_validate_json(schedule.payload)
240
+ except ValidationError:
241
+ raise BackgroundJobPayloadError(
242
+ job=definition.name,
243
+ version=definition.version,
244
+ reason="scheduled payload failed schema validation",
245
+ ) from None
246
+ canonical = json.dumps(
247
+ payload.model_dump(mode="json"),
248
+ sort_keys=True,
249
+ separators=(",", ":"),
250
+ ensure_ascii=False,
251
+ ).encode()
252
+ return replace(schedule, cron=cron, payload=canonical)
253
+
254
+
255
+ def _normalize_cron(schedule_name: str, expression: str) -> str:
256
+ normalized = " ".join(expression.split())
257
+ fields = normalized.split(" ")
258
+ if len(fields) not in (5, 6) or any(
259
+ not field or any(character not in "0123456789*/,-" for character in field)
260
+ for field in fields
261
+ ):
262
+ raise BackgroundJobDefinitionError(
263
+ job=schedule_name,
264
+ reason="cron must use numeric five-field or trailing-second six-field grammar",
265
+ )
266
+ try:
267
+ from croniter import croniter # type: ignore[import-untyped]
268
+ except ImportError:
269
+ raise BackgroundJobDefinitionError(
270
+ job=schedule_name,
271
+ reason="Cron schedules require the background-jobs extra",
272
+ ) from None
273
+ try:
274
+ valid = croniter.is_valid(normalized, strict=True)
275
+ if valid:
276
+ croniter(
277
+ normalized,
278
+ start_time=datetime.now(UTC),
279
+ ).get_next(datetime)
280
+ except (TypeError, ValueError):
281
+ valid = False
282
+ if not valid:
283
+ raise BackgroundJobDefinitionError(
284
+ job=schedule_name,
285
+ reason="cron expression is outside the supported UTC grammar",
286
+ )
287
+ return normalized
@@ -0,0 +1,202 @@
1
+ """Provider-neutral durable job identity、payload 与 enqueue contracts。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Awaitable, Callable
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timedelta
11
+ from typing import Generic, Protocol, TypeVar, cast
12
+
13
+ from pydantic import BaseModel
14
+
15
+ from ..invocation import BackgroundSystemCaller
16
+ from .errors import BackgroundJobDefinitionError
17
+
18
+ _TPayload = TypeVar("_TPayload", bound=BaseModel)
19
+
20
+
21
+ class BackgroundJobHandler(ABC, Generic[_TPayload]):
22
+ """一次 durable delivery 的业务边界;queue loop/retry 由 provider 独占。"""
23
+
24
+ @abstractmethod
25
+ async def execute(self, payload: _TPayload, context: BackgroundJobContext) -> None:
26
+ raise NotImplementedError
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class BackgroundJobContext:
31
+ job_id: str
32
+ name: str
33
+ version: int
34
+ attempt: int
35
+ scheduled_at: datetime
36
+ system_caller: BackgroundSystemCaller
37
+ _cancellation_event: asyncio.Event = field(repr=False, compare=False)
38
+
39
+ @property
40
+ def cancellation_requested(self) -> bool:
41
+ return self._cancellation_event.is_set()
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class BackgroundJobRetryPolicy:
46
+ max_attempts: int = 5
47
+ initial_delay: timedelta = timedelta(seconds=1)
48
+ max_delay: timedelta = timedelta(minutes=5)
49
+ backoff_multiplier: float = 2.0
50
+
51
+ def __post_init__(self) -> None:
52
+ if (
53
+ not isinstance(self.max_attempts, int)
54
+ or isinstance(self.max_attempts, bool)
55
+ or self.max_attempts < 1
56
+ ):
57
+ raise BackgroundJobDefinitionError(
58
+ job="<retry-policy>",
59
+ reason="max_attempts must be a positive integer",
60
+ )
61
+ if not isinstance(self.initial_delay, timedelta) or self.initial_delay.total_seconds() <= 0:
62
+ raise BackgroundJobDefinitionError(
63
+ job="<retry-policy>",
64
+ reason="initial_delay must be a positive timedelta",
65
+ )
66
+ if not isinstance(self.max_delay, timedelta) or self.max_delay < self.initial_delay:
67
+ raise BackgroundJobDefinitionError(
68
+ job="<retry-policy>",
69
+ reason="max_delay must be a timedelta no smaller than initial_delay",
70
+ )
71
+ if (
72
+ not isinstance(self.backoff_multiplier, (int, float))
73
+ or isinstance(self.backoff_multiplier, bool)
74
+ or self.backoff_multiplier < 1
75
+ ):
76
+ raise BackgroundJobDefinitionError(
77
+ job="<retry-policy>",
78
+ reason="backoff_multiplier must be at least 1",
79
+ )
80
+
81
+ def delay_for_attempt(self, attempt: int) -> timedelta:
82
+ if not isinstance(attempt, int) or isinstance(attempt, bool) or attempt < 0:
83
+ raise ValueError("attempt must be a non-negative integer")
84
+ seconds = self.initial_delay.total_seconds() * (self.backoff_multiplier**attempt)
85
+ return timedelta(seconds=min(seconds, self.max_delay.total_seconds()))
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class BackgroundJobDefinition(Generic[_TPayload]):
90
+ name: str
91
+ version: int
92
+ payload_type: type[_TPayload]
93
+ handler_type: type[BackgroundJobHandler[_TPayload]] | Callable[..., Awaitable[None]]
94
+ timeout: timedelta
95
+ current: bool = False
96
+ retry_policy: BackgroundJobRetryPolicy = BackgroundJobRetryPolicy()
97
+
98
+ def __post_init__(self) -> None:
99
+ _validate_name(self.name)
100
+ if not isinstance(self.version, int) or isinstance(self.version, bool) or self.version < 1:
101
+ raise BackgroundJobDefinitionError(
102
+ job=self.name,
103
+ reason="version must be a positive integer",
104
+ )
105
+ if not isinstance(self.payload_type, type) or not issubclass(self.payload_type, BaseModel):
106
+ raise BackgroundJobDefinitionError(
107
+ job=self.name,
108
+ reason="payload_type must be a Pydantic BaseModel class",
109
+ )
110
+ is_class = isinstance(self.handler_type, type) and issubclass(
111
+ self.handler_type, BackgroundJobHandler
112
+ )
113
+ if not is_class and not inspect.iscoroutinefunction(self.handler_type):
114
+ raise BackgroundJobDefinitionError(
115
+ job=self.name,
116
+ reason="handler_type must be a BackgroundJobHandler class or async function",
117
+ )
118
+ if isinstance(self.handler_type, type) and not inspect.iscoroutinefunction(
119
+ cast(type[BackgroundJobHandler[_TPayload]], self.handler_type).execute
120
+ ):
121
+ raise BackgroundJobDefinitionError(
122
+ job=self.name,
123
+ reason="execute must be async",
124
+ )
125
+ if not isinstance(self.timeout, timedelta) or self.timeout.total_seconds() <= 0:
126
+ raise BackgroundJobDefinitionError(
127
+ job=self.name,
128
+ reason="timeout must be a positive timedelta",
129
+ )
130
+ if not isinstance(self.current, bool):
131
+ raise BackgroundJobDefinitionError(job=self.name, reason="current must be bool")
132
+ if not isinstance(self.retry_policy, BackgroundJobRetryPolicy):
133
+ raise BackgroundJobDefinitionError(
134
+ job=self.name,
135
+ reason="retry_policy must be BackgroundJobRetryPolicy",
136
+ )
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class BackgroundJobSchedule:
141
+ name: str
142
+ cron: str
143
+ job_name: str
144
+ job_version: int
145
+ payload: bytes
146
+
147
+ def __post_init__(self) -> None:
148
+ _validate_name(self.name)
149
+ _validate_name(self.job_name)
150
+ if (
151
+ not isinstance(self.job_version, int)
152
+ or isinstance(self.job_version, bool)
153
+ or self.job_version < 1
154
+ ):
155
+ raise BackgroundJobDefinitionError(
156
+ job=self.name,
157
+ reason="job_version must be a positive integer",
158
+ )
159
+ if not isinstance(self.cron, str) or not self.cron.strip():
160
+ raise BackgroundJobDefinitionError(
161
+ job=self.name,
162
+ reason="cron must be a non-empty string",
163
+ )
164
+ if not isinstance(self.payload, bytes):
165
+ raise BackgroundJobDefinitionError(
166
+ job=self.name,
167
+ reason="payload must be immutable UTF-8 JSON bytes",
168
+ )
169
+ try:
170
+ self.payload.decode("utf-8")
171
+ except UnicodeDecodeError:
172
+ raise BackgroundJobDefinitionError(
173
+ job=self.name,
174
+ reason="payload must be UTF-8 JSON bytes",
175
+ ) from None
176
+
177
+
178
+ class BackgroundJobEnqueuer(Protocol):
179
+ async def enqueue(
180
+ self,
181
+ definition: BackgroundJobDefinition[_TPayload],
182
+ payload: _TPayload,
183
+ *,
184
+ priority: int = 0,
185
+ delay: timedelta | None = None,
186
+ ) -> str: ...
187
+
188
+
189
+ def _validate_name(name: str) -> None:
190
+ if (
191
+ not isinstance(name, str)
192
+ or not name
193
+ or name != name.strip()
194
+ or name.startswith(".")
195
+ or name.endswith(".")
196
+ or ".." in name
197
+ or any(not (character.isalnum() or character in "._-") for character in name)
198
+ ):
199
+ raise BackgroundJobDefinitionError(
200
+ job=name if isinstance(name, str) else repr(name),
201
+ reason="name must be a trimmed stable identifier",
202
+ )
@@ -0,0 +1,28 @@
1
+ """Durable background job definition、payload 与 provider-neutral failures。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..errors.base import FrameworkError
6
+
7
+
8
+ class BackgroundJobDefinitionError(FrameworkError):
9
+ def __init__(self, *, job: str, reason: str) -> None:
10
+ self.job = job
11
+ self.reason = reason
12
+ super().__init__(f"Invalid background job {job!r}: {reason}")
13
+
14
+
15
+ class BackgroundJobPayloadError(FrameworkError):
16
+ def __init__(self, *, job: str, version: int, reason: str) -> None:
17
+ self.job = job
18
+ self.version = version
19
+ self.reason = reason
20
+ super().__init__(f"Invalid payload for background job {job!r} v{version}: {reason}")
21
+
22
+
23
+ class BackgroundJobExecutionError(FrameworkError):
24
+ def __init__(self, *, job: str, version: int, error_type: str) -> None:
25
+ self.job = job
26
+ self.version = version
27
+ self.error_type = error_type
28
+ super().__init__(f"Background job {job!r} v{version} failed with {error_type}")