#14945·pnpm

Python in a real monorepo: eight popular pnpm+Python repos, none of them install

Author: zkochanCreated Sep 16, 2026Updated Sep 17, 2026
Labelstype: bugtype: featurearea: lockfilearea: monorepoarea: resolution

Summary

I tried to migrate eight popular open-source pnpm monorepos that also contain Python to python.enabled: true, to find out what pnpm v12 still needs before a real Python monorepo can adopt it.

None of the eight installs. Each one fails for a different reason, and every repo hits at least three separate gaps. This issue records what blocked each repo, with minimal reproductions, plus two correctness bugs and two performance findings found along the way.

Tested with pacquet at 611531adfe (pnpm v12.4.2), CPython 3.14.7, Linux x86_64.

Repos tested

All are pnpm workspaces with Python in the same repo:

Repo Stars pyproject.toml First failure
langgenius/dify 155k 40 Valid PyPI wheel rejected (mysql-connector-python)
apache/airflow 46k 141 Workspace member resolved from PyPI
getsentry/sentry 44k 1 Source-only distribution (python-u2flib-server)
gradio-app/gradio 43k 3 dynamic = ["dependencies"]
PostHog/posthog 39k 8 requires-python = "==3.13.13"
zulip/zulip 25k 1 git requirement (talon-core)
marimo-team/marimo 22k 2 Source-only distribution (lzstring)
Skyvern-AI/skyvern 23k 2 requires-python = ">=3.11,<3.14"

Method: for each repo I copied every pyproject.toml into an isolated workspace at its original relative path, added python: {enabled: true}, and ran pnpm install. This keeps discovery and manifest parsing faithful while leaving the npm side out of it.


Blockers

1. No workspace member linking, and a workspace name is silently taken from PyPI

Status: Addressed in pnpm v12 by https://github.com/pnpm/pnpm/pull/14953, merged on September 16, 2026 (UTC), as d6b0ef86d1be6065815b52552613e795eb9519a1.

pnpm install reads [tool.uv.sources] and installs a requirement that names a project in this repository from that project, for workspace = true and for path sources, editable or not. A member inherits the table its workspace root declares. A requirement that names a project the workspace declares, without saying where it comes from, is refused rather than taken from the index, so the substitution this item describes cannot happen quietly. Git and URL sources followed in https://github.com/pnpm/pnpm/pull/14983.

Update written by an agent (Claude Code, claude-opus-5).

This is the single biggest gap, and the one that defines a monorepo.

tool.uv.sources is ignored entirely, so a dependency on a sibling project in the same repo is resolved from the public index instead of from the local source tree. Occurrences: airflow 682, dify 38, posthog 2, marimo 1.

When the name does not resolve, you get a confusing failure:

$ pnpm install   # apache/airflow
Error:   × Python dependency resolution failed:
  │ Python project 0 depends on apache-airflow-task-sdk ∅

When the name does exist on PyPI, it is worse: pnpm silently installs the public package instead of the local one, with no warning.

toml
# packages/six/pyproject.toml
[project]
name = "six"
version = "99.0.0"
requires-python = ">=3.10"
dependencies = []
toml
# packages/app/pyproject.toml
[project]
name = "app"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["six"]

[tool.uv.sources]
six = { workspace = true }
$ pnpm install
Done in 451ms using pnpm v12.4.2

$ ls packages/app/.venv/lib/python3.14/site-packages/
six-1.17.0.dist-info   six.py       # the public PyPI six, not the workspace one

That is a dependency-confusion path: any internal Python package name that is registered on PyPI, now or later, is substituted without a diagnostic. For npm, pnpm links the workspace package. Python needs the same, plus tool.uv.sources-style path/editable sources.

2. The project's own package is never installed, so you cannot run the project

Status: Addressed in pnpm v12 by https://github.com/pnpm/pnpm/pull/14959, merged on September 16, 2026 (UTC), as 27e730f907abb37023f6224fa727498726c8257d.

pnpm install installs the project's own package into its environment from its source tree, so pnpm exec python -c "import <project>" works after an install.

Update written by an agent (Claude Code, claude-opus-5).

After a successful install you have the dependencies but not the code:

$ pnpm install                 # marimo-team/marimo, dev group, real manifest
Done in 29.5s using pnpm v12.4.2

$ pnpm exec python -c "import marimo"
ModuleNotFoundError: No module named 'marimo'

uv sync and pip install -e . install the project itself. Without it, pnpm install cannot replace the existing setup step in any of the eight repos.

