Skip to content

🐛 fix(seed): keep console script names that are not version suffixes - #3451

Open
JingHao-Leon wants to merge 4 commits into
pypa:mainfrom
JingHao-Leon:fix-console-script-name-normalization
Open

JingHao-Leon wants to merge 4 commits into
pypa:mainfrom
JingHao-Leon:fix-console-script-name-normalization

Conversation

@JingHao-Leon

@JingHao-Leon JingHao-Leon commented Oct 19, 2026 •

Copy link
Copy Markdown

Thanks for contributing, make sure you address all the checklists (for details on how see development documentation)

  • ran the linter to address style issues (ruff check / ruff format, with the minimal config below)
  • wrote descriptive pull request text
  • ensured there are test(s) validating the fix
  • added news fragment in docs/changelog folder
  • updated/extended the documentation
  • ran tests on Python 3.9 and 3.15 — not available on this machine; see the note below
  • linked upstream issues beside any temporary interpreter overrides
  • kept the PR in draft until CI passes

What breaks

Seeding a wheel whose entry_points.txt declares a console_scripts entry whose name contains digits renames
the script. The worst case is python3-config: it is truncated to python, so the generated launcher replaces
the environment's python executable and the environment can no longer run anything.

$ ./env/bin/python -c "print(1)"
OSError: [Errno 8] Exec format error: './env/bin/python'

env/bin/python is a symlink into the base interpreter before seeding and a generated console script afterwards.
Activation scripts and pyvenv.cfg both point at that path.

declared name installed as
python3-config python, python3, python-3.13, python3.13
2to3 / 3to2 " ", " 3", "3.13"
flake8 flake, flake3, flake3.13
pip3, pip3.13 pip, pip3, pip3.13 (intended)

The seed packages themselves are unaffected, because pip/pip3/pip3.13 all fold onto pip as intended.

Root cause

src/virtualenv/seed/embed/via_app_data/pip_install/base.py, the _console_scripts property:

match = re.match(r"(.*?)-?\d\.?\d*", name)

re.match anchors only at the start of the string and (.*?) is lazy, so the first digit run anywhere in the
name is consumed as a version suffix and the rest of the name is discarded. The name is truncated at its first
digit rather than at a trailing version.

The fix

Anchor the match to the whole remainder of the name and require the base to start with a letter, so only a real
trailing version is folded onto its base name:

_VERSION_SUFFIX: Final = re.compile(r"(?P<base>[^\W\d]\w*?)-?\d+(?:\.\d+)*\Z")
...
match = _VERSION_SUFFIX.fullmatch(name)

pip3, pip3.13 and pip-3.13 still fold onto pip; python3-config, 2to3 and 3to2 now keep their names.

This only changes which name a script is written under. A wheel declaring pip and pip3 is unaffected, and no
downloaded file is executed as code — the name is used as a filename and embedded in the generated shebang, exactly
as before.

Before / after

Seeding a wheel that declares python3-config = example:main:

before:  python  python3  python-3.13  python3.13          # interpreter clobbered
after:   python  python3  python3.13  python3-config  python3-config3  python3-config-3.13  python3-config3.13

env/bin/python is a symlink again after seeding.

Tests

New tests extend the existing entry-points coverage in
tests/unit/seed/embed/test_bootstrap_link_via_app_data.py.

$ PYTHONPATH=$PWD/src python -m pytest tests/unit/seed/embed/test_bootstrap_link_via_app_data.py \
    -k "keep_names_that_are_not_version_suffixes or do_not_overwrite_the_interpreter or still_fold_version_suffixed"

before the fix: 4 failed, 1 passed
after  the fix: 5 passed

Whole seed unit suite plus the other unit directories, against the clone's src (not site-packages), compared to
the same subset on unmodified upstream main:

$ PYTHONPATH=$PWD/src python -m pytest tests/unit/seed tests/unit/test_util.py tests/unit/config \
    tests/unit/activation tests/unit/discovery -q

main (79537b3): 607 passed, 122 skipped
this branch:    612 passed, 122 skipped

+5, which are exactly the new tests; no existing test changed state.

Linting, with the minimal config for the older ruff available here (line length 120):

$ ruff check --config /tmp/ruff_min.toml <changed files>      # All checks passed!
$ ruff format --check --config /tmp/ruff_min.toml <changed files>   # 2 files already formatted

I could not run tox -e fix or tox run -e 3.9,3.15: this machine only has CPython 3.13.9, and the repository's
tox environments are not installed. I ran the test subset and the linters directly instead.

Follow-up: the folding test asserted on a private attribute

The first pre-commit run failed ruff with private-member-access on
test_seed_entry_points_still_fold_version_suffixed_names, which read installer._console_scripts. The test now
asserts on the scripts that appear in bin instead: one folded base regenerates pip, pip3, pip3.13 and
pip-3.13, whereas not folding would emit a name per declared alias. Same guarantee, no private access.

$ ruff check --config pyproject.toml <changed files>      # All checks passed!
$ ruff format --check --config pyproject.toml <changed files>   # 2 files already formatted

Fixes #3450

CI status

The upstream workflows for this head have not run. GitHub recorded all of them
with conclusion: action_required — the runs are blocked awaiting approval
(for workflows triggered from a fork, GitHub requires a maintainer to approve
them before they execute). The affected workflows are:

  • 🧪 check (.github/workflows/check.yaml)
  • 🔍 dependency review (.github/workflows/dependency-review.yaml)
  • 🔎 CodeQL (.github/workflows/codeql.yaml)

gh pr checks reports only the two checks that do not need approval — the Read
the Docs build and pre-commit.ci — and both of them pass. It does not report
the workflows listed above, so there is no upstream CI verdict to report. The
test and lint output above comes from local runs against the patched tree.

This change was prepared with the assistance of an AI coding agent.

JingHao-Leon and others added 4 commits October 19, 2026 18:03
The console_scripts name normalization matched a prefix with re.match and a
lazy (.*?), so the first digit run anywhere in a name was treated as a version
suffix and the rest of the name discarded. A wheel declaring python3-config
was seeded as python, replacing the environment's interpreter executable and
leaving the environment unusable; 2to3 and 3to2 collapsed to an empty base
name and produced scripts called " ", " 3" and "3.13".

Anchor the match to the whole remainder of the name and require the base to
start with a letter, so only a real trailing version such as pip3, pip3.13 or
pip-3.13 is folded onto its base name.
The folding test read the private _console_scripts mapping, which trips
ruff's private-member-access rule. Assert on the scripts that end up in
the bin directory instead: one folded base regenerates pip, pip3, pip3.13
and pip-3.13, while not folding would add a name per declared alias.
virtualenv names changelog fragments after the pull request that adds them
(3441.bugfix.rst for pypa#3441, 3435.bugfix.rst for pypa#3435, 3434.bugfix.rst for
pypa#3434), even when the pull request references a separate issue. This one is
pull request pypa#3451 and fixes issue pypa#3450, so the fragment is renamed from
3450.bugfix.rst to 3451.bugfix.rst. The content is unchanged.

This branch has not been deployed

No deployments
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.

Seeding a wheel with a console_scripts name containing digits truncates the name, and python3-config overwrites the environment's python

1 participant