Skip to content

Commit b38786c

Browse files
kevinpark1217aignas
authored andcommitted
fix(pypi): normalize extras in requirement strings per PEP 685 (#3588)
## Summary Extras parsed from requirement strings (e.g., from `requirements.txt`) were not being normalized, causing mismatches when evaluating PEP 508 marker expressions. For example, `sqlalchemy[postgresql-psycopg2binary]` would fail to resolve `psycopg2-binary` as a transitive dependency because the wheel METADATA marker expression `extra == "postgresql_psycopg2binary"` uses the underscore-normalized form (per PEP 685), while the extras set retained the original hyphenated form from the requirement string. ## Before ``` # requirements.txt sqlalchemy[postgresql-psycopg2binary]==2.0.36 # Parsed extras: ["postgresql-psycopg2binary"] # Marker evaluation: "postgresql-psycopg2binary" != "postgresql_psycopg2binary" -> MISS # Result: psycopg2-binary NOT included as a dependency ``` ## After ``` # requirements.txt sqlalchemy[postgresql-psycopg2binary]==2.0.36 # Parsed extras: ["postgresql_psycopg2binary"] (normalized) # Marker evaluation: "postgresql_psycopg2binary" == "postgresql_psycopg2binary" -> MATCH # Result: psycopg2-binary correctly included as a dependency ``` ## Changes - **`python/private/pypi/pep508_requirement.bzl`**: Apply `normalize_name()` to each extra during requirement parsing, consistent with how the package name is already normalized. - **`tests/pypi/pep508/requirement_tests.bzl`**: Updated existing test expectation for case normalization and added test case for hyphenated extras (`sqlalchemy[asyncio,postgresql-psycopg2binary,postgresql-asyncpg]`). - **`tests/pypi/pep508/deps_tests.bzl`**: Added `test_extras_with_hyphens_are_normalized` integration test confirming that dependencies gated behind hyphenated extras are correctly resolved. - **`CHANGELOG.md`**: Added entry under Unreleased > Fixed. Fixes #3587 --------- Co-authored-by: Ignas Anikevicius <240938+aignas@users.noreply.github.com> (cherry picked from commit 9fe42b1)
1 parent 36e59d5 commit b38786c

File tree

8 files changed

+58
-15
lines changed

8 files changed

+58
-15
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ END_UNRELEASED_TEMPLATE
6060
* (pypi) `pip_parse` no longer silently drops PEP 508 URL-based requirements
6161
(`pkg @ https://...`) when `extract_url_srcs=False` (the default for
6262
`pip_repository`).
63+
* (pypi) Extras in requirement strings are now normalized per PEP 685,
64+
fixing missing transitive dependencies when extras contain hyphens
65+
(e.g., `sqlalchemy[postgresql-psycopg2binary]`).
66+
([#3587](https://github.com/bazel-contrib/rules_python/issues/3587))
6367

6468
{#v1-8-4}
6569
## [1.8.4] - 2026-02-10

python/private/pypi/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ bzl_library(
255255
name = "pep508_deps_bzl",
256256
srcs = ["pep508_deps.bzl"],
257257
deps = [
258+
":pep508_env_bzl",
258259
":pep508_evaluate_bzl",
259260
":pep508_requirement_bzl",
260261
"//python/private:normalize_name_bzl",
@@ -265,6 +266,7 @@ bzl_library(
265266
name = "pep508_env_bzl",
266267
srcs = ["pep508_env.bzl"],
267268
deps = [
269+
"//python/private:normalize_name_bzl",
268270
"//python/private:version_bzl",
269271
],
270272
)

python/private/pypi/pep508_deps.bzl

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"""
1717

1818
load("//python/private:normalize_name.bzl", "normalize_name")
19+
load(":pep508_env.bzl", "create_env")
1920
load(":pep508_evaluate.bzl", "evaluate")
2021
load(":pep508_requirement.bzl", "requirement")
2122

@@ -155,8 +156,9 @@ def _resolve_extras(self_name, reqs, extras):
155156
return sorted(extras)
156157

157158
def _evaluate_any(req, extras):
159+
env = create_env()
158160
for extra in extras:
159-
if evaluate(req.marker, env = {"extra": extra}):
161+
if evaluate(req.marker, env = env | {"extra": extra}):
160162
return True
161163

162164
return False
@@ -167,11 +169,12 @@ def _add_reqs(deps, deps_select, dep, reqs, *, extras):
167169
_add(deps, deps_select, dep)
168170
return
169171

172+
env = create_env()
170173
markers = {}
171174
found_unconditional = False
172175
for req in reqs:
173176
for x in extras:
174-
m = evaluate(req.marker, env = {"extra": x}, strict = False)
177+
m = evaluate(req.marker, env = env | {"extra": x}, strict = False)
175178
if m == False:
176179
continue
177180
elif m == True:

python/private/pypi/pep508_env.bzl

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""This module is for implementing PEP508 environment definition.
1616
"""
1717

18+
load("//python/private:normalize_name.bzl", "normalize_name")
1819
load("//python/private:version.bzl", "version")
1920

2021
_DEFAULT = "//conditions:default"
@@ -215,9 +216,11 @@ def env(*, env = None, os, arch, python_version = "", extra = None):
215216

216217
def create_env():
217218
return {
218-
# This is split by topic
219+
# Per-variable normalization functions. Each entry maps a marker
220+
# variable name to a function (value) -> normalized_value.
219221
"_aliases": {
220-
"platform_machine": platform_machine_aliases,
222+
"extra": normalize_name,
223+
"platform_machine": lambda x: platform_machine_aliases.get(x, x),
221224
},
222225
}
223226

python/private/pypi/pep508_evaluate.bzl

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -300,12 +300,10 @@ def marker_expr(left, op, right, *, env, strict = True):
300300
left = left.strip("\"")
301301

302302
if _ENV_ALIASES in env:
303-
# On Windows, Linux, OSX different values may mean the same hardware,
304-
# e.g. Python on Windows returns arm64, but on Linux returns aarch64.
305-
# e.g. Python on Windows returns amd64, but on Linux returns x86_64.
306-
#
307-
# The following normalizes the values
308-
left = env.get(_ENV_ALIASES, {}).get(var_name, {}).get(left, left)
303+
# Normalize the literal value using per-variable normalization
304+
# functions. This handles platform aliases (e.g. arm64 -> aarch64)
305+
# and PEP 685 extra name normalization (e.g. db-backend -> db_backend).
306+
left = env.get(_ENV_ALIASES, {}).get(var_name, lambda x: x)(left)
309307

310308
else:
311309
var_name = left
@@ -314,7 +312,7 @@ def marker_expr(left, op, right, *, env, strict = True):
314312

315313
if _ENV_ALIASES in env:
316314
# See the note above on normalization
317-
right = env.get(_ENV_ALIASES, {}).get(var_name, {}).get(right, right)
315+
right = env.get(_ENV_ALIASES, {}).get(var_name, lambda x: x)(right)
318316

319317
if var_name in _NON_VERSION_VAR_NAMES:
320318
return _env_expr(left, op, right)

python/private/pypi/pep508_requirement.bzl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def requirement(spec):
4545
extras_unparsed, _, _ = extras_unparsed.partition("]")
4646
for char in _STRIP:
4747
requires, _, _ = requires.partition(char)
48-
extras = extras_unparsed.replace(" ", "").split(",")
48+
extras = [normalize_name(e) for e in extras_unparsed.replace(" ", "").split(",") if e]
4949
name = requires.strip(" ")
5050
name = normalize_name(name)
5151

tests/pypi/pep508/deps_tests.bzl

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,38 @@ def test_span_all_python_versions(env):
218218

219219
_tests.append(test_span_all_python_versions)
220220

221+
def test_extras_with_hyphens_are_normalized(env):
222+
"""Test that extras with hyphens in marker expressions are normalized.
223+
224+
When wheel METADATA uses hyphens in marker expressions
225+
(e.g., extra == "db-backend") but the extras from requirement parsing
226+
are already normalized (e.g., "db_backend"), the deps should still
227+
resolve because marker evaluation normalizes per PEP 685.
228+
229+
Args:
230+
env: the test environment.
231+
"""
232+
requires_dist = [
233+
"bar",
234+
'baz-lib; extra == "db-backend"',
235+
'qux-async; extra == "async-driver"',
236+
]
237+
238+
got = deps(
239+
"foo",
240+
extras = ["db_backend", "async_driver"],
241+
requires_dist = requires_dist,
242+
)
243+
244+
env.expect.that_collection(got.deps).contains_exactly([
245+
"bar",
246+
"baz_lib",
247+
"qux_async",
248+
])
249+
env.expect.that_dict(got.deps_select).contains_exactly({})
250+
251+
_tests.append(test_extras_with_hyphens_are_normalized)
252+
221253
def deps_test_suite(name): # buildifier: disable=function-docstring
222254
test_suite(
223255
name = name,

tests/pypi/pep508/requirement_tests.bzl

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ def _test_requirement_line_parsing(env):
2323
" name1[ foo ] ": ("name1", ["foo"], None, ""),
2424
"Name[foo]": ("name", ["foo"], None, ""),
2525
"name [fred,bar] @ http://foo.com ; python_version=='2.7'": ("name", ["fred", "bar"], None, "python_version=='2.7'"),
26-
"name; (os_name=='a' or os_name=='b') and os_name=='c'": ("name", [""], None, "(os_name=='a' or os_name=='b') and os_name=='c'"),
27-
"name@http://foo.com": ("name", [""], None, ""),
28-
"name[ Foo123 ]": ("name", ["Foo123"], None, ""),
26+
"name; (os_name=='a' or os_name=='b') and os_name=='c'": ("name", [], None, "(os_name=='a' or os_name=='b') and os_name=='c'"),
27+
"name@http://foo.com": ("name", [], None, ""),
28+
"name[ Foo123 ]": ("name", ["foo123"], None, ""),
29+
"name[extra-one,extra-two.three]==1.0": ("name", ["extra_one", "extra_two_three"], "1.0", ""),
2930
"name[extra]@http://foo.com": ("name", ["extra"], None, ""),
3031
"name[foo]": ("name", ["foo"], None, ""),
3132
"name[quux, strange];python_version<'2.7' and platform_version=='2'": ("name", ["quux", "strange"], None, "python_version<'2.7' and platform_version=='2'"),

0 commit comments

Comments
 (0)