pz-cli 5.0.0__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 (399) hide show
  1. pz_cli-5.0.0.dist-info/METADATA +51 -0
  2. pz_cli-5.0.0.dist-info/RECORD +399 -0
  3. pz_cli-5.0.0.dist-info/WHEEL +4 -0
  4. pz_cli-5.0.0.dist-info/entry_points.txt +5 -0
  5. pz_core/__init__.py +64 -0
  6. pz_core/__main__.py +7 -0
  7. pz_core/app.py +45 -0
  8. pz_core/assembly/__init__.py +17 -0
  9. pz_core/assembly/app_discovery.py +1240 -0
  10. pz_core/assembly/apps/__init__.py +25 -0
  11. pz_core/assembly/apps/compiler.py +824 -0
  12. pz_core/assembly/apps/providers.py +188 -0
  13. pz_core/assembly/cli/__init__.py +9 -0
  14. pz_core/assembly/cli/compiler.py +166 -0
  15. pz_core/assembly/diagnostics/__init__.py +21 -0
  16. pz_core/assembly/diagnostics/checks.py +257 -0
  17. pz_core/assembly/diagnostics/dev_sources.py +569 -0
  18. pz_core/assembly/events/__init__.py +5 -0
  19. pz_core/assembly/events/compiler.py +219 -0
  20. pz_core/assembly/extensions/__init__.py +5 -0
  21. pz_core/assembly/extensions/compiler.py +696 -0
  22. pz_core/assembly/infra/__init__.py +6 -0
  23. pz_core/assembly/infra/compiler.py +272 -0
  24. pz_core/assembly/lifecycle/__init__.py +5 -0
  25. pz_core/assembly/lifecycle/compiler.py +95 -0
  26. pz_core/assembly/middlewares/__init__.py +19 -0
  27. pz_core/assembly/middlewares/compiler.py +508 -0
  28. pz_core/assembly/project/__init__.py +7 -0
  29. pz_core/assembly/project/compiler.py +9 -0
  30. pz_core/assembly/project/context.py +17 -0
  31. pz_core/assembly/project/discovery.py +227 -0
  32. pz_core/assembly/resources/__init__.py +19 -0
  33. pz_core/assembly/resources/compiler.py +310 -0
  34. pz_core/assembly/resources/settings.py +129 -0
  35. pz_core/assembly/routes/__init__.py +7 -0
  36. pz_core/assembly/routes/compiler.py +400 -0
  37. pz_core/assembly/runtime_manifests/__init__.py +13 -0
  38. pz_core/assembly/runtime_manifests/compiler.py +159 -0
  39. pz_core/assembly/settings/__init__.py +17 -0
  40. pz_core/assembly/settings/compiler.py +786 -0
  41. pz_core/assembly/settings/host_project_overrides.py +82 -0
  42. pz_core/assembly/settings/host_runtime_endpoint.py +207 -0
  43. pz_core/authoring/__init__.py +79 -0
  44. pz_core/authoring/app_init_service.py +946 -0
  45. pz_core/authoring/app_versions.py +814 -0
  46. pz_core/authoring/cli.py +192 -0
  47. pz_core/authoring/commands.py +1810 -0
  48. pz_core/authoring/conflicts.py +180 -0
  49. pz_core/authoring/dev_bindings.py +916 -0
  50. pz_core/authoring/dev_git.py +1087 -0
  51. pz_core/authoring/dev_import.py +1061 -0
  52. pz_core/authoring/dev_manifest.py +522 -0
  53. pz_core/authoring/dev_sources.py +1638 -0
  54. pz_core/authoring/env_cli.py +454 -0
  55. pz_core/authoring/files.py +109 -0
  56. pz_core/authoring/ide_projection.py +1129 -0
  57. pz_core/authoring/naming.py +58 -0
  58. pz_core/authoring/official_dev_sources.py +432 -0
  59. pz_core/authoring/official_sources.json +156 -0
  60. pz_core/authoring/orchestration_service.py +558 -0
  61. pz_core/authoring/project_default.jsonc +53 -0
  62. pz_core/authoring/project_defaults.py +88 -0
  63. pz_core/authoring/project_metadata_service.py +199 -0
  64. pz_core/authoring/project_settings_checks.py +51 -0
  65. pz_core/authoring/project_settings_cli.py +299 -0
  66. pz_core/authoring/project_settings_service.py +1700 -0
  67. pz_core/authoring/project_state.py +399 -0
  68. pz_core/authoring/publish_git.py +423 -0
  69. pz_core/authoring/publish_lock.py +188 -0
  70. pz_core/authoring/publish_source.py +195 -0
  71. pz_core/authoring/release.py +1382 -0
  72. pz_core/authoring/repo_keys.py +29 -0
  73. pz_core/authoring/root_cli.py +28 -0
  74. pz_core/authoring/scaffold_service.py +325 -0
  75. pz_core/authoring/sync_service.py +787 -0
  76. pz_core/authoring/templates/app/__init__.py +1 -0
  77. pz_core/authoring/templates/app/api.py +25 -0
  78. pz_core/authoring/templates/app/apps.py +31 -0
  79. pz_core/authoring/templates/app/auth_package_init.py +14 -0
  80. pz_core/authoring/templates/app/auth_readme.py +29 -0
  81. pz_core/authoring/templates/app/configs_package_init.py +14 -0
  82. pz_core/authoring/templates/app/default_env.py +18 -0
  83. pz_core/authoring/templates/app/default_jsonc.py +25 -0
  84. pz_core/authoring/templates/app/locales_readme.py +25 -0
  85. pz_core/authoring/templates/app/migrations_readme.py +19 -0
  86. pz_core/authoring/templates/app/models.py +14 -0
  87. pz_core/authoring/templates/app/package_init.py +8 -0
  88. pz_core/authoring/templates/app/pages_base.py +161 -0
  89. pz_core/authoring/templates/app/pages_readme.py +25 -0
  90. pz_core/authoring/templates/app/public.py +31 -0
  91. pz_core/authoring/templates/app/pyproject.py +77 -0
  92. pz_core/authoring/templates/app/readme.py +74 -0
  93. pz_core/authoring/templates/app/settings.py +46 -0
  94. pz_core/authoring/templates/app/static_app_js.py +24 -0
  95. pz_core/authoring/templates/app/static_readme.py +31 -0
  96. pz_core/authoring/templates/app/template_config_page.py +23 -0
  97. pz_core/authoring/templates/app/template_index_html.py +28 -0
  98. pz_core/authoring/templates/app/templates_readme.py +32 -0
  99. pz_core/authoring/templates/app/urls.py +36 -0
  100. pz_core/authoring/templates/app/views.py +36 -0
  101. pz_core/authoring/templates/project/__init__.py +1 -0
  102. pz_core/authoring/templates/project/agents.py +51 -0
  103. pz_core/authoring/templates/project/apps_container_readme.py +37 -0
  104. pz_core/authoring/templates/project/main_py.py +59 -0
  105. pz_core/authoring/templates/project/project_env.py +19 -0
  106. pz_core/authoring/templates/project/pyproject.py +26 -0
  107. pz_core/authoring/templates/project/pz_env_env.py +65 -0
  108. pz_core/authoring/templates/project/pz_env_jsonc.py +69 -0
  109. pz_core/authoring/templates/project/pz_jsonc.py +12 -0
  110. pz_core/authoring/templates/project/readme.py +94 -0
  111. pz_core/authoring/templates/project/root_gitkeep.py +8 -0
  112. pz_core/authoring/templates/shared/__init__.py +5 -0
  113. pz_core/authoring/templates/shared/text.py +10 -0
  114. pz_core/authoring/venv_service.py +87 -0
  115. pz_core/authoring/workspace.py +167 -0
  116. pz_core/cli.py +291 -0
  117. pz_core/cli_options.py +74 -0
  118. pz_core/core/__init__.py +3 -0
  119. pz_core/core/errors/__init__.py +5 -0
  120. pz_core/core/errors/models.py +117 -0
  121. pz_core/core/events/__init__.py +5 -0
  122. pz_core/core/events/registry.py +54 -0
  123. pz_core/core/extensions/__init__.py +51 -0
  124. pz_core/core/extensions/registry.py +790 -0
  125. pz_core/core/health/__init__.py +6 -0
  126. pz_core/core/health/contracts.py +10 -0
  127. pz_core/core/middlewares/__init__.py +6 -0
  128. pz_core/core/middlewares/models.py +24 -0
  129. pz_core/core/routing/__init__.py +5 -0
  130. pz_core/core/routing/models.py +21 -0
  131. pz_core/core/settings_contracts.py +25 -0
  132. pz_core/docs/README.md +20 -0
  133. pz_core/docs/distribution-README.md +438 -0
  134. pz_core/docs/pz/345/221/275/344/273/244/346/200/273/350/247/210.md +1011 -0
  135. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/00-pz/346/236/266/346/236/204/346/200/273/347/272/262.md +240 -0
  136. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/01-/347/233/256/345/275/225/344/270/216/345/210/206/345/261/202/350/247/204/350/214/203.md +216 -0
  137. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/02-/345/214/205/344/270/216/345/210/206/345/217/221/350/247/204/350/214/203.md +403 -0
  138. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/03-App/346/234/200/345/260/217/345/255/220/345/214/205/350/247/204/350/214/203.md +571 -0
  139. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/04-Settings/344/270/216/351/205/215/347/275/256/350/247/204/350/214/203.md +1177 -0
  140. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/05-/350/265/204/346/272/220/344/270/216/350/277/220/350/241/214/347/233/256/345/275/225/350/247/204/350/214/203.md +575 -0
  141. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/06-/346/225/260/346/215/256/345/272/223/344/270/216/345/217/257/351/200/211/345/237/272/347/241/200/350/256/276/346/226/275/350/247/204/350/214/203.md +499 -0
  142. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/07-App/346/233/277/346/215/242/350/247/204/350/214/203.md +206 -0
  143. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/08-/345/271/263/345/217/260/347/272/247App/344/270/216/350/277/220/350/241/214/346/227/266/345/215/225/344/276/213/350/247/204/350/214/203.md +445 -0
  144. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/09-CLI/344/270/216Worker/345/205/245/345/217/243/350/247/204/350/214/203.md +950 -0
  145. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/10-/346/265/213/350/257/225/344/270/216/345/245/221/347/272/246/350/247/204/350/214/203.md +1001 -0
  146. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/11-/344/273/223/345/272/223/347/234/237/347/233/270/346/272/220/345/234/260/345/233/276.md +102 -0
  147. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/12-/346/236/204/345/273/272/344/270/216/345/217/221/345/270/203/346/243/200/346/237/245/346/270/205/345/215/225.md +171 -0
  148. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/13-/346/255/243/345/274/217Facade/346/200/273/350/241/250.md +402 -0
  149. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/14-/345/210/206/345/261/202/344/270/216/346/211/251/345/261/225/350/247/204/350/214/203.md +550 -0
  150. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/15-/345/255/220/345/214/205/345/274/200/345/217/221/344/275/277/347/224/250/346/211/213/345/206/214.md +1004 -0
  151. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/16-/346/211/251/345/261/225/347/202/271/346/255/243/345/274/217/345/215/217/350/256/256/346/270/205/345/215/225.md +352 -0
  152. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/17-pz.protocols.v1/346/255/243/345/274/217/345/215/217/350/256/256.md +266 -0
  153. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/18-/346/211/251/345/261/225/347/202/271/345/206/262/347/252/201/350/243/201/345/206/263/346/200/273/350/241/250.md +131 -0
  154. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/19-/351/224/231/350/257/257/347/240/201/344/270/216/350/257/212/346/226/255/345/215/217/350/256/256.md +176 -0
  155. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/20-/347/247/201/346/234/211/345/214/205/344/273/223/344/270/216/347/273/237/344/270/200/350/256/244/350/257/201/351/203/250/347/275/262/350/247/204/350/214/203.md +243 -0
  156. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/21-contrib/347/272/246/345/256/232/344/270/216Provider/346/216/245/345/205/245/350/247/204/350/214/203.md +365 -0
  157. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/22-/345/244/232/345/233/275/350/257/255/350/250/200/350/265/204/346/272/220/346/262/273/347/220/206/344/270/216/346/214/211/351/234/200/345/212/240/350/275/275/350/247/204/350/214/203.md +912 -0
  158. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/23-/351/246/226/345/261/217/345/261/225/347/244/272/344/270/216/351/246/226/345/270/247/347/250/263/345/256/232/350/247/204/350/214/203.md +269 -0
  159. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/24-pz/345/220/257/345/212/250/344/270/216/350/277/220/350/241/214/346/227/266/347/274/226/350/257/221/346/265/201/347/250/213.md +245 -0
  160. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/25-/351/235/242/345/220/221/345/257/271/350/261/241/345/274/200/345/217/221/350/247/204/350/214/203.md +300 -0
  161. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/README.md +98 -0
  162. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/00-legacy_core/346/240/207/345/207/206pip/345/214/205/345/214/226/351/207/215/346/236/204/346/200/273/347/272/262.md +1073 -0
  163. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/01-/347/233/256/345/275/225/344/270/216/345/210/206/345/261/202/351/207/215/346/236/204/346/211/247/350/241/214/346/226/271/346/241/210.md +224 -0
  164. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/02-/345/214/205/344/270/216/345/210/206/345/217/221/350/247/204/350/214/203-/345/220/253/345/216/206/345/217/262/345/217/243/345/276/204.md +671 -0
  165. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/13-/344/270/200/346/254/241/346/200/247/345/256/236/346/226/275/346/270/205/345/215/225.md +387 -0
  166. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/14-/345/255/220/345/214/205/351/200/202/351/205/215/346/224/271/351/200/240/346/270/205/345/215/225.md +592 -0
  167. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/15-infra/350/243/201/345/211/252/344/270/216/350/220/275/344/275/215/346/270/205/345/215/225.md +81 -0
  168. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/19-core/345/215/225/344/270/200/346/255/243/345/274/217/346/226/271/346/241/210/346/224/266/346/225/233/346/211/247/350/241/214/346/270/205/345/215/225.md +161 -0
  169. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/25-/345/215/217/350/256/256/345/210/260/346/272/220/347/240/201/345/267/256/350/267/235/346/270/205/345/215/225.md +377 -0
  170. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/26-cutover/345/256/214/345/267/245/346/243/200/346/237/245/346/270/205/345/215/225.md +117 -0
  171. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/29-/345/244/232/345/233/275/350/257/255/350/250/200/350/265/204/346/272/220/346/262/273/347/220/206/344/270/216/346/214/211/351/234/200/345/212/240/350/275/275/350/247/204/350/214/203-/345/220/253/350/277/201/347/247/273/347/255/226/347/225/245.md +956 -0
  172. pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/archive//344/270/255/351/227/264/346/200/201/99-/350/265/204/346/272/220/346/224/266/345/217/243/345/220/216/347/273/255TODO.md +84 -0
  173. pz_core/docs//345/217/221/347/211/210/345/211/215/346/270/205/345/215/225-4.0.11.md +84 -0
  174. pz_core/docs//346/226/260/346/211/213/346/214/207/345/215/227.md +422 -0
  175. pz_core/infra/__init__.py +17 -0
  176. pz_core/infra/db/__init__.py +23 -0
  177. pz_core/infra/db/contracts.py +73 -0
  178. pz_core/infra/health/__init__.py +5 -0
  179. pz_core/infra/health/contracts.py +8 -0
  180. pz_core/infra/kv/__init__.py +5 -0
  181. pz_core/infra/kv/contracts.py +17 -0
  182. pz_core/infra/observability/__init__.py +23 -0
  183. pz_core/infra/observability/contracts.py +142 -0
  184. pz_core/infra/resilience/__init__.py +5 -0
  185. pz_core/infra/resilience/contracts.py +17 -0
  186. pz_core/infra/resources/__init__.py +5 -0
  187. pz_core/infra/resources/contracts.py +16 -0
  188. pz_core/infra/settings/__init__.py +17 -0
  189. pz_core/infra/settings/models.py +81 -0
  190. pz_core/infra/storage/__init__.py +5 -0
  191. pz_core/infra/storage/ports.py +27 -0
  192. pz_core/project_root_commands.py +282 -0
  193. pz_core/project_runtime_cli.py +344 -0
  194. pz_core/project_sources.py +253 -0
  195. pz_core/pz_launcher.py +209 -0
  196. pz_core/root_cli.py +70 -0
  197. pz_core/runtime/__init__.py +229 -0
  198. pz_core/runtime/access.py +86 -0
  199. pz_core/runtime/adapters/__init__.py +5 -0
  200. pz_core/runtime/adapters/base.py +18 -0
  201. pz_core/runtime/asyncio_bridge.py +18 -0
  202. pz_core/runtime/bindings/__init__.py +5 -0
  203. pz_core/runtime/bindings/container.py +50 -0
  204. pz_core/runtime/cli/__init__.py +5 -0
  205. pz_core/runtime/cli/_args.py +67 -0
  206. pz_core/runtime/cli/app_commands.py +417 -0
  207. pz_core/runtime/cli/check.py +214 -0
  208. pz_core/runtime/cli/db.py +297 -0
  209. pz_core/runtime/cli/main.py +89 -0
  210. pz_core/runtime/cli/resources.py +280 -0
  211. pz_core/runtime/discovery.py +23 -0
  212. pz_core/runtime/environment_integrity.py +157 -0
  213. pz_core/runtime/errors.py +5 -0
  214. pz_core/runtime/events.py +87 -0
  215. pz_core/runtime/facades/__init__.py +38 -0
  216. pz_core/runtime/facades/_capabilities.py +46 -0
  217. pz_core/runtime/facades/cache.py +103 -0
  218. pz_core/runtime/facades/database.py +71 -0
  219. pz_core/runtime/facades/events.py +380 -0
  220. pz_core/runtime/facades/health.py +101 -0
  221. pz_core/runtime/facades/mail.py +121 -0
  222. pz_core/runtime/facades/observability.py +102 -0
  223. pz_core/runtime/facades/providers.py +75 -0
  224. pz_core/runtime/facades/resources.py +52 -0
  225. pz_core/runtime/facades/storage.py +296 -0
  226. pz_core/runtime/health/__init__.py +5 -0
  227. pz_core/runtime/health/service.py +102 -0
  228. pz_core/runtime/host.py +188 -0
  229. pz_core/runtime/host_runner.py +282 -0
  230. pz_core/runtime/http/__init__.py +20 -0
  231. pz_core/runtime/http/app.py +576 -0
  232. pz_core/runtime/http_timing.py +173 -0
  233. pz_core/runtime/invocation.py +29 -0
  234. pz_core/runtime/json_safe.py +47 -0
  235. pz_core/runtime/lifecycle/__init__.py +25 -0
  236. pz_core/runtime/lifecycle/manager.py +533 -0
  237. pz_core/runtime/plan.py +214 -0
  238. pz_core/runtime/plan_context.py +33 -0
  239. pz_core/runtime/plan_snapshot.py +177 -0
  240. pz_core/runtime/registries/__init__.py +27 -0
  241. pz_core/runtime/registries/capabilities.py +101 -0
  242. pz_core/runtime/registries/providers.py +396 -0
  243. pz_core/runtime/resource_assets.py +344 -0
  244. pz_core/runtime/run_cli.py +211 -0
  245. pz_core/runtime/settings.py +888 -0
  246. pz_core/runtime/worker/__init__.py +21 -0
  247. pz_core/runtime/worker/engine.py +921 -0
  248. pz_core/runtime/worker/runtime.py +25 -0
  249. pz_core/testing/__init__.py +15 -0
  250. pz_core/testing/doc_route_contracts.py +305 -0
  251. pz_core/utils/__init__.py +44 -0
  252. pz_core/utils/env.py +65 -0
  253. pz_core/utils/project_jsonc/__init__.py +29 -0
  254. pz_core/utils/project_jsonc/document.py +420 -0
  255. pz_core/utils/project_settings.py +99 -0
  256. pz_foundation/__init__.py +50 -0
  257. pz_foundation/_contracts.py +81 -0
  258. pz_foundation/_errors.py +34 -0
  259. pz_foundation/api.py +14 -0
  260. pz_foundation/apps.py +127 -0
  261. pz_foundation/auth/__init__.py +1 -0
  262. pz_foundation/cache_control.py +233 -0
  263. pz_foundation/cli/__init__.py +2 -0
  264. pz_foundation/cli/workers.py +565 -0
  265. pz_foundation/configs/__init__.py +0 -0
  266. pz_foundation/configs/default.env +15 -0
  267. pz_foundation/configs/default.jsonc +452 -0
  268. pz_foundation/db/__init__.py +94 -0
  269. pz_foundation/db/aerich_executor.py +664 -0
  270. pz_foundation/db/config.py +358 -0
  271. pz_foundation/db/connection_manager.py +689 -0
  272. pz_foundation/db/connection_probe.py +204 -0
  273. pz_foundation/db/database.py +1291 -0
  274. pz_foundation/db/deps.py +47 -0
  275. pz_foundation/db/lifecycle_status.py +85 -0
  276. pz_foundation/db/migrations.py +477 -0
  277. pz_foundation/db/mysql.py +57 -0
  278. pz_foundation/db/postgresql.py +409 -0
  279. pz_foundation/db/production_pool_fallback.py +143 -0
  280. pz_foundation/db/public.py +45 -0
  281. pz_foundation/db/session.py +369 -0
  282. pz_foundation/db/sqlite.py +15 -0
  283. pz_foundation/db/tortoise_manager.py +724 -0
  284. pz_foundation/db/tortoise_psycopg.py +148 -0
  285. pz_foundation/docs/README.md +18 -0
  286. pz_foundation/docs/distribution-README.md +319 -0
  287. pz_foundation/docs/infra/350/243/201/345/211/252/344/270/216/350/220/275/344/275/215/350/257/264/346/230/216.md +39 -0
  288. pz_foundation/docs//345/205/254/345/274/200/345/205/215/350/264/271/346/216/245/345/217/243/351/230/262/346/273/245/347/224/250/344/270/216/344/272/272/346/234/272/346/240/241/351/252/214.md +680 -0
  289. pz_foundation/docs//350/256/276/350/256/241/346/226/207/346/241/243.md +288 -0
  290. pz_foundation/docs//350/267/257/347/224/261/344/270/216URL/345/244/204/347/220/206/350/247/204/350/214/203.md +119 -0
  291. pz_foundation/health.py +105 -0
  292. pz_foundation/host/__init__.py +11 -0
  293. pz_foundation/host/builder.py +34 -0
  294. pz_foundation/host/cache_runtime.py +44 -0
  295. pz_foundation/host/db/__init__.py +17 -0
  296. pz_foundation/host/db/aerich_config.py +36 -0
  297. pz_foundation/host/db/config.py +106 -0
  298. pz_foundation/host/db/connection_manager.py +48 -0
  299. pz_foundation/host/db/deps.py +5 -0
  300. pz_foundation/host/db/orm_retry_patch.py +566 -0
  301. pz_foundation/host/db/request_adapter.py +325 -0
  302. pz_foundation/host/db/router.py +28 -0
  303. pz_foundation/host/db/session.py +284 -0
  304. pz_foundation/host/db/tortoise_manager.py +10 -0
  305. pz_foundation/host/idempotency.py +132 -0
  306. pz_foundation/host/lifespan.py +247 -0
  307. pz_foundation/host/redis_runtime.py +100 -0
  308. pz_foundation/host/web.py +790 -0
  309. pz_foundation/infra_provider.py +236 -0
  310. pz_foundation/kv/__init__.py +25 -0
  311. pz_foundation/kv/cache.py +1166 -0
  312. pz_foundation/kv/deps.py +84 -0
  313. pz_foundation/kv/public.py +25 -0
  314. pz_foundation/kv/redis_manager.py +1531 -0
  315. pz_foundation/lazy_exports.py +43 -0
  316. pz_foundation/lazy_imports.py +124 -0
  317. pz_foundation/lifecycle.py +392 -0
  318. pz_foundation/managed_workers.py +1117 -0
  319. pz_foundation/middlewares/__init__.py +26 -0
  320. pz_foundation/middlewares/_settings.py +250 -0
  321. pz_foundation/middlewares/analytics.py +124 -0
  322. pz_foundation/middlewares/cache_control.py +156 -0
  323. pz_foundation/middlewares/compression.py +202 -0
  324. pz_foundation/middlewares/cors.py +62 -0
  325. pz_foundation/middlewares/geo_country.py +44 -0
  326. pz_foundation/middlewares/inner_router.py +50 -0
  327. pz_foundation/middlewares/rate_limited/__init__.py +3 -0
  328. pz_foundation/middlewares/rate_limited/key_limiter.py +115 -0
  329. pz_foundation/middlewares/request_logging.py +104 -0
  330. pz_foundation/middlewares/route_owner.py +309 -0
  331. pz_foundation/middlewares/security_csrf.py +44 -0
  332. pz_foundation/middlewares/security_headers/__init__.py +4 -0
  333. pz_foundation/middlewares/security_headers/conflicts.py +44 -0
  334. pz_foundation/middlewares/security_headers/csp_nonce.py +200 -0
  335. pz_foundation/middlewares/security_headers/security_headers.py +44 -0
  336. pz_foundation/migrations/__init__.py +1 -0
  337. pz_foundation/migrations/pz_foundation/__init__.py +1 -0
  338. pz_foundation/models.py +22 -0
  339. pz_foundation/notifications/__init__.py +23 -0
  340. pz_foundation/notifications/email.py +542 -0
  341. pz_foundation/notifications/public.py +31 -0
  342. pz_foundation/observability/__init__.py +71 -0
  343. pz_foundation/observability/http.py +34 -0
  344. pz_foundation/observability/logger/__init__.py +49 -0
  345. pz_foundation/observability/logger/_bot_settings.py +126 -0
  346. pz_foundation/observability/logger/bot_message.py +592 -0
  347. pz_foundation/observability/logger/core.py +385 -0
  348. pz_foundation/observability/logger/delivery_audit.py +84 -0
  349. pz_foundation/observability/logger/feishu_bot.py +199 -0
  350. pz_foundation/observability/logger/telegram_bot.py +194 -0
  351. pz_foundation/observability/metrics.py +365 -0
  352. pz_foundation/observability/runtime.py +68 -0
  353. pz_foundation/public.py +405 -0
  354. pz_foundation/resources/__init__.py +12 -0
  355. pz_foundation/resources/builder.py +900 -0
  356. pz_foundation/resources/images.py +363 -0
  357. pz_foundation/root_cli.py +20 -0
  358. pz_foundation/routing/__init__.py +203 -0
  359. pz_foundation/routing/canonical.py +486 -0
  360. pz_foundation/routing/convertors.py +82 -0
  361. pz_foundation/routing/declarations.py +843 -0
  362. pz_foundation/routing/exports.py +148 -0
  363. pz_foundation/routing/loaders.py +138 -0
  364. pz_foundation/routing/manifest.py +215 -0
  365. pz_foundation/routing/manifest_provider.py +47 -0
  366. pz_foundation/routing/mounts.py +101 -0
  367. pz_foundation/routing/ordering.py +172 -0
  368. pz_foundation/routing/registry.py +133 -0
  369. pz_foundation/routing/utils.py +28 -0
  370. pz_foundation/runtime.py +234 -0
  371. pz_foundation/settings.py +253 -0
  372. pz_foundation/settings_normalization.py +130 -0
  373. pz_foundation/storage/__init__.py +25 -0
  374. pz_foundation/storage/local.py +209 -0
  375. pz_foundation/storage/public.py +106 -0
  376. pz_foundation/storage/s3.py +116 -0
  377. pz_foundation/storage/service.py +234 -0
  378. pz_foundation/urls.py +19 -0
  379. pz_foundation/utils/__init__.py +6 -0
  380. pz_foundation/utils/merge.py +160 -0
  381. pz_foundation/worker_replicas.py +55 -0
  382. pz_settings_center/__init__.py +5 -0
  383. pz_settings_center/api/__init__.py +5 -0
  384. pz_settings_center/api/runtime_overrides.py +360 -0
  385. pz_settings_center/apps.py +28 -0
  386. pz_settings_center/configs/__init__.py +0 -0
  387. pz_settings_center/configs/default.env +2 -0
  388. pz_settings_center/configs/default.jsonc +1 -0
  389. pz_settings_center/docs/01-settings_center/350/203/275/345/212/233/350/276/271/347/225/214.md +124 -0
  390. pz_settings_center/docs/README.md +12 -0
  391. pz_settings_center/docs/distribution-README.md +150 -0
  392. pz_settings_center/docs//350/256/276/350/256/241/346/226/207/346/241/243.md +48 -0
  393. pz_settings_center/integration_payload.py +18 -0
  394. pz_settings_center/project_helpers.py +405 -0
  395. pz_settings_center/public.py +139 -0
  396. pz_settings_center/routing_helpers.py +73 -0
  397. pz_settings_center/runtime_override_provider.py +61 -0
  398. pz_settings_center/typed_settings.py +324 -0
  399. pz_settings_center/urls.py +9 -0
