Skip to content

Maintenance/code cleanup 2026-05 Phase V (performance and pure python resolver prework)#6668

Open
matteius wants to merge 85 commits into
maintenance/code-cleanup-phase4-resolver-followups-2026-05from
maintenance/code-cleanup-phase5-perf-2026-06
Open

Maintenance/code cleanup 2026-05 Phase V (performance and pure python resolver prework)#6668
matteius wants to merge 85 commits into
maintenance/code-cleanup-phase4-resolver-followups-2026-05from
maintenance/code-cleanup-phase5-perf-2026-06

Conversation

@matteius

@matteius matteius commented May 12, 2026

Copy link
Copy Markdown
Member

Summary

Phase V of the 2026-05 modernization track. Two intertwined workstreams:

  1. 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 ceiling
    is pip's per-package sequential index revalidation and Link.from_json
    loop, which pipenv can't move from outside pip.

  2. 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 zero
    pip._internal.* symbols. Phases 1 + 2 of a four-phase plan ship here;
    Phase 3 (the full pure-python backend replacing pip's PackageFinder)
    and Phase 4 (promote to default) are explicitly future work.

Companion design + plan docs in docs/dev/initiative-g-pure-python-design.md
and initiative-g-phase1-2-plan.md. No user-visible API change, no new
runtime 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_lock always iterated ["default", "develop", …] even when a section
    was empty, paying ~5-6 s of subprocess + Resolver/Session setup to produce
    an empty lockfile section. Skip the call when packages is an empty
    mapping. 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 module
    load. 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_constraints called pip's PackageFinder.find_best_candidate(name, specifier) once
    per resolved package solely to read candidate.link.requires_python. The resolved tree already
    carries that link (pip's resolvelib stores the chosen candidate on every InstallRequirement it
    returns). 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 3d16ca04 documents this explicitly): users who flip
    finder._ignore_compatibility = True in the resolve pipeline (cross-platform locking workflows,
    the patched-pip flag) will see cross-compat packages now CARRY their advertised requires-python
    markers in the lockfile. Pre-fix, 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
    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 docstring
    on resolve_constraints document 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 semantic
    regression: the change silently froze wildcard versions across
    pipenv lock runs, breaking the historical contract that pipenv lock
    picks 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):

stat before after delta
lock-warm 21.295 s 19.250 s −9.6 %
lock-cold 25.389 s 22.695 s −10.6 %
install-warm 19.951 s 18.648 s −6.5 %
install-cold 43.191 s 41.796 s −3.2 %
add-package 30.740 s 27.405 s −10.8 %
import (full) 73.263 s 69.889 s −4.6 %

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 (zero pip._internal.* imports, enforced
by a pre-commit grep gate scoped to pipenv/resolver/):

  • pipenv/resolver/candidate.pyCandidate dataclass + Hash
    namedtuple + Candidate.from_filename helper. Frozen, slotted, pure
    data; wheel tags derived once at parse time via pipenv.vendor.packaging.tags.
  • pipenv/resolver/pep691_types.pySimplePageResponse and
    FetchError typed result envelopes.
  • pipenv/resolver/pep691.py_parse_pep691_json (PEP 691 JSON),
    _parse_pep503_html (PEP 503 HTML fallback), and PEP691Client class.
    Threads per-request verify / cert into 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.pyParsedManifestCache with
    TTL, atomic write, schema versioning, and (per FU1, landed here)
    peek_etag for stale-cache If-None-Match short-circuits.
  • pipenv/resolver/fetcher.pyParallelFetcher with capped 16-worker
    ThreadPoolExecutor. Sends If-None-Match for stale cache entries
    (FU1); option-a TTL refresh on 304 Not Modified.
  • pipenv/resolver/auth.py — netrc / URL-embedded basic-auth /
    PIP_CLIENT_CERT helpers. Keyring deferred to Phase 3.

