From e38584b77fd916f74439bb9112b94a70c51d8a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 30 Oct 2026 12:27:23 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(py=5Finfo):=20expose=20mar?= =?UTF-8?q?ker=20environment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../+target-marker-environment.feature.rst | 1 + docs/how-to/standalone-usage.rst | 5 ++ pyproject.toml | 1 + src/python_discovery/_py_info.py | 1 + src/python_discovery/_py_info_collect.py | 24 ++++++++ tests/py_info/test_py_info.py | 55 ++++++++++++++++++- 6 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 docs/changelog/+target-marker-environment.feature.rst diff --git a/docs/changelog/+target-marker-environment.feature.rst b/docs/changelog/+target-marker-environment.feature.rst new file mode 100644 index 0000000..d35d60d --- /dev/null +++ b/docs/changelog/+target-marker-environment.feature.rst @@ -0,0 +1 @@ +Expose the selected interpreter's dependency marker values as ``PythonInfo.marker_environment``. diff --git a/docs/how-to/standalone-usage.rst b/docs/how-to/standalone-usage.rst index 66a311b..50a342b 100644 --- a/docs/how-to/standalone-usage.rst +++ b/docs/how-to/standalone-usage.rst @@ -153,6 +153,7 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin +platform: str +sysconfig_vars: dict +sysconfig_paths: dict + +marker_environment: dict[str, str] +machine: str +free_threaded: bool +debug_build: bool @@ -178,6 +179,10 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin info.debug_build # True if this is a Py_DEBUG build. info.sysconfig_vars # All sysconfig.get_config_vars() values. info.sysconfig_paths # All sysconfig.get_paths() values. + info.marker_environment + +Use ``marker_environment`` to evaluate :pep:`508` dependency markers for the discovered interpreter. The mapping +contains all standard marker keys, including its implementation version and operating system. Prefer :attr:`~python_discovery.PythonInfo.system_exe` over the raw :attr:`~python_discovery.PythonInfo.system_executable` field it reads. The field is typed ``str | None`` because it diff --git a/pyproject.toml b/pyproject.toml index 1cafb2e..805c975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ urls.Tracker = "https://github.com/tox-dev/python-discovery/issues" test = [ "covdefaults>=2.3", "coverage>=7.6.1", + "packaging>=23.2", "pytest>=8.3.5", "pytest-mock>=3.14.1", "setuptools>=75.3.4", diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 879764f..c93c67c 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -57,6 +57,7 @@ class PythonInfo: # ruff:ignore[too-many-public-methods] os: str free_threaded: bool debug_build: bool + marker_environment: dict[str, str] prefix: str | None base_prefix: str | None real_prefix: str | None diff --git a/src/python_discovery/_py_info_collect.py b/src/python_discovery/_py_info_collect.py index 3c350a9..a1bdf2d 100644 --- a/src/python_discovery/_py_info_collect.py +++ b/src/python_discovery/_py_info_collect.py @@ -64,6 +64,30 @@ def _init_identity(self): self.os = os.name self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1 self.debug_build = bool(sysconfig.get_config_var("Py_DEBUG")) + # Vermin checks the whole script against Python 2.7 despite the version gate above. + interpreter = getattr(sys, "implementation") # ruff:ignore[get-attr-with-constant] + interpreter_version = interpreter.version + marker_version = "{}.{}.{}{}".format( + interpreter_version.major, + interpreter_version.minor, + interpreter_version.micro, + "{}{}".format(interpreter_version.releaselevel[0], interpreter_version.serial) + if interpreter_version.releaselevel != "final" + else "", + ) + self.marker_environment = { + "implementation_name": interpreter.name, + "implementation_version": marker_version, + "os_name": os.name, + "platform_machine": platform.machine(), + "platform_release": platform.release(), + "platform_system": platform.system(), + "platform_version": platform.version(), + "python_full_version": platform.python_version(), + "platform_python_implementation": platform.python_implementation(), + "python_version": ".".join(platform.python_version_tuple()[:2]), + "sys_platform": sys.platform, + } def _init_prefixes(self): def abs_path(value): diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index ec06108..faa6678 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -9,9 +9,10 @@ import sysconfig from pathlib import Path from textwrap import dedent -from typing import TYPE_CHECKING, NamedTuple +from typing import TYPE_CHECKING, Final, NamedTuple import pytest +from packaging.markers import default_environment from setuptools.dist import Distribution from python_discovery import DiskCache, PythonInfo, PythonSpec @@ -492,6 +493,58 @@ def test_py_info_json_round_trip() -> None: assert restored.machine == CURRENT.machine +def test_marker_environment_matches_target_interpreter() -> None: + info: Final[PythonInfo | None] = PythonInfo.from_exe(sys.executable, ignore_cache=True, resolve_to_host=False) + assert info is not None + assert info.marker_environment == default_environment() + + +def test_marker_environment_uses_target_platform(tmp_path: Path) -> None: + (tmp_path / "sitecustomize.py").write_text( + 'import platform\nplatform.machine = lambda: "target-machine"\nplatform.release = lambda: "target-release"\n', + encoding="utf-8", + ) + expected = default_environment() + assert expected["platform_machine"] != "target-machine" + assert expected["platform_release"] != "target-release" + info: Final[PythonInfo | None] = PythonInfo.from_exe( + sys.executable, + ignore_cache=True, + resolve_to_host=False, + env={**os.environ, "PYTHONPATH": str(tmp_path)}, + ) + assert info is not None + assert info.marker_environment == { + **expected, + "platform_machine": "target-machine", + "platform_release": "target-release", + } + + +def test_marker_environment_survives_json_round_trip() -> None: + assert PythonInfo.from_json(CURRENT.to_json()).marker_environment == CURRENT.marker_environment + + +def test_marker_environment_formats_prerelease(mocker: MockerFixture) -> None: + version: Final[VersionInfo] = VersionInfo(*sys.implementation.version) + mocker.patch.object( + sys.implementation, + "version", + mocker.create_autospec( + version, + major=version.major, + minor=version.minor, + micro=version.micro, + releaselevel="candidate", + serial=1, + ), + ) + + assert PythonInfo().marker_environment["implementation_version"] == ( + f"{version.major}.{version.minor}.{version.micro}c1" + ) + + @pytest.mark.parametrize( ("target_platform", "discovered_platforms", "expected_idx"), [ From 425be0183ff37e985c53eeeadb79a7f97f34982f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 30 Oct 2026 12:56:33 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=AA=20test(py=5Finfo):=20simplify?= =?UTF-8?q?=20marker=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/py_info/test_py_info.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index faa6678..dfb43ea 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -20,6 +20,7 @@ from python_discovery._py_info import VersionInfo if TYPE_CHECKING: + from packaging.markers import Environment from pytest_mock import MockerFixture IS_PYPY = PythonInfo.current_system().implementation == "PyPy" @@ -504,7 +505,7 @@ def test_marker_environment_uses_target_platform(tmp_path: Path) -> None: 'import platform\nplatform.machine = lambda: "target-machine"\nplatform.release = lambda: "target-release"\n', encoding="utf-8", ) - expected = default_environment() + expected: Final[Environment] = default_environment() assert expected["platform_machine"] != "target-machine" assert expected["platform_release"] != "target-release" info: Final[PythonInfo | None] = PythonInfo.from_exe( @@ -527,18 +528,7 @@ def test_marker_environment_survives_json_round_trip() -> None: def test_marker_environment_formats_prerelease(mocker: MockerFixture) -> None: version: Final[VersionInfo] = VersionInfo(*sys.implementation.version) - mocker.patch.object( - sys.implementation, - "version", - mocker.create_autospec( - version, - major=version.major, - minor=version.minor, - micro=version.micro, - releaselevel="candidate", - serial=1, - ), - ) + mocker.patch.object(sys.implementation, "version", version._replace(releaselevel="candidate", serial=1)) assert PythonInfo().marker_environment["implementation_version"] == ( f"{version.major}.{version.minor}.{version.micro}c1"