Skip to content

Commit 3910129

Browse files
support 'extras' and 'dependency_groups' markers (#888)
Signed-off-by: Frost Ming <[email protected]> Co-authored-by: Brett Cannon <[email protected]>
1 parent 8e49b43 commit 3910129

File tree

5 files changed

+91
-24
lines changed

5 files changed

+91
-24
lines changed

CHANGELOG.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Changelog
44
*unreleased*
55
~~~~~~~~~~~~
66

7-
No unreleased changes.
7+
* PEP 751: Add support for ``extras`` and ``dependency_groups`` markers. (:issue:`885`)
88

99
24.2 - 2024-11-08
1010
~~~~~~~~~~~~~~~~~

docs/markers.rst

+3
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ Reference
6464

6565
:param dict environment: A dictionary containing keys and values to
6666
override the detected environment.
67+
:param str context: A string representing the context in which the marker is evaluated.
68+
Acceptable values are "metadata" (for core metadata; default),
69+
"lock_file", and "requirement" (i.e. all other situations).
6770
:raises: UndefinedComparison: If the marker uses a comparison on strings
6871
which are not valid versions per the
6972
:ref:`specification of version specifiers

src/packaging/_tokenizer.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ def __str__(self) -> str:
6868
|platform[._](version|machine|python_implementation)
6969
|python_implementation
7070
|implementation_(name|version)
71-
|extra
71+
|extras?
72+
|dependency_groups
7273
)\b
7374
""",
7475
re.VERBOSE,

src/packaging/markers.py

+53-22
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import os
99
import platform
1010
import sys
11-
from typing import Any, Callable, TypedDict, cast
11+
from typing import AbstractSet, Any, Callable, Literal, TypedDict, Union, cast
1212

1313
from ._parser import MarkerAtom, MarkerList, Op, Value, Variable
1414
from ._parser import parse_marker as _parse_marker
@@ -17,14 +17,17 @@
1717
from .utils import canonicalize_name
1818

1919
__all__ = [
20+
"EvaluateContext",
2021
"InvalidMarker",
2122
"Marker",
2223
"UndefinedComparison",
2324
"UndefinedEnvironmentName",
2425
"default_environment",
2526
]
2627

27-
Operator = Callable[[str, str], bool]
28+
Operator = Callable[[str, Union[str, AbstractSet[str]]], bool]
29+
EvaluateContext = Literal["metadata", "lock_file", "requirement"]
30+
MARKERS_ALLOWING_SET = {"extras", "dependency_groups"}
2831

2932

3033
class InvalidMarker(ValueError):
@@ -174,13 +177,14 @@ def _format_marker(
174177
}
175178

176179

177-
def _eval_op(lhs: str, op: Op, rhs: str) -> bool:
178-
try:
179-
spec = Specifier("".join([op.serialize(), rhs]))
180-
except InvalidSpecifier:
181-
pass
182-
else:
183-
return spec.contains(lhs, prereleases=True)
180+
def _eval_op(lhs: str, op: Op, rhs: str | AbstractSet[str]) -> bool:
181+
if isinstance(rhs, str):
182+
try:
183+
spec = Specifier("".join([op.serialize(), rhs]))
184+
except InvalidSpecifier:
185+
pass
186+
else:
187+
return spec.contains(lhs, prereleases=True)
184188

185189
oper: Operator | None = _operators.get(op.serialize())
186190
if oper is None:
@@ -189,19 +193,29 @@ def _eval_op(lhs: str, op: Op, rhs: str) -> bool:
189193
return oper(lhs, rhs)
190194

191195

192-
def _normalize(*values: str, key: str) -> tuple[str, ...]:
196+
def _normalize(
197+
lhs: str, rhs: str | AbstractSet[str], key: str
198+
) -> tuple[str, str | AbstractSet[str]]:
193199
# PEP 685 – Comparison of extra names for optional distribution dependencies
194200
# https://peps.python.org/pep-0685/
195201
# > When comparing extra names, tools MUST normalize the names being
196202
# > compared using the semantics outlined in PEP 503 for names
197203
if key == "extra":
198-
return tuple(canonicalize_name(v) for v in values)
204+
assert isinstance(rhs, str), "extra value must be a string"
205+
return (canonicalize_name(lhs), canonicalize_name(rhs))
206+
if key in MARKERS_ALLOWING_SET:
207+
if isinstance(rhs, str): # pragma: no cover
208+
return (canonicalize_name(lhs), canonicalize_name(rhs))
209+
else:
210+
return (canonicalize_name(lhs), {canonicalize_name(v) for v in rhs})
199211

200212
# other environment markers don't have such standards
201-
return values
213+
return lhs, rhs
202214

203215

204-
def _evaluate_markers(markers: MarkerList, environment: dict[str, str]) -> bool:
216+
def _evaluate_markers(
217+
markers: MarkerList, environment: dict[str, str | AbstractSet[str]]
218+
) -> bool:
205219
groups: list[list[bool]] = [[]]
206220

207221
for marker in markers:
@@ -220,7 +234,7 @@ def _evaluate_markers(markers: MarkerList, environment: dict[str, str]) -> bool:
220234
lhs_value = lhs.value
221235
environment_key = rhs.value
222236
rhs_value = environment[environment_key]
223-
237+
assert isinstance(lhs_value, str), "lhs must be a string"
224238
lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key)
225239
groups[-1].append(_eval_op(lhs_value, op, rhs_value))
226240
else:
@@ -298,34 +312,51 @@ def __eq__(self, other: Any) -> bool:
298312

299313
return str(self) == str(other)
300314

301-
def evaluate(self, environment: dict[str, str] | None = None) -> bool:
315+
def evaluate(
316+
self,
317+
environment: dict[str, str] | None = None,
318+
context: EvaluateContext = "metadata",
319+
) -> bool:
302320
"""Evaluate a marker.
303321
304322
Return the boolean from evaluating the given marker against the
305323
environment. environment is an optional argument to override all or
306-
part of the determined environment.
324+
part of the determined environment. The *context* parameter specifies what
325+
context the markers are being evaluated for, which influences what markers
326+
are considered valid. Acceptable values are "metadata" (for core metadata;
327+
default), "lock_file", and "requirement" (i.e. all other situations).
307328
308329
The environment is determined from the current Python process.
309330
"""
310-
current_environment = cast("dict[str, str]", default_environment())
311-
current_environment["extra"] = ""
331+
current_environment = cast(
332+
"dict[str, str | AbstractSet[str]]", default_environment()
333+
)
334+
if context == "lock_file":
335+
current_environment.update(
336+
extras=frozenset(), dependency_groups=frozenset()
337+
)
338+
elif context == "metadata":
339+
current_environment["extra"] = ""
312340
if environment is not None:
313341
current_environment.update(environment)
314342
# The API used to allow setting extra to None. We need to handle this
315343
# case for backwards compatibility.
316-
if current_environment["extra"] is None:
344+
if "extra" in current_environment and current_environment["extra"] is None:
317345
current_environment["extra"] = ""
318346

319347
return _evaluate_markers(
320348
self._markers, _repair_python_full_version(current_environment)
321349
)
322350

323351

324-
def _repair_python_full_version(env: dict[str, str]) -> dict[str, str]:
352+
def _repair_python_full_version(
353+
env: dict[str, str | AbstractSet[str]],
354+
) -> dict[str, str | AbstractSet[str]]:
325355
"""
326356
Work around platform.python_version() returning something that is not PEP 440
327357
compliant for non-tagged Python builds.
328358
"""
329-
if env["python_full_version"].endswith("+"):
330-
env["python_full_version"] += "local"
359+
python_full_version = cast(str, env["python_full_version"])
360+
if python_full_version.endswith("+"):
361+
env["python_full_version"] = f"{python_full_version}local"
331362
return env

tests/test_markers.py

+32
Original file line numberDiff line numberDiff line change
@@ -394,3 +394,35 @@ def test_python_full_version_untagged_user_provided(self):
394394
def test_python_full_version_untagged(self):
395395
with mock.patch("platform.python_version", return_value="3.11.1+"):
396396
assert Marker("python_full_version < '3.12'").evaluate()
397+
398+
@pytest.mark.parametrize("variable", ["extras", "dependency_groups"])
399+
@pytest.mark.parametrize(
400+
"expression,result",
401+
[
402+
pytest.param('"foo" in {0}', True, id="value-in-foo"),
403+
pytest.param('"bar" in {0}', True, id="value-in-bar"),
404+
pytest.param('"baz" in {0}', False, id="value-not-in"),
405+
pytest.param('"baz" not in {0}', True, id="value-not-in-negated"),
406+
pytest.param('"foo" in {0} and "bar" in {0}', True, id="and-in"),
407+
pytest.param('"foo" in {0} or "bar" in {0}', True, id="or-in"),
408+
pytest.param(
409+
'"baz" in {0} and "foo" in {0}', False, id="short-circuit-and"
410+
),
411+
pytest.param('"foo" in {0} or "baz" in {0}', True, id="short-circuit-or"),
412+
pytest.param('"Foo" in {0}', True, id="case-sensitive"),
413+
],
414+
)
415+
def test_extras_and_dependency_groups(self, variable, expression, result):
416+
environment = {variable: {"foo", "bar"}}
417+
assert Marker(expression.format(variable)).evaluate(environment) == result
418+
419+
@pytest.mark.parametrize("variable", ["extras", "dependency_groups"])
420+
def test_extras_and_dependency_groups_disallowed(self, variable):
421+
marker = Marker(f'"foo" in {variable}')
422+
assert not marker.evaluate(context="lock_file")
423+
424+
with pytest.raises(KeyError):
425+
marker.evaluate()
426+
427+
with pytest.raises(KeyError):
428+
marker.evaluate(context="requirement")

0 commit comments

Comments
 (0)