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,1166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from threading import RLock
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from concurrent.futures import Future
|
|
13
|
+
from threading import Event, Thread
|
|
14
|
+
from typing import Any, Callable, Generic, Iterable, Optional, Sequence, TypeVar
|
|
15
|
+
from urllib.parse import unquote, urlparse
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import redis
|
|
19
|
+
except Exception: # pragma: no cover
|
|
20
|
+
redis = None # type: ignore
|
|
21
|
+
|
|
22
|
+
import pz_settings_center.public as settings_center
|
|
23
|
+
from ..observability import get_logger
|
|
24
|
+
from .redis_manager import RedisConfig, RedisManager
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolved_settings(*, project_dir: str | os.PathLike[str] | None = None):
|
|
31
|
+
if project_dir is None:
|
|
32
|
+
return settings_center.settings
|
|
33
|
+
return settings_center.resolve_settings(project_dir=str(Path(project_dir).resolve()))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _project_settings(*, project_dir: str | os.PathLike[str] | None = None):
|
|
37
|
+
try:
|
|
38
|
+
return _resolved_settings(project_dir=project_dir).project
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _foundation_settings_section(
|
|
44
|
+
name: str,
|
|
45
|
+
*,
|
|
46
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
47
|
+
):
|
|
48
|
+
section_name = str(name or "").strip()
|
|
49
|
+
try:
|
|
50
|
+
resolved = _resolved_settings(project_dir=project_dir)
|
|
51
|
+
if hasattr(resolved, "app_settings"):
|
|
52
|
+
foundation_payload = resolved.app_settings("pz_foundation")
|
|
53
|
+
if isinstance(foundation_payload, dict):
|
|
54
|
+
section = foundation_payload.get(section_name, {})
|
|
55
|
+
if section not in (None, {}):
|
|
56
|
+
return section
|
|
57
|
+
foundation_settings = getattr(resolved, "pz_foundation", None)
|
|
58
|
+
if isinstance(foundation_settings, dict):
|
|
59
|
+
return foundation_settings.get(section_name, {})
|
|
60
|
+
if foundation_settings is None:
|
|
61
|
+
return {}
|
|
62
|
+
return getattr(foundation_settings, section_name, {})
|
|
63
|
+
except Exception:
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class FoundationRedisSettings:
|
|
69
|
+
enabled: bool = False
|
|
70
|
+
host: str = ""
|
|
71
|
+
port: int = 6379
|
|
72
|
+
db: int = 0
|
|
73
|
+
password: str | None = None
|
|
74
|
+
url: str | None = None
|
|
75
|
+
service: str = "mymaster"
|
|
76
|
+
allow_cluster: bool = False
|
|
77
|
+
connect_timeout_seconds: float = 1.0
|
|
78
|
+
socket_timeout_seconds: float = 1.0
|
|
79
|
+
operation_max_attempts: int = 2
|
|
80
|
+
connection_retry_attempts: int = 0
|
|
81
|
+
retry_base_delay_seconds: float = 0.05
|
|
82
|
+
retry_max_delay_seconds: float = 0.2
|
|
83
|
+
acquire_failure_cooldown_seconds: float = 2.0
|
|
84
|
+
health_failure_cooldown_seconds: float = 2.0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FoundationRedisUnavailable(RuntimeError):
|
|
88
|
+
"""Raised when a required foundation Redis capability is disabled, misconfigured, or unavailable."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _split_redis_host_entry(raw_entry: str, default_port: int) -> tuple[str, int]:
|
|
92
|
+
entry = str(raw_entry or "").strip()
|
|
93
|
+
if not entry:
|
|
94
|
+
return "", default_port
|
|
95
|
+
if entry.startswith("["):
|
|
96
|
+
closing = entry.find("]")
|
|
97
|
+
if closing > 0:
|
|
98
|
+
host = entry[1:closing].strip()
|
|
99
|
+
remainder = entry[closing + 1 :].strip()
|
|
100
|
+
if remainder.startswith(":"):
|
|
101
|
+
try:
|
|
102
|
+
return host, int(remainder[1:] or default_port)
|
|
103
|
+
except Exception:
|
|
104
|
+
return host, default_port
|
|
105
|
+
return host, default_port
|
|
106
|
+
if entry.count(":") == 1:
|
|
107
|
+
host, raw_port = entry.rsplit(":", 1)
|
|
108
|
+
try:
|
|
109
|
+
return host.strip(), int(raw_port or default_port)
|
|
110
|
+
except Exception:
|
|
111
|
+
return host.strip(), default_port
|
|
112
|
+
return entry, default_port
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parse_redis_endpoints(raw: str | None, default_port: int) -> tuple[tuple[str, int], ...]:
|
|
116
|
+
endpoints: list[tuple[str, int]] = []
|
|
117
|
+
for part in str(raw or "").split(","):
|
|
118
|
+
host, port = _split_redis_host_entry(part, default_port)
|
|
119
|
+
if not host:
|
|
120
|
+
continue
|
|
121
|
+
endpoints.append((host, port))
|
|
122
|
+
return tuple(endpoints)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _split_redis_netloc(netloc: str) -> tuple[str, str]:
|
|
126
|
+
text = str(netloc or "").strip()
|
|
127
|
+
if "@" not in text:
|
|
128
|
+
return "", text
|
|
129
|
+
userinfo, hostspec = text.rsplit("@", 1)
|
|
130
|
+
return userinfo, hostspec
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_redis_url_password(userinfo: str) -> str | None:
|
|
134
|
+
text = str(userinfo or "")
|
|
135
|
+
if ":" not in text:
|
|
136
|
+
return None
|
|
137
|
+
_username, _separator, raw_password = text.partition(":")
|
|
138
|
+
if not raw_password:
|
|
139
|
+
return None
|
|
140
|
+
return unquote(raw_password)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _build_redis_config_from_resolved_values(
|
|
144
|
+
*,
|
|
145
|
+
host: str,
|
|
146
|
+
port: int,
|
|
147
|
+
db: int,
|
|
148
|
+
password: str | None,
|
|
149
|
+
service: str,
|
|
150
|
+
allow_cluster: bool,
|
|
151
|
+
connect_timeout_seconds: float,
|
|
152
|
+
socket_timeout_seconds: float,
|
|
153
|
+
operation_max_attempts: int,
|
|
154
|
+
connection_retry_attempts: int,
|
|
155
|
+
retry_base_delay_seconds: float,
|
|
156
|
+
retry_max_delay_seconds: float,
|
|
157
|
+
acquire_failure_cooldown_seconds: float,
|
|
158
|
+
health_failure_cooldown_seconds: float,
|
|
159
|
+
) -> RedisConfig:
|
|
160
|
+
endpoints = _parse_redis_endpoints(host, int(port or 6379))
|
|
161
|
+
base_kwargs = {
|
|
162
|
+
"db": db,
|
|
163
|
+
"password": password,
|
|
164
|
+
"sentinel_service": str(service or "mymaster") or "mymaster",
|
|
165
|
+
"allow_cluster": bool(allow_cluster),
|
|
166
|
+
"connect_timeout_seconds": connect_timeout_seconds,
|
|
167
|
+
"socket_timeout_seconds": socket_timeout_seconds,
|
|
168
|
+
"operation_max_attempts": operation_max_attempts,
|
|
169
|
+
"connection_retry_attempts": connection_retry_attempts,
|
|
170
|
+
"retry_base_delay_seconds": retry_base_delay_seconds,
|
|
171
|
+
"retry_max_delay_seconds": retry_max_delay_seconds,
|
|
172
|
+
"acquire_failure_cooldown_seconds": acquire_failure_cooldown_seconds,
|
|
173
|
+
"health_failure_cooldown_seconds": health_failure_cooldown_seconds,
|
|
174
|
+
}
|
|
175
|
+
if len(endpoints) > 1:
|
|
176
|
+
primary_host, primary_port = endpoints[0]
|
|
177
|
+
return RedisConfig(
|
|
178
|
+
host=primary_host,
|
|
179
|
+
port=primary_port,
|
|
180
|
+
sentinel_hosts=endpoints,
|
|
181
|
+
**base_kwargs,
|
|
182
|
+
)
|
|
183
|
+
if len(endpoints) == 1:
|
|
184
|
+
primary_host, primary_port = endpoints[0]
|
|
185
|
+
return RedisConfig(
|
|
186
|
+
host=primary_host,
|
|
187
|
+
port=primary_port,
|
|
188
|
+
**base_kwargs,
|
|
189
|
+
)
|
|
190
|
+
return RedisConfig(
|
|
191
|
+
host="",
|
|
192
|
+
port=int(port or 6379),
|
|
193
|
+
**base_kwargs,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(slots=True)
|
|
198
|
+
class InMemoryFoundationCache:
|
|
199
|
+
_lock: RLock = field(default_factory=RLock)
|
|
200
|
+
_strings: dict[str, str] = field(default_factory=dict)
|
|
201
|
+
_lists: dict[str, list[str]] = field(default_factory=dict)
|
|
202
|
+
_sets: dict[str, set[str]] = field(default_factory=dict)
|
|
203
|
+
|
|
204
|
+
def get(self, key: str) -> str | None:
|
|
205
|
+
with self._lock:
|
|
206
|
+
return self._strings.get(key)
|
|
207
|
+
|
|
208
|
+
def mget(self, keys: Iterable[str]) -> list[str | None]:
|
|
209
|
+
return [self.get(str(key)) for key in keys]
|
|
210
|
+
|
|
211
|
+
def set(
|
|
212
|
+
self,
|
|
213
|
+
key: str,
|
|
214
|
+
value: object,
|
|
215
|
+
*,
|
|
216
|
+
ttl_seconds: int | None = None,
|
|
217
|
+
ex: int | None = None,
|
|
218
|
+
nx: bool = False,
|
|
219
|
+
) -> bool:
|
|
220
|
+
del ttl_seconds, ex
|
|
221
|
+
with self._lock:
|
|
222
|
+
if nx and key in self._strings:
|
|
223
|
+
return False
|
|
224
|
+
self._strings[key] = str(value)
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
def delete(self, key: str) -> None:
|
|
228
|
+
with self._lock:
|
|
229
|
+
self._strings.pop(key, None)
|
|
230
|
+
self._lists.pop(key, None)
|
|
231
|
+
self._sets.pop(key, None)
|
|
232
|
+
|
|
233
|
+
def delete_if_value_matches(self, key: str, expected_value: object) -> bool:
|
|
234
|
+
with self._lock:
|
|
235
|
+
if self._strings.get(key) != str(expected_value):
|
|
236
|
+
return False
|
|
237
|
+
self._strings.pop(key, None)
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
def sadd(self, key: str, *values: object) -> int:
|
|
241
|
+
with self._lock:
|
|
242
|
+
payload = self._sets.setdefault(key, set())
|
|
243
|
+
before = len(payload)
|
|
244
|
+
payload.update(str(value) for value in values)
|
|
245
|
+
return len(payload) - before
|
|
246
|
+
|
|
247
|
+
def smembers(self, key: str) -> set[str]:
|
|
248
|
+
with self._lock:
|
|
249
|
+
return set(self._sets.get(key, set()))
|
|
250
|
+
|
|
251
|
+
def rpush(self, key: str, *values: object) -> int:
|
|
252
|
+
with self._lock:
|
|
253
|
+
payload = self._lists.setdefault(key, [])
|
|
254
|
+
payload.extend(str(value) for value in values)
|
|
255
|
+
return len(payload)
|
|
256
|
+
|
|
257
|
+
def lrange(self, key: str, start: int, end: int) -> list[str]:
|
|
258
|
+
with self._lock:
|
|
259
|
+
payload = list(self._lists.get(key, []))
|
|
260
|
+
if end == -1:
|
|
261
|
+
return payload[start:]
|
|
262
|
+
return payload[start : end + 1]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(slots=True)
|
|
266
|
+
class RedisFoundationCache:
|
|
267
|
+
settings: FoundationRedisSettings
|
|
268
|
+
_client: Any = field(init=False, repr=False)
|
|
269
|
+
|
|
270
|
+
def __post_init__(self) -> None:
|
|
271
|
+
self._client = self._build_client()
|
|
272
|
+
|
|
273
|
+
def get(self, key: str) -> str | None:
|
|
274
|
+
value = self._client.get(key)
|
|
275
|
+
if value is None:
|
|
276
|
+
return None
|
|
277
|
+
return str(value)
|
|
278
|
+
|
|
279
|
+
def mget(self, keys: Iterable[str]) -> list[str | None]:
|
|
280
|
+
values = self._client.mget(list(keys))
|
|
281
|
+
return [None if value is None else str(value) for value in values]
|
|
282
|
+
|
|
283
|
+
def set(
|
|
284
|
+
self,
|
|
285
|
+
key: str,
|
|
286
|
+
value: object,
|
|
287
|
+
*,
|
|
288
|
+
ttl_seconds: int | None = None,
|
|
289
|
+
ex: int | None = None,
|
|
290
|
+
nx: bool = False,
|
|
291
|
+
) -> bool:
|
|
292
|
+
ttl = ex if ex is not None else ttl_seconds
|
|
293
|
+
result = self._client.set(key, str(value), ex=ttl, nx=nx)
|
|
294
|
+
return bool(result)
|
|
295
|
+
|
|
296
|
+
def delete(self, key: str) -> None:
|
|
297
|
+
self._client.delete(key)
|
|
298
|
+
|
|
299
|
+
def delete_if_value_matches(self, key: str, expected_value: object) -> bool:
|
|
300
|
+
result = self._client.eval(
|
|
301
|
+
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
|
|
302
|
+
1,
|
|
303
|
+
key,
|
|
304
|
+
str(expected_value),
|
|
305
|
+
)
|
|
306
|
+
return bool(result)
|
|
307
|
+
|
|
308
|
+
def sadd(self, key: str, *values: object) -> int:
|
|
309
|
+
return int(self._client.sadd(key, *(str(value) for value in values)))
|
|
310
|
+
|
|
311
|
+
def smembers(self, key: str) -> set[str]:
|
|
312
|
+
return {str(item) for item in self._client.smembers(key)}
|
|
313
|
+
|
|
314
|
+
def rpush(self, key: str, *values: object) -> int:
|
|
315
|
+
return int(self._client.rpush(key, *(str(value) for value in values)))
|
|
316
|
+
|
|
317
|
+
def lrange(self, key: str, start: int, end: int) -> list[str]:
|
|
318
|
+
return [str(item) for item in self._client.lrange(key, start, end)]
|
|
319
|
+
|
|
320
|
+
def _build_client(self) -> Any:
|
|
321
|
+
if redis is None:
|
|
322
|
+
raise RuntimeError("redis package is required for RedisFoundationCache")
|
|
323
|
+
if not self.settings.enabled:
|
|
324
|
+
raise RuntimeError("foundation redis is disabled")
|
|
325
|
+
if self.settings.url:
|
|
326
|
+
return redis.Redis.from_url(
|
|
327
|
+
self.settings.url,
|
|
328
|
+
decode_responses=True,
|
|
329
|
+
socket_timeout=float(self.settings.socket_timeout_seconds or 1.0),
|
|
330
|
+
socket_connect_timeout=float(self.settings.connect_timeout_seconds or 1.0),
|
|
331
|
+
retry_on_timeout=False,
|
|
332
|
+
)
|
|
333
|
+
if not self.settings.host.strip():
|
|
334
|
+
raise RuntimeError("foundation redis host is not configured")
|
|
335
|
+
return redis.Redis(
|
|
336
|
+
host=self.settings.host,
|
|
337
|
+
port=self.settings.port,
|
|
338
|
+
db=self.settings.db,
|
|
339
|
+
password=self.settings.password,
|
|
340
|
+
decode_responses=True,
|
|
341
|
+
socket_timeout=float(self.settings.socket_timeout_seconds or 1.0),
|
|
342
|
+
socket_connect_timeout=float(self.settings.connect_timeout_seconds or 1.0),
|
|
343
|
+
retry_on_timeout=False,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def ping(self) -> bool:
|
|
347
|
+
return bool(self._client.ping())
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass
|
|
351
|
+
class _L1Item:
|
|
352
|
+
value: Any
|
|
353
|
+
expires_at: float
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass(frozen=True, slots=True)
|
|
357
|
+
class CacheSnapshot:
|
|
358
|
+
json_values: dict[str, Any | None]
|
|
359
|
+
version_values: dict[str, int]
|
|
360
|
+
|
|
361
|
+
def get_json(self, key: str) -> Any | None:
|
|
362
|
+
return self.json_values.get(key)
|
|
363
|
+
|
|
364
|
+
def get_version(self, name: str) -> int:
|
|
365
|
+
value = self.version_values.get(name, 0)
|
|
366
|
+
try:
|
|
367
|
+
return int(value)
|
|
368
|
+
except Exception:
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class CacheService:
|
|
373
|
+
def __init__(self, redis_manager: Any) -> None:
|
|
374
|
+
self._redis_manager = redis_manager
|
|
375
|
+
self._version_prefix = "cache:version:"
|
|
376
|
+
self._lock_prefix = "lock:"
|
|
377
|
+
self._l1: dict[str, _L1Item] = {}
|
|
378
|
+
self._l1_lock = asyncio.Lock()
|
|
379
|
+
|
|
380
|
+
def _l1_get(self, key: str) -> Any | None:
|
|
381
|
+
item = self._l1.get(key)
|
|
382
|
+
if not item:
|
|
383
|
+
return None
|
|
384
|
+
if item.expires_at and item.expires_at < time.time():
|
|
385
|
+
self._l1.pop(key, None)
|
|
386
|
+
return None
|
|
387
|
+
return item.value
|
|
388
|
+
|
|
389
|
+
def _l1_set(self, key: str, value: Any, ttl: Optional[int]) -> None:
|
|
390
|
+
expires_at = 0.0 if not ttl else time.time() + int(ttl)
|
|
391
|
+
self._l1[key] = _L1Item(value=value, expires_at=expires_at)
|
|
392
|
+
|
|
393
|
+
def _l1_invalidate(self, keys: Sequence[str]) -> None:
|
|
394
|
+
for key in keys:
|
|
395
|
+
self._l1.pop(key, None)
|
|
396
|
+
|
|
397
|
+
async def get_json(self, key: str) -> Any | None:
|
|
398
|
+
cached = self._l1_get(key)
|
|
399
|
+
if cached is not None:
|
|
400
|
+
return cached
|
|
401
|
+
try:
|
|
402
|
+
client = await self._redis_manager.get_connection()
|
|
403
|
+
raw = await client.get(key)
|
|
404
|
+
if raw is None:
|
|
405
|
+
return None
|
|
406
|
+
parsed = json.loads(raw)
|
|
407
|
+
ttl = await client.ttl(key)
|
|
408
|
+
ttl = 0 if ttl < 0 else int(ttl)
|
|
409
|
+
self._l1_set(key, parsed, ttl or None)
|
|
410
|
+
return parsed
|
|
411
|
+
except Exception as exc:
|
|
412
|
+
logger.debug("cache.get_json(%s) failed: %s", key, exc)
|
|
413
|
+
raise
|
|
414
|
+
|
|
415
|
+
async def read_snapshot(
|
|
416
|
+
self,
|
|
417
|
+
*,
|
|
418
|
+
json_keys: Sequence[str] = (),
|
|
419
|
+
version_names: Sequence[str] = (),
|
|
420
|
+
) -> CacheSnapshot:
|
|
421
|
+
json_values: dict[str, Any | None] = {str(key): None for key in json_keys}
|
|
422
|
+
version_values: dict[str, int] = {str(name): 0 for name in version_names}
|
|
423
|
+
targets: list[tuple[str, str]] = []
|
|
424
|
+
raw_keys: list[str] = []
|
|
425
|
+
|
|
426
|
+
for key in dict.fromkeys(str(key) for key in json_keys):
|
|
427
|
+
cached = self._l1_get(key)
|
|
428
|
+
if cached is not None:
|
|
429
|
+
json_values[key] = cached
|
|
430
|
+
continue
|
|
431
|
+
targets.append(("json", key))
|
|
432
|
+
raw_keys.append(key)
|
|
433
|
+
for name in dict.fromkeys(str(name) for name in version_names):
|
|
434
|
+
targets.append(("version", name))
|
|
435
|
+
raw_keys.append(self._version_prefix + name)
|
|
436
|
+
|
|
437
|
+
if not raw_keys:
|
|
438
|
+
return CacheSnapshot(json_values=json_values, version_values=version_values)
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
client = await self._redis_manager.get_connection()
|
|
442
|
+
raw_values = await client.mget(raw_keys)
|
|
443
|
+
for target, raw in zip(targets, raw_values):
|
|
444
|
+
kind, name = target
|
|
445
|
+
if kind == "json":
|
|
446
|
+
json_values[name] = None if raw is None else json.loads(raw)
|
|
447
|
+
else:
|
|
448
|
+
version_values[name] = 0 if raw is None else int(raw)
|
|
449
|
+
return CacheSnapshot(json_values=json_values, version_values=version_values)
|
|
450
|
+
except Exception as exc:
|
|
451
|
+
logger.debug("cache.read_snapshot(json_keys=%s, version_names=%s) failed: %s", json_keys, version_names, exc)
|
|
452
|
+
raise
|
|
453
|
+
|
|
454
|
+
async def set_json(self, key: str, value: Any, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None:
|
|
455
|
+
try:
|
|
456
|
+
client = await self._redis_manager.get_connection()
|
|
457
|
+
payload = json.dumps(value, ensure_ascii=False)
|
|
458
|
+
if ttl and ttl > 0:
|
|
459
|
+
await client.setex(key, int(ttl), payload)
|
|
460
|
+
else:
|
|
461
|
+
await client.set(key, payload)
|
|
462
|
+
for tag in tags or []:
|
|
463
|
+
tag_key = f"cache:tag:{tag}"
|
|
464
|
+
await client.sadd(tag_key, key)
|
|
465
|
+
if ttl and ttl > 0:
|
|
466
|
+
await client.expire(tag_key, int(ttl))
|
|
467
|
+
self._l1_set(key, value, ttl)
|
|
468
|
+
except Exception as exc:
|
|
469
|
+
logger.debug("cache.set_json(%s) failed: %s", key, exc)
|
|
470
|
+
raise
|
|
471
|
+
|
|
472
|
+
async def invalidate(self, *, keys: Optional[Sequence[str]] = None, tags: Optional[Sequence[str]] = None) -> None:
|
|
473
|
+
keys = list(keys or [])
|
|
474
|
+
try:
|
|
475
|
+
client = await self._redis_manager.get_connection()
|
|
476
|
+
for tag in tags or []:
|
|
477
|
+
tag_key = f"cache:tag:{tag}"
|
|
478
|
+
members = await client.smembers(tag_key)
|
|
479
|
+
if members:
|
|
480
|
+
keys.extend(list(members))
|
|
481
|
+
await client.delete(tag_key)
|
|
482
|
+
if keys:
|
|
483
|
+
await client.delete(*set(keys))
|
|
484
|
+
except Exception as exc:
|
|
485
|
+
logger.debug("cache.invalidate(tags=%s, keys=%s) failed: %s", tags, keys, exc)
|
|
486
|
+
raise
|
|
487
|
+
self._l1_invalidate(set(keys))
|
|
488
|
+
|
|
489
|
+
async def get_version(self, name: str) -> int:
|
|
490
|
+
try:
|
|
491
|
+
client = await self._redis_manager.get_connection()
|
|
492
|
+
raw = await client.get(self._version_prefix + name)
|
|
493
|
+
return 0 if raw is None else int(raw)
|
|
494
|
+
except Exception as exc:
|
|
495
|
+
logger.debug("cache.get_version(%s) failed: %s", name, exc)
|
|
496
|
+
raise
|
|
497
|
+
|
|
498
|
+
async def bump_version(self, name: str) -> int:
|
|
499
|
+
try:
|
|
500
|
+
client = await self._redis_manager.get_connection()
|
|
501
|
+
return int(await client.incr(self._version_prefix + name))
|
|
502
|
+
except Exception as exc:
|
|
503
|
+
logger.debug("cache.bump_version(%s) failed: %s", name, exc)
|
|
504
|
+
raise
|
|
505
|
+
|
|
506
|
+
async def try_acquire_lock(self, name: str, ttl: int = 15) -> bool:
|
|
507
|
+
try:
|
|
508
|
+
client = await self._redis_manager.get_connection()
|
|
509
|
+
return bool(await client.set(self._lock_prefix + name, "1", ex=int(ttl), nx=True))
|
|
510
|
+
except Exception as exc:
|
|
511
|
+
logger.debug("cache.try_acquire_lock(%s) failed: %s", name, exc)
|
|
512
|
+
raise
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class SyncRedisAdapter:
|
|
516
|
+
"""Run async redis-manager commands on a dedicated event-loop thread."""
|
|
517
|
+
|
|
518
|
+
def __init__(self, redis_manager: Any) -> None:
|
|
519
|
+
if redis_manager is None:
|
|
520
|
+
raise ValueError("SyncRedisAdapter requires an explicit redis manager")
|
|
521
|
+
self._redis_manager = redis_manager
|
|
522
|
+
self._ready = Event()
|
|
523
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
524
|
+
self._thread = Thread(target=self._run_loop, name="foundation-sync-cache", daemon=True)
|
|
525
|
+
self._thread.start()
|
|
526
|
+
self._ready.wait()
|
|
527
|
+
|
|
528
|
+
def _run_loop(self) -> None:
|
|
529
|
+
loop = asyncio.new_event_loop()
|
|
530
|
+
self._loop = loop
|
|
531
|
+
asyncio.set_event_loop(loop)
|
|
532
|
+
self._ready.set()
|
|
533
|
+
loop.run_forever()
|
|
534
|
+
loop.close()
|
|
535
|
+
|
|
536
|
+
async def _invoke(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
|
|
537
|
+
client = await self._redis_manager.get_connection()
|
|
538
|
+
method = getattr(client, method_name)
|
|
539
|
+
return await method(*args, **kwargs)
|
|
540
|
+
|
|
541
|
+
def call(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
|
|
542
|
+
if self._loop is None:
|
|
543
|
+
raise RuntimeError("sync redis adapter loop is not ready")
|
|
544
|
+
future: Future[Any] = asyncio.run_coroutine_threadsafe(
|
|
545
|
+
self._invoke(method_name, *args, **kwargs),
|
|
546
|
+
self._loop,
|
|
547
|
+
)
|
|
548
|
+
return future.result()
|
|
549
|
+
|
|
550
|
+
def __getattr__(self, name: str) -> Any:
|
|
551
|
+
def caller(*args: Any, **kwargs: Any) -> Any:
|
|
552
|
+
return self.call(name, *args, **kwargs)
|
|
553
|
+
|
|
554
|
+
return caller
|
|
555
|
+
|
|
556
|
+
def close(self) -> None:
|
|
557
|
+
loop = self._loop
|
|
558
|
+
if loop is None or loop.is_closed():
|
|
559
|
+
return
|
|
560
|
+
with contextlib.suppress(Exception):
|
|
561
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
562
|
+
self._redis_manager.reset_connection(),
|
|
563
|
+
loop,
|
|
564
|
+
)
|
|
565
|
+
future.result(timeout=1.0)
|
|
566
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
567
|
+
self._thread.join(timeout=1.0)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class SyncCacheService:
|
|
571
|
+
def __init__(self, backend: Any) -> None:
|
|
572
|
+
self._backend = backend
|
|
573
|
+
self._version_prefix = "cache:version:"
|
|
574
|
+
self._lock_prefix = "lock:"
|
|
575
|
+
self._l1: dict[str, _L1Item] = {}
|
|
576
|
+
|
|
577
|
+
def _l1_get(self, key: str) -> Any | None:
|
|
578
|
+
item = self._l1.get(key)
|
|
579
|
+
if not item:
|
|
580
|
+
return None
|
|
581
|
+
if item.expires_at and item.expires_at < time.time():
|
|
582
|
+
self._l1.pop(key, None)
|
|
583
|
+
return None
|
|
584
|
+
return item.value
|
|
585
|
+
|
|
586
|
+
def _l1_set(self, key: str, value: Any, ttl: Optional[int]) -> None:
|
|
587
|
+
expires_at = 0.0 if not ttl else time.time() + int(ttl)
|
|
588
|
+
self._l1[key] = _L1Item(value=value, expires_at=expires_at)
|
|
589
|
+
|
|
590
|
+
def _l1_invalidate(self, keys: Sequence[str]) -> None:
|
|
591
|
+
for key in keys:
|
|
592
|
+
self._l1.pop(key, None)
|
|
593
|
+
|
|
594
|
+
def get_json(self, key: str) -> Any | None:
|
|
595
|
+
cached = self._l1_get(key)
|
|
596
|
+
if cached is not None:
|
|
597
|
+
return cached
|
|
598
|
+
try:
|
|
599
|
+
raw = self._backend.get(key)
|
|
600
|
+
if raw is None:
|
|
601
|
+
return None
|
|
602
|
+
parsed = json.loads(raw)
|
|
603
|
+
ttl_reader = getattr(self._backend, "ttl", None)
|
|
604
|
+
ttl = int(ttl_reader(key)) if callable(ttl_reader) else 0
|
|
605
|
+
ttl = 0 if ttl < 0 else ttl
|
|
606
|
+
self._l1_set(key, parsed, ttl or None)
|
|
607
|
+
return parsed
|
|
608
|
+
except Exception as exc:
|
|
609
|
+
logger.debug("sync cache.get_json(%s) failed: %s", key, exc)
|
|
610
|
+
return None
|
|
611
|
+
|
|
612
|
+
def read_snapshot(
|
|
613
|
+
self,
|
|
614
|
+
*,
|
|
615
|
+
json_keys: Sequence[str] = (),
|
|
616
|
+
version_names: Sequence[str] = (),
|
|
617
|
+
) -> CacheSnapshot:
|
|
618
|
+
json_values: dict[str, Any | None] = {str(key): None for key in json_keys}
|
|
619
|
+
version_values: dict[str, int] = {str(name): 0 for name in version_names}
|
|
620
|
+
targets: list[tuple[str, str]] = []
|
|
621
|
+
raw_keys: list[str] = []
|
|
622
|
+
|
|
623
|
+
for key in dict.fromkeys(str(key) for key in json_keys):
|
|
624
|
+
cached = self._l1_get(key)
|
|
625
|
+
if cached is not None:
|
|
626
|
+
json_values[key] = cached
|
|
627
|
+
continue
|
|
628
|
+
targets.append(("json", key))
|
|
629
|
+
raw_keys.append(key)
|
|
630
|
+
for name in dict.fromkeys(str(name) for name in version_names):
|
|
631
|
+
targets.append(("version", name))
|
|
632
|
+
raw_keys.append(self._version_prefix + name)
|
|
633
|
+
|
|
634
|
+
if not raw_keys:
|
|
635
|
+
return CacheSnapshot(json_values=json_values, version_values=version_values)
|
|
636
|
+
|
|
637
|
+
try:
|
|
638
|
+
raw_values = self._backend.mget(raw_keys)
|
|
639
|
+
for target, raw in zip(targets, raw_values):
|
|
640
|
+
kind, name = target
|
|
641
|
+
if kind == "json":
|
|
642
|
+
json_values[name] = None if raw is None else json.loads(raw)
|
|
643
|
+
else:
|
|
644
|
+
version_values[name] = 0 if raw is None else int(raw)
|
|
645
|
+
except Exception as exc:
|
|
646
|
+
logger.debug(
|
|
647
|
+
"sync cache.read_snapshot(json_keys=%s, version_names=%s) failed: %s",
|
|
648
|
+
json_keys,
|
|
649
|
+
version_names,
|
|
650
|
+
exc,
|
|
651
|
+
)
|
|
652
|
+
return CacheSnapshot(json_values=json_values, version_values=version_values)
|
|
653
|
+
|
|
654
|
+
def set_json(self, key: str, value: Any, ttl: Optional[int] = None, tags: Optional[Iterable[str]] = None) -> None:
|
|
655
|
+
try:
|
|
656
|
+
payload = json.dumps(value, ensure_ascii=False)
|
|
657
|
+
ttl_value = int(ttl) if ttl and ttl > 0 else None
|
|
658
|
+
if ttl_value is not None:
|
|
659
|
+
setex = getattr(self._backend, "setex", None)
|
|
660
|
+
if callable(setex):
|
|
661
|
+
setex(key, ttl_value, payload)
|
|
662
|
+
else:
|
|
663
|
+
self._backend.set(key, payload, ex=ttl_value)
|
|
664
|
+
else:
|
|
665
|
+
self._backend.set(key, payload)
|
|
666
|
+
for tag in tags or ():
|
|
667
|
+
tag_key = f"cache:tag:{tag}"
|
|
668
|
+
self._backend.sadd(tag_key, key)
|
|
669
|
+
expire = getattr(self._backend, "expire", None)
|
|
670
|
+
if ttl_value is not None and callable(expire):
|
|
671
|
+
expire(tag_key, ttl_value)
|
|
672
|
+
self._l1_set(key, value, ttl)
|
|
673
|
+
except Exception as exc:
|
|
674
|
+
logger.debug("sync cache.set_json(%s) failed: %s", key, exc)
|
|
675
|
+
self._l1_set(key, value, ttl)
|
|
676
|
+
|
|
677
|
+
def invalidate(self, *, keys: Optional[Sequence[str]] = None, tags: Optional[Sequence[str]] = None) -> None:
|
|
678
|
+
invalidated_keys = list(keys or ())
|
|
679
|
+
try:
|
|
680
|
+
for tag in tags or ():
|
|
681
|
+
tag_key = f"cache:tag:{tag}"
|
|
682
|
+
members = self._backend.smembers(tag_key)
|
|
683
|
+
if members:
|
|
684
|
+
invalidated_keys.extend(list(members))
|
|
685
|
+
self._backend.delete(tag_key)
|
|
686
|
+
if invalidated_keys:
|
|
687
|
+
self._backend.delete(*tuple(set(invalidated_keys)))
|
|
688
|
+
except Exception as exc:
|
|
689
|
+
logger.debug("sync cache.invalidate(tags=%s, keys=%s) failed: %s", tags, invalidated_keys, exc)
|
|
690
|
+
self._l1_invalidate(set(invalidated_keys))
|
|
691
|
+
|
|
692
|
+
def get_version(self, name: str) -> int:
|
|
693
|
+
try:
|
|
694
|
+
raw = self._backend.get(self._version_prefix + str(name))
|
|
695
|
+
return 0 if raw is None else int(raw)
|
|
696
|
+
except Exception as exc:
|
|
697
|
+
logger.debug("sync cache.get_version(%s) failed: %s", name, exc)
|
|
698
|
+
return 0
|
|
699
|
+
|
|
700
|
+
def bump_version(self, name: str) -> int:
|
|
701
|
+
try:
|
|
702
|
+
return int(self._backend.incr(self._version_prefix + str(name)))
|
|
703
|
+
except Exception as exc:
|
|
704
|
+
logger.debug("sync cache.bump_version(%s) failed: %s", name, exc)
|
|
705
|
+
return 0
|
|
706
|
+
|
|
707
|
+
def close(self) -> None:
|
|
708
|
+
closer = getattr(self._backend, "close", None)
|
|
709
|
+
if callable(closer):
|
|
710
|
+
with contextlib.suppress(Exception):
|
|
711
|
+
closer()
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
T = TypeVar("T")
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
class _LazyAccessor(Generic[T]):
|
|
718
|
+
__slots__ = ("_factory", "_instance")
|
|
719
|
+
|
|
720
|
+
def __init__(self, factory: Callable[[], T]) -> None:
|
|
721
|
+
object.__setattr__(self, "_factory", factory)
|
|
722
|
+
object.__setattr__(self, "_instance", None)
|
|
723
|
+
|
|
724
|
+
def _get_instance(self) -> T:
|
|
725
|
+
inst = object.__getattribute__(self, "_instance")
|
|
726
|
+
if inst is None:
|
|
727
|
+
inst = object.__getattribute__(self, "_factory")()
|
|
728
|
+
object.__setattr__(self, "_instance", inst)
|
|
729
|
+
return inst # type: ignore[return-value]
|
|
730
|
+
|
|
731
|
+
def __getattr__(self, name: str) -> Any:
|
|
732
|
+
return getattr(self._get_instance(), name)
|
|
733
|
+
|
|
734
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
735
|
+
if name in {"_factory", "_instance"}:
|
|
736
|
+
object.__setattr__(self, name, value)
|
|
737
|
+
return
|
|
738
|
+
setattr(self._get_instance(), name, value)
|
|
739
|
+
|
|
740
|
+
def __repr__(self) -> str:
|
|
741
|
+
inst = object.__getattribute__(self, "_instance")
|
|
742
|
+
if inst is None:
|
|
743
|
+
return "<lazy cache service proxy>"
|
|
744
|
+
return repr(inst)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
_CACHE_SINGLETONS: dict[str, RedisFoundationCache] = {}
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def load_foundation_redis_settings(
|
|
751
|
+
*,
|
|
752
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
753
|
+
) -> FoundationRedisSettings:
|
|
754
|
+
return _resolve_foundation_redis_settings(project_dir=project_dir)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _redis_binding_configured(settings: FoundationRedisSettings) -> bool:
|
|
758
|
+
if not settings.enabled:
|
|
759
|
+
return False
|
|
760
|
+
return bool(str(settings.url or "").strip() or str(settings.host or "").strip())
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def describe_foundation_redis_binding(
|
|
764
|
+
*,
|
|
765
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
766
|
+
settings: FoundationRedisSettings | None = None,
|
|
767
|
+
) -> dict[str, Any]:
|
|
768
|
+
resolved = settings or _resolve_foundation_redis_settings(project_dir=project_dir)
|
|
769
|
+
configured = _redis_binding_configured(resolved)
|
|
770
|
+
backend = "redis" if configured else "none"
|
|
771
|
+
return {
|
|
772
|
+
"enabled": bool(resolved.enabled),
|
|
773
|
+
"configured": configured,
|
|
774
|
+
"backend": backend,
|
|
775
|
+
"operational": False,
|
|
776
|
+
"degraded": False,
|
|
777
|
+
"ready": False,
|
|
778
|
+
"error": None,
|
|
779
|
+
"host": resolved.host,
|
|
780
|
+
"port": resolved.port,
|
|
781
|
+
"db": resolved.db,
|
|
782
|
+
"url": resolved.url,
|
|
783
|
+
"probe_deferred": True,
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def probe_foundation_redis_binding(
|
|
788
|
+
*,
|
|
789
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
790
|
+
settings: FoundationRedisSettings | None = None,
|
|
791
|
+
capability_name: str = "foundation redis",
|
|
792
|
+
) -> dict[str, Any]:
|
|
793
|
+
status = describe_foundation_redis_binding(project_dir=project_dir, settings=settings)
|
|
794
|
+
if not status["enabled"] or not status["configured"]:
|
|
795
|
+
status["probe_deferred"] = False
|
|
796
|
+
return status
|
|
797
|
+
try:
|
|
798
|
+
cache = get_required_foundation_cache(
|
|
799
|
+
project_dir=project_dir,
|
|
800
|
+
settings=settings,
|
|
801
|
+
capability_name=capability_name,
|
|
802
|
+
)
|
|
803
|
+
ready = bool(cache.ping())
|
|
804
|
+
status["ready"] = ready
|
|
805
|
+
status["operational"] = ready
|
|
806
|
+
status["probe_deferred"] = False
|
|
807
|
+
return status
|
|
808
|
+
except Exception as exc:
|
|
809
|
+
status["error"] = str(exc)
|
|
810
|
+
status["probe_deferred"] = False
|
|
811
|
+
return status
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _resolve_foundation_redis_settings(
|
|
815
|
+
*,
|
|
816
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
817
|
+
) -> FoundationRedisSettings:
|
|
818
|
+
redis_payload = _redis_snapshot(project_dir=project_dir)
|
|
819
|
+
url = _read_str(redis_payload, "url")
|
|
820
|
+
host = _read_str(redis_payload, "host")
|
|
821
|
+
port = _read_int(redis_payload, "port", 6379)
|
|
822
|
+
db = _read_int(redis_payload, "db", 0)
|
|
823
|
+
password = _read_optional_str(redis_payload, "password")
|
|
824
|
+
enabled = _read_bool_value(redis_payload, "enabled", False)
|
|
825
|
+
service = _read_str(redis_payload, "service") or "mymaster"
|
|
826
|
+
allow_cluster = _read_bool_value(redis_payload, "allow_cluster", False)
|
|
827
|
+
connect_timeout_seconds = _read_float(redis_payload, "connect_timeout_seconds", 1.0)
|
|
828
|
+
socket_timeout_seconds = _read_float(redis_payload, "socket_timeout_seconds", 1.0)
|
|
829
|
+
operation_max_attempts = _read_int(redis_payload, "operation_max_attempts", 2)
|
|
830
|
+
connection_retry_attempts = _read_int(redis_payload, "connection_retry_attempts", 0)
|
|
831
|
+
retry_base_delay_seconds = _read_float(redis_payload, "retry_base_delay_seconds", 0.05)
|
|
832
|
+
retry_max_delay_seconds = _read_float(redis_payload, "retry_max_delay_seconds", 0.2)
|
|
833
|
+
acquire_failure_cooldown_seconds = _read_float(redis_payload, "acquire_failure_cooldown_seconds", 2.0)
|
|
834
|
+
health_failure_cooldown_seconds = _read_float(redis_payload, "health_failure_cooldown_seconds", 2.0)
|
|
835
|
+
return FoundationRedisSettings(
|
|
836
|
+
enabled=enabled,
|
|
837
|
+
host=host,
|
|
838
|
+
port=port,
|
|
839
|
+
db=db,
|
|
840
|
+
password=password,
|
|
841
|
+
url=url,
|
|
842
|
+
service=service,
|
|
843
|
+
allow_cluster=allow_cluster,
|
|
844
|
+
connect_timeout_seconds=connect_timeout_seconds,
|
|
845
|
+
socket_timeout_seconds=socket_timeout_seconds,
|
|
846
|
+
operation_max_attempts=operation_max_attempts,
|
|
847
|
+
connection_retry_attempts=connection_retry_attempts,
|
|
848
|
+
retry_base_delay_seconds=retry_base_delay_seconds,
|
|
849
|
+
retry_max_delay_seconds=retry_max_delay_seconds,
|
|
850
|
+
acquire_failure_cooldown_seconds=acquire_failure_cooldown_seconds,
|
|
851
|
+
health_failure_cooldown_seconds=health_failure_cooldown_seconds,
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def get_foundation_cache(
|
|
856
|
+
*,
|
|
857
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
858
|
+
settings: FoundationRedisSettings | None = None,
|
|
859
|
+
) -> RedisFoundationCache:
|
|
860
|
+
resolved_settings = settings or _resolve_foundation_redis_settings(project_dir=project_dir)
|
|
861
|
+
key = _cache_key(resolved_settings)
|
|
862
|
+
cache = _CACHE_SINGLETONS.get(key)
|
|
863
|
+
if cache is None:
|
|
864
|
+
cache = RedisFoundationCache(resolved_settings)
|
|
865
|
+
_CACHE_SINGLETONS[key] = cache
|
|
866
|
+
return cache
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def get_required_foundation_cache(
|
|
870
|
+
*,
|
|
871
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
872
|
+
settings: FoundationRedisSettings | None = None,
|
|
873
|
+
capability_name: str = "foundation redis",
|
|
874
|
+
) -> RedisFoundationCache:
|
|
875
|
+
resolved_settings = settings or _resolve_foundation_redis_settings(project_dir=project_dir)
|
|
876
|
+
capability_label = str(capability_name or "foundation redis").strip() or "foundation redis"
|
|
877
|
+
if not resolved_settings.enabled:
|
|
878
|
+
raise FoundationRedisUnavailable(f"{capability_label} is disabled")
|
|
879
|
+
if not str(resolved_settings.url or resolved_settings.host or "").strip():
|
|
880
|
+
raise FoundationRedisUnavailable(f"{capability_label} is not configured")
|
|
881
|
+
try:
|
|
882
|
+
return get_foundation_cache(settings=resolved_settings)
|
|
883
|
+
except FoundationRedisUnavailable:
|
|
884
|
+
raise
|
|
885
|
+
except Exception as exc:
|
|
886
|
+
raise FoundationRedisUnavailable(f"{capability_label} is unavailable") from exc
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def get_foundation_redis_manager(
|
|
890
|
+
*,
|
|
891
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
892
|
+
settings: FoundationRedisSettings | None = None,
|
|
893
|
+
) -> RedisManager:
|
|
894
|
+
resolved_settings = settings or _resolve_foundation_redis_settings(project_dir=project_dir)
|
|
895
|
+
return RedisManager(build_foundation_redis_config(resolved_settings))
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def build_required_foundation_redis_config(
|
|
899
|
+
*,
|
|
900
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
901
|
+
settings: FoundationRedisSettings | None = None,
|
|
902
|
+
capability_name: str = "foundation redis",
|
|
903
|
+
decode_responses: bool = True,
|
|
904
|
+
) -> RedisConfig:
|
|
905
|
+
resolved_settings = settings or _resolve_foundation_redis_settings(project_dir=project_dir)
|
|
906
|
+
capability_label = str(capability_name or "foundation redis").strip() or "foundation redis"
|
|
907
|
+
if not resolved_settings.enabled:
|
|
908
|
+
raise FoundationRedisUnavailable(f"{capability_label} is disabled")
|
|
909
|
+
if not str(resolved_settings.url or resolved_settings.host or "").strip():
|
|
910
|
+
raise FoundationRedisUnavailable(f"{capability_label} is not configured")
|
|
911
|
+
return replace(
|
|
912
|
+
build_foundation_redis_config(resolved_settings),
|
|
913
|
+
decode_responses=bool(decode_responses),
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
@lru_cache(maxsize=32)
|
|
918
|
+
def _cached_required_foundation_redis_manager(
|
|
919
|
+
project_dir: str,
|
|
920
|
+
capability_name: str,
|
|
921
|
+
decode_responses: bool,
|
|
922
|
+
) -> RedisManager:
|
|
923
|
+
return RedisManager(
|
|
924
|
+
build_required_foundation_redis_config(
|
|
925
|
+
project_dir=project_dir,
|
|
926
|
+
capability_name=capability_name,
|
|
927
|
+
decode_responses=decode_responses,
|
|
928
|
+
)
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def get_required_foundation_redis_manager(
|
|
933
|
+
*,
|
|
934
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
935
|
+
settings: FoundationRedisSettings | None = None,
|
|
936
|
+
capability_name: str = "foundation redis",
|
|
937
|
+
decode_responses: bool = True,
|
|
938
|
+
) -> RedisManager:
|
|
939
|
+
if settings is not None:
|
|
940
|
+
return RedisManager(
|
|
941
|
+
build_required_foundation_redis_config(
|
|
942
|
+
settings=settings,
|
|
943
|
+
capability_name=capability_name,
|
|
944
|
+
decode_responses=decode_responses,
|
|
945
|
+
)
|
|
946
|
+
)
|
|
947
|
+
resolved_project_dir = "" if project_dir is None else str(Path(project_dir).resolve())
|
|
948
|
+
return _cached_required_foundation_redis_manager(
|
|
949
|
+
resolved_project_dir,
|
|
950
|
+
str(capability_name or "foundation redis"),
|
|
951
|
+
bool(decode_responses),
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
async def get_required_foundation_redis_connection(
|
|
956
|
+
*,
|
|
957
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
958
|
+
settings: FoundationRedisSettings | None = None,
|
|
959
|
+
capability_name: str = "foundation redis",
|
|
960
|
+
decode_responses: bool = True,
|
|
961
|
+
):
|
|
962
|
+
manager = get_required_foundation_redis_manager(
|
|
963
|
+
project_dir=project_dir,
|
|
964
|
+
settings=settings,
|
|
965
|
+
capability_name=capability_name,
|
|
966
|
+
decode_responses=decode_responses,
|
|
967
|
+
)
|
|
968
|
+
return await manager.get_connection()
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def build_foundation_redis_config(settings: FoundationRedisSettings) -> RedisConfig:
|
|
972
|
+
host = settings.host
|
|
973
|
+
port = settings.port
|
|
974
|
+
db = settings.db
|
|
975
|
+
password = settings.password
|
|
976
|
+
if settings.url:
|
|
977
|
+
parsed = urlparse(settings.url)
|
|
978
|
+
userinfo, hostspec = _split_redis_netloc(parsed.netloc)
|
|
979
|
+
if hostspec:
|
|
980
|
+
host = hostspec
|
|
981
|
+
parsed_password = _parse_redis_url_password(userinfo)
|
|
982
|
+
if parsed_password is not None:
|
|
983
|
+
password = parsed_password
|
|
984
|
+
if parsed.path and parsed.path != "/":
|
|
985
|
+
with_value = parsed.path.removeprefix("/")
|
|
986
|
+
if with_value.isdigit():
|
|
987
|
+
db = int(with_value)
|
|
988
|
+
return _build_redis_config_from_resolved_values(
|
|
989
|
+
host=host or "",
|
|
990
|
+
port=port,
|
|
991
|
+
db=db,
|
|
992
|
+
password=password,
|
|
993
|
+
service=settings.service,
|
|
994
|
+
allow_cluster=settings.allow_cluster,
|
|
995
|
+
connect_timeout_seconds=settings.connect_timeout_seconds,
|
|
996
|
+
socket_timeout_seconds=settings.socket_timeout_seconds,
|
|
997
|
+
operation_max_attempts=settings.operation_max_attempts,
|
|
998
|
+
connection_retry_attempts=settings.connection_retry_attempts,
|
|
999
|
+
retry_base_delay_seconds=settings.retry_base_delay_seconds,
|
|
1000
|
+
retry_max_delay_seconds=settings.retry_max_delay_seconds,
|
|
1001
|
+
acquire_failure_cooldown_seconds=settings.acquire_failure_cooldown_seconds,
|
|
1002
|
+
health_failure_cooldown_seconds=settings.health_failure_cooldown_seconds,
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def describe_foundation_redis_binding(
|
|
1007
|
+
*,
|
|
1008
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
1009
|
+
settings: FoundationRedisSettings | None = None,
|
|
1010
|
+
) -> dict[str, Any]:
|
|
1011
|
+
resolved = settings or load_foundation_redis_settings(project_dir=project_dir)
|
|
1012
|
+
configured = bool((resolved.url or "").strip() or (resolved.host or "").strip())
|
|
1013
|
+
return {
|
|
1014
|
+
"enabled": bool(resolved.enabled),
|
|
1015
|
+
"configured": configured,
|
|
1016
|
+
"backend": "redis" if resolved.enabled and configured else "disabled",
|
|
1017
|
+
"operational": False,
|
|
1018
|
+
"degraded": False,
|
|
1019
|
+
"ready": False,
|
|
1020
|
+
"error": None,
|
|
1021
|
+
"host": resolved.host,
|
|
1022
|
+
"port": resolved.port,
|
|
1023
|
+
"db": resolved.db,
|
|
1024
|
+
"url": resolved.url,
|
|
1025
|
+
"probe_deferred": True,
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def probe_foundation_redis_binding(
|
|
1030
|
+
*,
|
|
1031
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
1032
|
+
settings: FoundationRedisSettings | None = None,
|
|
1033
|
+
capability_name: str = "foundation redis",
|
|
1034
|
+
) -> dict[str, Any]:
|
|
1035
|
+
status = describe_foundation_redis_binding(project_dir=project_dir, settings=settings)
|
|
1036
|
+
if not status["enabled"] or not status["configured"]:
|
|
1037
|
+
status["probe_deferred"] = False
|
|
1038
|
+
return status
|
|
1039
|
+
try:
|
|
1040
|
+
cache = get_required_foundation_cache(
|
|
1041
|
+
project_dir=project_dir,
|
|
1042
|
+
settings=settings,
|
|
1043
|
+
capability_name=capability_name,
|
|
1044
|
+
)
|
|
1045
|
+
ready = bool(cache.ping())
|
|
1046
|
+
status["ready"] = ready
|
|
1047
|
+
status["operational"] = ready
|
|
1048
|
+
status["degraded"] = not ready
|
|
1049
|
+
status["probe_deferred"] = False
|
|
1050
|
+
return status
|
|
1051
|
+
except Exception as exc:
|
|
1052
|
+
status["error"] = str(exc) or exc.__class__.__name__
|
|
1053
|
+
status["degraded"] = True
|
|
1054
|
+
status["probe_deferred"] = False
|
|
1055
|
+
return status
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def reset_foundation_cache() -> None:
|
|
1059
|
+
for cache in list(_CACHE_SINGLETONS.values()):
|
|
1060
|
+
with contextlib.suppress(Exception):
|
|
1061
|
+
cache.close()
|
|
1062
|
+
_CACHE_SINGLETONS.clear()
|
|
1063
|
+
_cached_required_foundation_redis_manager.cache_clear()
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _cache_key(settings: FoundationRedisSettings) -> str:
|
|
1067
|
+
return (
|
|
1068
|
+
f"{settings.url or ''}|{settings.host}|{settings.port}|{settings.db}|"
|
|
1069
|
+
f"{settings.connect_timeout_seconds}|{settings.socket_timeout_seconds}|"
|
|
1070
|
+
f"{settings.operation_max_attempts}|{settings.connection_retry_attempts}|"
|
|
1071
|
+
f"{settings.retry_base_delay_seconds}|{settings.retry_max_delay_seconds}|"
|
|
1072
|
+
f"{settings.acquire_failure_cooldown_seconds}|{settings.health_failure_cooldown_seconds}"
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _redis_snapshot(
|
|
1077
|
+
*,
|
|
1078
|
+
project_dir: str | os.PathLike[str] | None = None,
|
|
1079
|
+
):
|
|
1080
|
+
try:
|
|
1081
|
+
return _payload_dict(_foundation_settings_section("cache", project_dir=project_dir))
|
|
1082
|
+
except Exception:
|
|
1083
|
+
return {}
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _payload_dict(payload: Any) -> dict[str, Any]:
|
|
1087
|
+
if isinstance(payload, dict):
|
|
1088
|
+
return dict(payload)
|
|
1089
|
+
if hasattr(payload, "to_dict") and callable(payload.to_dict):
|
|
1090
|
+
try:
|
|
1091
|
+
value = payload.to_dict()
|
|
1092
|
+
except Exception:
|
|
1093
|
+
value = None
|
|
1094
|
+
if isinstance(value, dict):
|
|
1095
|
+
return dict(value)
|
|
1096
|
+
return dict(getattr(payload, "__dict__", {}) or {})
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _read_str(payload: Any, key: str) -> str:
|
|
1100
|
+
if isinstance(payload, dict):
|
|
1101
|
+
value = payload.get(key, "")
|
|
1102
|
+
else:
|
|
1103
|
+
value = getattr(payload, key, "")
|
|
1104
|
+
return str(value or "").strip()
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _read_optional_str(payload: Any, key: str) -> str | None:
|
|
1108
|
+
value = _read_str(payload, key)
|
|
1109
|
+
return value or None
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _read_int(payload: Any, key: str, default: int) -> int:
|
|
1113
|
+
if isinstance(payload, dict):
|
|
1114
|
+
value = payload.get(key, default)
|
|
1115
|
+
else:
|
|
1116
|
+
value = getattr(payload, key, default)
|
|
1117
|
+
try:
|
|
1118
|
+
return int(value if value is not None else default)
|
|
1119
|
+
except Exception:
|
|
1120
|
+
return default
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _read_float(payload: Any, key: str, default: float) -> float:
|
|
1124
|
+
if isinstance(payload, dict):
|
|
1125
|
+
value = payload.get(key, default)
|
|
1126
|
+
else:
|
|
1127
|
+
value = getattr(payload, key, default)
|
|
1128
|
+
try:
|
|
1129
|
+
return float(value if value is not None else default)
|
|
1130
|
+
except Exception:
|
|
1131
|
+
return default
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _read_bool_value(payload: Any, key: str, default: bool) -> bool:
|
|
1135
|
+
if isinstance(payload, dict):
|
|
1136
|
+
value = payload.get(key, default)
|
|
1137
|
+
else:
|
|
1138
|
+
value = getattr(payload, key, default)
|
|
1139
|
+
if value is None:
|
|
1140
|
+
return default
|
|
1141
|
+
if isinstance(value, bool):
|
|
1142
|
+
return value
|
|
1143
|
+
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
__all__ = [
|
|
1147
|
+
"CacheService",
|
|
1148
|
+
"describe_foundation_redis_binding",
|
|
1149
|
+
"FoundationRedisUnavailable",
|
|
1150
|
+
"FoundationRedisSettings",
|
|
1151
|
+
"InMemoryFoundationCache",
|
|
1152
|
+
"probe_foundation_redis_binding",
|
|
1153
|
+
"RedisFoundationCache",
|
|
1154
|
+
"SyncCacheService",
|
|
1155
|
+
"SyncRedisAdapter",
|
|
1156
|
+
"build_foundation_redis_config",
|
|
1157
|
+
"build_required_foundation_redis_config",
|
|
1158
|
+
"get_foundation_cache",
|
|
1159
|
+
"get_required_foundation_cache",
|
|
1160
|
+
"get_foundation_redis_manager",
|
|
1161
|
+
"get_required_foundation_redis_connection",
|
|
1162
|
+
"get_required_foundation_redis_manager",
|
|
1163
|
+
"load_foundation_redis_settings",
|
|
1164
|
+
"reset_foundation_cache",
|
|
1165
|
+
"_LazyAccessor",
|
|
1166
|
+
]
|