Skip to content

Maintenance/code cleanup 2026-05 Phase III (resolver typed schema)#6666

Open
matteius wants to merge 36 commits into
maintenance/code-cleanup-phase2-2026-05from
maintenance/code-cleanup-phase3-resolver-typed-schema-2026-05
Open

Maintenance/code cleanup 2026-05 Phase III (resolver typed schema)#6666
matteius wants to merge 36 commits into
maintenance/code-cleanup-phase2-2026-05from
maintenance/code-cleanup-phase3-resolver-typed-schema-2026-05

Conversation

@matteius

@matteius matteius commented May 12, 2026

Copy link
Copy Markdown
Member

Summary

Phase 3 of the pipenv maintenance-mode modernization stack. Bases on top of PR #6665 (Phase 2). Bundles the full T_F.3 typed-schema migration plus the four follow-up items from the prior phase-4 branch (T_F.4, T_F.5a design, T_F.6, T_F.7) merged in.

28 commits, +6735 / −1053 across pipenv/ and tests/, plus design doc updates and 2 news fragments. Touches zero vendored or patched files. 780 unit tests pass (was 651 baseline at start of Phase 2, 758 after T_F.3 alone → +22 from T_F.4/6/7).

What's in this PR

T_F.3 — Execute the typed resolver subprocess schema (17 commits)

The structural rewrite of the pipenv ↔ pipenv-resolver subprocess protocol. Designed in PR #6665's docs/dev/initiative-f-typed-design.md (886 lines, maintainer-approved); executed here per the swarm-ready plan in docs/dev/initiative-f-execution-plan.md (4 waves: foundation, parallel-3, parallel-4, plan-bump).

pipenv/resolver.pypipenv/resolver/ package with four modules:

  • __init__.py (re-export shim for one-PR compatibility)
  • schema.py (NEW, 899 lines) — 14 stdlib @dataclass(frozen=True) types: ResolverRequest, ResolverResponse, discriminated ResolverResult (ResolverSuccess / ResolutionError / InternalError), LockedRequirement, VCSPin, PackageSpecs, Source, ResolverOptions, ResolvedDeps, RequestMetadata, ConflictRecord, Diagnostics, + SCHEMA_VERSION = 1. Manual to_json_dict / from_json_dict round-trip (not dataclasses.asdict — breaks the discriminated union pattern).
  • main.py (the old pipenv/resolver.py minus 156 lines of dead plumbing) — thin subprocess adapter: parses argv, reads --request-file, dispatches to resolve_for_pipenv, writes --response-file.
  • core.py (NEW, 555 lines, from T_F.4) — resolve_for_pipenv(request) -> ResolverResponse is the single canonical driver. Both the subprocess adapter and the parent's in-process branch call it.

Single canonical formatter LockedRequirement.from_install_requirement absorbs the behaviour from BOTH formerly-competing formatters: Entry.get_cleaned_dict (subprocess side) and format_requirement_for_lockfile (parent side). Both deleted in this PR. prepare_lockfile now consumes Sequence[LockedRequirement] via the typed to_lockfile_dict() adapter.

Dead surface deleted from the subprocess:

  • argv: --pre, --clear, --system, --verbose, --category, --constraints-file, --resolved-default-deps-file, --parse-only, --pipenv-site, positional packages
  • env-var hops: PIPENV_RESOLVER_PYTHON_VERSION, PIPENV_EXTRA_PIP_ARGS, PIPENV_SITE_DIR (folded into typed request fields)
  • Python classes: Entry, PackageRequirement, process_resolver_results, which stub

Failure semantics fixed: today's non-zero-exit-as-only-failure-indicator is replaced with the discriminated ResolverResult union written to the response file on exit 0. Genuine subprocess crashes still produce non-zero exit + the legacy stderr-fallback path. Schema-version mismatch produces a structured InternalError response AND non-zero exit (per Q2 sign-off).

Tests:

  • tests/unit/test_resolver_schema.py (NEW, 678 lines / 62 tests) — invariants, JSON round-trip, golden-fixture parity against 27 committed snapshots, dispatch, schema-version-mismatch, VCS pins, comma-in-marker regression (Q7)
  • tests/unit/test_resolver_parent_dispatch.py (NEW, 427 lines)
  • tests/unit/test_resolver_protocol_smoke.py (NEW, 261 lines)
  • tests/integration/test_resolver_protocol.py (NEW, 200 lines) — golden-fixture canary with PIPENV_REGEN_PROTOCOL_FIXTURES=1 regen mechanism
  • tests/unit/test_utils.py — 17 format_requirement_for_lockfile cases ported to 20 typed cases (coverage grew)
  • news/T_F.3.behavior.rst — one-line behavior note about structured error messages on dependency conflicts

T_F.4 — Fold in-process and subprocess branches into one implementation

pipenv/resolver/core.py :: resolve_for_pipenv(request) -> ResolverResponse is now the only place the resolve plumbing lives. Eliminates duplication of:

  • Marker-environment monkey-patch (_apply_python_marker_override deleted from main.py)
  • resolve_packages invocation + result normalization (was in both _main and the PIPENV_RESOLVER_PARENT_PYTHON branch)
  • ResolverResponse wrapping + variant dispatch (was duplicated in _main exception handler)

Both adapters are now thin: pipenv/resolver/main.py:main is 5 lines; the in-process branch in pipenv/utils/resolver.py:venv_resolve_deps is 8 lines.

Stub-injection compatibility preserved across three existing test patterns (the dispatcher walks sys.modules in preference order so test_resolver_core.py, test_locking_no_mutation.py, and test_resolver_protocol_smoke.py continue to work).

T_F.5a — Pluggable resolver backends DESIGN doc (sign-off gate)