@@ -0,0 +1,1291 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ from dataclasses import dataclass, field
5
+ import importlib
6
+ import json
7
+ import os
8
+ from os import PathLike
9
+ from pathlib import Path
10
+ import re
11
+ import sqlite3
12
+ from threading import RLock
13
+ from typing import Any, Protocol
14
+ from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
15
+
16
+ import pz_settings_center.public as settings_center
17
+ from pz_core.assembly.project import resolve_project_context
18
+
19
+ from .lifecycle_status import DBConnectionLifecycleStatus, connection_lifecycle_status
20
+ from .tortoise_manager import tortoise_manager
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class FoundationDatabaseSettings:
25
+ enabled: bool = False
26
+ driver: str = "sqlite"
27
+ path: str = ""
28
+ urls: tuple[str, ...] = ()
29
+ host: str = ""
30
+ port: int = 0
31
+ hosts: tuple[str, ...] = ()
32
+ ports: tuple[int, ...] = ()
33
+ user: str = ""
34
+ password: str = ""
35
+ database: str = ""
36
+ min_size: int = 1
37
+ max_size: int = 10
38
+ connect_timeout: float = 5.0
39
+ pool_acquire_timeout: float = 5.0
40
+ statement_timeout_ms: int = 30000
41
+ idle_in_transaction_session_timeout_ms: int = 30000
42
+ pool_max_lifetime: float = 3600.0
43
+ pool_max_idle: float = 600.0
44
+ reconnect_timeout: float = 30.0
45
+ health_check_interval: float = 60.0
46
+ ssl_mode: str = ""
47
+ ssl_root_cert: str = ""
48
+ ssl_cert: str = ""
49
+ ssl_key: str = ""
50
+ ssl_password: str = ""
51
+ target_session_attrs: str = ""
52
+ options: dict[str, Any] = field(default_factory=dict)
53
+
54
+
55
+ class FoundationDatabase(Protocol):
56
+ settings: FoundationDatabaseSettings
57
+ driver: str
58
+
59
+ def create_session(self): ...
60
+
61
+ def readiness_probe(self): ...
62
+
63
+ def executescript(self, script: str) -> None: ...
64
+
65
+ def close(self) -> None: ...
66
+
67
+
68
+ def _resolved_settings(*, project_dir: str | PathLike[str] | None = None):
69
+ if project_dir is None:
70
+ return settings_center.settings
71
+ return settings_center.resolve_settings(project_dir=str(Path(project_dir).resolve()))
72
+
73
+
74
+ def _project_settings(*, project_dir: str | PathLike[str] | None = None):
75
+ try:
76
+ return _resolved_settings(project_dir=project_dir).project
77
+ except Exception:
78
+ return None
79
+
80
+
81
+ def _foundation_settings_section(
82
+ name: str,
83
+ *,
84
+ project_dir: str | PathLike[str] | None = None,
85
+ ):
86
+ section_name = str(name or "").strip()
87
+ try:
88
+ resolved = _resolved_settings(project_dir=project_dir)
89
+ if hasattr(resolved, "app_settings"):
90
+ foundation_payload = resolved.app_settings("pz_foundation")
91
+ if isinstance(foundation_payload, dict):
92
+ section = foundation_payload.get(section_name, {})
93
+ if section not in (None, {}):
94
+ return section
95
+ foundation_settings = getattr(resolved, "pz_foundation", None)
96
+ if isinstance(foundation_settings, dict):
97
+ return foundation_settings.get(section_name, {})
98
+ if foundation_settings is None:
99
+ return {}
100
+ return getattr(foundation_settings, section_name, {})
101
+ except Exception:
102
+ return {}
103
+
104
+
105
+ @dataclass(slots=True)
106
+ class SQLiteFoundationSession:
107
+ connection: Any
108
+ driver: str = "sqlite"
109
+ _transaction_active: bool = False
110
+ _lock: RLock | None = None
111
+
112
+ def begin(self) -> SQLiteFoundationTransaction:
113
+ if self._transaction_active:
114
+ raise RuntimeError("nested foundation transactions are not supported")
115
+ if self._lock is not None:
116
+ self._lock.acquire()
117
+ try:
118
+ executor = getattr(self.connection, "execute", None)
119
+ if callable(executor):
120
+ executor("BEGIN")
121
+ else:
122
+ self.connection.cursor().execute("BEGIN")
123
+ self._transaction_active = True
124
+ except Exception:
125
+ if self._lock is not None:
126
+ self._lock.release()
127
+ raise
128
+ return SQLiteFoundationTransaction(self, self.connection, lock=self._lock)
129
+
130
+ def execute(self, statement: str, *args: Any, **kwargs: Any) -> Any:
131
+ acquired_lock = False
132
+ if not self._transaction_active and self._lock is not None:
133
+ self._lock.acquire()
134
+ acquired_lock = True
135
+ try:
136
+ cursor = self.connection.cursor()
137
+ sql, params = _prepare_statement(self.driver, statement, args, kwargs)
138
+ if params is not None:
139
+ cursor.execute(sql, params)
140
+ else:
141
+ cursor.execute(sql)
142
+ description = getattr(cursor, "description", None)
143
+ if description:
144
+ rows = cursor.fetchall()
145
+ if rows and isinstance(rows[0], sqlite3.Row):
146
+ if not self._transaction_active:
147
+ self.connection.commit()
148
+ return rows
149
+ if not self._transaction_active:
150
+ self.connection.commit()
151
+ return [_coerce_dbapi_row(description, row) for row in rows]
152
+ if not self._transaction_active:
153
+ self.connection.commit()
154
+ return int(getattr(cursor, "rowcount", 0) or 0)
155
+ except Exception:
156
+ if not self._transaction_active:
157
+ with contextlib.suppress(Exception):
158
+ self.connection.rollback()
159
+ raise
160
+ finally:
161
+ if acquired_lock and self._lock is not None:
162
+ self._lock.release()
163
+
164
+
165
+ @dataclass(slots=True)
166
+ class SQLiteFoundationTransaction:
167
+ session: SQLiteFoundationSession
168
+ connection: Any
169
+ lock: RLock | None = None
170
+ _active: bool = True
171
+
172
+ def commit(self) -> None:
173
+ if not self._active:
174
+ return
175
+ self.connection.commit()
176
+ self._active = False
177
+ self.session._transaction_active = False
178
+ if self.lock is not None:
179
+ self.lock.release()
180
+
181
+ def rollback(self) -> None:
182
+ if not self._active:
183
+ return
184
+ self.connection.rollback()
185
+ self._active = False
186
+ self.session._transaction_active = False
187
+ if self.lock is not None:
188
+ self.lock.release()
189
+
190
+
191
+ @dataclass(slots=True)
192
+ class FoundationDBAPINonTransactionalReadinessProbe:
193
+ connection: Any
194
+
195
+ def check_ready(self) -> bool:
196
+ try:
197
+ executor = getattr(self.connection, "execute", None)
198
+ if callable(executor):
199
+ executor("SELECT 1")
200
+ else:
201
+ self.connection.cursor().execute("SELECT 1")
202
+ except Exception:
203
+ return False
204
+ return True
205
+
206
+
207
+ SQLiteFoundationReadinessProbe = FoundationDBAPINonTransactionalReadinessProbe
208
+
209
+
210
+ @dataclass(slots=True)
211
+ class SQLiteFoundationDatabase:
212
+ settings: FoundationDatabaseSettings
213
+ _connection: Any = field(init=False, repr=False)
214
+ _lock: RLock = field(default_factory=RLock, init=False, repr=False)
215
+
216
+ def __post_init__(self) -> None:
217
+ if not self.settings.enabled:
218
+ raise RuntimeError("foundation database is disabled")
219
+ driver = _normalize_driver(self.settings.driver, url=_primary_database_url(self.settings))
220
+ if driver != "sqlite":
221
+ raise RuntimeError(f"unsupported foundation database driver: {self.settings.driver!r}")
222
+ db_path = Path(self.settings.path).expanduser()
223
+ db_path.parent.mkdir(parents=True, exist_ok=True)
224
+ self._connection = sqlite3.connect(db_path, check_same_thread=False)
225
+ self._connection.row_factory = sqlite3.Row
226
+
227
+ @property
228
+ def driver(self) -> str:
229
+ return "sqlite"
230
+
231
+ def create_session(self) -> SQLiteFoundationSession:
232
+ return SQLiteFoundationSession(self._connection, driver="sqlite", _lock=self._lock)
233
+
234
+ def readiness_probe(self) -> FoundationDBAPINonTransactionalReadinessProbe:
235
+ return FoundationDBAPINonTransactionalReadinessProbe(self._connection)
236
+
237
+ def executescript(self, script: str) -> None:
238
+ self._connection.executescript(script)
239
+ self._connection.commit()
240
+
241
+ def close(self) -> None:
242
+ with contextlib.suppress(Exception):
243
+ self._connection.close()
244
+
245
+
246
+ @dataclass(slots=True)
247
+ class _DBAPIFoundationDatabase:
248
+ settings: FoundationDatabaseSettings
249
+ _connection: Any = field(init=False, repr=False)
250
+ _lock: RLock = field(default_factory=RLock, init=False, repr=False)
251
+
252
+ def __post_init__(self) -> None:
253
+ if not self.settings.enabled:
254
+ raise RuntimeError("foundation database is disabled")
255
+ self._connection = self._open_connection()
256
+
257
+ @property
258
+ def driver(self) -> str:
259
+ return _normalize_driver(self.settings.driver, url=_primary_database_url(self.settings))
260
+
261
+ def create_session(self) -> SQLiteFoundationSession:
262
+ return SQLiteFoundationSession(
263
+ self._connection,
264
+ driver=_normalize_driver(self.settings.driver, url=_primary_database_url(self.settings)),
265
+ _lock=self._lock,
266
+ )
267
+
268
+ def readiness_probe(self) -> FoundationDBAPINonTransactionalReadinessProbe:
269
+ return FoundationDBAPINonTransactionalReadinessProbe(self._connection)
270
+
271
+ def executescript(self, script: str) -> None:
272
+ for statement in _split_sql_statements(script):
273
+ if not statement:
274
+ continue
275
+ self.create_session().execute(statement)
276
+
277
+ def close(self) -> None:
278
+ with contextlib.suppress(Exception):
279
+ self._connection.close()
280
+
281
+ def _open_connection(self) -> Any:
282
+ raise NotImplementedError
283
+
284
+
285
+ @dataclass(slots=True)
286
+ class MySQLFoundationDatabase(_DBAPIFoundationDatabase):
287
+ def _open_connection(self) -> Any:
288
+ settings = _resolve_database_settings(self.settings)
289
+ host, port = _primary_endpoint(settings, default_port=3306)
290
+ if not host:
291
+ raise RuntimeError("foundation mysql host is not configured")
292
+ try:
293
+ module = importlib.import_module("pymysql")
294
+ except Exception as exc:
295
+ raise RuntimeError("pymysql is required for foundation mysql driver") from exc
296
+ kwargs: dict[str, Any] = {
297
+ "host": host,
298
+ "port": port,
299
+ "user": settings.user,
300
+ "password": settings.password,
301
+ "database": settings.database,
302
+ "charset": "utf8mb4",
303
+ }
304
+ cursor_cls = getattr(getattr(module, "cursors", None), "DictCursor", None)
305
+ if cursor_cls is not None:
306
+ kwargs["cursorclass"] = cursor_cls
307
+ if settings.connect_timeout > 0:
308
+ kwargs["connect_timeout"] = int(max(1, settings.connect_timeout))
309
+ kwargs.update(dict(settings.options or {}))
310
+ kwargs["autocommit"] = True
311
+ return module.connect(**kwargs)
312
+
313
+
314
+ @dataclass(slots=True)
315
+ class PostgreSQLFoundationDatabase(_DBAPIFoundationDatabase):
316
+ def _open_connection(self) -> Any:
317
+ settings = _resolve_database_settings(self.settings)
318
+ try:
319
+ module = importlib.import_module("psycopg")
320
+ except Exception as exc:
321
+ raise RuntimeError("psycopg is required for foundation postgresql driver") from exc
322
+ kwargs: dict[str, Any] = {}
323
+ with contextlib.suppress(Exception):
324
+ rows_module = importlib.import_module("psycopg.rows")
325
+ kwargs["row_factory"] = rows_module.dict_row
326
+ if settings.connect_timeout > 0:
327
+ kwargs["connect_timeout"] = int(max(1, settings.connect_timeout))
328
+ kwargs.update(_build_postgresql_ssl_kwargs(settings))
329
+ kwargs.update(dict(settings.options or {}))
330
+ kwargs["autocommit"] = True
331
+ return module.connect(_build_postgresql_dsn(settings), **kwargs)
332
+
333
+
334
+ _DATABASE_SINGLETONS: dict[str, FoundationDatabase] = {}
335
+ _NAMED_PARAM_PATTERN = re.compile(r"(?<!:):([A-Za-z_][A-Za-z0-9_]*)")
336
+
337
+
338
+ def load_foundation_database_settings(
339
+ *,
340
+ project_dir: str | PathLike[str] | None = None,
341
+ ) -> FoundationDatabaseSettings:
342
+ return _resolve_foundation_database_settings(project_dir=project_dir)
343
+
344
+
345
+ def _database_binding_configured(settings: FoundationDatabaseSettings) -> bool:
346
+ if not settings.enabled:
347
+ return False
348
+ if tuple(getattr(settings, "urls", ()) or ()):
349
+ return True
350
+ driver = _normalize_driver(settings.driver, url=_primary_database_url(settings))
351
+ if driver == "sqlite":
352
+ return bool(str(settings.path or "").strip())
353
+ if driver in {"mysql", "postgresql"}:
354
+ return bool(str(settings.host or "").strip() or settings.hosts) and bool(
355
+ str(settings.database or "").strip()
356
+ )
357
+ return False
358
+
359
+
360
+ def describe_foundation_database_binding(
361
+ *,
362
+ project_dir: str | PathLike[str] | None = None,
363
+ settings: FoundationDatabaseSettings | None = None,
364
+ ) -> dict[str, Any]:
365
+ resolved = settings or _resolve_foundation_database_settings(project_dir=project_dir)
366
+ configured = _database_binding_configured(resolved)
367
+ return {
368
+ "enabled": bool(resolved.enabled),
369
+ "configured": configured,
370
+ "driver": resolved.driver,
371
+ "path": resolved.path,
372
+ "urls": tuple(resolved.urls),
373
+ "host": resolved.host,
374
+ "port": resolved.port,
375
+ "hosts": tuple(resolved.hosts),
376
+ "ports": tuple(resolved.ports),
377
+ "database": resolved.database,
378
+ "user": resolved.user,
379
+ "target_session_attrs": resolved.target_session_attrs,
380
+ "operational": False,
381
+ "degraded": False,
382
+ "ready": False,
383
+ "error": None,
384
+ "probe_deferred": True,
385
+ }
386
+
387
+
388
+ def probe_foundation_database_binding(
389
+ *,
390
+ project_dir: str | PathLike[str] | None = None,
391
+ settings: FoundationDatabaseSettings | None = None,
392
+ ) -> dict[str, Any]:
393
+ status = describe_foundation_database_binding(project_dir=project_dir, settings=settings)
394
+ if not status["enabled"] or not status["configured"]:
395
+ status["probe_deferred"] = False
396
+ return status
397
+ try:
398
+ lifecycle = connection_lifecycle_status(tortoise_manager)
399
+ status["lifecycle"] = _lifecycle_status_payload(lifecycle)
400
+ if lifecycle.default_ready:
401
+ status["ready"] = True
402
+ status["operational"] = True
403
+ status["probe_deferred"] = False
404
+ else:
405
+ status["probe_deferred"] = True
406
+ return status
407
+ except Exception as exc:
408
+ status["error"] = str(exc)
409
+ status["probe_deferred"] = False
410
+ return status
411
+
412
+
413
+ def _lifecycle_status_payload(lifecycle: DBConnectionLifecycleStatus) -> dict[str, Any]:
414
+ return {
415
+ "initialized": lifecycle.initialized,
416
+ "loop_matches": lifecycle.loop_matches,
417
+ "default_configured": lifecycle.default_configured,
418
+ "default_open": lifecycle.default_open,
419
+ "read_only_configured": lifecycle.read_only_configured,
420
+ "read_only_open": lifecycle.read_only_open,
421
+ "default_status": lifecycle.alias_status("default"),
422
+ "read_only_status": lifecycle.alias_status("read_only"),
423
+ }
424
+
425
+
426
+ def normalize_foundation_database_settings(settings: FoundationDatabaseSettings) -> FoundationDatabaseSettings:
427
+ return _resolve_database_settings(settings)
428
+
429
+
430
+ def _resolve_foundation_database_settings(
431
+ *,
432
+ project_dir: str | PathLike[str] | None = None,
433
+ ) -> FoundationDatabaseSettings:
434
+ database_payload = _database_snapshot(project_dir=project_dir)
435
+ urls = _read_str_sequence(database_payload, "urls")
436
+ raw_driver = _read_str_value(database_payload, "driver")
437
+ driver = _normalize_driver(raw_driver or "", url="" if raw_driver else (urls[0] if urls else ""))
438
+ enabled = _read_bool_value(database_payload, "enabled", False)
439
+ explicit_path = _read_str_value(database_payload, "path")
440
+ sqlite_path = _sqlite_path_from_url(urls[0] if urls else "")
441
+ default_sqlite_path = str(
442
+ resolve_project_context(_resolve_project_dir(project_dir)).asset_dir / "foundation" / "foundation.sqlite3"
443
+ )
444
+ path = explicit_path or sqlite_path
445
+ if driver == "sqlite" and not path:
446
+ path = default_sqlite_path
447
+ host = _read_str_value(database_payload, "host")
448
+ raw_port = _read_str_value(database_payload, "port")
449
+ ports = _coerce_port_values(raw_port)
450
+ port = ports[0] if ports else 0
451
+ user = _read_str_value(database_payload, "user")
452
+ password = _read_str_value(database_payload, "password")
453
+ database_name = _read_str_value(database_payload, "database")
454
+ min_size = _read_alias_int(database_payload, ("min_size",), 1)
455
+ max_size = _read_alias_int(database_payload, ("max_size",), 10)
456
+ connect_timeout = _read_alias_float(database_payload, ("connect_timeout",), 5.0)
457
+ pool_acquire_timeout = _read_alias_float(database_payload, ("pool_acquire_timeout", "pool_timeout"), 5.0)
458
+ statement_timeout_ms = _read_alias_int(database_payload, ("statement_timeout_ms",), 30000)
459
+ idle_in_transaction_session_timeout_ms = _read_alias_int(
460
+ database_payload,
461
+ ("idle_in_transaction_session_timeout_ms",),
462
+ 30000,
463
+ )
464
+ pool_max_lifetime = _read_alias_float(database_payload, ("pool_max_lifetime",), 3600.0)
465
+ pool_max_idle = _read_alias_float(database_payload, ("pool_max_idle",), 600.0)
466
+ reconnect_timeout = _read_alias_float(database_payload, ("reconnect_timeout",), 30.0)
467
+ health_check_interval = _read_alias_float(database_payload, ("health_check_interval",), 60.0)
468
+ ssl_mode = _read_str_value(database_payload, "ssl_mode")
469
+ ssl_root_cert = _read_str_value(database_payload, "ssl_root_cert")
470
+ ssl_cert = _read_str_value(database_payload, "ssl_cert")
471
+ ssl_key = _read_str_value(database_payload, "ssl_key")
472
+ ssl_password = _read_str_value(database_payload, "ssl_password")
473
+ target_session_attrs = _read_str_value(database_payload, "target_session_attrs")
474
+ settings = FoundationDatabaseSettings(
475
+ enabled=enabled,
476
+ driver=driver,
477
+ path=path,
478
+ urls=urls,
479
+ host=host,
480
+ port=port,
481
+ ports=ports,
482
+ user=user,
483
+ password=password,
484
+ database=database_name,
485
+ min_size=min_size,
486
+ max_size=max_size,
487
+ connect_timeout=connect_timeout,
488
+ pool_acquire_timeout=pool_acquire_timeout,
489
+ statement_timeout_ms=statement_timeout_ms,
490
+ idle_in_transaction_session_timeout_ms=idle_in_transaction_session_timeout_ms,
491
+ pool_max_lifetime=pool_max_lifetime,
492
+ pool_max_idle=pool_max_idle,
493
+ reconnect_timeout=reconnect_timeout,
494
+ health_check_interval=health_check_interval,
495
+ ssl_mode=ssl_mode,
496
+ ssl_root_cert=ssl_root_cert,
497
+ ssl_cert=ssl_cert,
498
+ ssl_key=ssl_key,
499
+ ssl_password=ssl_password,
500
+ target_session_attrs=target_session_attrs,
501
+ )
502
+ return _resolve_database_settings(settings)
503
+
504
+
505
+ def get_foundation_database(
506
+ *,
507
+ project_dir: str | PathLike[str] | None = None,
508
+ settings: FoundationDatabaseSettings | None = None,
509
+ ) -> FoundationDatabase:
510
+ resolved_settings = _resolve_database_settings(
511
+ settings or _resolve_foundation_database_settings(project_dir=project_dir)
512
+ )
513
+ key = _database_singleton_key(resolved_settings)
514
+ database = _DATABASE_SINGLETONS.get(key)
515
+ if database is None:
516
+ if resolved_settings.driver == "sqlite":
517
+ database = SQLiteFoundationDatabase(resolved_settings)
518
+ elif resolved_settings.driver == "mysql":
519
+ database = MySQLFoundationDatabase(resolved_settings)
520
+ elif resolved_settings.driver == "postgresql":
521
+ database = PostgreSQLFoundationDatabase(resolved_settings)
522
+ else:
523
+ raise RuntimeError(f"unsupported foundation database driver: {resolved_settings.driver!r}")
524
+ _DATABASE_SINGLETONS[key] = database
525
+ return database
526
+
527
+
528
+ def get_foundation_database_if_enabled(
529
+ *,
530
+ project_dir: str | PathLike[str] | None = None,
531
+ settings: FoundationDatabaseSettings | None = None,
532
+ ) -> FoundationDatabase | None:
533
+ resolved_settings = _resolve_database_settings(
534
+ settings or _resolve_foundation_database_settings(project_dir=project_dir)
535
+ )
536
+ if not resolved_settings.enabled:
537
+ return None
538
+ return get_foundation_database(project_dir=project_dir, settings=resolved_settings)
539
+
540
+
541
+ def reset_foundation_databases() -> None:
542
+ for database in list(_DATABASE_SINGLETONS.values()):
543
+ with contextlib.suppress(Exception):
544
+ database.close()
545
+ _DATABASE_SINGLETONS.clear()
546
+
547
+
548
+ def _database_snapshot(
549
+ *,
550
+ project_dir: str | PathLike[str] | None = None,
551
+ ):
552
+ try:
553
+ payload = _payload_dict(_foundation_settings_section("database", project_dir=project_dir))
554
+ except Exception:
555
+ payload = {}
556
+ return payload
557
+
558
+
559
+ def _resolve_project_dir(project_dir: str | PathLike[str] | None) -> Path:
560
+ if project_dir is not None:
561
+ return resolve_project_context(project_dir).project_dir.resolve()
562
+ try:
563
+ project = _project_settings()
564
+ return resolve_project_context(str(getattr(project, "project_dir", ".") or ".")).project_dir.resolve()
565
+ except Exception:
566
+ return resolve_project_context().project_dir.resolve()
567
+
568
+
569
+ def _primary_database_url(settings: FoundationDatabaseSettings) -> str:
570
+ urls = tuple(getattr(settings, "urls", ()) or ())
571
+ return str(urls[0] or "").strip() if urls else ""
572
+
573
+
574
+ def _payload_dict(payload: Any) -> dict[str, Any]:
575
+ if isinstance(payload, dict):
576
+ return dict(payload)
577
+ if hasattr(payload, "to_dict") and callable(payload.to_dict):
578
+ try:
579
+ value = payload.to_dict()
580
+ except Exception:
581
+ value = None
582
+ if isinstance(value, dict):
583
+ return dict(value)
584
+ return dict(getattr(payload, "__dict__", {}) or {})
585
+
586
+
587
+ def _read_bool_value(payload: Any, key: str, default: bool) -> bool:
588
+ value = payload.get(key, default) if isinstance(payload, dict) else getattr(payload, key, default)
589
+ if value is None:
590
+ return default
591
+ if isinstance(value, bool):
592
+ return value
593
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
594
+
595
+
596
+ def _read_str_value(payload: Any, key: str) -> str:
597
+ value = payload.get(key) if isinstance(payload, dict) else getattr(payload, key, None)
598
+ text = str(value or "").strip()
599
+ if text in {'""', "''"}:
600
+ return ""
601
+ return text
602
+
603
+
604
+ def _read_alias_int(payload: Any, keys: tuple[str, ...], default: int) -> int:
605
+ for key in keys:
606
+ value = payload.get(key) if isinstance(payload, dict) else getattr(payload, key, None)
607
+ if value is None or str(value).strip() == "":
608
+ continue
609
+ try:
610
+ return int(value)
611
+ except Exception:
612
+ continue
613
+ return int(default)
614
+
615
+
616
+ def _resolve_database_settings(settings: FoundationDatabaseSettings) -> FoundationDatabaseSettings:
617
+ raw_driver = str(settings.driver or "").strip()
618
+ urls = _coerce_database_url_values(getattr(settings, "urls", ()))
619
+ driver = _normalize_driver(raw_driver, url="" if raw_driver else (urls[0] if urls else ""))
620
+ host = settings.host
621
+ port = int(settings.port or 0)
622
+ hosts = _coerce_host_values(getattr(settings, "hosts", ()))
623
+ ports = _coerce_port_values(getattr(settings, "ports", ()))
624
+ user = settings.user
625
+ password = settings.password
626
+ database_name = settings.database
627
+ min_size = int(getattr(settings, "min_size", 1) or 1)
628
+ max_size = int(getattr(settings, "max_size", 10) or 10)
629
+ pool_acquire_timeout = float(getattr(settings, "pool_acquire_timeout", 5.0) or 0.0)
630
+ statement_timeout_ms = int(getattr(settings, "statement_timeout_ms", 30000) or 0)
631
+ idle_in_transaction_session_timeout_ms = int(
632
+ getattr(settings, "idle_in_transaction_session_timeout_ms", 30000) or 0
633
+ )
634
+ pool_max_lifetime = float(getattr(settings, "pool_max_lifetime", 3600.0) or 0.0)
635
+ pool_max_idle = float(getattr(settings, "pool_max_idle", 600.0) or 0.0)
636
+ reconnect_timeout = float(getattr(settings, "reconnect_timeout", 30.0) or 0.0)
637
+ health_check_interval = float(getattr(settings, "health_check_interval", 60.0) or 0.0)
638
+ path = settings.path
639
+ ssl_mode = settings.ssl_mode
640
+ ssl_root_cert = settings.ssl_root_cert
641
+ ssl_cert = settings.ssl_cert
642
+ ssl_key = settings.ssl_key
643
+ ssl_password = settings.ssl_password
644
+ target_session_attrs = str(getattr(settings, "target_session_attrs", "") or "").strip()
645
+ options = dict(settings.options or {})
646
+ url_sources = urls
647
+ parsed_url_payloads: list[dict[str, Any]] = []
648
+ for url_value in url_sources:
649
+ try:
650
+ parsed_url_payloads.append(_parse_database_url(url_value))
651
+ except Exception:
652
+ raise
653
+ if parsed_url_payloads:
654
+ first = parsed_url_payloads[0]
655
+ parsed_driver = _normalize_driver(first["driver"])
656
+ if raw_driver and parsed_driver != driver:
657
+ urls = ()
658
+ parsed_url_payloads = []
659
+ else:
660
+ parsed_hosts: list[str] = []
661
+ parsed_ports: list[int] = []
662
+ for parsed_payload in parsed_url_payloads:
663
+ if _normalize_driver(parsed_payload["driver"]) != parsed_driver:
664
+ continue
665
+ parsed_hosts.extend(parsed_payload["hosts"])
666
+ parsed_ports.extend(parsed_payload["ports"])
667
+ for key, value in dict(parsed_payload.get("options", {}) or {}).items():
668
+ if key not in options:
669
+ options[key] = value
670
+ if not host:
671
+ host = ",".join(parsed_hosts) or first["host"]
672
+ if not port:
673
+ port = parsed_ports[0] if parsed_ports else first["port"]
674
+ if not hosts:
675
+ hosts = tuple(parsed_hosts) or first["hosts"]
676
+ if not ports:
677
+ ports = tuple(parsed_ports) or first["ports"]
678
+ if not user:
679
+ user = first["user"]
680
+ if not password:
681
+ password = first["password"]
682
+ if not database_name:
683
+ database_name = first["database"]
684
+ if not path:
685
+ path = first["path"]
686
+ if not ssl_mode:
687
+ ssl_mode = first["ssl_mode"]
688
+ if not ssl_root_cert:
689
+ ssl_root_cert = first["ssl_root_cert"]
690
+ if not ssl_cert:
691
+ ssl_cert = first["ssl_cert"]
692
+ if not ssl_key:
693
+ ssl_key = first["ssl_key"]
694
+ if not ssl_password:
695
+ ssl_password = first["ssl_password"]
696
+ if not target_session_attrs:
697
+ target_session_attrs = first["target_session_attrs"]
698
+ if not raw_driver:
699
+ driver = parsed_driver
700
+ host, port, hosts, ports = _normalize_network_settings(
701
+ driver=driver,
702
+ host=host,
703
+ port=port,
704
+ hosts=hosts,
705
+ ports=ports,
706
+ )
707
+ if driver == "sqlite":
708
+ port = 0
709
+ host = ""
710
+ hosts = ()
711
+ ports = ()
712
+ user = ""
713
+ password = ""
714
+ database_name = database_name or Path(path or "").name
715
+ ssl_mode = ""
716
+ ssl_root_cert = ""
717
+ ssl_cert = ""
718
+ ssl_key = ""
719
+ ssl_password = ""
720
+ target_session_attrs = ""
721
+ urls = ()
722
+ elif driver == "mysql":
723
+ port = port or 3306
724
+ elif driver == "postgresql":
725
+ port = port or 5432
726
+ min_size = max(1, min_size)
727
+ max_size = max(min_size, max_size)
728
+ pool_acquire_timeout = max(0.1, pool_acquire_timeout or 5.0)
729
+ statement_timeout_ms = max(0, statement_timeout_ms)
730
+ idle_in_transaction_session_timeout_ms = max(0, idle_in_transaction_session_timeout_ms)
731
+ pool_max_lifetime = max(0.0, pool_max_lifetime)
732
+ pool_max_idle = max(0.0, pool_max_idle)
733
+ reconnect_timeout = max(0.0, reconnect_timeout)
734
+ health_check_interval = max(1.0, health_check_interval or 60.0)
735
+ return FoundationDatabaseSettings(
736
+ enabled=settings.enabled,
737
+ driver=driver,
738
+ path=path,
739
+ urls=urls,
740
+ host=host,
741
+ port=port,
742
+ hosts=hosts,
743
+ ports=ports,
744
+ user=user,
745
+ password=password,
746
+ database=database_name,
747
+ min_size=min_size,
748
+ max_size=max_size,
749
+ connect_timeout=float(settings.connect_timeout or 0.0),
750
+ pool_acquire_timeout=pool_acquire_timeout,
751
+ statement_timeout_ms=statement_timeout_ms,
752
+ idle_in_transaction_session_timeout_ms=idle_in_transaction_session_timeout_ms,
753
+ pool_max_lifetime=pool_max_lifetime,
754
+ pool_max_idle=pool_max_idle,
755
+ reconnect_timeout=reconnect_timeout,
756
+ health_check_interval=health_check_interval,
757
+ ssl_mode=ssl_mode,
758
+ ssl_root_cert=ssl_root_cert,
759
+ ssl_cert=ssl_cert,
760
+ ssl_key=ssl_key,
761
+ ssl_password=ssl_password,
762
+ target_session_attrs=target_session_attrs,
763
+ options=options,
764
+ )
765
+
766
+
767
+ def _parse_database_url(url: str) -> dict[str, Any]:
768
+ parsed = urlparse(url)
769
+ driver = _normalize_driver(parsed.scheme, url=url)
770
+ query = parse_qs(parsed.query or "", keep_blank_values=False)
771
+ if driver == "sqlite":
772
+ return {
773
+ "driver": "sqlite",
774
+ "host": "",
775
+ "port": 0,
776
+ "hosts": (),
777
+ "ports": (),
778
+ "user": "",
779
+ "password": "",
780
+ "database": Path(_sqlite_path_from_url(url) or "").name,
781
+ "path": _sqlite_path_from_url(url),
782
+ "ssl_mode": "",
783
+ "ssl_root_cert": "",
784
+ "ssl_cert": "",
785
+ "ssl_key": "",
786
+ "ssl_password": "",
787
+ "target_session_attrs": "",
788
+ "options": {},
789
+ }
790
+ userinfo, hostspec = _split_database_netloc(parsed.netloc or "")
791
+ user, password = _parse_database_userinfo(userinfo)
792
+ hosts, ports = _split_hosts_and_ports(hostspec, _default_port_for_driver(driver))
793
+ return {
794
+ "driver": driver,
795
+ "host": ",".join(hosts),
796
+ "port": ports[0] if ports else 0,
797
+ "hosts": hosts,
798
+ "ports": ports,
799
+ "user": user,
800
+ "password": password,
801
+ "database": unquote((parsed.path or "").lstrip("/")),
802
+ "path": "",
803
+ "ssl_mode": _first_query_value(query, "sslmode"),
804
+ "ssl_root_cert": _first_query_value(query, "sslrootcert"),
805
+ "ssl_cert": _first_query_value(query, "sslcert"),
806
+ "ssl_key": _first_query_value(query, "sslkey"),
807
+ "ssl_password": _first_query_value(query, "sslpassword"),
808
+ "target_session_attrs": _first_query_value(query, "target_session_attrs"),
809
+ "options": _extra_query_options(query),
810
+ }
811
+
812
+
813
+ def _extra_query_options(query: dict[str, list[str]]) -> dict[str, str]:
814
+ reserved = {"sslmode", "sslrootcert", "sslcert", "sslkey", "sslpassword", "target_session_attrs"}
815
+ options: dict[str, str] = {}
816
+ for key in query:
817
+ if key in reserved:
818
+ continue
819
+ value = _first_query_value(query, key)
820
+ if value:
821
+ options[key] = value
822
+ return options
823
+
824
+
825
+ def _sqlite_path_from_url(url: str) -> str:
826
+ raw = str(url or "").strip()
827
+ if not raw.lower().startswith("sqlite:"):
828
+ return ""
829
+ if raw.lower().startswith("sqlite:///:memory:"):
830
+ return ":memory:"
831
+ if raw.lower().startswith("sqlite:////"):
832
+ return raw[len("sqlite:///") :]
833
+ if raw.lower().startswith("sqlite:///"):
834
+ return raw[len("sqlite:///") :]
835
+ if raw.lower().startswith("sqlite://"):
836
+ return raw[len("sqlite://") :]
837
+ return ""
838
+
839
+
840
+ def _default_port_for_driver(driver: str) -> int:
841
+ normalized = _normalize_driver(driver)
842
+ if normalized == "postgresql":
843
+ return 5432
844
+ if normalized == "mysql":
845
+ return 3306
846
+ return 0
847
+
848
+
849
+ def _coerce_host_values(values: Any) -> tuple[str, ...]:
850
+ if isinstance(values, str):
851
+ items = values.split(",")
852
+ else:
853
+ items = values or ()
854
+ out: list[str] = []
855
+ for item in items:
856
+ value = str(item or "").strip()
857
+ if value:
858
+ out.append(value)
859
+ return tuple(out)
860
+
861
+
862
+ def _coerce_port_values(values: Any) -> tuple[int, ...]:
863
+ if isinstance(values, str):
864
+ items = values.split(",")
865
+ else:
866
+ items = values or ()
867
+ out: list[int] = []
868
+ for item in items:
869
+ value = str(item or "").strip()
870
+ if not value:
871
+ continue
872
+ try:
873
+ parsed = int(value)
874
+ except Exception:
875
+ continue
876
+ if parsed > 0:
877
+ out.append(parsed)
878
+ return tuple(out)
879
+
880
+
881
+ def _coerce_database_url_values(values: Any) -> tuple[str, ...]:
882
+ if values in (None, ""):
883
+ return ()
884
+ if isinstance(values, str):
885
+ return _split_database_url_list_string(values)
886
+ if isinstance(values, (list, tuple, set)):
887
+ out: list[str] = []
888
+ for item in values:
889
+ if isinstance(item, str):
890
+ out.extend(_split_database_url_list_string(item))
891
+ continue
892
+ value = str(item or "").strip()
893
+ if value:
894
+ out.append(value)
895
+ return tuple(out)
896
+ value = str(values or "").strip()
897
+ return (value,) if value else ()
898
+
899
+
900
+ def _split_database_url_list_string(value: str) -> tuple[str, ...]:
901
+ text = str(value or "").strip()
902
+ if not text:
903
+ return ()
904
+ if text.startswith("["):
905
+ try:
906
+ decoded = json.loads(text)
907
+ except Exception:
908
+ decoded = None
909
+ if isinstance(decoded, list):
910
+ return _coerce_database_url_values(decoded)
911
+ if "\n" in text or ";" in text or "|" in text:
912
+ parts = re.split(r"[\n;|]+", text)
913
+ else:
914
+ parts = re.split(r"\s*,\s*(?=[A-Za-z][A-Za-z0-9+.-]*://)", text)
915
+ return tuple(part.strip() for part in parts if part.strip())
916
+
917
+
918
+ def _split_database_netloc(netloc: str) -> tuple[str, str]:
919
+ text = str(netloc or "").strip()
920
+ if "@" not in text:
921
+ return "", text
922
+ userinfo, hostspec = text.rsplit("@", 1)
923
+ return userinfo, hostspec
924
+
925
+
926
+ def _parse_database_userinfo(userinfo: str) -> tuple[str, str]:
927
+ if not userinfo:
928
+ return "", ""
929
+ user, _, password = str(userinfo).partition(":")
930
+ return unquote(user or ""), unquote(password or "")
931
+
932
+
933
+ def _split_hosts_and_ports(hostspec: str, default_port: int) -> tuple[tuple[str, ...], tuple[int, ...]]:
934
+ hosts: list[str] = []
935
+ ports: list[int] = []
936
+ for raw_entry in str(hostspec or "").split(","):
937
+ host, port = _split_host_entry(raw_entry, default_port)
938
+ if not host:
939
+ continue
940
+ hosts.append(host)
941
+ ports.append(port)
942
+ return tuple(hosts), tuple(ports)
943
+
944
+
945
+ def _split_host_entry(raw_entry: str, default_port: int) -> tuple[str, int]:
946
+ entry = str(raw_entry or "").strip()
947
+ if not entry:
948
+ return "", default_port
949
+ if entry.startswith("["):
950
+ closing = entry.find("]")
951
+ if closing > 0:
952
+ host = entry[1:closing].strip()
953
+ remainder = entry[closing + 1 :].strip()
954
+ if remainder.startswith(":"):
955
+ try:
956
+ return host, int(remainder[1:] or default_port)
957
+ except Exception:
958
+ return host, default_port
959
+ return host, default_port
960
+ if entry.count(":") == 1:
961
+ host, raw_port = entry.rsplit(":", 1)
962
+ try:
963
+ return host.strip(), int(raw_port or default_port)
964
+ except Exception:
965
+ return host.strip(), default_port
966
+ return entry, default_port
967
+
968
+
969
+ def _format_host_for_uri(host: str) -> str:
970
+ value = str(host or "").strip()
971
+ if ":" in value and not (value.startswith("[") and value.endswith("]")):
972
+ return f"[{value}]"
973
+ return value
974
+
975
+
976
+ def _expand_ports(ports: tuple[int, ...], *, count: int, fallback: int) -> tuple[int, ...]:
977
+ if count <= 0:
978
+ return ()
979
+ valid = tuple(int(item) for item in ports if int(item) > 0)
980
+ if len(valid) >= count:
981
+ return valid[:count]
982
+ if len(valid) == 1:
983
+ return tuple(valid[0] for _ in range(count))
984
+ if valid:
985
+ fill = valid[0]
986
+ return (*valid, *(fill for _ in range(count - len(valid))))
987
+ return tuple(int(fallback) for _ in range(count))
988
+
989
+
990
+ def _normalize_network_settings(
991
+ *,
992
+ driver: str,
993
+ host: str,
994
+ port: int,
995
+ hosts: tuple[str, ...],
996
+ ports: tuple[int, ...],
997
+ ) -> tuple[str, int, tuple[str, ...], tuple[int, ...]]:
998
+ normalized_driver = _normalize_driver(driver)
999
+ if normalized_driver not in {"mysql", "postgresql"}:
1000
+ return str(host or "").strip(), int(port or 0), (), ()
1001
+ default_port = _default_port_for_driver(normalized_driver)
1002
+ parsed_hosts = _coerce_host_values(hosts)
1003
+ embedded_ports: tuple[int, ...] = ()
1004
+ if parsed_hosts:
1005
+ parsed_hosts, embedded_ports = _split_hosts_and_ports(",".join(parsed_hosts), default_port)
1006
+ elif str(host or "").strip():
1007
+ parsed_hosts, embedded_ports = _split_hosts_and_ports(str(host or ""), default_port)
1008
+ if not parsed_hosts:
1009
+ return "", int(port or default_port), (), ()
1010
+ explicit_ports = _coerce_port_values(ports)
1011
+ if explicit_ports:
1012
+ port_values = explicit_ports
1013
+ elif port:
1014
+ port_values = (int(port),)
1015
+ else:
1016
+ port_values = embedded_ports
1017
+ normalized_ports = _expand_ports(port_values, count=len(parsed_hosts), fallback=default_port)
1018
+ return ",".join(parsed_hosts), normalized_ports[0], parsed_hosts, normalized_ports
1019
+
1020
+
1021
+ def _primary_endpoint(settings: FoundationDatabaseSettings, *, default_port: int) -> tuple[str, int]:
1022
+ host = str(settings.host or "").strip()
1023
+ port = int(settings.port or 0)
1024
+ hosts, ports = _coerce_host_values(getattr(settings, "hosts", ())), _coerce_port_values(getattr(settings, "ports", ()))
1025
+ normalized_host, normalized_port, normalized_hosts, normalized_ports = _normalize_network_settings(
1026
+ driver=settings.driver,
1027
+ host=host,
1028
+ port=port,
1029
+ hosts=hosts,
1030
+ ports=ports,
1031
+ )
1032
+ if normalized_hosts:
1033
+ return normalized_hosts[0], normalized_ports[0]
1034
+ return normalized_host, normalized_port or default_port
1035
+
1036
+
1037
+ def _build_postgresql_dsn(settings: FoundationDatabaseSettings) -> str:
1038
+ hosts = _coerce_host_values(getattr(settings, "hosts", ()))
1039
+ ports = _coerce_port_values(getattr(settings, "ports", ()))
1040
+ if not hosts:
1041
+ normalized_host, _normalized_port, hosts, ports = _normalize_network_settings(
1042
+ driver="postgresql",
1043
+ host=settings.host,
1044
+ port=settings.port,
1045
+ hosts=hosts,
1046
+ ports=ports,
1047
+ )
1048
+ if not hosts and normalized_host:
1049
+ hosts = _coerce_host_values((normalized_host,))
1050
+ if not hosts:
1051
+ raise RuntimeError("foundation postgresql host is not configured")
1052
+ user = settings.user or ""
1053
+ password = settings.password or ""
1054
+ auth = user
1055
+ if password:
1056
+ auth = f"{auth}:{password}" if auth else f":{password}"
1057
+ if auth:
1058
+ if ":" in auth:
1059
+ raw_user, raw_password = auth.split(":", 1)
1060
+ auth = f"{quote(raw_user, safe='')}:{quote(raw_password, safe='')}@"
1061
+ else:
1062
+ auth = f"{quote(auth, safe='')}@"
1063
+ port_list = _expand_ports(
1064
+ ports,
1065
+ count=len(hosts),
1066
+ fallback=_default_port_for_driver("postgresql"),
1067
+ )
1068
+ host = ",".join(f"{_format_host_for_uri(host_item)}:{port_item}" for host_item, port_item in zip(hosts, port_list))
1069
+ name = quote(settings.database or "", safe="")
1070
+ query: dict[str, str] = {}
1071
+ if settings.ssl_mode:
1072
+ query["sslmode"] = settings.ssl_mode
1073
+ if settings.ssl_root_cert:
1074
+ query["sslrootcert"] = settings.ssl_root_cert
1075
+ if settings.ssl_cert:
1076
+ query["sslcert"] = settings.ssl_cert
1077
+ if settings.ssl_key:
1078
+ query["sslkey"] = settings.ssl_key
1079
+ if settings.ssl_password:
1080
+ query["sslpassword"] = settings.ssl_password
1081
+ target_session_attrs = str(getattr(settings, "target_session_attrs", "") or "").strip()
1082
+ if not target_session_attrs and len(hosts) > 1:
1083
+ target_session_attrs = "read-write"
1084
+ if target_session_attrs:
1085
+ query["target_session_attrs"] = target_session_attrs
1086
+ for key, value in dict(settings.options or {}).items():
1087
+ if key == "options" or key in query:
1088
+ continue
1089
+ if isinstance(value, (str, int, float, bool)) and str(value).strip():
1090
+ query[key] = str(value)
1091
+ dsn = f"postgresql://{auth}{host}/{name}"
1092
+ if query:
1093
+ dsn = f"{dsn}?{urlencode(query)}"
1094
+ return dsn
1095
+
1096
+
1097
+ def _database_singleton_key(settings: FoundationDatabaseSettings) -> str:
1098
+ return "|".join(
1099
+ (
1100
+ settings.driver,
1101
+ settings.path,
1102
+ ",".join(getattr(settings, "urls", ()) or ()),
1103
+ settings.host,
1104
+ str(settings.port),
1105
+ ",".join(getattr(settings, "hosts", ()) or ()),
1106
+ ",".join(str(item) for item in (getattr(settings, "ports", ()) or ())),
1107
+ settings.user,
1108
+ settings.database,
1109
+ str(settings.connect_timeout),
1110
+ str(settings.min_size),
1111
+ str(settings.max_size),
1112
+ str(settings.pool_acquire_timeout),
1113
+ str(settings.statement_timeout_ms),
1114
+ str(settings.idle_in_transaction_session_timeout_ms),
1115
+ str(settings.pool_max_lifetime),
1116
+ str(settings.pool_max_idle),
1117
+ str(settings.reconnect_timeout),
1118
+ str(settings.health_check_interval),
1119
+ settings.ssl_mode,
1120
+ settings.ssl_root_cert,
1121
+ settings.ssl_cert,
1122
+ settings.ssl_key,
1123
+ settings.ssl_password,
1124
+ settings.target_session_attrs,
1125
+ repr(sorted(dict(settings.options or {}).items())),
1126
+ )
1127
+ )
1128
+
1129
+
1130
+ def _build_postgresql_ssl_kwargs(settings: FoundationDatabaseSettings) -> dict[str, Any]:
1131
+ kwargs: dict[str, Any] = {}
1132
+ if settings.ssl_mode:
1133
+ kwargs["sslmode"] = settings.ssl_mode
1134
+ if settings.ssl_root_cert:
1135
+ kwargs["sslrootcert"] = settings.ssl_root_cert
1136
+ if settings.ssl_cert:
1137
+ kwargs["sslcert"] = settings.ssl_cert
1138
+ if settings.ssl_key:
1139
+ kwargs["sslkey"] = settings.ssl_key
1140
+ if settings.ssl_password:
1141
+ kwargs["sslpassword"] = settings.ssl_password
1142
+ return kwargs
1143
+
1144
+
1145
+ def _split_sql_statements(script: str) -> list[str]:
1146
+ statements: list[str] = []
1147
+ buffer: list[str] = []
1148
+ in_single = False
1149
+ in_double = False
1150
+ escaped = False
1151
+ for char in str(script or ""):
1152
+ buffer.append(char)
1153
+ if escaped:
1154
+ escaped = False
1155
+ continue
1156
+ if char == "\\":
1157
+ escaped = True
1158
+ continue
1159
+ if char == "'" and not in_double:
1160
+ in_single = not in_single
1161
+ continue
1162
+ if char == '"' and not in_single:
1163
+ in_double = not in_double
1164
+ continue
1165
+ if char == ";" and not in_single and not in_double:
1166
+ statement = "".join(buffer).strip().rstrip(";").strip()
1167
+ if statement:
1168
+ statements.append(statement)
1169
+ buffer = []
1170
+ tail = "".join(buffer).strip()
1171
+ if tail:
1172
+ statements.append(tail.rstrip(";").strip())
1173
+ return [item for item in statements if item]
1174
+
1175
+
1176
+ def _coerce_dbapi_row(description: Any, row: Any) -> dict[str, Any]:
1177
+ if isinstance(row, dict):
1178
+ return dict(row)
1179
+ names = [str(item[0]) for item in description or ()]
1180
+ if hasattr(row, "keys") and callable(row.keys):
1181
+ return {str(key): row[key] for key in row.keys()}
1182
+ return {name: row[index] for index, name in enumerate(names)}
1183
+
1184
+
1185
+ def _prepare_statement(driver: str, statement: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[str, Any]:
1186
+ params: Any
1187
+ if kwargs:
1188
+ params = kwargs
1189
+ elif len(args) == 1 and isinstance(args[0], (tuple, list, dict)):
1190
+ params = args[0]
1191
+ elif args:
1192
+ params = args
1193
+ else:
1194
+ params = None
1195
+ normalized = _normalize_driver(driver)
1196
+ if params is None or normalized == "sqlite":
1197
+ return statement, params
1198
+ if isinstance(params, dict):
1199
+ return _NAMED_PARAM_PATTERN.sub(r"%(\1)s", statement), params
1200
+ return statement, params
1201
+
1202
+
1203
+ def _normalize_driver(driver: str, *, url: str = "") -> str:
1204
+ raw = str(driver or "").strip().lower()
1205
+ if not raw and url:
1206
+ raw = urlparse(url).scheme.lower()
1207
+ if "+" in raw:
1208
+ raw = raw.split("+", 1)[0]
1209
+ if raw in {"postgres", "postgresql", "psql"}:
1210
+ return "postgresql"
1211
+ if raw in {"mysql", "mariadb"}:
1212
+ return "mysql"
1213
+ if raw in {"sqlite", "sqlite3"}:
1214
+ return "sqlite"
1215
+ return raw or "sqlite"
1216
+
1217
+
1218
+ def _read_str(payload: Any, key: str) -> str:
1219
+ def _normalize(raw: Any) -> str:
1220
+ value = str(raw or "").strip()
1221
+ if value in {'""', "''"}:
1222
+ return ""
1223
+ return value
1224
+
1225
+ if isinstance(payload, dict):
1226
+ return _normalize(payload.get(key))
1227
+ return _normalize(getattr(payload, key, ""))
1228
+
1229
+
1230
+ def _read_alias_str(payload: Any, *keys: str) -> str:
1231
+ for key in keys:
1232
+ value = _read_str(payload, key)
1233
+ if value:
1234
+ return value
1235
+ return ""
1236
+
1237
+
1238
+ def _read_str_sequence(payload: Any, key: str) -> tuple[str, ...]:
1239
+ value = payload.get(key) if isinstance(payload, dict) else getattr(payload, key, None)
1240
+ return _coerce_database_url_values(value)
1241
+
1242
+
1243
+ def _read_alias_int(payload: Any, keys: tuple[str, ...], default: int) -> int:
1244
+ for key in keys:
1245
+ value = payload.get(key) if isinstance(payload, dict) else getattr(payload, key, None)
1246
+ if value in {None, ""}:
1247
+ continue
1248
+ try:
1249
+ return int(value)
1250
+ except Exception:
1251
+ return default
1252
+ return default
1253
+
1254
+
1255
+ def _read_alias_float(payload: Any, keys: tuple[str, ...], default: float) -> float:
1256
+ for key in keys:
1257
+ value = payload.get(key) if isinstance(payload, dict) else getattr(payload, key, None)
1258
+ if value in {None, ""}:
1259
+ continue
1260
+ try:
1261
+ return float(value)
1262
+ except Exception:
1263
+ return default
1264
+ return default
1265
+
1266
+
1267
+ def _first_query_value(query: dict[str, list[str]], key: str) -> str:
1268
+ values = query.get(key) or []
1269
+ if not values:
1270
+ return ""
1271
+ return str(values[0] or "").strip()
1272
+
1273
+
1274
+
1275
+
1276
+ __all__ = [
1277
+ "describe_foundation_database_binding",
1278
+ "FoundationDatabase",
1279
+ "FoundationDatabaseSettings",
1280
+ "MySQLFoundationDatabase",
1281
+ "normalize_foundation_database_settings",
1282
+ "PostgreSQLFoundationDatabase",
1283
+ "SQLiteFoundationDatabase",
1284
+ "SQLiteFoundationReadinessProbe",
1285
+ "SQLiteFoundationSession",
1286
+ "SQLiteFoundationTransaction",
1287
+ "get_foundation_database",
1288
+ "load_foundation_database_settings",
1289
+ "probe_foundation_database_binding",
1290
+ "reset_foundation_databases",
1291
+ ]