3. The lockfile is resolved for one platform and one Python minor

Status: Addressed in pnpm v12 by https://github.com/pnpm/pnpm/pull/14970, merged on September 16, 2026 (UTC), as a9ee095ca7b4a58c5071f382d2bb89d16686fd85.

python.platforms and python.pythonVersions in pnpm-workspace.yaml say which platforms and Python versions one pylock.toml is resolved for, as target triples or the manylinux/musllinux spellings, and as minor or full versions. Left empty, an install locks for the platform and interpreter it runs on. A platform no wheel can satisfy is reported rather than locked. pnpm does not read uv's tool.uv.environments.

Update written by an agent (Claude Code, claude-opus-5).

pylock.toml pins exactly one wheel per distribution, for the machine that ran the install:

toml
environments = ["python_version == '3.14'"]
[[packages]]
name = "numpy"
version = "2.5.3"
[[packages.wheels]]
name = "numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl"

A committed lockfile therefore cannot serve Linux CI and macOS/Windows contributors at once, which every one of these repos needs. sentry already declares tool.uv.environments for exactly this reason. Universal, multi-target locking is listed as a deliberate limit in plans/PYTHON_SPIKE.md; this is the point at which it stops being optional.

4. No interpreter provisioning, and .python-version is ignored

Status: Implemented in pnpm v12 by https://github.com/pnpm/pnpm/pull/14973, merged as 162be7530642c19649be537920d98c394be96465, and https://github.com/pnpm/pnpm/pull/14986, merged as 338ac4d8b3ef837d8bfa39ca12011e724257e57a, both on September 16, 2026 (UTC).

Each project gets the first interpreter this machine has that its requires-python accepts, so airflow's 141 projects across 3.10 to 3.14 no longer need one interpreter between them. The nearest .python-version file, searched from the project up to the workspace root, asks for a version by name, so sentry's pin is read. Where no interpreter on the machine fits, pnpm installs one from python-build-standalone, the builds uv, rye, hatch and mise install too. It lands under <store>/v11/python/, shared by every project and repository on the machine, and is found there afterwards without reading the release, offline included. python.downloads: never keeps pnpm from installing any, and python.downloadUrl names a mirror.

A pin of a patch release that the latest python-build-standalone release has moved past installs another patch of that line, with a warning, rather than the exact one: installing the exact one needs metadata for older releases, which is a second source of truth. plans/PYTHON_SPIKE.md records the limit. Merged implementation does not imply a published release.

python.executable is a single workspace-wide path to an interpreter the user must already have.

$ pnpm install   # PostHog/posthog
Error:   × …/posthog requires Python ==3.13.13, but 3.14.7 was selected

$ pnpm install   # Skyvern-AI/skyvern
Error:   × …/packages/skyvern-ui requires Python >=3.11, <3.14, but 3.14.7 was selected

Four of the eight repos pin an interpreter pnpm will not fetch: posthog ==3.13.13, dify ~=3.12.0, skyvern <3.14, sentry via .python-version (3.13.1, not read). pnpm already manages Node.js runtimes through pnpm env; Python needs the equivalent. .python-version should be honored.

Related: because executable is workspace-global, a repo whose projects legitimately want different interpreters (airflow supports 3.10-3.14 across its 141 projects) has no way to express that.

5. No source distributions

Status: Implemented in pnpm v12 by https://github.com/pnpm/pnpm/pull/15009, merged on September 17, 2026 (UTC), as 854138e9fd3d36d2a246e795197590c2ed49679c.

A release whose wheels this interpreter accepts none of is installed by building the source distribution the index serves beside it, so sentry's python-u2flib-server and marimo's lzstring resolve. .tar.gz and .zip archives are read. A wheel always wins over the archive of its own release, so the archive is downloaded only where nothing else installs. The archive goes through pnpm's shared artifact store, which verifies the SHA-256 the index published, keeps the archive for later installs and replays it offline. pylock.toml pins it as a PEP 751 packages.sdist entry. Building a source distribution runs the release's own build backend. Approve it with pkg:pypi/<distribution>: true under allowBuilds.

A resolution that finds no version of a distribution now says which of the causes it was: no index publishes the distribution, its releases publish nothing this interpreter can install, or the project's own requirements select none of the versions offered. The releases involved are named.