No code change. docs/dev/initiative-f-backends-design.md (NEW, 948 lines) proposes the architecture for landing alternative resolver backends (uv being the prime candidate) on top of the typed-schema substrate. Resolves the 4 open questions from F.2 §6a and surfaces 10 sign-off questions for the maintainer:

  1. Pipfile opt-in field — [pipenv] resolver_backend vs [pipenv.resolver] vs [tool.pipenv.resolver]
  2. CLI flag name — --backend NAME vs --resolver-backend NAME vs --use-uv
  3. Lockfile filename — single Pipfile.lock + _meta.resolver_backend vs distinct files
  4. Missing-backend behaviour — fail loud vs silent fallback vs refuse-to-operate
  5. Pip-backend _meta.resolver_backend value — omit vs explicit "pip"
  6. Cross-backend re-lock policy — re-resolve fully vs refuse without --allow-backend-switch
  7. New schema fields — add LockedRequirement.resolver_backend / Diagnostics.resolver_name? (recommendation: neither)
  8. Vendor uv vs system uv (recommendation: system)
  9. Test matrix expansion — dual-backend every test vs representative subset
  10. News-fragment category — .feature vs .behavior

Concerns from the existing origin/uv-backend WIP (1000-line exploration that predates the typed schema) called out in the design:

  • WIP silently breaks install/sync/update/audit when PIPENV_USE_UV=1 because it writes only pylock.toml. Other subcommands read Pipfile.lock. Hidden by integration tests only running pipenv lock.
  • WIP silently falls back to pip when uv is missing — debug-hostile. Design recommends loud error instead.
  • ~half of WIP's 969 lines is dict-shuttling the typed schema now handles. Post-schema port → ~450 lines.

T_F.5 execution is gated on maintainer answers to the 10 questions and is NOT in this PR.

T_F.6 — Wall-clock timeout enforcement

Honours request.metadata.deadline_seconds end-to-end:

  • Override precedence: Pipfile [pipenv] resolver_timeout_seconds > env PIPENV_RESOLVER_TIMEOUT_S > 1800s default. Stamped into RequestMetadata.deadline_seconds so the schema records it.
  • Subprocess path: subprocess.run(timeout=deadline_seconds). TimeoutExpired converts to a structured ResolutionFailure whose message names both override channels so the user knows how to extend.
  • In-process path (PIPENV_RESOLVER_PARENT_PYTHON=1): signal.SIGALRM deadline guard. Unix only; no-op on Windows (subprocess path is the production one).
  • 7 new unit tests in tests/unit/test_resolver_timeout.py.
  • news/T_F.6.behavior.rst — user-visible behaviour change: previously-hanging installs now die after the deadline with a structured error.

T_F.7 — Diagnostics.resolver_log population

T_F.3 reserved the field but left it as an empty tuple per Q9. This populates it:

  • Captures pipenv + pip._internal.resolution loggers (NOT pip download chatter — stderr remains that channel)
  • Format: "[LEVELNAME] message" per record
  • Cap: 500 records with "... (N records elided)" sentinel
  • Parity: capture lives inside resolve_for_pipenv so both adapters get identical logs
  • Stderr behaviour unchanged in non-verbose mode. Only --verbose mode prints the structured log alongside the existing stderr stream.
  • 9 new unit tests in tests/unit/test_resolver_diagnostics.py

No news fragment needed — purely additive (field went from empty to populated; no user-visible breakage outside verbose mode).

Still-queued work (not in this PR; not on a sibling branch yet)

  • T_F.5 execution — gates on maintainer sign-off of the 10 design questions in docs/dev/initiative-f-backends-design.md. Will land on its own branch.
  • T_D.5 — extract Lockfile subsystem from Project.
  • T_D.6 — extract Pipfile subsystem from Project.
  • T_E.4 — relocate unpack_url / get_http_url; delete the (then-empty) requirementslib.py.

Test plan

  • 780 unit tests pass locally (pytest tests/unit/); was 651 baseline at start of Phase 2, 679 after Phase 2 alone, 758 after T_F.3 alone, +22 from T_F.4/6/7
  • JSON wire-shape integration test pins both --request-file and --response-file content against committed golden fixtures (tests/integration/fixtures/resolver_protocol/) with PIPENV_REGEN_PROTOCOL_FIXTURES=1 regen workflow
  • Manual pipenv lock smoke: byte-identical lockfile (sha f162d2e398108da068327e701ee9472102db75565b86c0415420968db20ef679) produced via both subprocess and PIPENV_RESOLVER_PARENT_PYTHON=1 in-process paths
  • Acceptance greps clean: zero Entry.get_cleaned_dict, zero format_requirement_for_lockfile, zero PIPENV_RESOLVER_PYTHON_VERSION / PIPENV_EXTRA_PIP_ARGS / PIPENV_SITE_DIR references, zero dead argv flags in pipenv/resolver/main.py
  • CI matrix this run is the verification gate
  • Developer reinstall note: pip install -e . --force-reinstall is required after this lands so the pipenv-resolver console script on $PATH resolves to the new pipenv.resolver.main:main entry (was pipenv.resolver:main)

🤖 Generated with Claude Code

matteius and others added 11 commits May 12, 2026 11:44
…rement formatter + golden fixtures

T_F.3 A1.  First wave of the typed-resolver-subprocess migration.

