Back to Pipenv

Plan: T_F.3 — Execute the Typed Resolver Subprocess Schema

docs/dev/initiative-f-execution-plan.md

2026.8.049.0 KB
Original Source

Plan: T_F.3 — Execute the Typed Resolver Subprocess Schema

Generated: 2026-05-12 Source design: initiative-f-typed-design.md Companion reference: initiative-f-protocol.md (current ad-hoc protocol catalogue) Target branch: maintenance/code-cleanup-phase2-2026-05 (same branch as T_D.4/T_E.3; single atomic PR for the whole T_F.3 scope per maintainer answer 2026-05-12)


Overview

Execute the typed ResolverRequest / ResolverResponse migration designed in T_F.2. One atomic rewrite of the pipenv ↔ pipenv-resolver subprocess wire format:

  • Introduce pipenv/resolver/schema.py with the stdlib @dataclass envelope + discriminated ResolverResult
  • Convert pipenv/resolver.py (single file) into pipenv/resolver/ (package): __init__.py + main.py
  • Subprocess entry consumes --request-file <path> only (drops all other argv + three env-var hops)
  • Single canonical LockedRequirement.from_install_requirement(...) formatter replaces both Entry.get_cleaned_dict and format_requirement_for_lockfile
  • prepare_lockfile consumes typed LockedRequirement via to_lockfile_dict() adapter
  • New unit-suite for the schema dataclasses; new integration test pinning the JSON wire shape; comma-in-marker regression fixture

No backwards-compat shim. No external-API guarantees (CLI is the contract). Subprocess and parent ship together; protocol-version negotiation is unnecessary.

Prerequisites

This plan assumes the §8 Q1–Q10 recommendations in the design doc are accepted (maintainer stated "we agree with the ideas/discussions" on 2026-05-12). Specifically:

  • Q1: Schema home = pipenv/resolver/schema.py (package layout). Reserves pipenv/resolver/backends/ for future pluggability (§6a).
  • Q2: Schema-version mismatch = structured InternalError written to response file AND non-zero exit.
  • Q3: LockedRequirement.to_lockfile_dict() returns plain dict (no Plette in schema module).
  • Q4: Ship a one-line news/T_F.3.behavior.rst fragment ("Resolver subprocess now produces structured error messages on dependency conflicts").
  • Q5: no_binary is a first-class field on LockedRequirement.
  • Q6: T_F.3 does NOT fold the in-process branch (deferred to T_F.4).
  • Q7: Add the comma-in-marker fixture and regression test (PEP 508 markers with internal commas).
  • Q8: Schema versioning policy = non-breaking additive default; only field rename / semantics change bumps SCHEMA_VERSION.
  • Q9: Diagnostics.resolver_log is reserved-but-empty in T_F.3 — stderr stays the user-facing channel.
  • Q10: Two tempfiles (--request-file + --response-file), no consolidation.

If any recommendation is overridden the plan needs revision — affected tasks are flagged below.

Dependency Graph

Wave A (parallel, foundation):
  A1 ──────────────┐
  A2 ──────────────┤
                   │
Wave B (parallel, depend on Wave A):
                   ├─→ B1 (subprocess rewrite) ──┐
                   ├─→ B2 (parent rewrite)      ─┤
                   └─→ B3 (lockfile writer)     ─┤
                                                 │
Wave C (parallel, depend on Wave B):             │
                                                 ├─→ C1 (schema unit tests)
                                                 ├─→ C2 (JSON wire-shape integ test)
                                                 ├─→ C3 (comma-in-marker fixture)
                                                 └─→ C4 (news fragment)
                                                                          │
Wave D (depends on Wave C):                                               │
                                                                          └─→ D1 (mark T_F.3 complete in plan)

Tasks

