Skip to content

Commit c0fd7dd

Browse files
authored
feat: support 'extras' and 'dependency_groups' markers (#11)
Signed-off-by: Frost Ming <me@frostming.com>
1 parent e5d1594 commit c0fd7dd

File tree

9 files changed

+206
-38
lines changed

9 files changed

+206
-38
lines changed

src/dep_logic/markers/__init__.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@
3131

3232

3333
__all__ = [
34-
"parse_marker",
35-
"from_pkg_marker",
36-
"InvalidMarker",
37-
"BaseMarker",
3834
"AnyMarker",
35+
"BaseMarker",
3936
"EmptyMarker",
37+
"InvalidMarker",
4038
"MarkerExpression",
4139
"MarkerUnion",
4240
"MultiMarker",
41+
"from_pkg_marker",
42+
"parse_marker",
4343
]
4444

4545

@@ -99,3 +99,34 @@ def _build_markers(markers: _ParsedMarkers) -> BaseMarker:
9999
else:
100100
or_groups[-1] &= _build_markers(item)
101101
return MarkerUnion.of(*or_groups)
102+
103+
104+
def _patch_marker_parser() -> None:
105+
import re
106+
107+
try:
108+
from packaging._tokenizer import DEFAULT_RULES
109+
except (ModuleNotFoundError, AttributeError):
110+
return
111+
112+
DEFAULT_RULES["VARIABLE"] = re.compile(
113+
r"""
114+
\b(
115+
python_version
116+
|python_full_version
117+
|os[._]name
118+
|sys[._]platform
119+
|platform_(release|system)
120+
|platform[._](version|machine|python_implementation)
121+
|python_implementation
122+
|implementation_(name|version)
123+
|extras?
124+
|dependency_groups
125+
)\b
126+
""",
127+
re.VERBOSE,
128+
)
129+
130+
131+
_patch_marker_parser()
132+
del _patch_marker_parser

src/dep_logic/markers/any.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from dep_logic.markers.base import BaseMarker
3+
from dep_logic.markers.base import BaseMarker, EvaluationContext
44

55

66
class AnyMarker(BaseMarker):
@@ -17,7 +17,11 @@ def __or__(self, other: BaseMarker) -> BaseMarker:
1717
def is_any(self) -> bool:
1818
return True
1919

20-
def evaluate(self, environment: dict[str, str] | None = None) -> bool:
20+
def evaluate(
21+
self,
22+
environment: dict[str, str | set[str]] | None = None,
23+
context: EvaluationContext = "metadata",
24+
):
2125
return True
2226

2327
def without_extras(self) -> BaseMarker:

src/dep_logic/markers/base.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

33
from abc import ABCMeta, abstractmethod
4-
from typing import Any
4+
from typing import Any, Literal
5+
6+
EvaluationContext = Literal["lock_file", "metadata", "requirement"]
57

68

79
class BaseMarker(metaclass=ABCMeta):
@@ -28,7 +30,11 @@ def is_empty(self) -> bool:
2830
return False
2931

3032
@abstractmethod
31-
def evaluate(self, environment: dict[str, str] | None = None) -> bool:
33+
def evaluate(
34+
self,
35+
environment: dict[str, str | set[str]] | None = None,
36+
context: EvaluationContext = "metadata",
37+
) -> bool:
3238
raise NotImplementedError
3339

3440
@abstractmethod

src/dep_logic/markers/empty.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from dep_logic.markers.base import BaseMarker
3+
from dep_logic.markers.base import BaseMarker, EvaluationContext
44

55

66
class EmptyMarker(BaseMarker):
@@ -17,7 +17,11 @@ def __or__(self, other: BaseMarker) -> BaseMarker:
1717
def is_empty(self) -> bool:
1818
return True
1919

20-
def evaluate(self, environment: dict[str, str] | None = None) -> bool:
20+
def evaluate(
21+
self,
22+
environment: dict[str, str | set[str]] | None = None,
23+
context: EvaluationContext = "metadata",
24+
) -> bool:
2125
return False
2226

2327
def without_extras(self) -> BaseMarker:

src/dep_logic/markers/multi.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Iterator
55

66
from dep_logic.markers.any import AnyMarker
7-
from dep_logic.markers.base import BaseMarker
7+
from dep_logic.markers.base import BaseMarker, EvaluationContext
88
from dep_logic.markers.empty import EmptyMarker
99
from dep_logic.markers.single import MarkerExpression, SingleMarker
1010
from dep_logic.utils import DATACLASS_ARGS, flatten_items, intersection, union
@@ -135,8 +135,12 @@ def union_simplify(self, other: BaseMarker) -> BaseMarker | None:
135135

136136
return None
137137

138-
def evaluate(self, environment: dict[str, str] | None = None) -> bool:
139-
return all(m.evaluate(environment) for m in self.markers)
138+
def evaluate(
139+
self,
140+
environment: dict[str, str | set[str]] | None = None,
141+
context: EvaluationContext = "metadata",
142+
) -> bool:
143+
return all(m.evaluate(environment, context) for m in self.markers)
140144

141145
def without_extras(self) -> BaseMarker:
142146
return self.exclude("extra")

src/dep_logic/markers/single.py

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,44 @@
11
from __future__ import annotations
22

33
import functools
4+
import operator
45
import typing as t
6+
from abc import abstractmethod
57
from dataclasses import dataclass, field, replace
68

7-
from packaging.markers import Marker as _Marker
9+
from packaging.markers import default_environment
10+
from packaging.specifiers import InvalidSpecifier, Specifier
11+
from packaging.version import InvalidVersion
812

913
from dep_logic.markers.any import AnyMarker
10-
from dep_logic.markers.base import BaseMarker
14+
from dep_logic.markers.base import BaseMarker, EvaluationContext
1115
from dep_logic.markers.empty import EmptyMarker
1216
from dep_logic.specifiers import BaseSpecifier
1317
from dep_logic.specifiers.base import VersionSpecifier
1418
from dep_logic.specifiers.generic import GenericSpecifier
15-
from dep_logic.utils import DATACLASS_ARGS, OrderedSet, get_reflect_op
19+
from dep_logic.utils import DATACLASS_ARGS, OrderedSet, get_reflect_op, normalize_name
1620

1721
if t.TYPE_CHECKING:
1822
from dep_logic.markers.multi import MultiMarker
1923
from dep_logic.markers.union import MarkerUnion
2024

2125
PYTHON_VERSION_MARKERS = {"python_version", "python_full_version"}
26+
MARKERS_ALLOWING_SET = {"extras", "dependency_groups"}
27+
Operator = t.Callable[[str, t.Union[str, t.Set[str]]], bool]
28+
_operators: dict[str, Operator] = {
29+
"in": lambda lhs, rhs: lhs in rhs,
30+
"not in": lambda lhs, rhs: lhs not in rhs,
31+
"<": operator.lt,
32+
"<=": operator.le,
33+
"==": operator.eq,
34+
"!=": operator.ne,
35+
">=": operator.ge,
36+
">": operator.gt,
37+
}
38+
39+
40+
class UndefinedComparison(ValueError):
41+
pass
2242

2343

2444
class SingleMarker(BaseMarker):
@@ -44,16 +64,25 @@ def only(self, *marker_names: str) -> BaseMarker:
4464

4565
return self
4666

47-
def evaluate(self, environment: dict[str, str] | None = None) -> bool:
48-
pkg_marker = _Marker(str(self))
49-
if self.name != "extra" or not environment or "extra" not in environment:
50-
return pkg_marker.evaluate(environment)
51-
extras = [extra] if isinstance(extra := environment["extra"], str) else extra
52-
assert isinstance(self, MarkerExpression)
53-
is_negated = self.op in ("not in", "!=")
54-
if is_negated:
55-
return all(pkg_marker.evaluate({"extra": extra}) for extra in extras)
56-
return any(pkg_marker.evaluate({"extra": extra}) for extra in extras)
67+
def evaluate(
68+
self,
69+
environment: dict[str, str | set[str]] | None = None,
70+
context: EvaluationContext = "metadata",
71+
) -> bool:
72+
current_environment = t.cast("dict[str, str|set[str]]", default_environment())
73+
if context == "metadata":
74+
current_environment["extra"] = ""
75+
elif context == "lock_file":
76+
current_environment.update(extras=set(), dependency_groups=set())
77+
if environment:
78+
current_environment.update(environment)
79+
if "extra" in current_environment and current_environment["extra"] is None:
80+
current_environment["extra"] = ""
81+
return self._evaluate(current_environment)
82+
83+
@abstractmethod
84+
def _evaluate(self, environment: dict[str, str | set[str]]) -> bool:
85+
raise NotImplementedError
5786

5887

5988
@dataclass(unsafe_hash=True, **DATACLASS_ARGS)
@@ -141,6 +170,46 @@ def __or__(self, other: t.Any) -> BaseMarker:
141170

142171
return MarkerUnion(self, other)
143172

173+
def _evaluate(self, environment: dict[str, str | set[str]]) -> bool:
174+
if self.name == "extra":
175+
# Support batch comparison for "extra" markers
176+
extra = environment["extra"]
177+
if isinstance(extra, str):
178+
extra = {extra}
179+
assert self.op in ("==", "!=")
180+
value = normalize_name(self.value)
181+
extra = {normalize_name(v) for v in extra}
182+
return value in extra if self.op == "==" else value not in extra
183+
184+
target = environment[self.name]
185+
if self.reversed:
186+
lhs, rhs = self.value, target
187+
oper = _operators.get(get_reflect_op(self.op))
188+
else:
189+
lhs, rhs = target, self.value
190+
assert isinstance(lhs, str)
191+
oper = _operators.get(self.op)
192+
if self.name in MARKERS_ALLOWING_SET:
193+
lhs = normalize_name(lhs)
194+
if isinstance(rhs, set):
195+
rhs = {normalize_name(v) for v in rhs}
196+
else:
197+
rhs = normalize_name(rhs)
198+
if isinstance(rhs, str):
199+
try:
200+
spec = Specifier(f"{self.op}{rhs}")
201+
except InvalidSpecifier:
202+
pass
203+
else:
204+
try:
205+
return spec.contains(lhs)
206+
except InvalidVersion:
207+
pass
208+
209+
if oper is None:
210+
raise UndefinedComparison(f"Undefined comparison {self}")
211+
return oper(lhs, rhs)
212+
144213

145214
@dataclass(frozen=True, unsafe_hash=True, **DATACLASS_ARGS)
146215
class EqualityMarkerUnion(SingleMarker):
@@ -210,6 +279,9 @@ def __or__(self, other: t.Any) -> BaseMarker:
210279
__rand__ = __and__
211280
__ror__ = __or__
212281

282+
def _evaluate(self, environment: dict[str, str | set[str]]) -> bool:
283+
return environment[self.name] in self.values
284+
213285

214286
@dataclass(frozen=True, unsafe_hash=True, **DATACLASS_ARGS)
215287
class InequalityMultiMarker(SingleMarker):
@@ -283,6 +355,9 @@ def __or__(self, other: t.Any) -> BaseMarker:
283355
__rand__ = __and__
284356
__ror__ = __or__
285357

358+
def _evaluate(self, environment: dict[str, str | set[str]]) -> bool:
359+
return environment[self.name] not in self.values
360+
286361

287362
@functools.lru_cache(maxsize=None)
288363
def _merge_single_markers(
@@ -375,5 +450,5 @@ def _normalize_python_version_specifier(marker: MarkerExpression) -> BaseSpecifi
375450
splitted[-1] = str(int(splitted[-1]) + 1)
376451
op = "<"
377452

378-
spec = parse_version_specifier(f'{op}{".".join(splitted)}')
453+
spec = parse_version_specifier(f"{op}{'.'.join(splitted)}")
379454
return spec

src/dep_logic/markers/union.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Iterator
55

66
from dep_logic.markers.any import AnyMarker
7-
from dep_logic.markers.base import BaseMarker
7+
from dep_logic.markers.base import BaseMarker, EvaluationContext
88
from dep_logic.markers.empty import EmptyMarker
99
from dep_logic.markers.multi import MultiMarker
1010
from dep_logic.markers.single import SingleMarker
@@ -135,8 +135,12 @@ def intersect_simplify(self, other: BaseMarker) -> BaseMarker | None:
135135

136136
return None
137137

138-
def evaluate(self, environment: dict[str, str] | None = None) -> bool:
139-
return any(m.evaluate(environment) for m in self.markers)
138+
def evaluate(
139+
self,
140+
environment: dict[str, str | set[str]] | None = None,
141+
context: EvaluationContext = "metadata",
142+
) -> bool:
143+
return any(m.evaluate(environment, context) for m in self.markers)
140144

141145
def without_extras(self) -> BaseMarker:
142146
return self.exclude("extra")

src/dep_logic/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,7 @@ def __len__(self) -> int:
191191

192192
def peek(self) -> T:
193193
return self._data[0]
194+
195+
196+
def normalize_name(name: str) -> str:
197+
return re.sub(r"[-_.]+", "-", name).lower()

0 commit comments

Comments
 (0)