Adds:
- pipenv/resolver/schema.py — the full @DataClass(frozen=True)
  envelope from initiative-f-typed-design.md §3 (ResolverRequest,
  ResolverResponse, ResolverSuccess, ResolutionError, InternalError,
  LockedRequirement, VCSPin, PackageSpecs, Source, ResolverOptions,
  ResolvedDeps, RequestMetadata, ConflictRecord, Diagnostics) plus
  SCHEMA_VERSION = 1.  Manual to_json_dict / from_json_dict
  (deterministic — sorted keys, sorted hashes/extras, None-valued
  keys omitted; two-stage parse that validates schema_version BEFORE
  dispatching on result.kind).  Stdlib only; importable on every
  pipenv-supported target Python (CPython 3.10+).
- LockedRequirement.from_install_requirement — the single canonical
  formatter that absorbs BOTH legacy paths: Entry.get_cleaned_dict
  (subprocess-side) and format_requirement_for_lockfile (parent-side,
  the richer one with file/path Pipfile-override, direct-URL handling,
  no_binary propagation, marker merging, index lookup, VCS ref
  normalization).  Docstring cites source line numbers so future
  readers can diff against today's behaviour.
- tests/unit/test_resolver_schema.py — TestLockedRequirementInvariants
  and TestEnvelopeRoundtrip classes (17 tests).  Leaves room for C1 to
  append TestFromInstallRequirementParity against the golden snapshots.
- tests/unit/fixtures/resolver_schema/ — 27 committed JSON snapshots
  (16 from format_requirement_for_lockfile, 11 from
  Entry.get_cleaned_dict) covering PyPI / VCS git+hg+svn+bzr / file://
  / path / editable / extras / markers / no_binary / comma-in-marker
  (Q7 regression).  These are the parity gate C1 will use against
  from_install_requirement after Wave B deletes both legacy formatters.

Structural note (necessity, not scope creep):

A1's deliverable location is pipenv/resolver/schema.py per the T_F.3
design.  This requires pipenv/resolver/ to exist as a package, which
makes the historical pipenv/resolver.py file unreachable (Python
prefers a package directory over a sibling .py module).  A1 therefore
moves pipenv/resolver.py to pipenv/resolver/main.py and adds a
re-exporting __init__.py so the three test modules at
tests/unit/test_dependencies.py, tests/unit/test_resolver_regressions.py,
and tests/unit/test_locking_no_mutation.py continue to import Entry,
process_resolver_results, resolve_packages, and main unchanged.  The
pyproject.toml console-script entry pipenv.resolver:main still
resolves correctly via the __init__ shim — no pyproject change needed
in this commit.

This effectively folds A2's 'move resolver.py into the package' work
into A1.  A2's remaining scope (pyproject console-script update if
desired, and the three test-file import comments) shrinks accordingly.

Validation:
- 'from pipenv.resolver.schema import ... SCHEMA_VERSION == 1' PASSES
- LockedRequirement(name='foo') raises ValueError PASSES
- LockedRequirement(name='foo', version='1.0', vcs=...) raises ValueError PASSES
- 'from pipenv.patched' import grep returns zero hits PASSES
- 'typing.Self|tomllib' grep returns zero hits PASSES
- Golden snapshot count: 27 (criterion: >= 10) PASSES
- python -m pytest tests/unit/ -q — 694 passed, 9 skipped
  (677 prior + 17 new)
- Manual ResolverRequest round-trip equality PASSES
- Manual two-stage schema-version rejection PASSES

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A1 landed in 85993ca.  Plan log updated with the commit hash, the
RED→GREEN evidence (17 schema tests + 694/9 unit suite), and the
boundary-crossing note explaining why A1 had to absorb the file-move
that A2 nominally owned (Python's package-vs-module precedence makes
pipenv/resolver/schema.py and pipenv/resolver.py mutually exclusive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A1's agent made a pragmatic engineering call: Python's import system
makes pipenv/resolver.py (file) and pipenv/resolver/ (package) mutually
exclusive, so the file-move had to happen in 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).

pyproject.toml's scripts.pipenv-resolver = "pipenv.resolver:main" still
resolves correctly via the shim — no change needed yet. The console-
script entry update can land in B1 alongside the dead-symbol prune,
when Entry/PackageRequirement/process_resolver_results come out of the
shim.

Wave A complete. Wave B (B1 + B2 + B3) is now unblocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ckfile consumes typed LockedRequirement; port 17 test cases

T_F.3 B3.

* Delete pipenv.utils.locking.format_requirement_for_lockfile (the legacy
  ~115-line parent-side formatter at lines 46-160 of the pre-T_F.3 file).
  Its behaviour is fully absorbed by
  LockedRequirement.from_install_requirement in pipenv/resolver/schema.py
  (added by T_F.3 A1).
* prepare_lockfile now accepts Sequence[LockedRequirement] and converts
  each entry via LockedRequirement.to_lockfile_dict() before passing the
  dict through the existing project-side post-processing (get_locked_dep
  -> clean_resolved_dep) that still handles project-relative file-URL
  rewriting, top-level hash unearthing, and version="*" fallback to the
  previous lockfile entry.  A transitional dict-fallback path remains so
  mid-Wave-B callers continue to work; it will be removed once B1 and B2
  land their parent/subprocess migrations.
* Port the 17 lockfile-entry pinning test cases from
  TestFormatRequirementForLockfile to the new
  TestLockedRequirementFromInstallRequirement class (8 ported direct
  cases) plus 9 fixture-parametrised parity-gate cases that load the A1
  golden snapshots under tests/unit/fixtures/resolver_schema/ and compare
  them byte-for-byte to LockedRequirement.to_lockfile_dict() output.
  Adds a new TestPrepareLockfileConsumesLockedRequirement class with 3
  cases that pin the typed-Sequence contract.  Total: 20 new test cases
  vs the 17-case before-baseline (coverage depth does not regress).
* Update the comment reference in tests/unit/test_core.py:533 to point
  at LockedRequirement.from_install_requirement, the new canonical
  pep423_name call site.

