docs/dev/modernization-prd.md
| Field | Value |
|---|---|
| Status | Draft |
| Owners | @matteius, @oz123 |
| Created | 2026-05-12 |
| Branch | maintenance/code-cleanup-2026-05 (rolling) |
Pipenv's internal architecture grew organically across multiple eras of the
project: the early Pipfile-as-config days, the requirementslib /
requirements-parser vendored era, the post-vendor inlining era, and the
current resolver-modernization era driven by frequent pip bumps. Each era
left load-bearing modules in place, and the seams between them have
calcified. The codebase still works and ships, but the cost-per-change has
crept up: small bugs touch many files, new features bottleneck on a handful
of god objects, and pip-version bumps require manual reconciliation work
that should be local.
This PRD captures six structural pain points and proposes incremental, behaviour-preserving moves against each. It is not a rewrite plan. Every initiative is decomposed into PRs small enough to land independently, with the existing test suite as the safety harness. Initiatives can be paused, reordered, or abandoned without leaving the tree in a worse state than they found it.
Pipfile workflow or breaking backwards compatibility for
existing users.pipenv/patched/ or pipenv/vendor/.pip.The six initiatives are sequenced into four waves. Wave order is chosen to front-load low-risk wins that demonstrate the discipline and to defer invasive work until the supporting cleanup is done.
Current state. pipenv/utils/internet.py and pipenv/utils/fileutils.py
overlap on URL/path concerns. is_valid_url is defined identically in both
modules. Callers import from whichever was nearest when the code was
written. Other small overlaps (is_file_url, scheme parsing, path
normalisation) follow the same pattern.
Target state. One canonical URL utility module and one canonical path utility module, with no functional duplication between them. Imports point to one location per concept.
Approach.
internet.py and fileutils.py.internet.py for URL/scheme concerns; fileutils.py for filesystem
path concerns).Acceptance criteria.
import statement in pipenv/ (excluding patched/, vendor/)
reaches into both internet and fileutils for overlapping concepts.Estimated effort. 2–3 PRs, ≤ 200 lines each.
Current state. Several modules under pipenv/utils/ (notably
requirementslib.py, requirements.py, and the URL/path-overlapping
helpers in fileutils.py) are inlined copies of code that used to be
vendored. They are no longer reconciled with upstream, but they also
haven't been refactored to project style. This is the worst-of-both-worlds
state: we carry the upstream complexity without the upstream maintenance.
Target state. Every module clearly identifies as either project-owned
(refactor freely; project conventions apply; tests are owned) or vendored
(lives under pipenv/vendor/, do not edit). The middle state is eliminated.
Approach.
pipenv/vendor/,
stop modifying), or delete (no live callers — remove).CONTRIBUTING.md / docs/dev/ to state the policy: code lives
either under pipenv/utils/ (owned, refactor freely) or under
pipenv/vendor/ (do not touch). There is no third category.Acceptance criteria.
pipenv/utils/ has a clear owner decision.Estimated effort. 1 audit doc PR + 3–5 execution PRs.
Current state. pipenv/routines/install.py, update.py,
uninstall.py, and several siblings expose top-level routine functions
that accept long, mostly-keyword parameter lists, then thread subsets of
those parameters through their child calls. The parameter set is the
de-facto interface between top-level commands and the resolver/lock
machinery, but it is spread across signatures rather than collected into a
named type. Concretely (in pipenv/routines/install.py): do_install at
line 353 takes 17 parameters (one positional project plus 16 keyword);
handle_new_packages takes 11; handle_lockfile takes 10; do_init
takes 10. Adding or removing a flag requires updating every signature in
the chain, and a reader cannot enumerate the routine "state" without
visiting each call site.
Target state. A typed, immutable-by-default context object (likely a
@dataclass(frozen=True) or NamedTuple) with a documented shape. Mutation
points become explicit ("here is where we produce a new context with this
field updated"). Call sites become greppable.
Approach.
state. Document
them.RoutineContext (or similar) dataclass alongside the
existing dict — populated from the dict at the top of each routine.Acceptance criteria.
state dict.Estimated effort. ~6 PRs (one per routine + scaffolding + cleanup).
Project god classCurrent state. pipenv/project.py is 1850 lines. Project mixes:
Pipfile parsing/serialization, lockfile I/O, virtualenv discovery and
location resolution, source/index management, settings, environment variable
handling, hash computation, and assorted URL/name helpers. It is passed as
the first argument to nearly every routine and utility, which makes it the
de-facto application context.
Target state. Project becomes a thin coordinator that composes
focused subsystems:
Pipfile — read/write/parse of Pipfile (some scaffolding already in
pipenv/utils/pipfile.py).Lockfile — read/write/parse of Pipfile.lock.Sources — index URL resolution, source list management.VenvLocator — virtualenv discovery and creation.Settings — environment variable / [pipenv] section resolution.Each subsystem owns its data and exposes a small, documented surface.
Project holds references to them but delegates.
Approach (per subsystem).
Project to extract (start with
Sources — smallest and most self-contained).Project instantiate the new class and delegate via thin
wrappers — preserving the existing Project.foo() call sites without
change.project.foo() to
project.sources.foo() in a follow-up PR.Acceptance criteria.
pipenv/project.py is materially smaller (target: ≤ 800 lines).Project changes during
the extraction (deprecation removal comes in a separate release).Estimated effort. 5 extractions × ~3 PRs each = ~15 PRs. Sequenceable over multiple release cycles.
Current state. Three modules cover overlapping requirement-parsing
concerns: pipenv/utils/requirementslib.py (740 lines, inlined former
vendored package), pipenv/utils/dependencies.py (1515 lines, newer core
logic), and pipenv/utils/requirements.py (395 lines). It is not
predictable from a function name which module it lives in.
Target state. One module owns the requirement model (parse, normalise, serialise, hash); other modules consume it through a documented API. Cross- module helpers that no longer fit the boundary are either folded in or moved out to their natural home.
Approach.
requirementslib.py) being done
first — we need a clean decision on what is owned vs vendored before
reshaping.dependencies.py (the
newest and most actively maintained of the three).requirementslib.py and requirements.py
into the canonical location, one logical group at a time. Each PR
moves one group, updates callers, runs tests.Acceptance criteria.
requirementslib.py and/or requirements.py are eliminated or reduced
to documented compatibility shims.Estimated effort. ~8 PRs, gated on Initiative B.
Current state. pipenv/resolver.py (top-level, 572 lines) wraps
pipenv/utils/resolver.py (1607 lines), and the top-level module exists in
part because pipenv invokes itself as a subprocess for resolution
(pipenv-resolver). State crosses the subprocess boundary via JSON
serialization of project metadata and arguments. Several functions exist in
two flavours to serve the in-process and out-of-process call paths.
Target state. One resolver implementation. The in-process and subprocess entry points are thin adapters around it. The serialization boundary has a single, typed schema (a dataclass round-tripped to JSON, not an ad-hoc dict).
Approach.
ResolverRequest / ResolverResponse pair
(dataclasses) and use them at the in-process boundary first. Subprocess
still uses the legacy ad-hoc format.Acceptance criteria.
pipenv/utils/resolver.py is materially smaller (target: ≤ 1200 lines
through duplicate elimination, not just relocation).Estimated effort. ~5 PRs. Gated on Initiative E (a clean requirement model makes the typed schema design straightforward).
Wave 1 (parallel-safe, ~5 PRs total)
├── Initiative A — URL/path utility consolidation
└── Initiative B — Inlined-vendor triage
Wave 2 (depends on nothing in Wave 1; can run in parallel)
└── Initiative C — Routine context type
Wave 3 (Initiative E depends on Initiative B; D is independent)
├── Initiative D — Project god-class decomposition
└── Initiative E — Requirement-model consolidation
Wave 4 (depends on Initiative E)
└── Initiative F — Resolver seam
A reasonable rolling cadence is to keep one Wave-3 initiative as the background project, one Wave-1 or Wave-2 PR as the foreground commit-of- the-week, and to defer Wave 4 until E is materially complete.
These are leading indicators, not contractual targets. Move in the right direction over the next several release cycles.
pipenv/ (excluding patched/, vendor/): down
meaningfully (~10–20%) once Waves 1–3 complete, primarily through
deduplication.project.py size: ≤ 800 lines.utils/resolver.py size: ≤ 1200 lines.requirement in the name: at most two, with one canonical.pip bump PR: lower (fewer hand-reconciled
surfaces). Track by sampling the last several pip-bump PRs against the
next several.| Risk | Mitigation |
|---|---|
| Refactor introduces regression in a code path the unit tests don't cover. | Identify coverage gaps in the integration suite before the refactor PR; add tests first. |
A god-class extraction breaks a third-party caller that imports Project.foo directly. | Preserve Project.foo() as a thin delegating wrapper for at least one release after the extraction. |
| Wave 3 work blocks the routine pipeline of bug fixes and pip bumps. | Wave-3 PRs land in small, independent slices. Bug fixes and pip bumps always take priority on the merge queue. |
| Resolver seam change introduces a subprocess-protocol incompatibility for in-flight installs across pipenv versions. | The typed schema is additive on the wire; old subprocess entrypoint stays valid for one release after the cut. |
| Initiative B (vendor triage) determines a module we expected to "adopt" is actually unsafe to touch (e.g. a security-critical inlined parser). | Recorded in the triage doc as "vendor → move under pipenv/vendor/". Do not adopt anything we can't confidently maintain. |
| Maintainer time evaporates mid-wave. | Every PR is independently revertible and independently useful. No initiative leaves the tree worse than it found it. |
pipenv/patched/ or pipenv/vendor/ (excluding moves
into pipenv/vendor/ under Initiative B).[pipenv] section eventually move to [tool.pipenv] in
pyproject.toml? Not in scope for this PRD, but it should not be
precluded by any refactor here.Project API with proper type
hints as part of Initiative D? Cheap if done during extraction, expensive
if done later.