What a release requires is read from the wheel its archive builds, because an archive declares it nowhere else, so the build runs while the project resolves, --lockfile-only included. pnpm builds an archive only with the interpreter running the install. Where that interpreter reaches the release too, its build answers for every environment the lockfile covers, and a replay elsewhere builds the archive again and is re-solved against what that build declares. Where only an environment other than the running one asks for the release, the install is refused. Two environments that would take different archives of one release are refused as well, because one lockfile entry cannot pin both. An archive is built again on every install rather than kept as the wheel it produced. plans/PYTHON_SPIKE.md records these limits. pnpm.io does not document archive builds or the pkg:pypi/ matcher yet. Merged implementation does not imply a published release.

Update written by an agent (Claude Code, claude-opus-5).

Registry wheels only. Two of the eight repos depend on a distribution that publishes no wheel at all:

  • sentry: python-u2flib-server>=5.0.0 — 15 sdists, 0 wheels on PyPI
  • marimo: lzstring>=1.0.4 — wheels stop at 1.0.3

Both surface as the same opaque message, which does not say the packages exist and only ship sdists:

Error:   × Python dependency resolution failed:
  │ Python project 0 depends on python-u2flib-server ∅

Even before sdist building lands, should distinguish "no such distribution", "no version matches the constraint", and "versions exist but publish no wheel this installer can use".

6. No git or URL requirements

Status: Addressed in pnpm v12 by https://github.com/pnpm/pnpm/pull/14983, merged on September 16, 2026 (UTC). pnpm install now supports PEP 508 Git requirements and HTTP(S) wheel URLs, including declarations inherited through [tool.uv.sources]. Git revisions, branches, tags, and subdirectories are supported. Lockfiles pin full Git commits and wheel SHA-256 hashes, and frozen offline installs replay cached sources. Installed packages record PEP 610 provenance.

Git dependencies and their build requirements require allowBuilds approval. Public HTTP wheel URLs require a SHA-256 pin. URL source archives and marker-, extra-, or group-conditioned uv source tables remain unsupported. This addresses item 6's Git forks and direct wheel requirements without claiming that the other migration blockers are resolved. Merged implementation does not imply a published release.

Update written by an agent (Codex, GPT-5).

zulip (4), posthog (2) and dify (1) all pin forks by git revision. When the source is declared in tool.uv.sources it is ignored, and pnpm fetches the name from PyPI:

$ pnpm install   # zulip/zulip, talon-core = { git = "…/zulip/talon.git", rev = "e87a64d" }
Error:   × Python index request for talon-core returned 404 Not Found

The direct-URL form at least fails honestly:

Error:   × direct URL Python requirements are not supported:
  │ zulint @ git+https://github.com/zulip/zulint.git@0a1b2c3

airflow also has an https:// wheel requirement (sphinx-airflow-theme).

7. dynamic metadata is rejected, and requirements.txt is silently ignored

Status: Addressed in pnpm v12 by https://github.com/pnpm/pnpm/pull/14981, merged on September 16, 2026. pnpm install now prepares dynamic version, dependency, optional-dependency, and Python-support metadata through the build backend, with a wheel-build fallback. It also discovers requirements.txt dependency projects and reads PEP 508 requirements with local includes. Unsupported pip directives produce contextual errors. A neighboring [project] table takes precedence.

Metadata preparation follows each project's selected interpreter and the existing allowBuilds policy. External path sources that were not discovered still require static metadata, and pnpm add does not edit dynamic metadata. This addresses item 7 without claiming that the full Gradio migration or the other blockers are resolved.

Update written by an agent (Codex, GPT-5).

$ pnpm install   # gradio-app/gradio
Error:   × pnpm Python integration requires static dependency metadata in pyproject.toml

gradio's two published packages both use dynamic = ["version", "dependencies", "optional-dependencies", "readme"], fed by hatch-requirements-txt from its 113 requirements*.txt files. Supporting either dynamic metadata (via the build backend's prepare-metadata hook) or plain requirements.txt files would unblock it.

Today a repo with only requirements.txt gets no feedback whatsoever:

$ ls            # requirements.txt, package.json, pnpm-workspace.yaml (python.enabled: true)
$ pnpm install
Done in 9ms using pnpm v12.4.2      # no venv, no lockfile, no warning

Enabling Python and having nothing happen should at least warn.

8. python.extras and python.groups are workspace-global, so they are unusable in a monorepo

Status: Addressed in pnpm v12 by https://github.com/pnpm/pnpm/pull/14990, merged on September 16, 2026.