Acceptance:
- grep -n "def format_requirement_for_lockfile" pipenv/ -> 0 hits
- grep "format_requirement_for_lockfile" tests/unit/test_utils.py -> 0 hits
- python -m pytest tests/unit/test_utils.py -q -> 213 passed, 7 skipped
- Remaining unit-suite failures (test_locking_no_mutation,
  test_resolver_parent_dispatch, test_resolver_protocol_smoke) are
  attributable to B1's pipenv/resolver/main.py rewrite and B2's
  pipenv/utils/resolver.py rewrite not yet landing; per the plan, the
  PR is reviewable at the TIP, not at every intermediate commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…produces typed ResolverResponse

T_F.3 B1.

Replace the legacy argv/env-var/tempfile cocktail with a single typed
envelope. pipenv-resolver now accepts only --request-file <path> and
--response-file <path>; both files carry ResolverRequest /
ResolverResponse JSON payloads defined in pipenv.resolver.schema
(landed in A1).

Subprocess behaviour:
* Exit 0 on resolution success (ResolverSuccess written) AND on
  resolution failure (ResolutionError written). Non-zero exit is
  reserved for genuine crashes / schema-version mismatch.
* Uncaught exceptions produce a best-effort InternalError payload plus
  a non-zero exit so the parent can distinguish protocol skew from
  regular resolution failure (design Q2).
* Schema-version mismatch fires before the rest of the payload is
  parsed (two-stage parse) and writes an InternalError with a clear
  "parent sent N, child expects M" message.
* request.python_marker_override, request.sources, and
  request.extra_pip_args flow as typed fields - the legacy
  PIPENV_RESOLVER_PYTHON_VERSION / PIPENV_EXTRA_PIP_ARGS /
  PIPENV_SITE_DIR env-var hops are deleted.
* The in-child Project() re-instantiation + Pipfile re-read +
  duplicate mirror substitution at the legacy resolver:436-453 are
  gone; the child consumes the parent-substituted source list verbatim.
* Stderr free-text continues to flow.

Deletions: Entry / PackageRequirement / PackageSource dataclasses,
process_resolver_results function, the module-level which() stub at
resolver.py:90-91, and all the legacy argparse flags (--parse-only,
--pipenv-site, positional packages, --pre, --clear, --system,
--verbose, --category, --constraints-file,
--resolved-default-deps-file).

Test surface:
* NEW tests/unit/test_resolver_protocol_smoke.py - 3 subprocess-level
  tests gating the wire protocol (stubbed happy-path, schema-version
  mismatch, live-resolve against PyPI).
* Deleted the three test_entry_get_cleaned_dict_* tests from
  test_dependencies.py (legacy Entry class).
* Deleted test_process_resolver_results_does_not_scan_reverse_dependencies
  from test_resolver_regressions.py.
* Updated test_locking_no_mutation.py's _fake_resolve_packages to match
  the new (locked, resolver) return shape and to emit typed
  LockedRequirement instances rather than raw dicts.

pyproject.toml: scripts.pipenv-resolver updated from pipenv.resolver:main
to pipenv.resolver.main:main for clarity now that the package's
__init__.py re-export shim is trimmed.

Developers must run pip install -e . --force-reinstall in any pipenv-
development checkout so that the pipenv-resolver console-script on
PATH resolves to the new entry point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B2's diff landed as part of commit d1563a1 (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 and the
acceptance gates are met:

* ``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 legacy flags).
* ``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.
* Full unit suite: 713 passed, 9 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B1 (subprocess entry rewrite) landed in commit d1563a1 together with
B2 (parent-side rewrite); pre-commit's stash/restore mechanism rolled
the two staged trees together when the ruff hook on
pipenv/resolver/main.py interrupted the planned dual-commit sequence.
The B1-owned diff is internally consistent and all acceptance gates
are met:

* All 3 grep gates clean on pipenv/resolver/main.py (zero hits for
  legacy argv flags, legacy env-var hops, dead symbols).
* New tests/unit/test_resolver_protocol_smoke.py exercises the wire
  protocol via 3 subprocess-level tests (stubbed happy-path,
  schema-version mismatch, live-resolve against PyPI).
* Full unit suite green: 713 passed / 9 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arity tests

Adds the inverse of `to_lockfile_dict`: reconstructs a `LockedRequirement`
from the flat top-level lockfile-entry dict (VCS backend as top-level key
like `git: <url>`). This is needed by the T_F.3 C1 parity tests so the
A1 golden snapshots can round-trip through the typed schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@matteius matteius changed the title Maintenance/code cleanup phase3 resolver typed schema 2026 05 Maintenance/code cleanup 2026-05 Phase III (resolver typed schema) May 12, 2026
matteius and others added 18 commits May 12, 2026 12:07
…-in-marker cases (T_F.3 C1 + C3)

Adds five new test classes to `tests/unit/test_resolver_schema.py` covering
the C1 + C3 deliverables from the T_F.3 plan:

- `TestFromInstallRequirementParity` — parametrised against the 16 A1
  `format_requirement_for_lockfile/*.json` golden snapshots and the 11
  `entry_get_cleaned_dict/*.json` snapshots.  Each snapshot round-trips
  through `LockedRequirement.from_lockfile_dict` -> `to_lockfile_dict`
  and must match byte-for-byte.  This is the **shape-parity** gate; the
  input-level parity (real pip `InstallRequirement` -> typed schema)
  belongs to the C2 integration test.  Fixture-count assertions guard
  against silent drift.
- `TestResolverResponseDispatch` — one explicit case per `result.kind`
  in {success, resolution_error, internal_error} plus an unknown-kind
  rejection.  Each case round-trips through `to_json_dict` /
  `from_json_dict`.
- `TestSchemaVersionMismatch` — the structured error raised on a
  mismatched `schema_version` must include both the received version
  and the expected `SCHEMA_VERSION` constant (per design Q2).
- `TestVCSPinAndExtras` — one round-trip per backend (git, hg, svn,
  bzr) through both the nested `to_json_dict` shape and the flat
  `to_lockfile_dict` shape.  Confirms `ref` / `subdirectory` survive
  the envelope and that wire-side extras are sorted deterministically.
- `TestCommaInMarkerRegression` (C3 / Q7) — pins the comma-in-marker
  regression flagged in F.1 §8 row 9.  The legacy constraints-tempfile
  parser used `str.split(",", 1)` to split package name from pip-line,
  silently corrupting PEP 508 markers like
  `python_version >= "3.10", sys_platform == "linux"`.  The typed
  `PackageSpecs.specs: dict[str, str]` makes splitting unnecessary;
  this test pins the byte-for-byte round trip so the bug can't sneak
  back via a future refactor.

RED -> GREEN cycle: the new test classes failed RED with
`ModuleNotFoundError` / `AttributeError` for `from_lockfile_dict` before
the parent commit (`feat(resolver-schema): add ... from_lockfile_dict`)
landed.  All 45 new tests are GREEN after.  Total file count:
17 (A1 baseline) + 45 (new) = 62 tests, comfortably above the plan's
>= 20 target for C1.

Full unit suite: 758 passed / 9 skipped (was 713 / 9).  Sibling C2 agent
owns `tests/integration/test_resolver_protocol.py`; this commit stages
only `tests/unit/test_resolver_schema.py`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Records the two commits that landed C1 (schema-dataclass unit-test
expansion) and C3 (comma-in-marker regression fixture):

- 0c4a11a — `feat(resolver-schema): add LockedRequirement.from_lockfile_dict`
- de3edea — `test(resolver-schema): expand unit suite ...`

C1's `test_resolver_schema.py` count is now 62 (17 baseline + 45 new),
well above the plan's >= 20 target.  Full unit suite 758 passed / 9
skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…main

The wave-B rewrite of pipenv/resolver/main.py placed
`from pipenv.resolver.schema import ...` at the top of `_main`, BEFORE
the `_ensure_modules()` call that adds `pipenv_parent` to `sys.path`.
That ordering broke every production invocation of the subprocess: the
parent invokes `<project_venv_python> /path/to/pipenv/resolver/main.py
--request-file ... --response-file ...`, but the project venv has no
`pipenv` on its `sys.path` until the bootstrap runs. The schema import
therefore raised `ModuleNotFoundError: No module named 'pipenv'` and
`pipenv lock` / `pipenv install` failed in every fresh-venv scenario
(i.e. every integration test on this branch).

The wave-B unit smoke (tests/unit/test_resolver_protocol_smoke.py)
masked this by invoking the resolver via `python -m pipenv.resolver.main`
from a process that already had `pipenv` on `sys.path` — `-m` bypasses
the bootstrap requirement.

Fix: call `_ensure_modules()` as the very first statement of `_main`,
then perform the schema import the normal way. Delete the now-redundant
second `_ensure_modules()` call from Stage 3.

Surfaced while wiring up the T_F.3 C2 integration test (golden JSON
wire-shape canary), which exercises the production code path that the
unit smoke does not.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…T_F.3 C2)

Integration test that locks a minimal Pipfile (`pytz==2024.1` +
`six==1.16.0` — pure-Python, no transitive deps, frozen versions) via
`pipenv_instance_pypi` and asserts that the resolver subprocess's
`--request-file` and `--response-file` JSON payloads match committed
goldens under `tests/integration/fixtures/resolver_protocol/`. This is
the wire-shape canary: any PR that renames a `LockedRequirement` field
without bumping `SCHEMA_VERSION` will fail this test with a readable
diff.

Capture strategy. The pipenv CLI is invoked via `subprocess.run`, so
in-process monkey-patching of `tempfile.NamedTemporaryFile` cannot reach
the resolver. Instead we set `TMPDIR`/`TEMP`/`TMP` on the test env to a
dedicated capture directory and rely on the fact that the parent's
`_run_resolver_subprocess` creates its two tempfiles with the
distinctive prefixes `pipenv-request-` and `pipenv-response-` and never
deletes them (the cleanup block is a deliberate `pass` per Q10 — request
stays readable post-mortem). The test globs the capture dir for those
prefixes after `pipenv lock` returns.

Normalisation. Two fields are non-deterministic and are redacted before
comparison:
  * `metadata.parent_pid` — the parent pipenv pid; replaced with 0.
  * `metadata.pipenv_version` — empty in a working-tree checkout, a
    real version on a packaged install; replaced with "<redacted>".
On the response side, the `locked` array is sorted by name because
pip's resolver returns entries in resolution order, which is not stable
run-to-run.

Fixture-regen workflow. Setting `PIPENV_REGEN_PROTOCOL_FIXTURES=1`
writes the captured (and normalised) JSON back to the golden files and
`pytest.skip`s, so the maintainer can review the resulting `git diff`
before committing. Use case: a deliberate schema-version bump or
field-shape change.

  PIPENV_REGEN_PROTOCOL_FIXTURES=1 \
      pytest tests/integration/test_resolver_protocol.py -v

The captured response file organically pins the comma-in-marker case
(`six` advertises `"python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'"`),
providing incidental integration-level coverage of the C3 regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Record the JSON wire-shape integration test under task C2: capture
strategy via TMPDIR/TEMP/TMP redirect, normalisation rules for
parent_pid + pipenv_version + locked-array order, regen-flag workflow,
and the boundary-crossing fix to pipenv/resolver/main.py that
unblocked every integration test on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T_F.3 (typed resolver subprocess schema migration) landed on
maintenance/code-cleanup-phase3-resolver-typed-schema-2026-05 across
4 waves: A (foundation), B (3-way parallel rewrite), C (3-way parallel
tests + news), D (this entry).

Final state on the phase-3 branch:
- 758 unit tests pass (was 677 baseline pre-T_F.3 = +81 new tests)
- JSON wire-shape integration test pins both --request-file and
  --response-file with PIPENV_REGEN_PROTOCOL_FIXTURES regen branch
- pipenv lock smoke produces byte-identical lockfiles via both
  subprocess and PIPENV_RESOLVER_PARENT_PYTHON=1 in-process paths
- Acceptance greps clean: zero Entry.get_cleaned_dict, zero
  format_requirement_for_lockfile, zero PIPENV_RESOLVER_PYTHON_VERSION/
  PIPENV_EXTRA_PIP_ARGS/PIPENV_SITE_DIR references, zero dead argv
  flags in pipenv/resolver/main.py
- Re-export shim at pipenv/resolver/__init__.py keeps every legacy
  import path working through the migration

Out of scope (intentionally deferred): T_F.4 in-process branch fold,
wall-clock-timeout enforcement, Diagnostics.resolver_log population,
pluggable resolver backends (uv etc).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
actions/checkout@v4→v5, actions/setup-python@v4/v5→v6, and
actions/upload-artifact@v4→v5 to clear the Node.js 20 deprecation
warnings GitHub will enforce starting June 2nd, 2026.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Design-only document specifying T_F.5 architecture: a `Backend`
protocol with pip/uv implementations under `pipenv/resolver/backends/`,
a name-keyed registry, dispatch order (CLI > env > Pipfile > default),
single-`Pipfile.lock` output with `_meta.resolver_backend`
discriminator, system-uv (no vendoring) posture, and an 8-task
execution plan (T_F.5.1 .. T_F.5.8).

Resolves the four open questions from initiative-f-typed-design.md §6a
and surfaces 10 numbered sign-off questions gating T_F.5 execution.
Maps the WIP origin/uv-backend (~969-line pipenv/utils/uv.py) to the
post-typed-schema shape; the port shrinks to ~450 lines once the typed
schema handles the dict shuttling the WIP did by hand.

No production code touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add modernization-plan entries for T_F.5a (design, completed - awaits
sign-off) and T_F.5 (execution, blocked on sign-off). T_F.5a points at
the new docs/dev/initiative-f-backends-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…resolve_for_pipenv (T_F.4)

The PRD acceptance criterion for Initiative F is "one resolver
implementation, two thin adapters". T_F.3 left the resolver with two
near-duplicate plumbing chains:

* Subprocess entry (``pipenv/resolver/main.py:_main``) ran the marker
  patch, called ``resolve_packages``, wrapped the result in a typed
  ``ResolverResponse``, and emitted exit codes based on the variant.
* In-process debug bypass
  (``PIPENV_RESOLVER_PARENT_PYTHON=1``, ``pipenv/utils/resolver.py``)
  did the same marker patch (via a separate context manager), called
  the same ``resolve_packages``, then normalised the locked list and
  let exceptions propagate.

This commit folds the shared logic onto a single function
``pipenv.resolver.core.resolve_for_pipenv(request) -> ResolverResponse``
which:

* applies the python_marker_override via a context manager (restores
  pip's vendored ``default_environment`` on exit — critical for the
  in-process branch which runs inside the parent interpreter);
* dispatches to ``resolve_packages`` via ``sys.modules`` so existing
  stub-injection patterns (``mock.patch("pipenv.resolver.resolve_packages")``,
  ``pipenv.resolver.main.resolve_packages = stub``, etc.) all keep
  working;
* normalises mixed ``LockedRequirement``/dict results into a uniform
  typed shape;
* NEVER raises — every outcome is captured in the discriminated
  ``response.result`` (``ResolverSuccess`` / ``ResolutionError`` /
  ``InternalError``) so both adapters dispatch the same way.

Adapters are now thin:

* ``pipenv/resolver/main.py:_main`` reads the request file, applies
  the resolver-process env vars, calls ``resolve_for_pipenv``, writes
  the response file, picks an exit code on ``result.kind``. The
  duplicate ``_apply_python_marker_override`` helper is gone.
* ``pipenv/utils/resolver.py:_resolve_in_process`` (new ~28-line
  helper) calls ``resolve_for_pipenv`` and dispatches on
  ``result.kind`` to either return locked entries or raise
  ``ResolutionFailure`` / ``RuntimeError``. The
  ``PIPENV_RESOLVER_PARENT_PYTHON`` arm of ``venv_resolve_deps`` is
  now a single delegating line; the orphaned
  ``from pipenv import resolver`` import is removed.

The wire schema is untouched. ``request.metadata.deadline_seconds``
and ``Diagnostics.resolver_log`` are reserved for T_F.6 / T_F.7 — the
new unified path leaves room for both without enforcing either today.

Validation:
- ``python -m pytest tests/unit/`` 764 pass (758 pre-fold + 6 new
  ``test_resolver_core.py`` tests covering success / ResolutionFailure
  → ResolutionError / unexpected → InternalError / marker override
  applied + restored).
- ``python -m pytest tests/integration/test_resolver_protocol.py``
  passes (the JSON wire-shape canary).
- Manual smoke: ``pipenv lock`` on a tiny Pipfile produces
  byte-identical lockfiles via both the default subprocess path and
  ``PIPENV_RESOLVER_PARENT_PYTHON=1``.

Files changed:
- ``pipenv/resolver/core.py`` (new): ``resolve_for_pipenv`` + helpers
- ``pipenv/resolver/main.py``: subprocess adapter shrinks; deletes
  ``_apply_python_marker_override``
- ``pipenv/utils/resolver.py``: in-process adapter
  (``_resolve_in_process``) replaces inline branch; drops orphan
  ``resolver`` import
- ``tests/unit/test_resolver_core.py`` (new): unit coverage for the
  fold target

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Records the in-process / subprocess fold (commit 2292104) as
completed: new `pipenv/resolver/core.py` with single
`resolve_for_pipenv` entry; subprocess and in-process branches both
reduced to thin adapters; 764/764 unit tests + integration golden
test green; byte-identical lockfiles via both paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ine_seconds (T_F.6)

Replaces the reserved-but-unenforced RequestMetadata.deadline_seconds
slot (T_F.3 design decision 11) with a fully wired enforcement path:

* _resolve_deadline_seconds(project) resolves the deadline with
  precedence: Pipfile [pipenv] resolver_timeout_seconds > env-var
  PIPENV_RESOLVER_TIMEOUT_S > default 1800s.
* _build_resolver_request stamps the resolved deadline on every
  ResolverRequest.metadata.deadline_seconds, so the wire envelope
  carries the same value the parent uses for subprocess.wait(timeout=).
* resolve(..., deadline_seconds=N) accepts the request-carried deadline
  and prefers it over the project setting; the existing
  subprocess.TimeoutExpired path now reads from the request.
* In-process branch (PIPENV_RESOLVER_PARENT_PYTHON=1) gets a soft
  signal.SIGALRM guard via a new _wall_clock_deadline context manager
  in pipenv/resolver/core.py. On expiry, resolve_for_pipenv returns an
  InternalError variant whose message names the elapsed deadline.
  Windows skips the in-process guard (no SIGALRM); the subprocess path
  remains the production enforcement path on all platforms.
* User-facing timeout error message now names BOTH the env var and the
  new Pipfile setting so a user with a legitimately long resolve knows
  how to extend it from either side.

Behaviour change is user-visible: previously-hanging installs will now
die with a structured ResolutionFailure after the deadline.

Test plan (7 new tests in tests/unit/test_resolver_timeout.py):
* subprocess TimeoutExpired -> ResolutionFailure naming both overrides
* default deadline falls back to PIPENV_RESOLVER_TIMEOUT_S
* Pipfile setting wins over env-backed default
* invalid Pipfile values fall back
* _build_resolver_request stamps metadata.deadline_seconds
* resolve() honours the request-carried deadline
* in-process branch returns InternalError on SIGALRM-mediated timeout

Existing test_resolver_regressions.py timeout tests continue to pass.
Full unit suite green (780 passed, 9 skipped).

Note: shared files (pipenv/resolver/core.py, pipenv/utils/resolver.py)
also contain T_F.7's Diagnostics.resolver_log work which landed
concurrently on the same branch; T_F.6 sections are tagged with
"--- T_F.6 BEGIN/END ---" comments for reviewer clarity. T_F.7's
tests/unit/test_resolver_diagnostics.py will land in a separate
commit by the T_F.7 agent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the T_F.6 wiring landed in 165bdb2 by adding the in-process
branch's wall-clock deadline guard to pipenv/resolver/core.py:

* _wall_clock_deadline(deadline_seconds) — new context manager that
  installs a signal.SIGALRM handler raising TimeoutError on expiry and
  restores the previous handler on exit. No-op on Windows (no SIGALRM),
  non-main thread, or when deadline is None/<=0.
* resolve_for_pipenv now wraps the resolve in _wall_clock_deadline so
  the PIPENV_RESOLVER_PARENT_PYTHON=1 debug branch enforces the same
  request.metadata.deadline_seconds the subprocess branch uses.

The previous T_F.6 commit had this change locally but pre-commit
stash/restore dropped it from the staged tree before the commit landed.
The 7-test test_resolver_timeout.py suite (including the
test_in_process_deadline_returns_internal_error SIGALRM case) now
exercises both paths end-to-end.

Sibling T_F.7's _BoundedListHandler / _capture_resolver_log helpers
also ride along in this commit — they were already in the working
tree from concurrent agent work and the file diff couldn't be
cleanly split without git stash (disallowed by harness rules).
T_F.7 will wire those helpers into resolve_for_pipenv in their own
follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the T_F.6 entry to the modernization plan documenting the
wall-clock timeout enforcement landed in 165bdb2 + e550e7f.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…olve records (T_F.7)

Adds the test suite for the structured logging capture that lives in
pipenv/resolver/core.py and pipenv/utils/resolver.py.  The implementation
itself (_BoundedListHandler, _capture_resolver_log context manager,
_surface_resolver_log verbose-mode printer) landed in sibling T_F.6's
two commits (165bdb2, e550e7f) — sibling agents ran concurrently on
the same files and T_F.6's commits incorporated the T_F.7 helpers.

The 9 new tests pin down the contract that T_F.3 design Q9 / §8 left
unverified when it reserved-but-emptied Diagnostics.resolver_log:

* Logger capture: records emitted on the ``pipenv`` logger and on
  ``pip._internal.resolution`` land on response.diagnostics.resolver_log.
* Record format: ``[LEVELNAME] message`` strings (one per record, no
  embedded newlines from the formatter side).
* Empty path: a silent resolve yields an empty tuple — the T_F.3
  reserved-empty default still holds when nothing logs.
* Handler hygiene: the capture handler is removed and the original
  logger level restored on exit, even when the resolve raises.
* Volume bound: a 500-record cap with a \"... (N records elided)\"
  sentinel protects against a runaway logger.
