Maintenance/code cleanup 2026-05 Phase V (performance and pure python resolver prework)#6668
Open
matteius wants to merge 85 commits into
Conversation
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
… and news fragment Three Copilot review findings on PR #6663: 1. **Sources.all could return None.** When a lockfile exists but ``_meta.sources`` is missing/empty, the if/else fell through with no explicit return — the implicit ``None`` would break callers (``default``, ``index_urls``, ``get_source``, ``find_source``) that expect a list. Inherited behaviour from the original ``Project.sources`` property, surfaced by Copilot now that the shape is documented as a List accessor. Always fall back to ``pipfile_sources()`` after the lockfile path declines to provide anything. 2. **Stale Python 3.7 reference in find_source.** Inline comment claimed the explicit iteration was needed "to stay compatible with Python 3.7" — pipenv has required ≥ 3.10 for some time. The short-circuit concern the comment described is also moot after #1 (``self.all`` no longer returns None), so the explicit-iteration pattern stands on its own without the misleading version note — dropped. 3. **News fragment mismatched reality.** The fragment described a deprecation-with-future-removal, but T_A.4 in this same PR removed the alias outright (commit 8554651). Rewrote the fragment to describe the actual change and the underlying "CLI is the stable API" rationale; also covers the parallel SourceNotFound re-export removal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Scripts Windows / Python 3.10 CI run 25744107117 on PR #6665 reproducibly fails test_multiple_editable_packages_should_not_race with: FileNotFoundError: [WinError 3] The system cannot find the path specified: 'C:\\Users\\runneradmin\\.virtualenvs\\pipenv-...\\Scripts' The traceback runs through `VenvLocator._get_virtualenv_hash`, which iterates `WORKON_HOME` and calls `is_virtual_environment(path)` on every child to detect case-collision conflicts. When a parallel test leaves a partially torn-down venv directory under WORKON_HOME — root exists, but `Scripts` (or `bin`) has been removed — `Path.glob` behaves differently per platform: - Unix: returns an empty iterator (the loop body simply doesn't run). - Windows: `scandir` is strict, so `Path.joinpath("Scripts").glob("python*")` raises `FileNotFoundError`, which bubbles all the way up and crashes `pipenv install` before resolution can start. Fix is a single `bindir.is_dir()` guard before iterating its glob. Resilient on both platforms; preserves existing semantics for real venvs (test added). Two new unit tests pin the behaviour: - `test_is_virtual_environment_returns_false_for_directory_without_bindir` — the bug repro: empty dir is not a venv, must not crash. - `test_is_virtual_environment_returns_true_for_real_venv_layout` — sanity: a bin/python layout still resolves to True. No skip marker added; the proper fix replaces the platform-specific workaround. 679 unit tests pass (was 677 baseline + 2 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ce/code-cleanup-phase3-resolver-typed-schema-2026-05
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pypa/pipenv/sessions/18303346-406f-45d2-bff4-984f606f3020 Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pypa/pipenv/sessions/18303346-406f-45d2-bff4-984f606f3020 Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pypa/pipenv/sessions/18303346-406f-45d2-bff4-984f606f3020 Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pypa/pipenv/sessions/18303346-406f-45d2-bff4-984f606f3020 Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
Maintainer answers to the 10 questions in initiative-f-backends-design.md §6 captured as a sign-off addendum: 1. Pipfile opt-in: [pipenv] resolver = "..." (plus pyproject/pylock readers) 2. CLI: --resolver NAME 3. Lockfile: support BOTH Pipfile.lock + pylock.toml 4. Missing-backend: fail loud 5. Pip _meta.resolver_backend: omit (rec accepted) 6. Cross-backend re-lock: refuse without --allow-backend-switch (rec) 7. New schema fields: NONE — keep backend-neutral (rec) 8. Vendor vs system uv: DEFER — T_F.5 here is groundwork only 9. Test matrix expansion: DEFER to uv-backend follow-up 10. News fragment category: .feature.rst Critically, answer 8 re-scopes T_F.5 in this branch: scaffolding only (Backend protocol, pipenv/resolver/backends/ package, registry, Pipfile reading, --resolver flag, fail-loud error). The uv backend port from origin/uv-backend and the dual-backend test matrix become T_F.8 in a future iteration; T_F.5 lays the framework now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y; delete legacy requirementslib.py (T_E.4)
T_E.4: Per the T_E.1 design + §6 question 4 sign-off ("APPROVED as
proposed: new pipenv/utils/unpack.py"), relocate the pip-internal fork
pair plus their local VCS_SCHEMES set out of pipenv/utils/requirementslib.py
into a new pipenv/utils/unpack.py. Then delete the now-empty
requirementslib.py shell.
Moved:
unpack_url (~63 lines incl. provenance docstring)
get_http_url (~38 lines incl. provenance docstring)
VCS_SCHEMES (25-element set, used only by unpack_url)
The new module has a top-level docstring noting the pip-internal-fork
provenance, points at the two design docs (initiative-b-triage,
initiative-e-design), and the per-function provenance commentary
(load-bearing divergences: VCS-link return-value handling in unpack_url;
globally_managed=False in get_http_url) is preserved verbatim from
the requirementslib.py copy.
VCS_SCHEMES placement: kept alongside unpack_url in unpack.py rather
than moved to pipenv/utils/constants.py. Reason: zero cross-module
callers (only unpack_url itself reads it; the constants.py VCS_SCHEMES
is a distinct list of vcs+transport strings that does NOT contain the
bare git/hg/svn/bzr schemes the unpack set needs).
Caller migration (1 file):
- pipenv/utils/dependencies.py:41
from pipenv.utils.requirementslib import unpack_url
-> from pipenv.utils.unpack import unpack_url
Files moved: pipenv/utils/requirementslib.py -> pipenv/utils/unpack.py
Files deleted: pipenv/utils/requirementslib.py (now zero in-tree)
Files modified: pipenv/utils/dependencies.py (1 import line);
tests/unit/test_dependencies_bridges.py (T_E.3
"old module no longer exports moved symbols" test
strengthened to "module itself is gone")
Test pinning (8 new in tests/unit/test_unpack.py):
Import-shape pins (5):
- unpack_url importable from pipenv.utils.unpack
- get_http_url importable from pipenv.utils.unpack
- VCS_SCHEMES is a set on the new module (distinct from the
constants.py list)
- pipenv.utils.requirementslib module is gone
- pipenv.utils.dependencies sources unpack_url from the new home
Behavioural smoke (3):
- unpack_url returns File(location, content_type=None) for VCS
links (load-bearing divergence from upstream pip's None return)
- bare 'git' scheme (no +transport) triggers the VCS branch via
our local VCS_SCHEMES set
- get_http_url constructs TempDirectory with
globally_managed=False (load-bearing divergence from pip's True)
Validation:
- tests/unit/ suite: 790 passed, 9 skipped (was 791 collected before;
+8 new in test_unpack.py = 799 collected, with 9 pre-existing
Windows-only skips).
- grep -rn 'from pipenv.utils.requirementslib\|pipenv\.utils\.requirementslib'
under pipenv/ tests/ (excluding vendor + patched) returns zero
real-import hits; only two negative-assertion test calls remain
(in test_unpack.py and test_dependencies_bridges.py, both calling
importlib.import_module to assert ModuleNotFoundError is raised).
Initiative E status after this commit: T_E.2, T_E.3, T_E.4 complete.
The 'requirementslib.py' file is gone. T_E.5 (BAD_PACKAGES) and T_E.6
(add_index_to_pipfile rename) were folded into T_E.2. T_E.7 (optional
requirements.py -> redact.py rename) remains as the only Initiative E
follow-up; it depends on no other E work and can be sequenced at the
maintainer's discretion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the full T_E.4 entry to docs/dev/modernization-plan.md following the T_E.3 format: status Completed, log of moved symbols + caller-migration summary + test-pinning summary, files-edited/created list. Notes that after T_E.4 Initiative E is structurally complete; only the optional T_E.7 rename remains. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the maintainer sign-off recorded in
docs/dev/initiative-f-backends-design.md (2026-05-12), T_F.5 in this PR
is scaffolding only:
* NEW pipenv/resolver/backends/ subpackage with a Backend protocol
(base.py), a name -> backend REGISTRY + get_backend() dispatcher
(__init__.py), and the in-tree PipBackend (pip.py) that wraps the
existing resolve flow.
* pipenv/resolver/core.py: resolve_for_pipenv is now a thin dispatcher;
the original resolve plumbing moves into _pip_resolve and is invoked
via PipBackend.resolve. Behaviour is unchanged for the default case.
* Precedence chain CLI > env > Pipfile > default is honoured by
_selected_backend_name(). Unknown / unavailable backends yield a
structured InternalError response (fail-loud per sign-off Q4).
* --resolver NAME CLI flag added on install/lock/sync/upgrade subcommands;
PIPENV_RESOLVER env var declared in environments.py; Settings.resolver
reads [pipenv] resolver from the Pipfile. ExecutionOptions.resolver on
RoutineContext propagates the CLI choice down to the resolve layer.
* ResolverOptions.backend ("" sentinel default) carries the choice across
the wire; suppressed when empty so existing wire-shape goldens stay
byte-identical (no fixture regen required).
* TODO(T_F.8) marker left at pipenv/utils/pylock.py for the future
[tool.pipenv] resolver hook in pyproject.toml / pylock.toml.
The actual uv backend port from origin/uv-backend (sign-off Q8) becomes
a follow-up initiative; this PR ships the framework so a later PR can
register additional backends with no further plumbing churn.
9 new unit tests in tests/unit/test_resolver_backends.py cover:
- registry dispatch + unknown-backend fail-loud
- PipBackend.is_available() / .resolve() shape parity with
resolve_for_pipenv
- missing-backend -> InternalError (no crash)
- precedence: CLI > env > Pipfile > default
- Settings.resolver reads [pipenv] resolver
news/T_F.5.feature.rst added per sign-off Q10 (.feature.rst).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the 2026-05-12 maintainer sign-off, T_F.5 was re-scoped to scaffolding only: the Backend protocol, the registry, the pip backend wrapping the existing flow, and the CLI/env/Pipfile precedence chain. The uv backend port (and its dual-backend test matrix) becomes a future T_F.8 (or similar) initiative. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fourth extraction in Initiative D (after T_D.2 Sources, T_D.3 Settings,
and T_D.4 VenvLocator). The 13 Lockfile-classified methods on
pipenv.project.Project move into a new pipenv.utils.lockfile.Lockfile
class accessed via a @cached_property on Project. Every internal
caller migrated to the new access path in the same PR (per T_D.1
§8.4 sign-off: no holding-pattern wrappers, no DeprecationWarning).
Per T_D.1 §8.1 maintainer sign-off pylock.toml (PEP 751) support is
NOT folded into this extraction; the new Lockfile subsystem handles
only the legacy Pipfile.lock format. The pylock detection seams in
.content / .as_dict / .write / .any_exists / .pylock_* carry
``# TODO(pylock):`` tags (10 distinct annotations) so the format-
detection layer is re-findable for the 2027 follow-up.
API rename (matches T_D.2 Sources / T_D.4 VenvLocator patterns):
project.lockfile(categories=...) -> project.lockfile.as_dict(categories=...)
project.lockfile_location -> project.lockfile.location
project.lockfile_exists -> project.lockfile.exists
project.lockfile_content -> project.lockfile.content
project.lockfile_package_names -> project.lockfile.package_names
project.any_lockfile_exists -> project.lockfile.any_exists
project.pylock_location -> project.lockfile.pylock_location
project.pylock_exists -> project.lockfile.pylock_exists
project.pylock_output_path -> project.lockfile.pylock_output_path
project.get_lockfile_meta() -> project.lockfile.meta()
project.get_lockfile_hash() -> project.lockfile.hash()
project.load_lockfile(...) -> project.lockfile.load(...)
project.write_lockfile(content) -> project.lockfile.write(content)
Naming-collision resolution: the previous ``Project.lockfile`` was a
CALLABLE method returning the lockfile dict. The new ``Project.lockfile``
is the subsystem instance, and the previous method becomes
``Lockfile.as_dict(categories=...)``. Mirrors the T_D.2 Sources pattern
(``Project.sources`` went from "list of dicts" to subsystem instance,
old data exposed as ``project.sources.all``).
pipenv/project.py shrinks by 173 net lines (1281 -> 1108) and loses
the imports it no longer needs (JSONDecodeError, LockfileCorruptException,
atomic_open_for_write, PylockFile, find_pylock_file, expand_url_credentials).
The extracted methods + their docstrings live in pipenv/utils/lockfile.py
(354 lines).
The orchestrating ``get_or_create_lockfile`` stays on ``Project``
per the T_D.1 §2 ``coordinator`` bucket — it crosses Lockfile +
Sources + Pipfile boundaries and is the only legitimate
cross-subsystem consumer of all three.
Cross-subsystem references documented in the inventory (§3) and
honoured here:
- Lockfile -> Pipfile: meta() and load() read project.pipfile_location
and call project.calculate_pipfile_hash().
- Lockfile -> Sources: meta() reads project.sources.pipfile_sources()
and uses Sources.populate_source as the source canonicaliser.
- Lockfile -> Settings: content / write read project.settings.use_pylock;
pylock_output_path reads project.settings.get("pylock_name").
- Sources -> Lockfile (back-reference): Sources.all reads
project.lockfile.any_exists and project.lockfile.content
(migrated in this PR).
Caller-site migration covers production code (pipenv/help.py,
pipenv/routines/{audit,check,clean,install,lock,requirements,scan,
sync,uninstall,update}.py, pipenv/utils/sources.py) and test code
(tests/integration/{test_install_markers,test_lockfile,test_pylock}.py,
tests/unit/{test_do_update_context_routing,test_lock_sync_uninstall_context_routing,
test_pylock}.py).
Tests: 17 new in tests/unit/test_lockfile.py covering the constructor,
the @cached_property accessor, location / exists / any_exists, the
pylock_* accessors and pylock_output_path default, load / content /
as_dict / write round-trip, meta / hash / package_names, and the
empty-lockfile / no-lockfile edge cases.
Full unit suite green: 816 passed, 9 skipped (above the 780 baseline).
``pipenv lock`` smoke test produces a valid Pipfile.lock.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Records the 2026-05-12 Lockfile-subsystem extraction outcome: 13 methods relocated to ``pipenv/utils/lockfile.py``, ``project.py`` shrinks by 173 net lines, 17 new unit tests, 10 ``# TODO(pylock):`` annotations at the format-detection seams. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…se-3 regression)
Phase-3 CI run (2026-05-12) had 18 integration tests failing reproducibly
with this pattern:
Building requirements...
Resolving dependencies...
Success! <-- default category locks OK
Building requirements...
Resolving dependencies...
Locking Failed! <-- dev category dies
...
raise ResolutionFailure("Failed to lock Pipfile.lock!")
The captured stderr was full of:
VERBOSE:pipenv.patched.pip._internal.configuration:For variant 'global', ...
VERBOSE:pipenv.patched.pip._internal.configuration:For variant 'site', ...
(hundreds of lines per config load, three loads per subprocess)
Root cause: T_F.7's ``_capture_resolver_log`` raised the ``pipenv``
logger's level to ``DEBUG`` during capture. Python's logger inheritance
walks NOTSET children up to the first ancestor with an explicit level
when computing the effective level, so setting ``pipenv`` to DEBUG made
EVERY ``pipenv.*`` child — including ``pipenv.patched.pip._internal.
configuration`` — see DEBUG as its effective filter. pip's config-
loader is naturally chatty at DEBUG; those records propagated to root,
where pip's pre-installed handler (with its custom ``VERBOSE:`` formatter)
emitted them. On the SECOND resolve in a multi-category ``pipenv lock``,
the flood was severe enough that the subprocess exited non-zero.
``ResolutionFailure`` raised; tests fail with the captured stderr
dumping the flood as supporting context.
Two-part fix, per the maintainer's review (2 + 3 of my proposed
candidates):
1. **Use INFO, not DEBUG.** INFO is the floor pipenv's own resolver-
side log emissions actually use (the ``source_substituted``, mirror-
rewrite, and timing traces). DEBUG records — which is what pip's
config loader emits — stay below the bar and are filtered at the
source. We capture the signal without the noise.
2. **``lg.propagate = False`` during capture.** Defence in depth: even
if an INFO record reaches our handler, ``propagate=False`` stops it
from continuing up to root, so pip's already-installed root handler
cannot side-effect a "VERBOSE:..." stderr line from any record we
captured. The original ``propagate`` flag is restored on exit.
Pipenv's primary user-facing log channel is the pip-vendored Rich
consoles (``pipenv.utils.console`` / ``pipenv.utils.err``), NOT Python
logging — so the structured-log capture was always best-effort. The
field stays reserved-but-mostly-empty in non-verbose runs, consistent
with T_F.7's Q9 sign-off.
Two new regression tests in tests/unit/test_resolver_diagnostics.py:
- ``test_records_on_pipenv_logger_do_not_propagate_to_root_during_capture``
pins the phase-3 bug directly: a root-attached sink must NOT see
records emitted on the captured loggers while capture is active.
- ``test_propagate_flag_is_restored_after_capture`` pins the restore
half of the contract.
Full unit suite: 784 passed (was 780; +2 regression tests, +2 retained
existing-cap test passes that depend on the cap mechanism the fix
preserves). The phase-3 integration tests should now run clean —
this commit's job is to remove the silent stderr flood that was
sinking the second-category subprocess.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nitiative D complete
Fifth and final extraction in Initiative D (after T_D.2 Sources, T_D.3
Settings, T_D.4 VenvLocator, and T_D.5 Lockfile). The 38 Pipfile-bucket
methods on pipenv.project.Project move into a new Pipfile class in
pipenv.utils.pipfile, accessed via a @cached_property Project.pipfile.
Every internal caller migrated to the new access path in the same PR
(per T_D.1 §8.4 sign-off: no holding-pattern wrappers, no
DeprecationWarning).
Naming-collision resolution: pipenv/utils/pipfile.py previously hosted a
plette-wrapper dataclass named Pipfile (used only by
pipenv.utils.locking.Lockfile.lockfile_from_pipfile). Per the T_D.5
pattern, the legacy dataclass was renamed to PlettePipfile so the
unqualified name Pipfile is now reserved for the Initiative-D subsystem.
The single caller migrated to the new name in the same commit.
API rename (matches T_D.2 Sources / T_D.4 VenvLocator / T_D.5 Lockfile;
the pipfile_ / _pipfile prefixes drop because the subsystem is named
pipfile):
project.parsed_pipfile -> project.pipfile.parsed
project.pipfile_location -> project.pipfile.location
project.pipfile_exists -> project.pipfile.exists
project.pipfile_is_empty -> project.pipfile.is_empty
project.read_pipfile() -> project.pipfile.read()
project.name -> project.pipfile.name
project.project_directory -> project.pipfile.project_directory
project.required_python_version -> project.pipfile.required_python_version
project.requirements_location -> project.pipfile.requirements_location
project.requirements_exists -> project.pipfile.requirements_exists
project.get_pipfile_section(s) -> project.pipfile.get_section(s)
project.get_package_categories(...) -> project.pipfile.get_package_categories(...)
project.pipfile_package_names -> project.pipfile.package_names
project.write_toml(...) -> project.pipfile.write_toml(...)
project.has_script(n) -> project.pipfile.has_script(n)
project.build_script(n, args) -> project.pipfile.build_script(n, args)
project.proper_names -> project.pipfile.proper_names
project.register_proper_name(n) -> project.pipfile.register_proper_name(n)
project.pipfile_build_requires -> project.pipfile.build_requires
project.calculate_pipfile_hash() -> project.pipfile.calculate_hash()
project.all_packages -> project.pipfile.all_packages
project.packages -> project.pipfile.packages
project.dev_packages -> project.pipfile.dev_packages
project.get_editable_packages(c) -> project.pipfile.get_editable_packages(c)
project.get_package_name_in_pipfile(..) -> project.pipfile.get_package_name(..)
project.get_pipfile_entry(..) -> project.pipfile.get_entry(..)
project.remove_package_from_pipfile(..) -> project.pipfile.remove_package(..)
project.remove_packages_from_pipfile(.) -> project.pipfile.remove_packages(.)
project.reset_category_in_pipfile(c) -> project.pipfile.reset_category(c)
project.generate_package_pipfile_entry -> project.pipfile.generate_entry
project.add_package_to_pipfile(..) -> project.pipfile.add_package(..)
project.add_pipfile_entry_to_pipfile(.) -> project.pipfile.add_entry(.)
project.add_packages_to_pipfile_batch(.) -> project.pipfile.add_packages_batch(.)
project.recase_pipfile() -> project.pipfile.recase()
project.ensure_proper_casing() -> project.pipfile.ensure_proper_casing()
project.proper_case_section(s) -> project.pipfile.proper_case_section(s)
Project._parse_pipfile (internal) -> Pipfile._parse (staticmethod)
Project._get_vcs_packages (internal) -> Pipfile._get_vcs_packages
Project._sort_category (internal) -> Pipfile._sort_category
Project NON_CATEGORY_SECTIONS const -> pipenv.utils.pipfile.NON_CATEGORY_SECTIONS
The mtime-invalidated parsed-Pipfile cache (was
_parsed_pipfile_cache + _parsed_pipfile_mtime_ns on Project; T_D.1
inventory §5: "critical lazy-init") moved verbatim onto Pipfile as
_parsed_cache + _parsed_mtime_ns. The cache lives with the subsystem
that owns the file; Pipfile.write_toml is the single invalidator, and
every external writer (Sources.add_index_to_pipfile, Settings.update,
coordinator Project.create_pipfile) routes through Pipfile.write_toml
rather than poking at the cache directly.
pipenv/project.py shrinks by 617 net lines (1108 -> 491) and loses the
imports it no longer needs (Script, InstallRequirement, VCS_LIST,
extract_vcs_url, normalize_editable_path_for_pip, unquote, plette/tomlkit
items, several pipenv.utils.* helpers, tomllib/tomli, find_requirements,
proper_case, pep423_name, and the determine_*/expansive_install_req_from_line
imports). The extracted methods + their docstrings live in
pipenv/utils/pipfile.py (which grew from 416 to ~1275 lines).
Cross-subsystem references (per T_D.1 §3) honoured:
- Lockfile -> Pipfile: Lockfile.meta() calls
project.pipfile.calculate_hash() and reads project.pipfile.parsed.
Lockfile.load() / .as_dict() / .hash() read project.pipfile.location.
Lockfile.package_names reads project.pipfile.get_package_categories.
- Sources -> Pipfile: Sources.add_index_to_pipfile writes via
project.pipfile.write_toml; pipfile_sources reads
project.pipfile.parsed.
- Settings -> Pipfile: Settings._table() reads
project.pipfile.parsed["pipenv"]; Settings.update writes via
project.pipfile.write_toml.
- VenvLocator -> Pipfile: is_venv_in_project reads
project.pipfile.parsed["pipenv"], project.pipfile.exists; .location
reads project.pipfile.project_directory; .name reads
project.pipfile.name; the hash routine reads
project.pipfile.location.
- Pipfile -> VenvLocator: proper_names / register_proper_name read
project.venv_locator.proper_names_db_path (the file physically lives
under the venv per T_D.4).
- Pipfile -> Settings: remove_package, add_entry, add_packages_batch
all read project.settings.get("sort_pipfile").
The orchestrating coordinators stay on Project per T_D.1 §2:
create_pipfile (spans Sources/VenvLocator/Settings/Pipfile),
get_or_create_lockfile (spans Lockfile/Sources/Pipfile), and
get_environment / environment (span Pipfile/Sources/VenvLocator/Settings).
Caller-site migration touched 26 production files and 14 test files.
Mechanically: ~140 production sites + ~70 test sites were rewritten
from project.X to project.pipfile.X across pipenv/cli/command.py,
pipenv/environment.py, pipenv/help.py, every pipenv/routines/*.py,
pipenv/utils/dependencies.py, environment.py, lockfile.py,
locking.py (the PlettePipfile rename), project.py, resolver.py,
settings.py, sources.py, toml.py, venv_locator.py, virtualenv.py,
plus all referenced unit tests and three integration tests. A small
targeted hand-fix migrated three remaining call sites that the
bulk-rewrite avoided (the bare project.name in venv_locator.py /
virtualenv.py / test_utils.py mock builders, the
getattr(project, 'project_directory', None) shape in
dependencies.clean_resolved_dep, and the self.packages in
resolver.py).
Tests: 42 new in tests/unit/test_pipfile_subsystem.py covering the
constructor and @cached_property accessor, location / exists / name /
project_directory, requirements sibling, required_python_version,
mtime-invalidated parsed cache, write-to-disk + cache invalidation,
write-elsewhere does NOT invalidate, read / is_empty, section and
category accessors, build-system parsing, scripts, package mutators
(remove / reset / bulk remove / add_entry), key lookup with casing,
PEP 503 hash digest + casing invariance, proper-names DB write/read,
and ensure_proper_casing's network-failure fallback.
Full unit suite green: 858 passed, 9 skipped (above the 816 T_D.5
baseline; +42 = the new pipfile-subsystem tests). ``pipenv lock`` smoke
test produces a valid Pipfile.lock and the same canonical hash before
and after the extraction.
Helper-bucket disposition (per T_D.1 §8.5 / §6.5 — the "revisit after
T_D.6 lands" promise):
- path_to(p) — uses self._original_dir; one-line wrapper.
RECOMMENDATION: leave on Project (orchestrator role). The
_original_dir snapshot is captured in __init__ and feels
load-bearing on the Project root; not worth the churn.
- prepend_hash_types(checksums, hash_type) — pure classmethod, no
self. RECOMMENDATION: move to pipenv/utils/hashing.py (new small
module) as a free function. Defer to a separate maintenance pass;
not blocking.
- get_file_hash(session, link) — pure staticmethod, no self.
RECOMMENDATION: move alongside prepend_hash_types in the same
follow-up. Defer.
- _lockfile_encoder (class attribute, JSONEncoder subclass) —
RECOMMENDATION: stays on Project (lockfile-writer detail; lives
where Lockfile.write looks it up).
The orchestrating methods (get_environment, environment,
installed_packages, installed_package_names, create_pipfile,
get_or_create_lockfile) stay on Project per the T_D.1 §2 coordinator
bucket — they're cross-subsystem orchestrators and there is no
smaller home for them.
With T_D.6 landed, Initiative D is structurally complete: the five
subsystems (Sources, Settings, VenvLocator, Lockfile, Pipfile) are
extracted, all callers migrated, and pipenv/project.py sits at
491 lines (down from 1848 at the start of Initiative D — a 73%
reduction). The remaining helper-bucket cleanup is a low-priority
follow-up and not on the critical path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T_D.6 landed in commit 3a160cb — the fifth and final Initiative D extraction. ``pipenv/project.py`` is at its target lean shape (491 lines, down from 1848 at the start of Initiative D — a 73% reduction). The five subsystems (Sources, Settings, VenvLocator, Lockfile, Pipfile) now live in independent modules under ``pipenv/utils/``, accessed via ``@cached_property`` on ``Project``. Plan entry updated with the helper-bucket disposition recommendations, the 858/9 unit-test totals, and the files-edited rollup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…se-3 regression)
Phase-3 CI run (2026-05-12) had 18 integration tests failing reproducibly
with this pattern:
Building requirements...
Resolving dependencies...
Success! <-- default category locks OK
Building requirements...
Resolving dependencies...
Locking Failed! <-- dev category dies
...
raise ResolutionFailure("Failed to lock Pipfile.lock!")
The captured stderr was full of:
VERBOSE:pipenv.patched.pip._internal.configuration:For variant 'global', ...
VERBOSE:pipenv.patched.pip._internal.configuration:For variant 'site', ...
(hundreds of lines per config load, three loads per subprocess)
Root cause: T_F.7's ``_capture_resolver_log`` raised the ``pipenv``
logger's level to ``DEBUG`` during capture. Python's logger inheritance
walks NOTSET children up to the first ancestor with an explicit level
when computing the effective level, so setting ``pipenv`` to DEBUG made
EVERY ``pipenv.*`` child — including ``pipenv.patched.pip._internal.
configuration`` — see DEBUG as its effective filter. pip's config-
loader is naturally chatty at DEBUG; those records propagated to root,
where pip's pre-installed handler (with its custom ``VERBOSE:`` formatter)
emitted them. On the SECOND resolve in a multi-category ``pipenv lock``,
the flood was severe enough that the subprocess exited non-zero.
``ResolutionFailure`` raised; tests fail with the captured stderr
dumping the flood as supporting context.
Two-part fix, per the maintainer's review (2 + 3 of my proposed
candidates):
1. **Use INFO, not DEBUG.** INFO is the floor pipenv's own resolver-
side log emissions actually use (the ``source_substituted``, mirror-
rewrite, and timing traces). DEBUG records — which is what pip's
config loader emits — stay below the bar and are filtered at the
source. We capture the signal without the noise.
2. **``lg.propagate = False`` during capture.** Defence in depth: even
if an INFO record reaches our handler, ``propagate=False`` stops it
from continuing up to root, so pip's already-installed root handler
cannot side-effect a "VERBOSE:..." stderr line from any record we
captured. The original ``propagate`` flag is restored on exit.
Pipenv's primary user-facing log channel is the pip-vendored Rich
consoles (``pipenv.utils.console`` / ``pipenv.utils.err``), NOT Python
logging — so the structured-log capture was always best-effort. The
field stays reserved-but-mostly-empty in non-verbose runs, consistent
with T_F.7's Q9 sign-off.
Two new regression tests in tests/unit/test_resolver_diagnostics.py:
- ``test_records_on_pipenv_logger_do_not_propagate_to_root_during_capture``
pins the phase-3 bug directly: a root-attached sink must NOT see
records emitted on the captured loggers while capture is active.
- ``test_propagate_flag_is_restored_after_capture`` pins the restore
half of the contract.
Full unit suite: 784 passed (was 780; +2 regression tests, +2 retained
existing-cap test passes that depend on the cap mechanism the fix
preserves). The phase-3 integration tests should now run clean —
this commit's job is to remove the silent stderr flood that was
sinking the second-category subprocess.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… protocol fixture
Phase-3 CI run 25751144209 still showed 18 integration test failures
after the T_F.7 logger-leak fix landed. All ran ``pipenv install`` or
``pipenv lock``, the SECOND resolve subprocess exited non-zero, and the
test stderr showed only the parent's ``ResolutionFailure("Failed to
lock Pipfile.lock!")`` traceback — no useful info from the subprocess.
Root cause of the diagnostic loss: ``pipenv/utils/resolver.py::resolve``
raised ``ResolutionFailure`` whenever the subprocess exit was non-zero,
which threw away the structured ``ResolverResponse`` the subprocess
writes to ``--response-file`` via the ``InternalError`` exit path
(see ``pipenv/resolver/main.py:_main``). The caller
(``_run_resolver_subprocess``) already has dispatch logic at
``response_path``-read that handles structured errors — but it never
ran because ``resolve()`` raised first. Q10 of the typed-design
contract is explicit: "response file is the source of truth whenever
it exists, regardless of exit code"; this PR honours it.
Two-part fix:
1. ``resolve()`` returns the ``CompletedProcess`` unconditionally
(instead of raising on non-zero exit). The caller
``_run_resolver_subprocess`` now reaches its response-file dispatch
on both exit paths: structured ``InternalError`` → ``RuntimeError``
with the child's error message; structured ``ResolutionError`` →
``ResolutionFailure`` with ``pip_message`` and ``conflicts``;
no-response-file + non-zero → legacy stderr-fallback ``RuntimeError``.
The happy path is unchanged.
2. ``test_resolver_protocol_lock_smoke`` fixture stabilized. T_F.6
added ``metadata.deadline_seconds`` to ``RequestMetadata`` and T_F.7
added ``diagnostics.resolver_log`` to ``Diagnostics``; both ride on
the wire now and varied between the test runner's environment and
the golden, breaking the canary. Strip both fields in the
normalisers (``deadline_seconds`` via the Pipfile/env precedence
chain; ``diagnostics`` is a side channel pinned separately by the
T_F.7 unit suite) and regenerate the golden.
Validation:
- 784 unit tests green
- ``test_resolver_protocol_lock_smoke`` passes (3 consecutive runs)
- Local ``pipenv lock`` on a six+empty-dev Pipfile produces a valid
lockfile via both subprocess and ``PIPENV_RESOLVER_PARENT_PYTHON=1``
in-process paths.
After this lands, CI failure traces on phase-3 should include the
subprocess's actual error message (carried by the typed
``InternalError`` payload) so we can diagnose the underlying
second-resolve-fails bug, which is *separate* from the diagnostic-loss
bug this commit closes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ict, not a list
The typed-schema refactor (T_F.4 Wave B) replaced the argv+tempfile
``resolved_default_deps`` channel between parent and resolver subprocess
with a ``ResolvedDeps`` envelope carrying a sequence of
``LockedRequirement``. The subprocess re-hydrated that envelope into a
flat ``list[dict]`` (``pipenv/resolver/main.py:212``), but every
downstream consumer (``Resolver.default_constraint_file`` →
``get_constraints_from_resolved_deps`` in
``pipenv/utils/dependencies.py:1501``, and the request builder at
``pipenv/utils/resolver.py:1450``) iterates the value with ``.items()``
and treats the key as the package name.
The mismatch was silent — both call sites accept ``Any`` — until the
first non-default category ran, at which point
``resolved_deps.items()`` raised ``AttributeError: 'list' object has
no attribute 'items'`` inside the resolver subprocess. The parent's
old non-zero-exit handler then masked the AttributeError with a
generic ``ResolutionFailure("Failed to lock Pipfile.lock!")``.
Combined with the diagnostic-recovery commit
(``6ad8c74d``) that unmasked the underlying subprocess error, this
explains the ~17 integration test failures on Ubuntu/3.12 for any test
that locks more than one category (``pipenv install --dev``,
``pipenv lock`` with both packages and dev-packages, the ``test_lock``
suite, ``test_install_basic.test_install_without_dev``,
``test_install_vcs.test_vcs_dev_package_install``, the
``test_uninstall*`` suites, etc.).
The fix keys the rehydrated mapping by ``LockedRequirement.name``,
restoring the ``{name: lockfile_entry}`` shape the in-process path
already used pre-refactor.
Verified locally: a two-category lock (``six`` in ``[packages]`` +
``tablib`` in ``[dev-packages]``) now succeeds end-to-end with both
categories populated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…026-05' into maintenance/code-cleanup-phase4-deferred-2026-05
…tderr forwarding
Two phase-3 refactor regressions, both surfacing on
``test_resolve_skip_unmatched_requirements``:
1. ``resolve_packages`` (``pipenv/resolver/main.py``) called
``_result_dict_to_locked_requirement`` strictly on every entry that
``clean_results`` returned. When a package is filtered out by
``Resolver.check_if_package_req_skipped`` (markers that don't
evaluate — e.g. ``os_name=='FakeOS'`` on Linux) or carries
``skip_resolver=True``, ``clean_results`` already tolerates the
resulting sparse ``{"name": ...}`` dict by falling through its own
ValueError branch — but the subprocess adapter then re-attempted
``LockedRequirement(...)`` construction and raised
``ValueError("LockedRequirement '<name>' carries no version, vcs,
file, or path")``, killing the resolve. Catch the ValueError at
the adapter boundary and drop those entries (they can't survive
the typed wire anyway).
2. ``_run_resolver_subprocess`` (``pipenv/utils/resolver.py``)
stopped forwarding the subprocess's captured stderr to the
parent's stderr stream on the success path. Pre-refactor (see
``main`` branch ``venv_resolve_deps`` body), after a returncode-0
resolve and when non-verbose, the parent printed
``Warning: <subprocess stderr>`` so user-actionable notices
emitted from inside the resolver (most prominently
``check_if_package_req_skipped``'s "Could not find a matching
version of <pkg>; <markers>" message) reached the user's
terminal. ``read_stderr`` captures every line into
``stderr_lines`` for in-memory access via
``CompletedProcess.stderr`` but only echoes verbose /
download-progress lines live, so without the explicit forward
the warning was effectively swallowed. Restore the forward
inside the structured-response success branch.
Verified locally:
- ``test_resolve_skip_unmatched_requirements`` now passes (was the
last remaining failure after the prior ``resolved_default_deps``
dict-shape and diagnostic-recovery commits).
- Unit suite (785 tests) and resolver protocol golden still pass.
- Two-category lock reproducer (``six`` + ``tablib``) still
succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…026-05' into maintenance/code-cleanup-phase4-deferred-2026-05
…pipfile.build_script (T_D.6)
T_D.6 ("extract Pipfile subsystem from Project") moved ``build_script``
and 30+ sibling methods onto ``project.pipfile`` and removed them from
``Project`` outright — no ``__getattr__`` shim is provided, so any
remaining call to the old attribute raises ``AttributeError`` at the
point of use.
The CI deploy step for PR #6667 (phase-4) tripped on this when
``pipenv run`` reached
``pipenv/routines/shell.py:126 -> project.build_script(command, args)``,
killing the run with::
AttributeError: 'Project' object has no attribute 'build_script'
``do_run`` already uses ``project.pipfile.project_directory`` three
lines earlier, confirming the migration target is reachable at this
call site. The integration test in ``tests/integration/test_run.py``
had the same stale calls and would have failed for the same reason
on any environment that actually exercised the assertions on lines
58–64.
The phase-4 doc comment in ``pipenv/project.py`` (the post-T_D.6
boundary inventory) and the migration map in
``pipenv/utils/pipfile.py`` already document this exact rename
(``project.build_script(...) -> project.pipfile.build_script(...)``),
so this is purely a missed call-site update.
Verified: ``hasattr(project, 'build_script') is False``,
``hasattr(project.pipfile, 'build_script') is True``; full unit suite
(860 tests) still passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``do_lock`` always iterates ``["default", "develop", ...]`` even when
a section is empty, and each iteration unconditionally invoked
``venv_resolve_deps`` — spawning the resolver subprocess,
re-importing pipenv + pip + the typed schema, instantiating a fresh
``Resolver`` / ``PackageFinder`` / ``Session``, and (when
``use_default_constraints`` is on) walking PyPI for every default-
category transitive pin just to confirm the empty category doesn't
conflict with anything.
The work produces an empty section; the lockfile category was already
emptied two lines earlier when ``lockfile.pop(category)`` ran.
Skipping the call when ``packages`` is an empty mapping is local to
``do_lock`` — no public-API or contract change. Stale entries are
still removed (the prior ``pop`` ran) and the lockfile writer still
sees an initialized ``{}`` section.
Profiling on a 30-package Pipfile with empty ``[dev-packages]``
(May 2026):
subprocess resolver (default mode): 10.1 s -> 8.8 s (~13 %)
in-process resolver (debug bypass): 12.7 s -> 7.8 s (~39 %)
The win scales with the number of empty categories. Projects that
declare optional groups (``[test-packages]``, ``[docs-packages]``,
etc.) but populate them per-environment will see proportional
savings on every ``pipenv lock`` / ``pipenv install`` /
``pipenv install <pkg>``. Populated categories take the unchanged
resolve path; stale lockfile entries are correctly cleared when a
section is emptied (verified end-to-end).
This is cut #1 of the phase-5 perf plan; the profile that motivated
it (``benchmarks/timings/lock-warm.1.prof`` analysis + a fresh
in-process trace) showed the second-category resolve at ~6 s on the
benchmark fixture even with zero packages to resolve.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…elock Cut #3 of the phase-5 perf plan. Before this commit, ``pipenv lock`` on a warm cache still walked PyPI for every package — pip's ``find_all_candidates`` fetched the project URL, parsed every link record (~200 k ``evaluate_link`` calls on a 30-package Pipfile), and only then narrowed down to a satisfying version, even when the prior ``Pipfile.lock`` already pinned an exact version that the current Pipfile spec accepts. Reuse the existing ``resolved_default_deps`` plumbing (the cross-category constraint channel already built for gh-4665) to also carry warm-relock prior locks. Pre-pinning the resolver to the previous lockfile's versions lets ``find_all_candidates`` short-circuit: pip's PackageFinder picks the constrained version directly and skips the index walk. The result is identical to a fresh resolve (same Pipfile.lock hash) when no Pipfile spec drifted, just much faster. Surgical changes ---------------- - ``pipenv/routines/lock.py`` ``do_lock``: merge two constraint sources into ``category_default_deps`` for every category: 1. Warm-relock prior locks (the popped ``old_lock_data``) when ``--clear`` was not passed, filtered to entries whose locked version still satisfies the current Pipfile spec. 2. Cross-category default constraints (unchanged behaviour). - ``pipenv/routines/lock.py`` ``_filter_pinnable_lock_entries``: new helper. Drops entries with no version (VCS / file / path pins), drops top-level entries where the Pipfile spec no longer accepts the locked version, keeps transitive deps as-is (they have no Pipfile spec and they account for most of the warm-path win). - ``pipenv/utils/resolver.py`` ``Resolver.parsed_constraints`` / ``Resolver.constraints``: lift the ``category != "default"`` gate. Historically these branches only fired for non-default categories (the cross-category use case); the gate now keys off ``self.resolved_default_deps`` presence so the warm-relock pins apply to default-category resolves too. The user-facing kill switch ``[pipenv] use_default_constraints = false`` is still honoured. Measured impact (30-package Pipfile, subprocess resolver, no spinner) -------------------------------------------------------------------- baseline (pre-phase-5) 10.1 s + cut #1 (skip empty cat) 8.8 s (~13 %) + cut #3 (warm-relock pins) 5.9 s (~42 % off baseline, ~33 % off cut #1) Lockfile hash unchanged across the two runs — same resolution result, faster path. Edge cases verified ------------------- - Pipfile spec tightened (``tablib = "==3.6.0"`` when lock has ``3.9.0``): pin dropped, fresh resolve picks ``3.6.0``. - Pipfile spec loosened (``tablib = ">=3.0"`` when lock has ``3.6.0``): pin kept, lockfile stays at ``3.6.0`` (no churn). - ``pipenv lock --clear``: warm-relock pinning skipped, resolver picks the latest matching version regardless of the prior lock. - Populated categories take the unchanged resolve path; transitive deps are pinned to their previous locked versions on every warm relock. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n warm relock" This reverts commit 20092de.
…verage gate, lint gate, news, doc (T17)
Six wrap-up tasks for Phase 1 sign-off:
1. pipenv/resolver/__init__.py — public surface re-exports
(Candidate, Hash, PEP691Client, ParsedManifestCache,
ParallelFetcher, SimplePageResponse, FetchError,
CachedManifest). Pre-existing Initiative F surface
(_main, main, resolve_packages, which) preserved.
2. ``pipenv install --clear`` now invalidates our parsed-manifest
cache via the inner do_lock path. The _clear_parsed_manifest_cache
helper in pipenv/routines/lock.py was landed by the parallel T19
agent under the T17 banner (same hot section); T17's contribution
here is plumbing ``clear=state.clear`` through ``cmd_install``'s
RoutineContext.from_cli call — that parameter was missing before,
so ``pipenv install --clear`` never reached the resolver's clear
path at all. ``pipenv lock --clear`` already worked.
3. pytest-cov config in pyproject.toml (``[tool.coverage.run]``
source + branch=false, ``[tool.coverage.report]`` fail_under=90
+ show_missing + exclude_lines) plus a dedicated CI job
``resolver-module-coverage`` that runs the six T11-T16 test
suites with ``--cov-fail-under=90`` and overrides addopts to
drop ``--no-cov``. Local coverage: 99.67% on the six new
modules — well above the 90% floor. Without this, the
coverage claims in T11-T16 would silently regress.
4. Pre-commit hook ``no-pip-internal-in-resolver`` scoped to
^pipenv/resolver/ that fails any commit reintroducing
``pip._internal`` imports. Pattern anchors on actual import
statements (^\\s*(from|import)\\s+pipenv\\.patched\\.pip\\._internal)
rather than raw substring matches, so docstring / comment /
literal mentions of the path don't false-positive (T1's gotcha).
T10's deliberate parity import in tests/ is exempt via the
files: path filter.
5. News fragment news/initiative-g-phase1-pep691-client.feature.rst
summarising the Phase-1 surface + ``--clear`` invalidation
behaviour. Rendered by ``python -m towncrier build --draft``.
6. docs/dev/initiative-g-pure-python-design.md status line updated
to ``Phase 1 shipped; phases 2-4 awaiting maintainer sign-off``;
§11 Phase 1 acceptance bullets converted to a [x] checklist
with ``Shipped at T17`` annotation. Two extra bullets added
covering the ``--clear`` wiring and the CI/pre-commit gates
(acceptance criteria per T17's plan entry but missing from the
original §11 list). Phase 2 / 3 bullets unchanged.
Verifications (all passing):
- ``python -c "from pipenv.resolver import PEP691Client,
ParsedManifestCache, ParallelFetcher, Candidate, Hash, FetchError,
SimplePageResponse, CachedManifest; print('ok')"`` -> ok.
- ``_clear_parsed_manifest_cache(project)`` removes
<PIPENV_CACHE_DIR>/manifests-v1/ end-to-end, idempotent on
missing dirs, defensive against broken projects.
- ``pytest tests/unit/test_candidate.py --cov=pipenv.resolver.candidate
--cov-fail-under=99 --override-ini="addopts=-ra"`` passes (100%);
same invocation with broader ``--cov=pipenv.resolver`` scope
fails at 23.33% (gate is wired).
- Pre-commit hook returns exit 1 against a deliberate failing
``from pipenv.patched.pip._internal...`` import; exit 0 on the
current clean tree.
- ``python -m towncrier build --draft`` renders the news fragment.
- ``docs/dev/initiative-g-pure-python-design.md`` Phase 1
acceptance criteria shown checked.
Initiative G phase 1 — T17 (ship).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add `resolver_backend=None` param to `venv_resolve_deps()` and pass it down to `_build_resolver_request()` so that the typed `ResolverRequest.options.backend` is properly stamped. - Extract `resolver` from `ctx.execution_options.resolver` in `do_lock()` and pass as `resolver_backend=` to `venv_resolve_deps()`. - Add `resolver` extraction in `_resolve_and_update_lockfile()` from `ctx.execution_options.resolver`; plumb into both `venv_resolve_deps()` calls inside the function. - Add `resolver=None` param to `upgrade()`, thread it into `helper_ctx` via `RoutineContext.from_cli(resolver=resolver)` and into the standalone `venv_resolve_deps()` call in `upgrade()`. - Pass `resolver=exec_opts.resolver` from `do_update()` → `upgrade()`. - Pass `resolver=state.resolver` from `cmd_upgrade()` → `upgrade()`. - Extract `resolver` from `ctx.execution_options.resolver` in `do_uninstall()` and pass as `resolver_backend=` to `venv_resolve_deps()`. Fixes: copilot-pull-request-reviewer comment on resolver.py:1418-1478 Agent-Logs-Url: https://github.com/pypa/pipenv/sessions/e89154d6-cd98-40e5-92e4-991399640e9d Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
… doc + news + status flip (T22) Phase-2 ship of Initiative G: - User-facing documentation for ``[pipenv] prefetch_index_manifests`` + ``PIPENV_PREFETCH_INDEX_MANIFESTS`` env-var override. - News fragment under towncrier convention. - Design-doc Phase-2 acceptance bullets flipped to ``[x]`` except T21 (CI bench measurement) which is annotated as deferred per the maintainer's scoping call. - Sign-off note (§11a) explicitly documenting that the Phase-2 perf claim is theoretical, not measured against the current CI bench — shipped as a low-risk opt-in setting gated on user enablement; future bench data can revisit the Phase-2 acceptance criteria. No code changes. Phase-2 functional surface (T18 + T19 + T20) remains exactly as landed in prior commits. Initiative G phase 2 — T22. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Update venv_locator.py docstring: `project.name` → `project.pipfile.name`
(T_D.6 moved the `name` property onto the Pipfile subsystem)
- Clarify news/T_F.5.feature.rst: the resolver selection knobs
(--resolver, PIPENV_RESOLVER, [pipenv] resolver) ARE exposed in this
release, but only "pip" backend is shipped; selecting unknown backends
yields a clear error. The wording now matches the actual implementation
and avoids claiming "supports" when only scaffolding is present.
Fixes: copilot-pull-request-reviewer comments on venv_locator.py:15-19
and news/T_F.5.feature.rst:1-5
Agent-Logs-Url: https://github.com/pypa/pipenv/sessions/e523d207-1729-4a70-b877-189e86b9cb9a
Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
… Initiative G phase 2) Five scenarios pinning T19's contract: 1. Lockfile parity: prefetch on vs off produces identical ``_meta.hash.sha256`` and per-package pins. 2. Best-effort: populate raising RuntimeError doesn't fail the lock. 3. ``--clear`` short-circuits the prefetch (populate.assert_not_called). 4. Verbose stderr contains the prefetch summary but does NOT leak URLs, package paths, or credentials. 5. ``--clear`` invalidates the parsed-manifest cache at <PIPENV_CACHE_DIR>/manifests-v1/. Phase 2 acceptance test for the do_lock prefetch wiring. Initiative G phase 2 -- T20. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cache T20's integration tests surfaced a real production bug: T17's ``_clear_parsed_manifest_cache`` wiped ``<PIPENV_CACHE_DIR>/manifests-v1/`` while T19's ``_prefetch_index_manifests_if_enabled`` wrote to ``<PIPENV_CACHE_DIR>/pipenv-manifests/manifests-v1/``. Result: ``pipenv lock --clear`` left the prefetcher's cache fully intact — exactly the poisoning surface T17 was meant to nuke. T19's namespacing (``pipenv-manifests/`` subdir) is the correct pattern: it keeps pipenv-owned cache files cleanly separated from anything pip stores in the same directory. Update T17 to match. T20's ``test_clear_invalidates_parsed_manifest_cache`` was seeding the wrong path to keep passing while the production code disagreed with itself; updated to seed the canonical path and pin the bug post-fix. Initiative G phase 2 — T17/T19 path-alignment follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folds Wave 6 (T15, T17, T19) and Wave 7 (T20, T22) plan-status updates into a single tracking commit. T21 marked Skipped per maintainer (see T22 design-doc sign-off note for the framing). Wave 6: e0fdf78 (T15), 85fe117 (T17), f29b87b (T19). Wave 7: d5fa0a6 (T20), 69e821b (T22). Initiative G — Wave 6 + Wave 7 plan-tracking; Phase 2 structurally shipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(FU3, Initiative G phase-3 prep) T8 stored ``verify`` and ``cert`` kwargs on the client but didn't thread them into the actual ``session.request(...)`` call — TLS material was constructor-time-only on the underlying session. Real production sessions are ``PipSession`` (requests.Session subclass) which supports per-request ``verify=`` and ``cert=``; pass them through. Closes the Phase-3 follow-up T8's agent flagged. FU2's per-source verify_ssl fan-out now actually takes effect at the request layer. Initiative G — Phase-3 follow-up #3 (FU3). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tive G phase-3 prep) Refactors ``_prefetch_index_manifests_if_enabled`` to build one ParallelFetcher per unique ``verify_ssl`` policy among Pipfile sources, dispatching each target through the fetcher matching its source's policy. Replaces T19's "majority-verify wins" heuristic that left minority-policy sources falling through to pip's normal cold fetch. Single-policy projects (the common case — one PyPI source, verify_ssl=true) see identical behavior to T19: exactly one fetcher constructed; zero overhead. Mixed-policy projects (private index with self-signed cert alongside public PyPI) now get correct per-source verify routing. Closes Phase-3 follow-up T19's agent flagged. Initiative G — Phase-3 follow-up #2 (FU2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ive G phase-3 prep) Adds ``ParsedManifestCache.peek_etag(index_url, package_name) -> str | None`` that reads the on-disk etag regardless of expiry, plus wires ``ParallelFetcher.populate`` to call it when ``cache.get(...)`` returned None -- so stale-but-present entries now get a conditional GET (``If-None-Match: <etag>``) instead of a full re-download. Closes the Phase-3 follow-up T9's agent flagged: the ``status="not-modified"`` branch in ``_dispatch_fetch_result`` was unreachable before this commit because the fetcher never sent ``If-None-Match`` for stale entries. ``_refresh_not_modified`` now falls back to ``_load_manifest`` (a private cache helper extracted from ``get`` while preserving the public contract) to recover the stale candidates when ``cache.get`` returns None, so option-a TTL refresh actually fires on stale-cache reads. Same defensive contract as ``get()``: any exception (missing file, corrupt JSON, schema mismatch, malformed etag) returns None silently. Coverage stays at 100% on both modules. Initiative G -- Phase-3 follow-up #1 (FU1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three Phase-3 follow-ups flagged by Wave-3/4/5 agents (T8, T9, T19) all landed on this branch ahead of Phase 3's formal scoping: - FU1 (91c1e4e): peek_etag + stale-cache short-circuit - FU2 (4a0ff8a): per-source verify_ssl fan-out - FU3 (0047a2e): per-request TLS material threading Adds a "Phase-3 follow-ups landed during plan execution" section to the plan documenting each follow-up's trigger, resolution, and test footprint. Lists the remaining Phase-3-flagged items (self-signed-cert fixture, the full pure_python.Provider) for future scoping. Initiative G — Phase-3-prep plan-tracking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ve_constraints ``Resolver.resolve_constraints`` called pip's ``PackageFinder.find_best_candidate(name, specifier)`` once per resolved package, then read ``candidate.link.requires_python`` off the returned link. The resolved tree already carries that link (pip's resolvelib stores the chosen candidate on every ``InstallRequirement`` it returns from ``resolve()``), so the second pass was a redundant per-package simple-API walk — cached HTTP, but still parses every link through pip's ``Link.from_json`` plus ``_ensure_quoted_url``. Measured on the 100-package benchmark Pipfile (May 2026): in-process wall: 31.4 s -> 23.4 s (-25.5 %, ~8 s saved) subprocess warm: 22.6 s -> 17.9 s (-21 %, ~4.7 s saved) ``resolve_constraints`` and its ``_requires_python_marker`` helper both fall out of the top-50 cumulative profile entries entirely. The remaining in-process wall (20.7 s of 23.4 s) lives inside pip's own resolver loop, the architectural ceiling documented in ``docs/dev/initiative-g-pure-python-design.md`` §2.2. Lockfile-byte-identity check: same Pipfile, same lockfile ``_meta.hash.sha256`` before and after (``7a3cce84d…``). The marker we compute is identical to what the prior code computed because we read ``requires-python`` from the same link the prior code would have fetched a second time. The ``ThreadPoolExecutor`` is removed alongside — there's no I/O left in the loop to parallelise, and the executor + barrier machinery was pure overhead at attribute-read speed. The two unit tests in ``test_resolver_regressions.py`` that pinned the old behaviour (``test_resolve_constraints_reuses_package_finder`` asserted ``find_best_candidate.call_count == 2``; ``test_resolve_constraints_runs_candidate_lookup_in_parallel`` asserted overlap) are replaced with tests that pin the new contract: an explosive ``resolver.finder`` mock that fails the test if it is ever invoked. Regression-proofed against any future slip back into the slow path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ore_compatibility users Commit ``cf53eb17`` eliminated a redundant ``find_best_candidate`` walk in ``resolve_constraints`` (~21 % lock-warm win on the 100-pkg bench). The fix has a subtle interaction with the ``pip_finder_ignore_compatability`` patched-pip flag that's worth pinning explicitly. Behaviour change scope ---------------------- Standard pipenv users (single-platform locks, no monkey-patching): zero behaviour change — the resolved tree only contains packages the strict finder accepted, so ``find_best_candidate(strict)`` and ``result.link.requires_python`` produce identical markers. Users who flip ``finder._ignore_compatibility = True`` somewhere in the resolve pipeline (cross-platform locking workflows, the patched-pip flag, etc.): cross-compat packages whose links advertise ``requires-python`` now get those markers in the lockfile. Pre-2026-05 the strict ``find_best_candidate`` returned ``None`` for those candidates and the marker was silently dropped. This is arguably a correctness fix but IS a behaviour change for consumers that relied on those markers being absent. What this commit adds --------------------- 1. ``resolve_constraints`` docstring now spells out the pre/post comparison and links to the regression test. 2. ``tests/unit/test_resolver_regressions.py`` ``test_resolve_constraints_marker_for_ignore_compatibility_link`` constructs a resolved tree whose link's ``requires-python`` would NOT have been extracted by the old strict-finder code path, and asserts the new code DOES extract it. An explosive ``resolver.finder`` mock catches any regression back to the slow path. No code change to ``resolve_constraints``'s logic — purely documentation + a regression-pinning test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR continues the May 2026 modernization track by (1) reducing startup/lock overhead on the existing pip-driven resolver path and (2) landing major groundwork for a future pure-Python Simple API resolver (PEP 691/503), along with scaffolding for pluggable resolver backends.
Changes:
- Performance-oriented refactors (notably lazy imports) and a Windows virtualenv detection fix.
- Adds a new
pipenv/resolver/pure-Python Simple API client surface (types, candidate model, auth, cache, parallel fetch) plus targeted coverage gating. - Introduces resolver-backend scaffolding (
--resolver,PIPENV_RESOLVER,[pipenv] resolver) and an opt-in manifest prefetch setting (prefetch_index_manifests).
Reviewed changes
Copilot reviewed 105 out of 110 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_utils.py | Update unit tests for Pipfile/Lockfile subsystem API moves + new venv detection tests |
| tests/unit/test_update.py | Update update-routine unit tests to new project.pipfile.* APIs |
| tests/unit/test_unpack.py | New tests for pipenv.utils.unpack relocation + behavior pins |
| tests/unit/test_settings.py | Add tests for prefetch_index_manifests setting + env override; update cache invalidation paths |
| tests/unit/test_resolver_regressions.py | Update regression tests for resolve_constraints marker extraction behavior |
| tests/unit/test_resolver_parent_dispatch.py | Update resolver parent-dispatch stubs to new Pipfile subsystem fields |
| tests/unit/test_resolver_diagnostics.py | Add regression tests preventing resolver-log propagation to root |
| tests/unit/test_resolver_backends.py | New unit tests for resolver-backend registry/dispatch scaffolding |
| tests/unit/test_pylock.py | Update unit tests to new project.lockfile.* subsystem APIs |
| tests/unit/test_project_caching.py | Update Pipfile caching tests to Pipfile subsystem implementation |
| tests/unit/test_prefetch_fan_out.py | New unit tests for per-source verify_ssl prefetch fan-out |
| tests/unit/test_pep691_parity_known_diffs.md | New documentation of known parity normalizations/divergences |
| tests/unit/test_pep691_parity_fixtures.py | New parity tests vs pip’s Link parsing for frozen fixtures |
| tests/unit/test_locking_no_mutation.py | Update test to new Pipfile subsystem write API |
| tests/unit/test_lockfile.py | New unit tests for extracted Lockfile subsystem |
| tests/unit/test_lock_sync_uninstall_context_routing.py | Update context-routing tests for Pipfile/Lockfile subsystem APIs |
| tests/unit/test_do_update_context_routing.py | Update update context-routing tests for lockfile.any_exists |
| tests/unit/test_do_install_context_routing.py | Update install context-routing stub for pipfile.exists |
| tests/unit/test_dependencies.py | Update deps tests for new project.pipfile.project_directory usage |
| tests/unit/test_dependencies_bridges.py | Update “requirementslib removed” assertion to module nonexistence |
| tests/unit/test_core.py | Update core tests to project.pipfile.* and NON_CATEGORY_SECTIONS import move |
| tests/unit/fixtures/README.md | New fixture provenance documentation (PEP 691/503 snapshots) |
| tests/unit/fixtures/pep691/yanked-pkg.json | New synthetic PEP 691 fixture for yanked variants |
| tests/unit/fixtures/pep691/missing-hash.json | New synthetic PEP 691 fixture for empty-hash edge case |
| tests/unit/fixtures/pep503/yanked-pkg.html | New synthetic PEP 503 fixture for yanked variants |
| tests/integration/test_run.py | Update integration test to project.pipfile.build_script |
| tests/integration/test_resolver_protocol.py | Normalize new deadline_seconds + redact diagnostics in protocol canary |
| tests/integration/test_pylock.py | Update integration tests to project.lockfile.* APIs |
| tests/integration/test_pipenv.py | Update integration test to project.pipfile.proper_names |
| tests/integration/test_lockfile.py | Update integration tests to project.lockfile.load |
| tests/integration/test_install_twists.py | Comment update to reflect new pipfile writer location |
| tests/integration/test_install_markers.py | Update integration tests to new lockfile/hash/pipfile hash accessors |
| tests/integration/test_import_requirements.py | Patch target update for moved unpack_url |
| tests/integration/fixtures/resolver_protocol/response.json | Update protocol golden with redacted diagnostics field |
| tests/integration/fixtures/resolver_protocol/request.json | Update protocol golden with redacted deadline_seconds |
| pyproject.toml | Configure coverage to target pipenv/resolver for a dedicated gate |
| pipenv/utils/virtualenv.py | Update to Pipfile subsystem fields (location, project_directory, name, required python) |
| pipenv/utils/venv_locator.py | Update to use project.pipfile.* fields |
| pipenv/utils/unpack.py | Add module docstring for relocated pip-fork unpack helpers |
| pipenv/utils/toml.py | Use project.pipfile.get_package_categories() |
| pipenv/utils/sources.py | Update to project.pipfile.* + project.lockfile.* APIs |
| pipenv/utils/shell.py | Fix Windows Path.glob crash by guarding bindir existence |
| pipenv/utils/settings.py | Add env-var override plumbing + new resolver accessor + migrate to project.pipfile.parsed |
| pipenv/utils/pylock.py | Add TODO for resolver backend metadata in pylock conversion |
| pipenv/utils/project.py | Update required-python warning logic to Pipfile subsystem |
| pipenv/utils/locking.py | Switch to PlettePipfile loader naming after subsystem extraction |
| pipenv/utils/internet.py | Lazy-import pip network stack; adjust session cache_dir defaulting |
| pipenv/utils/fileutils.py | Lazy-import pip network utilities; avoid heavy imports at module load |
| pipenv/utils/environment.py | Use project.pipfile.project_directory for .env lookup |
| pipenv/utils/dependencies.py | Lazy-import pip internals in hot modules; migrate to new Pipfile APIs |
| pipenv/routines/update.py | Update update routine to new Pipfile/Lockfile APIs |
| pipenv/routines/uninstall.py | Update uninstall routine to new Pipfile/Lockfile APIs |
| pipenv/routines/sync.py | Update sync routine to project.lockfile.any_exists |
| pipenv/routines/shell.py | Update shell/run routines to Pipfile subsystem project directory + scripts |
| pipenv/routines/scan.py | Update scan to use Pipfile/Lockfile subsystem fields |
| pipenv/routines/requirements.py | Update requirements generation to new Pipfile/Lockfile APIs |
| pipenv/routines/outdated.py | Update outdated routine to new Pipfile APIs |
| pipenv/routines/install.py | Update install routines to new Pipfile/Lockfile APIs; propagate --resolver + --clear |
| pipenv/routines/context.py | Add ExecutionOptions.resolver plumbing from CLI |
| pipenv/routines/clean.py | Update clean routine to new lockfile hash/package-names accessors |
| pipenv/routines/check.py | Update check routine to use Pipfile/Lockfile subsystem fields |
| pipenv/routines/audit.py | Update audit routine to use project.lockfile.* APIs |
| pipenv/resolver/schema.py | Add additive ResolverOptions.backend + suppress empty backend on wire |
| pipenv/resolver/pep691_types.py | New typed envelopes for Simple API fetch results/errors |
| pipenv/resolver/main.py | Fix resolved-default-deps structure + avoid PackageFinder second-pass marker lookup |
| pipenv/resolver/core.py | Add resolver-log propagation fix + implement backend dispatcher scaffolding |
| pipenv/resolver/candidate.py | New pure-Python Candidate/Hash data model |
| pipenv/resolver/backends/pip.py | New pip backend adapter wrapping existing resolve flow |
| pipenv/resolver/backends/base.py | New Backend protocol + shared registry |
| pipenv/resolver/backends/init.py | New backend registry + lookup helpers |
| pipenv/resolver/auth.py | New pure-Python auth helpers (netrc, URL creds, client cert) |
| pipenv/resolver/init.py | Re-export new Initiative G resolver surface |
| pipenv/help.py | Update diagnostics printing to new Pipfile/Lockfile subsystem locations |
| pipenv/environments.py | Add env vars for prefetch + resolver backend selection |
| pipenv/environment.py | Lazy-import InstallCommand to reduce startup cost |
| pipenv/cli/options.py | Add --resolver flag plumbing into CLI state |
| pipenv/cli/command.py | Thread resolver selection into contexts; update to Pipfile subsystem APIs |
| news/T_F.5.feature.rst | News fragment for resolver backend scaffolding |
| news/initiative-g-phase2-prefetch-bridge.feature.rst | News fragment for opt-in manifest prefetch |
| news/initiative-g-phase1-pep691-client.feature.rst | News fragment for PEP 691/503 client + cache surface |
| docs/pipfile.md | Document [pipenv] prefetch_index_manifests setting + env var |
| docs/dev/modernization-plan.md | Update task status/logs for Initiative D/E/F/G work |
| docs/dev/initiative-f-backends-design.md | Add maintainer sign-off decisions and updated scope |
| .pre-commit-config.yaml | Add guard preventing pip internal imports in pipenv/resolver/ |
| .github/workflows/ci.yaml | Add resolver-module coverage gate job |
Comments suppressed due to low confidence (1)
pipenv/resolver/backends/base.py:76
REGISTRYis annotated asdict[str, Backend], but the registry actually stores backend classes (e.g.REGISTRY["pip"] = PipBackend) as well as instances in tests. This type mismatch will confuse type checkers/IDEs and contradicts the module docstring (“dict from name to backend class”). Update the type to something likedict[str, type[Backend] | Backend](or introduce a dedicatedBackendEntrytype alias) so the registry’s intended contents are accurately represented.
# Single shared registry. The ``backends/__init__.py`` populates this
# on import with the in-tree backends. Keeping it here (rather than on
# ``__init__``) lets test code patch via ``mock.patch.dict`` against a
# single canonical reference no matter which module the patch targets.
REGISTRY: dict[str, Backend] = {}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…-cleanup-phase2-2026-05
…ce/code-cleanup-phase3-resolver-typed-schema-2026-05
…026-05' into maintenance/code-cleanup-phase4-deferred-2026-05
…deferred-2026-05' into maintenance/code-cleanup-phase5-perf-2026-06
Agent-Logs-Url: https://github.com/pypa/pipenv/sessions/6a3b0b17-d3de-4173-9c1a-3dabbdcbe12a Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pypa/pipenv/sessions/6a3b0b17-d3de-4173-9c1a-3dabbdcbe12a Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
…ackend field
CI failed on all three smoke platforms (Ubuntu/MacOS/Windows on 3.12)
with the documented JSON-drift assertion:
Resolver request JSON drift. If this is an intentional schema
change, regenerate the golden via PIPENV_REGEN_PROTOCOL_FIXTURES=1
pytest test_resolver_protocol.py and review the diff before
committing.
Commit 0bf0c19 ("fix(resolver): stamp selected backend onto
resolver requests") added the env / Pipfile / default fallback chain
for ``ResolverOptions.backend`` so the parent now always stamps a
concrete backend name on the request envelope (was empty-string
sentinel before). The fixture needs the matching additive line.
Regenerated under PIPENV_REGEN_PROTOCOL_FIXTURES=1; the diff is a
single ``"backend": "pip"`` line inserted into ``options``. No
other fields drift.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 tasks
…with wall-clock budget ``ParsedManifestCache.put`` writes a temp file then ``os.replace``s it into place. On POSIX the rename is atomic and uncontended. On Windows ``os.replace`` raises ``PermissionError`` (``ERROR_ACCESS_DENIED``) when the destination is held open by another process — including a well-behaved concurrent reader doing ``open(target, "rb")`` / ``read_bytes()``. ``test_reader_never_sees_partial_payload`` reliably hits this on the Windows runner because the writer churns 20 distinct payloads while the reader spins in a tight ``while not stop_event`` loop for ~100 ms — the reader's open/close cycle compounds across thousands of iterations. Wrap the rename in ``_replace_with_windows_retry``: POSIX takes the no-retry happy path on the first call, Windows retries under a 2 s wall-clock budget with exponential backoff (5 ms → 10 ms → 20 ms, capped at 100 ms). The earlier 5-attempt × 10 ms-linear scheme (~100 ms total) was tight enough that a single reader-busy window could exhaust it; 2 s is well within the test's 5 s join timeout and effectively infinite for production manifest writes (rare and mostly uncontended). The reader path is unchanged — ``_load_manifest`` and ``peek_etag`` already catch ``OSError`` (parent of ``PermissionError``) and treat it as a miss. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Python 3.10's netrc parser preserves surrounding quote characters in tokens, so an entry like ``login ""`` is read as the literal two-char string ``""`` rather than the empty string returned by 3.11+. The helper's falsy check (``if not login``) therefore failed to skip the quote-only entry on 3.10, causing test_lookup_netrc_auth_empty_login_ returns_none to fail across Ubuntu/macOS/Windows on that interpreter. Strip outer quotes before the falsy check so both parser behaviors agree, without changing the returned value for legitimate logins. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase V of the 2026-05 modernization track. Two intertwined workstreams:
Performance cuts on the existing pip-driven resolver path — chasing the
warm-relock and CLI-startup ceilings identified by the May 2026 benchmark
suite (
benchmarks/benchmark.py). Final measured win is modest: ~7-11 %across the CI bench (
lock-warm,lock-cold,install-warm,add-package,import) from lazy-import work; the architectural ceilingis pip's per-package sequential index revalidation and
Link.from_jsonloop, which pipenv can't move from outside pip.
Initiative G — pure-Python simple-API resolver groundwork — a new
in-tree implementation (PEP 691 JSON + PEP 503 HTML client, parsed-manifest
cache, parallel fetcher) under
pipenv/resolver/that imports zeropip._internal.*symbols. Phases 1 + 2 of a four-phase plan ship here;Phase 3 (the full
pure-pythonbackend replacing pip'sPackageFinder)and Phase 4 (promote to default) are explicitly future work.
Companion design + plan docs in
docs/dev/initiative-g-pure-python-design.mdand
initiative-g-phase1-2-plan.md. No user-visible API change, no newruntime dependencies, no lockfile-format change. The opt-in Phase-2 setting
defaults
false.What's in this PR
Phase-5 perf cuts (lazy imports + empty category skip)
perf(lock): skip resolver subprocess for empty Pipfile categories(49c7a249) —do_lockalways iterated["default", "develop", …]even when a sectionwas empty, paying ~5-6 s of subprocess + Resolver/Session setup to produce
an empty lockfile section. Skip the call when
packagesis an emptymapping. Stale entries still cleared because
lockfile.pop(category)already ran.
perf(startup): defer pip-internal network imports in fileutils + internet(7335a1c6)and
perf(startup): defer pip-internal InstallCommand + unpack + Downloader imports(74a466b1) —the parent CLI and resolver subprocess were both pulling
pip._internal.network.download,pip._internal.commands.install.InstallCommand,pip._internal.operations.prepare(~78 ms each, cumulative) at moduleload. Moved these to first-use inside the functions that need them.
Tests follow the import-target rename (the prior fix for the same
symptom on integration tests is at
c85cd3b6).perf(resolver): eliminate redundant find_best_candidate walk in resolve_constraints(cf53eb17) —Resolver.resolve_constraintscalled pip'sPackageFinder.find_best_candidate(name, specifier)onceper resolved package solely to read
candidate.link.requires_python. The resolved tree alreadycarries that link (pip's resolvelib stores the chosen candidate on every
InstallRequirementitreturns). Read the marker directly off
result.link. Measured on the 100-package bench:in-process 31.4 s → 23.4 s (−25.5 %); subprocess warm ~22.6 s → ~17.9 s (−21 %, ~4.7 s saved).
Lockfile hash byte-identical for standard pipenv users.
Subtle interaction with
tasks/vendoring/patches/patched/pip_finder_ignore_compatability.patch(commit
3d16ca04documents this explicitly): users who flipfinder._ignore_compatibility = Truein the resolve pipeline (cross-platform locking workflows,the patched-pip flag) will see cross-compat packages now CARRY their advertised
requires-pythonmarkers in the lockfile. Pre-fix, the strict
find_best_candidatereturnedNonefor thosecandidates and the marker was silently dropped. This is arguably a correctness fix but IS a
behavioural change for any consumer that relied on those markers being absent. A regression-
pinning test (
test_resolve_constraints_marker_for_ignore_compatibility_link) and a docstringon
resolve_constraintsdocument the trade-off.perf(lock): feed prior Pipfile.lock pins as pip constraints on warm relock(20092de9)was reverted (
a2157da4) after maintainer review caught a semanticregression: the change silently froze wildcard versions across
pipenv lockruns, breaking the historical contract thatpipenv lockpicks up newer matching versions. The revert is preserved in history
for traceability.
Measured CI delta vs the pre-phase-5 baseline (median across the bench
suite, single CI run — not multi-run statistical):
These are real-but-modest gains. The architectural ceiling is documented
in detail in
docs/dev/initiative-g-pure-python-design.md§2.2.Initiative G phases 1 + 2 — pure-Python resolver groundwork
A new
pipenv/resolver/surface (zeropip._internal.*imports, enforcedby a pre-commit grep gate scoped to
pipenv/resolver/):pipenv/resolver/candidate.py—Candidatedataclass +Hashnamedtuple +
Candidate.from_filenamehelper. Frozen, slotted, puredata; wheel tags derived once at parse time via
pipenv.vendor.packaging.tags.pipenv/resolver/pep691_types.py—SimplePageResponseandFetchErrortyped result envelopes.pipenv/resolver/pep691.py—_parse_pep691_json(PEP 691 JSON),_parse_pep503_html(PEP 503 HTML fallback), andPEP691Clientclass.Threads per-request
verify/certinto the underlying session(FU3, landed in this PR). Deliberately does not send
Cache-Control: max-age=0(deliberate divergence from pip).pipenv/resolver/manifest_cache.py—ParsedManifestCachewithTTL, atomic write, schema versioning, and (per FU1, landed here)
peek_etagfor stale-cacheIf-None-Matchshort-circuits.pipenv/resolver/fetcher.py—ParallelFetcherwith capped 16-workerThreadPoolExecutor. SendsIf-None-Matchfor stale cache entries(FU1); option-a TTL refresh on
304 Not Modified.pipenv/resolver/auth.py— netrc / URL-embedded basic-auth /PIP_CLIENT_CERThelpers. Keyring deferred to Phase 3.Phase-2 integration (opt-in, off by default):
[pipenv] prefetch_index_manifests+ env-var overridePIPENV_PREFETCH_INDEX_MANIFESTS=1. When enabled,do_lockcalls_prefetch_index_manifests_if_enabledto fan outper-source parallel pre-fetches (FU2, per-
verify_sslpolicy)through pip's own
PipSession— so pip'sSafeFileCacheis warmedas a side effect (no on-disk format reverse engineering).
User-facing doc for the new setting in
docs/pipfile.md.Why opt-in / why no measured Phase-2 perf claim
T21 (CI bench measurement for the prefetch path) was explicitly
deferred during execution review — no appetite for a multi-run
statistically-guarded CI bench step at this time. The Phase-2 perf
hypothesis (parallel cold-cache pre-fetch helps slow networks) is sound
based on the phase-5 I/O analysis but is theoretical, not measured
against the current CI baseline. The design doc §11a (
Phase 2a — sign-off note)is explicit about this; the user-doc points readers at the phase-5
branch history rather than quoting a percentage.
Phase-3 follow-ups landed early
Three items originally scoped as Phase-3 work were resolved during the
plan execution after their respective Wave agents flagged them:
91c1e4e9) —ParsedManifestCache.peek_etag()+ fetcherintegration. Closes a dead-code path: T19's
status="not-modified"TTL-refresh branch was unreachable before this commit because the
fetcher never sent
If-None-Match. Now stale-but-present cache entriesshort-circuit to a 304 instead of re-downloading.
4a0ff8a1) — per-sourceverify_sslfan-out in_prefetch_index_manifests_if_enabled. Original T19 cut routed everytarget through the majority-verify session; mixed-policy projects
(self-signed private index alongside public PyPI) silently fell
through to pip's cold fetch. Now one
ParallelFetcherper uniqueverify_sslvalue. Single-policy projects (common case) unchanged.0047a2e3) —PEP691Client.fetchthreadsverify/certinto the per-request
session.request(...)call. Pre-fix thesekwargs were stored on
selfbut never reached the request — soFU2's per-source verify routing was effectively a no-op at the
request layer until FU3 landed.
Bug fixes caught during execution
c85cd3b6— fixes 4 integration tests intest_import_requirements.pythat monkey-patched
pipenv.utils.dependencies.unpack_url(no longera module attribute after the phase-5 lazy-import work). Patch target
moved to the canonical source (
pipenv.utils.unpack.unpack_url).c76ecb42— T20's integration test surfaced a cache-pathmismatch: T17's
_clear_parsed_manifest_cachewiped<PIPENV_CACHE_DIR>/manifests-v1/while T19's prefetch wrote to<PIPENV_CACHE_DIR>/pipenv-manifests/manifests-v1/. Aligned both onthe namespaced path.
Test plan
the design-doc commit
a6832ce3; +307 from Initiative G alone).99.68 % across the six new modules (603 statements, 2 missed
— the Windows-only
_netrcbranch inauth.py, reaches 100 % onWindows CI).
Link.from_json/Link.from_elementacross the T2 fixturesuite (six, django, cryptography, tablib, yanked-pkg, missing-hash
× JSON + HTML, including a 3496-file
cryptographysnapshot).Zero semantic divergences; representation diffs documented in
tests/unit/test_pep691_parity_known_diffs.md.(self-signed-cert fixture not available — gap documented in
docstring for a future Phase-3 follow-up).
maintained at 100 % on
candidate.py,fetcher.py,manifest_cache.py,pep691.py,pep691_types.py.Test fixtures added
tests/unit/fixtures/pep691/*.jsonandtests/unit/fixtures/pep503/*.htmlcontain real-PyPI snapshots captured 2026-05-12 plus two hand-crafted
synthetics (
yanked-pkg,missing-hash) exercising edge cases.Provenance and re-baseline procedure documented in
tests/unit/fixtures/README.md.Companion docs
docs/dev/initiative-g-pure-python-design.md— Initiative G design(motivation, architecture, four-phase plan, dependency strategy, open
questions for sign-off). Status updated to reflect Phase 1 + 2 shipped.
initiative-g-phase1-2-plan.md— the 22-task dependency-aware swarmplan that executed Phases 1 + 2 (21 tasks done, T21 deferred, 3
Phase-3 follow-ups landed early). Each task's
status:/log:/files edited/created:fields populated post-execution.docs/pipfile.md— user-facing entry for[pipenv] prefetch_index_manifestsalongside the existing
cool-down-periodblock.News fragments:
news/initiative-g-phase1-pep691-client.feature.rst— Phase 1 ship.news/initiative-g-phase2-prefetch-bridge.feature.rst— Phase 2 ship.Migration notes
None. Per-commit details:
pipenv/resolver/. Noexisting imports break; the modules are reachable via
from pipenv.resolver import PEP691Client, ParsedManifestCache, ParallelFetcher, Candidate, Hash, FetchError, SimplePageResponse, CachedManifestif any caller wants them. Phase 1 ships themstandalone; Phase 2's
do_lockhook is gated on the opt-in setting.fileutils.py,internet.py,dependencies.py,project.py,environment.py,utils/resolver.py) keep allpublic attribute names addressable. Tests that monkey-patched
module-level symbols that became function-scope imports were updated
to point at the canonical source — the
unpack_urlfix inc85cd3b6covers the four cases CI flagged. No other tests in therepo follow the same anti-pattern (verified by sweep).
What's NOT in this PR
deferred per maintainer scope call. The Phase-2 perf claim is
theoretical, not measured. Design-doc §11a documents this.
pure_pythonbackend that replaces pip'sPackageFinder.find_all_candidatesvia aresolvelib.Providerimplementation. Three follow-up items (FU1, FU2, FU3) that were
scoped as Phase-3 work landed here, but the main Phase-3 deliverable
(the backend itself) is a separate future PR.
scenario; needs work in
tests/pytest-pypi/that's out of scope here.unchanged. Adding a
PIPENV_PREFETCH_INDEX_MANIFESTS=1matrix entrywould require T21-style statistical guards; deferred.
🤖 Generated with Claude Code