apm-cli 0.12.0__tar.gz → 0.12.2__tar.gz
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.
- {apm_cli-0.12.0 → apm_cli-0.12.2}/NOTICE +37 -0
- {apm_cli-0.12.0/src/apm_cli.egg-info → apm_cli-0.12.2}/PKG-INFO +3 -1
- {apm_cli-0.12.0 → apm_cli-0.12.2}/README.md +1 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/pyproject.toml +2 -1
- apm_cli-0.12.2/src/apm_cli/cache/__init__.py +16 -0
- apm_cli-0.12.2/src/apm_cli/cache/git_cache.py +577 -0
- apm_cli-0.12.2/src/apm_cli/cache/http_cache.py +358 -0
- apm_cli-0.12.2/src/apm_cli/cache/integrity.py +104 -0
- apm_cli-0.12.2/src/apm_cli/cache/locking.py +151 -0
- apm_cli-0.12.2/src/apm_cli/cache/paths.py +169 -0
- apm_cli-0.12.2/src/apm_cli/cache/url_normalize.py +130 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/cli.py +2 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/audit.py +144 -17
- apm_cli-0.12.2/src/apm_cli/commands/cache.py +137 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/install.py +50 -30
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/command_logger.py +68 -3
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/null_logger.py +12 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/apm_resolver.py +218 -71
- apm_cli-0.12.2/src/apm_cli/deps/bare_cache.py +545 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/github_downloader.py +473 -170
- apm_cli-0.12.2/src/apm_cli/deps/shared_clone_cache.py +157 -0
- apm_cli-0.12.2/src/apm_cli/install/cache_pin.py +233 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/context.py +11 -0
- apm_cli-0.12.2/src/apm_cli/install/drift.py +731 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/phases/download.py +28 -41
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/phases/finalize.py +34 -5
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/phases/integrate.py +85 -92
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/phases/lockfile.py +57 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/phases/resolve.py +92 -9
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/pipeline.py +58 -9
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/services.py +138 -38
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/sources.py +51 -17
- apm_cli-0.12.2/src/apm_cli/install/summary.py +73 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/base_integrator.py +28 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/command_integrator.py +244 -14
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/mcp_integrator.py +4 -1
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/targets.py +12 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/ci_checks.py +94 -1
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/registry/client.py +127 -9
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/registry/operations.py +47 -41
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/console.py +1 -1
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/content_hash.py +11 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/diagnostics.py +84 -32
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/file_ops.py +32 -1
- apm_cli-0.12.2/src/apm_cli/utils/git_env.py +97 -0
- apm_cli-0.12.2/src/apm_cli/utils/guards.py +123 -0
- apm_cli-0.12.2/src/apm_cli/utils/install_tui.py +365 -0
- apm_cli-0.12.2/src/apm_cli/utils/normalization.py +57 -0
- apm_cli-0.12.2/src/apm_cli/utils/reflink.py +281 -0
- apm_cli-0.12.2/src/apm_cli/utils/short_sha.py +45 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2/src/apm_cli.egg-info}/PKG-INFO +3 -1
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli.egg-info/SOURCES.txt +19 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli.egg-info/requires.txt +1 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/AUTHORS +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/LICENSE +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/setup.cfg +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/client/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/client/base.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/client/claude.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/client/codex.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/client/copilot.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/client/cursor.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/client/gemini.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/client/opencode.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/client/vscode.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/client/windsurf.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/package_manager/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/package_manager/base.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/adapters/package_manager/default_manager.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/bundle/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/bundle/local_bundle.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/bundle/lockfile_enrichment.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/bundle/packer.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/bundle/plugin_exporter.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/bundle/unpacker.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/_apm_yml_writer.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/_helpers.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/compile/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/compile/cli.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/compile/watcher.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/config.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/deps/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/deps/_utils.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/deps/cli.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/experimental.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/init.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/list_cmd.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/marketplace/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/marketplace/check.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/marketplace/doctor.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/marketplace/init.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/marketplace/migrate.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/marketplace/outdated.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/marketplace/plugin/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/marketplace/plugin/add.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/marketplace/plugin/remove.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/marketplace/plugin/set.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/marketplace/publish.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/marketplace/validate.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/mcp.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/outdated.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/pack.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/policy.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/prune.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/run.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/runtime.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/uninstall/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/uninstall/cli.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/uninstall/engine.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/update.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/commands/view.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/agents_compiler.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/build_id.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/claude_formatter.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/constants.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/constitution.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/constitution_block.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/context_optimizer.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/distributed_compiler.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/gemini_formatter.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/injector.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/link_resolver.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/output_writer.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/compilation/template_builder.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/config.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/constants.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/auth.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/azure_cli.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/build_orchestrator.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/conflict_detector.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/docker_args.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/experimental.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/operations.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/safe_installer.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/scope.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/script_runner.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/target_detection.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/core/token_manager.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/aggregator.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/artifactory_entry.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/dependency_graph.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/download_strategies.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/git_remote_ops.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/github_downloader_validation.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/installed_package.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/lockfile.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/package_validator.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/plugin_parser.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/registry_proxy.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/transport_selection.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/deps/verifier.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/drift.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/factory.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/errors.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/helpers/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/helpers/security_scan.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/insecure_policy.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/local_bundle_handler.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/mcp/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/mcp/args.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/mcp/command.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/mcp/conflicts.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/mcp/entry.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/mcp/registry.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/mcp/warnings.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/mcp/writer.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/phases/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/phases/cleanup.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/phases/local_content.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/phases/policy_gate.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/phases/policy_target_check.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/phases/post_deps_local.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/phases/targets.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/presentation/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/presentation/dry_run.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/request.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/service.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/skill_path_migration.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/template.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/install/validation.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/agent_integrator.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/cleanup.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/copilot_cowork_paths.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/coverage.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/dispatch.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/hook_integrator.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/instruction_integrator.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/prompt_integrator.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/skill_integrator.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/skill_transformer.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/integration/utils.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/_git_utils.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/_io.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/builder.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/client.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/errors.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/git_stderr.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/init_template.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/migration.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/models.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/pr_integration.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/publisher.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/ref_resolver.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/registry.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/resolver.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/semver.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/shadow_detector.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/tag_pattern.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/validator.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/version_pins.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/yml_editor.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/marketplace/yml_schema.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/models/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/models/apm_package.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/models/dependency/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/models/dependency/mcp.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/models/dependency/reference.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/models/dependency/types.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/models/plugin.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/models/results.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/models/validation.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/output/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/output/formatters.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/output/models.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/output/script_formatters.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/_help_text.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/discovery.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/inheritance.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/install_preflight.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/matcher.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/models.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/outcome_routing.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/parser.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/policy_checks.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/project_config.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/policy/schema.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/primitives/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/primitives/discovery.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/primitives/models.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/primitives/parser.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/registry/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/registry/integration.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/runtime/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/runtime/base.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/runtime/codex_runtime.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/runtime/copilot_runtime.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/runtime/factory.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/runtime/llm_runtime.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/runtime/manager.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/security/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/security/audit_report.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/security/content_scanner.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/security/file_scanner.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/security/gate.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/update_policy.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/atomic_io.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/exclude.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/github_host.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/helpers.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/path_security.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/paths.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/subprocess_env.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/version_checker.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/utils/yaml_io.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/version.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/workflow/__init__.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/workflow/discovery.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/workflow/parser.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli/workflow/runner.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli.egg-info/dependency_links.txt +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli.egg-info/entry_points.txt +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/src/apm_cli.egg-info/top_level.txt +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_apm_package_models.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_apm_resolver.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_codex_docker_args_fix.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_codex_empty_string_and_defaults.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_collision_integration.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_console.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_distributed_compilation.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_empty_string_and_defaults.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_enhanced_discovery.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_github_downloader.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_github_downloader_token_precedence.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_lockfile.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_runnable_prompts.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_runtime_manager_token_precedence.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_token_manager.py +0 -0
- {apm_cli-0.12.0 → apm_cli-0.12.2}/tests/test_virtual_package_multi_install.py +0 -0
|
@@ -1215,6 +1215,43 @@ _Copyright (c) 2014-2026 Anthon van der Neut, Ruamel bvba_
|
|
|
1215
1215
|
|
|
1216
1216
|
---
|
|
1217
1217
|
|
|
1218
|
+
## Component. filelock
|
|
1219
|
+
|
|
1220
|
+
- Version requirement: `>=3.12`
|
|
1221
|
+
- Upstream: https://github.com/tox-dev/filelock
|
|
1222
|
+
- SPDX: `Unlicense`
|
|
1223
|
+
- Notes: Used for cross-process file-based locks in the persistent install cache.
|
|
1224
|
+
|
|
1225
|
+
### Open Source License/Copyright Notice.
|
|
1226
|
+
|
|
1227
|
+
_Released into the public domain via The Unlicense (no copyright claimed by upstream)._
|
|
1228
|
+
|
|
1229
|
+
```
|
|
1230
|
+
MIT License
|
|
1231
|
+
|
|
1232
|
+
Copyright (c) 2025 Bernát Gábor and contributors
|
|
1233
|
+
|
|
1234
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
1235
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
1236
|
+
in the Software without restriction, including without limitation the rights
|
|
1237
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
1238
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
1239
|
+
furnished to do so, subject to the following conditions:
|
|
1240
|
+
|
|
1241
|
+
The above copyright notice and this permission notice shall be included in all
|
|
1242
|
+
copies or substantial portions of the Software.
|
|
1243
|
+
|
|
1244
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
1245
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
1246
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
1247
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
1248
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
1249
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
1250
|
+
SOFTWARE.
|
|
1251
|
+
```
|
|
1252
|
+
|
|
1253
|
+
---
|
|
1254
|
+
|
|
1218
1255
|
Submitted on behalf of a third-party
|
|
1219
1256
|
|
|
1220
1257
|
The contributions below are identified as submitted on behalf of a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: apm-cli
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.2
|
|
4
4
|
Summary: MCP configuration tool
|
|
5
5
|
Author-email: Daniel Meppiel <user@example.com>
|
|
6
6
|
License: MIT License
|
|
@@ -51,6 +51,7 @@ Requires-Dist: rich-click>=1.7.0
|
|
|
51
51
|
Requires-Dist: watchdog>=3.0.0
|
|
52
52
|
Requires-Dist: GitPython>=3.1.0
|
|
53
53
|
Requires-Dist: ruamel.yaml>=0.18.0
|
|
54
|
+
Requires-Dist: filelock>=3.12
|
|
54
55
|
Provides-Extra: dev
|
|
55
56
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
56
57
|
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
@@ -145,6 +146,7 @@ Agent context is executable in effect — a prompt is a program for an LLM. APM
|
|
|
145
146
|
|
|
146
147
|
- **[Content security](https://microsoft.github.io/apm/enterprise/security/)** — `apm install` blocks compromised packages before agents read them; `apm audit` runs the same checks on demand
|
|
147
148
|
- **[Lockfile integrity](https://microsoft.github.io/apm/enterprise/governance/)** — `apm.lock` records resolved sources and content hashes for full provenance
|
|
149
|
+
- **[Drift detection](https://microsoft.github.io/apm/guides/drift-detection/)** — `apm audit` rebuilds your agent context in scratch and diffs it against your working tree to catch hand-edits before they ship
|
|
148
150
|
- **[MCP trust boundaries](https://microsoft.github.io/apm/guides/mcp-servers/)** — transitive MCP servers require explicit consent
|
|
149
151
|
|
|
150
152
|
### 3. Governed by policy
|
|
@@ -81,6 +81,7 @@ Agent context is executable in effect — a prompt is a program for an LLM. APM
|
|
|
81
81
|
|
|
82
82
|
- **[Content security](https://microsoft.github.io/apm/enterprise/security/)** — `apm install` blocks compromised packages before agents read them; `apm audit` runs the same checks on demand
|
|
83
83
|
- **[Lockfile integrity](https://microsoft.github.io/apm/enterprise/governance/)** — `apm.lock` records resolved sources and content hashes for full provenance
|
|
84
|
+
- **[Drift detection](https://microsoft.github.io/apm/guides/drift-detection/)** — `apm audit` rebuilds your agent context in scratch and diffs it against your working tree to catch hand-edits before they ship
|
|
84
85
|
- **[MCP trust boundaries](https://microsoft.github.io/apm/guides/mcp-servers/)** — transitive MCP servers require explicit consent
|
|
85
86
|
|
|
86
87
|
### 3. Governed by policy
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "apm-cli"
|
|
7
|
-
version = "0.12.
|
|
7
|
+
version = "0.12.2"
|
|
8
8
|
description = "MCP configuration tool"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -36,6 +36,7 @@ dependencies = [
|
|
|
36
36
|
"watchdog>=3.0.0",
|
|
37
37
|
"GitPython>=3.1.0",
|
|
38
38
|
"ruamel.yaml>=0.18.0",
|
|
39
|
+
"filelock>=3.12",
|
|
39
40
|
]
|
|
40
41
|
|
|
41
42
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Persistent content-addressable cache for APM install.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
- :func:`get_cache_root` -- resolve the platform cache directory
|
|
6
|
+
- :class:`GitCache` -- content-addressable git repository + checkout cache
|
|
7
|
+
- :class:`HttpCache` -- HTTP response cache with conditional revalidation
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .git_cache import GitCache
|
|
13
|
+
from .http_cache import HttpCache
|
|
14
|
+
from .paths import get_cache_root
|
|
15
|
+
|
|
16
|
+
__all__ = ["GitCache", "HttpCache", "get_cache_root"]
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""Persistent content-addressable git cache.
|
|
2
|
+
|
|
3
|
+
Two-tier structure:
|
|
4
|
+
- ``git/db_v1/<shard>/`` -- bare git repositories (full clones)
|
|
5
|
+
- ``git/checkouts_v1/<shard>/<sha>/`` -- per-SHA working copies
|
|
6
|
+
|
|
7
|
+
Cache keys are derived from normalized repository URLs (see
|
|
8
|
+
:mod:`url_normalize`). Checkouts are keyed by resolved SHA, never
|
|
9
|
+
by mutable ref strings.
|
|
10
|
+
|
|
11
|
+
Resolution flow:
|
|
12
|
+
1. If lockfile provides SHA for this dep -> use directly
|
|
13
|
+
2. If ref looks like full SHA (40 hex chars) -> use as-is
|
|
14
|
+
3. Else ``git ls-remote <url> <ref>`` to resolve ref -> SHA
|
|
15
|
+
|
|
16
|
+
On every cache HIT:
|
|
17
|
+
- Run integrity check (verify HEAD == expected SHA)
|
|
18
|
+
- Mismatch -> evict shard, fall through to fresh fetch, log warning
|
|
19
|
+
|
|
20
|
+
Concurrency:
|
|
21
|
+
- Per-shard file locks (via filelock) for atomic operations
|
|
22
|
+
- Atomic landing protocol for safe concurrent installs
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import subprocess
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from ..utils.path_security import ensure_path_within
|
|
35
|
+
from .integrity import verify_checkout_sha
|
|
36
|
+
from .locking import atomic_land, cleanup_incomplete, shard_lock, stage_path
|
|
37
|
+
from .paths import get_git_checkouts_path, get_git_db_path
|
|
38
|
+
from .url_normalize import cache_shard_key
|
|
39
|
+
|
|
40
|
+
_log = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# Full SHA pattern: 40 hex characters
|
|
43
|
+
_SHA_RE = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GitCache:
|
|
47
|
+
"""Content-addressable git cache with integrity verification.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
cache_root: Root cache directory (from :func:`get_cache_root`).
|
|
51
|
+
refresh: If True, force revalidation even on cache hit.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, cache_root: Path, *, refresh: bool = False) -> None:
|
|
55
|
+
self._cache_root = cache_root
|
|
56
|
+
self._refresh = refresh
|
|
57
|
+
self._db_root = get_git_db_path(cache_root)
|
|
58
|
+
self._checkouts_root = get_git_checkouts_path(cache_root)
|
|
59
|
+
|
|
60
|
+
# Ensure bucket directories exist
|
|
61
|
+
self._db_root.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
self._checkouts_root.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
os.chmod(str(self._db_root), 0o700)
|
|
64
|
+
os.chmod(str(self._checkouts_root), 0o700)
|
|
65
|
+
|
|
66
|
+
# Clean up any stale incomplete operations from previous crashes
|
|
67
|
+
cleanup_incomplete(self._db_root)
|
|
68
|
+
cleanup_incomplete(self._checkouts_root)
|
|
69
|
+
|
|
70
|
+
def get_checkout(
|
|
71
|
+
self,
|
|
72
|
+
url: str,
|
|
73
|
+
ref: str | None,
|
|
74
|
+
*,
|
|
75
|
+
locked_sha: str | None = None,
|
|
76
|
+
env: dict[str, str] | None = None,
|
|
77
|
+
) -> Path:
|
|
78
|
+
"""Return path to a cached checkout for the given repo+ref.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
url: Repository URL (any supported form).
|
|
82
|
+
ref: Git ref (branch, tag, SHA) or None for default branch.
|
|
83
|
+
locked_sha: If provided (from lockfile), skip resolution and
|
|
84
|
+
use this SHA directly.
|
|
85
|
+
env: Environment dict for git subprocesses.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Path to the checkout directory (guaranteed to contain valid
|
|
89
|
+
git working copy at the expected SHA).
|
|
90
|
+
"""
|
|
91
|
+
shard_key = cache_shard_key(url)
|
|
92
|
+
sha = self._resolve_sha(url, ref, locked_sha=locked_sha, env=env)
|
|
93
|
+
|
|
94
|
+
checkout_dir = self._checkouts_root / shard_key / sha
|
|
95
|
+
|
|
96
|
+
# Cache hit path (skip if refresh requested)
|
|
97
|
+
if not self._refresh and checkout_dir.is_dir():
|
|
98
|
+
if verify_checkout_sha(checkout_dir, sha):
|
|
99
|
+
_log.debug("Cache HIT: %s @ %s", url, sha[:12])
|
|
100
|
+
return checkout_dir
|
|
101
|
+
else:
|
|
102
|
+
# Integrity failure -- evict
|
|
103
|
+
_log.warning(
|
|
104
|
+
"[!] Evicting corrupt cache entry: %s @ %s",
|
|
105
|
+
_sanitize_url(url),
|
|
106
|
+
sha[:12],
|
|
107
|
+
)
|
|
108
|
+
self._evict_checkout(checkout_dir)
|
|
109
|
+
|
|
110
|
+
# Cache miss: ensure we have the bare repo, then create checkout
|
|
111
|
+
self._ensure_bare_repo(url, shard_key, sha, env=env)
|
|
112
|
+
return self._create_checkout(url, shard_key, sha, env=env)
|
|
113
|
+
|
|
114
|
+
def _resolve_sha(
|
|
115
|
+
self,
|
|
116
|
+
url: str,
|
|
117
|
+
ref: str | None,
|
|
118
|
+
*,
|
|
119
|
+
locked_sha: str | None = None,
|
|
120
|
+
env: dict[str, str] | None = None,
|
|
121
|
+
) -> str:
|
|
122
|
+
"""Resolve a ref to a full SHA.
|
|
123
|
+
|
|
124
|
+
Priority:
|
|
125
|
+
1. locked_sha from lockfile (trusted, no network)
|
|
126
|
+
2. ref already looks like a full SHA
|
|
127
|
+
3. git ls-remote to resolve ref -> SHA
|
|
128
|
+
"""
|
|
129
|
+
if locked_sha and _SHA_RE.match(locked_sha):
|
|
130
|
+
return locked_sha.lower()
|
|
131
|
+
|
|
132
|
+
if ref and _SHA_RE.match(ref):
|
|
133
|
+
return ref.lower()
|
|
134
|
+
|
|
135
|
+
# Need to resolve via ls-remote
|
|
136
|
+
return self._ls_remote_resolve(url, ref, env=env)
|
|
137
|
+
|
|
138
|
+
def _ls_remote_resolve(
|
|
139
|
+
self,
|
|
140
|
+
url: str,
|
|
141
|
+
ref: str | None,
|
|
142
|
+
*,
|
|
143
|
+
env: dict[str, str] | None = None,
|
|
144
|
+
) -> str:
|
|
145
|
+
"""Resolve a ref to SHA via git ls-remote.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
url: Repository URL.
|
|
149
|
+
ref: Ref to resolve (branch, tag, or None for HEAD).
|
|
150
|
+
env: Environment for subprocess.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
40-char lowercase hex SHA.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
RuntimeError: If resolution fails.
|
|
157
|
+
"""
|
|
158
|
+
from ..utils.git_env import get_git_executable, git_subprocess_env
|
|
159
|
+
|
|
160
|
+
git_exe = get_git_executable()
|
|
161
|
+
cmd = [git_exe, "ls-remote", url]
|
|
162
|
+
if ref:
|
|
163
|
+
cmd.append(ref)
|
|
164
|
+
|
|
165
|
+
subprocess_env = env if env is not None else git_subprocess_env()
|
|
166
|
+
try:
|
|
167
|
+
result = subprocess.run(
|
|
168
|
+
cmd,
|
|
169
|
+
capture_output=True,
|
|
170
|
+
text=True,
|
|
171
|
+
timeout=30,
|
|
172
|
+
env=subprocess_env,
|
|
173
|
+
)
|
|
174
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
175
|
+
raise RuntimeError(
|
|
176
|
+
f"Failed to resolve ref '{ref}' for {_sanitize_url(url)}: {exc}"
|
|
177
|
+
) from exc
|
|
178
|
+
|
|
179
|
+
if result.returncode != 0:
|
|
180
|
+
raise RuntimeError(
|
|
181
|
+
f"git ls-remote failed for {_sanitize_url(url)}: {result.stderr.strip()}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Parse ls-remote output: first column is SHA
|
|
185
|
+
for line in result.stdout.strip().splitlines():
|
|
186
|
+
parts = line.split("\t", 1)
|
|
187
|
+
if len(parts) >= 1 and _SHA_RE.match(parts[0]):
|
|
188
|
+
sha = parts[0].lower()
|
|
189
|
+
# If no ref specified, return HEAD (first line)
|
|
190
|
+
if not ref:
|
|
191
|
+
return sha
|
|
192
|
+
# Match exact ref or refs/heads/ref or refs/tags/ref
|
|
193
|
+
if len(parts) == 2:
|
|
194
|
+
remote_ref = parts[1]
|
|
195
|
+
if remote_ref in (
|
|
196
|
+
ref,
|
|
197
|
+
f"refs/heads/{ref}",
|
|
198
|
+
f"refs/tags/{ref}",
|
|
199
|
+
):
|
|
200
|
+
return sha
|
|
201
|
+
# If we have any SHA from output, use the first one
|
|
202
|
+
for line in result.stdout.strip().splitlines():
|
|
203
|
+
parts = line.split("\t", 1)
|
|
204
|
+
if len(parts) >= 1 and _SHA_RE.match(parts[0]):
|
|
205
|
+
return parts[0].lower()
|
|
206
|
+
|
|
207
|
+
raise RuntimeError(f"Could not resolve ref '{ref}' for {_sanitize_url(url)}")
|
|
208
|
+
|
|
209
|
+
def _ensure_bare_repo(
|
|
210
|
+
self,
|
|
211
|
+
url: str,
|
|
212
|
+
shard_key: str,
|
|
213
|
+
sha: str,
|
|
214
|
+
*,
|
|
215
|
+
env: dict[str, str] | None = None,
|
|
216
|
+
) -> Path:
|
|
217
|
+
"""Ensure a bare repo clone exists for the given shard, fetching if needed.
|
|
218
|
+
|
|
219
|
+
Returns the path to the bare repo directory.
|
|
220
|
+
"""
|
|
221
|
+
from ..utils.git_env import get_git_executable, git_subprocess_env
|
|
222
|
+
|
|
223
|
+
bare_dir = self._db_root / shard_key
|
|
224
|
+
# Containment guard: defends against pathological shard_key
|
|
225
|
+
# values bypassing the cache root.
|
|
226
|
+
ensure_path_within(bare_dir, self._db_root)
|
|
227
|
+
lock = shard_lock(bare_dir)
|
|
228
|
+
|
|
229
|
+
# Acquire the shard lock BEFORE the existence probe so that two
|
|
230
|
+
# concurrent processes hitting a cold shard cannot both perform
|
|
231
|
+
# a full network clone (one would lose the atomic_land race
|
|
232
|
+
# later, but only after wasting bandwidth + wall time).
|
|
233
|
+
with lock:
|
|
234
|
+
if bare_dir.is_dir():
|
|
235
|
+
# Repo exists -- check if we have the required SHA
|
|
236
|
+
if self._bare_has_sha(bare_dir, sha, env=env):
|
|
237
|
+
return bare_dir
|
|
238
|
+
# Need to fetch the SHA (lock already held; call the
|
|
239
|
+
# inner helper that does NOT re-acquire).
|
|
240
|
+
self._fetch_into_bare_locked(bare_dir, url, sha, env=env)
|
|
241
|
+
return bare_dir
|
|
242
|
+
|
|
243
|
+
# Cold miss: clone bare repo
|
|
244
|
+
git_exe = get_git_executable()
|
|
245
|
+
staged = stage_path(bare_dir)
|
|
246
|
+
ensure_path_within(staged, self._db_root)
|
|
247
|
+
staged.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
os.chmod(str(staged), 0o700)
|
|
249
|
+
|
|
250
|
+
subprocess_env = env if env is not None else git_subprocess_env()
|
|
251
|
+
try:
|
|
252
|
+
# Full bare clone (no --filter): we extract file contents at
|
|
253
|
+
# checkout time, so all blobs must be present locally. A
|
|
254
|
+
# partial clone would leave the working tree empty after
|
|
255
|
+
# `git clone --local --shared` + `git checkout`, because the
|
|
256
|
+
# alternates pointer would resolve trees but not blobs.
|
|
257
|
+
subprocess.run(
|
|
258
|
+
[git_exe, "clone", "--bare", url, str(staged)],
|
|
259
|
+
capture_output=True,
|
|
260
|
+
text=True,
|
|
261
|
+
timeout=300,
|
|
262
|
+
env=subprocess_env,
|
|
263
|
+
check=True,
|
|
264
|
+
)
|
|
265
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
|
|
266
|
+
# Clean up staged on failure
|
|
267
|
+
from ..utils.file_ops import robust_rmtree
|
|
268
|
+
|
|
269
|
+
robust_rmtree(staged, ignore_errors=True)
|
|
270
|
+
raise RuntimeError(f"Failed to clone {_sanitize_url(url)}: {exc}") from exc
|
|
271
|
+
|
|
272
|
+
# Atomic land (lock is already held; pass it through so the
|
|
273
|
+
# rename completes under the same critical section).
|
|
274
|
+
if not atomic_land(staged, bare_dir, lock):
|
|
275
|
+
# Another process won between our staging and rename
|
|
276
|
+
# (possible only on lock-acquisition timeout fallthrough);
|
|
277
|
+
# verify it has our SHA.
|
|
278
|
+
if not self._bare_has_sha(bare_dir, sha, env=env):
|
|
279
|
+
self._fetch_into_bare_locked(bare_dir, url, sha, env=env)
|
|
280
|
+
|
|
281
|
+
return bare_dir
|
|
282
|
+
|
|
283
|
+
def _create_checkout(
|
|
284
|
+
self,
|
|
285
|
+
url: str,
|
|
286
|
+
shard_key: str,
|
|
287
|
+
sha: str,
|
|
288
|
+
*,
|
|
289
|
+
env: dict[str, str] | None = None,
|
|
290
|
+
) -> Path:
|
|
291
|
+
"""Create a checkout at the specified SHA from the bare repo.
|
|
292
|
+
|
|
293
|
+
Uses ``git clone --local --shared`` from the bare repo for
|
|
294
|
+
efficiency (no network, hardlinks objects).
|
|
295
|
+
|
|
296
|
+
Concurrency / write-deduplication
|
|
297
|
+
---------------------------------
|
|
298
|
+
Acquires the shard lock BEFORE staging any work. On lock entry
|
|
299
|
+
we re-probe the final shard and short-circuit if another
|
|
300
|
+
process populated it while we were waiting on the lock. This
|
|
301
|
+
collapses N racing installs of the same SHA from N concurrent
|
|
302
|
+
``git clone`` operations to ~1: only the lock winner pays the
|
|
303
|
+
clone cost; all losers see a populated shard the moment they
|
|
304
|
+
get the lock and return immediately. Critical for CI matrix
|
|
305
|
+
builds where multiple jobs hit the same uncached repo.
|
|
306
|
+
"""
|
|
307
|
+
from ..utils.git_env import get_git_executable, git_subprocess_env
|
|
308
|
+
|
|
309
|
+
bare_dir = self._db_root / shard_key
|
|
310
|
+
checkout_parent = self._checkouts_root / shard_key
|
|
311
|
+
# Containment guards: the shard_key + sha components are
|
|
312
|
+
# derived from sha256 / hex but defend at the boundary anyway.
|
|
313
|
+
ensure_path_within(checkout_parent, self._checkouts_root)
|
|
314
|
+
checkout_parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
os.chmod(str(checkout_parent), 0o700)
|
|
316
|
+
|
|
317
|
+
final_dir = checkout_parent / sha
|
|
318
|
+
ensure_path_within(final_dir, self._checkouts_root)
|
|
319
|
+
lock = shard_lock(final_dir)
|
|
320
|
+
|
|
321
|
+
# Acquire the lock BEFORE doing any work so that a concurrent
|
|
322
|
+
# install of the same shard does not duplicate the clone work.
|
|
323
|
+
# The lock winner clones; every other process re-probes after
|
|
324
|
+
# the lock and short-circuits.
|
|
325
|
+
with lock:
|
|
326
|
+
# Write-dedup re-probe: another process may have populated
|
|
327
|
+
# this shard while we were waiting. Verify integrity to
|
|
328
|
+
# rule out a poisoned half-write (atomic_land guards
|
|
329
|
+
# against that, but we re-check defensively).
|
|
330
|
+
if final_dir.is_dir() and verify_checkout_sha(final_dir, sha):
|
|
331
|
+
_log.debug("Write-dedup HIT under lock: %s @ %s", url, sha[:12])
|
|
332
|
+
return final_dir
|
|
333
|
+
|
|
334
|
+
staged = stage_path(final_dir)
|
|
335
|
+
ensure_path_within(staged, self._checkouts_root)
|
|
336
|
+
staged.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
os.chmod(str(staged), 0o700)
|
|
338
|
+
|
|
339
|
+
git_exe = get_git_executable()
|
|
340
|
+
subprocess_env = env if env is not None else git_subprocess_env()
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
# Clone from local bare repo (fast, no network)
|
|
344
|
+
subprocess.run(
|
|
345
|
+
[
|
|
346
|
+
git_exe,
|
|
347
|
+
"clone",
|
|
348
|
+
"--local",
|
|
349
|
+
"--shared",
|
|
350
|
+
"--no-checkout",
|
|
351
|
+
str(bare_dir),
|
|
352
|
+
str(staged),
|
|
353
|
+
],
|
|
354
|
+
capture_output=True,
|
|
355
|
+
text=True,
|
|
356
|
+
timeout=60,
|
|
357
|
+
env=subprocess_env,
|
|
358
|
+
check=True,
|
|
359
|
+
)
|
|
360
|
+
# Checkout the specific SHA
|
|
361
|
+
subprocess.run(
|
|
362
|
+
[git_exe, "-C", str(staged), "checkout", sha],
|
|
363
|
+
capture_output=True,
|
|
364
|
+
text=True,
|
|
365
|
+
timeout=60,
|
|
366
|
+
env=subprocess_env,
|
|
367
|
+
check=True,
|
|
368
|
+
)
|
|
369
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
|
|
370
|
+
from ..utils.file_ops import robust_rmtree
|
|
371
|
+
|
|
372
|
+
robust_rmtree(staged, ignore_errors=True)
|
|
373
|
+
raise RuntimeError(
|
|
374
|
+
f"Failed to create checkout for {_sanitize_url(url)} @ {sha[:12]}: {exc}"
|
|
375
|
+
) from exc
|
|
376
|
+
|
|
377
|
+
# We hold the shard lock, so atomic_land's re-acquire is a
|
|
378
|
+
# reentrant no-op (filelock supports same-process recursion).
|
|
379
|
+
if not atomic_land(staged, final_dir, lock):
|
|
380
|
+
# Another process landed first between our re-probe and
|
|
381
|
+
# the rename (only possible if our lock dropped, which
|
|
382
|
+
# it didn't); verify integrity defensively.
|
|
383
|
+
if not verify_checkout_sha(final_dir, sha):
|
|
384
|
+
self._evict_checkout(final_dir)
|
|
385
|
+
raise RuntimeError(
|
|
386
|
+
f"Race condition: concurrent checkout failed integrity "
|
|
387
|
+
f"for {_sanitize_url(url)} @ {sha[:12]}"
|
|
388
|
+
)
|
|
389
|
+
return final_dir
|
|
390
|
+
|
|
391
|
+
def _bare_has_sha(self, bare_dir: Path, sha: str, *, env: dict[str, str] | None = None) -> bool:
|
|
392
|
+
"""Check if the bare repo contains the specified commit."""
|
|
393
|
+
from ..utils.git_env import get_git_executable, git_subprocess_env
|
|
394
|
+
|
|
395
|
+
git_exe = get_git_executable()
|
|
396
|
+
subprocess_env = env if env is not None else git_subprocess_env()
|
|
397
|
+
try:
|
|
398
|
+
result = subprocess.run(
|
|
399
|
+
[git_exe, "-C", str(bare_dir), "cat-file", "-t", sha],
|
|
400
|
+
capture_output=True,
|
|
401
|
+
text=True,
|
|
402
|
+
timeout=10,
|
|
403
|
+
env=subprocess_env,
|
|
404
|
+
)
|
|
405
|
+
return result.returncode == 0 and "commit" in result.stdout.strip()
|
|
406
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
407
|
+
return False
|
|
408
|
+
|
|
409
|
+
def _fetch_into_bare(
|
|
410
|
+
self,
|
|
411
|
+
bare_dir: Path,
|
|
412
|
+
url: str,
|
|
413
|
+
sha: str,
|
|
414
|
+
*,
|
|
415
|
+
env: dict[str, str] | None = None,
|
|
416
|
+
) -> None:
|
|
417
|
+
"""Fetch a specific SHA into an existing bare repo (acquires lock)."""
|
|
418
|
+
lock = shard_lock(bare_dir)
|
|
419
|
+
with lock:
|
|
420
|
+
if self._bare_has_sha(bare_dir, sha, env=env):
|
|
421
|
+
return
|
|
422
|
+
self._fetch_into_bare_locked(bare_dir, url, sha, env=env)
|
|
423
|
+
|
|
424
|
+
def _fetch_into_bare_locked(
|
|
425
|
+
self,
|
|
426
|
+
bare_dir: Path,
|
|
427
|
+
url: str,
|
|
428
|
+
sha: str,
|
|
429
|
+
*,
|
|
430
|
+
env: dict[str, str] | None = None,
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Fetch a specific SHA into a bare repo. Caller MUST hold the shard lock."""
|
|
433
|
+
from ..utils.git_env import get_git_executable, git_subprocess_env
|
|
434
|
+
|
|
435
|
+
git_exe = get_git_executable()
|
|
436
|
+
subprocess_env = env if env is not None else git_subprocess_env()
|
|
437
|
+
try:
|
|
438
|
+
subprocess.run(
|
|
439
|
+
[git_exe, "-C", str(bare_dir), "fetch", url, sha],
|
|
440
|
+
capture_output=True,
|
|
441
|
+
text=True,
|
|
442
|
+
timeout=120,
|
|
443
|
+
env=subprocess_env,
|
|
444
|
+
check=True,
|
|
445
|
+
)
|
|
446
|
+
except subprocess.CalledProcessError:
|
|
447
|
+
# Some servers don't allow fetching by SHA -- fetch all refs
|
|
448
|
+
subprocess.run(
|
|
449
|
+
[git_exe, "-C", str(bare_dir), "fetch", "--all"],
|
|
450
|
+
capture_output=True,
|
|
451
|
+
text=True,
|
|
452
|
+
timeout=120,
|
|
453
|
+
env=subprocess_env,
|
|
454
|
+
check=True,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def _evict_checkout(self, checkout_dir: Path) -> None:
|
|
458
|
+
"""Safely remove a corrupt checkout shard."""
|
|
459
|
+
from ..utils.file_ops import robust_rmtree
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
robust_rmtree(checkout_dir, ignore_errors=True)
|
|
463
|
+
except Exception as exc:
|
|
464
|
+
_log.debug("Failed to evict checkout %s: %s", checkout_dir, exc)
|
|
465
|
+
|
|
466
|
+
def get_cache_stats(self) -> dict[str, int]:
|
|
467
|
+
"""Return cache statistics for ``apm cache info``.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
Dict with keys: db_count, checkout_count, total_size_bytes.
|
|
471
|
+
"""
|
|
472
|
+
db_count = 0
|
|
473
|
+
checkout_count = 0
|
|
474
|
+
total_size = 0
|
|
475
|
+
|
|
476
|
+
if self._db_root.is_dir():
|
|
477
|
+
for entry in os.scandir(str(self._db_root)):
|
|
478
|
+
if entry.is_dir(follow_symlinks=False) and not entry.name.endswith(".lock"):
|
|
479
|
+
db_count += 1
|
|
480
|
+
total_size += _dir_size(Path(entry.path))
|
|
481
|
+
|
|
482
|
+
if self._checkouts_root.is_dir():
|
|
483
|
+
for shard_entry in os.scandir(str(self._checkouts_root)):
|
|
484
|
+
if shard_entry.is_dir(follow_symlinks=False):
|
|
485
|
+
for sha_entry in os.scandir(shard_entry.path):
|
|
486
|
+
if sha_entry.is_dir(follow_symlinks=False):
|
|
487
|
+
checkout_count += 1
|
|
488
|
+
total_size += _dir_size(Path(sha_entry.path))
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
"db_count": db_count,
|
|
492
|
+
"checkout_count": checkout_count,
|
|
493
|
+
"total_size_bytes": total_size,
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
def clean_all(self) -> None:
|
|
497
|
+
"""Remove ALL cache content (db + checkouts). Used by ``apm cache clean``."""
|
|
498
|
+
from ..utils.file_ops import robust_rmtree
|
|
499
|
+
|
|
500
|
+
for bucket in (self._db_root, self._checkouts_root):
|
|
501
|
+
if bucket.is_dir():
|
|
502
|
+
for entry in os.scandir(str(bucket)):
|
|
503
|
+
if entry.is_dir(follow_symlinks=False):
|
|
504
|
+
robust_rmtree(Path(entry.path), ignore_errors=True)
|
|
505
|
+
elif entry.is_file(follow_symlinks=False):
|
|
506
|
+
with contextlib.suppress(OSError):
|
|
507
|
+
os.unlink(entry.path)
|
|
508
|
+
|
|
509
|
+
def prune(self, *, max_age_days: int = 30) -> int:
|
|
510
|
+
"""Remove checkout entries older than *max_age_days*.
|
|
511
|
+
|
|
512
|
+
Uses mtime of the checkout directory as the access indicator.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
Number of entries pruned.
|
|
516
|
+
"""
|
|
517
|
+
import time
|
|
518
|
+
|
|
519
|
+
from ..utils.file_ops import robust_rmtree
|
|
520
|
+
|
|
521
|
+
cutoff = time.time() - (max_age_days * 86400)
|
|
522
|
+
pruned = 0
|
|
523
|
+
|
|
524
|
+
if not self._checkouts_root.is_dir():
|
|
525
|
+
return 0
|
|
526
|
+
|
|
527
|
+
for shard_entry in os.scandir(str(self._checkouts_root)):
|
|
528
|
+
if not shard_entry.is_dir(follow_symlinks=False):
|
|
529
|
+
continue
|
|
530
|
+
for sha_entry in os.scandir(shard_entry.path):
|
|
531
|
+
if not sha_entry.is_dir(follow_symlinks=False):
|
|
532
|
+
continue
|
|
533
|
+
try:
|
|
534
|
+
stat = sha_entry.stat(follow_symlinks=False)
|
|
535
|
+
if stat.st_mtime < cutoff:
|
|
536
|
+
robust_rmtree(Path(sha_entry.path), ignore_errors=True)
|
|
537
|
+
pruned += 1
|
|
538
|
+
except OSError:
|
|
539
|
+
continue
|
|
540
|
+
|
|
541
|
+
return pruned
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _dir_size(path: Path) -> int:
|
|
545
|
+
"""Calculate total size of a directory (non-recursive symlink-safe)."""
|
|
546
|
+
total = 0
|
|
547
|
+
try:
|
|
548
|
+
for root, _dirs, files in os.walk(str(path)):
|
|
549
|
+
for f in files:
|
|
550
|
+
fp = os.path.join(root, f)
|
|
551
|
+
try:
|
|
552
|
+
st = os.lstat(fp)
|
|
553
|
+
total += st.st_size
|
|
554
|
+
except OSError:
|
|
555
|
+
pass
|
|
556
|
+
except OSError:
|
|
557
|
+
pass
|
|
558
|
+
return total
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _sanitize_url(url: str) -> str:
|
|
562
|
+
"""Strip credentials from URL for safe logging."""
|
|
563
|
+
import urllib.parse
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
parsed = urllib.parse.urlparse(url)
|
|
567
|
+
if parsed.password:
|
|
568
|
+
# Replace password with ***
|
|
569
|
+
netloc = parsed.hostname or ""
|
|
570
|
+
if parsed.username:
|
|
571
|
+
netloc = f"{parsed.username}:***@{netloc}"
|
|
572
|
+
if parsed.port:
|
|
573
|
+
netloc = f"{netloc}:{parsed.port}"
|
|
574
|
+
return urllib.parse.urlunparse(parsed._replace(netloc=netloc))
|
|
575
|
+
except Exception:
|
|
576
|
+
pass
|
|
577
|
+
return url
|