Phase-2 integration (opt-in, off by default):

  • New setting [pipenv] prefetch_index_manifests + env-var override
    PIPENV_PREFETCH_INDEX_MANIFESTS=1. When enabled,
    do_lock calls _prefetch_index_manifests_if_enabled to fan out
    per-source parallel pre-fetches (FU2, per-verify_ssl policy)
    through pip's own PipSession — so pip's SafeFileCache is warmed
    as 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:

  • FU1 (91c1e4e9) — ParsedManifestCache.peek_etag() + fetcher
    integration. 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 entries
    short-circuit to a 304 instead of re-downloading.
  • FU2 (4a0ff8a1) — per-source verify_ssl fan-out in
    _prefetch_index_manifests_if_enabled. Original T19 cut routed every
    target 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 ParallelFetcher per unique
    verify_ssl value. Single-policy projects (common case) unchanged.
  • FU3 (0047a2e3) — PEP691Client.fetch threads verify / cert
    into the per-request session.request(...) call. Pre-fix these
    kwargs were stored on self but never reached the request — so
    FU2'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 in test_import_requirements.py
    that monkey-patched pipenv.utils.dependencies.unpack_url (no longer
    a 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-path
    mismatch: T17's _clear_parsed_manifest_cache wiped
    <PIPENV_CACHE_DIR>/manifests-v1/ while T19's prefetch wrote to
    <PIPENV_CACHE_DIR>/pipenv-manifests/manifests-v1/. Aligned both on
    the namespaced path.

Test plan

  • Full unit suite green locally: 1263 tests passing (+341 since
    the design-doc commit a6832ce3; +307 from Initiative G alone).
  • Targeted resolver-module coverage (T17's CI gate, narrow scope):
    99.68 % across the six new modules (603 statements, 2 missed
    — the Windows-only _netrc branch in auth.py, reaches 100 % on
    Windows CI).
  • Phase-1 acceptance (T10): byte-equivalent parity vs pip's
    Link.from_json / Link.from_element across the T2 fixture
    suite (six, django, cryptography, tablib, yanked-pkg, missing-hash
    × JSON + HTML, including a 3496-file cryptography snapshot).
    Zero semantic divergences; representation diffs documented in
    tests/unit/test_pep691_parity_known_diffs.md.
  • Phase-2 integration (T20): 5 scenarios passing, 1 skipped
    (self-signed-cert fixture not available — gap documented in
    docstring for a future Phase-3 follow-up).
  • FU1+FU2+FU3 each carry their own dedicated tests; coverage
    maintained at 100 % on candidate.py, fetcher.py,
    manifest_cache.py, pep691.py, pep691_types.py.
  • CI: Lint, build, unit, resolver-module-coverage gate, benchmark.

Test fixtures added

tests/unit/fixtures/pep691/*.json and tests/unit/fixtures/pep503/*.html
contain 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 swarm
    plan 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_manifests
    alongside the existing cool-down-period block.

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:

  • All Initiative G modules are new code under pipenv/resolver/. No
    existing imports break; the modules are reachable via
    from pipenv.resolver import PEP691Client, ParsedManifestCache, ParallelFetcher, Candidate, Hash, FetchError, SimplePageResponse, CachedManifest if any caller wants them. Phase 1 ships them
    standalone; Phase 2's do_lock hook is gated on the opt-in setting.
  • Lazy-import changes (fileutils.py, internet.py, dependencies.py,
    project.py, environment.py, utils/resolver.py) keep all
    public 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_url fix in
    c85cd3b6 covers the four cases CI flagged. No other tests in the
    repo follow the same anti-pattern (verified by sweep).

What's NOT in this PR

  • T21 (CI bench measurement for the prefetch path) — explicitly
    deferred per maintainer scope call. The Phase-2 perf claim is
    theoretical, not measured. Design-doc §11a documents this.
  • Phase 3 — the full pure_python backend that replaces pip's
    PackageFinder.find_all_candidates via a resolvelib.Provider
    implementation. 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.
  • Self-signed-cert integration test fixture — T20's skipped
    scenario; needs work in tests/pytest-pypi/ that's out of scope here.
  • CI bench workflow changes — the existing benchmark job is
    unchanged. Adding a PIPENV_PREFETCH_INDEX_MANIFESTS=1 matrix entry
    would require T21-style statistical guards; deferred.

🤖 Generated with Claude Code

matteius and others added 30 commits May 12, 2026 11:09
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>
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>
matteius and others added 13 commits May 12, 2026 18:20
…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>
@matteius matteius marked this pull request as ready for review May 12, 2026 23:11
@matteius matteius requested a review from Copilot May 13, 2026 03:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • REGISTRY is annotated as dict[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 like dict[str, type[Backend] | Backend] (or introduce a dedicated BackendEntry type 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.

Comment thread pipenv/resolver/core.py
matteius added 5 commits May 12, 2026 23:27
…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
Copilot AI and others added 2 commits May 13, 2026 03:55
…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>
matteius and others added 2 commits May 13, 2026 12:43
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants