Skip to content

Commit

Permalink
feat: respect .python-version file (#3367)
Browse files Browse the repository at this point in the history
* feat: respect .python-version file

Signed-off-by: Frost Ming <[email protected]>

* feat: add an option to switch

Signed-off-by: Frost Ming <[email protected]>

* fix: skip tests

Signed-off-by: Frost Ming <[email protected]>
  • Loading branch information
frostming authored Jan 2, 2025
1 parent 1d44e71 commit 6c52c4e
Show file tree
Hide file tree
Showing 7 changed files with 75 additions and 159 deletions.
6 changes: 5 additions & 1 deletion docs/usage/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ will be stored in `.pdm-python` and used by subsequent commands. You can also ch

Alternatively, you can specify the Python interpreter path via `PDM_PYTHON` environment variable. When it is set, the path saved in `.pdm-python` will be ignored.

+++ 2.23.0

If `.python-version` is present in the project root or `PDM_PYTHON_VERSION` env var is set, PDM will use the Python version specified in it. The file or env var should contain a valid Python version string, such as `3.11`.

!!! warning "Using an existing environment"
If you choose to use an existing environment, such as reusing an environment created by `conda`, please note that PDM will _remove_ dependencies not listed in `pyproject.toml` or `pdm.lock` when running `pdm sync --clean` or `pdm remove`. This may lead to destructive consequences. Therefore, try not to share environment among multiple projects.
If you choose to use an existing environment, such as reusing an environment created by `conda`, please note that PDM will _remove_ dependencies not listed in `pyproject.toml` or `pdm.lock` when running `pdm sync --clean` or `pdm remove`. This may lead to destructive consequences. Therefore, try not to share environment among multiple projects.

### Install Python interpreters with PDM

Expand Down
1 change: 1 addition & 0 deletions news/3367.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Respect `.python-version` file in the project root directory when selecting the Python interpreter. By default, it will be written when running `pdm use` command.
155 changes: 3 additions & 152 deletions pdm.lock

Large diffs are not rendered by default.

18 changes: 16 additions & 2 deletions src/pdm/cli/commands/use.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
action="store_true",
help="Ignore the remembered selection",
)
parser.add_argument(
"--no-version-file",
dest="version_file",
default=True,
action="store_false",
help="Do not write .python-version file",
)
parser.add_argument("--venv", help="Use the interpreter in the virtual environment with the given name")
parser.add_argument("python", nargs="?", help="Specify the Python version or path", default="")

Expand Down Expand Up @@ -99,7 +106,9 @@ def version_matcher(py_version: PythonInfo) -> bool:
else:
return installed_interpreter_to_use