Projects can select extras and groups in [tool.pnpm.python] in their own pyproject.toml. Each list independently overrides its workspace default. An omitted list inherits the default, and an empty list disables it. Workspace defaults skip names a project does not define; explicit project selections reject unknown names, including during production-only or development-only installs.

The companion documentation update is open at https://github.com/pnpm/pnpm.io/pull/934.

Update written by an agent (Codex, GPT-5).

Both settings apply to every discovered project, and any project that does not define the named group or extra is a hard error:

yaml
python:
  enabled: true
  groups: [dev, test]
Error:   × unknown Python dependency group: test     # project b has only `dev`
Error:   × unknown Python project extra: cli         # project b has only `web`

With airflow's 141 projects or dify's 40, the only usable value is the intersection across all of them. Group and extra selection needs to be per project, and a project that lacks a globally requested group should be skipped rather than fail.

9. No extra index URLs, overrides, or constraints

Status: Implemented in pnpm v12 by https://github.com/pnpm/pnpm/pull/14991, merged on September 16, 2026 (UTC).

python.extraIndexUrls searches additional Simple JSON indexes in listed order before python.indexUrl. The first index containing a distribution supplies its versions. Only a 404 falls through to the next index. Index credentials remain scoped to their configured URL paths, and cached responses are separated by credential identity.

python.overrides replaces matching version requirements and requested extras. python.constraints narrows permitted versions without adding dependencies. Both support PEP 508 registry requirements and markers. pnpm also reads tool.uv.override-dependencies and tool.uv.constraint-dependencies from the declared uv workspace root. Index settings and dependency rules are recorded in pylock.toml, checked for frozen installs, and applied during locked replay. Projects using these settings resolve locally when pnpr is configured.

HTML-only indexes remain unsupported, so this does not establish support for every PyTorch or private index. URL entries in overrides and constraints are rejected, and project rules do not apply to isolated build-backend dependencies. This addresses item 9's configuration and dependency rules without claiming that the other migration blockers are resolved. Merged implementation does not imply a published release.

The companion documentation update is open at https://github.com/pnpm/pnpm.io/pull/935, with passing checks and reviewer approval.

Update written by an agent (Codex, GPT-5).

  • extraIndexUrls is not a field: unknown field 'extraIndexUrls', expected one of enabled, executable, indexUrl, extras, groups. sentry needs a second index; so does any repo pulling CPU/CUDA PyTorch builds.
  • tool.uv.override-dependencies (dify, skyvern) and tool.uv.constraint-dependencies (skyvern) have no equivalent. pnpm has overrides for npm; Python needs the same escape hatch for broken transitive metadata.

Bugs

10. Valid PyPI wheels are rejected over the informational WHEEL Tag: field

Status: Fixed in pnpm v12 by https://github.com/pnpm/pnpm/pull/14992, merged on September 16, 2026 (UTC), as e747bceaf2c5f206a0a954174262255925cd1ce8.

The Tag: fields in *.dist-info/WHEEL are informational, and pnpm no longer requires them to equal the tags its filename carries. A wheel retagged after the build, such as the published mysql-connector-python, installs.

Update written by an agent (Claude Code, claude-opus-5).

This one is a genuine regression against pip and uv, and it is what stops dify.

$ pnpm install   # langgenius/dify
Error:   × Python inspect failed: …
  │ ValueError: wheel Tag fields do not match filename:
  │ mysql_connector_python-26.7.0-cp314-cp314-manylinux_2_28_x86_64.whl

crates/python-installer/src/host.py (inspect_wheel) requires the Tag: fields in *.dist-info/WHEEL to equal the tags parsed from the filename. The published mysql-connector-python wheel disagrees with itself:

$ python3 -c "import zipfile; print(zipfile.ZipFile('mysql_connector_python-26.7.0-cp314-cp314-manylinux_2_28_x86_64.whl').read('mysql_connector_python-26.7.0.dist-info/WHEEL').decode())"
Wheel-Version: 1.0
Generator: setuptools (83.0.0)
Root-Is-Purelib: false
Tag: cp314-cp314-linux_x86_64          # filename says manylinux_2_28_x86_64

pip installs it without complaint:

$ pip install --no-deps mysql_connector_python-26.7.0-cp314-cp314-manylinux_2_28_x86_64.whl
$ python -c "import mysql.connector; print(mysql.connector.__version__)"
26.7.0

The filename is what installers select on; the Tag: field is informational and is routinely left stale when a wheel is retagged after the build. Wheels that are renamed without rewriting WHEEL are common enough that enforcing equality blocks real,