From 6a4e4e12a4e826270dcc80e94a550c7f74266a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sat, 23 May 2026 18:21:27 +0200 Subject: [PATCH] add `Marker.apply(environment)` to partially evaluate a marker This is required by poetry-plugin-export, which previously used a combination of `validate` and `without_extras`, which only worked in some cases. --- src/poetry/core/version/markers.py | 42 ++++++++++++ tests/version/test_markers.py | 103 +++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/src/poetry/core/version/markers.py b/src/poetry/core/version/markers.py index c157d7b20..d7b4ef6a7 100644 --- a/src/poetry/core/version/markers.py +++ b/src/poetry/core/version/markers.py @@ -101,6 +101,27 @@ def is_empty(self) -> bool: def validate(self, environment: Mapping[str, Any] | None) -> bool: raise NotImplementedError + @abstractmethod + def apply(self, environment: Mapping[str, Any]) -> BaseMarker: + """ + Partially evaluate this marker against the (possibly partial) environment. + + Similar to `validate`, but instead of collapsing the whole marker tree to + a single `bool`, this returns a simplified `BaseMarker`: + + - Single markers whose variable name is present in `environment` are + validated and replaced with `AnyMarker` (if they hold) or `EmptyMarker` + (if they do not). + - Single markers whose variable name is not present in `environment` are + left untouched, so the result can still depend on those undefined + variables. + - Composite markers are simplified as usual. + + Whereas `validate` treats missing variables as satisfied (returning + `True`), `apply` preserves them so the caller can decide later. + """ + raise NotImplementedError + @abstractmethod def without_extras(self) -> BaseMarker: raise NotImplementedError @@ -148,6 +169,9 @@ def is_any(self) -> bool: def validate(self, environment: Mapping[str, Any] | None) -> bool: return True + def apply(self, environment: Mapping[str, Any]) -> BaseMarker: + return self + def without_extras(self) -> BaseMarker: return self @@ -194,6 +218,9 @@ def is_empty(self) -> bool: def validate(self, environment: Mapping[str, Any] | None) -> bool: return False + def apply(self, environment: Mapping[str, Any]) -> BaseMarker: + return self + def without_extras(self) -> BaseMarker: return self @@ -293,6 +320,11 @@ def validate(self, environment: Mapping[str, Any] | None) -> bool: constraint = self._parser(environment[self._name]) return self._constraint.allows(constraint) # type: ignore[arg-type] + def apply(self, environment: Mapping[str, Any]) -> BaseMarker: + if self._name not in environment: + return self + return AnyMarker() if self.validate(environment) else EmptyMarker() + def without_extras(self) -> BaseMarker: return self.exclude("extra") @@ -770,6 +802,11 @@ def union_simplify(self, other: BaseMarker) -> BaseMarker | None: def validate(self, environment: Mapping[str, Any] | None) -> bool: return all(m.validate(environment) for m in self._markers) + def apply(self, environment: Mapping[str, Any]) -> BaseMarker: + if not environment: + return self + return self.of(*(m.apply(environment) for m in self._markers)) + def without_extras(self) -> BaseMarker: return self.exclude("extra") @@ -976,6 +1013,11 @@ def intersect_simplify(self, other: BaseMarker) -> BaseMarker | None: def validate(self, environment: Mapping[str, Any] | None) -> bool: return any(m.validate(environment) for m in self._markers) + def apply(self, environment: Mapping[str, Any]) -> BaseMarker: + if not environment: + return self + return self.of(*(m.apply(environment) for m in self._markers)) + def without_extras(self) -> BaseMarker: return self.exclude("extra") diff --git a/tests/version/test_markers.py b/tests/version/test_markers.py index c396208ff..7b536918a 100644 --- a/tests/version/test_markers.py +++ b/tests/version/test_markers.py @@ -1540,6 +1540,109 @@ def test_validate( assert m.validate(environment) is expected +@pytest.mark.parametrize( + ("marker_string", "environment", "expected"), + [ + # AnyMarker / EmptyMarker are returned unchanged. + ("", {}, ""), + ("", {"os_name": "linux"}, ""), + (EMPTY, {}, EMPTY), + (EMPTY, {"os_name": "linux"}, EMPTY), + # Empty environment preserves the marker. + ("os_name == 'foo'", {}, "os_name == 'foo'"), + ( + "python_version >= '3.8' and sys_platform == 'linux'", + {}, + "python_version >= '3.8' and sys_platform == 'linux'", + ), + # SingleMarker with name in env collapses to Any/Empty. + ("os_name == 'foo'", {"os_name": "foo"}, ""), + ("os_name == 'foo'", {"os_name": "bar"}, EMPTY), + ("python_version >= '3.8'", {"python_version": "3.10"}, ""), + ("python_version >= '3.8'", {"python_version": "3.7"}, EMPTY), + # SingleMarker with name not in env is returned unchanged. + ("os_name == 'foo'", {}, "os_name == 'foo'"), + ("os_name == 'foo'", {"sys_platform": "linux"}, "os_name == 'foo'"), + # MultiMarker: only one name in env -> residual is the other branch. + ( + "python_version >= '3.8' and sys_platform == 'linux'", + {"python_version": "3.10"}, + "sys_platform == 'linux'", + ), + ( + "python_version >= '3.8' and sys_platform == 'linux'", + {"sys_platform": "linux"}, + "python_version >= '3.8'", + ), + # MultiMarker: one branch is False -> whole thing collapses to Empty. + ( + "python_version >= '3.8' and sys_platform == 'linux'", + {"python_version": "3.7"}, + EMPTY, + ), + # MultiMarker: both names known. + ( + "python_version >= '3.8' and sys_platform == 'linux'", + {"python_version": "3.10", "sys_platform": "linux"}, + "", + ), + ( + "python_version >= '3.8' and sys_platform == 'linux'", + {"python_version": "3.10", "sys_platform": "win32"}, + EMPTY, + ), + # MarkerUnion: one branch True -> collapses to Any. + ( + "python_version >= '3.8' or sys_platform == 'linux'", + {"python_version": "3.10"}, + "", + ), + # MarkerUnion: one branch False -> residual is the other branch. + ( + "python_version >= '3.8' or sys_platform == 'linux'", + {"python_version": "3.7"}, + "sys_platform == 'linux'", + ), + # MarkerUnion: both names known. + ( + "python_version >= '3.8' or sys_platform == 'linux'", + {"python_version": "3.7", "sys_platform": "win32"}, + EMPTY, + ), + # Nested: (A or B) and C, only python_version known. + ( + "(python_version >= '3.8' or sys_platform == 'win32') and extra == 'foo'", + {"python_version": "3.10"}, + "extra == 'foo'", + ), + ( + "(python_version >= '3.8' or sys_platform == 'win32') and extra == 'foo'", + {"python_version": "3.7"}, + "sys_platform == 'win32' and extra == 'foo'", + ), + # extra: name in env is evaluated, including empty-tuple case. + ("extra == 'a'", {"extra": "a"}, ""), + ("extra == 'a'", {"extra": "b"}, EMPTY), + ("extra == 'a'", {"extra": ()}, EMPTY), + ("extra != 'a'", {"extra": ()}, ""), + # extra: name not in env is preserved. + ("extra == 'a'", {}, "extra == 'a'"), + ("extra == 'a'", {"python_version": "3.10"}, "extra == 'a'"), + # AtomicMultiMarker / AtomicMarkerUnion (compact extra forms). + ("extra != 'a' and extra != 'b'", {"extra": ("c",)}, ""), + ("extra != 'a' and extra != 'b'", {"extra": ("a",)}, EMPTY), + ("extra != 'a' and extra != 'b'", {}, "extra != 'a' and extra != 'b'"), + ("extra == 'a' or extra == 'b'", {"extra": ("a",)}, ""), + ("extra == 'a' or extra == 'b'", {"extra": ("c",)}, EMPTY), + ("extra == 'a' or extra == 'b'", {}, "extra == 'a' or extra == 'b'"), + ], +) +def test_apply(marker_string: str, environment: dict[str, str], expected: str) -> None: + m = parse_marker(marker_string) + + assert m.apply(environment) == parse_marker(expected) + + @pytest.mark.parametrize( "marker, env", [