found_interpreters = list(dict.fromkeys(project.iter_interpreters(python, filter_func=version_matcher)))
found_interpreters = list(
dict.fromkeys(project.iter_interpreters(python, filter_func=version_matcher, respect_version_file=False))
)
if not found_interpreters:
req = python if ignore_requires_python else f'requires-python="{project.python_requires}"'
raise NoPythonVersion(f"No Python interpreter matching [success]{req}[/] is found.")
Expand Down Expand Up @@ -135,6 +144,7 @@ def do_use(
venv: str | None = None,
auto_install_min: bool = False,
auto_install_max: bool = False,
version_file: bool = True,
hooks: HookManager | None = None,
) -> PythonInfo:
"""Use the specified python version and save in project config.
Expand All @@ -156,7 +166,7 @@ def do_use(
# This can lead to inconsistency when the same virtual environment is reused.
# So the original python identifier is preserved here for logging purpose.
selected_python_identifier = selected_python.identifier
if python:
if python and selected_python.get_venv() is None:
use_cache: JSONFileCache[str, str] = JSONFileCache(project.cache_dir / "use_cache.json")
use_cache.set(python, selected_python.path.as_posix())

Expand All @@ -174,6 +184,9 @@ def do_use(
f"Using {'[bold]Global[/] ' if project.is_global else ''}Python interpreter: [success]{selected_python.path!s}[/] ({selected_python_identifier})"
)
project.python = selected_python
if version_file:
with project.root.joinpath(".python-version").open("w") as f:
f.write(f"{selected_python.major}.{selected_python.minor}\n")
if project.environment.is_local:
assert isinstance(project.environment, PythonLocalEnvironment)
project.core.ui.echo(
Expand All @@ -198,5 +211,6 @@ def handle(self, project: Project, options: argparse.Namespace) -> None:
venv=options.venv,
auto_install_min=options.auto_install_min,
auto_install_max=options.auto_install_max,
version_file=options.version_file,
hooks=HookManager(project, options.skip),
)
20 changes: 19 additions & 1 deletion src/pdm/project/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,13 +792,26 @@ def iter_interpreters(
python_spec: str | None = None,
search_venv: bool | None = None,
filter_func: Callable[[PythonInfo], bool] | None = None,
respect_version_file: bool = True,
) -> Iterable[PythonInfo]:
"""Iterate over all interpreters that matches the given specifier.
And optionally install the interpreter if not found.
"""
from packaging.version import InvalidVersion

from pdm.cli.commands.python import InstallCommand

found = False
if (
respect_version_file
and not python_spec
and (os.getenv("PDM_PYTHON_VERSION") or self.root.joinpath(".python-version").exists())
):
requested = os.getenv("PDM_PYTHON_VERSION") or self.root.joinpath(".python-version").read_text().strip()
if requested not in self.python_requires:
self.core.ui.warn(".python-version is found but the version is not in requires-python, ignored.")
else:
python_spec = requested
for interpreter in self.find_interpreters(python_spec, search_venv):
if filter_func is None or filter_func(interpreter):
found = True
Expand All @@ -812,7 +825,12 @@ def iter_interpreters(
if best_match is None:
return
python_spec = str(best_match)

else:
try:
if python_spec not in self.python_requires:
return
except InvalidVersion:
return
try:
# otherwise if no interpreter is found, try to install it
installed = InstallCommand.install_python(self, python_spec)
Expand Down
1 change: 1 addition & 0 deletions tests/cli/test_venv.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def test_venv_activate(pdm, mocker, project):


@pytest.mark.usefixtures("venv_backends")
@pytest.mark.skipif(platform.system() == "Windows", reason="UNIX only")
def test_venv_activate_tcsh(pdm, mocker, project):
project.project_config["venv.in_project"] = False
result = pdm(["venv", "create"], obj=project)
Expand Down
33 changes: 30 additions & 3 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from pdm.utils import cd

PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12", "3.13"]
DEFAULT_PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12", "3.13"]
PYPROJECT = {
"project": {"name": "test-project", "version": "0.1.0", "requires-python": ">=3.7"},
"build-system": {"requires": ["pdm-backend"], "build-backend": "pdm.backend"},
Expand All @@ -13,17 +13,20 @@
def get_python_versions():
finder = findpython.Finder(resolve_symlinks=True)
available_versions = []
for version in PYTHON_VERSIONS:
for version in DEFAULT_PYTHON_VERSIONS:
v = finder.find(version)
if v and v.is_valid():
available_versions.append(version)
return available_versions


PYTHON_VERSIONS = get_python_versions()


@pytest.mark.integration
@pytest.mark.network
@pytest.mark.flaky(reruns=3)
@pytest.mark.parametrize("python_version", get_python_versions())
@pytest.mark.parametrize("python_version", PYTHON_VERSIONS)
def test_basic_integration(python_version, core, tmp_path, pdm):
"""An e2e test case to ensure PDM works on all supported Python versions"""
project = core.create_project(tmp_path)
Expand All @@ -40,6 +43,30 @@ def test_basic_integration(python_version, core, tmp_path, pdm):
assert not any(line.strip().lower().startswith("django") for line in result.output.splitlines())


@pytest.mark.integration
@pytest.mark.skipif(len(PYTHON_VERSIONS) < 2, reason="Need at least 2 Python versions to test")
def test_use_python_write_file(pdm, project):
pdm(["use", PYTHON_VERSIONS[0]], obj=project, strict=True)
assert f"{project.python.major}.{project.python.minor}" == PYTHON_VERSIONS[0]
assert project.root.joinpath(".python-version").read_text().strip() == PYTHON_VERSIONS[0]
pdm(["use", PYTHON_VERSIONS[1]], obj=project, strict=True)
assert f"{project.python.major}.{project.python.minor}" == PYTHON_VERSIONS[1]
assert project.root.joinpath(".python-version").read_text().strip() == PYTHON_VERSIONS[1]


@pytest.mark.integration
@pytest.mark.parametrize("python_version", PYTHON_VERSIONS)
@pytest.mark.parametrize("via_env", [True, False])
def test_init_project_respect_version_file(pdm, project, python_version, via_env, monkeypatch):
if via_env:
monkeypatch.setenv("PDM_PYTHON_VERSION", python_version)
else:
project.root.joinpath(".python-version").write_text(python_version)
project._saved_python = None
pdm(["install"], obj=project, strict=True)
assert f"{project.python.major}.{project.python.minor}" == python_version


def test_actual_list_freeze(project, local_finder, pdm):
pdm(["config", "-l", "install.parallel", "false"], obj=project, strict=True)
pdm(["add", "first"], obj=project, strict=True)
Expand Down

0 comments on commit 6c52c4e

Please sign in to comment.