Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog/3433.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Normalize extra names when resolving dependencies so that underscores and hyphens are treated equivalently (e.g.
``extras = snake_case`` now matches ``Provides-Extra: snake-case`` in wheel metadata) - by :user:`Fridayai700`.
11 changes: 6 additions & 5 deletions src/tox/tox_env/python/virtual_env/package/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,24 @@ def dependencies_with_extras_from_markers(
*,
available_extras: set[str] | None = None,
) -> list[Requirement]:
if available_extras is not None and extras:
normalized_extras = {canonicalize_name(e) for e in extras}
if available_extras is not None and normalized_extras:
normalized_available = {canonicalize_name(e) for e in available_extras}
if unknown := extras - normalized_available:
if unknown := normalized_extras - normalized_available:
available_str = ", ".join(sorted(normalized_available)) or "none"
unknown_str = ", ".join(sorted(unknown))
msg = f"extras not found for package {package_name}: {unknown_str} (available: {available_str})"
raise Fail(msg)
result: list[Requirement] = []
found: set[str] = set()
todo: set[str | None] = extras | {None}
todo: set[str | None] = normalized_extras | {None}
visited: set[str | None] = set()
while todo:
new_extras: set[str | None] = set()
for req, extra_markers in deps_with_markers:
if todo & extra_markers:
if req.name == package_name: # support for recursive extras
new_extras.update(req.extras or set())
new_extras.update(canonicalize_name(e) for e in (req.extras or set()))
else:
req_str = str(req)
if req_str not in found:
Expand Down Expand Up @@ -81,7 +82,7 @@ def _extract_extra_markers(req: Requirement) -> tuple[Requirement, set[str | Non
while marker:
extra = _get_extra(marker)
if extra is not None:
extra_markers.add(extra)
extra_markers.add(canonicalize_name(extra))
if new_markers and new_markers[-1] in {"and", "or"}:
del new_markers[-1]
marker = markers.pop(0) if markers else None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,34 @@ def test_validate_extras_normalization() -> None:
def test_validate_extras_no_available() -> None:
with pytest.raises(Fail, match=r"extras not found for package pkg: alpha \(available: none\)"):
dependencies_with_extras([], {"alpha"}, "pkg", available_extras=set())


def test_extras_underscore_hyphen_matching() -> None:
"""Extras with underscores in tox.ini should match hyphens in metadata markers (#3433)."""
requires = [
Requirement('dep-a; extra == "kebab-case"'),
Requirement('dep-b; extra == "snake-case"'),
Requirement('dep-c; extra == "kebab-case-2"'),
]
result = dependencies_with_extras(requires, {"kebab-case", "snake_case", "kebab-case-2"}, "pkg")
assert sorted(str(r) for r in result) == ["dep-a", "dep-b", "dep-c"]


def test_extras_underscore_in_markers() -> None:
"""Extras with underscores in markers should match hyphens in tox.ini (#3433)."""
requires = [
Requirement('dep-a; extra == "snake_case"'),
]
result = dependencies_with_extras(requires, {"snake-case"}, "pkg")
assert [str(r) for r in result] == ["dep-a"]


def test_extras_normalization_with_recursive() -> None:
"""Recursive extras with underscores should be resolved correctly (#3433)."""
requires = [
Requirement('dep1; extra == "my-extra"'),
Requirement('name[sub_extra]; extra == "my-extra"'),
Requirement('dep2; extra == "sub-extra"'),
]
result = dependencies_with_extras(requires, {"my_extra"}, "name")
assert sorted(str(r) for r in result) == ["dep1", "dep2"]