From ca0a27ac11593ba3a7613c739b62c473dc9aca60 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 27 Mar 2024 10:10:05 +0800 Subject: [PATCH] feat: install python using pbs-installer (#2721) --- README.md | 1 + README_zh.md | 1 + docs/docs/index.md | 1 + docs/docs/usage/project.md | 38 +++++++ news/2721.feature.md | 1 + pdm.lock | 103 +++++++++++++++++-- pyproject.toml | 4 +- src/pdm/cli/commands/python.py | 136 ++++++++++++++++++++++++++ src/pdm/cli/commands/use.py | 31 +++--- src/pdm/cli/commands/venv/backends.py | 14 ++- src/pdm/cli/completions/pdm.bash | 10 +- src/pdm/cli/completions/pdm.fish | 50 +++++++++- src/pdm/cli/completions/pdm.ps1 | 33 ++++++- src/pdm/cli/completions/pdm.zsh | 36 +++++++ src/pdm/cli/options.py | 2 +- src/pdm/models/python.py | 4 + src/pdm/project/config.py | 5 + src/pdm/project/core.py | 60 ++++++++++-- src/pdm/pytest.py | 11 ++- src/pdm/termui.py | 2 +- tests/cli/test_python.py | 89 +++++++++++++++++ 21 files changed, 585 insertions(+), 47 deletions(-) create mode 100644 news/2721.feature.md create mode 100644 src/pdm/cli/commands/python.py create mode 100644 tests/cli/test_python.py diff --git a/README.md b/README.md index 6cae161045..096de8801a 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ you can probably find some goodness in `pdm`. - [PEP 621] project metadata. - Flexible and powerful plug-in system. - Versatile user scripts. +- Install Pythons using [indygreg's python-build-standalone](https://github.com/indygreg/python-build-standalone). - Opt-in centralized installation cache like [pnpm](https://pnpm.io/motivation#saving-disk-space-and-boosting-installation-speed). [pep 517]: https://www.python.org/dev/peps/pep-0517 diff --git a/README_zh.md b/README_zh.md index 9fea6b5aac..abee724d1c 100644 --- a/README_zh.md +++ b/README_zh.md @@ -33,6 +33,7 @@ PDM 旨在成为下一代 Python 软件包管理工具。它最初是为个人 - 灵活且强大的插件系统 - [PEP 621] 元数据格式 - 功能强大的用户脚本 +- 支持从 [indygreg's python-build-standalone](https://github.com/indygreg/python-build-standalone) 安装 Python。 - 像 [pnpm] 一样的中心化安装缓存,节省磁盘空间 [pep 517]: https://www.python.org/dev/peps/pep-0517 diff --git a/docs/docs/index.md b/docs/docs/index.md index a0f823f332..a11f7f3878 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -15,6 +15,7 @@ PDM, as described, is a modern Python package and dependency manager supporting - [PEP 621] project metadata. - Flexible and powerful plug-in system. - Versatile user scripts. +- Install Pythons using [indygreg's python-build-standalone](https://github.com/indygreg/python-build-standalone). - Opt-in centralized installation cache like [pnpm](https://pnpm.io/motivation#saving-disk-space-and-boosting-installation-speed). [pep 517]: https://www.python.org/dev/peps/pep-0517 diff --git a/docs/docs/usage/project.md b/docs/docs/usage/project.md index 26475fa6a3..cf349d76e2 100644 --- a/docs/docs/usage/project.md +++ b/docs/docs/usage/project.md @@ -17,6 +17,44 @@ 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. +## Install Python interpreters with PDM + ++++ 2.13.0 + +PDM supports installing additional Python interpreters from [@indygreg's python-build-standalone](https://github.com/indygreg/python-build-standalone) +with the `pdm python install` command. For example, to install CPython 3.9.8: + +```bash +pdm python install 3.9.8 +``` + +You can view all available Python versions with `pdm python install --list`. + +This will install the Python interpreter into the location specified by `python.install_root` configuration. + +List the currently installed Python interpreters: + +```bash +pdm python list +``` + +Remove an installed Python interpreter: + +```bash +pdm python remove 3.9.8 +``` + +!!! TIP "Share installations with Rye" + + PDM installs Python interpreters using the same source as [Rye](https://rye-up.com). If you are using Rye at the same time, you can point the `python.install_root` to the same directory as Rye to share the Python interpreters: + + ```bash + pdm config python.install_root ~/.rye/py + ``` + + Afterwards you can manage the installations using either `rye toolchain` or `pdm python`. + + ## Virtualenv or not After you select the Python interpreter, PDM will ask you whether you want to create a virtual environment for the project. diff --git a/news/2721.feature.md b/news/2721.feature.md new file mode 100644 index 0000000000..609190aa52 --- /dev/null +++ b/news/2721.feature.md @@ -0,0 +1 @@ +Support installing Pythons from [python-build-standalone](https://github.com/indygreg/python-build-standalone). Add command group `pdm python` to manage Python installations. And `pdm use` can automatically install the Python interpreter if it's not found. diff --git a/pdm.lock b/pdm.lock index 5e0cf21200..8e7ffa3d2f 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "all", "doc", "pytest", "test", "tox", "workflow"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:4913ba83e2aba4315d326a33dbfd713c3a506ee6a4a5e14af25b345946e6133b" +content_hash = "sha256:f9ce79567511e77d63526ef41829cbbad8a3180678bcda40fd512ac0be20a989" [[package]] name = "anyio" @@ -108,8 +108,8 @@ files = [ name = "cffi" version = "1.15.1" summary = "Foreign Function Interface for Python calling C code." -groups = ["all"] -marker = "sys_platform == \"linux\"" +groups = ["all", "default", "test"] +marker = "platform_python_implementation == \"PyPy\" or sys_platform == \"linux\"" dependencies = [ "pycparser", ] @@ -619,16 +619,16 @@ files = [ [[package]] name = "findpython" -version = "0.4.0" -requires_python = ">=3.7" +version = "0.6.0" +requires_python = ">=3.8" summary = "A utility to find python versions on your system" groups = ["all", "default", "test"] dependencies = [ "packaging>=20", ] files = [ - {file = "findpython-0.4.0-py3-none-any.whl", hash = "sha256:087148ac5935f9be458f36a05f3fa479efdf2c629f5d386c73ea481cfecff15e"}, - {file = "findpython-0.4.0.tar.gz", hash = "sha256:18b14d115678da18ae92ee22d7001cc30915ea531053f77010ee05a39680f438"}, + {file = "findpython-0.6.0-py3-none-any.whl", hash = "sha256:08a2059140dce0d2b48509bad52ac9601f87322c650cf5c1893ed3ede89316eb"}, + {file = "findpython-0.6.0.tar.gz", hash = "sha256:036a7841b88e2f372433a589b1f09219519737d2989eb5f5370d70afbcf84765"}, ] [[package]] @@ -1291,6 +1291,33 @@ files = [ {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, ] +[[package]] +name = "pbs-installer" +version = "2024.3.27" +requires_python = ">=3.8" +summary = "Installer for Python Build Standalone" +groups = ["all", "default", "test"] +files = [ + {file = "pbs_installer-2024.3.27-py3-none-any.whl", hash = "sha256:6299ca01a91cc87033fa4176106a977da16e2484b4094ef4bacb576f822c0392"}, + {file = "pbs_installer-2024.3.27.tar.gz", hash = "sha256:30a368d14710a5e38a57401006b4c1061f030ed06d42d69c8f5e8dd20ee25d58"}, +] + +[[package]] +name = "pbs-installer" +version = "2024.3.27" +extras = ["install"] +requires_python = ">=3.8" +summary = "Installer for Python Build Standalone" +groups = ["all", "default", "test"] +dependencies = [ + "pbs-installer==2024.3.27", + "zstandard>=0.21.0", +] +files = [ + {file = "pbs_installer-2024.3.27-py3-none-any.whl", hash = "sha256:6299ca01a91cc87033fa4176106a977da16e2484b4094ef4bacb576f822c0392"}, + {file = "pbs_installer-2024.3.27.tar.gz", hash = "sha256:30a368d14710a5e38a57401006b4c1061f030ed06d42d69c8f5e8dd20ee25d58"}, +] + [[package]] name = "pdm-pep517" version = "1.1.4" @@ -1368,8 +1395,8 @@ name = "pycparser" version = "2.21" requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" summary = "C parser in Python" -groups = ["all"] -marker = "sys_platform == \"linux\"" +groups = ["all", "default", "test"] +marker = "platform_python_implementation == \"PyPy\" or sys_platform == \"linux\"" files = [ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, @@ -2175,3 +2202,61 @@ files = [ {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, ] + +[[package]] +name = "zstandard" +version = "0.22.0" +requires_python = ">=3.8" +summary = "Zstandard bindings for Python" +groups = ["all", "default", "test"] +dependencies = [ + "cffi>=1.11; platform_python_implementation == \"PyPy\"", +] +files = [ + {file = "zstandard-0.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:275df437ab03f8c033b8a2c181e51716c32d831082d93ce48002a5227ec93019"}, + {file = "zstandard-0.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ac9957bc6d2403c4772c890916bf181b2653640da98f32e04b96e4d6fb3252a"}, + {file = "zstandard-0.22.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe3390c538f12437b859d815040763abc728955a52ca6ff9c5d4ac707c4ad98e"}, + {file = "zstandard-0.22.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1958100b8a1cc3f27fa21071a55cb2ed32e9e5df4c3c6e661c193437f171cba2"}, + {file = "zstandard-0.22.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93e1856c8313bc688d5df069e106a4bc962eef3d13372020cc6e3ebf5e045202"}, + {file = "zstandard-0.22.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1a90ba9a4c9c884bb876a14be2b1d216609385efb180393df40e5172e7ecf356"}, + {file = "zstandard-0.22.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3db41c5e49ef73641d5111554e1d1d3af106410a6c1fb52cf68912ba7a343a0d"}, + {file = "zstandard-0.22.0-cp310-cp310-win32.whl", hash = "sha256:d8593f8464fb64d58e8cb0b905b272d40184eac9a18d83cf8c10749c3eafcd7e"}, + {file = "zstandard-0.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:f1a4b358947a65b94e2501ce3e078bbc929b039ede4679ddb0460829b12f7375"}, + {file = "zstandard-0.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:589402548251056878d2e7c8859286eb91bd841af117dbe4ab000e6450987e08"}, + {file = "zstandard-0.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a97079b955b00b732c6f280d5023e0eefe359045e8b83b08cf0333af9ec78f26"}, + {file = "zstandard-0.22.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:445b47bc32de69d990ad0f34da0e20f535914623d1e506e74d6bc5c9dc40bb09"}, + {file = "zstandard-0.22.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33591d59f4956c9812f8063eff2e2c0065bc02050837f152574069f5f9f17775"}, + {file = "zstandard-0.22.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:888196c9c8893a1e8ff5e89b8f894e7f4f0e64a5af4d8f3c410f0319128bb2f8"}, + {file = "zstandard-0.22.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:53866a9d8ab363271c9e80c7c2e9441814961d47f88c9bc3b248142c32141d94"}, + {file = "zstandard-0.22.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4ac59d5d6910b220141c1737b79d4a5aa9e57466e7469a012ed42ce2d3995e88"}, + {file = "zstandard-0.22.0-cp311-cp311-win32.whl", hash = "sha256:2b11ea433db22e720758cba584c9d661077121fcf60ab43351950ded20283440"}, + {file = "zstandard-0.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:11f0d1aab9516a497137b41e3d3ed4bbf7b2ee2abc79e5c8b010ad286d7464bd"}, + {file = "zstandard-0.22.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6c25b8eb733d4e741246151d895dd0308137532737f337411160ff69ca24f93a"}, + {file = "zstandard-0.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9b2cde1cd1b2a10246dbc143ba49d942d14fb3d2b4bccf4618d475c65464912"}, + {file = "zstandard-0.22.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a88b7df61a292603e7cd662d92565d915796b094ffb3d206579aaebac6b85d5f"}, + {file = "zstandard-0.22.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466e6ad8caefb589ed281c076deb6f0cd330e8bc13c5035854ffb9c2014b118c"}, + {file = "zstandard-0.22.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1d67d0d53d2a138f9e29d8acdabe11310c185e36f0a848efa104d4e40b808e4"}, + {file = "zstandard-0.22.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:39b2853efc9403927f9065cc48c9980649462acbdf81cd4f0cb773af2fd734bc"}, + {file = "zstandard-0.22.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8a1b2effa96a5f019e72874969394edd393e2fbd6414a8208fea363a22803b45"}, + {file = "zstandard-0.22.0-cp312-cp312-win32.whl", hash = "sha256:88c5b4b47a8a138338a07fc94e2ba3b1535f69247670abfe422de4e0b344aae2"}, + {file = "zstandard-0.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:de20a212ef3d00d609d0b22eb7cc798d5a69035e81839f549b538eff4105d01c"}, + {file = "zstandard-0.22.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d75f693bb4e92c335e0645e8845e553cd09dc91616412d1d4650da835b5449df"}, + {file = "zstandard-0.22.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:36a47636c3de227cd765e25a21dc5dace00539b82ddd99ee36abae38178eff9e"}, + {file = "zstandard-0.22.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68953dc84b244b053c0d5f137a21ae8287ecf51b20872eccf8eaac0302d3e3b0"}, + {file = "zstandard-0.22.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2612e9bb4977381184bb2463150336d0f7e014d6bb5d4a370f9a372d21916f69"}, + {file = "zstandard-0.22.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23d2b3c2b8e7e5a6cb7922f7c27d73a9a615f0a5ab5d0e03dd533c477de23004"}, + {file = "zstandard-0.22.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d43501f5f31e22baf822720d82b5547f8a08f5386a883b32584a185675c8fbf"}, + {file = "zstandard-0.22.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a493d470183ee620a3df1e6e55b3e4de8143c0ba1b16f3ded83208ea8ddfd91d"}, + {file = "zstandard-0.22.0-cp38-cp38-win32.whl", hash = "sha256:7034d381789f45576ec3f1fa0e15d741828146439228dc3f7c59856c5bcd3292"}, + {file = "zstandard-0.22.0-cp38-cp38-win_amd64.whl", hash = "sha256:d8fff0f0c1d8bc5d866762ae95bd99d53282337af1be9dc0d88506b340e74b73"}, + {file = "zstandard-0.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2fdd53b806786bd6112d97c1f1e7841e5e4daa06810ab4b284026a1a0e484c0b"}, + {file = "zstandard-0.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:73a1d6bd01961e9fd447162e137ed949c01bdb830dfca487c4a14e9742dccc93"}, + {file = "zstandard-0.22.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9501f36fac6b875c124243a379267d879262480bf85b1dbda61f5ad4d01b75a3"}, + {file = "zstandard-0.22.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48f260e4c7294ef275744210a4010f116048e0c95857befb7462e033f09442fe"}, + {file = "zstandard-0.22.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959665072bd60f45c5b6b5d711f15bdefc9849dd5da9fb6c873e35f5d34d8cfb"}, + {file = "zstandard-0.22.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d22fdef58976457c65e2796e6730a3ea4a254f3ba83777ecfc8592ff8d77d303"}, + {file = "zstandard-0.22.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a7ccf5825fd71d4542c8ab28d4d482aace885f5ebe4b40faaa290eed8e095a4c"}, + {file = "zstandard-0.22.0-cp39-cp39-win32.whl", hash = "sha256:f058a77ef0ece4e210bb0450e68408d4223f728b109764676e1a13537d056bb0"}, + {file = "zstandard-0.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:e9e9d4e2e336c529d4c435baad846a181e39a982f823f7e4495ec0b0ec8538d2"}, + {file = "zstandard-0.22.0.tar.gz", hash = "sha256:8226a33c542bcb54cd6bd0a366067b610b41713b64c9abec1bc4533d69f51e70"}, +] diff --git a/pyproject.toml b/pyproject.toml index 4e8fefc884..4915163866 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ requires-python = ">=3.8" license = {text = "MIT"} dependencies = [ "blinker", - "certifi", "packaging>=20.9,!=22.0", "platformdirs", "rich>=12.3.0", @@ -23,7 +22,7 @@ dependencies = [ "pyproject-hooks", "unearth>=0.15.0", "dep-logic>=0.2.0,<1.0", - "findpython>=0.4.0,<1.0.0a0", + "findpython>=0.6.0,<1.0.0a0", "tomlkit>=0.11.1,<1", "shellingham>=1.3.2", "python-dotenv>=0.15", @@ -35,6 +34,7 @@ dependencies = [ "importlib-metadata>=3.6; python_version < \"3.10\"", "hishel>=0.0.24,<0.1.0", "msgpack>=1.0", + "pbs-installer[install]", ] readme = "README.md" keywords = ["packaging", "dependency", "workflow"] diff --git a/src/pdm/cli/commands/python.py b/src/pdm/cli/commands/python.py new file mode 100644 index 0000000000..5658fe2317 --- /dev/null +++ b/src/pdm/cli/commands/python.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import shutil +import sys +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING + +from pdm.cli.commands.base import BaseCommand +from pdm.cli.options import verbose_option +from pdm.exceptions import InstallationError +from pdm.models.python import PythonInfo + +if TYPE_CHECKING: + from argparse import ArgumentParser, Namespace, _SubParsersAction + from typing import Any + + from pdm.project.core import Project + + +class Command(BaseCommand): + """Manage installed Python interpreters""" + + arguments = () + + def add_arguments(self, parser: ArgumentParser) -> None: + self.parser = parser + subparsers = parser.add_subparsers(title="commands", metavar="") + ListCommand.register_to(subparsers, name="list") + RemoveCommand.register_to(subparsers, name="remove") + InstallCommand.register_to(subparsers, name="install") + + @classmethod + def register_to(cls, subparsers: _SubParsersAction, name: str | None = None, **kwargs: Any) -> None: + return super().register_to(subparsers, name, aliases=["py"], **kwargs) + + def handle(self, project: Project, options: Namespace) -> None: + self.parser.print_help() + + +class ListCommand(BaseCommand): + """List all Python interpreters installed with PDM""" + + arguments = (verbose_option,) + + def handle(self, project: Project, options: Namespace) -> None: + from findpython.providers.rye import RyeProvider + + ui = project.core.ui + provider = RyeProvider(root=Path(project.config["python.install_root"]).expanduser()) + for version in provider.find_pythons(): + ui.echo(f"[success]{version.implementation.lower()}@{version.version}[/] ({version.executable})") + + +class RemoveCommand(BaseCommand): + """Remove a Python interpreter installed with PDM""" + + arguments = (verbose_option,) + + def add_arguments(self, parser: ArgumentParser) -> None: + parser.add_argument("version", help="The Python version to remove. E.g. cpython@3.10.3") + + def handle(self, project: Project, options: Namespace) -> None: + ui = project.core.ui + root = Path(project.config["python.install_root"]).expanduser() + if not root.exists(): + ui.error(f"No Python interpreter found for {options.version!r}") + sys.exit(1) + version = options.version.lower() + if "@" not in version: # pragma: no cover + version = f"cpython@{version}" + matched = next((child for child in root.iterdir() if child.name == version), None) + if not matched: + ui.error(f"No Python interpreter found for {options.version!r}") + ui.echo("Installed Pythons:", err=True) + for child in root.iterdir(): + ui.echo(f" {child.name}", err=True) + sys.exit(1) + shutil.rmtree(matched, ignore_errors=True) + ui.echo(f"[success]Removed installed[/] {options.version}") + + +class InstallCommand(BaseCommand): + """Install a Python interpreter with PDM""" + + arguments = (verbose_option,) + + def add_arguments(self, parser: ArgumentParser) -> None: + parser.add_argument("version", help="The Python version to install. E.g. cpython@3.10.3", nargs="?") + parser.add_argument("--list", "-l", action="store_true", help="List all available Python versions") + + def handle(self, project: Project, options: Namespace) -> None: + from pbs_installer._versions import PYTHON_VERSIONS + + if options.list: + for version in PYTHON_VERSIONS: + project.core.ui.echo(str(version)) + return + self.install_python(project, options.version) + + @staticmethod + def install_python(project: Project, request: str) -> PythonInfo: + from pbs_installer import download, get_download_link, install_file + from pbs_installer._install import THIS_ARCH + + from pdm.termui import logger + + ui = project.core.ui + root = Path(project.config["python.install_root"]).expanduser() + + implementation, _, version = request.rpartition("@") + implementation = implementation.lower() or "cpython" + version, _, arch = version.partition("-") + arch = "x86" if arch == "32" else (arch or THIS_ARCH) + + ver, python_file = get_download_link(version, implementation=implementation, arch=arch) + with ui.open_spinner(f"Downloading [success]{ver}[/]") as spinner: + destination = root / str(ver) + logger.debug("Installing %s to %s", ver, destination) + if not destination.exists(): + destination.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile() as tf: + tf.close() + original_filename = download(python_file, tf.name) + spinner.update(f"Installing [success]{ver}[/]") + install_file(tf.name, destination, original_filename, build_dir=False) + + interpreter = destination / "bin" / "python3" if sys.platform != "win32" else destination / "python.exe" + if not interpreter.exists(): + raise InstallationError("Installation failed") + + python_info = PythonInfo.from_path(interpreter) + ui.echo(f"[success]Successfully installed[/] {python_info.implementation}@{python_info.version}") + ui.echo(f"[info]Version:[/] {python_info.version}") + ui.echo(f"[info]Executable:[/] {python_info.path}") + return python_info diff --git a/src/pdm/cli/commands/use.py b/src/pdm/cli/commands/use.py index 33e5472da9..e9b8a9f9af 100644 --- a/src/pdm/cli/commands/use.py +++ b/src/pdm/cli/commands/use.py @@ -15,7 +15,7 @@ class Command(BaseCommand): - """Use the given python version or path as base interpreter""" + """Use the given python version or path as base interpreter. If not found, PDM will try to install one.""" def add_arguments(self, parser: argparse.ArgumentParser) -> None: skip_option.add_to_parser(parser) @@ -70,32 +70,27 @@ def version_matcher(py_version: PythonInfo) -> bool: project.core.ui.info("Using the last selection, add '-i' to ignore it.") return cached_python - found_interpreters = list(dict.fromkeys(project.find_interpreters(python))) - matching_interpreters = list(filter(version_matcher, found_interpreters)) + found_interpreters = list(dict.fromkeys(project.iter_interpreters(python, filter_func=version_matcher))) if not found_interpreters: - raise NoPythonVersion(f"No Python interpreter matching [success]{python}[/] is found.") - if not matching_interpreters: - project.core.ui.echo("Interpreters found but not matching:", err=True) - for py in found_interpreters: - info = py.identifier if py.valid else "Invalid" - project.core.ui.echo(f" - {py.path} ({info})", err=True) - raise NoPythonVersion( - f"No python is found meeting the requirement [success]python {project.python_requires!s}[/]" - ) - if first or len(matching_interpreters) == 1: - return matching_interpreters[0] + req = python if ignore_requires_python else f'requires-python="{project.python_requires}"' + raise NoPythonVersion(f"No Python interpreter matching [success]{req}[/] is found.") + + if first or len(found_interpreters) == 1: + return found_interpreters[0] project.core.ui.echo("Please enter the Python interpreter to use") - for i, py_version in enumerate(matching_interpreters): - project.core.ui.echo(f"{i}. [success]{py_version.path!s}[/] ({py_version.identifier})") + for i, py_version in enumerate(found_interpreters): + project.core.ui.echo( + f"{i:>2}. [success]{py_version.implementation}@{py_version.identifier}[/] ({py_version.path!s})" + ) selection = termui.ask( "Please select", default="0", prompt_type=int, - choices=[str(i) for i in range(len(matching_interpreters))], + choices=[str(i) for i in range(len(found_interpreters))], show_choices=False, ) - return matching_interpreters[int(selection)] + return found_interpreters[int(selection)] def do_use( self, diff --git a/src/pdm/cli/commands/venv/backends.py b/src/pdm/cli/commands/venv/backends.py index 3acab6d628..2f6b33e911 100644 --- a/src/pdm/cli/commands/venv/backends.py +++ b/src/pdm/cli/commands/venv/backends.py @@ -37,10 +37,16 @@ def _resolved_interpreter(self) -> PythonInfo: project_python = self.project._python if project_python: return project_python - # disable venv provider temporarily - for py_version in self.project.find_interpreters(self.python, search_venv=False): - if self.python or py_version.valid and self.project.python_requires.contains(py_version.version, True): - return py_version + + def match_func(py_version: PythonInfo) -> bool: + return ( + bool(self.python) + or py_version.valid + and self.project.python_requires.contains(py_version.version, True) + ) + + for py_version in self.project.iter_interpreters(self.python, search_venv=False, filter_func=match_func): + return py_version python = f" {self.python}" if self.python else "" raise VirtualenvCreateError(f"Can't resolve python interpreter{python}") diff --git a/src/pdm/cli/completions/pdm.bash b/src/pdm/cli/completions/pdm.bash index 33c1a4a009..975f31c968 100644 --- a/src/pdm/cli/completions/pdm.bash +++ b/src/pdm/cli/completions/pdm.bash @@ -92,6 +92,14 @@ _pdm_a919b69078acdf0a_complete() opts="--ca-certs --comment --help --identity --no-build --no-very-ssl --password --project --quiet --repository --sign --skip --skip-existing --username --verbose" ;; + (py) + opts="--help" + ;; + + (python) + opts="--help" + ;; + (remove) opts="--config-setting --dev --dry-run --fail-fast --frozen-lockfile --global --group --help --lockfile --no-editable --no-isolation --no-self --no-sync --project --quiet --skip --venv --verbose" ;; @@ -138,7 +146,7 @@ _pdm_a919b69078acdf0a_complete() # completing for a command if [[ $cur == $com ]]; then - coms="add build cache completion config export fix import info init install list lock outdated plugin publish remove run search self show sync update use venv" + coms="add build cache completion config export fix import info init install list lock outdated plugin publish py python remove run search self show sync update use venv" COMPREPLY=($(compgen -W "${coms}" -- ${cur})) __ltrim_colon_completions "$cur" diff --git a/src/pdm/cli/completions/pdm.fish b/src/pdm/cli/completions/pdm.fish index ff20fcc8d5..0dacef6b13 100644 --- a/src/pdm/cli/completions/pdm.fish +++ b/src/pdm/cli/completions/pdm.fish @@ -3,7 +3,7 @@ function __fish_pdm_a919b69078acdf0a_complete_no_subcommand for i in (commandline -opc) - if contains -- $i add build cache completion config export fix import info init install list lock outdated plugin publish remove run search self show sync update use venv + if contains -- $i add build cache completion config export fix import info init install list lock outdated plugin publish py python remove run search self show sync update use venv return 1 end end @@ -322,6 +322,54 @@ complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l skip-existing -d complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l username -d 'The username to access the repository [env var: PDM_PUBLISH_USERNAME]' complete -c pdm -A -n '__fish_seen_subcommand_from publish' -l verbose -d 'Use `-v` for detailed output and `-vv` for more detailed' +# py +complete -c pdm -f -n '__fish_pdm_a919b69078acdf0a_complete_no_subcommand' -a py -d 'Manage installed Python interpreters' +complete -c pdm -A -n '__fish_seen_subcommand_from py' -l help -d 'Show this help message and exit.' +# py subcommands +set -l py_subcommands install list remove +# py install +complete -c pdm -f -n '__fish_seen_subcommand_from py; and not __fish_seen_subcommand_from $py_subcommands' -a install -d 'Install a Python interpreter with PDM' +complete -c pdm -A -n '__fish_seen_subcommand_from py; and __fish_seen_subcommand_from install' -l help -d 'Show this help message and exit.' +complete -c pdm -A -n '__fish_seen_subcommand_from py; and __fish_seen_subcommand_from install' -l list -d 'List all available Python versions' +complete -c pdm -A -n '__fish_seen_subcommand_from py; and __fish_seen_subcommand_from install' -l quiet -d 'Suppress output' +complete -c pdm -A -n '__fish_seen_subcommand_from py; and __fish_seen_subcommand_from install' -l verbose -d 'Use `-v` for detailed output and `-vv` for more detailed' + +# py list +complete -c pdm -f -n '__fish_seen_subcommand_from py; and not __fish_seen_subcommand_from $py_subcommands' -a list -d 'List all Python interpreters installed with PDM' +complete -c pdm -A -n '__fish_seen_subcommand_from py; and __fish_seen_subcommand_from list' -l help -d 'Show this help message and exit.' +complete -c pdm -A -n '__fish_seen_subcommand_from py; and __fish_seen_subcommand_from list' -l quiet -d 'Suppress output' +complete -c pdm -A -n '__fish_seen_subcommand_from py; and __fish_seen_subcommand_from list' -l verbose -d 'Use `-v` for detailed output and `-vv` for more detailed' + +# py remove +complete -c pdm -f -n '__fish_seen_subcommand_from py; and not __fish_seen_subcommand_from $py_subcommands' -a remove -d 'Remove a Python interpreter installed with PDM' +complete -c pdm -A -n '__fish_seen_subcommand_from py; and __fish_seen_subcommand_from remove' -l help -d 'Show this help message and exit.' +complete -c pdm -A -n '__fish_seen_subcommand_from py; and __fish_seen_subcommand_from remove' -l quiet -d 'Suppress output' +complete -c pdm -A -n '__fish_seen_subcommand_from py; and __fish_seen_subcommand_from remove' -l verbose -d 'Use `-v` for detailed output and `-vv` for more detailed' + +# python +complete -c pdm -f -n '__fish_pdm_a919b69078acdf0a_complete_no_subcommand' -a python -d 'Manage installed Python interpreters' +complete -c pdm -A -n '__fish_seen_subcommand_from python' -l help -d 'Show this help message and exit.' +# python subcommands +set -l python_subcommands install list remove +# python install +complete -c pdm -f -n '__fish_seen_subcommand_from python; and not __fish_seen_subcommand_from $python_subcommands' -a install -d 'Install a Python interpreter with PDM' +complete -c pdm -A -n '__fish_seen_subcommand_from python; and __fish_seen_subcommand_from install' -l help -d 'Show this help message and exit.' +complete -c pdm -A -n '__fish_seen_subcommand_from python; and __fish_seen_subcommand_from install' -l list -d 'List all available Python versions' +complete -c pdm -A -n '__fish_seen_subcommand_from python; and __fish_seen_subcommand_from install' -l quiet -d 'Suppress output' +complete -c pdm -A -n '__fish_seen_subcommand_from python; and __fish_seen_subcommand_from install' -l verbose -d 'Use `-v` for detailed output and `-vv` for more detailed' + +# python list +complete -c pdm -f -n '__fish_seen_subcommand_from python; and not __fish_seen_subcommand_from $python_subcommands' -a list -d 'List all Python interpreters installed with PDM' +complete -c pdm -A -n '__fish_seen_subcommand_from python; and __fish_seen_subcommand_from list' -l help -d 'Show this help message and exit.' +complete -c pdm -A -n '__fish_seen_subcommand_from python; and __fish_seen_subcommand_from list' -l quiet -d 'Suppress output' +complete -c pdm -A -n '__fish_seen_subcommand_from python; and __fish_seen_subcommand_from list' -l verbose -d 'Use `-v` for detailed output and `-vv` for more detailed' + +# python remove +complete -c pdm -f -n '__fish_seen_subcommand_from python; and not __fish_seen_subcommand_from $python_subcommands' -a remove -d 'Remove a Python interpreter installed with PDM' +complete -c pdm -A -n '__fish_seen_subcommand_from python; and __fish_seen_subcommand_from remove' -l help -d 'Show this help message and exit.' +complete -c pdm -A -n '__fish_seen_subcommand_from python; and __fish_seen_subcommand_from remove' -l quiet -d 'Suppress output' +complete -c pdm -A -n '__fish_seen_subcommand_from python; and __fish_seen_subcommand_from remove' -l verbose -d 'Use `-v` for detailed output and `-vv` for more detailed' + # remove complete -c pdm -f -n '__fish_pdm_a919b69078acdf0a_complete_no_subcommand' -a remove -d 'Remove packages from pyproject.toml' complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l config-setting -d 'Pass options to the builder. options with a value must be specified after "=": `--config-setting=key(=value)` or `-Ckey(=value)`' diff --git a/src/pdm/cli/completions/pdm.ps1 b/src/pdm/cli/completions/pdm.ps1 index 546f9382b1..a307c65beb 100644 --- a/src/pdm/cli/completions/pdm.ps1 +++ b/src/pdm/cli/completions/pdm.ps1 @@ -192,7 +192,11 @@ function TabExpansion($line, $lastWord) { if ($lastBlock -match "^pdm ") { [string[]]$words = $lastBlock.Split()[1..$lastBlock.Length] - [string[]]$AllCommands = ("add", "build", "cache", "completion", "config", "export", "fix", "import", "info", "init", "install", "list", "lock", "outdated", "plugin", "publish", "remove", "run", "search", "show", "sync", "update", "use") + [string[]]$AllCommands = ( + "add", "build", "cache", "completion", "config", "export", "fix", "import", "info", "init", "install", + "list", "lock", "outdated", "plugin", "publish", "remove", "run", "search", "show", "sync", "update", + "use", "python", "py" + ) [string[]]$commands = $words.Where( { $_ -notlike "-*" }) $command = $commands[0] $completer = [Completer]::new() @@ -350,18 +354,22 @@ function TabExpansion($line, $lastWord) { $completer.AddOpts(([Option]::new(("--pip-args")))) $completer.AddParams(@(getPyPIPackages), $true) $command = $subCommand + break } "remove" { $completer.AddOpts(([Option]::new(("--pip-args", "-y", "--yes")))) $command = $subCommand + break } "list" { $completer.AddOpts(([Option]::new(("--plugins")))) $command = $subCommand + break } "update" { $completer.AddOpts(([Option]::new(("--pip-args", "--head", "--pre")))) $command = $subCommand + break } Default { $completer.AddParams(@("add", "remove", "list", "update"), $false) @@ -381,6 +389,29 @@ function TabExpansion($line, $lastWord) { )) break } + "py" {} + "python" { + $subCommand = $commands[1] + switch ($subCommand) { + "list" { + $command = $subCommand + break + } + "remove" { + $command = $subCommand + break + } + "install" { + $completer.AddOpts(([Option]::new(("--list"))) + $command = $subCommand + break + } + Default { + break + } + } + break + } "remove" { $completer.AddOpts( @( diff --git a/src/pdm/cli/completions/pdm.zsh b/src/pdm/cli/completions/pdm.zsh index 9db84b1ab7..498173f5c2 100644 --- a/src/pdm/cli/completions/pdm.zsh +++ b/src/pdm/cli/completions/pdm.zsh @@ -32,6 +32,8 @@ _pdm() { 'self:Manage the PDM program itself (previously known as plugin)' 'outdated:Check for outdated packages and list the latest versions' 'publish:Build and publish the project to PyPI' + 'python:Manage installed Python interpreters' + 'py:Manage installed Python interpreters' 'remove:Remove packages from pyproject.toml' 'run:Run commands or scripts with local packages loaded' 'search:Search for PyPI packages' @@ -324,6 +326,40 @@ _pdm() { esac return $ret ;; + python|py) + _arguments -C \ + $arguments \ + ': :->command' \ + '*:: :->args' && ret=0 + case $state in + command) + local -a actions=( + "remove:Remove a Python interpreter installed with PDM" + "list:List all Python interpreters installed with PDM" + "install:Install a Python interpreter with PDM" + ) + _describe -t command 'pdm python actions' actions && ret=0 + ;; + args) + case $words[1] in + remove) + arguments+=( + ':python:' + ) + ;; + install) + arguments+=( + '--list[List all available Python versions]' + ':python:_files' + ) + ;; + *) + ;; + esac + ;; + esac + return $ret + ;; publish) arguments+=( {-r,--repository}'[The repository name or url to publish the package to }\[env var: PDM_PUBLISH_REPO\]]:repository:' diff --git a/src/pdm/cli/options.py b/src/pdm/cli/options.py index 212af3a744..ced2386a63 100644 --- a/src/pdm/cli/options.py +++ b/src/pdm/cli/options.py @@ -189,7 +189,7 @@ def frozen_lockfile_option( ) -> None: if option_string == "--no-lock": project.core.ui.warn("--no-lock is deprecated, use --frozen-lockfile instead.") - project.enable_write_lockfile = False + project.enable_write_lockfile = False # type: ignore[has-type] @Option("--pep582", const="AUTO", metavar="SHELL", nargs="?", help="Print the command line to be eval'd by the shell") diff --git a/src/pdm/models/python.py b/src/pdm/models/python.py index 34c19fd1bd..8d709756c9 100644 --- a/src/pdm/models/python.py +++ b/src/pdm/models/python.py @@ -52,6 +52,10 @@ def executable(self) -> Path: def version(self) -> Version: return self._py_ver.version + @cached_property + def implementation(self) -> str: + return self._py_ver.implementation.lower() + @property def major(self) -> int: return self.version.major diff --git a/src/pdm/project/config.py b/src/pdm/project/config.py index d27e480e6c..744af59fed 100644 --- a/src/pdm/project/config.py +++ b/src/pdm/project/config.py @@ -175,6 +175,11 @@ class Config(MutableMapping[str, str]): "python.use_venv": ConfigItem( "Use virtual environments when available", True, env_var="PDM_USE_VENV", coerce=ensure_boolean ), + "python.install_root": ConfigItem( + "The root directory to install python interpreters", + global_only=True, + default=os.path.join(platformdirs.user_data_dir("pdm"), "python"), + ), "pypi.url": ConfigItem( "The URL of PyPI mirror, defaults to https://pypi.org/simple", DEFAULT_PYPI_INDEX, diff --git a/src/pdm/project/core.py b/src/pdm/project/core.py index fda561525e..03f575dc64 100644 --- a/src/pdm/project/core.py +++ b/src/pdm/project/core.py @@ -246,11 +246,10 @@ def note(message: str) -> None: return self.python if self.root.joinpath("__pypackages__").exists() or not config["python.use_venv"]: - for py_version in self.find_interpreters(): - if match_version(py_version): - note("[success]__pypackages__[/] is detected, using the PEP 582 mode") - self.python = py_version - return py_version + for py_version in self.iter_interpreters(filter_func=match_version): + note("[success]__pypackages__[/] is detected, using the PEP 582 mode") + self.python = py_version + return py_version raise NoPythonVersion(f"No Python that satisfies {self.python_requires} is found on the system.") @@ -626,6 +625,47 @@ def make_hash_cache(self) -> HashCache: return HashCache(directory=self.cache("hashes")) + def iter_interpreters( + self, + python_spec: str | None = None, + search_venv: bool | None = None, + filter_func: Callable[[PythonInfo], bool] | None = None, + ) -> Iterable[PythonInfo]: + """Iterate over all interpreters that matches the given specifier. + And optionally install the interpreter if not found. + """ + from pbs_installer._versions import PYTHON_VERSIONS, PythonVersion + + from pdm.cli.commands.python import InstallCommand + + found = False + for interpreter in self.find_interpreters(python_spec, search_venv): + if filter_func is None or filter_func(interpreter): + found = True + yield interpreter + if found: + return + + def get_version(version: PythonVersion) -> str: + return f"{version.major}.{version.minor}.{version.micro}" + + if not python_spec: # handle both empty string and None + # Get the best match meeting the requires-python + best_match = next((v for v in PYTHON_VERSIONS if get_version(v) in self.python_requires), None) + if best_match is None: + return + python_spec = str(best_match) + + try: + # otherwise if no interpreter is found, try to install it + installed = InstallCommand.install_python(self, python_spec) + except Exception as e: + self.core.ui.error(f"Failed to install Python {python_spec}: {e}") + return + else: + if filter_func is None or filter_func(installed): + yield installed + def find_interpreters( self, python_spec: str | None = None, search_venv: bool | None = None ) -> Iterable[PythonInfo]: @@ -682,7 +722,15 @@ def _get_python_finder(self, search_venv: bool = True) -> Finder: from pdm.cli.commands.venv.utils import VenvProvider providers: list[str] = self.config["python.providers"] - finder = Finder(resolve_symlinks=True, selected_providers=providers or None) + old_rye_root = os.getenv("RYE_PY_ROOT") + os.environ["RYE_PY_ROOT"] = os.path.expanduser(self.config["python.install_root"]) + try: + finder = Finder(resolve_symlinks=True, selected_providers=providers or None) + finally: + if old_rye_root: # pragma: no cover + os.environ["RYE_PY_ROOT"] = old_rye_root + else: + del os.environ["RYE_PY_ROOT"] if search_venv and (not providers or "venv" in providers): venv_pos = providers.index("venv") if providers else 0 finder.add_provider(VenvProvider(self), venv_pos) diff --git a/src/pdm/pytest.py b/src/pdm/pytest.py index a7e87b733a..e49605e838 100644 --- a/src/pdm/pytest.py +++ b/src/pdm/pytest.py @@ -44,6 +44,7 @@ import httpx import pytest +from httpx._content import IteratorByteStream from pytest_mock import MockerFixture from unearth import Link @@ -72,6 +73,11 @@ from pdm._types import CandidateInfo, FileHash, RepositoryConfig +class FileByteStream(IteratorByteStream): + def close(self) -> None: + self._stream.close() # type: ignore[attr-defined] + + class LocalIndexTransport(httpx.BaseTransport): """ A local file transport for HTTPX. @@ -103,8 +109,6 @@ def get_file_path(self, path: str) -> Path | None: return None def handle_request(self, request: httpx.Request) -> httpx.Response: - from httpx._content import IteratorByteStream - request_path = request.url.path file_path = self.get_file_path(request_path) headers: dict[str, str] = {} @@ -118,7 +122,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: status_code = 404 else: status_code = 200 - stream = IteratorByteStream(file_path.open("rb")) + stream = FileByteStream(file_path.open("rb")) if file_path.suffix == ".html": headers["Content-Type"] = "text/html" elif file_path.suffix == ".json": @@ -381,6 +385,7 @@ def project_no_init( '[global_project]\npath = "{}"\n'.format(test_home.joinpath("global-project").as_posix()) ) p = core.create_project(tmp_path, global_config=test_home.joinpath("config.toml").as_posix()) + p.global_config["python.install_root"] = str(tmp_path / "pythons") p.global_config["venv.location"] = str(tmp_path / "venvs") mocker.patch.object(BaseEnvironment, "_build_session", build_test_session) mocker.patch("pdm.builders.base.EnvBuilder.get_shared_env", return_value=str(build_env)) diff --git a/src/pdm/termui.py b/src/pdm/termui.py index 2984571e53..df766d6ab2 100644 --- a/src/pdm/termui.py +++ b/src/pdm/termui.py @@ -290,4 +290,4 @@ def warn(self, message: str, verbosity: Verbosity = Verbosity.QUIET) -> None: def error(self, message: str, verbosity: Verbosity = Verbosity.QUIET) -> None: """Print a message to stdout.""" - self.echo(f"[error]WARNING:[/] {message}", err=True, verbosity=verbosity) + self.echo(f"[error]ERROR:[/] {message}", err=True, verbosity=verbosity) diff --git a/tests/cli/test_python.py b/tests/cli/test_python.py new file mode 100644 index 0000000000..f5888071ef --- /dev/null +++ b/tests/cli/test_python.py @@ -0,0 +1,89 @@ +import sys +from pathlib import Path + +import pytest +from packaging.version import Version + + +@pytest.fixture +def mock_install(mocker): + def install_file( + filename, + destination, + original_filename=None, + build_dir=False, + ) -> None: + if sys.platform == "win32": + Path(destination, "python.exe").touch() + else: + Path(destination, "bin").mkdir(parents=True, exist_ok=True) + Path(destination, "bin", "python3").touch() + + def get_version(self): + if sys.platform == "win32": + return Version(self.executable.parent.name.split("@", 1)[1]) + else: + return Version(self.executable.parent.parent.name.split("@", 1)[1]) + + @property + def interpreter(self): + return self.executable + + @property + def implementation(self): + if sys.platform == "win32": + return self.executable.parent.name.split("@", 1)[0] + else: + return self.executable.parent.parent.name.split("@", 1)[0] + + mocker.patch("pbs_installer.download", return_value="python-3.10.8.tar.gz") + installer = mocker.patch("pbs_installer.install_file", side_effect=install_file) + mocker.patch("findpython.python.PythonVersion.implementation", implementation) + mocker.patch("findpython.python.PythonVersion._get_version", get_version) + mocker.patch("findpython.python.PythonVersion.interpreter", interpreter) + mocker.patch("findpython.python.PythonVersion.architecture", mocker.PropertyMock(return_value="64bit")) + return installer + + +def test_install_python(project, pdm, mock_install): + root = Path(project.config["python.install_root"]) + + pdm(["py", "install", "cpython@3.10.8"], obj=project, strict=True) + mock_install.assert_called_once() + assert (root / "cpython@3.10.8").exists() + + result = pdm(["py", "list"], obj=project, strict=True) + assert result.stdout.splitlines()[0].startswith("cpython@3.10.8") + + result = pdm(["py", "remove", "3.11.1"], obj=project) + assert result.exit_code != 0 + pdm(["py", "remove", "cpython@3.10.8"], obj=project, strict=True) + assert not (root / "cpython@3.10.8").exists() + + result = pdm(["py", "install", "--list"], obj=project, strict=True) + assert len(result.stdout.splitlines()) > 0 + + +def test_use_auto_install_missing(project, pdm, mock_install, mocker): + root = Path(project.config["python.install_root"]) + mocker.patch("pdm.project.Project.find_interpreters", return_value=[]) + + pdm(["use", "3.10.8"], obj=project, strict=True) + mock_install.assert_called_once() + assert (root / "cpython@3.10.8").exists() + + +def test_use_auto_install_pick_latest(project, pdm, mock_install, mocker): + root = Path(project.config["python.install_root"]) + mocker.patch("pdm.project.Project.find_interpreters", return_value=[]) + + pdm(["use", "-v"], obj=project, strict=True) + mock_install.assert_called_once() + assert len(list(root.iterdir())) == 1 + + +def test_use_no_auto_install(project, pdm, mocker): + installer = mocker.patch("pbs_installer.install_file") + + pdm(["use", "-f"], obj=project, strict=True) + installer.assert_not_called()