A1: Schema dataclasses + canonical formatter + golden-output snapshots

  • depends_on: []

  • location:

    • NEW pipenv/resolver/schema.py — all dataclasses from design §3 (ResolverRequest, ResolverResponse, ResolverSuccess, ResolutionError, InternalError, LockedRequirement, VCSPin, PackageSpecs, Source, ResolverOptions, ResolvedDeps, RequestMetadata, ConflictRecord, Diagnostics) + SCHEMA_VERSION = 1 module-level constant
    • The single canonical formatter LockedRequirement.from_install_requirement(req, *, sources_lookup, markers_lookup, pipfile_entry, hashes) -> LockedRequirement (design §3.3 + §6). Absorbs the richer behaviour from format_requirement_for_lockfile (file/path Pipfile-override, direct-URL handling, no_binary propagation, merge_markers, index lookup) and the _clean_version / _clean_markers logic from Entry.
    • NEW tests/unit/fixtures/resolver_schema/ directory containing committed golden JSON snapshots produced by running today's Entry.get_cleaned_dict AND today's format_requirement_for_lockfile on a parameterised set of InstallRequirement inputs (PyPI / VCS git+hg+svn+bzr / file:// / path / editable / extras / markers / no_binary). The snapshots are the regression depth that tests/unit/test_utils.py:1323-1538's 17 cases currently provide; after B3 deletes format_requirement_for_lockfile, these snapshots become C1's parity gate. Generate them while both old formatters still exist.
  • description: Plain stdlib @dataclass(frozen=True). Manual to_json_dict() / from_json_dict() classmethods (no dataclasses.asdict — it doesn't handle the discriminated union pattern). LockedRequirement.__post_init__ enforces the mutual-exclusion invariants (no version-and-vcs both; at least one of {version, vcs, file, path}). to_json_dict MUST be deterministic — sorted dict keys, sorted hashes, no None-valued keys on the wire (so the JSON matches today's pruned-dict shape).

    from_install_requirement is the single hardest piece. It reads from a pip InstallRequirement plus the maps the caller maintains. Pull behaviour from these existing locations (cite line numbers in the docstring so future readers can diff):

    • pipenv/resolver.py:213-245_clean_version / _clean_markers from Entry
    • pipenv/resolver.py:288-320Entry.get_cleaned_dict shape
    • pipenv/utils/locking.py:46-160format_requirement_for_lockfile (the richer one)
    • pipenv/utils/locking.py:121-131merge_markers handling
    • pipenv/utils/locking.py:142-154 — file/path Pipfile-override semantics
    • pipenv/utils/locking.py:156-157no_binary propagation
  • validation:

    • python -c "from pipenv.resolver.schema import ResolverRequest, ResolverResponse, LockedRequirement, SCHEMA_VERSION; assert SCHEMA_VERSION == 1" passes
    • python -c "from pipenv.resolver.schema import LockedRequirement; LockedRequirement(name='foo')" raises ValueError (post-init invariant fires)
    • Schema module does not import pip-internal types in module-level namespace — only inside from_install_requirement body. Verify with grep -n "from pipenv.patched" pipenv/resolver/schema.py returning zero hits OR only inside function bodies.
  • status: Completed

  • log:

    • 2026-05-12 — 85993ca4 feat(resolver): introduce typed schema module + canonical LockedRequirement formatter + golden fixtures. RED→GREEN cycle: 17 unit tests in TestLockedRequirementInvariants + TestEnvelopeRoundtrip failed with ModuleNotFoundError before schema.py landed, all 17 pass after. Full unit suite 694 passed / 9 skipped (was 677 / 9 prior). Acceptance grep gates clean (zero pipenv.patched top-level imports; zero typing.Self / tomllib). 27 golden JSON snapshots committed under tests/unit/fixtures/resolver_schema/.
    • Boundary-crossing note: A1's deliverable location pipenv/resolver/schema.py requires pipenv/resolver/ to exist as a package, which Python's import system makes mutually exclusive with the historical pipenv/resolver.py file (the package directory shadows the .py module). A1 therefore moved pipenv/resolver.pypipenv/resolver/main.py and added a re-exporting __init__.py for Entry, PackageRequirement, PackageSource, _main, main, process_resolver_results, resolve_packages, which. The three test modules at tests/unit/test_dependencies.py, tests/unit/test_resolver_regressions.py, tests/unit/test_locking_no_mutation.py continue to import these names through the shim. pyproject.toml's console-script pipenv.resolver:main still resolves correctly. A2's remaining scope is therefore reduced — see A2's log when it lands.
  • files edited/created:

    • CREATED pipenv/resolver/__init__.py — package + re-export shim
    • CREATED pipenv/resolver/schema.py — typed dataclass envelope (14 dataclasses + SCHEMA_VERSION)
    • MOVED pipenv/resolver.pypipenv/resolver/main.py (necessity, see boundary-crossing note above)
    • CREATED tests/unit/test_resolver_schema.py — 17 tests covering invariants + envelope round-trip
    • CREATED tests/unit/fixtures/resolver_schema/format_requirement_for_lockfile/*.json — 16 golden snapshots
    • CREATED tests/unit/fixtures/resolver_schema/entry_get_cleaned_dict/*.json — 11 golden snapshots

A2: Convert pipenv/resolver.pypipenv/resolver/ package (pure restructure, NO behavior change)

  • depends_on: [A1] (A1 must commit its golden-fixture snapshots BEFORE A2 moves the file, because the snapshot generator runs against today's symbols at today's import path)

  • location:

    • MOVE pipenv/resolver.pypipenv/resolver/main.py (full content, no logic change)
    • NEW pipenv/resolver/__init__.py — re-exports current public names (main, _main, Entry, PackageRequirement, resolve_packages, process_resolver_results) so existing imports keep working through Wave B. These re-exports come out in B1 when the symbols are deleted.
    • EDIT pyproject.toml line 63: scripts.pipenv-resolver = "pipenv.resolver:main""pipenv.resolver.main:main"
    • EDIT tests/unit/test_dependencies.py:9, tests/unit/test_resolver_regressions.py:358, tests/unit/test_locking_no_mutation.py:93 to rely on the new package layout via the temporary re-exports (path stays pipenv.resolver.X — the re-export keeps the import working until B1 takes the symbol away)
  • description: Pure code-move + console-script entry update. Zero behavior change. Lands as one commit so the diff is reviewable as "file moved, console-script entry updated, three test imports unchanged via re-export, nothing else."

    Dev-environment note: changing pyproject.toml's console-script entry requires pip install -e . --force-reinstall (or equivalent re-link) in any pipenv-development checkout so pipenv-resolver on $PATH resolves to the new entry point. Without this the subprocess invocation in B2's smoke test will still hit the old file path (which won't exist) and fail mysteriously. Call this out in the commit message AND in the PR description so reviewers don't trip on it.

    After this task the test suite must pass without changing any other code under pipenv/. If a test fails after A2, the move broke something — diagnose and fix (likely a circular import via __init__.py re-exports).

  • validation:

    • python -m pytest tests/unit/ -q green
    • python -c "from pipenv.resolver import main; assert callable(main)" passes
    • pipenv-resolver --help (or equivalent invocation) still works after pip install -e . --force-reinstall
    • git diff shows the move + the pyproject.toml line + the three test imports (only) and nothing else
  • status: Completed (absorbed into A1 — commit 85993ca4)

  • log: Python's import system makes pipenv/resolver.py (file) and pipenv/resolver/ (package directory) mutually exclusive, so the A1 agent had to do the file-move as part of the same commit that introduced pipenv/resolver/schema.py. The re-export shim at pipenv/resolver/__init__.py keeps every existing import path working (from pipenv.resolver import Entry, process_resolver_results, resolve_packages, main, etc.). pyproject.toml's scripts.pipenv-resolver = "pipenv.resolver:main" still resolves correctly via the shim, so no pyproject.toml change is needed yet — that update can land in B1 alongside the dead-symbol prune (when Entry/PackageRequirement/process_resolver_results come out of the shim, the entry can be pinned to pipenv.resolver.main:main for clarity).

  • files edited/created:

    • pipenv/resolver/main.py (moved from pipenv/resolver.py, no logic change)
    • pipenv/resolver/__init__.py (new, re-export shim)
    • (pyproject.toml change deferred to B1; tests still pass via the shim)

B1: Subprocess entry rewrite — read --request-file, write --response-file; delete dead symbols + dead test imports

  • depends_on: [A1, A2]

  • location:

    • pipenv/resolver/main.py (the renamed entry point)
    • pipenv/resolver/__init__.py (prune the temporary A2 re-exports of deleted symbols)
    • tests/unit/test_dependencies.py (line 9 imports Entry — delete the test, or rewrite to use LockedRequirement)
    • tests/unit/test_resolver_regressions.py (line 358 imports process_resolver_results — same, delete or rewrite against the new typed pipeline)
    • tests/unit/test_locking_no_mutation.py (line 93 mocks pipenv.resolver.resolve_packages — verify the signature change does not break the mock; update if it does)
  • description: Rewrite main() / _main() / resolve_packages() / process_resolver_results() so the subprocess:

    1. Accepts ONLY --request-file <path> and --response-file <path> (drop --pre, --clear, --system, --verbose, --category, --constraints-file, --resolved-default-deps-file, --parse-only, --pipenv-site, positional packages, the which() stub at lines 90-91).
    2. Reads + validates the JSON ResolverRequest; on schema_version != SCHEMA_VERSION, writes a ResolverResponse(result=InternalError(message="schema version mismatch: parent sent N, child expects M")) and exits non-zero (per Q2).
    3. Uses request.python_marker_override directly (drops the PIPENV_RESOLVER_PYTHON_VERSION env-var hop).
    4. Uses request.sources directly (drops the in-child Pipfile re-read at resolver.py:448-453 and the duplicate mirror substitution at lines 436-453).
    5. Uses request.extra_pip_args directly (drops the PIPENV_EXTRA_PIP_ARGS env-var hop).
    6. Builds LockedRequirement instances via LockedRequirement.from_install_requirement(...) (no more Entry.get_cleaned_dict).
    7. Wraps the result in ResolverResponse(schema_version=SCHEMA_VERSION, result=ResolverSuccess(...)) on success, or ResolverResponse(..., result=ResolutionError(...)) on dependency conflict. Both paths exit 0 — non-zero exit is reserved for genuine crashes.
    8. On uncaught exception: best-effort write a ResolverResponse(..., result=InternalError(message=str(e), traceback=...)) to --response-file, then exit non-zero.
    9. Stderr free-text continues to flow (Q9 — _is_download_status_line pattern preserved on the parent side).

    Drop the re-exports added in A2 from pipenv/resolver/__init__.py IF the test files have been migrated to import from pipenv.resolver.main; otherwise keep them only for the symbols still imported by tests.

    Entry and PackageRequirement classes can be deleted from main.py after this rewrite — LockedRequirement and the from_install_requirement path replace them.

  • validation:

    • python -m pytest tests/unit/ -q green
    • The subprocess invoked with --request-file /tmp/x.json --response-file /tmp/y.json against a hand-built fixture request produces a valid ResolverResponse JSON
    • Schema-version mismatch path (request file with schema_version: 999) produces structured InternalError response AND non-zero exit
    • grep -nE -- "--parse-only|--pipenv-site|--constraints-file|--resolved-default-deps-file|--category" pipenv/resolver/main.py returns zero hits
    • grep -nE "PIPENV_RESOLVER_PYTHON_VERSION|PIPENV_EXTRA_PIP_ARGS|PIPENV_SITE_DIR" pipenv/resolver/main.py returns zero hits
  • status: Completed

  • log:

    • 2026-05-12 — d1563a1e refactor(resolver): subprocess entry consumes typed ResolverRequest, produces typed ResolverResponse. TDD cycle: 3 subprocess-level tests in tests/unit/test_resolver_protocol_smoke.py (stubbed happy-path, schema-version mismatch, live-resolve against PyPI) failed RED before the rewrite, all GREEN after. Full unit suite: 713 passed / 9 skipped (was 694 / 9 prior to wave B). All three acceptance grep gates clean. Live-subprocess smoke: tempfile-based hand-built ResolverRequest for pytz==2024.1 returned a valid ResolverResponse with result.kind == "success" and the expected LockedRequirement (sha256 hashes + version).
    • Race-with-sibling note: pre-commit's Stashing/Restoring unstaged files mechanism interacted with B2's concurrent staging — B2's pipenv/utils/resolver.py rewrite and B2's tests/unit/test_resolver_parent_dispatch.py were swept into this B1 commit because B2 had staged them in the same window pre-commit was stashing. Net effect: B1+B2 landed in one commit instead of two. Functionally consistent (full unit suite green); the orchestrator should be aware that B2's commit hash collapsed into this one rather than landing separately.
    • Stub-injection note for the smoke test: the unit smoke test invokes the subprocess via python -m pipenv.resolver.main (not via script-path as production does) so that a sitecustomize-stubbed pipenv.resolver.main.resolve_packages takes effect. The script-path form loads the file under the __main__ module identity, separate from pipenv.resolver.main in sys.modules, which would defeat the stub. The production code path (script-path invocation) is exercised end-to-end by C2's integration test. A small helper at the call-site of resolve_packages from _main looks up the function via sys.modules["pipenv.resolver.main"].resolve_packages so future test injections can patch a single well-known location regardless of which entry path Python took.
  • files edited/created:

    • REWRITTEN pipenv/resolver/main.py — typed-envelope subprocess entry; deleted Entry / PackageRequirement / PackageSource dataclasses, process_resolver_results function, module-level which() stub, and all legacy argparse flags
    • EDITED pipenv/resolver/__init__.py — pruned re-exports of deleted symbols; only _main, main, resolve_packages, which remain
    • EDITED pyproject.tomlscripts.pipenv-resolver = "pipenv.resolver.main:main" (was pipenv.resolver:main); developers must run pip install -e . --force-reinstall to re-link
    • EDITED tests/unit/test_dependencies.py — deleted the three test_entry_get_cleaned_dict_* tests + the _make_entry helper + the Entry import (equivalent coverage lives in test_resolver_schema.py and the A1 golden snapshots)
    • EDITED tests/unit/test_resolver_regressions.py — deleted test_process_resolver_results_does_not_scan_reverse_dependencies (function gone; regression structurally impossible)
    • EDITED tests/unit/test_locking_no_mutation.py — updated _fake_resolve_packages to return (locked, resolver) with typed LockedRequirement instances
    • CREATED tests/unit/test_resolver_protocol_smoke.py — 3 subprocess-level tests gating the wire protocol

B2: Parent-side rewrite — pipenv/utils/resolver.py :: venv_resolve_deps + resolve + in-process branch

  • depends_on: [A1, A2]

  • location:

    • pipenv/utils/resolver.py (lines ~1180 venv_resolve_deps, ~1282 resolve, ~1431 PIPENV_RESOLVER_PARENT_PYTHON in-process branch, and actually_resolve_deps callers at lines ~1581 / ~1610 — all touched in a single commit so the file-level diff is internally consistent)
  • description: Build a ResolverRequest instead of an argv list + multiple tempfiles. Serialize to ONE --request-file tempfile (Q10 — two tempfiles, request stays readable post-mortem). Invoke the subprocess with only --request-file <p> --response-file <q>. Parse the response JSON, dispatch on response.result.kind:

    • success → unwrap LockedRequirement instances; existing downstream code at pipenv/utils/locking.py :: prepare_lockfile consumes them (see B3).
    • resolution_error → raise ResolutionFailure with pip_message as the user-facing text + conflicts as structured detail. Existing ResolutionFailure-aware code path stays.
    • internal_error → raise the same crash-path exception as today's non-zero-exit handling.
    • Non-zero exit without a response file → unchanged stderr-fallback path (true subprocess crash; the structured response was never written).

    Drop the env-var setup hops for PIPENV_RESOLVER_PYTHON_VERSION, PIPENV_EXTRA_PIP_ARGS, PIPENV_SITE_DIR from the parent. The pip-config family (PIP_*), NETRC, PYTHONIOENCODING, PYTHONUNBUFFERED, PIPENV_PYPI_MIRROR continue to be inherited via os.environ.copy() because pip-internal code reads them directly.

    _is_download_status_line filter at pipenv/utils/resolver.py:1159-1177 stays — stderr is still the user-facing log channel.

    In-process branch migration: the PIPENV_RESOLVER_PARENT_PYTHON=1 debug bypass at pipenv/utils/resolver.py:1431 calls actually_resolve_deps directly in the parent interpreter. Per Q6 the fold between the two branches is deferred to T_F.4, but the type migration must happen here: after B1 changes resolve_packages to return typed LockedRequirement instances, the in-process call site must consume them the same way the subprocess parse-step does. B2 owns this migration entirely (not B1) so there is no file collision on pipenv/utils/resolver.py between the two tasks.

  • validation:

    • python -m pytest tests/unit/ -q green
    • python -m pytest tests/integration/ -q -k "lock or install" smoke-passes a representative subset (full integration suite is the wave-D gate)
    • Manual: pipenv lock on a tiny test Pipfile produces a valid lockfile (no crash, no malformed JSON)
    • Manual with PIPENV_RESOLVER_PARENT_PYTHON=1: same pipenv lock produces an identical lockfile via the in-process branch
    • grep -nE "PIPENV_RESOLVER_PYTHON_VERSION|PIPENV_EXTRA_PIP_ARGS|PIPENV_SITE_DIR" pipenv/utils/resolver.py returns zero hits
  • status: Completed

  • log:

    • 2026-05-12 — B2's diff landed as part of commit d1563a1e (the wave-B atomic commit that also carries B1's subprocess rewrite — the harness rolled the two staged trees together when the pre-commit ruff hook on pipenv/resolver/main.py interrupted the planned dual-commit sequence). The B2-owned diff is internally consistent: 517-line delta to pipenv/utils/resolver.py (typed-request builder, response-file dispatch, in-process branch migrated to B1's resolve_packages(request) signature, Resolver.clean_results routed through LockedRequirement.from_install_requirement(...).to_lockfile_dict(), Resolver.prepare_pip_args consumes extra_pip_args from the instance instead of the deleted env-var hop) plus the 427-line NEW tests/unit/test_resolver_parent_dispatch.py (8 parametrized tests covering request-envelope building, argv-shape contract, and response-dispatch on each result.kind).
    • Acceptance grep clean: grep -nE "PIPENV_RESOLVER_PYTHON_VERSION|PIPENV_EXTRA_PIP_ARGS|PIPENV_SITE_DIR" pipenv/utils/resolver.py returns zero hits. Subprocess argv carries only --request-file / --response-file (no --pre, --clear, --system, --verbose, --category, --constraints-file, --resolved-default-deps-file, --parse-only, --pipenv-site, --write).
    • Manual smoke: pipenv lock on a tiny six Pipfile produces a byte-identical lockfile via both the default subprocess path and PIPENV_RESOLVER_PARENT_PYTHON=1's in-process branch (lockfile sha = ae1993be... in both runs).
    • Full unit suite: 713 passed / 9 skipped (platform).
  • files edited/created:

    • EDITED pipenv/utils/resolver.py (parent-side typed-request build + response dispatch; in-process branch migrated to B1's signature; Resolver instance carries extra_pip_args directly; Resolver.clean_results produces flat lockfile-dict via LockedRequirement.to_lockfile_dict())
    • CREATED tests/unit/test_resolver_parent_dispatch.py (8 tests for request build, argv shape, response dispatch on each result.kind)

B3: Lockfile writer consumes LockedRequirement; delete old formatters; port test coverage

  • depends_on: [A1, A2]

  • location:

    • pipenv/utils/locking.py (delete format_requirement_for_lockfile at lines 46-160; update prepare_lockfile at line ~195 to consume LockedRequirement)
    • pipenv/resolver/main.py (delete Entry.get_cleaned_dict if not already removed in B1 — should be removed there; this task verifies the deletion)
    • tests/unit/test_utils.py — 17 test cases at lines ~1323-1538 pin format_requirement_for_lockfile behaviour. Port every case to either (a) call LockedRequirement.from_install_requirement directly and assert on the resulting dataclass, or (b) move into the C1 parameterised fixture set against the A1 golden JSON snapshots. Coverage depth must not regress; this is a hard requirement, not a nice-to-have.
    • tests/unit/test_core.py:533 — comment/docstring reference to format_requirement_for_lockfile; clean up.
  • description: prepare_lockfile takes the Sequence[LockedRequirement] produced by the subprocess (via B2's parse step) and emits the TOML-ready dict the lockfile writer expects. The conversion is LockedRequirement.to_lockfile_dict() (plain dict per Q3 — no Plette imports in the schema module).

    After this lands, neither Entry.get_cleaned_dict nor format_requirement_for_lockfile exists in the tree. Both are replaced by LockedRequirement.from_install_requirement (constructor, A1) + LockedRequirement.to_lockfile_dict (sink, here).

    Test-coverage porting is part of this task (NOT C1) so the deletion + the equivalent coverage land in the same commit. The 17 test_utils.py cases pin behaviours like file/path Pipfile-override, marker merging, no_binary propagation, VCS ref normalization — every one of those behaviours must have at least one explicit assertion in the new world before the deletion.

  • validation:

    • grep -n "def format_requirement_for_lockfile\|def get_cleaned_dict" pipenv/ returns zero hits
    • All format_requirement_for_lockfile callers either go through prepare_lockfile or have been migrated to use LockedRequirement.to_lockfile_dict() directly
    • tests/unit/test_utils.py no longer references format_requirement_for_lockfile
    • Behavioural coverage count: grep -c "format_requirement_for_lockfile" tests/ was 17 before the task; the equivalent count of LockedRequirement / from_install_requirement test cases is ≥ 17 after (i.e. coverage did not shrink)
    • python -m pytest tests/unit/ -q green
  • status: Completed (commit 5e6eca82)

  • log:

    • 2026-05-12 — Deleted pipenv.utils.locking.format_requirement_for_lockfile (legacy parent-side formatter, lines 46-160 of the pre-T_F.3 file). prepare_lockfile now consumes Sequence[LockedRequirement], calls req.to_lockfile_dict() per entry, then hands the resulting dict through the existing get_locked_dep -> clean_resolved_dep post-processing chain that still handles project-relative file-URL rewriting (gh-6119), top-level hash unearthing, and version="*" fallback. A transitional dict-fallback branch is retained so mid-Wave-B callers don't break before B2 lands. 20 new test cases (8 direct ports + 9 fixture-parametrised parity-gate cases loading A1 golden JSONs + 3 prepare_lockfile typed-contract cases) replace the 17 deleted TestFormatRequirementForLockfile cases — coverage depth ≥ before.
  • files edited/created:

    • Modified pipenv/utils/locking.py
    • Modified tests/unit/test_utils.py (added TestLockedRequirementFromInstallRequirement + TestPrepareLockfileConsumesLockedRequirement; removed TestFormatRequirementForLockfile)
    • Modified tests/unit/test_core.py (updated docstring reference)

C1: Schema-dataclass unit tests

  • depends_on: [B1, B2, B3]
  • location:
    • NEW tests/unit/test_resolver_schema.py
  • description: Cover every dataclass-level invariant and round-trip:
    • LockedRequirement.__post_init__ rejects (a) no version+vcs+file+path; (b) version-and-vcs both present.
    • LockedRequirement.from_install_requirement produces the same wire shape today's Entry.get_cleaned_dict produced, on a parameterised set of fixture InstallRequirement objects (PyPI, VCS git/hg/svn/bzr, file://, path, editable, with/without extras, with/without markers, with no_binary).
    • Same fixture set ALSO checks that today's format_requirement_for_lockfile output would match — but since that function is deleted in B3, this assertion is historical: pin against committed expected-output JSON instead.
    • ResolverRequest.to_json_dict / from_json_dict round-trip is lossless for every combination of optional fields.
    • ResolverResponse.from_json_dict dispatches correctly for each result.kind ∈ {success, resolution_error, internal_error}; unknown kind raises a typed error.
    • Schema-version mismatch at from_json_dict parse time raises with a clear message.
  • validation:
    • python -m pytest tests/unit/test_resolver_schema.py -v shows ≥ 20 tests, all green
  • status: Completed
  • log:
    • 2026-05-12 — 0c4a11a9 feat(resolver-schema): add LockedRequirement.from_lockfile_dict for parity tests — one-line touch-up to pipenv/resolver/schema.py adding the inverse of to_lockfile_dict (committed separately so the schema diff is clean).
    • 2026-05-12 — de3edea9 test(resolver-schema): expand unit suite with parity, dispatch, comma-in-marker cases (T_F.3 C1 + C3). Appends five test classes to tests/unit/test_resolver_schema.py:
      • TestFromInstallRequirementParity — parametrised against the A1 golden snapshots: 16 cases from format_requirement_for_lockfile/*.json + 11 cases from entry_get_cleaned_dict/*.json, plus 2 fixture-count guards. Each snapshot round-trips through LockedRequirement.from_lockfile_dict -> to_lockfile_dict byte-for-byte. Strategy choice: shape-parity (snapshot dict -> typed -> dict) rather than reconstructing a real pip InstallRequirement from the snapshot; input-level parity (pip InstallRequirement -> typed schema) is C2's job.
      • TestResolverResponseDispatch — 4 cases: one per result.kind ∈ {success, resolution_error, internal_error} plus an unknown-kind rejection. Each round-trips through to_json_dict / from_json_dict.
      • TestSchemaVersionMismatch — 2 cases: the ValueError raised on schema-version mismatch must mention both the received version AND the expected SCHEMA_VERSION constant (per Q2). Covers both ResolverResponse and ResolverRequest.
      • TestVCSPinAndExtras — 8 cases: one round-trip per backend (git, hg, svn, bzr) × two encodings (nested to_json_dict shape and flat to_lockfile_dict shape).
      • TestCommaInMarkerRegression (C3 / Q7) — 2 cases pinning the comma-in-marker regression: a commaful marker on PackageSpecs.specs and on LockedRequirement.markers.
    • RED -> GREEN cycle: the new classes failed RED with AttributeError for LockedRequirement.from_lockfile_dict before 0c4a11a9 landed. All 45 new tests GREEN after.
    • Total test_resolver_schema.py count: 17 (A1 baseline) + 45 (new) = 62 tests, comfortably above the plan's ≥ 20 target.
    • Full unit suite: 758 passed / 9 skipped (was 713 / 9 prior to C1).
  • files edited/created:
    • EDITED pipenv/resolver/schema.py — added LockedRequirement.from_lockfile_dict classmethod (+42 lines)
    • EDITED tests/unit/test_resolver_schema.py — added 5 new test classes (+400 / -12 lines)

C2: JSON wire-shape integration test

  • depends_on: [B1, B2, B3]

  • location:

    • NEW tests/integration/test_resolver_protocol.py
    • NEW tests/integration/fixtures/resolver_protocol/ (request + response golden JSON files)
  • description: Run an actual pipenv lock against a tiny committed Pipfile (3-4 packages: one PyPI, one with markers, one with extras). Patch the subprocess invocation to copy --request-file and --response-file to a fixture-comparison location before tempfile cleanup. Snapshot-diff each against committed golden JSON.

    This is the wire-shape canary. Any PR that changes a field name without bumping SCHEMA_VERSION will fail this test.

    Fixture-regen mechanism is part of this task's scope — implement the regen branch explicitly, do not assume it already exists. Sketch:

    python
    def test_resolver_protocol_lock_smoke(tmp_path):
        request_path, response_path = _run_pipenv_lock_capturing_tempfiles(...)
        actual_request = json.loads(request_path.read_text())
        actual_response = json.loads(response_path.read_text())
        if os.environ.get("PIPENV_REGEN_PROTOCOL_FIXTURES"):
            GOLDEN_REQUEST.write_text(json.dumps(actual_request, indent=2, sort_keys=True))
            GOLDEN_RESPONSE.write_text(json.dumps(actual_response, indent=2, sort_keys=True))
            pytest.skip("fixtures regenerated; rerun without env var to assert")
        assert actual_request == json.loads(GOLDEN_REQUEST.read_text())
        assert actual_response == json.loads(GOLDEN_RESPONSE.read_text())
    

    Fixture-update workflow: when a deliberate schema change lands, the maintainer regenerates the golden by running PIPENV_REGEN_PROTOCOL_FIXTURES=1 pytest tests/integration/test_resolver_protocol.py, reviews the resulting git diff on the fixture files, and commits both the code change and the fixture update together.

  • validation:

    • Test passes green against committed fixtures
    • Test fails (with a readable diff) if LockedRequirement field names are renamed without a SCHEMA_VERSION bump
    • Fixture-regen flag works end-to-end
  • status: Completed

  • log:

    • 2026-05-12 — test(resolver): pin JSON wire shape via integration golden fixtures (T_F.3 C2). The test runs pipenv lock against a 2-package Pipfile (pytz==2024.1 + six==1.16.0 — pure-Python, no transitive deps, frozen versions) via pipenv_instance_pypi and captures the resolver's --request-file / --response-file tempfiles by redirecting them through TMPDIR/TEMP/TMP env vars (the existing parent code uses tempfile.NamedTemporaryFile(prefix="pipenv-request-"/"pipenv-response-", delete=False), which honours the standard tempdir env). After the lock returns the test globs the capture dir for the distinctive prefixes, parses both files, normalises non-deterministic fields (metadata.parent_pid, metadata.pipenv_version, and the locked array's resolution order), then compares against the committed goldens. The regen branch (PIPENV_REGEN_PROTOCOL_FIXTURES=1) writes the normalised JSON back and pytest.skips so the maintainer reviews the resulting git diff before committing.
    • Boundary-crossing fix: surfaced a pre-existing wave-B bug — pipenv/resolver/main.py :: _main did from pipenv.resolver.schema import ... BEFORE calling _ensure_modules() at line 356. The schema import therefore raced the bootstrap and failed with ModuleNotFoundError: No module named 'pipenv' whenever the resolver was invoked via the project venv's python against the absolute file path (i.e. the production code path that integration tests exercise; the wave-B unit smoke avoided this by invoking via python -m pipenv.resolver.main from a process that already had pipenv on sys.path). The fix moves _ensure_modules() to the top of _main (and deletes the now-redundant second call). Without this fix every integration lock/install test on this branch was broken. Committed separately as fix(resolver): bootstrap pipenv on sys.path before schema import in _main.
    • The captured response.json organically includes a comma-bearing marker (six's "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'") — incidental coverage of the C3 regression at the integration level.
    • Test runs green 3 consecutive times; full unit suite untouched at 758 passed / 9 skipped after the main.py bootstrap fix.
  • files edited/created:

    • CREATED tests/integration/test_resolver_protocol.py — single integration test + normalisation helpers + regen branch
    • CREATED tests/integration/fixtures/resolver_protocol/request.json — golden request envelope (normalised)
    • CREATED tests/integration/fixtures/resolver_protocol/response.json — golden response envelope (locked entries sorted by name)
    • EDITED pipenv/resolver/main.py — moved _ensure_modules() ahead of the schema import in _main (one-line bootstrap-order fix; committed separately)

C3: Comma-in-marker regression fixture

  • depends_on: [B1, B2, B3]

  • location:

    • Add a fixture + test case to tests/unit/test_resolver_schema.py (the C1 file)
  • description: Per Q7: today's constraints-file parser uses str.split(",", 1) to separate name from pip-line (F.1 §8 row 9). PEP 508 markers can contain commas (e.g. 'python_version >= "3.10", sys_platform == "linux"'), which currently breaks the parser. The typed-schema replacement uses PackageSpecs.specs: dict[str, str] so commas are no longer a parser concern — but the regression test pins this so the bug can't sneak back if anyone ever refactors PackageSpecs to line-based parsing.

    Fixture: ResolverRequest with one package whose pip-line includes a comma-bearing marker. Assert the round-trip preserves it byte-for-byte.

  • validation:

    • The test exists and passes
  • status: Completed

  • log:

    • 2026-05-12 — Landed in the same commit as C1 (de3edea9 test(resolver-schema): expand unit suite with parity, dispatch, comma-in-marker cases (T_F.3 C1 + C3)) per the plan note that "C3 adds a fixture + test case to the C1 file". The new TestCommaInMarkerRegression class adds 2 cases: (1) a commaful PEP 508 marker (python_version >= "3.10", sys_platform == "linux") on PackageSpecs.specs round-trips through ResolverRequest.to_json_dict / from_json_dict byte-for-byte (and survives JSON re-serialisation); (2) the same marker on LockedRequirement.markers round-trips. Each test cites F.1 §8 row 9 (the legacy str.split(",", 1) bug) and design Q7 in its docstring so a future grep finds the pin.
  • files edited/created:

    • EDITED tests/unit/test_resolver_schema.py — added TestCommaInMarkerRegression class (part of the +400 line C1 + C3 diff)

C4: News fragment

  • depends_on: [B1, B2, B3]

  • location:

    • NEW news/T_F.3.behavior.rst
  • description: Per Q4. One-line news fragment in the .behavior.rst category:

    rst
    Resolver subprocess now produces structured error messages on
    dependency conflicts, surfacing the conflicting packages and the
    specific requirements that cause the conflict.
    

    The internal protocol rewrite itself is invisible to users; the user-facing diff is the cleaner error message on pipenv install / pipenv lock failure.

  • validation:

    • File present, valid RST, towncrier pre-commit hook accepts it
  • status: Completed

  • log:

    • 2026-05-12 — Added news/T_F.3.behavior.rst (4-line behavior fragment per Q4) — commit e891c888. Pre-commit hooks passed.
  • files edited/created:

    • NEW news/T_F.3.behavior.rst

D1: Mark T_F.3 complete in modernization plan

  • depends_on: [C1, C2, C3, C4]

  • location:

    • docs/dev/modernization-plan.md (add a T_F.3 task entry mirroring the T_F.1/T_F.2 entries' shape; no dependency-row edits — the wave table at the bottom of the plan does not list T_F.3 yet, so the addition is purely additive)
  • description: Add the T_F.3 entry: status Completed, log lists every commit hash from waves A–C, files edited/created enumerates the full diff surface. Append a "T_F.4 still pending" note pointing at the design doc §4 step 6 ("the in-process branch fold").

    No other plan-doc edits in T_F.3 — this is the one and only writer of docs/dev/modernization-plan.md for the whole initiative. No other task in this plan touches that file, so no parallel-collision risk.

  • validation:

    • Plan diff shows only the T_F.3 entry addition + a status flip
  • status: Not Completed

  • log:

  • files edited/created:

Parallel Execution Groups

WaveTasksCan Start WhenNotes
AA1, then A2Immediately for A1; A2 after A1 commits golden fixturesA1 must commit golden JSON snapshots BEFORE A2 moves the file, because the snapshot generator runs against today's Entry.get_cleaned_dict and format_requirement_for_lockfile at today's import paths. A1 commits the snapshots in a single commit; A2 then proceeds with the file move + console-script update + import re-export setup. A1 and A2 are therefore strictly serial, not parallel.
BB1, B2, B3Wave A completeDisjoint files: B1 owns pipenv/resolver/main.py + the three Entry/process_resolver_results-importing test files; B2 owns pipenv/utils/resolver.py (including the in-process branch); B3 owns pipenv/utils/locking.py + tests/unit/test_utils.py (17 test-case port). They communicate through the typed schema from A1 + the response-file shape both write/read. Wire-shape coordination point: B1 and B2 must agree on the JSON layout — that's locked in by A1's to_json_dict / from_json_dict methods. If anything ambiguous in A1 surfaces during B execution, fix A1 first then resume B. Single-atomic-PR ordering note: between any two intermediate Wave-B commits the subprocess wire shape may be temporarily inconsistent (e.g. parent has flipped to --request-file while subprocess still reads old argv, or vice versa). The PR is reviewable + green at the TIP, not at every intermediate commit. This is acceptable per the maintainer's "single atomic PR" decision; CI runs only at PR tip and merge.
CC1, C2, C3, C4Wave B completeAll test/doc adds. Fully disjoint.
DD1Wave C completeSingle plan-bump commit; sole writer of modernization-plan.md across the whole T_F.3 scope.

Maximum concurrent agents: 3 (in Wave B). 1 in Wave A (A1 then A2 serial). 4 in Wave C.

Testing Strategy

  • Per-task gate (each agent): python -m pytest tests/unit/ -q green before commit.
  • Wave-B gate: python -m pytest tests/integration -q -k "lock or install or sync" green (subset; the full integration suite runs in CI).
  • Wave-C gate: full unit suite + the new test_resolver_schema.py + test_resolver_protocol.py green.
  • PR gate (CI): full unit + integration suite green on Linux/macOS/Windows × Python 3.10–3.14.
  • Wave-D gate (maintainer-side): manual smoke pipenv install requests + pipenv lock + pipenv install -e git+https://github.com/foo/bar.git@v1 to verify the structured-error path on a deliberately-broken-resolve.

Risks & Mitigations

  1. LockedRequirement.from_install_requirement divergence from current behaviour. The richer format_requirement_for_lockfile and the simpler Entry.get_cleaned_dict produce slightly different shapes today (the divergence cases are flagged in F.1 §8 row 4). The unified constructor must absorb the union, not the intersection. Mitigation: C1's parameterised fixture set includes one case per divergence point. If a test fails, the constructor is missing a branch from one of the two source functions.

  2. JSON wire-shape regression that the canary misses. A renamed field with the same string value at one fixture point would still pass C2. Field-name discipline depends on developer attention, not the test. Mitigation: C1 unit-tests for the round-trip cover the type level; C2 covers the fixture level. The two together close most gaps. SCHEMA_VERSION bumping policy (Q8) is the third line of defence.

  3. Console-script entry breakage on packaged install. pyproject.toml change in A2 only takes effect after pip install -e . is rerun. Mitigation: A2's validation step includes pipenv-resolver --help. If that fails because the entry-point cache is stale, pip install -e . --force-reinstall is the recovery command — call it out in A2's log entry.

  4. In-process branch breaks because Entry is gone. The in-process branch at pipenv/utils/resolver.py :: actually_resolve_deps (F.1 §7) currently constructs Entry instances. After B1 deletes Entry, that branch must instead construct LockedRequirement via the same constructor. Mitigation: B1 is responsible for updating both branches. The in-process branch is NOT folded (Q6) but it MUST be migrated to the new types. If B1 misses this, B2's parent-side rewrite will surface it (the parent calls into the in-process branch directly).

  5. Parallel-agent collisions on shared callers. B1 touches pipenv/resolver/main.py; B2 and B3 do not. B2 touches pipenv/utils/resolver.py; B1 and B3 do not. B3 touches pipenv/utils/locking.py; B1 and B2 do not. So in principle no file-level collision. But all three import from pipenv/resolver/schema.py (A1's output) — if A1 needs amendment mid-wave-B, coordinate the amendment as a single commit visible to all three. Mitigation: The standard parallel-agent rules apply (no git stash; explicit git commit -- <files>).

  6. Schema-version mismatch test path requires writing the response file even on schema rejection — the subprocess must construct an InternalError response before the schema-version check completes successfully (per Q2). Care: schema_version is the first field on the envelope precisely so a partial parse can still detect mismatch and produce a structured rejection. Mitigation: B1's task description spells out the two-stage parse (read schema_version first, then conditionally parse the rest). A1's from_json_dict should expose this two-stage option.

  7. Behaviour drift between subprocess and in-process branches during the wave-B work. The in-process branch shares resolve_packages() with the subprocess; if B1's rewrite of resolve_packages regresses the in-process call site, B2's parent-side smoke tests catch it. Mitigation: B2's validation explicitly includes a small pipenv lock smoke, which exercises both branches depending on PIPENV_RESOLVER_PARENT_PYTHON.

  8. Test-coverage regression from deleting format_requirement_for_lockfile's 17 pinning cases. tests/unit/test_utils.py:1323-1538 is the single largest behavioural coverage block for the lockfile-entry shape. If B3 deletes those cases without porting equivalent coverage to LockedRequirement.from_install_requirement, the typed-schema regression net is thinner than today's untyped one — exactly the wrong direction. Mitigation: B3's task description treats the test port as part of the deletion commit (single atomic change: delete + port). Validation step explicitly counts behavioural test-cases-before vs after; the count must not drop. The A1 golden snapshots serve as a parity gate for the trickier cases (file/path Pipfile-override, marker merging, no_binary propagation).

  9. Target-Python compatibility of the schema module. Per design §3.6, pipenv/resolver/schema.py runs inside the target venv's Python — typically the minimum pipenv supports (currently CPython 3.10) through the latest — not the parent pipenv's Python. A1 must use only stdlib idioms that work back to that minimum: @dataclass(frozen=True) ✓, from __future__ import annotations ✓, Optional/Sequence/Mapping from typing ✓. Disallowed at module top level: typing.Self (3.11+), tomllib (3.11+; pipenv already conditionally imports it), exhaustive match patterns that depend on 3.11+ semantics, ANY new vendored dependency, ANY from pipenv.patched.pip._internal import. Pip-internal types are accepted only inside the body of LockedRequirement.from_install_requirement because the subprocess is the only caller that has the patched-pip path available. Mitigation: A1's validation adds python3.10 -c "from pipenv.resolver.schema import *" (if 3.10 is available locally) and grep -n "^from pipenv.patched\\|^import pipenv.patched" pipenv/resolver/schema.py must return zero hits. CI's Python-version matrix (3.10–3.14) is the integration gate.

What this plan deliberately does NOT cover

  • T_F.4 — fold the in-process and subprocess branches into a single implementation (PRD-stated acceptance: "one resolver implementation, two thin adapters"). Per Q6 this is the next task in line, not this one.
  • Wall-clock timeout enforcementRequestMetadata.deadline_seconds exists on the wire, but T_F.3 does not enforce it. A follow-up small PR adds c.wait(timeout=...) with its own news fragment because the behaviour change (hanging installs start dying) is user-visible.
  • Diagnostics.resolver_log population — reserved field, empty tuple in T_F.3 per Q9. A future PR may start populating it if a structured-log use case materialises.
  • Pluggable resolver backends — the design's §6a discusses future pluggability for uv etc. T_F.3 stays strictly pip-only; the schema is shaped to preserve the option but does not introduce a Backend ABC.

Source design under docs/dev/initiative-f-typed-design.md. Updates to this plan or to the design should be made in lock-step.