docs/dev/initiative-f-execution-plan.md
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)
Execute the typed ResolverRequest / ResolverResponse migration designed in T_F.2. One atomic rewrite of the pipenv ↔ pipenv-resolver subprocess wire format:
pipenv/resolver/schema.py with the stdlib @dataclass envelope + discriminated ResolverResultpipenv/resolver.py (single file) into pipenv/resolver/ (package): __init__.py + main.py--request-file <path> only (drops all other argv + three env-var hops)LockedRequirement.from_install_requirement(...) formatter replaces both Entry.get_cleaned_dict and format_requirement_for_lockfileprepare_lockfile consumes typed LockedRequirement via to_lockfile_dict() adapterNo backwards-compat shim. No external-API guarantees (CLI is the contract). Subprocess and parent ship together; protocol-version negotiation is unnecessary.
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:
pipenv/resolver/schema.py (package layout). Reserves pipenv/resolver/backends/ for future pluggability (§6a).InternalError written to response file AND non-zero exit.LockedRequirement.to_lockfile_dict() returns plain dict (no Plette in schema module).news/T_F.3.behavior.rst fragment ("Resolver subprocess now produces structured error messages on dependency conflicts").no_binary is a first-class field on LockedRequirement.SCHEMA_VERSION.Diagnostics.resolver_log is reserved-but-empty in T_F.3 — stderr stays the user-facing channel.--request-file + --response-file), no consolidation.If any recommendation is overridden the plan needs revision — affected tasks are flagged below.
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)
depends_on: []
location:
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 constantLockedRequirement.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.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 Entrypipenv/resolver.py:288-320 — Entry.get_cleaned_dict shapepipenv/utils/locking.py:46-160 — format_requirement_for_lockfile (the richer one)pipenv/utils/locking.py:121-131 — merge_markers handlingpipenv/utils/locking.py:142-154 — file/path Pipfile-override semanticspipenv/utils/locking.py:156-157 — no_binary propagationvalidation:
python -c "from pipenv.resolver.schema import ResolverRequest, ResolverResponse, LockedRequirement, SCHEMA_VERSION; assert SCHEMA_VERSION == 1" passespython -c "from pipenv.resolver.schema import LockedRequirement; LockedRequirement(name='foo')" raises ValueError (post-init invariant fires)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:
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/.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.py → pipenv/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:
pipenv/resolver/__init__.py — package + re-export shimpipenv/resolver/schema.py — typed dataclass envelope (14 dataclasses + SCHEMA_VERSION)pipenv/resolver.py → pipenv/resolver/main.py (necessity, see boundary-crossing note above)tests/unit/test_resolver_schema.py — 17 tests covering invariants + envelope round-triptests/unit/fixtures/resolver_schema/format_requirement_for_lockfile/*.json — 16 golden snapshotstests/unit/fixtures/resolver_schema/entry_get_cleaned_dict/*.json — 11 golden snapshotspipenv/resolver.py → pipenv/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:
pipenv/resolver.py → pipenv/resolver/main.py (full content, no logic change)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.pyproject.toml line 63: scripts.pipenv-resolver = "pipenv.resolver:main" → "pipenv.resolver.main:main"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 greenpython -c "from pipenv.resolver import main; assert callable(main)" passespipenv-resolver --help (or equivalent invocation) still works after pip install -e . --force-reinstallgit diff shows the move + the pyproject.toml line + the three test imports (only) and nothing elsestatus: 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)--request-file, write --response-file; delete dead symbols + dead test importsdepends_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:
--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).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).request.python_marker_override directly (drops the PIPENV_RESOLVER_PYTHON_VERSION env-var hop).request.sources directly (drops the in-child Pipfile re-read at resolver.py:448-453 and the duplicate mirror substitution at lines 436-453).request.extra_pip_args directly (drops the PIPENV_EXTRA_PIP_ARGS env-var hop).LockedRequirement instances via LockedRequirement.from_install_requirement(...) (no more Entry.get_cleaned_dict).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.ResolverResponse(..., result=InternalError(message=str(e), traceback=...)) to --response-file, then exit non-zero._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--request-file /tmp/x.json --response-file /tmp/y.json against a hand-built fixture request produces a valid ResolverResponse JSONschema_version: 999) produces structured InternalError response AND non-zero exitgrep -nE -- "--parse-only|--pipenv-site|--constraints-file|--resolved-default-deps-file|--category" pipenv/resolver/main.py returns zero hitsgrep -nE "PIPENV_RESOLVER_PYTHON_VERSION|PIPENV_EXTRA_PIP_ARGS|PIPENV_SITE_DIR" pipenv/resolver/main.py returns zero hitsstatus: Completed
log:
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).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.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:
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 flagspipenv/resolver/__init__.py — pruned re-exports of deleted symbols; only _main, main, resolve_packages, which remainpyproject.toml — scripts.pipenv-resolver = "pipenv.resolver.main:main" (was pipenv.resolver:main); developers must run pip install -e . --force-reinstall to re-linktests/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)tests/unit/test_resolver_regressions.py — deleted test_process_resolver_results_does_not_scan_reverse_dependencies (function gone; regression structurally impossible)tests/unit/test_locking_no_mutation.py — updated _fake_resolve_packages to return (locked, resolver) with typed LockedRequirement instancestests/unit/test_resolver_protocol_smoke.py — 3 subprocess-level tests gating the wire protocolpipenv/utils/resolver.py :: venv_resolve_deps + resolve + in-process branchdepends_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.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 greenpython -m pytest tests/integration/ -q -k "lock or install" smoke-passes a representative subset (full integration suite is the wave-D gate)pipenv lock on a tiny test Pipfile produces a valid lockfile (no crash, no malformed JSON)PIPENV_RESOLVER_PARENT_PYTHON=1: same pipenv lock produces an identical lockfile via the in-process branchgrep -nE "PIPENV_RESOLVER_PYTHON_VERSION|PIPENV_EXTRA_PIP_ARGS|PIPENV_SITE_DIR" pipenv/utils/resolver.py returns zero hitsstatus: Completed
log:
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).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).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).files edited/created:
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())tests/unit/test_resolver_parent_dispatch.py (8 tests for request build, argv shape, response dispatch on each result.kind)LockedRequirement; delete old formatters; port test coveragedepends_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 hitsformat_requirement_for_lockfile callers either go through prepare_lockfile or have been migrated to use LockedRequirement.to_lockfile_dict() directlytests/unit/test_utils.py no longer references format_requirement_for_lockfilegrep -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 greenstatus: Completed (commit 5e6eca82)
log:
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:
pipenv/utils/locking.pytests/unit/test_utils.py (added TestLockedRequirementFromInstallRequirement + TestPrepareLockfileConsumesLockedRequirement; removed TestFormatRequirementForLockfile)tests/unit/test_core.py (updated docstring reference)tests/unit/test_resolver_schema.pyLockedRequirement.__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).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.from_json_dict parse time raises with a clear message.python -m pytest tests/unit/test_resolver_schema.py -v shows ≥ 20 tests, all green0c4a11a9 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).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.AttributeError for LockedRequirement.from_lockfile_dict before 0c4a11a9 landed. All 45 new tests GREEN after.test_resolver_schema.py count: 17 (A1 baseline) + 45 (new) = 62 tests, comfortably above the plan's ≥ 20 target.pipenv/resolver/schema.py — added LockedRequirement.from_lockfile_dict classmethod (+42 lines)tests/unit/test_resolver_schema.py — added 5 new test classes (+400 / -12 lines)depends_on: [B1, B2, B3]
location:
tests/integration/test_resolver_protocol.pytests/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:
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:
LockedRequirement field names are renamed without a SCHEMA_VERSION bumpstatus: Completed
log:
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.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.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.main.py bootstrap fix.files edited/created:
tests/integration/test_resolver_protocol.py — single integration test + normalisation helpers + regen branchtests/integration/fixtures/resolver_protocol/request.json — golden request envelope (normalised)tests/integration/fixtures/resolver_protocol/response.json — golden response envelope (locked entries sorted by name)pipenv/resolver/main.py — moved _ensure_modules() ahead of the schema import in _main (one-line bootstrap-order fix; committed separately)depends_on: [B1, B2, B3]
location:
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:
status: Completed
log:
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:
tests/unit/test_resolver_schema.py — added TestCommaInMarkerRegression class (part of the +400 line C1 + C3 diff)depends_on: [B1, B2, B3]
location:
news/T_F.3.behavior.rstdescription:
Per Q4. One-line news fragment in the .behavior.rst category:
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:
status: Completed
log:
news/T_F.3.behavior.rst (4-line behavior fragment per Q4) — commit e891c888. Pre-commit hooks passed.files edited/created:
news/T_F.3.behavior.rstdepends_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:
status: Not Completed
log:
files edited/created:
| Wave | Tasks | Can Start When | Notes |
|---|---|---|---|
| A | A1, then A2 | Immediately for A1; A2 after A1 commits golden fixtures | A1 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. |
| B | B1, B2, B3 | Wave A complete | Disjoint 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. |
| C | C1, C2, C3, C4 | Wave B complete | All test/doc adds. Fully disjoint. |
| D | D1 | Wave C complete | Single 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.
python -m pytest tests/unit/ -q green before commit.python -m pytest tests/integration -q -k "lock or install or sync" green (subset; the full integration suite runs in CI).test_resolver_schema.py + test_resolver_protocol.py green.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.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.
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.
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.
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).
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>).
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.
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.
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).
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.
RequestMetadata.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.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.