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,1700 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import secrets
6
+ import shutil
7
+ import time
8
+ from collections.abc import Callable
9
+ from contextlib import AbstractContextManager
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from uuid import uuid4
15
+
16
+ from pz_core.assembly.apps import compile_app_registry, compile_conventional_app_order, merge_explicit_app_order
17
+ from pz_core.assembly.middlewares import compile_conventional_middleware_manifest, merge_explicit_middleware_order
18
+ from pz_core.assembly.settings import compile_settings_snapshot_from_payload
19
+ from pz_core.assembly.project import active_project_import_root
20
+ from pz_core.runtime.discovery import (
21
+ discover_declared_app_references,
22
+ discover_leaf_app_packages,
23
+ import_app_settings_module,
24
+ resolve_declared_app_id,
25
+ resolve_import_roots_for_app_references,
26
+ resolve_package_root,
27
+ )
28
+ from pz_core.runtime.errors import PzError
29
+ from pz_core.runtime.settings import active_settings_project
30
+ from pz_core.utils.project_jsonc import PROJECT_JSONC_NAME, dump_jsonc_document, load_jsonc_file, load_jsonc_text
31
+ from .templates.project.project_env import project_env_template
32
+ from .templates.project.pz_env_env import pz_env_env_template
33
+ from .templates.project.pz_env_jsonc import (
34
+ _annotate_project_base_url_comment,
35
+ pz_env_jsonc_template,
36
+ )
37
+ from .project_defaults import load_project_default_jsonc_payload
38
+ from .scaffold_service import render_project_agents_file_text, write_project_agents_file
39
+
40
+
41
+ try: # pragma: no cover - fcntl is unavailable on some platforms.
42
+ import fcntl
43
+ except Exception: # pragma: no cover
44
+ fcntl = None # type: ignore[assignment]
45
+
46
+
47
+ _OWNER = "pz_core.authoring.project_settings"
48
+ _MANAGED_PATHS = ("installed_apps", "middlewares")
49
+ _UPDATE_BACKUP_DIRNAME = "pz-update-settings"
50
+ _UPDATE_BACKUP_DEFAULT_KEEP_COUNT = 15
51
+ _UPDATE_LOCK_NAME = "settings-update.lock"
52
+ _ROOT_ENV_REDIRECTS: dict[tuple[str, ...], str] = {}
53
+ _SECRET_TEMPLATE_KEY_TOKENS = ("password", "secret", "token", "key", "webhook")
54
+ _SECRET_GENERATOR_PREFIX = "@pz:secret"
55
+ _ROUTING_DEFAULT_KEYS = frozenset({"default_language", "all_supported_languages", "language_aliases"})
56
+ _ROOT_DEFAULT_REFRESH_KEYS = frozenset({"commands"})
57
+ _PZ_ENV_SETTINGS_TEMPLATES = {
58
+ "pz.develop.jsonc": lambda: pz_env_jsonc_template("develop"),
59
+ "pz.test.jsonc": lambda: pz_env_jsonc_template("test"),
60
+ "pz.product.jsonc": lambda: pz_env_jsonc_template("product"),
61
+ }
62
+ _PZ_ENV_ENV_TEMPLATES = {
63
+ ".env": project_env_template,
64
+ ".env.develop": lambda: pz_env_env_template("develop"),
65
+ ".env.test": lambda: pz_env_env_template("test"),
66
+ ".env.product": lambda: pz_env_env_template("product"),
67
+ }
68
+
69
+
70
+ SettingsPayloadNormalizer = Callable[[dict[str, Any]], dict[str, Any]]
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class AppSettingsSource:
75
+ module_path: str
76
+ app_id: str
77
+ defaults: dict[str, Any]
78
+ normalize: SettingsPayloadNormalizer | None = None
79
+ secret_template: str = ""
80
+ root_defaults: dict[str, Any] | None = None
81
+ comments: dict[tuple[str, ...], tuple[str, ...]] | None = None
82
+ root_comments: dict[tuple[str, ...], tuple[str, ...]] | None = None
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class SettingsWriteResult:
87
+ path: Path
88
+ module_paths: tuple[str, ...]
89
+ created: bool
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class SettingsUpdateDriftResult:
94
+ project_dir: Path
95
+ required: bool
96
+ changed_paths: tuple[Path, ...]
97
+ module_paths: tuple[str, ...]
98
+ command: str
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class _SettingsUpdateDryRun:
103
+ project_dir: Path
104
+ config_path: Path
105
+ current_payload: dict[str, Any]
106
+ next_payload: dict[str, Any]
107
+ discovered_sources: tuple[AppSettingsSource, ...]
108
+ selected_sources: tuple[AppSettingsSource, ...]
109
+ root_env_assignments: dict[str, str]
110
+
111
+
112
+ def discover_settings_sources(
113
+ project_dir: str | Path | None = None,
114
+ *,
115
+ include_project_apps_container: bool = False,
116
+ ) -> tuple[AppSettingsSource, ...]:
117
+ resolved_project_dir = _resolve_existing_project_dir(project_dir) if project_dir is not None else None
118
+ refs = discover_declared_app_references(
119
+ _declared_app_entries(
120
+ resolved_project_dir,
121
+ include_project_apps_container=include_project_apps_container,
122
+ ),
123
+ project_dir=resolved_project_dir,
124
+ )
125
+ import_roots = resolve_import_roots_for_app_references(refs)
126
+ with active_project_import_root(resolved_project_dir, extra_import_roots=import_roots):
127
+ source_pairs = [(item, _build_settings_source(item.module_path)) for item in refs]
128
+ sources = _dedupe_settings_sources_by_app_id(
129
+ resolved_project_dir,
130
+ source_pairs,
131
+ prefer_project_apps_leaf=include_project_apps_container,
132
+ )
133
+ return tuple(sorted(sources, key=lambda item: item.module_path))
134
+
135
+
136
+ def initialize_settings_file(
137
+ project_dir: str | Path,
138
+ *,
139
+ force: bool = False,
140
+ sources: tuple[AppSettingsSource, ...] | None = None,
141
+ ) -> SettingsWriteResult:
142
+ resolved_project_dir = _resolve_project_dir(project_dir)
143
+ with _SettingsUpdateLock(resolved_project_dir):
144
+ config_path = resolved_project_dir / PROJECT_JSONC_NAME
145
+ existed_before = config_path.exists()
146
+ if existed_before and not force:
147
+ raise PzError(
148
+ code="PZ_SETTINGS_FILE_EXISTS",
149
+ message=f"Refusing to overwrite existing settings file: {config_path}",
150
+ stage="cli",
151
+ owner=_OWNER,
152
+ action="init_settings",
153
+ )
154
+ current_payload = _load_existing_document(config_path) if existed_before else {}
155
+ if existed_before and force:
156
+ _create_update_backup(resolved_project_dir, current_payload)
157
+ selected_sources = tuple(
158
+ sources
159
+ or discover_settings_sources(
160
+ resolved_project_dir,
161
+ include_project_apps_container=force,
162
+ )
163
+ )
164
+ seed_payload = {} if force else current_payload
165
+ next_payload = _build_initialized_payload(resolved_project_dir, seed_payload, selected_sources)
166
+ root_env_assignments = _pop_root_env_redirects(next_payload)
167
+ _atomic_write_text(
168
+ config_path,
169
+ dump_jsonc_document(
170
+ next_payload,
171
+ managed_paths=_MANAGED_PATHS,
172
+ comments=_collect_project_jsonc_comments(selected_sources),
173
+ ),
174
+ )
175
+ _ensure_pz_env_settings_placeholders(resolved_project_dir)
176
+ _ensure_pz_env_env_templates(
177
+ resolved_project_dir,
178
+ root_env_assignments=root_env_assignments,
179
+ app_secret_templates=_collect_app_secret_templates(selected_sources),
180
+ )
181
+ write_project_agents_file(resolved_project_dir)
182
+ return SettingsWriteResult(
183
+ path=config_path,
184
+ module_paths=tuple(item.module_path for item in selected_sources),
185
+ created=not existed_before,
186
+ )
187
+
188
+
189
+ def update_settings_file(
190
+ project_dir: str | Path,
191
+ *,
192
+ target: str | None = None,
193
+ sources: tuple[AppSettingsSource, ...] | None = None,
194
+ reset_middlewares_order: bool = False,
195
+ ) -> SettingsWriteResult:
196
+ resolved_project_dir = _resolve_project_dir(project_dir)
197
+ with _SettingsUpdateLock(resolved_project_dir):
198
+ config_path = resolved_project_dir / PROJECT_JSONC_NAME
199
+ if not config_path.is_file():
200
+ raise PzError(
201
+ code="PZ_SETTINGS_FILE_MISSING",
202
+ message=f"Settings file does not exist: {config_path}",
203
+ stage="cli",
204
+ owner=_OWNER,
205
+ action="update_settings",
206
+ )
207
+ dry_run = _build_settings_update_dry_run(
208
+ resolved_project_dir,
209
+ target=target,
210
+ sources=sources,
211
+ reset_middlewares_order=reset_middlewares_order,
212
+ )
213
+ _create_update_backup(resolved_project_dir, dry_run.current_payload)
214
+ _atomic_write_text(
215
+ dry_run.config_path,
216
+ dump_jsonc_document(
217
+ dry_run.next_payload,
218
+ managed_paths=_MANAGED_PATHS,
219
+ comments=_collect_project_jsonc_comments(dry_run.discovered_sources),
220
+ ),
221
+ )
222
+ _ensure_pz_env_settings_placeholders(resolved_project_dir)
223
+ _ensure_pz_env_env_templates(
224
+ resolved_project_dir,
225
+ root_env_assignments=dry_run.root_env_assignments,
226
+ app_secret_templates=_collect_app_secret_templates(dry_run.discovered_sources),
227
+ )
228
+ write_project_agents_file(resolved_project_dir)
229
+ return SettingsWriteResult(
230
+ path=dry_run.config_path,
231
+ module_paths=tuple(item.module_path for item in dry_run.selected_sources),
232
+ created=False,
233
+ )
234
+
235
+
236
+ def inspect_settings_update_drift(
237
+ project_dir: str | Path,
238
+ *,
239
+ target: str | None = None,
240
+ sources: tuple[AppSettingsSource, ...] | None = None,
241
+ reset_middlewares_order: bool = False,
242
+ ) -> SettingsUpdateDriftResult:
243
+ resolved_project_dir = Path(project_dir).expanduser().resolve()
244
+ command = f"pz settings update --project-dir {resolved_project_dir}"
245
+ config_path = resolved_project_dir / PROJECT_JSONC_NAME
246
+ if not resolved_project_dir.is_dir() or not config_path.is_file():
247
+ return SettingsUpdateDriftResult(
248
+ project_dir=resolved_project_dir,
249
+ required=False,
250
+ changed_paths=(),
251
+ module_paths=(),
252
+ command=command,
253
+ )
254
+ dry_run = _build_settings_update_dry_run(
255
+ resolved_project_dir,
256
+ target=target,
257
+ sources=sources,
258
+ reset_middlewares_order=reset_middlewares_order,
259
+ )
260
+ changed_paths = _settings_update_changed_paths(dry_run)
261
+ return SettingsUpdateDriftResult(
262
+ project_dir=resolved_project_dir,
263
+ required=bool(changed_paths),
264
+ changed_paths=changed_paths,
265
+ module_paths=tuple(item.module_path for item in dry_run.selected_sources),
266
+ command=command,
267
+ )
268
+
269
+
270
+ def _build_settings_update_dry_run(
271
+ project_dir: Path,
272
+ *,
273
+ target: str | None,
274
+ sources: tuple[AppSettingsSource, ...] | None,
275
+ reset_middlewares_order: bool,
276
+ ) -> _SettingsUpdateDryRun:
277
+ config_path = project_dir / PROJECT_JSONC_NAME
278
+ discovered_sources = tuple(sources or discover_settings_sources(project_dir))
279
+ selected_sources = _resolve_target_sources(target, discovered_sources)
280
+ current_payload = _load_existing_document(config_path)
281
+ next_payload = _build_updated_payload(
282
+ project_dir,
283
+ current_payload,
284
+ selected_sources,
285
+ all_sources=discovered_sources,
286
+ reset_middlewares_order=reset_middlewares_order,
287
+ )
288
+ root_env_assignments = _pop_root_env_redirects(next_payload)
289
+ return _SettingsUpdateDryRun(
290
+ project_dir=project_dir,
291
+ config_path=config_path,
292
+ current_payload=current_payload,
293
+ next_payload=next_payload,
294
+ discovered_sources=discovered_sources,
295
+ selected_sources=selected_sources,
296
+ root_env_assignments=root_env_assignments,
297
+ )
298
+
299
+
300
+ def _settings_update_changed_paths(dry_run: _SettingsUpdateDryRun) -> tuple[Path, ...]:
301
+ changed_paths: list[Path] = []
302
+ if not _json_payload_equivalent(dry_run.next_payload, dry_run.current_payload):
303
+ changed_paths.append(dry_run.config_path)
304
+ changed_paths.extend(_project_agents_changed_paths(dry_run.project_dir))
305
+ changed_paths.extend(_pz_env_settings_placeholder_changed_paths(dry_run.project_dir))
306
+ changed_paths.extend(
307
+ _pz_env_template_changed_paths(
308
+ dry_run.project_dir,
309
+ root_env_assignments=dry_run.root_env_assignments,
310
+ app_secret_templates=_collect_app_secret_templates(dry_run.discovered_sources),
311
+ )
312
+ )
313
+ return tuple(dict.fromkeys(changed_paths))
314
+
315
+
316
+ def _json_payload_equivalent(left: Any, right: Any) -> bool:
317
+ try:
318
+ return json.loads(json.dumps(left, ensure_ascii=False, sort_keys=True)) == json.loads(
319
+ json.dumps(right, ensure_ascii=False, sort_keys=True)
320
+ )
321
+ except TypeError:
322
+ return left == right
323
+
324
+
325
+ def _pz_env_settings_placeholder_changed_paths(project_dir: Path) -> tuple[Path, ...]:
326
+ changed_paths: list[Path] = []
327
+ for file_name, template in _PZ_ENV_SETTINGS_TEMPLATES.items():
328
+ path = project_dir / file_name
329
+ if not path.exists():
330
+ changed_paths.append(path)
331
+ continue
332
+ current_payload = _load_existing_document(path)
333
+ template_payload = _load_template_jsonc_payload(template())
334
+ merged_payload = _migrate_root_routing_language_fields(
335
+ _deep_merge_payload(template_payload, current_payload)
336
+ )
337
+ if merged_payload != current_payload:
338
+ changed_paths.append(path)
339
+ return tuple(changed_paths)
340
+
341
+
342
+ def _pz_env_template_changed_paths(
343
+ project_dir: Path,
344
+ *,
345
+ root_env_assignments: dict[str, str],
346
+ app_secret_templates: tuple[tuple[str, tuple[tuple[str, str], ...]], ...],
347
+ ) -> tuple[Path, ...]:
348
+ changed_paths: list[Path] = []
349
+ for file_name, template in _PZ_ENV_ENV_TEMPLATES.items():
350
+ path = project_dir / file_name
351
+ template_text = template() + _render_app_secret_template_block(
352
+ app_secret_templates,
353
+ commented=file_name != ".env",
354
+ )
355
+ preferred_assignments = root_env_assignments if file_name == ".env" else None
356
+ if not path.exists():
357
+ changed_paths.append(path)
358
+ continue
359
+ existing_text = path.read_text(encoding="utf-8")
360
+ merged_text = _merge_env_template_text(
361
+ template_text,
362
+ existing_text,
363
+ preferred_assignments=preferred_assignments,
364
+ )
365
+ if merged_text != existing_text:
366
+ changed_paths.append(path)
367
+ return tuple(changed_paths)
368
+
369
+
370
+ def _resolve_project_dir(project_dir: str | Path) -> Path:
371
+ path = Path(project_dir).expanduser().resolve()
372
+ if not path.exists() or not path.is_dir():
373
+ raise PzError(
374
+ code="PZ_SETTINGS_PROJECT_DIR_INVALID",
375
+ message=f"project directory must exist: {path}",
376
+ stage="cli",
377
+ owner=_OWNER,
378
+ action="resolve_project_dir",
379
+ )
380
+ return path
381
+
382
+
383
+ def _resolve_existing_project_dir(project_dir: str | Path | None) -> Path | None:
384
+ if project_dir is None:
385
+ return None
386
+ return _resolve_project_dir(project_dir)
387
+
388
+
389
+ def _settings_update_lock_path(project_dir: Path) -> Path:
390
+ return project_dir / ".runtime" / "run" / "locks" / _UPDATE_LOCK_NAME
391
+
392
+
393
+ class _SettingsUpdateLock(AbstractContextManager):
394
+ def __init__(self, project_dir: Path) -> None:
395
+ self._lock_path = _settings_update_lock_path(project_dir)
396
+ self._handle = None
397
+ self._fallback_lock_dir: Path | None = None
398
+
399
+ def __enter__(self):
400
+ self.acquire()
401
+ return self
402
+
403
+ def __exit__(self, exc_type, exc, tb) -> None:
404
+ self.release()
405
+
406
+ def acquire(self) -> None:
407
+ self._lock_path.parent.mkdir(parents=True, exist_ok=True)
408
+ self._handle = self._lock_path.open("a+", encoding="utf-8")
409
+ if fcntl is not None:
410
+ try:
411
+ fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX)
412
+ return
413
+ except Exception:
414
+ self._handle.close()
415
+ self._handle = None
416
+ raise
417
+ fallback_lock_dir = self._lock_path.with_suffix(self._lock_path.suffix + ".d")
418
+ try:
419
+ while True: # pragma: no cover
420
+ try:
421
+ fallback_lock_dir.mkdir(parents=False, exist_ok=False)
422
+ self._fallback_lock_dir = fallback_lock_dir
423
+ return
424
+ except FileExistsError:
425
+ time.sleep(0.05)
426
+ except Exception:
427
+ if self._handle is not None:
428
+ self._handle.close()
429
+ self._handle = None
430
+ raise
431
+
432
+ def release(self) -> None:
433
+ try:
434
+ if self._handle is not None and fcntl is not None:
435
+ fcntl.flock(self._handle.fileno(), fcntl.LOCK_UN)
436
+ finally:
437
+ if self._handle is not None:
438
+ self._handle.close()
439
+ self._handle = None
440
+ if self._fallback_lock_dir is not None:
441
+ try: # pragma: no cover
442
+ self._fallback_lock_dir.rmdir()
443
+ finally:
444
+ self._fallback_lock_dir = None
445
+
446
+
447
+ def _atomic_write_text(path: Path, content: str) -> None:
448
+ path.parent.mkdir(parents=True, exist_ok=True)
449
+ tmp_path = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
450
+ try:
451
+ with tmp_path.open("w", encoding="utf-8") as handle:
452
+ handle.write(content)
453
+ handle.flush()
454
+ os.fsync(handle.fileno())
455
+ if path.exists():
456
+ try:
457
+ tmp_path.chmod(path.stat().st_mode)
458
+ except OSError:
459
+ pass
460
+ tmp_path.replace(path)
461
+ _fsync_parent_directory(path)
462
+ except Exception:
463
+ try:
464
+ tmp_path.unlink()
465
+ except FileNotFoundError:
466
+ pass
467
+ raise
468
+
469
+
470
+ def _fsync_parent_directory(path: Path) -> None:
471
+ try:
472
+ fd = os.open(str(path.parent), os.O_RDONLY)
473
+ except OSError:
474
+ return
475
+ try:
476
+ os.fsync(fd)
477
+ except OSError:
478
+ pass
479
+ finally:
480
+ os.close(fd)
481
+
482
+
483
+ def _ensure_pz_env_settings_placeholders(project_dir: Path) -> None:
484
+ for file_name, template in _PZ_ENV_SETTINGS_TEMPLATES.items():
485
+ path = project_dir / file_name
486
+ template_payload = _load_template_jsonc_payload(template())
487
+ if not path.exists():
488
+ _atomic_write_text(path, template())
489
+ continue
490
+ current_payload = _load_existing_document(path)
491
+ merged_payload = _migrate_root_routing_language_fields(
492
+ _deep_merge_payload(template_payload, current_payload)
493
+ )
494
+ if merged_payload != current_payload:
495
+ _atomic_write_text(path, _annotate_project_base_url_comment(dump_jsonc_document(merged_payload)))
496
+
497
+
498
+ def _ensure_pz_env_env_templates(
499
+ project_dir: Path,
500
+ *,
501
+ root_env_assignments: dict[str, str] | None = None,
502
+ app_secret_templates: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (),
503
+ ) -> None:
504
+ for file_name, template in _PZ_ENV_ENV_TEMPLATES.items():
505
+ path = project_dir / file_name
506
+ template_text = template() + _render_app_secret_template_block(
507
+ app_secret_templates,
508
+ commented=file_name != ".env",
509
+ )
510
+ preferred_assignments = root_env_assignments if file_name == ".env" else None
511
+ if not path.exists():
512
+ _atomic_write_text(
513
+ path,
514
+ _merge_env_template_text(template_text, "", preferred_assignments=preferred_assignments),
515
+ )
516
+ continue
517
+ existing_text = path.read_text(encoding="utf-8")
518
+ merged_text = _merge_env_template_text(
519
+ template_text,
520
+ existing_text,
521
+ preferred_assignments=preferred_assignments,
522
+ )
523
+ if merged_text != existing_text:
524
+ _atomic_write_text(path, merged_text)
525
+
526
+
527
+ def _load_template_jsonc_payload(template_text: str) -> dict[str, Any]:
528
+ payload = load_jsonc_text(
529
+ template_text,
530
+ code="PZ_SETTINGS_TEMPLATE_INVALID",
531
+ owner=_OWNER,
532
+ action="load_template_jsonc_payload",
533
+ source_name="pz_env template",
534
+ )
535
+ if not isinstance(payload, dict):
536
+ raise PzError(
537
+ code="PZ_SETTINGS_TEMPLATE_INVALID",
538
+ message="pz_env JSONC template must define an object payload.",
539
+ stage="cli",
540
+ owner=_OWNER,
541
+ action="load_template_jsonc_payload",
542
+ )
543
+ return payload
544
+
545
+
546
+ def _merge_env_template_text(
547
+ template_text: str,
548
+ existing_text: str,
549
+ *,
550
+ preferred_assignments: dict[str, str] | None = None,
551
+ ) -> str:
552
+ existing_values = _parse_env_assignments(existing_text)
553
+ for key, value in (preferred_assignments or {}).items():
554
+ if key not in existing_values and str(value or "").strip():
555
+ existing_values[key] = str(value).strip()
556
+ emitted_keys: set[str] = set()
557
+ rendered_lines: list[str] = []
558
+ for line in template_text.splitlines():
559
+ assignment = _parse_env_assignment_line(line)
560
+ key = assignment[0] if assignment is not None else None
561
+ if key is None:
562
+ rendered_lines.append(line)
563
+ continue
564
+ template_value = assignment[1]
565
+ if key in existing_values:
566
+ value = existing_values[key]
567
+ if _is_secret_generator_directive(template_value) and not str(value or "").strip():
568
+ value = _generate_env_secret_value(template_value)
569
+ rendered_lines.append(f"{key}={value}")
570
+ elif _is_secret_generator_directive(template_value) and not assignment[2]:
571
+ rendered_lines.append(f"{key}={_generate_env_secret_value(template_value)}")
572
+ else:
573
+ rendered_lines.append(line)
574
+ emitted_keys.add(key)
575
+ extra_keys = [key for key in existing_values if key not in emitted_keys]
576
+ if extra_keys:
577
+ if rendered_lines and rendered_lines[-1] != "":
578
+ rendered_lines.append("")
579
+ rendered_lines.append("# Existing custom overrides")
580
+ for key in extra_keys:
581
+ rendered_lines.append(f"{key}={existing_values[key]}")
582
+ return "\n".join(rendered_lines).rstrip() + "\n"
583
+
584
+
585
+ def _parse_env_assignments(text: str) -> dict[str, str]:
586
+ assignments: dict[str, str] = {}
587
+ for raw_line in text.splitlines():
588
+ line = raw_line.strip()
589
+ if not line or line.startswith("#") or "=" not in line:
590
+ continue
591
+ key, value = line.split("=", 1)
592
+ key = key.strip()
593
+ if key:
594
+ assignments[key] = value
595
+ return assignments
596
+
597
+
598
+ def _parse_env_assignment_line(line: str) -> tuple[str, str, bool] | None:
599
+ stripped = line.strip()
600
+ if not stripped:
601
+ return None
602
+ commented = stripped.startswith("#")
603
+ candidate = stripped[1:].strip() if commented else stripped
604
+ if "=" not in candidate:
605
+ return None
606
+ key, value = candidate.split("=", 1)
607
+ key = key.strip()
608
+ if not key or " " in key:
609
+ return None
610
+ return key, value, commented
611
+
612
+
613
+ def _parse_env_template_assignments(text: str) -> tuple[tuple[str, str], ...]:
614
+ assignments: list[tuple[str, str]] = []
615
+ seen: set[str] = set()
616
+ for raw_line in text.splitlines():
617
+ key = _extract_env_assignment_key(raw_line)
618
+ if key is None or key in seen:
619
+ continue
620
+ stripped = raw_line.strip()
621
+ candidate = stripped[1:].strip() if stripped.startswith("#") else stripped
622
+ _key, value = candidate.split("=", 1)
623
+ assignments.append((key, value))
624
+ seen.add(key)
625
+ return tuple(assignments)
626
+
627
+
628
+ def _extract_env_assignment_key(line: str) -> str | None:
629
+ assignment = _parse_env_assignment_line(line)
630
+ return assignment[0] if assignment is not None else None
631
+
632
+
633
+ def _is_secret_generator_directive(value: str) -> bool:
634
+ return str(value or "").strip().lower().startswith(_SECRET_GENERATOR_PREFIX)
635
+
636
+
637
+ def _generate_env_secret_value(value: str) -> str:
638
+ directive = str(value or "").strip().lower()
639
+ if directive in {"@pz:secret", "@pz:secret:urlsafe", "@pz:secret:urlsafe64"}:
640
+ return secrets.token_urlsafe(48)
641
+ if directive in {"@pz:secret:hex", "@pz:secret:hex64"}:
642
+ return secrets.token_hex(32)
643
+ raise PzError(
644
+ code="PZ_APP_DEFAULT_ENV_INVALID",
645
+ message=f"Unsupported env secret generator directive: {value}",
646
+ stage="cli",
647
+ owner=_OWNER,
648
+ action="merge_env_template",
649
+ )
650
+
651
+
652
+ def _collect_app_secret_templates(
653
+ sources: tuple[AppSettingsSource, ...],
654
+ ) -> tuple[tuple[str, tuple[tuple[str, str], ...]], ...]:
655
+ collected: list[tuple[str, tuple[tuple[str, str], ...]]] = []
656
+ seen_apps: set[str] = set()
657
+ for source in sorted(sources, key=lambda item: item.app_id):
658
+ if source.app_id in seen_apps:
659
+ continue
660
+ assignments = _parse_env_template_assignments(source.secret_template)
661
+ if assignments:
662
+ collected.append((source.app_id, assignments))
663
+ seen_apps.add(source.app_id)
664
+ return tuple(collected)
665
+
666
+
667
+ def _render_app_secret_template_block(
668
+ templates: tuple[tuple[str, tuple[tuple[str, str], ...]], ...],
669
+ *,
670
+ commented: bool,
671
+ ) -> str:
672
+ if not templates:
673
+ return ""
674
+ lines = [
675
+ "",
676
+ "# Installed app secret placeholders",
677
+ "# Generated from installed apps' configs/default.env files.",
678
+ ]
679
+ prefix = "# " if commented else ""
680
+ for app_id, assignments in templates:
681
+ lines.append("")
682
+ lines.append(f"# {app_id}")
683
+ for key, value in assignments:
684
+ rendered_value = "" if commented and _is_secret_generator_directive(value) else value
685
+ lines.append(f"{prefix}{key}={rendered_value}")
686
+ return "\n".join(lines).rstrip() + "\n"
687
+
688
+
689
+ def _pop_root_env_redirects(payload: dict[str, Any]) -> dict[str, str]:
690
+ assignments: dict[str, str] = {}
691
+ for path, env_key in _ROOT_ENV_REDIRECTS.items():
692
+ current: Any = payload
693
+ for segment in path[:-1]:
694
+ if not isinstance(current, dict):
695
+ current = None
696
+ break
697
+ current = current.get(segment)
698
+ if not isinstance(current, dict):
699
+ continue
700
+ leaf_key = path[-1]
701
+ if leaf_key not in current:
702
+ continue
703
+ value = current.pop(leaf_key)
704
+ if value in (None, ""):
705
+ continue
706
+ assignments[env_key] = str(value)
707
+ return assignments
708
+
709
+
710
+ def _settings_update_backup_root(project_dir: Path) -> Path:
711
+ return project_dir / ".runtime" / "backups" / _UPDATE_BACKUP_DIRNAME
712
+
713
+
714
+ def _settings_backup_keep_count(payload: dict[str, Any]) -> int:
715
+ project = payload.get("project")
716
+ if isinstance(project, dict):
717
+ raw = project.get("settings_backup_keep_count", _UPDATE_BACKUP_DEFAULT_KEEP_COUNT)
718
+ else:
719
+ raw = _UPDATE_BACKUP_DEFAULT_KEEP_COUNT
720
+ try:
721
+ keep_count = int(raw)
722
+ except Exception:
723
+ keep_count = _UPDATE_BACKUP_DEFAULT_KEEP_COUNT
724
+ return keep_count if keep_count > 0 else _UPDATE_BACKUP_DEFAULT_KEEP_COUNT
725
+
726
+
727
+ def _update_backup_candidates(project_dir: Path) -> tuple[Path, ...]:
728
+ candidates = (
729
+ "AGENTS.md",
730
+ PROJECT_JSONC_NAME,
731
+ "pz.develop.jsonc",
732
+ "pz.test.jsonc",
733
+ "pz.product.jsonc",
734
+ ".env",
735
+ ".env.develop",
736
+ ".env.test",
737
+ ".env.product",
738
+ )
739
+ return tuple(project_dir / item for item in candidates)
740
+
741
+
742
+ def _create_update_backup(project_dir: Path, current_payload: dict[str, Any]) -> Path:
743
+ backup_root = _settings_update_backup_root(project_dir)
744
+ backup_root.mkdir(parents=True, exist_ok=True)
745
+ backup_dir = backup_root / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
746
+ backup_dir.mkdir(parents=True, exist_ok=False)
747
+ for source in _update_backup_candidates(project_dir):
748
+ if not source.exists():
749
+ continue
750
+ shutil.copy2(source, backup_dir / source.name)
751
+ _prune_update_backups(backup_root, keep_count=_settings_backup_keep_count(current_payload))
752
+ return backup_dir
753
+
754
+
755
+ def _project_agents_changed_paths(project_dir: Path) -> tuple[Path, ...]:
756
+ path = project_dir / "AGENTS.md"
757
+ current = path.read_text(encoding="utf-8") if path.exists() else None
758
+ expected = render_project_agents_file_text(project_dir)
759
+ if current == expected:
760
+ return ()
761
+ return (path,)
762
+
763
+
764
+ def _prune_update_backups(backup_root: Path, *, keep_count: int) -> None:
765
+ existing_dirs = sorted(
766
+ [path for path in backup_root.iterdir() if path.is_dir()],
767
+ key=lambda item: item.name,
768
+ reverse=True,
769
+ )
770
+ for stale in existing_dirs[keep_count:]:
771
+ shutil.rmtree(stale, ignore_errors=True)
772
+
773
+
774
+ def _declared_app_entries(
775
+ project_dir: Path | None,
776
+ *,
777
+ include_project_apps_container: bool = False,
778
+ ) -> tuple[str, ...]:
779
+ if project_dir is None:
780
+ return ("apps",)
781
+
782
+ entries: list[str] = []
783
+ config_path = project_dir / PROJECT_JSONC_NAME
784
+ if config_path.is_file():
785
+ data = _load_existing_document(config_path)
786
+ raw = data.get("installed_apps")
787
+ if isinstance(raw, list):
788
+ entries.extend(
789
+ str(item).strip()
790
+ for item in raw
791
+ if isinstance(item, str)
792
+ and str(item).strip()
793
+ and _is_formal_app_or_container_entry(project_dir, str(item).strip())
794
+ )
795
+
796
+ if include_project_apps_container and (project_dir / "apps").is_dir() and "apps" not in entries:
797
+ entries.append("apps")
798
+
799
+ if entries:
800
+ return tuple(dict.fromkeys(entries))
801
+
802
+ if (project_dir / "apps").is_dir():
803
+ return ("apps",)
804
+ return ()
805
+
806
+
807
+ def _build_settings_source(module_path: str) -> AppSettingsSource:
808
+ app_id = _load_app_id(module_path)
809
+ raw_defaults, raw_comments = _load_app_settings_defaults_with_comments(module_path)
810
+ defaults, root_defaults, comments, root_comments = _split_app_settings_defaults_with_comments(
811
+ raw_defaults,
812
+ raw_comments,
813
+ module_path=module_path,
814
+ )
815
+ normalize = _load_app_settings_normalizer(module_path)
816
+ secret_template = _load_app_settings_secret_template(module_path)
817
+ _validate_app_settings_secret_template(
818
+ app_id=app_id,
819
+ module_path=module_path,
820
+ defaults=defaults,
821
+ secret_template=secret_template,
822
+ )
823
+ return AppSettingsSource(
824
+ module_path=module_path,
825
+ app_id=app_id,
826
+ defaults=defaults,
827
+ normalize=normalize,
828
+ secret_template=secret_template,
829
+ root_defaults=root_defaults,
830
+ comments=comments,
831
+ root_comments=root_comments,
832
+ )
833
+
834
+
835
+ def _dedupe_settings_sources_by_app_id(
836
+ project_dir: Path | None,
837
+ source_pairs: list[tuple[Any, AppSettingsSource]],
838
+ *,
839
+ prefer_project_apps_leaf: bool,
840
+ ) -> list[AppSettingsSource]:
841
+ selected: dict[str, tuple[Any, AppSettingsSource]] = {}
842
+ for ref, source in source_pairs:
843
+ existing = selected.get(source.app_id)
844
+ if existing is None:
845
+ selected[source.app_id] = (ref, source)
846
+ continue
847
+ if prefer_project_apps_leaf and _prefer_settings_source_over_existing(project_dir, ref, source, existing):
848
+ selected[source.app_id] = (ref, source)
849
+ continue
850
+ return [item[1] for item in selected.values()]
851
+
852
+
853
+ def _prefer_settings_source_over_existing(
854
+ project_dir: Path | None,
855
+ ref: Any,
856
+ source: AppSettingsSource,
857
+ existing: tuple[Any, AppSettingsSource],
858
+ ) -> bool:
859
+ if not _is_project_apps_leaf_ref(project_dir, ref):
860
+ return False
861
+ existing_ref, existing_source = existing
862
+ if not _is_project_apps_leaf_ref(project_dir, existing_ref):
863
+ return True
864
+ return existing_source.module_path.startswith("apps.") and not source.module_path.startswith("apps.")
865
+
866
+
867
+ def _is_project_apps_leaf_ref(project_dir: Path | None, ref: Any) -> bool:
868
+ if project_dir is None:
869
+ return False
870
+ import_root = getattr(ref, "import_root", None)
871
+ if import_root is None:
872
+ return False
873
+ try:
874
+ root = Path(import_root).resolve()
875
+ apps_root = (project_dir / "apps").resolve()
876
+ return root == apps_root or root.is_relative_to(apps_root)
877
+ except Exception:
878
+ return False
879
+
880
+
881
+ def _load_app_id(module_path: str) -> str:
882
+ try:
883
+ return resolve_declared_app_id(module_path)
884
+ except PzError as exc:
885
+ raise PzError(
886
+ code="PZ_SETTINGS_APP_ID_INVALID",
887
+ message=f"App '{module_path}' must expose a valid app.id identifier.",
888
+ stage="cli",
889
+ owner=_OWNER,
890
+ action="discover_settings_sources",
891
+ detail=exc.detail,
892
+ ) from exc
893
+
894
+
895
+ def _load_app_settings_defaults(module_path: str) -> dict[str, Any]:
896
+ defaults, _comments = _load_app_settings_defaults_with_comments(module_path)
897
+ return defaults
898
+
899
+
900
+ def _load_app_settings_defaults_with_comments(
901
+ module_path: str,
902
+ ) -> tuple[dict[str, Any], dict[tuple[str, ...], tuple[str, ...]]]:
903
+ package_root = resolve_package_root(module_path)
904
+ if package_root is None:
905
+ return {}, {}
906
+ default_jsonc_path = package_root / "configs" / "default.jsonc"
907
+ if not default_jsonc_path.is_file():
908
+ return {}, {}
909
+ raw_text = default_jsonc_path.read_text(encoding="utf-8")
910
+ payload = load_jsonc_file(
911
+ default_jsonc_path,
912
+ code="PZ_APP_DEFAULTS_INVALID",
913
+ owner=_OWNER,
914
+ action="load_app_settings_defaults",
915
+ )
916
+ if not isinstance(payload, dict):
917
+ raise PzError(
918
+ code="PZ_APP_DEFAULTS_INVALID",
919
+ message=f"App default settings file '{default_jsonc_path}' must define an object payload.",
920
+ stage="cli",
921
+ owner=_OWNER,
922
+ action="load_app_settings_defaults",
923
+ )
924
+ return dict(payload), _extract_default_jsonc_comments(raw_text)
925
+
926
+
927
+ def _split_app_settings_defaults(
928
+ payload: dict[str, Any],
929
+ *,
930
+ module_path: str,
931
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
932
+ defaults, root_defaults, _comments, _root_comments = _split_app_settings_defaults_with_comments(
933
+ payload,
934
+ {},
935
+ module_path=module_path,
936
+ )
937
+ return defaults, root_defaults
938
+
939
+
940
+ def _split_app_settings_defaults_with_comments(
941
+ payload: dict[str, Any],
942
+ comments: dict[tuple[str, ...], tuple[str, ...]],
943
+ *,
944
+ module_path: str,
945
+ ) -> tuple[
946
+ dict[str, Any],
947
+ dict[str, Any],
948
+ dict[tuple[str, ...], tuple[str, ...]],
949
+ dict[tuple[str, ...], tuple[str, ...]],
950
+ ]:
951
+ defaults = dict(payload)
952
+ app_comments = dict(comments)
953
+ root_defaults: dict[str, Any] = {}
954
+ root_comments: dict[tuple[str, ...], tuple[str, ...]] = {}
955
+ if "routing" in defaults:
956
+ root_defaults["routing"] = _normalize_routing_defaults(defaults.pop("routing"), module_path=module_path)
957
+ routing_prefix = ("routing",)
958
+ for path, lines in tuple(app_comments.items()):
959
+ if path == routing_prefix or path[:1] == routing_prefix:
960
+ root_comments[path] = lines
961
+ app_comments.pop(path, None)
962
+ if "commands" in defaults:
963
+ root_defaults["commands"] = _normalize_commands_defaults(defaults.pop("commands"), module_path=module_path)
964
+ commands_prefix = ("commands",)
965
+ for path, lines in tuple(app_comments.items()):
966
+ if path == commands_prefix or path[:1] == commands_prefix:
967
+ root_comments[path] = lines
968
+ app_comments.pop(path, None)
969
+ if "apps" in defaults:
970
+ root_defaults["apps"] = _normalize_apps_defaults(defaults.pop("apps"), module_path=module_path)
971
+ apps_prefix = ("apps",)
972
+ for path, lines in tuple(app_comments.items()):
973
+ if path == apps_prefix or path[:1] == apps_prefix:
974
+ root_comments[path] = lines
975
+ app_comments.pop(path, None)
976
+ return defaults, root_defaults, app_comments, root_comments
977
+
978
+
979
+ def _normalize_routing_defaults(payload: Any, *, module_path: str) -> dict[str, Any]:
980
+ if not isinstance(payload, dict):
981
+ raise PzError(
982
+ code="PZ_APP_DEFAULTS_INVALID",
983
+ message=(
984
+ f"App default settings file '{module_path}/configs/default.jsonc' field "
985
+ "'routing' must define an object payload."
986
+ ),
987
+ stage="cli",
988
+ owner=_OWNER,
989
+ action="load_app_settings_defaults",
990
+ )
991
+ out: dict[str, Any] = {}
992
+ for raw_key, raw_value in payload.items():
993
+ key = str(raw_key or "").strip()
994
+ if key not in _ROUTING_DEFAULT_KEYS:
995
+ raise PzError(
996
+ code="PZ_APP_DEFAULTS_INVALID",
997
+ message=(
998
+ f"App default settings file '{module_path}/configs/default.jsonc' field "
999
+ f"'routing.{key}' is not supported."
1000
+ ),
1001
+ stage="cli",
1002
+ owner=_OWNER,
1003
+ action="load_app_settings_defaults",
1004
+ )
1005
+ out[key] = raw_value
1006
+ return out
1007
+
1008
+
1009
+ def _normalize_commands_defaults(payload: Any, *, module_path: str) -> dict[str, Any]:
1010
+ if not isinstance(payload, dict):
1011
+ raise PzError(
1012
+ code="PZ_APP_DEFAULTS_INVALID",
1013
+ message=(
1014
+ f"App default settings file '{module_path}/configs/default.jsonc' field "
1015
+ "'commands' must define an object payload."
1016
+ ),
1017
+ stage="cli",
1018
+ owner=_OWNER,
1019
+ action="load_app_settings_defaults",
1020
+ )
1021
+ return dict(payload)
1022
+
1023
+
1024
+ def _normalize_apps_defaults(payload: Any, *, module_path: str) -> dict[str, Any]:
1025
+ if not isinstance(payload, dict):
1026
+ raise PzError(
1027
+ code="PZ_APP_DEFAULTS_INVALID",
1028
+ message=(
1029
+ f"App default settings file '{module_path}/configs/default.jsonc' field "
1030
+ "'apps' must define an object payload."
1031
+ ),
1032
+ stage="cli",
1033
+ owner=_OWNER,
1034
+ action="load_app_settings_defaults",
1035
+ )
1036
+ return dict(payload)
1037
+
1038
+
1039
+ def _should_refresh_root_default(path: tuple[str, ...]) -> bool:
1040
+ if path and path[0] in _ROOT_DEFAULT_REFRESH_KEYS:
1041
+ return True
1042
+ if len(path) >= 4 and path[0] == "apps" and path[2] == "sources" and path[3] == "source_mode":
1043
+ return True
1044
+ return False
1045
+
1046
+
1047
+ def _extract_default_jsonc_comments(text: str) -> dict[tuple[str, ...], tuple[str, ...]]:
1048
+ comments: dict[tuple[str, ...], tuple[str, ...]] = {}
1049
+ stack: list[tuple[str, int]] = []
1050
+ pending: list[str] = []
1051
+ for raw_line in text.splitlines():
1052
+ line = raw_line.strip()
1053
+ if not line:
1054
+ pending = []
1055
+ continue
1056
+ if line.startswith("//") and not line.startswith("// @pz:"):
1057
+ pending.append(line[2:].strip())
1058
+ continue
1059
+ key = _jsonc_object_key_from_line(line)
1060
+ if key is not None:
1061
+ depth = _leading_space_count(raw_line) // 2
1062
+ while stack and stack[-1][1] >= depth:
1063
+ stack.pop()
1064
+ path = tuple(item[0] for item in stack) + (key,)
1065
+ if pending:
1066
+ comments[path] = tuple(item for item in pending if item)
1067
+ pending = []
1068
+ if _line_opens_object(line):
1069
+ stack.append((key, depth))
1070
+ if _line_closes_object(line):
1071
+ while stack and stack[-1][1] >= depth:
1072
+ stack.pop()
1073
+ continue
1074
+ pending = []
1075
+ if _line_closes_object(line):
1076
+ depth = _leading_space_count(raw_line) // 2
1077
+ while stack and stack[-1][1] >= depth:
1078
+ stack.pop()
1079
+ return comments
1080
+
1081
+
1082
+ def _leading_space_count(value: str) -> int:
1083
+ return len(value) - len(value.lstrip(" "))
1084
+
1085
+
1086
+ def _jsonc_object_key_from_line(line: str) -> str | None:
1087
+ if not line.startswith('"'):
1088
+ return None
1089
+ escaped = False
1090
+ for index in range(1, len(line)):
1091
+ char = line[index]
1092
+ if escaped:
1093
+ escaped = False
1094
+ continue
1095
+ if char == "\\":
1096
+ escaped = True
1097
+ continue
1098
+ if char != '"':
1099
+ continue
1100
+ rest = line[index + 1 :].lstrip()
1101
+ if not rest.startswith(":"):
1102
+ return None
1103
+ try:
1104
+ key = json.loads(line[: index + 1])
1105
+ except Exception:
1106
+ return None
1107
+ return str(key)
1108
+ return None
1109
+
1110
+
1111
+ def _line_opens_object(line: str) -> bool:
1112
+ return line.rstrip().endswith("{") or line.rstrip().endswith("{,")
1113
+
1114
+
1115
+ def _line_closes_object(line: str) -> bool:
1116
+ stripped = line.lstrip()
1117
+ return stripped.startswith("}") or stripped.startswith("]")
1118
+
1119
+
1120
+ def _load_app_settings_secret_template(module_path: str) -> str:
1121
+ package_root = resolve_package_root(module_path)
1122
+ if package_root is None:
1123
+ return ""
1124
+ default_env_path = package_root / "configs" / "default.env"
1125
+ if not default_env_path.is_file():
1126
+ return ""
1127
+ return default_env_path.read_text(encoding="utf-8")
1128
+
1129
+
1130
+ def _flatten_payload_paths(payload: dict[str, Any], prefix: tuple[str, ...] = ()) -> set[tuple[str, ...]]:
1131
+ paths: set[tuple[str, ...]] = set()
1132
+ for key, value in payload.items():
1133
+ path = (*prefix, str(key).strip().lower())
1134
+ paths.add(path)
1135
+ if isinstance(value, dict):
1136
+ paths.update(_flatten_payload_paths(value, path))
1137
+ return paths
1138
+
1139
+
1140
+ def _is_secret_template_path(path: tuple[str, ...]) -> bool:
1141
+ normalized_path = "__".join(path).lower()
1142
+ return any(token in normalized_path for token in _SECRET_TEMPLATE_KEY_TOKENS)
1143
+
1144
+
1145
+ def _validate_app_settings_secret_template(
1146
+ *,
1147
+ app_id: str,
1148
+ module_path: str,
1149
+ defaults: dict[str, Any],
1150
+ secret_template: str,
1151
+ ) -> None:
1152
+ assignments = _parse_env_template_assignments(secret_template)
1153
+ if not assignments:
1154
+ return
1155
+ allowed_paths = _flatten_payload_paths(defaults)
1156
+ prefix = app_id.replace("-", "_") + "__"
1157
+ for key, _value in assignments:
1158
+ if not key.startswith(prefix):
1159
+ raise PzError(
1160
+ code="PZ_APP_DEFAULT_ENV_INVALID",
1161
+ message=(
1162
+ f"App secret template key '{key}' must start with '{prefix}' "
1163
+ f"for app '{app_id}'."
1164
+ ),
1165
+ stage="cli",
1166
+ owner=_OWNER,
1167
+ action="load_app_settings_secret_template",
1168
+ )
1169
+ nested_path = tuple(part.strip().lower() for part in key[len(prefix) :].split("__") if part.strip())
1170
+ if not nested_path or nested_path not in allowed_paths:
1171
+ raise PzError(
1172
+ code="PZ_APP_DEFAULT_ENV_INVALID",
1173
+ message=(
1174
+ f"App secret template key '{key}' is not backed by "
1175
+ f"{module_path}/configs/default.jsonc."
1176
+ ),
1177
+ stage="cli",
1178
+ owner=_OWNER,
1179
+ action="load_app_settings_secret_template",
1180
+ )
1181
+ if not _is_secret_template_path(nested_path):
1182
+ raise PzError(
1183
+ code="PZ_APP_DEFAULT_ENV_INVALID",
1184
+ message=(
1185
+ f"App secret template key '{key}' must target a password, secret, token, "
1186
+ "key, or webhook setting declared by the app defaults."
1187
+ ),
1188
+ stage="cli",
1189
+ owner=_OWNER,
1190
+ action="load_app_settings_secret_template",
1191
+ )
1192
+
1193
+
1194
+ def _load_app_settings_normalizer(module_path: str) -> SettingsPayloadNormalizer | None:
1195
+ settings_module = import_app_settings_module(
1196
+ module_path,
1197
+ action="load_app_settings_normalizer",
1198
+ )
1199
+ if settings_module is None:
1200
+ return None
1201
+ normalizer = getattr(settings_module, "normalize_settings_payload", None)
1202
+ if normalizer is None:
1203
+ normalizer = getattr(settings_module, "_normalize_settings_payload", None)
1204
+ if normalizer is None:
1205
+ return None
1206
+ if not callable(normalizer):
1207
+ raise PzError(
1208
+ code="PZ_APP_SETTINGS_NORMALIZER_INVALID",
1209
+ message=(
1210
+ f"App settings entrypoint '{module_path}.settings' must expose "
1211
+ "'normalize_settings_payload()' or '_normalize_settings_payload()' as a callable."
1212
+ ),
1213
+ stage="cli",
1214
+ owner=_OWNER,
1215
+ action="load_app_settings_normalizer",
1216
+ )
1217
+ return normalizer
1218
+
1219
+
1220
+ def _resolve_target_sources(target: str | None, sources: tuple[AppSettingsSource, ...]) -> tuple[AppSettingsSource, ...]:
1221
+ if target is None:
1222
+ return sources
1223
+ token = str(target).strip()
1224
+ if not token:
1225
+ raise PzError(
1226
+ code="PZ_SETTINGS_TARGET_INVALID",
1227
+ message="target must be a non-empty app selector when provided.",
1228
+ stage="cli",
1229
+ owner=_OWNER,
1230
+ action="update_settings",
1231
+ )
1232
+ if token.startswith("apps."):
1233
+ prefixed_matches = tuple(item for item in sources if item.module_path == token or item.module_path.startswith(token + "."))
1234
+ if prefixed_matches:
1235
+ return prefixed_matches
1236
+ exact_matches = tuple(
1237
+ item
1238
+ for item in sources
1239
+ if item.app_id == token or item.module_path == token or item.module_path.rsplit(".", 1)[-1] == token
1240
+ )
1241
+ if exact_matches:
1242
+ return exact_matches
1243
+ raise PzError(
1244
+ code="PZ_SETTINGS_TARGET_NOT_FOUND",
1245
+ message=f"No app settings source matched target '{token}'.",
1246
+ stage="cli",
1247
+ owner=_OWNER,
1248
+ action="update_settings",
1249
+ )
1250
+
1251
+
1252
+ def _load_existing_document(path: Path) -> dict[str, Any]:
1253
+ return load_jsonc_file(
1254
+ path,
1255
+ code="PZ_PROJECT_CONFIG_INVALID",
1256
+ owner=_OWNER,
1257
+ action="read_project_jsonc",
1258
+ )
1259
+
1260
+
1261
+ def _build_initialized_payload(
1262
+ project_dir: Path,
1263
+ current_payload: dict[str, Any],
1264
+ sources: tuple[AppSettingsSource, ...],
1265
+ ) -> dict[str, Any]:
1266
+ next_payload = _build_project_payload_with_defaults(current_payload)
1267
+ installed_apps = _managed_installed_app_entries(
1268
+ project_dir,
1269
+ current_payload,
1270
+ sources,
1271
+ preserve_existing=False,
1272
+ )
1273
+ next_payload["installed_apps"] = installed_apps
1274
+ next_payload["apps"] = _managed_app_settings_payload(
1275
+ project_dir,
1276
+ current_payload,
1277
+ sources,
1278
+ installed_apps=installed_apps,
1279
+ )
1280
+ _apply_app_root_defaults(next_payload, sources, preserve_existing=False)
1281
+ next_payload["middlewares"] = _managed_middlewares_payload(
1282
+ project_dir,
1283
+ current_payload,
1284
+ next_payload,
1285
+ preserve_existing=False,
1286
+ )
1287
+ return _ordered_root_payload(next_payload)
1288
+
1289
+
1290
+ def _build_updated_payload(
1291
+ project_dir: Path,
1292
+ current_payload: dict[str, Any],
1293
+ sources: tuple[AppSettingsSource, ...],
1294
+ all_sources: tuple[AppSettingsSource, ...],
1295
+ *,
1296
+ reset_middlewares_order: bool,
1297
+ ) -> dict[str, Any]:
1298
+ next_payload = _build_project_payload_with_defaults(current_payload)
1299
+ installed_apps = _managed_installed_app_entries(
1300
+ project_dir,
1301
+ current_payload,
1302
+ all_sources,
1303
+ preserve_existing=True,
1304
+ )
1305
+ next_payload["installed_apps"] = installed_apps
1306
+
1307
+ next_payload["apps"] = _managed_app_settings_payload(
1308
+ project_dir,
1309
+ current_payload,
1310
+ sources,
1311
+ installed_apps=installed_apps,
1312
+ )
1313
+ _apply_app_root_defaults(next_payload, sources, preserve_existing=True)
1314
+ next_payload["middlewares"] = _managed_middlewares_payload(
1315
+ project_dir,
1316
+ current_payload,
1317
+ next_payload,
1318
+ preserve_existing=not reset_middlewares_order,
1319
+ )
1320
+ return _ordered_root_payload(next_payload)
1321
+
1322
+
1323
+ def _apply_app_root_defaults(
1324
+ payload: dict[str, Any],
1325
+ sources: tuple[AppSettingsSource, ...],
1326
+ *,
1327
+ preserve_existing: bool,
1328
+ ) -> None:
1329
+ for source in sorted(sources, key=lambda item: (item.module_path, item.app_id)):
1330
+ defaults = source.root_defaults if isinstance(source.root_defaults, dict) else {}
1331
+ if not defaults:
1332
+ continue
1333
+ _merge_root_defaults(
1334
+ payload,
1335
+ defaults,
1336
+ source=source.module_path,
1337
+ preserve_existing=preserve_existing,
1338
+ )
1339
+
1340
+
1341
+ def _merge_root_defaults(
1342
+ payload: dict[str, Any],
1343
+ defaults: dict[str, Any],
1344
+ *,
1345
+ source: str,
1346
+ preserve_existing: bool,
1347
+ path: tuple[str, ...] = (),
1348
+ ) -> None:
1349
+ for raw_key, raw_value in defaults.items():
1350
+ key = str(raw_key)
1351
+ current_path = path + (key,)
1352
+ if isinstance(raw_value, dict):
1353
+ existing = payload.get(key)
1354
+ if existing is not None and not isinstance(existing, dict):
1355
+ if preserve_existing:
1356
+ continue
1357
+ raise PzError(
1358
+ code="PZ_APP_ROOT_DEFAULT_CONFLICT",
1359
+ message=(
1360
+ f"App root default from '{source}' cannot merge "
1361
+ f"'{'.'.join(current_path)}' into a non-object value."
1362
+ ),
1363
+ stage="cli",
1364
+ owner=_OWNER,
1365
+ action="merge_app_root_defaults",
1366
+ )
1367
+ target = existing if isinstance(existing, dict) else {}
1368
+ payload[key] = target
1369
+ _merge_root_defaults(
1370
+ target,
1371
+ raw_value,
1372
+ source=source,
1373
+ preserve_existing=preserve_existing,
1374
+ path=current_path,
1375
+ )
1376
+ continue
1377
+ refresh_root_default = _should_refresh_root_default(current_path)
1378
+ if key in payload and preserve_existing and not refresh_root_default:
1379
+ continue
1380
+ payload[key] = raw_value
1381
+
1382
+
1383
+ def _build_project_payload_with_defaults(current_payload: dict[str, Any]) -> dict[str, Any]:
1384
+ sanitized_payload = _migrate_root_routing_language_fields(dict(current_payload))
1385
+ sanitized_payload.pop("catalog", None)
1386
+ return _migrate_root_routing_language_fields(
1387
+ _deep_merge_payload(load_project_default_jsonc_payload(), sanitized_payload)
1388
+ )
1389
+
1390
+
1391
+ def _collect_project_jsonc_comments(sources: tuple[AppSettingsSource, ...]) -> dict[tuple[str, ...], tuple[str, ...]]:
1392
+ comments: dict[tuple[str, ...], tuple[str, ...]] = {}
1393
+ for source in sources:
1394
+ for path, lines in (source.comments or {}).items():
1395
+ comments[("apps", source.app_id, *path)] = lines
1396
+ for path, lines in (source.root_comments or {}).items():
1397
+ comments[path] = lines
1398
+ return comments
1399
+
1400
+
1401
+ def _deep_merge_payload(base: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
1402
+ merged: dict[str, Any] = dict(base)
1403
+ for key, value in incoming.items():
1404
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
1405
+ merged[key] = _deep_merge_payload(merged[key], value)
1406
+ continue
1407
+ merged[key] = value
1408
+ return merged
1409
+
1410
+
1411
+ def _migrate_root_routing_language_fields(payload: dict[str, Any]) -> dict[str, Any]:
1412
+ routing = payload.get("routing")
1413
+ if not isinstance(routing, dict) or "supported_languages" not in routing:
1414
+ return payload
1415
+ migrated = dict(routing)
1416
+ legacy_value = migrated.pop("supported_languages", None)
1417
+ if "all_supported_languages" not in migrated:
1418
+ migrated["all_supported_languages"] = legacy_value
1419
+ payload["routing"] = migrated
1420
+ return payload
1421
+
1422
+
1423
+ def _read_installed_apps(payload: dict[str, Any]) -> list[str]:
1424
+ raw = payload.get("installed_apps")
1425
+ if isinstance(raw, list):
1426
+ return [str(item).strip() for item in raw if isinstance(item, str) and str(item).strip()]
1427
+ return []
1428
+
1429
+
1430
+ def _managed_installed_app_entries(
1431
+ project_dir: Path,
1432
+ current_payload: dict[str, Any],
1433
+ sources: tuple[AppSettingsSource, ...],
1434
+ *,
1435
+ preserve_existing: bool,
1436
+ ) -> list[str]:
1437
+ seeded_entries = _seed_installed_app_entries(project_dir, current_payload, sources)
1438
+ if not preserve_existing:
1439
+ return list(seeded_entries)
1440
+ existing = [
1441
+ item
1442
+ for item in _read_installed_apps(current_payload)
1443
+ if _is_formal_app_or_container_entry(project_dir, item)
1444
+ ]
1445
+ if existing:
1446
+ if len(existing) > 1 and any(_is_container_entry(project_dir, item) for item in existing):
1447
+ return list(_conventional_installed_app_order(project_dir, current_payload, existing))
1448
+ return existing
1449
+ return list(seeded_entries)
1450
+
1451
+
1452
+ def _seed_installed_app_entries(
1453
+ project_dir: Path,
1454
+ current_payload: dict[str, Any],
1455
+ sources: tuple[AppSettingsSource, ...],
1456
+ ) -> list[str]:
1457
+ declared = [item for item in _read_installed_apps(current_payload) if _is_discoverable_app_entry(project_dir, item)]
1458
+ discovered = [item.module_path for item in sources if _is_discoverable_app_entry(project_dir, item.module_path)]
1459
+ conventional = _conventional_installed_app_order(project_dir, current_payload, discovered)
1460
+ if declared:
1461
+ merged_entries = list(merge_explicit_app_order(tuple(declared), conventional))
1462
+ return list(_conventional_installed_app_order(project_dir, current_payload, merged_entries))
1463
+ if discovered:
1464
+ return list(conventional)
1465
+ if (project_dir / "apps").is_dir():
1466
+ return list(_conventional_installed_app_order(project_dir, current_payload, ["apps"]))
1467
+ return list(conventional)
1468
+
1469
+
1470
+ def _conventional_installed_app_order(
1471
+ project_dir: Path,
1472
+ current_payload: dict[str, Any],
1473
+ discovered_entries: list[str],
1474
+ ) -> tuple[str, ...]:
1475
+ if not discovered_entries:
1476
+ return ()
1477
+ payload = _build_project_payload_with_defaults(current_payload)
1478
+ payload["installed_apps"] = list(discovered_entries)
1479
+ with active_settings_project(project_dir):
1480
+ settings = compile_settings_snapshot_from_payload(project_dir, payload)
1481
+ return compile_conventional_app_order(settings, project_dir=project_dir)
1482
+
1483
+
1484
+ def _is_discoverable_app_entry(project_dir: Path, module_path: str) -> bool:
1485
+ return _is_formal_app_or_container_entry(project_dir, module_path)
1486
+
1487
+
1488
+ def _is_container_entry(project_dir: Path, module_path: str) -> bool:
1489
+ token = str(module_path or "").strip()
1490
+ if not token:
1491
+ return False
1492
+ if token == "apps":
1493
+ return True
1494
+ expanded = discover_leaf_app_packages(token, project_dir=project_dir)
1495
+ return bool(expanded and expanded != (token,))
1496
+
1497
+
1498
+ def _is_formal_app_or_container_entry(project_dir: Path, module_path: str) -> bool:
1499
+ token = str(module_path or "").strip()
1500
+ if not token:
1501
+ return False
1502
+ if token == "apps":
1503
+ return (project_dir / "apps").is_dir()
1504
+ if "." not in token:
1505
+ if (project_dir / token).is_dir() or (project_dir / "apps" / token).is_dir():
1506
+ return True
1507
+ refs = discover_declared_app_references((token,), project_dir=project_dir)
1508
+ if any(item.import_root is not None for item in refs):
1509
+ return True
1510
+ expanded = tuple(item.module_path for item in refs)
1511
+ if expanded and expanded != (token,):
1512
+ return True
1513
+ package_root = resolve_package_root(token)
1514
+ if package_root is None:
1515
+ return False
1516
+ if (package_root / "apps.py").is_file():
1517
+ return True
1518
+ expanded = discover_leaf_app_packages(token, project_dir=project_dir)
1519
+ return bool(expanded and expanded != (token,))
1520
+
1521
+
1522
+ def _managed_app_settings_payload(
1523
+ project_dir: Path,
1524
+ current_payload: dict[str, Any],
1525
+ sources: tuple[AppSettingsSource, ...],
1526
+ *,
1527
+ installed_apps: list[str],
1528
+ ) -> dict[str, Any]:
1529
+ current_apps = dict(current_payload.get("apps") if isinstance(current_payload.get("apps"), dict) else {})
1530
+ merged_apps: dict[str, Any] = {}
1531
+ source_by_app_id = {item.app_id: item for item in sources}
1532
+ ordered_app_ids = _ordered_app_settings_app_ids(
1533
+ project_dir,
1534
+ installed_apps=installed_apps,
1535
+ current_apps=current_apps,
1536
+ source_by_app_id=source_by_app_id,
1537
+ )
1538
+
1539
+ for app_id in ordered_app_ids:
1540
+ source = source_by_app_id.get(app_id)
1541
+ if source is None:
1542
+ payload = current_apps.get(app_id)
1543
+ if payload is not None:
1544
+ merged_apps[app_id] = payload
1545
+ continue
1546
+ existing = current_apps.get(source.app_id)
1547
+ if isinstance(existing, dict):
1548
+ merged_apps[source.app_id] = _merge_project_app_settings(source, existing)
1549
+ elif existing is not None:
1550
+ merged_apps[source.app_id] = existing
1551
+ else:
1552
+ merged_apps[source.app_id] = _merge_project_app_settings(source, None)
1553
+
1554
+ for item in sources:
1555
+ if item.app_id in merged_apps:
1556
+ continue
1557
+ existing = current_apps.get(item.app_id)
1558
+ if isinstance(existing, dict):
1559
+ merged_apps[item.app_id] = _merge_project_app_settings(item, existing)
1560
+ elif existing is not None:
1561
+ merged_apps[item.app_id] = existing
1562
+ else:
1563
+ merged_apps[item.app_id] = _merge_project_app_settings(item, None)
1564
+
1565
+ for app_id, payload in current_apps.items():
1566
+ if not isinstance(app_id, str) or app_id in merged_apps:
1567
+ continue
1568
+ merged_apps[app_id] = payload
1569
+
1570
+ return merged_apps
1571
+
1572
+
1573
+ def _merge_project_app_settings(source: AppSettingsSource, existing: dict[str, Any] | None) -> dict[str, Any]:
1574
+ existing_payload = dict(existing or {})
1575
+ if source.normalize is None:
1576
+ return _deep_merge_payload(source.defaults, existing_payload)
1577
+ merged_payload = _deep_merge_payload(source.defaults, existing_payload)
1578
+ normalized = source.normalize(merged_payload)
1579
+ if not isinstance(normalized, dict):
1580
+ raise PzError(
1581
+ code="PZ_APP_SETTINGS_NORMALIZER_INVALID",
1582
+ message=f"App settings normalizer for '{source.module_path}' must return a mapping payload.",
1583
+ stage="cli",
1584
+ owner=_OWNER,
1585
+ action="merge_project_app_settings",
1586
+ )
1587
+ return dict(normalized)
1588
+
1589
+
1590
+ def _ordered_app_settings_app_ids(
1591
+ project_dir: Path,
1592
+ *,
1593
+ installed_apps: list[str],
1594
+ current_apps: dict[str, Any],
1595
+ source_by_app_id: dict[str, AppSettingsSource],
1596
+ ) -> tuple[str, ...]:
1597
+ source_by_module_path = {item.module_path: item for item in source_by_app_id.values()}
1598
+ ordered: list[str] = []
1599
+ seen: set[str] = set()
1600
+
1601
+ for ref in discover_declared_app_references(installed_apps, project_dir=project_dir):
1602
+ source = source_by_module_path.get(ref.module_path)
1603
+ if source is None or source.app_id in seen:
1604
+ continue
1605
+ seen.add(source.app_id)
1606
+ ordered.append(source.app_id)
1607
+
1608
+ for app_id in current_apps:
1609
+ if not isinstance(app_id, str) or app_id in seen:
1610
+ continue
1611
+ seen.add(app_id)
1612
+ ordered.append(app_id)
1613
+
1614
+ for app_id in source_by_app_id:
1615
+ if app_id in seen:
1616
+ continue
1617
+ seen.add(app_id)
1618
+ ordered.append(app_id)
1619
+
1620
+ return tuple(ordered)
1621
+
1622
+
1623
+ def _managed_middlewares_payload(
1624
+ project_dir: Path,
1625
+ current_payload: dict[str, Any],
1626
+ payload: dict[str, Any],
1627
+ *,
1628
+ preserve_existing: bool,
1629
+ ) -> list[dict[str, Any]]:
1630
+ generation_payload = _deep_merge_payload({}, payload)
1631
+ generation_payload.pop("middlewares", None)
1632
+ with active_settings_project(project_dir):
1633
+ settings = compile_settings_snapshot_from_payload(project_dir, generation_payload)
1634
+ with active_project_import_root(project_dir, extra_import_roots=settings.import_roots):
1635
+ app_registry = compile_app_registry(settings, project_dir=project_dir, run_init_hooks=False)
1636
+ manifest = compile_conventional_middleware_manifest(settings, app_registry)
1637
+ if preserve_existing:
1638
+ manifest = merge_explicit_middleware_order(_read_middleware_ids(current_payload), manifest)
1639
+ return [_middleware_decl_payload(item) for item in manifest.middlewares]
1640
+
1641
+
1642
+ def _read_middleware_ids(payload: dict[str, Any]) -> tuple[str, ...]:
1643
+ raw = payload.get("middlewares")
1644
+ if not isinstance(raw, list):
1645
+ return ()
1646
+ out: list[str] = []
1647
+ for item in raw:
1648
+ if isinstance(item, dict):
1649
+ middleware_id = str(item.get("id") or "").strip()
1650
+ if middleware_id:
1651
+ out.append(middleware_id)
1652
+ return tuple(out)
1653
+
1654
+
1655
+ def _middleware_decl_payload(item: Any) -> dict[str, Any]:
1656
+ priority = getattr(item, "priority", 100)
1657
+ payload: dict[str, Any] = {
1658
+ "id": str(item.id),
1659
+ "owner": str(item.owner),
1660
+ "factory": str(item.factory),
1661
+ "priority": int(100 if priority is None else priority),
1662
+ }
1663
+ if getattr(item, "replace", None):
1664
+ payload["replace"] = str(item.replace)
1665
+ if getattr(item, "params_model", None):
1666
+ payload["params_model"] = str(item.params_model)
1667
+ if getattr(item, "params", None):
1668
+ payload["params"] = dict(item.params)
1669
+ return payload
1670
+
1671
+
1672
+ def _ordered_root_payload(payload: dict[str, Any]) -> dict[str, Any]:
1673
+ ordered: dict[str, Any] = {}
1674
+ for key in (
1675
+ "installed_apps",
1676
+ "middlewares",
1677
+ "catalog",
1678
+ "project",
1679
+ "routing",
1680
+ "events",
1681
+ "app_replacements",
1682
+ "apps",
1683
+ ):
1684
+ if key in payload:
1685
+ ordered[key] = payload[key]
1686
+ for key, value in payload.items():
1687
+ if key not in ordered:
1688
+ ordered[key] = value
1689
+ return ordered
1690
+
1691
+
1692
+ __all__ = [
1693
+ "AppSettingsSource",
1694
+ "SettingsUpdateDriftResult",
1695
+ "SettingsWriteResult",
1696
+ "discover_settings_sources",
1697
+ "initialize_settings_file",
1698
+ "inspect_settings_update_drift",
1699
+ "update_settings_file",
1700
+ ]