Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: respect .python-version file #3367

Merged
merged 3 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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.")

Check warning on line 812 in src/pdm/project/core.py

View check run for this annotation

Codecov / codecov/patch

src/pdm/project/core.py#L812

Added line #L812 was not covered by tests
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 @@
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
Loading