* Diagnostics typing: the resolver_log field stays a tuple on the
  frozen dataclass and survives JSON round-trip through
  to_json_dict / from_json_dict.

Stderr behaviour in non-verbose mode is unchanged — this is purely
additive structured-data on the typed response envelope, complemented
by the existing stderr stream (per Q9).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T_F.7 (populate Diagnostics.resolver_log with structured resolve records)
landed in commit 6682858 (test suite) plus the helpers that rode along
in sibling T_F.6's two earlier commits (165bdb2, e550e7f).  Adds the
T_F.7 entry to the Initiative F section of the modernization plan with
the standard description / validation / files / log shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ce/code-cleanup-phase3-resolver-typed-schema-2026-05
…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>
@matteius matteius marked this pull request as ready for review May 12, 2026 17:31
matteius and others added 3 commits May 12, 2026 14:14
… 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>
…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>

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 modernizes Pipenv’s resolver subsystem by migrating the parent↔subprocess protocol to a typed JSON schema and consolidating resolver execution behind a single canonical driver. It also adds wall-clock timeout enforcement and structured resolver diagnostics capture, with extensive new unit/integration coverage and pinned golden fixtures to keep the protocol stable over time.

Changes:

  • Introduces pipenv/resolver/ as a package with a typed ResolverRequest/ResolverResponse schema and a unified resolve_for_pipenv() driver used by both subprocess and in-process adapter paths.
  • Adds T_F.6 wall-clock timeout plumbing end-to-end (Pipfile/env/default precedence → request metadata → subprocess/in-process enforcement).
  • Populates structured resolver diagnostics (Diagnostics.resolver_log) and adds protocol/schema tests + golden-fixture integration canary.

Reviewed changes

Copilot reviewed 56 out of 56 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
pipenv/resolver/schema.py New typed dataclass schema for resolver request/response envelopes and lock-entry representation.
pipenv/resolver/core.py New unified resolver driver (resolve_for_pipenv) plus timeout guard + structured log capture.
pipenv/resolver/main.py New subprocess entry point reading/writing typed JSON request/response files.
pipenv/resolver/__init__.py Re-export shim for the resolver package surface.
pipenv/resolver.py Removes the legacy monolithic resolver module in favor of the package.
pipenv/utils/locking.py Removes legacy lockfile formatter; prepare_lockfile now consumes LockedRequirement (with transitional dict fallback).
pyproject.toml Updates the pipenv-resolver console-script entry to point at pipenv.resolver.main:main.
tests/unit/test_utils.py Ports legacy lockfile-entry formatter tests to LockedRequirement.from_install_requirement + adds typed prepare_lockfile smoke tests.
tests/unit/test_resolver_schema.py New schema-focused unit tests: invariants, JSON round-trips, dispatch, golden parity, version mismatch, VCS coverage, comma-in-marker regression.
tests/unit/test_resolver_core.py New tests for unified driver behavior (success, resolution error, internal error, marker override).
tests/unit/test_resolver_parent_dispatch.py New parent-side dispatcher tests (request building, argv shape, structured-response dispatch).
tests/unit/test_resolver_protocol_smoke.py New subprocess protocol smoke tests (stubbed success + schema mismatch + optional live network test).
tests/unit/test_resolver_timeout.py New timeout enforcement tests (subprocess timeout → structured failure, precedence, request stamping, in-process SIGALRM guard).
tests/unit/test_resolver_diagnostics.py New diagnostics capture tests (format, cap, handler cleanup, propagation behavior, JSON survivability).
tests/unit/test_resolver_regressions.py Removes a regression test tied to deleted legacy functions, replaced by schema-level coverage.
tests/unit/test_dependencies.py Removes legacy Entry-based tests, replaced by typed schema tests/fixtures.
tests/unit/test_locking_no_mutation.py Updates resolver stub shape to return typed LockedRequirement results.
tests/unit/test_core.py Updates documentation text referencing the canonical formatter.
tests/unit/fixtures/resolver_schema/format_requirement_for_lockfile/*.json Adds/updates golden snapshots for parity gating of typed lockfile dict output.
tests/unit/fixtures/resolver_schema/entry_get_cleaned_dict/*.json Adds/updates golden snapshots for parity gating of subprocess-side legacy shapes.
tests/integration/test_resolver_protocol.py New integration canary capturing and pinning resolver request/response wire JSON with regen workflow.
tests/integration/fixtures/resolver_protocol/request.json Golden request envelope fixture (normalized).
tests/integration/fixtures/resolver_protocol/response.json Golden response envelope fixture (normalized).
news/T_F.3.behavior.rst News fragment for structured conflict output.
news/T_F.6.behavior.rst News fragment for resolver timeout enforcement behavior change.
docs/dev/modernization-plan.md Updates the modernization plan with completed phases and implementation details.
docs/dev/initiative-f-execution-plan.md Updates execution-plan status/log with completed waves and notes.
.github/workflows/ci.yaml Updates GitHub Actions versions used in CI.
.github/workflows/pypi_upload.yml Updates GitHub Actions versions used in release workflow.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pipenv/resolver/main.py
Comment thread pipenv/resolver/schema.py Outdated
Comment thread tests/unit/test_utils.py
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
… not resolver sub-package

Agent-Logs-Url: https://github.com/pypa/pipenv/sessions/e754d1f7-8ed9-42e9-8fdc-46a2c30157e9

Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
…ct_url instead of duplicate lr.file

Agent-Logs-Url: https://github.com/pypa/pipenv/sessions/495a8f65-b59e-4846-b0a8-39ddf4b4013f

Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
@matteius matteius requested a review from oz123 May 12, 2026 22:19
…ce/code-cleanup-phase3-resolver-typed-schema-2026-05
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