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.
- pz_cli-5.0.0.dist-info/METADATA +51 -0
- pz_cli-5.0.0.dist-info/RECORD +399 -0
- pz_cli-5.0.0.dist-info/WHEEL +4 -0
- pz_cli-5.0.0.dist-info/entry_points.txt +5 -0
- pz_core/__init__.py +64 -0
- pz_core/__main__.py +7 -0
- pz_core/app.py +45 -0
- pz_core/assembly/__init__.py +17 -0
- pz_core/assembly/app_discovery.py +1240 -0
- pz_core/assembly/apps/__init__.py +25 -0
- pz_core/assembly/apps/compiler.py +824 -0
- pz_core/assembly/apps/providers.py +188 -0
- pz_core/assembly/cli/__init__.py +9 -0
- pz_core/assembly/cli/compiler.py +166 -0
- pz_core/assembly/diagnostics/__init__.py +21 -0
- pz_core/assembly/diagnostics/checks.py +257 -0
- pz_core/assembly/diagnostics/dev_sources.py +569 -0
- pz_core/assembly/events/__init__.py +5 -0
- pz_core/assembly/events/compiler.py +219 -0
- pz_core/assembly/extensions/__init__.py +5 -0
- pz_core/assembly/extensions/compiler.py +696 -0
- pz_core/assembly/infra/__init__.py +6 -0
- pz_core/assembly/infra/compiler.py +272 -0
- pz_core/assembly/lifecycle/__init__.py +5 -0
- pz_core/assembly/lifecycle/compiler.py +95 -0
- pz_core/assembly/middlewares/__init__.py +19 -0
- pz_core/assembly/middlewares/compiler.py +508 -0
- pz_core/assembly/project/__init__.py +7 -0
- pz_core/assembly/project/compiler.py +9 -0
- pz_core/assembly/project/context.py +17 -0
- pz_core/assembly/project/discovery.py +227 -0
- pz_core/assembly/resources/__init__.py +19 -0
- pz_core/assembly/resources/compiler.py +310 -0
- pz_core/assembly/resources/settings.py +129 -0
- pz_core/assembly/routes/__init__.py +7 -0
- pz_core/assembly/routes/compiler.py +400 -0
- pz_core/assembly/runtime_manifests/__init__.py +13 -0
- pz_core/assembly/runtime_manifests/compiler.py +159 -0
- pz_core/assembly/settings/__init__.py +17 -0
- pz_core/assembly/settings/compiler.py +786 -0
- pz_core/assembly/settings/host_project_overrides.py +82 -0
- pz_core/assembly/settings/host_runtime_endpoint.py +207 -0
- pz_core/authoring/__init__.py +79 -0
- pz_core/authoring/app_init_service.py +946 -0
- pz_core/authoring/app_versions.py +814 -0
- pz_core/authoring/cli.py +192 -0
- pz_core/authoring/commands.py +1810 -0
- pz_core/authoring/conflicts.py +180 -0
- pz_core/authoring/dev_bindings.py +916 -0
- pz_core/authoring/dev_git.py +1087 -0
- pz_core/authoring/dev_import.py +1061 -0
- pz_core/authoring/dev_manifest.py +522 -0
- pz_core/authoring/dev_sources.py +1638 -0
- pz_core/authoring/env_cli.py +454 -0
- pz_core/authoring/files.py +109 -0
- pz_core/authoring/ide_projection.py +1129 -0
- pz_core/authoring/naming.py +58 -0
- pz_core/authoring/official_dev_sources.py +432 -0
- pz_core/authoring/official_sources.json +156 -0
- pz_core/authoring/orchestration_service.py +558 -0
- pz_core/authoring/project_default.jsonc +53 -0
- pz_core/authoring/project_defaults.py +88 -0
- pz_core/authoring/project_metadata_service.py +199 -0
- pz_core/authoring/project_settings_checks.py +51 -0
- pz_core/authoring/project_settings_cli.py +299 -0
- pz_core/authoring/project_settings_service.py +1700 -0
- pz_core/authoring/project_state.py +399 -0
- pz_core/authoring/publish_git.py +423 -0
- pz_core/authoring/publish_lock.py +188 -0
- pz_core/authoring/publish_source.py +195 -0
- pz_core/authoring/release.py +1382 -0
- pz_core/authoring/repo_keys.py +29 -0
- pz_core/authoring/root_cli.py +28 -0
- pz_core/authoring/scaffold_service.py +325 -0
- pz_core/authoring/sync_service.py +787 -0
- pz_core/authoring/templates/app/__init__.py +1 -0
- pz_core/authoring/templates/app/api.py +25 -0
- pz_core/authoring/templates/app/apps.py +31 -0
- pz_core/authoring/templates/app/auth_package_init.py +14 -0
- pz_core/authoring/templates/app/auth_readme.py +29 -0
- pz_core/authoring/templates/app/configs_package_init.py +14 -0
- pz_core/authoring/templates/app/default_env.py +18 -0
- pz_core/authoring/templates/app/default_jsonc.py +25 -0
- pz_core/authoring/templates/app/locales_readme.py +25 -0
- pz_core/authoring/templates/app/migrations_readme.py +19 -0
- pz_core/authoring/templates/app/models.py +14 -0
- pz_core/authoring/templates/app/package_init.py +8 -0
- pz_core/authoring/templates/app/pages_base.py +161 -0
- pz_core/authoring/templates/app/pages_readme.py +25 -0
- pz_core/authoring/templates/app/public.py +31 -0
- pz_core/authoring/templates/app/pyproject.py +77 -0
- pz_core/authoring/templates/app/readme.py +74 -0
- pz_core/authoring/templates/app/settings.py +46 -0
- pz_core/authoring/templates/app/static_app_js.py +24 -0
- pz_core/authoring/templates/app/static_readme.py +31 -0
- pz_core/authoring/templates/app/template_config_page.py +23 -0
- pz_core/authoring/templates/app/template_index_html.py +28 -0
- pz_core/authoring/templates/app/templates_readme.py +32 -0
- pz_core/authoring/templates/app/urls.py +36 -0
- pz_core/authoring/templates/app/views.py +36 -0
- pz_core/authoring/templates/project/__init__.py +1 -0
- pz_core/authoring/templates/project/agents.py +51 -0
- pz_core/authoring/templates/project/apps_container_readme.py +37 -0
- pz_core/authoring/templates/project/main_py.py +59 -0
- pz_core/authoring/templates/project/project_env.py +19 -0
- pz_core/authoring/templates/project/pyproject.py +26 -0
- pz_core/authoring/templates/project/pz_env_env.py +65 -0
- pz_core/authoring/templates/project/pz_env_jsonc.py +69 -0
- pz_core/authoring/templates/project/pz_jsonc.py +12 -0
- pz_core/authoring/templates/project/readme.py +94 -0
- pz_core/authoring/templates/project/root_gitkeep.py +8 -0
- pz_core/authoring/templates/shared/__init__.py +5 -0
- pz_core/authoring/templates/shared/text.py +10 -0
- pz_core/authoring/venv_service.py +87 -0
- pz_core/authoring/workspace.py +167 -0
- pz_core/cli.py +291 -0
- pz_core/cli_options.py +74 -0
- pz_core/core/__init__.py +3 -0
- pz_core/core/errors/__init__.py +5 -0
- pz_core/core/errors/models.py +117 -0
- pz_core/core/events/__init__.py +5 -0
- pz_core/core/events/registry.py +54 -0
- pz_core/core/extensions/__init__.py +51 -0
- pz_core/core/extensions/registry.py +790 -0
- pz_core/core/health/__init__.py +6 -0
- pz_core/core/health/contracts.py +10 -0
- pz_core/core/middlewares/__init__.py +6 -0
- pz_core/core/middlewares/models.py +24 -0
- pz_core/core/routing/__init__.py +5 -0
- pz_core/core/routing/models.py +21 -0
- pz_core/core/settings_contracts.py +25 -0
- pz_core/docs/README.md +20 -0
- pz_core/docs/distribution-README.md +438 -0
- pz_core/docs/pz/345/221/275/344/273/244/346/200/273/350/247/210.md +1011 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- pz_core/docs/pz/350/247/204/350/214/203/344/270/216/344/275/277/347/224/250/README.md +98 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- pz_core/docs//345/217/221/347/211/210/345/211/215/346/270/205/345/215/225-4.0.11.md +84 -0
- pz_core/docs//346/226/260/346/211/213/346/214/207/345/215/227.md +422 -0
- pz_core/infra/__init__.py +17 -0
- pz_core/infra/db/__init__.py +23 -0
- pz_core/infra/db/contracts.py +73 -0
- pz_core/infra/health/__init__.py +5 -0
- pz_core/infra/health/contracts.py +8 -0
- pz_core/infra/kv/__init__.py +5 -0
- pz_core/infra/kv/contracts.py +17 -0
- pz_core/infra/observability/__init__.py +23 -0
- pz_core/infra/observability/contracts.py +142 -0
- pz_core/infra/resilience/__init__.py +5 -0
- pz_core/infra/resilience/contracts.py +17 -0
- pz_core/infra/resources/__init__.py +5 -0
- pz_core/infra/resources/contracts.py +16 -0
- pz_core/infra/settings/__init__.py +17 -0
- pz_core/infra/settings/models.py +81 -0
- pz_core/infra/storage/__init__.py +5 -0
- pz_core/infra/storage/ports.py +27 -0
- pz_core/project_root_commands.py +282 -0
- pz_core/project_runtime_cli.py +344 -0
- pz_core/project_sources.py +253 -0
- pz_core/pz_launcher.py +209 -0
- pz_core/root_cli.py +70 -0
- pz_core/runtime/__init__.py +229 -0
- pz_core/runtime/access.py +86 -0
- pz_core/runtime/adapters/__init__.py +5 -0
- pz_core/runtime/adapters/base.py +18 -0
- pz_core/runtime/asyncio_bridge.py +18 -0
- pz_core/runtime/bindings/__init__.py +5 -0
- pz_core/runtime/bindings/container.py +50 -0
- pz_core/runtime/cli/__init__.py +5 -0
- pz_core/runtime/cli/_args.py +67 -0
- pz_core/runtime/cli/app_commands.py +417 -0
- pz_core/runtime/cli/check.py +214 -0
- pz_core/runtime/cli/db.py +297 -0
- pz_core/runtime/cli/main.py +89 -0
- pz_core/runtime/cli/resources.py +280 -0
- pz_core/runtime/discovery.py +23 -0
- pz_core/runtime/environment_integrity.py +157 -0
- pz_core/runtime/errors.py +5 -0
- pz_core/runtime/events.py +87 -0
- pz_core/runtime/facades/__init__.py +38 -0
- pz_core/runtime/facades/_capabilities.py +46 -0
- pz_core/runtime/facades/cache.py +103 -0
- pz_core/runtime/facades/database.py +71 -0
- pz_core/runtime/facades/events.py +380 -0
- pz_core/runtime/facades/health.py +101 -0
- pz_core/runtime/facades/mail.py +121 -0
- pz_core/runtime/facades/observability.py +102 -0
- pz_core/runtime/facades/providers.py +75 -0
- pz_core/runtime/facades/resources.py +52 -0
- pz_core/runtime/facades/storage.py +296 -0
- pz_core/runtime/health/__init__.py +5 -0
- pz_core/runtime/health/service.py +102 -0
- pz_core/runtime/host.py +188 -0
- pz_core/runtime/host_runner.py +282 -0
- pz_core/runtime/http/__init__.py +20 -0
- pz_core/runtime/http/app.py +576 -0
- pz_core/runtime/http_timing.py +173 -0
- pz_core/runtime/invocation.py +29 -0
- pz_core/runtime/json_safe.py +47 -0
- pz_core/runtime/lifecycle/__init__.py +25 -0
- pz_core/runtime/lifecycle/manager.py +533 -0
- pz_core/runtime/plan.py +214 -0
- pz_core/runtime/plan_context.py +33 -0
- pz_core/runtime/plan_snapshot.py +177 -0
- pz_core/runtime/registries/__init__.py +27 -0
- pz_core/runtime/registries/capabilities.py +101 -0
- pz_core/runtime/registries/providers.py +396 -0
- pz_core/runtime/resource_assets.py +344 -0
- pz_core/runtime/run_cli.py +211 -0
- pz_core/runtime/settings.py +888 -0
- pz_core/runtime/worker/__init__.py +21 -0
- pz_core/runtime/worker/engine.py +921 -0
- pz_core/runtime/worker/runtime.py +25 -0
- pz_core/testing/__init__.py +15 -0
- pz_core/testing/doc_route_contracts.py +305 -0
- pz_core/utils/__init__.py +44 -0
- pz_core/utils/env.py +65 -0
- pz_core/utils/project_jsonc/__init__.py +29 -0
- pz_core/utils/project_jsonc/document.py +420 -0
- pz_core/utils/project_settings.py +99 -0
- pz_foundation/__init__.py +50 -0
- pz_foundation/_contracts.py +81 -0
- pz_foundation/_errors.py +34 -0
- pz_foundation/api.py +14 -0
- pz_foundation/apps.py +127 -0
- pz_foundation/auth/__init__.py +1 -0
- pz_foundation/cache_control.py +233 -0
- pz_foundation/cli/__init__.py +2 -0
- pz_foundation/cli/workers.py +565 -0
- pz_foundation/configs/__init__.py +0 -0
- pz_foundation/configs/default.env +15 -0
- pz_foundation/configs/default.jsonc +452 -0
- pz_foundation/db/__init__.py +94 -0
- pz_foundation/db/aerich_executor.py +664 -0
- pz_foundation/db/config.py +358 -0
- pz_foundation/db/connection_manager.py +689 -0
- pz_foundation/db/connection_probe.py +204 -0
- pz_foundation/db/database.py +1291 -0
- pz_foundation/db/deps.py +47 -0
- pz_foundation/db/lifecycle_status.py +85 -0
- pz_foundation/db/migrations.py +477 -0
- pz_foundation/db/mysql.py +57 -0
- pz_foundation/db/postgresql.py +409 -0
- pz_foundation/db/production_pool_fallback.py +143 -0
- pz_foundation/db/public.py +45 -0
- pz_foundation/db/session.py +369 -0
- pz_foundation/db/sqlite.py +15 -0
- pz_foundation/db/tortoise_manager.py +724 -0
- pz_foundation/db/tortoise_psycopg.py +148 -0
- pz_foundation/docs/README.md +18 -0
- pz_foundation/docs/distribution-README.md +319 -0
- 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
- 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
- pz_foundation/docs//350/256/276/350/256/241/346/226/207/346/241/243.md +288 -0
- 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
- pz_foundation/health.py +105 -0
- pz_foundation/host/__init__.py +11 -0
- pz_foundation/host/builder.py +34 -0
- pz_foundation/host/cache_runtime.py +44 -0
- pz_foundation/host/db/__init__.py +17 -0
- pz_foundation/host/db/aerich_config.py +36 -0
- pz_foundation/host/db/config.py +106 -0
- pz_foundation/host/db/connection_manager.py +48 -0
- pz_foundation/host/db/deps.py +5 -0
- pz_foundation/host/db/orm_retry_patch.py +566 -0
- pz_foundation/host/db/request_adapter.py +325 -0
- pz_foundation/host/db/router.py +28 -0
- pz_foundation/host/db/session.py +284 -0
- pz_foundation/host/db/tortoise_manager.py +10 -0
- pz_foundation/host/idempotency.py +132 -0
- pz_foundation/host/lifespan.py +247 -0
- pz_foundation/host/redis_runtime.py +100 -0
- pz_foundation/host/web.py +790 -0
- pz_foundation/infra_provider.py +236 -0
- pz_foundation/kv/__init__.py +25 -0
- pz_foundation/kv/cache.py +1166 -0
- pz_foundation/kv/deps.py +84 -0
- pz_foundation/kv/public.py +25 -0
- pz_foundation/kv/redis_manager.py +1531 -0
- pz_foundation/lazy_exports.py +43 -0
- pz_foundation/lazy_imports.py +124 -0
- pz_foundation/lifecycle.py +392 -0
- pz_foundation/managed_workers.py +1117 -0
- pz_foundation/middlewares/__init__.py +26 -0
- pz_foundation/middlewares/_settings.py +250 -0
- pz_foundation/middlewares/analytics.py +124 -0
- pz_foundation/middlewares/cache_control.py +156 -0
- pz_foundation/middlewares/compression.py +202 -0
- pz_foundation/middlewares/cors.py +62 -0
- pz_foundation/middlewares/geo_country.py +44 -0
- pz_foundation/middlewares/inner_router.py +50 -0
- pz_foundation/middlewares/rate_limited/__init__.py +3 -0
- pz_foundation/middlewares/rate_limited/key_limiter.py +115 -0
- pz_foundation/middlewares/request_logging.py +104 -0
- pz_foundation/middlewares/route_owner.py +309 -0
- pz_foundation/middlewares/security_csrf.py +44 -0
- pz_foundation/middlewares/security_headers/__init__.py +4 -0
- pz_foundation/middlewares/security_headers/conflicts.py +44 -0
- pz_foundation/middlewares/security_headers/csp_nonce.py +200 -0
- pz_foundation/middlewares/security_headers/security_headers.py +44 -0
- pz_foundation/migrations/__init__.py +1 -0
- pz_foundation/migrations/pz_foundation/__init__.py +1 -0
- pz_foundation/models.py +22 -0
- pz_foundation/notifications/__init__.py +23 -0
- pz_foundation/notifications/email.py +542 -0
- pz_foundation/notifications/public.py +31 -0
- pz_foundation/observability/__init__.py +71 -0
- pz_foundation/observability/http.py +34 -0
- pz_foundation/observability/logger/__init__.py +49 -0
- pz_foundation/observability/logger/_bot_settings.py +126 -0
- pz_foundation/observability/logger/bot_message.py +592 -0
- pz_foundation/observability/logger/core.py +385 -0
- pz_foundation/observability/logger/delivery_audit.py +84 -0
- pz_foundation/observability/logger/feishu_bot.py +199 -0
- pz_foundation/observability/logger/telegram_bot.py +194 -0
- pz_foundation/observability/metrics.py +365 -0
- pz_foundation/observability/runtime.py +68 -0
- pz_foundation/public.py +405 -0
- pz_foundation/resources/__init__.py +12 -0
- pz_foundation/resources/builder.py +900 -0
- pz_foundation/resources/images.py +363 -0
- pz_foundation/root_cli.py +20 -0
- pz_foundation/routing/__init__.py +203 -0
- pz_foundation/routing/canonical.py +486 -0
- pz_foundation/routing/convertors.py +82 -0
- pz_foundation/routing/declarations.py +843 -0
- pz_foundation/routing/exports.py +148 -0
- pz_foundation/routing/loaders.py +138 -0
- pz_foundation/routing/manifest.py +215 -0
- pz_foundation/routing/manifest_provider.py +47 -0
- pz_foundation/routing/mounts.py +101 -0
- pz_foundation/routing/ordering.py +172 -0
- pz_foundation/routing/registry.py +133 -0
- pz_foundation/routing/utils.py +28 -0
- pz_foundation/runtime.py +234 -0
- pz_foundation/settings.py +253 -0
- pz_foundation/settings_normalization.py +130 -0
- pz_foundation/storage/__init__.py +25 -0
- pz_foundation/storage/local.py +209 -0
- pz_foundation/storage/public.py +106 -0
- pz_foundation/storage/s3.py +116 -0
- pz_foundation/storage/service.py +234 -0
- pz_foundation/urls.py +19 -0
- pz_foundation/utils/__init__.py +6 -0
- pz_foundation/utils/merge.py +160 -0
- pz_foundation/worker_replicas.py +55 -0
- pz_settings_center/__init__.py +5 -0
- pz_settings_center/api/__init__.py +5 -0
- pz_settings_center/api/runtime_overrides.py +360 -0
- pz_settings_center/apps.py +28 -0
- pz_settings_center/configs/__init__.py +0 -0
- pz_settings_center/configs/default.env +2 -0
- pz_settings_center/configs/default.jsonc +1 -0
- pz_settings_center/docs/01-settings_center/350/203/275/345/212/233/350/276/271/347/225/214.md +124 -0
- pz_settings_center/docs/README.md +12 -0
- pz_settings_center/docs/distribution-README.md +150 -0
- pz_settings_center/docs//350/256/276/350/256/241/346/226/207/346/241/243.md +48 -0
- pz_settings_center/integration_payload.py +18 -0
- pz_settings_center/project_helpers.py +405 -0
- pz_settings_center/public.py +139 -0
- pz_settings_center/routing_helpers.py +73 -0
- pz_settings_center/runtime_override_provider.py +61 -0
- pz_settings_center/typed_settings.py +324 -0
- pz_settings_center/urls.py +9 -0
|
@@ -0,0 +1,1638 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tomllib
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pz_core.project_sources import resolve_sources_manifest_path
|
|
14
|
+
from pz_core.runtime.errors import PzError
|
|
15
|
+
|
|
16
|
+
from .dev_bindings import (
|
|
17
|
+
BINDING_CONFLICT_CODE,
|
|
18
|
+
LinkMutation,
|
|
19
|
+
commit_binding_projection,
|
|
20
|
+
discard_staged_projection,
|
|
21
|
+
plan_binding_projection,
|
|
22
|
+
plan_binding_removals,
|
|
23
|
+
rollback_binding_projection,
|
|
24
|
+
stage_binding_projection,
|
|
25
|
+
)
|
|
26
|
+
from .dev_git import BaseBranch, DevGitService, GitBranchPlan, PreparedBranch
|
|
27
|
+
from .dev_manifest import (
|
|
28
|
+
DevBinding,
|
|
29
|
+
DevManifest,
|
|
30
|
+
DevRepo,
|
|
31
|
+
load_dev_manifest,
|
|
32
|
+
manifest_bytes,
|
|
33
|
+
validate_dev_manifest,
|
|
34
|
+
)
|
|
35
|
+
from .naming import derive_default_local_app_slug
|
|
36
|
+
from .official_dev_sources import ResolvedSource, resolve_source_locator
|
|
37
|
+
from .project_state import (
|
|
38
|
+
_normalize_business_app_id,
|
|
39
|
+
_run_git_ref_check,
|
|
40
|
+
_validate_business_app_git_ref,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_OWNER = "pz_core.authoring.dev_sources"
|
|
45
|
+
_TRANSACTION_FAILED = "PZ_DEV_TRANSACTION_FAILED"
|
|
46
|
+
_REQUEST_INVALID = "PZ_DEV_REQUEST_INVALID"
|
|
47
|
+
_MANIFEST_MISSING = "PZ_DEV_MANIFEST_MISSING"
|
|
48
|
+
_SOURCE_CONFLICT = "PZ_DEV_SOURCE_CONFLICT"
|
|
49
|
+
_SOURCE_NOT_GIT = "PZ_DEV_SOURCE_NOT_GIT"
|
|
50
|
+
_SOURCE_LAYOUT_INVALID = "PZ_DEV_SOURCE_LAYOUT_INVALID"
|
|
51
|
+
_OS_REPLACE = os.replace
|
|
52
|
+
|
|
53
|
+
Operation = Literal["add", "link", "branch", "init", "update", "remove", "unlink"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class AddDevSourceRequest:
|
|
58
|
+
project_dir: Path
|
|
59
|
+
locators: tuple[str, ...]
|
|
60
|
+
base_branch: BaseBranch = "develop"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class BranchDevSourceRequest:
|
|
65
|
+
project_dir: Path
|
|
66
|
+
locator: str
|
|
67
|
+
base_branch: BaseBranch = "develop"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class InitDevSourcesRequest:
|
|
72
|
+
project_dir: Path
|
|
73
|
+
app_name: str | None = None
|
|
74
|
+
locators: tuple[str, ...] = ("pz",)
|
|
75
|
+
base_branch: BaseBranch = "develop"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class UpdateDevSourceRequest:
|
|
80
|
+
project_dir: Path
|
|
81
|
+
locator: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class RemoveDevSourceRequest:
|
|
86
|
+
project_dir: Path
|
|
87
|
+
binding: str
|
|
88
|
+
delete_checkout: bool = False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class DevTransactionResult:
|
|
93
|
+
operation: Operation
|
|
94
|
+
project_dir: Path
|
|
95
|
+
business_branch: str
|
|
96
|
+
base_branch: BaseBranch
|
|
97
|
+
repo_keys: tuple[str, ...]
|
|
98
|
+
checkouts: tuple[Path, ...]
|
|
99
|
+
bindings: tuple[str, ...]
|
|
100
|
+
links: tuple[Path, ...]
|
|
101
|
+
updated: bool | None = None
|
|
102
|
+
upstream: str | None = None
|
|
103
|
+
checkout_deleted: bool = False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class _RepoIntent:
|
|
108
|
+
repo_key: str
|
|
109
|
+
source: str
|
|
110
|
+
checkout: Path
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class _PreparedRepo:
|
|
115
|
+
intent: _RepoIntent
|
|
116
|
+
branch: PreparedBranch
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _error(
|
|
120
|
+
code: str,
|
|
121
|
+
message: str,
|
|
122
|
+
*,
|
|
123
|
+
action: str,
|
|
124
|
+
detail: str | None = None,
|
|
125
|
+
) -> PzError:
|
|
126
|
+
return PzError(
|
|
127
|
+
code=code,
|
|
128
|
+
message=message,
|
|
129
|
+
stage="dev",
|
|
130
|
+
owner=_OWNER,
|
|
131
|
+
action=action,
|
|
132
|
+
detail=detail,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _new_git_service() -> DevGitService:
|
|
137
|
+
return DevGitService()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _transaction_phase(_name: str) -> None:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _inject_fault(_name: str) -> None:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _atomic_replace(source: Path, destination: Path) -> None:
|
|
149
|
+
_OS_REPLACE(source, destination)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _fsync_directory(path: Path) -> None:
|
|
153
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
154
|
+
try:
|
|
155
|
+
os.fsync(descriptor)
|
|
156
|
+
finally:
|
|
157
|
+
os.close(descriptor)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _project_root(value: object, *, action: str) -> Path:
|
|
161
|
+
if not isinstance(value, Path):
|
|
162
|
+
raise _error(
|
|
163
|
+
_REQUEST_INVALID,
|
|
164
|
+
"project_dir must be a pathlib.Path.",
|
|
165
|
+
action=action,
|
|
166
|
+
)
|
|
167
|
+
project_dir = value.expanduser().resolve(strict=False)
|
|
168
|
+
if not project_dir.is_dir():
|
|
169
|
+
raise _error(
|
|
170
|
+
_REQUEST_INVALID,
|
|
171
|
+
f"project directory does not exist: {project_dir}",
|
|
172
|
+
action=action,
|
|
173
|
+
)
|
|
174
|
+
apps_root = project_dir / "apps"
|
|
175
|
+
if apps_root.is_symlink() or not apps_root.is_dir():
|
|
176
|
+
raise _error(
|
|
177
|
+
_REQUEST_INVALID,
|
|
178
|
+
f"project apps directory must be a real directory: {apps_root}",
|
|
179
|
+
action=action,
|
|
180
|
+
)
|
|
181
|
+
return project_dir
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _base_branch(value: object, *, action: str) -> BaseBranch:
|
|
185
|
+
if value not in {"develop", "main"}:
|
|
186
|
+
raise _error(
|
|
187
|
+
_REQUEST_INVALID,
|
|
188
|
+
"base branch must be develop or main.",
|
|
189
|
+
action=action,
|
|
190
|
+
)
|
|
191
|
+
return value # type: ignore[return-value]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _locators(values: object, *, action: str) -> tuple[str, ...]:
|
|
195
|
+
if not isinstance(values, tuple) or not values:
|
|
196
|
+
raise _error(
|
|
197
|
+
_REQUEST_INVALID,
|
|
198
|
+
"at least one source locator is required.",
|
|
199
|
+
action=action,
|
|
200
|
+
)
|
|
201
|
+
normalized: list[str] = []
|
|
202
|
+
for value in values:
|
|
203
|
+
if not isinstance(value, str) or not value.strip():
|
|
204
|
+
raise _error(
|
|
205
|
+
_REQUEST_INVALID,
|
|
206
|
+
"source locators must be non-empty strings.",
|
|
207
|
+
action=action,
|
|
208
|
+
)
|
|
209
|
+
candidate = value.strip()
|
|
210
|
+
if candidate not in normalized:
|
|
211
|
+
normalized.append(candidate)
|
|
212
|
+
return tuple(normalized)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _business_manifest(
|
|
216
|
+
project_dir: Path,
|
|
217
|
+
manifest: DevManifest | None,
|
|
218
|
+
*,
|
|
219
|
+
app_name: str | None,
|
|
220
|
+
action: str,
|
|
221
|
+
) -> DevManifest:
|
|
222
|
+
if manifest is not None:
|
|
223
|
+
business_app_id = _normalize_business_app_id(
|
|
224
|
+
manifest.business_app_id,
|
|
225
|
+
action=action,
|
|
226
|
+
)
|
|
227
|
+
if app_name is not None:
|
|
228
|
+
requested = _normalize_business_app_id(app_name, action=action)
|
|
229
|
+
if requested != business_app_id:
|
|
230
|
+
raise _error(
|
|
231
|
+
"PZ_DEV_BUSINESS_APP_ID_INVALID",
|
|
232
|
+
"business app id is already persisted and cannot be changed.",
|
|
233
|
+
action=action,
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
candidate = app_name or derive_default_local_app_slug(project_dir)
|
|
237
|
+
business_app_id = _normalize_business_app_id(candidate, action=action)
|
|
238
|
+
_validate_business_app_git_ref(
|
|
239
|
+
business_app_id,
|
|
240
|
+
action=action,
|
|
241
|
+
git_ref_runner=_run_git_ref_check,
|
|
242
|
+
)
|
|
243
|
+
if manifest is not None:
|
|
244
|
+
return manifest
|
|
245
|
+
return DevManifest(
|
|
246
|
+
schema_version=5,
|
|
247
|
+
business_app_id=business_app_id,
|
|
248
|
+
repos={},
|
|
249
|
+
bindings={},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _read_manifest_snapshot(path: Path) -> bytes | None:
|
|
254
|
+
if not path.exists() and not path.is_symlink():
|
|
255
|
+
return None
|
|
256
|
+
if path.is_symlink() or not path.is_file():
|
|
257
|
+
raise _error(
|
|
258
|
+
"PZ_DEV_MANIFEST_INVALID",
|
|
259
|
+
"dev manifest must be a real file.",
|
|
260
|
+
action="load_dev_transaction",
|
|
261
|
+
)
|
|
262
|
+
try:
|
|
263
|
+
return path.read_bytes()
|
|
264
|
+
except OSError as exc:
|
|
265
|
+
raise _error(
|
|
266
|
+
"PZ_DEV_MANIFEST_INVALID",
|
|
267
|
+
"dev manifest could not be read.",
|
|
268
|
+
action="load_dev_transaction",
|
|
269
|
+
detail=str(exc),
|
|
270
|
+
) from exc
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _resolved_binding(source: ResolvedSource) -> tuple[str, DevBinding]:
|
|
274
|
+
kind = source.binding_kind
|
|
275
|
+
subpath = source.subpath
|
|
276
|
+
link_name = source.link_name
|
|
277
|
+
expected_app_id = source.expected_app_id
|
|
278
|
+
if source.pending_layout:
|
|
279
|
+
kind = "directory"
|
|
280
|
+
subpath = PurePosixPath(".")
|
|
281
|
+
expected_app_id = None
|
|
282
|
+
if kind not in {"directory", "app"}:
|
|
283
|
+
raise _error(
|
|
284
|
+
_SOURCE_LAYOUT_INVALID,
|
|
285
|
+
"source locator did not resolve to a bindable layout.",
|
|
286
|
+
action="resolve_dev_transaction",
|
|
287
|
+
detail=source.locator,
|
|
288
|
+
)
|
|
289
|
+
binding_key = expected_app_id if kind == "app" else link_name
|
|
290
|
+
if not binding_key:
|
|
291
|
+
raise _error(
|
|
292
|
+
_SOURCE_LAYOUT_INVALID,
|
|
293
|
+
"source locator did not resolve a binding identity.",
|
|
294
|
+
action="resolve_dev_transaction",
|
|
295
|
+
detail=source.locator,
|
|
296
|
+
)
|
|
297
|
+
if source.source_kind == "git":
|
|
298
|
+
binding_source = source.clone_source
|
|
299
|
+
repo_key = source.repo_key
|
|
300
|
+
else:
|
|
301
|
+
binding_source = str(Path(source.clone_source).expanduser().resolve())
|
|
302
|
+
repo_key = None
|
|
303
|
+
return binding_key, DevBinding(
|
|
304
|
+
kind=kind,
|
|
305
|
+
source_kind=source.source_kind,
|
|
306
|
+
repo_key=repo_key,
|
|
307
|
+
source=binding_source,
|
|
308
|
+
link=PurePosixPath("apps") / link_name,
|
|
309
|
+
subpath=subpath,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _build_desired_manifest(
|
|
314
|
+
project_dir: Path,
|
|
315
|
+
before: DevManifest,
|
|
316
|
+
resolved_sources: Sequence[ResolvedSource],
|
|
317
|
+
*,
|
|
318
|
+
operation: Operation,
|
|
319
|
+
base_branch: BaseBranch,
|
|
320
|
+
action: str,
|
|
321
|
+
) -> tuple[DevManifest, tuple[_RepoIntent, ...], tuple[str, ...]]:
|
|
322
|
+
repos = dict(before.repos)
|
|
323
|
+
bindings = dict(before.bindings)
|
|
324
|
+
intents: dict[str, _RepoIntent] = {}
|
|
325
|
+
selected_bindings: list[str] = []
|
|
326
|
+
|
|
327
|
+
for source in resolved_sources:
|
|
328
|
+
if source.source_kind == "git":
|
|
329
|
+
repo_key = source.repo_key
|
|
330
|
+
if not repo_key:
|
|
331
|
+
raise _error(
|
|
332
|
+
_SOURCE_LAYOUT_INVALID,
|
|
333
|
+
"Git source locator did not resolve a repo key.",
|
|
334
|
+
action=action,
|
|
335
|
+
detail=source.locator,
|
|
336
|
+
)
|
|
337
|
+
existing_repo = repos.get(repo_key)
|
|
338
|
+
if (
|
|
339
|
+
existing_repo is not None
|
|
340
|
+
and existing_repo.source != source.clone_source
|
|
341
|
+
):
|
|
342
|
+
raise _error(
|
|
343
|
+
_SOURCE_CONFLICT,
|
|
344
|
+
f"repo key is already bound to a different source: {repo_key}",
|
|
345
|
+
action=action,
|
|
346
|
+
detail=(
|
|
347
|
+
f"existing={existing_repo.source}; desired={source.clone_source}"
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
previous_intent = intents.get(repo_key)
|
|
351
|
+
if (
|
|
352
|
+
previous_intent is not None
|
|
353
|
+
and previous_intent.source != source.clone_source
|
|
354
|
+
):
|
|
355
|
+
raise _error(
|
|
356
|
+
_SOURCE_CONFLICT,
|
|
357
|
+
f"source locators disagree for repo key: {repo_key}",
|
|
358
|
+
action=action,
|
|
359
|
+
)
|
|
360
|
+
checkout_posix = PurePosixPath("apps/.sources") / repo_key
|
|
361
|
+
repos[repo_key] = DevRepo(
|
|
362
|
+
source=source.clone_source,
|
|
363
|
+
checkout=checkout_posix,
|
|
364
|
+
base_branch=base_branch,
|
|
365
|
+
branch=before.business_app_id,
|
|
366
|
+
)
|
|
367
|
+
intents.setdefault(
|
|
368
|
+
repo_key,
|
|
369
|
+
_RepoIntent(
|
|
370
|
+
repo_key=repo_key,
|
|
371
|
+
source=source.clone_source,
|
|
372
|
+
checkout=project_dir / Path(*checkout_posix.parts),
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
elif operation == "branch":
|
|
376
|
+
raise _error(
|
|
377
|
+
_SOURCE_NOT_GIT,
|
|
378
|
+
"branch preparation requires a Git-backed source.",
|
|
379
|
+
action=action,
|
|
380
|
+
detail=source.locator,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if operation == "branch":
|
|
384
|
+
continue
|
|
385
|
+
if source.bootstrap:
|
|
386
|
+
continue
|
|
387
|
+
binding_key, binding = _resolved_binding(source)
|
|
388
|
+
previous_binding = bindings.get(binding_key)
|
|
389
|
+
if previous_binding is not None and previous_binding != binding:
|
|
390
|
+
previous_link = previous_binding.link
|
|
391
|
+
if previous_link != binding.link:
|
|
392
|
+
raise _error(
|
|
393
|
+
_SOURCE_CONFLICT,
|
|
394
|
+
f"binding identity is already exposed at another link: {binding_key}",
|
|
395
|
+
action=action,
|
|
396
|
+
detail=f"existing={previous_link}; desired={binding.link}",
|
|
397
|
+
)
|
|
398
|
+
bindings[binding_key] = binding
|
|
399
|
+
if binding_key not in selected_bindings:
|
|
400
|
+
selected_bindings.append(binding_key)
|
|
401
|
+
|
|
402
|
+
desired = DevManifest(
|
|
403
|
+
schema_version=5,
|
|
404
|
+
business_app_id=before.business_app_id,
|
|
405
|
+
repos=repos,
|
|
406
|
+
bindings=bindings,
|
|
407
|
+
)
|
|
408
|
+
validate_dev_manifest(project_dir, desired)
|
|
409
|
+
return desired, tuple(intents.values()), tuple(selected_bindings)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _binding_base(
|
|
413
|
+
project_dir: Path,
|
|
414
|
+
manifest: DevManifest,
|
|
415
|
+
binding: DevBinding,
|
|
416
|
+
) -> Path:
|
|
417
|
+
if binding.source_kind == "link":
|
|
418
|
+
return Path(binding.source)
|
|
419
|
+
assert binding.repo_key is not None
|
|
420
|
+
repo = manifest.repos[binding.repo_key]
|
|
421
|
+
return project_dir / Path(*repo.checkout.parts)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _binding_target(
|
|
425
|
+
project_dir: Path,
|
|
426
|
+
manifest: DevManifest,
|
|
427
|
+
binding: DevBinding,
|
|
428
|
+
) -> Path:
|
|
429
|
+
base = _binding_base(project_dir, manifest, binding)
|
|
430
|
+
if binding.subpath == PurePosixPath("."):
|
|
431
|
+
return base
|
|
432
|
+
return base / Path(*binding.subpath.parts)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _layout_error(message: str, *, locator: str, detail: str) -> PzError:
|
|
436
|
+
return _error(
|
|
437
|
+
_SOURCE_LAYOUT_INVALID,
|
|
438
|
+
message,
|
|
439
|
+
action="validate_dev_source_layout",
|
|
440
|
+
detail=f"locator={locator}; {detail}",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _static_apps_py_app_id(path: Path) -> str | None:
|
|
445
|
+
try:
|
|
446
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
447
|
+
except (OSError, UnicodeError, SyntaxError):
|
|
448
|
+
return None
|
|
449
|
+
class_ids: dict[str, str] = {}
|
|
450
|
+
for statement in tree.body:
|
|
451
|
+
if not isinstance(statement, ast.ClassDef):
|
|
452
|
+
continue
|
|
453
|
+
for field in statement.body:
|
|
454
|
+
targets: tuple[ast.expr, ...]
|
|
455
|
+
value: ast.expr | None
|
|
456
|
+
if isinstance(field, ast.Assign):
|
|
457
|
+
targets = tuple(field.targets)
|
|
458
|
+
value = field.value
|
|
459
|
+
elif isinstance(field, ast.AnnAssign):
|
|
460
|
+
targets = (field.target,)
|
|
461
|
+
value = field.value
|
|
462
|
+
else:
|
|
463
|
+
continue
|
|
464
|
+
if not any(
|
|
465
|
+
isinstance(target, ast.Name) and target.id == "id" for target in targets
|
|
466
|
+
):
|
|
467
|
+
continue
|
|
468
|
+
if (
|
|
469
|
+
isinstance(value, ast.Constant)
|
|
470
|
+
and isinstance(value.value, str)
|
|
471
|
+
and value.value.isidentifier()
|
|
472
|
+
):
|
|
473
|
+
class_ids[statement.name] = value.value
|
|
474
|
+
break
|
|
475
|
+
for statement in tree.body:
|
|
476
|
+
if not isinstance(statement, ast.Assign):
|
|
477
|
+
continue
|
|
478
|
+
if not any(
|
|
479
|
+
isinstance(target, ast.Name) and target.id == "app"
|
|
480
|
+
for target in statement.targets
|
|
481
|
+
):
|
|
482
|
+
continue
|
|
483
|
+
value = statement.value
|
|
484
|
+
if (
|
|
485
|
+
isinstance(value, ast.Call)
|
|
486
|
+
and isinstance(value.func, ast.Name)
|
|
487
|
+
and value.func.id in class_ids
|
|
488
|
+
):
|
|
489
|
+
return class_ids[value.func.id]
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _formal_leaf(path: Path) -> str | None:
|
|
494
|
+
pyproject = path / "pyproject.toml"
|
|
495
|
+
if not pyproject.is_file():
|
|
496
|
+
return None
|
|
497
|
+
try:
|
|
498
|
+
payload = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
499
|
+
except (OSError, UnicodeError, tomllib.TOMLDecodeError):
|
|
500
|
+
return None
|
|
501
|
+
project = payload.get("project")
|
|
502
|
+
if not isinstance(project, dict):
|
|
503
|
+
return None
|
|
504
|
+
distribution = project.get("name")
|
|
505
|
+
if not isinstance(distribution, str) or not distribution.strip():
|
|
506
|
+
return None
|
|
507
|
+
app_id = distribution.strip().replace("-", "_")
|
|
508
|
+
apps_py = path / app_id / "apps.py"
|
|
509
|
+
if _static_apps_py_app_id(apps_py) != app_id:
|
|
510
|
+
return None
|
|
511
|
+
return app_id
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _has_formal_top_level_leaf(root: Path) -> bool:
|
|
515
|
+
for child in sorted(root.iterdir(), key=lambda item: item.name):
|
|
516
|
+
if child.name.startswith(".") or child.is_symlink() or not child.is_dir():
|
|
517
|
+
continue
|
|
518
|
+
if _formal_leaf(child) is not None:
|
|
519
|
+
return True
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _validate_source_layout(
|
|
524
|
+
source: ResolvedSource,
|
|
525
|
+
*,
|
|
526
|
+
source_root: Path,
|
|
527
|
+
) -> None:
|
|
528
|
+
subpath = PurePosixPath(".") if source.pending_layout else source.subpath
|
|
529
|
+
target = (
|
|
530
|
+
source_root
|
|
531
|
+
if subpath == PurePosixPath(".")
|
|
532
|
+
else source_root / Path(*subpath.parts)
|
|
533
|
+
)
|
|
534
|
+
try:
|
|
535
|
+
source_realpath = source_root.resolve(strict=True)
|
|
536
|
+
target_realpath = target.resolve(strict=True)
|
|
537
|
+
except (OSError, RuntimeError) as exc:
|
|
538
|
+
raise _layout_error(
|
|
539
|
+
"source layout target is missing or broken.",
|
|
540
|
+
locator=source.locator,
|
|
541
|
+
detail=str(exc),
|
|
542
|
+
) from exc
|
|
543
|
+
if not source_realpath.is_dir() or not target_realpath.is_dir():
|
|
544
|
+
raise _layout_error(
|
|
545
|
+
"source layout target must be a directory.",
|
|
546
|
+
locator=source.locator,
|
|
547
|
+
detail=str(target),
|
|
548
|
+
)
|
|
549
|
+
if not target_realpath.is_relative_to(source_realpath):
|
|
550
|
+
raise _layout_error(
|
|
551
|
+
"source layout target escapes its source root.",
|
|
552
|
+
locator=source.locator,
|
|
553
|
+
detail=str(target_realpath),
|
|
554
|
+
)
|
|
555
|
+
if source.pending_layout:
|
|
556
|
+
if not _has_formal_top_level_leaf(target_realpath):
|
|
557
|
+
raise _layout_error(
|
|
558
|
+
"Git source contains no formal top-level minimal app.",
|
|
559
|
+
locator=source.locator,
|
|
560
|
+
detail=str(target_realpath),
|
|
561
|
+
)
|
|
562
|
+
return
|
|
563
|
+
if source.binding_kind != "app":
|
|
564
|
+
return
|
|
565
|
+
expected_app_id = source.expected_app_id
|
|
566
|
+
discovered_app_id = _formal_leaf(target_realpath)
|
|
567
|
+
if not expected_app_id or discovered_app_id != expected_app_id:
|
|
568
|
+
raise _layout_error(
|
|
569
|
+
"app source does not match its declared app id.",
|
|
570
|
+
locator=source.locator,
|
|
571
|
+
detail=(
|
|
572
|
+
f"expected={expected_app_id!r}; discovered={discovered_app_id!r}; "
|
|
573
|
+
f"path={target_realpath}"
|
|
574
|
+
),
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _preflight_paths(project_dir: Path, manifest_path: Path) -> None:
|
|
579
|
+
sources_root = manifest_path.parent
|
|
580
|
+
if sources_root.is_symlink():
|
|
581
|
+
raise _error(
|
|
582
|
+
_REQUEST_INVALID,
|
|
583
|
+
"managed source root cannot be a symbolic link.",
|
|
584
|
+
action="preflight_dev_transaction",
|
|
585
|
+
detail=str(sources_root),
|
|
586
|
+
)
|
|
587
|
+
if sources_root.exists() and not sources_root.is_dir():
|
|
588
|
+
raise _error(
|
|
589
|
+
_REQUEST_INVALID,
|
|
590
|
+
"managed source root must be a directory.",
|
|
591
|
+
action="preflight_dev_transaction",
|
|
592
|
+
detail=str(sources_root),
|
|
593
|
+
)
|
|
594
|
+
if manifest_path.is_symlink():
|
|
595
|
+
raise _error(
|
|
596
|
+
"PZ_DEV_MANIFEST_INVALID",
|
|
597
|
+
"dev manifest cannot be a symbolic link.",
|
|
598
|
+
action="preflight_dev_transaction",
|
|
599
|
+
)
|
|
600
|
+
project_realpath = project_dir.resolve(strict=True)
|
|
601
|
+
apps_realpath = (project_dir / "apps").resolve(strict=True)
|
|
602
|
+
if not apps_realpath.is_relative_to(project_realpath):
|
|
603
|
+
raise _error(
|
|
604
|
+
_REQUEST_INVALID,
|
|
605
|
+
"apps directory escapes the project root.",
|
|
606
|
+
action="preflight_dev_transaction",
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _preflight_projection(
|
|
611
|
+
project_dir: Path,
|
|
612
|
+
before: DevManifest,
|
|
613
|
+
desired: DevManifest,
|
|
614
|
+
) -> None:
|
|
615
|
+
plan_binding_projection(project_dir, before, before)
|
|
616
|
+
before_links = {
|
|
617
|
+
project_dir / Path(*binding.link.parts) for binding in before.bindings.values()
|
|
618
|
+
}
|
|
619
|
+
for binding in desired.bindings.values():
|
|
620
|
+
final_path = project_dir / Path(*binding.link.parts)
|
|
621
|
+
if final_path not in before_links and os.path.lexists(final_path):
|
|
622
|
+
raise _error(
|
|
623
|
+
BINDING_CONFLICT_CODE,
|
|
624
|
+
f"binding destination is occupied by an unmanaged path: {final_path}",
|
|
625
|
+
action="preflight_dev_transaction",
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
partial_bindings = dict(before.bindings)
|
|
629
|
+
for key, binding in desired.bindings.items():
|
|
630
|
+
target = _binding_target(project_dir, desired, binding)
|
|
631
|
+
try:
|
|
632
|
+
available = target.resolve(strict=True).is_dir()
|
|
633
|
+
except (OSError, RuntimeError):
|
|
634
|
+
available = False
|
|
635
|
+
if available:
|
|
636
|
+
partial_bindings[key] = binding
|
|
637
|
+
partial = DevManifest(
|
|
638
|
+
schema_version=5,
|
|
639
|
+
business_app_id=desired.business_app_id,
|
|
640
|
+
repos=desired.repos,
|
|
641
|
+
bindings=partial_bindings,
|
|
642
|
+
)
|
|
643
|
+
validate_dev_manifest(project_dir, partial)
|
|
644
|
+
plan_binding_projection(project_dir, before, partial)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _git_plan(
|
|
648
|
+
project_dir: Path,
|
|
649
|
+
intent: _RepoIntent,
|
|
650
|
+
*,
|
|
651
|
+
business_branch: str,
|
|
652
|
+
base_branch: BaseBranch,
|
|
653
|
+
transaction_id: str,
|
|
654
|
+
) -> GitBranchPlan:
|
|
655
|
+
return GitBranchPlan(
|
|
656
|
+
project_dir=project_dir,
|
|
657
|
+
repo_key=intent.repo_key,
|
|
658
|
+
source=intent.source,
|
|
659
|
+
checkout=intent.checkout,
|
|
660
|
+
business_branch=business_branch,
|
|
661
|
+
transaction_id=transaction_id,
|
|
662
|
+
base_branch=base_branch,
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _prepared_source_root(
|
|
667
|
+
source: ResolvedSource,
|
|
668
|
+
prepared: Mapping[str, _PreparedRepo],
|
|
669
|
+
) -> Path:
|
|
670
|
+
if source.source_kind == "link":
|
|
671
|
+
return Path(source.clone_source).expanduser().resolve()
|
|
672
|
+
assert source.repo_key is not None
|
|
673
|
+
return prepared[source.repo_key].branch.checkout
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _shadow_manifest(
|
|
677
|
+
desired: DevManifest,
|
|
678
|
+
prepared: Mapping[str, _PreparedRepo],
|
|
679
|
+
) -> tuple[DevManifest, frozenset[Path]]:
|
|
680
|
+
bindings: dict[str, DevBinding] = {}
|
|
681
|
+
staged_final_paths: set[Path] = set()
|
|
682
|
+
for key, binding in desired.bindings.items():
|
|
683
|
+
if binding.source_kind != "git" or binding.repo_key is None:
|
|
684
|
+
bindings[key] = binding
|
|
685
|
+
continue
|
|
686
|
+
prepared_repo = prepared.get(binding.repo_key)
|
|
687
|
+
if prepared_repo is None or not prepared_repo.branch.new_clone:
|
|
688
|
+
bindings[key] = binding
|
|
689
|
+
continue
|
|
690
|
+
bindings[key] = DevBinding(
|
|
691
|
+
kind=binding.kind,
|
|
692
|
+
source_kind="link",
|
|
693
|
+
repo_key=None,
|
|
694
|
+
source=str(prepared_repo.branch.checkout.resolve(strict=True)),
|
|
695
|
+
link=binding.link,
|
|
696
|
+
subpath=binding.subpath,
|
|
697
|
+
)
|
|
698
|
+
staged_final_paths.add(Path(*binding.link.parts))
|
|
699
|
+
return (
|
|
700
|
+
DevManifest(
|
|
701
|
+
schema_version=5,
|
|
702
|
+
business_app_id=desired.business_app_id,
|
|
703
|
+
repos=desired.repos,
|
|
704
|
+
bindings=bindings,
|
|
705
|
+
),
|
|
706
|
+
frozenset(staged_final_paths),
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _final_relative_target(
|
|
711
|
+
project_dir: Path,
|
|
712
|
+
desired: DevManifest,
|
|
713
|
+
mutation: LinkMutation,
|
|
714
|
+
) -> str:
|
|
715
|
+
binding = next(
|
|
716
|
+
binding
|
|
717
|
+
for binding in desired.bindings.values()
|
|
718
|
+
if project_dir / Path(*binding.link.parts) == mutation.final_path
|
|
719
|
+
)
|
|
720
|
+
target = _binding_target(project_dir, desired, binding)
|
|
721
|
+
return Path(os.path.relpath(target, start=mutation.final_path.parent)).as_posix()
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _final_link_mutations(
|
|
725
|
+
project_dir: Path,
|
|
726
|
+
desired: DevManifest,
|
|
727
|
+
shadow_mutations: Sequence[LinkMutation],
|
|
728
|
+
staged_final_paths: frozenset[Path],
|
|
729
|
+
) -> tuple[LinkMutation, ...]:
|
|
730
|
+
mutations: list[LinkMutation] = []
|
|
731
|
+
for mutation in shadow_mutations:
|
|
732
|
+
relative_final = mutation.final_path.relative_to(project_dir)
|
|
733
|
+
if relative_final not in staged_final_paths:
|
|
734
|
+
mutations.append(mutation)
|
|
735
|
+
continue
|
|
736
|
+
relative_target = _final_relative_target(project_dir, desired, mutation)
|
|
737
|
+
mutations.append(
|
|
738
|
+
LinkMutation(
|
|
739
|
+
final_path=mutation.final_path,
|
|
740
|
+
staged_path=mutation.staged_path,
|
|
741
|
+
relative_target=relative_target,
|
|
742
|
+
previous_target=mutation.previous_target,
|
|
743
|
+
)
|
|
744
|
+
)
|
|
745
|
+
return tuple(mutations)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _retarget_staged_links(
|
|
749
|
+
shadow_mutations: Sequence[LinkMutation],
|
|
750
|
+
final_mutations: Sequence[LinkMutation],
|
|
751
|
+
) -> None:
|
|
752
|
+
pairs = tuple(zip(shadow_mutations, final_mutations, strict=True))
|
|
753
|
+
try:
|
|
754
|
+
for shadow, final in pairs:
|
|
755
|
+
if shadow.relative_target == final.relative_target:
|
|
756
|
+
continue
|
|
757
|
+
if (
|
|
758
|
+
not shadow.staged_path.is_symlink()
|
|
759
|
+
or os.readlink(shadow.staged_path) != shadow.relative_target
|
|
760
|
+
):
|
|
761
|
+
raise _error(
|
|
762
|
+
"PZ_DEV_BINDING_INVALID_PROJECTION",
|
|
763
|
+
"staged binding changed before final-target conversion.",
|
|
764
|
+
action="stage_dev_transaction",
|
|
765
|
+
detail=str(shadow.staged_path),
|
|
766
|
+
)
|
|
767
|
+
shadow.staged_path.unlink()
|
|
768
|
+
os.symlink(
|
|
769
|
+
final.relative_target,
|
|
770
|
+
final.staged_path,
|
|
771
|
+
target_is_directory=True,
|
|
772
|
+
)
|
|
773
|
+
except BaseException as original:
|
|
774
|
+
cleanup_failures: list[Exception] = []
|
|
775
|
+
for shadow, final in reversed(pairs):
|
|
776
|
+
path = shadow.staged_path
|
|
777
|
+
if not path.is_symlink():
|
|
778
|
+
continue
|
|
779
|
+
try:
|
|
780
|
+
actual = os.readlink(path)
|
|
781
|
+
if actual not in {shadow.relative_target, final.relative_target}:
|
|
782
|
+
raise RuntimeError(f"staged binding changed concurrently: {path}")
|
|
783
|
+
path.unlink()
|
|
784
|
+
except Exception as exc:
|
|
785
|
+
cleanup_failures.append(exc)
|
|
786
|
+
if cleanup_failures:
|
|
787
|
+
raise RuntimeError(
|
|
788
|
+
"staged binding retarget cleanup failed: "
|
|
789
|
+
+ "; ".join(str(failure) for failure in cleanup_failures)
|
|
790
|
+
) from original
|
|
791
|
+
raise
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _write_staged_manifest(path: Path, payload: bytes) -> None:
|
|
795
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
796
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
797
|
+
try:
|
|
798
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
799
|
+
handle.write(payload)
|
|
800
|
+
handle.flush()
|
|
801
|
+
os.fsync(handle.fileno())
|
|
802
|
+
except BaseException:
|
|
803
|
+
path.unlink(missing_ok=True)
|
|
804
|
+
raise
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _discard_staged_manifest(path: Path, expected: bytes) -> None:
|
|
808
|
+
if not path.exists() and not path.is_symlink():
|
|
809
|
+
return
|
|
810
|
+
if path.is_symlink() or not path.is_file() or path.read_bytes() != expected:
|
|
811
|
+
raise RuntimeError(f"staged manifest identity changed: {path}")
|
|
812
|
+
path.unlink()
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _prepared_identity_matches(path: Path, prepared: PreparedBranch) -> bool:
|
|
816
|
+
if path.is_symlink() or not path.is_dir():
|
|
817
|
+
return False
|
|
818
|
+
if prepared.clone_device is None or prepared.clone_inode is None:
|
|
819
|
+
return False
|
|
820
|
+
stat = path.stat(follow_symlinks=False)
|
|
821
|
+
return stat.st_dev == prepared.clone_device and stat.st_ino == prepared.clone_inode
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _restore_manifest(
|
|
825
|
+
manifest_path: Path,
|
|
826
|
+
*,
|
|
827
|
+
before_bytes: bytes | None,
|
|
828
|
+
desired_bytes: bytes,
|
|
829
|
+
transaction_id: str,
|
|
830
|
+
) -> None:
|
|
831
|
+
if not manifest_path.exists() or manifest_path.is_symlink():
|
|
832
|
+
if before_bytes is None and not manifest_path.is_symlink():
|
|
833
|
+
return
|
|
834
|
+
raise RuntimeError("manifest disappeared or changed type during rollback")
|
|
835
|
+
current = manifest_path.read_bytes()
|
|
836
|
+
if current == before_bytes:
|
|
837
|
+
return
|
|
838
|
+
if current != desired_bytes:
|
|
839
|
+
raise RuntimeError("manifest bytes changed concurrently before rollback")
|
|
840
|
+
if before_bytes is None:
|
|
841
|
+
manifest_path.unlink()
|
|
842
|
+
_fsync_directory(manifest_path.parent)
|
|
843
|
+
return
|
|
844
|
+
restore_path = manifest_path.with_name(
|
|
845
|
+
f".{manifest_path.name}.{transaction_id}.restore"
|
|
846
|
+
)
|
|
847
|
+
_write_staged_manifest(restore_path, before_bytes)
|
|
848
|
+
try:
|
|
849
|
+
_OS_REPLACE(restore_path, manifest_path)
|
|
850
|
+
_fsync_directory(manifest_path.parent)
|
|
851
|
+
finally:
|
|
852
|
+
_discard_staged_manifest(restore_path, before_bytes)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _committed_link_mutations(
|
|
856
|
+
mutations: Sequence[LinkMutation],
|
|
857
|
+
) -> tuple[LinkMutation, ...]:
|
|
858
|
+
committed: list[LinkMutation] = []
|
|
859
|
+
for mutation in mutations:
|
|
860
|
+
if mutation.final_path.is_symlink():
|
|
861
|
+
actual = os.readlink(mutation.final_path)
|
|
862
|
+
if actual == mutation.relative_target:
|
|
863
|
+
committed.append(mutation)
|
|
864
|
+
continue
|
|
865
|
+
if (
|
|
866
|
+
mutation.previous_target is not None
|
|
867
|
+
and actual == mutation.previous_target
|
|
868
|
+
):
|
|
869
|
+
continue
|
|
870
|
+
raise RuntimeError(f"binding changed concurrently: {mutation.final_path}")
|
|
871
|
+
if (
|
|
872
|
+
not os.path.lexists(mutation.final_path)
|
|
873
|
+
and mutation.previous_target is None
|
|
874
|
+
):
|
|
875
|
+
continue
|
|
876
|
+
raise RuntimeError(
|
|
877
|
+
f"binding changed type during rollback: {mutation.final_path}"
|
|
878
|
+
)
|
|
879
|
+
return tuple(committed)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _restore_new_checkout(prepared: _PreparedRepo) -> None:
|
|
883
|
+
branch = prepared.branch
|
|
884
|
+
if not branch.new_clone:
|
|
885
|
+
return
|
|
886
|
+
staged = branch.checkout
|
|
887
|
+
final = prepared.intent.checkout
|
|
888
|
+
staged_matches = _prepared_identity_matches(staged, branch)
|
|
889
|
+
final_matches = _prepared_identity_matches(final, branch)
|
|
890
|
+
if staged_matches and not os.path.lexists(final):
|
|
891
|
+
return
|
|
892
|
+
if final_matches and not os.path.lexists(staged):
|
|
893
|
+
staged.parent.mkdir(parents=True, exist_ok=True)
|
|
894
|
+
_OS_REPLACE(final, staged)
|
|
895
|
+
return
|
|
896
|
+
if not os.path.lexists(staged) and not os.path.lexists(final):
|
|
897
|
+
return
|
|
898
|
+
raise RuntimeError(
|
|
899
|
+
f"prepared checkout identity changed: {prepared.intent.repo_key}"
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _cleanup_transaction_paths(
|
|
904
|
+
project_dir: Path,
|
|
905
|
+
*,
|
|
906
|
+
transaction_id: str,
|
|
907
|
+
staged_manifest: Path | None,
|
|
908
|
+
desired_bytes: bytes,
|
|
909
|
+
mutations: Sequence[LinkMutation],
|
|
910
|
+
sources_root_existed: bool,
|
|
911
|
+
) -> None:
|
|
912
|
+
discard_staged_projection(mutations)
|
|
913
|
+
if staged_manifest is not None:
|
|
914
|
+
_discard_staged_manifest(staged_manifest, desired_bytes)
|
|
915
|
+
sources_root = project_dir / "apps" / ".sources"
|
|
916
|
+
transaction_root = sources_root / ".transactions" / transaction_id
|
|
917
|
+
transactions_root = transaction_root.parent
|
|
918
|
+
try:
|
|
919
|
+
transaction_root.rmdir()
|
|
920
|
+
except FileNotFoundError:
|
|
921
|
+
pass
|
|
922
|
+
except OSError:
|
|
923
|
+
if transaction_root.exists() and any(transaction_root.iterdir()):
|
|
924
|
+
raise
|
|
925
|
+
try:
|
|
926
|
+
transactions_root.rmdir()
|
|
927
|
+
except (FileNotFoundError, OSError):
|
|
928
|
+
pass
|
|
929
|
+
if not sources_root_existed:
|
|
930
|
+
try:
|
|
931
|
+
sources_root.rmdir()
|
|
932
|
+
except (FileNotFoundError, OSError):
|
|
933
|
+
pass
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _rollback_transaction(
|
|
937
|
+
project_dir: Path,
|
|
938
|
+
*,
|
|
939
|
+
manifest_path: Path,
|
|
940
|
+
before_bytes: bytes | None,
|
|
941
|
+
desired_bytes: bytes,
|
|
942
|
+
prepared_repos: Sequence[_PreparedRepo],
|
|
943
|
+
git_service: DevGitService | None,
|
|
944
|
+
mutations: Sequence[LinkMutation],
|
|
945
|
+
links_commit_attempted: bool,
|
|
946
|
+
staged_manifest: Path | None,
|
|
947
|
+
manifest_replace_attempted: bool,
|
|
948
|
+
transaction_id: str,
|
|
949
|
+
sources_root_existed: bool,
|
|
950
|
+
) -> tuple[Exception, ...]:
|
|
951
|
+
failures: list[Exception] = []
|
|
952
|
+
if manifest_replace_attempted:
|
|
953
|
+
try:
|
|
954
|
+
_restore_manifest(
|
|
955
|
+
manifest_path,
|
|
956
|
+
before_bytes=before_bytes,
|
|
957
|
+
desired_bytes=desired_bytes,
|
|
958
|
+
transaction_id=transaction_id,
|
|
959
|
+
)
|
|
960
|
+
except Exception as exc:
|
|
961
|
+
failures.append(exc)
|
|
962
|
+
if links_commit_attempted:
|
|
963
|
+
try:
|
|
964
|
+
committed = _committed_link_mutations(mutations)
|
|
965
|
+
if committed:
|
|
966
|
+
rollback_binding_projection(committed)
|
|
967
|
+
except Exception as exc:
|
|
968
|
+
failures.append(exc)
|
|
969
|
+
for prepared in reversed(tuple(prepared_repos)):
|
|
970
|
+
try:
|
|
971
|
+
_restore_new_checkout(prepared)
|
|
972
|
+
except Exception as exc:
|
|
973
|
+
failures.append(exc)
|
|
974
|
+
if git_service is not None:
|
|
975
|
+
for prepared in reversed(tuple(prepared_repos)):
|
|
976
|
+
try:
|
|
977
|
+
git_service.rollback(prepared.branch)
|
|
978
|
+
except Exception as exc:
|
|
979
|
+
failures.append(exc)
|
|
980
|
+
try:
|
|
981
|
+
_cleanup_transaction_paths(
|
|
982
|
+
project_dir,
|
|
983
|
+
transaction_id=transaction_id,
|
|
984
|
+
staged_manifest=staged_manifest,
|
|
985
|
+
desired_bytes=desired_bytes,
|
|
986
|
+
mutations=mutations,
|
|
987
|
+
sources_root_existed=sources_root_existed,
|
|
988
|
+
)
|
|
989
|
+
except Exception as exc:
|
|
990
|
+
failures.append(exc)
|
|
991
|
+
return tuple(failures)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _failure_detail(
|
|
995
|
+
original: BaseException,
|
|
996
|
+
*,
|
|
997
|
+
phase: str,
|
|
998
|
+
rollback_failures: Sequence[Exception],
|
|
999
|
+
) -> str:
|
|
1000
|
+
root = f"{original.__class__.__name__}: {original}"
|
|
1001
|
+
if rollback_failures:
|
|
1002
|
+
rollback = "failed: " + "; ".join(
|
|
1003
|
+
f"{failure.__class__.__name__}: {failure}" for failure in rollback_failures
|
|
1004
|
+
)
|
|
1005
|
+
else:
|
|
1006
|
+
rollback = "ok"
|
|
1007
|
+
return f"phase={phase}; cause={root}; rollback={rollback}"
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _verify_manifest_snapshot(
|
|
1011
|
+
path: Path, expected: bytes | None, *, action: str
|
|
1012
|
+
) -> None:
|
|
1013
|
+
if _read_manifest_snapshot(path) != expected:
|
|
1014
|
+
raise _error(
|
|
1015
|
+
"PZ_DEV_MANIFEST_CHANGED",
|
|
1016
|
+
"dev manifest changed after transaction preflight.",
|
|
1017
|
+
action=action,
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _execute_transaction(
|
|
1022
|
+
*,
|
|
1023
|
+
operation: Operation,
|
|
1024
|
+
project_dir_value: object,
|
|
1025
|
+
locator_values: object,
|
|
1026
|
+
base_branch_value: object,
|
|
1027
|
+
app_name: str | None,
|
|
1028
|
+
action: str,
|
|
1029
|
+
) -> DevTransactionResult:
|
|
1030
|
+
project_dir = _project_root(project_dir_value, action=action)
|
|
1031
|
+
base_branch = _base_branch(base_branch_value, action=action)
|
|
1032
|
+
locators = _locators(locator_values, action=action)
|
|
1033
|
+
manifest_path = resolve_sources_manifest_path(project_dir)
|
|
1034
|
+
transaction_id = f"pz-{uuid.uuid4().hex}"
|
|
1035
|
+
sources_root_existed = manifest_path.parent.exists()
|
|
1036
|
+
before_bytes: bytes | None = None
|
|
1037
|
+
desired_bytes = b""
|
|
1038
|
+
git_service: DevGitService | None = None
|
|
1039
|
+
prepared_repos: list[_PreparedRepo] = []
|
|
1040
|
+
mutations: tuple[LinkMutation, ...] = ()
|
|
1041
|
+
links_commit_attempted = False
|
|
1042
|
+
staged_manifest: Path | None = None
|
|
1043
|
+
manifest_replace_attempted = False
|
|
1044
|
+
phase = "load"
|
|
1045
|
+
|
|
1046
|
+
try:
|
|
1047
|
+
_transaction_phase(phase)
|
|
1048
|
+
before_bytes = _read_manifest_snapshot(manifest_path)
|
|
1049
|
+
loaded = load_dev_manifest(project_dir)
|
|
1050
|
+
if loaded is None and operation != "init":
|
|
1051
|
+
raise _error(
|
|
1052
|
+
_MANIFEST_MISSING,
|
|
1053
|
+
"dev manifest is missing; run pz dev init first.",
|
|
1054
|
+
action=action,
|
|
1055
|
+
)
|
|
1056
|
+
before = _business_manifest(
|
|
1057
|
+
project_dir,
|
|
1058
|
+
loaded,
|
|
1059
|
+
app_name=app_name,
|
|
1060
|
+
action=action,
|
|
1061
|
+
)
|
|
1062
|
+
if _read_manifest_snapshot(manifest_path) != before_bytes:
|
|
1063
|
+
raise _error(
|
|
1064
|
+
"PZ_DEV_MANIFEST_INVALID",
|
|
1065
|
+
"dev manifest changed while it was being loaded.",
|
|
1066
|
+
action=action,
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
phase = "resolve"
|
|
1070
|
+
_transaction_phase(phase)
|
|
1071
|
+
resolved_sources = tuple(
|
|
1072
|
+
resolve_source_locator(locator, project_dir=project_dir, action=action)
|
|
1073
|
+
for locator in locators
|
|
1074
|
+
)
|
|
1075
|
+
desired, intents, selected_bindings = _build_desired_manifest(
|
|
1076
|
+
project_dir,
|
|
1077
|
+
before,
|
|
1078
|
+
resolved_sources,
|
|
1079
|
+
operation=operation,
|
|
1080
|
+
base_branch=base_branch,
|
|
1081
|
+
action=action,
|
|
1082
|
+
)
|
|
1083
|
+
desired_bytes = manifest_bytes(desired)
|
|
1084
|
+
|
|
1085
|
+
phase = "preflight"
|
|
1086
|
+
_transaction_phase(phase)
|
|
1087
|
+
_preflight_paths(project_dir, manifest_path)
|
|
1088
|
+
_preflight_projection(project_dir, before, desired)
|
|
1089
|
+
for source in resolved_sources:
|
|
1090
|
+
if source.source_kind == "link":
|
|
1091
|
+
_validate_source_layout(
|
|
1092
|
+
source,
|
|
1093
|
+
source_root=Path(source.clone_source).expanduser().resolve(),
|
|
1094
|
+
)
|
|
1095
|
+
continue
|
|
1096
|
+
assert source.repo_key is not None
|
|
1097
|
+
intent = next(item for item in intents if item.repo_key == source.repo_key)
|
|
1098
|
+
if intent.checkout.exists():
|
|
1099
|
+
_validate_source_layout(source, source_root=intent.checkout)
|
|
1100
|
+
plans = tuple(
|
|
1101
|
+
_git_plan(
|
|
1102
|
+
project_dir,
|
|
1103
|
+
intent,
|
|
1104
|
+
business_branch=before.business_app_id,
|
|
1105
|
+
base_branch=base_branch,
|
|
1106
|
+
transaction_id=transaction_id,
|
|
1107
|
+
)
|
|
1108
|
+
for intent in intents
|
|
1109
|
+
)
|
|
1110
|
+
if plans:
|
|
1111
|
+
git_service = _new_git_service()
|
|
1112
|
+
for plan in plans:
|
|
1113
|
+
git_service.preflight_existing(plan)
|
|
1114
|
+
|
|
1115
|
+
phase = "git_prepare"
|
|
1116
|
+
_transaction_phase(phase)
|
|
1117
|
+
if git_service is not None:
|
|
1118
|
+
for intent, plan in zip(intents, plans, strict=True):
|
|
1119
|
+
prepared = _PreparedRepo(
|
|
1120
|
+
intent=intent, branch=git_service.prepare(plan)
|
|
1121
|
+
)
|
|
1122
|
+
prepared_repos.append(prepared)
|
|
1123
|
+
if prepared.branch.new_clone:
|
|
1124
|
+
_inject_fault("after_clone")
|
|
1125
|
+
_inject_fault("after_branch")
|
|
1126
|
+
prepared_by_key = {
|
|
1127
|
+
prepared.intent.repo_key: prepared for prepared in prepared_repos
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
phase = "layout"
|
|
1131
|
+
_transaction_phase(phase)
|
|
1132
|
+
for source in resolved_sources:
|
|
1133
|
+
_validate_source_layout(
|
|
1134
|
+
source,
|
|
1135
|
+
source_root=_prepared_source_root(source, prepared_by_key),
|
|
1136
|
+
)
|
|
1137
|
+
_inject_fault("layout")
|
|
1138
|
+
|
|
1139
|
+
phase = "stage"
|
|
1140
|
+
_transaction_phase(phase)
|
|
1141
|
+
if desired_bytes != before_bytes:
|
|
1142
|
+
staged_manifest = manifest_path.with_name(
|
|
1143
|
+
f".{manifest_path.name}.{transaction_id}.tmp"
|
|
1144
|
+
)
|
|
1145
|
+
_write_staged_manifest(staged_manifest, desired_bytes)
|
|
1146
|
+
shadow, staged_final_paths = _shadow_manifest(desired, prepared_by_key)
|
|
1147
|
+
shadow_mutations = plan_binding_projection(project_dir, before, shadow)
|
|
1148
|
+
mutations = _final_link_mutations(
|
|
1149
|
+
project_dir,
|
|
1150
|
+
desired,
|
|
1151
|
+
shadow_mutations,
|
|
1152
|
+
staged_final_paths,
|
|
1153
|
+
)
|
|
1154
|
+
stage_binding_projection(shadow_mutations)
|
|
1155
|
+
_retarget_staged_links(
|
|
1156
|
+
shadow_mutations,
|
|
1157
|
+
mutations,
|
|
1158
|
+
)
|
|
1159
|
+
_inject_fault("stage")
|
|
1160
|
+
|
|
1161
|
+
phase = "place"
|
|
1162
|
+
_transaction_phase(phase)
|
|
1163
|
+
_verify_manifest_snapshot(manifest_path, before_bytes, action=action)
|
|
1164
|
+
for prepared in prepared_repos:
|
|
1165
|
+
if not prepared.branch.new_clone:
|
|
1166
|
+
continue
|
|
1167
|
+
final = prepared.intent.checkout
|
|
1168
|
+
if os.path.lexists(final):
|
|
1169
|
+
raise _error(
|
|
1170
|
+
BINDING_CONFLICT_CODE,
|
|
1171
|
+
f"Git checkout destination became occupied: {final}",
|
|
1172
|
+
action=action,
|
|
1173
|
+
)
|
|
1174
|
+
_atomic_replace(prepared.branch.checkout, final)
|
|
1175
|
+
_inject_fault("place")
|
|
1176
|
+
|
|
1177
|
+
phase = "links"
|
|
1178
|
+
_transaction_phase(phase)
|
|
1179
|
+
_verify_manifest_snapshot(manifest_path, before_bytes, action=action)
|
|
1180
|
+
links_commit_attempted = True
|
|
1181
|
+
commit_binding_projection(mutations)
|
|
1182
|
+
_inject_fault("links")
|
|
1183
|
+
|
|
1184
|
+
phase = "manifest"
|
|
1185
|
+
_transaction_phase(phase)
|
|
1186
|
+
if staged_manifest is not None:
|
|
1187
|
+
_verify_manifest_snapshot(manifest_path, before_bytes, action=action)
|
|
1188
|
+
manifest_replace_attempted = True
|
|
1189
|
+
_atomic_replace(staged_manifest, manifest_path)
|
|
1190
|
+
_fsync_directory(manifest_path.parent)
|
|
1191
|
+
_inject_fault("manifest")
|
|
1192
|
+
|
|
1193
|
+
phase = "cleanup"
|
|
1194
|
+
_transaction_phase(phase)
|
|
1195
|
+
_cleanup_transaction_paths(
|
|
1196
|
+
project_dir,
|
|
1197
|
+
transaction_id=transaction_id,
|
|
1198
|
+
staged_manifest=staged_manifest,
|
|
1199
|
+
desired_bytes=desired_bytes,
|
|
1200
|
+
mutations=mutations,
|
|
1201
|
+
sources_root_existed=sources_root_existed,
|
|
1202
|
+
)
|
|
1203
|
+
return DevTransactionResult(
|
|
1204
|
+
operation=operation,
|
|
1205
|
+
project_dir=project_dir,
|
|
1206
|
+
business_branch=before.business_app_id,
|
|
1207
|
+
base_branch=base_branch,
|
|
1208
|
+
repo_keys=tuple(intent.repo_key for intent in intents),
|
|
1209
|
+
checkouts=tuple(intent.checkout for intent in intents),
|
|
1210
|
+
bindings=selected_bindings,
|
|
1211
|
+
links=tuple(mutation.final_path for mutation in mutations),
|
|
1212
|
+
)
|
|
1213
|
+
except BaseException as original:
|
|
1214
|
+
rollback_failures = _rollback_transaction(
|
|
1215
|
+
project_dir,
|
|
1216
|
+
manifest_path=manifest_path,
|
|
1217
|
+
before_bytes=before_bytes,
|
|
1218
|
+
desired_bytes=desired_bytes,
|
|
1219
|
+
prepared_repos=prepared_repos,
|
|
1220
|
+
git_service=git_service,
|
|
1221
|
+
mutations=mutations,
|
|
1222
|
+
links_commit_attempted=links_commit_attempted,
|
|
1223
|
+
staged_manifest=staged_manifest,
|
|
1224
|
+
manifest_replace_attempted=manifest_replace_attempted,
|
|
1225
|
+
transaction_id=transaction_id,
|
|
1226
|
+
sources_root_existed=sources_root_existed,
|
|
1227
|
+
)
|
|
1228
|
+
if isinstance(original, PzError) and not rollback_failures:
|
|
1229
|
+
raise
|
|
1230
|
+
wrapped = _error(
|
|
1231
|
+
_TRANSACTION_FAILED,
|
|
1232
|
+
"dev source transaction failed.",
|
|
1233
|
+
action=action,
|
|
1234
|
+
detail=_failure_detail(
|
|
1235
|
+
original,
|
|
1236
|
+
phase=phase,
|
|
1237
|
+
rollback_failures=rollback_failures,
|
|
1238
|
+
),
|
|
1239
|
+
)
|
|
1240
|
+
raise wrapped from original
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def add_dev_source(request: AddDevSourceRequest) -> DevTransactionResult:
|
|
1244
|
+
if not isinstance(request, AddDevSourceRequest):
|
|
1245
|
+
raise _error(
|
|
1246
|
+
_REQUEST_INVALID,
|
|
1247
|
+
"add_dev_source requires an AddDevSourceRequest.",
|
|
1248
|
+
action="add_dev_source",
|
|
1249
|
+
)
|
|
1250
|
+
return _execute_transaction(
|
|
1251
|
+
operation="add",
|
|
1252
|
+
project_dir_value=request.project_dir,
|
|
1253
|
+
locator_values=request.locators,
|
|
1254
|
+
base_branch_value=request.base_branch,
|
|
1255
|
+
app_name=None,
|
|
1256
|
+
action="add_dev_source",
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def branch_dev_source(request: BranchDevSourceRequest) -> DevTransactionResult:
|
|
1261
|
+
if not isinstance(request, BranchDevSourceRequest):
|
|
1262
|
+
raise _error(
|
|
1263
|
+
_REQUEST_INVALID,
|
|
1264
|
+
"branch_dev_source requires a BranchDevSourceRequest.",
|
|
1265
|
+
action="branch_dev_source",
|
|
1266
|
+
)
|
|
1267
|
+
project_dir = _project_root(request.project_dir, action="branch_dev_source")
|
|
1268
|
+
locator = _locators((request.locator,), action="branch_dev_source")[0]
|
|
1269
|
+
manifest = load_dev_manifest(project_dir)
|
|
1270
|
+
if manifest is not None:
|
|
1271
|
+
repo_key = _repo_key_for_locator(
|
|
1272
|
+
project_dir,
|
|
1273
|
+
manifest,
|
|
1274
|
+
locator,
|
|
1275
|
+
action="branch_dev_source",
|
|
1276
|
+
)
|
|
1277
|
+
checkout = project_dir / Path(*manifest.repos[repo_key].checkout.parts)
|
|
1278
|
+
if checkout.is_symlink():
|
|
1279
|
+
raise _error(
|
|
1280
|
+
"PZ_DEV_SHARED_CHECKOUT_READ_ONLY",
|
|
1281
|
+
"Shared checkout is externally owned and read-only to PZ authoring commands.",
|
|
1282
|
+
action="branch_dev_source",
|
|
1283
|
+
detail=repo_key,
|
|
1284
|
+
)
|
|
1285
|
+
return _execute_transaction(
|
|
1286
|
+
operation="branch",
|
|
1287
|
+
project_dir_value=project_dir,
|
|
1288
|
+
locator_values=(locator,),
|
|
1289
|
+
base_branch_value=request.base_branch,
|
|
1290
|
+
app_name=None,
|
|
1291
|
+
action="branch_dev_source",
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def initialize_dev_sources(request: InitDevSourcesRequest) -> DevTransactionResult:
|
|
1296
|
+
if not isinstance(request, InitDevSourcesRequest):
|
|
1297
|
+
raise _error(
|
|
1298
|
+
_REQUEST_INVALID,
|
|
1299
|
+
"initialize_dev_sources requires an InitDevSourcesRequest.",
|
|
1300
|
+
action="initialize_dev_sources",
|
|
1301
|
+
)
|
|
1302
|
+
return _execute_transaction(
|
|
1303
|
+
operation="init",
|
|
1304
|
+
project_dir_value=request.project_dir,
|
|
1305
|
+
locator_values=request.locators,
|
|
1306
|
+
base_branch_value=request.base_branch,
|
|
1307
|
+
app_name=request.app_name,
|
|
1308
|
+
action="initialize_dev_sources",
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def _repo_key_for_locator(
|
|
1313
|
+
project_dir: Path,
|
|
1314
|
+
manifest: DevManifest,
|
|
1315
|
+
locator: str,
|
|
1316
|
+
*,
|
|
1317
|
+
action: str,
|
|
1318
|
+
) -> str:
|
|
1319
|
+
if locator in manifest.repos:
|
|
1320
|
+
return locator
|
|
1321
|
+
binding = manifest.bindings.get(locator)
|
|
1322
|
+
if binding is not None:
|
|
1323
|
+
if binding.source_kind != "git" or binding.repo_key is None:
|
|
1324
|
+
raise _error(
|
|
1325
|
+
_SOURCE_NOT_GIT,
|
|
1326
|
+
"development source is not Git-backed.",
|
|
1327
|
+
action=action,
|
|
1328
|
+
detail=locator,
|
|
1329
|
+
)
|
|
1330
|
+
return binding.repo_key
|
|
1331
|
+
resolved = resolve_source_locator(locator, project_dir=project_dir, action=action)
|
|
1332
|
+
if (
|
|
1333
|
+
resolved.source_kind != "git"
|
|
1334
|
+
or resolved.repo_key is None
|
|
1335
|
+
or resolved.repo_key not in manifest.repos
|
|
1336
|
+
):
|
|
1337
|
+
raise _error(
|
|
1338
|
+
_SOURCE_NOT_GIT,
|
|
1339
|
+
"development source is not a managed Git checkout.",
|
|
1340
|
+
action=action,
|
|
1341
|
+
detail=locator,
|
|
1342
|
+
)
|
|
1343
|
+
return resolved.repo_key
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def update_dev_source(request: UpdateDevSourceRequest) -> DevTransactionResult:
|
|
1347
|
+
if not isinstance(request, UpdateDevSourceRequest):
|
|
1348
|
+
raise _error(
|
|
1349
|
+
_REQUEST_INVALID,
|
|
1350
|
+
"update_dev_source requires an UpdateDevSourceRequest.",
|
|
1351
|
+
action="update_dev_source",
|
|
1352
|
+
)
|
|
1353
|
+
action = "update_dev_source"
|
|
1354
|
+
project_dir = _project_root(request.project_dir, action=action)
|
|
1355
|
+
locator = _locators((request.locator,), action=action)[0]
|
|
1356
|
+
manifest = load_dev_manifest(project_dir)
|
|
1357
|
+
if manifest is None:
|
|
1358
|
+
raise _error(
|
|
1359
|
+
_MANIFEST_MISSING,
|
|
1360
|
+
"dev manifest is missing; run pz dev init first.",
|
|
1361
|
+
action=action,
|
|
1362
|
+
)
|
|
1363
|
+
repo_key = _repo_key_for_locator(
|
|
1364
|
+
project_dir,
|
|
1365
|
+
manifest,
|
|
1366
|
+
locator,
|
|
1367
|
+
action=action,
|
|
1368
|
+
)
|
|
1369
|
+
repo = manifest.repos[repo_key]
|
|
1370
|
+
checkout = project_dir / Path(*repo.checkout.parts)
|
|
1371
|
+
if checkout.is_symlink():
|
|
1372
|
+
raise _error(
|
|
1373
|
+
"PZ_DEV_SHARED_CHECKOUT_READ_ONLY",
|
|
1374
|
+
"Shared checkout is externally owned and read-only to PZ authoring commands.",
|
|
1375
|
+
action=action,
|
|
1376
|
+
detail=repo_key,
|
|
1377
|
+
)
|
|
1378
|
+
service = _new_git_service()
|
|
1379
|
+
update = service.update(checkout, expected_branch=manifest.business_app_id)
|
|
1380
|
+
repo_bindings = tuple(
|
|
1381
|
+
key
|
|
1382
|
+
for key, binding in sorted(manifest.bindings.items())
|
|
1383
|
+
if binding.repo_key == repo_key
|
|
1384
|
+
)
|
|
1385
|
+
return DevTransactionResult(
|
|
1386
|
+
operation="update",
|
|
1387
|
+
project_dir=project_dir,
|
|
1388
|
+
business_branch=manifest.business_app_id,
|
|
1389
|
+
base_branch=repo.base_branch,
|
|
1390
|
+
repo_keys=(repo_key,),
|
|
1391
|
+
checkouts=(checkout,),
|
|
1392
|
+
bindings=repo_bindings,
|
|
1393
|
+
links=(),
|
|
1394
|
+
updated=update.updated,
|
|
1395
|
+
upstream=update.upstream,
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def _checkout_delete_safety(
|
|
1400
|
+
project_dir: Path,
|
|
1401
|
+
manifest: DevManifest,
|
|
1402
|
+
*,
|
|
1403
|
+
repo_key: str,
|
|
1404
|
+
action: str,
|
|
1405
|
+
) -> tuple[DevGitService, Path]:
|
|
1406
|
+
if any(binding.repo_key == repo_key for binding in manifest.bindings.values()):
|
|
1407
|
+
raise _error(
|
|
1408
|
+
"PZ_DEV_CHECKOUT_DELETE_UNSAFE",
|
|
1409
|
+
"checkout still has managed bindings.",
|
|
1410
|
+
action=action,
|
|
1411
|
+
detail=repo_key,
|
|
1412
|
+
)
|
|
1413
|
+
repo = manifest.repos[repo_key]
|
|
1414
|
+
checkout = project_dir / Path(*repo.checkout.parts)
|
|
1415
|
+
service = _new_git_service()
|
|
1416
|
+
if checkout.is_symlink():
|
|
1417
|
+
return service, checkout
|
|
1418
|
+
try:
|
|
1419
|
+
service.preflight_delete(
|
|
1420
|
+
checkout,
|
|
1421
|
+
expected_branch=manifest.business_app_id,
|
|
1422
|
+
)
|
|
1423
|
+
except PzError as exc:
|
|
1424
|
+
raise _error(
|
|
1425
|
+
"PZ_DEV_CHECKOUT_DELETE_UNSAFE",
|
|
1426
|
+
"checkout does not satisfy the deletion safety contract.",
|
|
1427
|
+
action=action,
|
|
1428
|
+
detail=f"repo={repo_key}; reason={exc.code}",
|
|
1429
|
+
) from exc
|
|
1430
|
+
return service, checkout
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def _quarantine_checkout(
|
|
1434
|
+
project_dir: Path,
|
|
1435
|
+
checkout: Path,
|
|
1436
|
+
*,
|
|
1437
|
+
transaction_id: str,
|
|
1438
|
+
repo_key: str,
|
|
1439
|
+
) -> Path:
|
|
1440
|
+
sources_root = project_dir / "apps" / ".sources"
|
|
1441
|
+
trash_root = sources_root / ".trash" / transaction_id
|
|
1442
|
+
for component in (sources_root, sources_root / ".trash", trash_root):
|
|
1443
|
+
if component.is_symlink():
|
|
1444
|
+
raise RuntimeError("checkout quarantine path contains a symbolic link")
|
|
1445
|
+
trash_root.mkdir(parents=True, exist_ok=False)
|
|
1446
|
+
quarantine = trash_root / repo_key
|
|
1447
|
+
_OS_REPLACE(checkout, quarantine)
|
|
1448
|
+
_fsync_directory(sources_root)
|
|
1449
|
+
return quarantine
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def _cleanup_quarantine(path: Path) -> None:
|
|
1453
|
+
try:
|
|
1454
|
+
if path.is_symlink():
|
|
1455
|
+
path.unlink()
|
|
1456
|
+
else:
|
|
1457
|
+
shutil.rmtree(path)
|
|
1458
|
+
path.parent.rmdir()
|
|
1459
|
+
path.parent.parent.rmdir()
|
|
1460
|
+
except OSError:
|
|
1461
|
+
# The checkout is already atomically detached from managed state. A later
|
|
1462
|
+
# cleanup may remove the owner-only hidden quarantine.
|
|
1463
|
+
return
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def remove_dev_source(request: RemoveDevSourceRequest) -> DevTransactionResult:
|
|
1467
|
+
if not isinstance(request, RemoveDevSourceRequest):
|
|
1468
|
+
raise _error(
|
|
1469
|
+
_REQUEST_INVALID,
|
|
1470
|
+
"remove_dev_source requires a RemoveDevSourceRequest.",
|
|
1471
|
+
action="remove_dev_source",
|
|
1472
|
+
)
|
|
1473
|
+
action = "remove_dev_source"
|
|
1474
|
+
project_dir = _project_root(request.project_dir, action=action)
|
|
1475
|
+
binding_key = _locators((request.binding,), action=action)[0]
|
|
1476
|
+
manifest_path = resolve_sources_manifest_path(project_dir)
|
|
1477
|
+
before_bytes = _read_manifest_snapshot(manifest_path)
|
|
1478
|
+
before = load_dev_manifest(project_dir)
|
|
1479
|
+
if before is None or before_bytes is None:
|
|
1480
|
+
raise _error(
|
|
1481
|
+
_MANIFEST_MISSING,
|
|
1482
|
+
"dev manifest is missing; run pz dev init first.",
|
|
1483
|
+
action=action,
|
|
1484
|
+
)
|
|
1485
|
+
binding = before.bindings.get(binding_key)
|
|
1486
|
+
if binding is None:
|
|
1487
|
+
raise _error(
|
|
1488
|
+
"PZ_DEV_BINDING_MISSING",
|
|
1489
|
+
f"managed binding does not exist: {binding_key}",
|
|
1490
|
+
action=action,
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
repos = dict(before.repos)
|
|
1494
|
+
bindings = dict(before.bindings)
|
|
1495
|
+
bindings.pop(binding_key)
|
|
1496
|
+
repo_key = binding.repo_key
|
|
1497
|
+
if request.delete_checkout:
|
|
1498
|
+
if binding.source_kind != "git" or repo_key is None:
|
|
1499
|
+
raise _error(
|
|
1500
|
+
"PZ_DEV_CHECKOUT_DELETE_UNSAFE",
|
|
1501
|
+
"direct-link bindings do not own a managed checkout.",
|
|
1502
|
+
action=action,
|
|
1503
|
+
)
|
|
1504
|
+
if any(candidate.repo_key == repo_key for candidate in bindings.values()):
|
|
1505
|
+
raise _error(
|
|
1506
|
+
"PZ_DEV_CHECKOUT_DELETE_UNSAFE",
|
|
1507
|
+
"checkout still has managed bindings.",
|
|
1508
|
+
action=action,
|
|
1509
|
+
detail=repo_key,
|
|
1510
|
+
)
|
|
1511
|
+
repos.pop(repo_key)
|
|
1512
|
+
desired = DevManifest(
|
|
1513
|
+
schema_version=5,
|
|
1514
|
+
business_app_id=before.business_app_id,
|
|
1515
|
+
repos=repos,
|
|
1516
|
+
bindings=bindings,
|
|
1517
|
+
)
|
|
1518
|
+
validate_dev_manifest(project_dir, desired)
|
|
1519
|
+
removals = plan_binding_removals(project_dir, before, desired)
|
|
1520
|
+
desired_bytes = manifest_bytes(desired)
|
|
1521
|
+
transaction_id = f"pz-{uuid.uuid4().hex}"
|
|
1522
|
+
staged_manifest = manifest_path.with_name(
|
|
1523
|
+
f".{manifest_path.name}.{transaction_id}.tmp"
|
|
1524
|
+
)
|
|
1525
|
+
delete_checkout: Path | None = None
|
|
1526
|
+
if request.delete_checkout:
|
|
1527
|
+
assert repo_key is not None
|
|
1528
|
+
safety_manifest = DevManifest(
|
|
1529
|
+
schema_version=5,
|
|
1530
|
+
business_app_id=before.business_app_id,
|
|
1531
|
+
repos=before.repos,
|
|
1532
|
+
bindings=bindings,
|
|
1533
|
+
)
|
|
1534
|
+
_, delete_checkout = _checkout_delete_safety(
|
|
1535
|
+
project_dir,
|
|
1536
|
+
safety_manifest,
|
|
1537
|
+
repo_key=repo_key,
|
|
1538
|
+
action=action,
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
links_committed = False
|
|
1542
|
+
manifest_committed = False
|
|
1543
|
+
quarantine: Path | None = None
|
|
1544
|
+
try:
|
|
1545
|
+
_write_staged_manifest(staged_manifest, desired_bytes)
|
|
1546
|
+
if _read_manifest_snapshot(manifest_path) != before_bytes:
|
|
1547
|
+
raise _error(
|
|
1548
|
+
"PZ_DEV_MANIFEST_INVALID",
|
|
1549
|
+
"dev manifest changed before binding removal.",
|
|
1550
|
+
action=action,
|
|
1551
|
+
)
|
|
1552
|
+
commit_binding_projection((), removals)
|
|
1553
|
+
links_committed = True
|
|
1554
|
+
_atomic_replace(staged_manifest, manifest_path)
|
|
1555
|
+
_fsync_directory(manifest_path.parent)
|
|
1556
|
+
manifest_committed = True
|
|
1557
|
+
if delete_checkout is not None:
|
|
1558
|
+
assert repo_key is not None
|
|
1559
|
+
quarantine = _quarantine_checkout(
|
|
1560
|
+
project_dir,
|
|
1561
|
+
delete_checkout,
|
|
1562
|
+
transaction_id=transaction_id,
|
|
1563
|
+
repo_key=repo_key,
|
|
1564
|
+
)
|
|
1565
|
+
except BaseException as original:
|
|
1566
|
+
failures: list[Exception] = []
|
|
1567
|
+
if quarantine is not None and delete_checkout is not None:
|
|
1568
|
+
try:
|
|
1569
|
+
_OS_REPLACE(quarantine, delete_checkout)
|
|
1570
|
+
except Exception as exc:
|
|
1571
|
+
failures.append(exc)
|
|
1572
|
+
if manifest_committed:
|
|
1573
|
+
try:
|
|
1574
|
+
_restore_manifest(
|
|
1575
|
+
manifest_path,
|
|
1576
|
+
before_bytes=before_bytes,
|
|
1577
|
+
desired_bytes=desired_bytes,
|
|
1578
|
+
transaction_id=transaction_id,
|
|
1579
|
+
)
|
|
1580
|
+
except Exception as exc:
|
|
1581
|
+
failures.append(exc)
|
|
1582
|
+
if links_committed:
|
|
1583
|
+
try:
|
|
1584
|
+
rollback_binding_projection((), removals)
|
|
1585
|
+
except Exception as exc:
|
|
1586
|
+
failures.append(exc)
|
|
1587
|
+
staged_manifest.unlink(missing_ok=True)
|
|
1588
|
+
if isinstance(original, PzError) and not failures:
|
|
1589
|
+
raise
|
|
1590
|
+
raise _error(
|
|
1591
|
+
_TRANSACTION_FAILED,
|
|
1592
|
+
"dev source removal transaction failed.",
|
|
1593
|
+
action=action,
|
|
1594
|
+
detail=_failure_detail(
|
|
1595
|
+
original,
|
|
1596
|
+
phase="remove",
|
|
1597
|
+
rollback_failures=failures,
|
|
1598
|
+
),
|
|
1599
|
+
) from original
|
|
1600
|
+
finally:
|
|
1601
|
+
staged_manifest.unlink(missing_ok=True)
|
|
1602
|
+
|
|
1603
|
+
if quarantine is not None:
|
|
1604
|
+
_cleanup_quarantine(quarantine)
|
|
1605
|
+
link_path = project_dir / Path(*binding.link.parts)
|
|
1606
|
+
checkout_paths = (
|
|
1607
|
+
()
|
|
1608
|
+
if repo_key is None
|
|
1609
|
+
else (project_dir / Path(*before.repos[repo_key].checkout.parts),)
|
|
1610
|
+
)
|
|
1611
|
+
return DevTransactionResult(
|
|
1612
|
+
operation="remove",
|
|
1613
|
+
project_dir=project_dir,
|
|
1614
|
+
business_branch=before.business_app_id,
|
|
1615
|
+
base_branch=(
|
|
1616
|
+
before.repos[repo_key].base_branch if repo_key is not None else "develop"
|
|
1617
|
+
),
|
|
1618
|
+
repo_keys=() if repo_key is None else (repo_key,),
|
|
1619
|
+
checkouts=checkout_paths,
|
|
1620
|
+
bindings=(binding_key,),
|
|
1621
|
+
links=(link_path,),
|
|
1622
|
+
checkout_deleted=delete_checkout is not None,
|
|
1623
|
+
)
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
__all__ = [
|
|
1627
|
+
"AddDevSourceRequest",
|
|
1628
|
+
"BranchDevSourceRequest",
|
|
1629
|
+
"DevTransactionResult",
|
|
1630
|
+
"InitDevSourcesRequest",
|
|
1631
|
+
"RemoveDevSourceRequest",
|
|
1632
|
+
"UpdateDevSourceRequest",
|
|
1633
|
+
"add_dev_source",
|
|
1634
|
+
"branch_dev_source",
|
|
1635
|
+
"initialize_dev_sources",
|
|
1636
|
+
"remove_dev_source",
|
|
1637
|
+
"update_dev_source",
|
|
1638
|
+
]
|