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..dfb43ea 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 @@ -19,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" @@ -492,6 +494,47 @@ 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: Final[Environment] = 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", version._replace(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"), [