From 431455a5f4c39d406da6b6a422138cd6829c6a96 Mon Sep 17 00:00:00 2001 From: Abdullah Mujahid <48271080+abdullahmujahidali@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:11:30 +0500 Subject: [PATCH 01/21] docs: clarify install vs update command differences (#10713) (cherry picked from commit 197571e5831bc4fae45124e490093308f1678d7d) --- docs/cli.md | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index db9e28be603..29d5ff14d73 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -475,14 +475,6 @@ poetry init The `install` command reads the `pyproject.toml` file from the current project, resolves the dependencies, and installs them. -{{% note %}} -Normally, you should prefer `poetry sync` to `poetry install` to avoid untracked outdated packages. -However, if you have set `virtualenvs.create = false` to install dependencies into your system environment, -which is discouraged, or `virtualenvs.options.system-site-packages = true` to make -system site-packages available in your virtual environment, you should use `poetry install` -because `poetry sync` will normally not work well in these cases. -{{% /note %}} - ```bash poetry install ``` @@ -493,6 +485,23 @@ This ensures that everyone using the library will get the same versions of the d If there is no `poetry.lock` file, Poetry will create one after dependency resolution. +{{% note %}} +**When to use `install` vs `update`:** +- Use `poetry install` to install dependencies as specified in `poetry.lock` (or resolve dependencies and create the lock file if it is missing). + This is what you run after cloning a project. For reproducible installs, prefer `poetry sync`, + which also removes packages that are not in the lock file. +- Use `poetry update` when you want to update dependencies to their latest versions (within the constraints from the `pyproject.toml`) + and refresh `poetry.lock`. +{{% /note %}} + +{{% note %}} +Normally, you should prefer `poetry sync` to `poetry install` to avoid untracked outdated packages. +However, if you have set `virtualenvs.create = false` to install dependencies into your system environment, +which is discouraged, or `virtualenvs.options.system-site-packages = true` to make +system site-packages available in your virtual environment, you should use `poetry install` +because `poetry sync` will normally not work well in these cases. +{{% /note %}} + If you want to exclude one or more dependency groups for the installation, you can use the `--without` option. @@ -1276,7 +1285,14 @@ you should use the `update` command. poetry update ``` -This will resolve all dependencies of the project and write the exact versions into `poetry.lock`. +This will resolve all dependencies of the project, write the exact versions into `poetry.lock`, +and install them into your environment. + +{{% note %}} +The `update` command **does not** modify your `pyproject.toml` file. It only updates the +`poetry.lock` file with the latest compatible versions based on the constraints already +defined in `pyproject.toml`. To change version constraints, use the `add` command instead. +{{% /note %}} If you just want to update a few packages and not all, you can list them as such: From 1697edd73fa67eb623a43f5dc548a21c2c2e86ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Sat, 14 Feb 2026 10:05:22 +0000 Subject: [PATCH 02/21] Fix AssertionError when cloning at annotated tag (#10719) When cloning a Git repository at an annotated tag, if the peeled tag reference (refs/tags/v1.0.0^{}) is not available in the fetch result, Poetry would set HEAD to the tag object SHA instead of the commit SHA. This caused reset_index() to fail with: AssertionError: assert isinstance(obj, Commit) The fix peels tag objects recursively to extract the underlying commit SHA before setting HEAD. This ensures HEAD always points to a Commit object, not a Tag object. Fixes python-poetry#10658 (cherry picked from commit 2234234696ba8164d6b2d30fb2629ce7b85ff56f) --- src/poetry/vcs/git/backend.py | 15 +++- tests/vcs/git/test_backend.py | 134 ++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/src/poetry/vcs/git/backend.py b/src/poetry/vcs/git/backend.py index d0b028de639..dad4867cbbc 100644 --- a/src/poetry/vcs/git/backend.py +++ b/src/poetry/vcs/git/backend.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import dataclasses import logging import os @@ -20,6 +21,7 @@ from dulwich.errors import NotGitRepository from dulwich.file import FileLocked from dulwich.index import IndexEntry +from dulwich.object_store import peel_sha from dulwich.objects import ObjectID from dulwich.protocol import PEELED_TAG_SUFFIX from dulwich.refs import Ref @@ -96,7 +98,7 @@ def resolve(self, remote_refs: FetchPackResult, repo: Repo) -> None: Resolve the ref using the provided remote refs. """ self._normalise(remote_refs=remote_refs, repo=repo) - self._set_head(remote_refs=remote_refs) + self._set_head(remote_refs=remote_refs, repo=repo) def _normalise(self, remote_refs: FetchPackResult, repo: Repo) -> None: """ @@ -142,7 +144,7 @@ def _normalise(self, remote_refs: FetchPackResult, repo: Repo) -> None: self.revision = sha.decode("utf-8") return - def _set_head(self, remote_refs: FetchPackResult) -> None: + def _set_head(self, remote_refs: FetchPackResult, repo: Repo) -> None: """ Internal helper method to populate ref and set it's sha as the remote's head and default ref. @@ -165,6 +167,15 @@ def _set_head(self, remote_refs: FetchPackResult) -> None: ) head = remote_refs.refs[self.ref] + # Peel tag objects to get the underlying commit SHA. + # Annotated tags are Tag objects, not Commit objects. Operations like + # reset_index() expect HEAD to point to a Commit, so we must peel tags + # to extract the commit SHA they reference. + # Object not in store yet will be handled during fetch + if head is not None: + with contextlib.suppress(KeyError): + head = peel_sha(repo.object_store, head)[1].id + remote_refs.refs[self.ref] = remote_refs.refs[Ref(b"HEAD")] = head @property diff --git a/tests/vcs/git/test_backend.py b/tests/vcs/git/test_backend.py index 2531f76d466..b2c28aa93e7 100644 --- a/tests/vcs/git/test_backend.py +++ b/tests/vcs/git/test_backend.py @@ -8,6 +8,8 @@ import pytest from dulwich.client import FetchPackResult +from dulwich.refs import HEADREF +from dulwich.refs import Ref from dulwich.repo import Repo from poetry.console.exceptions import PoetryRuntimeError @@ -290,3 +292,135 @@ def test_clone_existing_locked_tag(tmp_path: Path, temp_repo: TempRepoFixture) - f"Try again later or remove the {tag_ref_lock} manually" " if you are sure no other process is holding it." ) + + +@pytest.mark.skip_git_mock +def test_clone_annotated_tag(tmp_path: Path) -> None: + """Test cloning at an annotated tag (issue #10658).""" + from dulwich import porcelain + from dulwich.objects import Commit + + # Create a source repository with an annotated tag + source_path = tmp_path / "source-repo" + source_path.mkdir() + repo = Repo.init(str(source_path)) + + # Create initial commit + test_file = source_path / "test.txt" + test_file.write_text("test content", encoding="utf-8") + porcelain.add(repo, str(test_file)) + expected_commit_sha = porcelain.commit( + repo, + message=b"Initial commit", + author=b"Test ", + committer=b"Test ", + ) + + # Create an annotated tag + porcelain.tag_create( + repo, + tag=b"v1.0.0", + message=b"Release 1.0.0", + author=b"Test ", + annotated=True, + ) + + # Clone at the annotated tag + source_root_dir = tmp_path / "clone-root" + source_root_dir.mkdir() + cloned_repo = Git.clone( + url=source_path.as_uri(), + source_root=source_root_dir, + name="clone-test", + tag="v1.0.0", + ) + + # Verify HEAD points to a commit, not a tag object + head_sha = cloned_repo.refs[HEADREF] + head_obj = cloned_repo.object_store[head_sha] + assert isinstance(head_obj, Commit), ( + f"HEAD should point to a Commit, got {type(head_obj).__name__}" + ) + # Verify it's the correct commit + assert head_sha == expected_commit_sha, ( + f"HEAD should point to the expected commit {expected_commit_sha.hex()}, " + f"got {head_sha.hex()}" + ) + + # Verify the clone succeeded and files are present + clone_dir = source_root_dir / "clone-test" + assert (clone_dir / ".git").is_dir() + assert (clone_dir / "test.txt").exists() + assert (clone_dir / "test.txt").read_text(encoding="utf-8") == "test content" + + +@pytest.mark.skip_git_mock +def test_clone_nested_annotated_tags(tmp_path: Path) -> None: + """Test cloning at a tag that points to another tag (nested tags).""" + from dulwich import porcelain + from dulwich.objects import Commit + from dulwich.objects import Tag + + # Create a source repository with nested annotated tags + source_path = tmp_path / "source-repo" + source_path.mkdir() + repo = Repo.init(str(source_path)) + + # Create initial commit + test_file = source_path / "test.txt" + test_file.write_text("nested tag test", encoding="utf-8") + porcelain.add(repo, paths=[b"test.txt"]) + commit_sha = porcelain.commit( + repo, + message=b"Initial commit", + committer=b"Test ", + author=b"Test ", + ) + + # Create first annotated tag pointing to the commit + tag1 = Tag() + tag1.name = b"v1.0.0" + tag1.object = (Commit, commit_sha) + tag1.message = b"First tag" + tag1.tag_time = 1234567890 + tag1.tag_timezone = 0 + tag1.tagger = b"Test " + repo.object_store.add_object(tag1) + repo.refs[Ref(b"refs/tags/v1.0.0")] = tag1.id + + # Create second annotated tag pointing to the first tag + tag2 = Tag() + tag2.name = b"v1.0.0-release" + tag2.object = (Tag, tag1.id) + tag2.message = b"Second tag (points to first tag)" + tag2.tag_time = 1234567891 + tag2.tag_timezone = 0 + tag2.tagger = b"Test " + repo.object_store.add_object(tag2) + repo.refs[Ref(b"refs/tags/v1.0.0-release")] = tag2.id + + # Clone at the nested tag + source_root_dir = tmp_path / "clone-root" + source_root_dir.mkdir() + cloned_repo = Git.clone( + url=source_path.as_uri(), + source_root=source_root_dir, + name="clone-test", + tag="v1.0.0-release", + ) + + # Verify HEAD points to a commit, not a tag object + head_sha = cloned_repo.refs[HEADREF] + head_obj = cloned_repo.object_store[head_sha] + assert isinstance(head_obj, Commit), ( + f"HEAD should point to a Commit (peeling nested tags), got {type(head_obj).__name__}" + ) + + # Verify it's the correct commit + assert head_sha == commit_sha + + # Verify the clone succeeded and files are present + clone_dir = source_root_dir / "clone-test" + assert (clone_dir / ".git").is_dir() + assert (clone_dir / "test.txt").exists() + assert (clone_dir / "test.txt").read_text(encoding="utf-8") == "nested tag test" From 6e4df3719301f455743f011e93e2ab3797e373c2 Mon Sep 17 00:00:00 2001 From: Adam Nichols Date: Thu, 19 Feb 2026 05:10:40 -0500 Subject: [PATCH 03/21] Update docs with 3.10+ requirement (#10739) (cherry picked from commit d2d77b11d7e2a0e4b21d9a0af6ffcb49a9760802) --- docs/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_index.md b/docs/_index.md index 22a43e7bb27..9e0223535f0 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -18,7 +18,7 @@ Poetry offers a lockfile to ensure repeatable installs, and can build your proje ## System requirements -Poetry requires **Python 3.9+**. It is multi-platform and the goal is to make it work equally well +Poetry requires **Python 3.10+**. It is multi-platform and the goal is to make it work equally well on Linux, macOS and Windows. ## Installation From ed1aec5311d602946fd14ba4561e699fad7f13b0 Mon Sep 17 00:00:00 2001 From: Hiren Thakore Date: Wed, 25 Feb 2026 04:43:22 -0500 Subject: [PATCH 04/21] Fix cache clear example to use PyPI instead of pypi (#10749) (cherry picked from commit d4e9399affb2c0f4b1f835131733f3185aa0d1f6) --- docs/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index 29d5ff14d73..4a950a9b2a5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -263,7 +263,7 @@ poetry cache clear PyPI --all To only remove a specific package from a cache, you have to specify the cache entry in the following form `cache:package:version`: ```bash -poetry cache clear pypi:requests:2.24.0 +poetry cache clear PyPI:requests:2.24.0 ``` ### cache list From d37cff9d1800d12f412cb133fd5fcc40791c58ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sat, 7 Mar 2026 07:20:05 +0100 Subject: [PATCH 05/21] docs: add pyproject section in examples (#10753) (cherry picked from commit 161e68beda53b2d530c4cf123fd679047ff203d3) --- docs/pyproject.md | 51 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/pyproject.md b/docs/pyproject.md index e3902d016be..6d6fe27b650 100644 --- a/docs/pyproject.md +++ b/docs/pyproject.md @@ -35,6 +35,7 @@ This should be a valid name as defined by [PEP 508](https://peps.python.org/pep- ```toml +[project] name = "my-package" ``` @@ -45,6 +46,8 @@ The version of the package. **Always required when the `project` section is spec This should be a valid [PEP 440](https://peps.python.org/pep-0440/) string. ```toml +[project] +# ... version = "0.1.0" ``` @@ -66,6 +69,8 @@ version = "1.0" # base version A short description of the package. ```toml +[project] +# ... description = "A short description of the package." ``` @@ -94,6 +99,8 @@ Optional, but it is highly recommended to supply this. More identifiers are listed at the [SPDX Open Source License Registry](https://spdx.org/licenses/). ```toml +[project] +# ... license = "MIT" ``` @@ -136,6 +143,8 @@ Specifying an empty list results in no license files being included. A path to the README file or the content. ```toml +[project] +# ... readme = "README.md" ``` @@ -159,6 +168,8 @@ readme = ["docs/README1.md", "docs/README2.md"] The Python version requirements of the project. ```toml +[project] +# ... requires-python = ">=3.8" ``` @@ -184,6 +195,8 @@ The authors of the package. This is a list of authors and should contain at least one author. ```toml +[project] +# ... authors = [ { name = "Sébastien Eustace", email = "sebastien@eustace.io" }, ] @@ -196,6 +209,8 @@ The maintainers of the package. This is a list of maintainers and should be distinct from authors. ```toml +[project] +# ... maintainers = [ { name = "John Smith", email = "johnsmith@example.org" }, { name = "Jane Smith", email = "janesmith@example.org" }, @@ -207,6 +222,8 @@ maintainers = [ A list of keywords that the package is related to. ```toml +[project] +# ... keywords = [ "packaging", "poetry" ] ``` @@ -215,6 +232,8 @@ keywords = [ "packaging", "poetry" ] A list of PyPI [trove classifiers](https://pypi.org/classifiers/) that describe the project. ```toml +[project] +# ... classifiers = [ "Topic :: Software Development :: Build Tools", "Topic :: Software Development :: Libraries :: Python Modules" @@ -326,6 +345,8 @@ export = "poetry_plugin_export.plugins:ExportApplicationPlugin" The `dependencies` of the project. ```toml +[project] +# ... dependencies = [ "requests>=2.13.0", ] @@ -366,6 +387,8 @@ Whether Poetry operates in package mode (default) or not. See [basic usage]({{< relref "basic-usage#operating-modes" >}}) for more information. ```toml +[tool.poetry] +# ... package-mode = false ``` @@ -379,6 +402,7 @@ This should be a valid name as defined by [PEP 508](https://peps.python.org/pep- ```toml +[tool.poetry] name = "my-package" ``` @@ -395,6 +419,8 @@ The version of the package. **Required in package mode if not defined in the pro This should be a valid [PEP 440](https://peps.python.org/pep-0440/) string. ```toml +[tool.poetry] +# ... version = "0.1.0" ``` @@ -412,6 +438,8 @@ If you would like to use semantic versioning for your project, please see A short description of the package. ```toml +[tool.poetry] +# ... description = "A short description of the package." ``` @@ -441,6 +469,8 @@ Optional, but it is highly recommended to supply this. More identifiers are listed at the [SPDX Open Source License Registry](https://spdx.org/licenses/). ```toml +[tool.poetry] +# ... license = "MIT" ``` @@ -453,6 +483,8 @@ The authors of the package. This is a list of authors and should contain at least one author. Authors must be in the form `name `. ```toml +[tool.poetry] +# ... authors = [ "Sébastien Eustace ", ] @@ -467,6 +499,8 @@ The maintainers of the package. This is a list of maintainers and should be distinct from authors. Maintainers may contain an email and be in the form `name `. ```toml +[tool.poetry] +# ... maintainers = [ "John Smith ", "Jane Smith ", @@ -513,9 +547,11 @@ readme = ["docs/README1.md", "docs/README2.md"] **Deprecated**: Use `project.urls` instead. -An URL to the website of the project. +A URL to the website of the project. ```toml +[tool.poetry] +# ... homepage = "https://python-poetry.org/" ``` @@ -523,9 +559,11 @@ homepage = "https://python-poetry.org/" **Deprecated**: Use `project.urls` instead. -An URL to the repository of the project. +A URL to the repository of the project. ```toml +[tool.poetry] +# ... repository = "https://github.com/python-poetry/poetry" ``` @@ -533,9 +571,11 @@ repository = "https://github.com/python-poetry/poetry" **Deprecated**: Use `project.urls` instead. -An URL to the documentation of the project. +A URL to the documentation of the project. ```toml +[tool.poetry] +# ... documentation = "https://python-poetry.org/docs/" ``` @@ -546,6 +586,8 @@ documentation = "https://python-poetry.org/docs/" A list of keywords that the package is related to. ```toml +[tool.poetry] +# ... keywords = ["packaging", "poetry"] ``` @@ -642,6 +684,8 @@ For instance, if you have a package named `my_package` and you want to also incl another package named `extra_package`, you will need to specify `my_package` explicitly: ```toml +[tool.poetry] +# ... packages = [ { include = "my_package" }, { include = "extra_package" }, @@ -921,6 +965,7 @@ an error will be raised. ```toml [tool.poetry] +# ... requires-poetry = ">=2.0" ``` From 5457430994de9c8c1bed955aa20068e86ec2da81 Mon Sep 17 00:00:00 2001 From: Jamie Tanna Date: Sun, 15 Mar 2026 09:42:12 +0000 Subject: [PATCH 06/21] chore(git): warn users that they may be missing Git submodule auth (#10771) (cherry picked from commit 7b6c7f313fc357c336bdc1a4a886ec1dcbca027e) --- src/poetry/vcs/git/backend.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/poetry/vcs/git/backend.py b/src/poetry/vcs/git/backend.py index dad4867cbbc..64bbbccdbb0 100644 --- a/src/poetry/vcs/git/backend.py +++ b/src/poetry/vcs/git/backend.py @@ -66,6 +66,7 @@ " - was misspelled\n" " - does not exist\n" " - requires credentials that were either not configured or is incorrect\n" + " - contains Git submodules that require credentials that were either not configured or are incorrect\n" " - is in accessible due to network issues" ) ERROR_MESSAGE_FILE_LOCK = ( From 2f5e19a418316ef959e423c22df1396d5eb3915b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sun, 15 Mar 2026 10:57:58 +0100 Subject: [PATCH 07/21] chore(git): fix typos in error message (#10772) (cherry picked from commit 48e5ce0c06ae4eb5832641b3214cab945c892307) --- src/poetry/vcs/git/backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/poetry/vcs/git/backend.py b/src/poetry/vcs/git/backend.py index 64bbbccdbb0..5312d64432b 100644 --- a/src/poetry/vcs/git/backend.py +++ b/src/poetry/vcs/git/backend.py @@ -65,9 +65,9 @@ "- the remote ({remote}) you have specified\n" " - was misspelled\n" " - does not exist\n" - " - requires credentials that were either not configured or is incorrect\n" + " - requires credentials that were either not configured or are incorrect\n" " - contains Git submodules that require credentials that were either not configured or are incorrect\n" - " - is in accessible due to network issues" + " - is inaccessible due to network issues" ) ERROR_MESSAGE_FILE_LOCK = ( "- another process is holding the file lock\n" From 541f2e43e9f0e73aca8aba244e9b10817269af71 Mon Sep 17 00:00:00 2001 From: Kelly Ballinger <165738945+KellyBallinger05@users.noreply.github.com> Date: Sun, 15 Mar 2026 03:36:40 -0700 Subject: [PATCH 08/21] docs: add note about Microsoft Store Python PATH on Windows (#10759) (cherry picked from commit 2c270050111bf57bed83b76e73e11292d13665e7) --- docs/_index.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/_index.md b/docs/_index.md index 9e0223535f0..c2394d6992f 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -190,6 +190,18 @@ The installer creates a `poetry` wrapper in a well-known, platform-specific dire - `%APPDATA%\Python\Scripts` on Windows. - `$POETRY_HOME/bin` if `$POETRY_HOME` is set. +{{% note %}} +If you have installed Python through the Microsoft Store, the `poetry` binary +will be installed to a different location, for example: + +``` +%LOCALAPPDATA%\Packages\PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0 +\LocalCache\Roaming\Python\Scripts +``` + +Replace `3.12` with your installed Python version and `qbz5n2kfra8p0` with your suffix. +{{% /note %}} + If this directory is not present in your `$PATH`, you can add it in order to invoke Poetry as `poetry`. From 96883826998f964ae12963fac0b4751bedd04b50 Mon Sep 17 00:00:00 2001 From: Rob B Date: Wed, 25 Mar 2026 12:07:03 -0400 Subject: [PATCH 09/21] docs: fix pipx install directions link (#10783) (cherry picked from commit 40bec93fec0425563b52e3175dfcc3d07b936cc4) --- docs/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_index.md b/docs/_index.md index c2394d6992f..20d3647236a 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -40,7 +40,7 @@ See the **advanced** installation instructions to use a preview or alternate ver **Install pipx** If `pipx` is not already installed, you can follow any of the options in the -[official pipx installation instructions](https://pipx.pypa.io/stable/installation/). +[official pipx installation instructions](https://pipx.pypa.io/stable/how-to/install-pipx/). Any non-ancient version of `pipx` will do. {{< /step >}} From 9fced1a13ded1a7dcec562f295b2362a1a4fa8dc Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Sat, 28 Mar 2026 04:32:58 -0700 Subject: [PATCH 10/21] fix(env): treat empty VIRTUAL_ENV/CONDA_PREFIX as unset (#10784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Randy Döring <30527984+radoering@users.noreply.github.com> (cherry picked from commit 020616502b66d336de483d3c527f72e26718feb1) --- src/poetry/utils/env/env_manager.py | 16 ++++++--------- src/poetry/utils/env/python/manager.py | 2 +- tests/utils/env/test_env_manager.py | 27 ++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/poetry/utils/env/env_manager.py b/src/poetry/utils/env/env_manager.py index 1061ff772ce..0c1d52e0433 100644 --- a/src/poetry/utils/env/env_manager.py +++ b/src/poetry/utils/env/env_manager.py @@ -159,7 +159,7 @@ def activate(self, python: str) -> Env: # Create if needed if not venv.exists() or create: - in_venv = os.environ.get("VIRTUAL_ENV") is not None + in_venv = bool(os.environ.get("VIRTUAL_ENV")) if in_venv or not venv.exists(): create = True @@ -214,7 +214,9 @@ def get(self, reload: bool = False) -> Env: conda_env_name = os.environ.get("CONDA_DEFAULT_ENV") # It's probably not a good idea to pollute Conda's global "base" env, since # most users have it activated all the time. - in_venv = env_prefix is not None and conda_env_name != "base" + # Treat an empty env_prefix as if no virtualenv is active, since conda + # can leave CONDA_PREFIX set to an empty string after deactivation. + in_venv = bool(env_prefix) and conda_env_name != "base" if not in_venv or env is not None: # Checking if a local virtualenv exists @@ -249,14 +251,8 @@ def get(self, reload: bool = False) -> Env: return VirtualEnv(venv) - if env_prefix is not None: - prefix = Path(env_prefix) - base_prefix = None - else: - prefix = Path(sys.prefix) - base_prefix = self.get_base_prefix() - - return VirtualEnv(prefix, base_prefix) + assert env_prefix + return VirtualEnv(Path(env_prefix)) def list(self, name: str | None = None) -> list[VirtualEnv]: if name is None: diff --git a/src/poetry/utils/env/python/manager.py b/src/poetry/utils/env/python/manager.py index ec02c6d638a..aa3bbdc1e9c 100644 --- a/src/poetry/utils/env/python/manager.py +++ b/src/poetry/utils/env/python/manager.py @@ -84,7 +84,7 @@ def __init__( @classmethod def find_all(cls) -> Iterator[Python]: venv_path: Path | None = ( - Path(os.environ["VIRTUAL_ENV"]) if "VIRTUAL_ENV" in os.environ else None + Path(venv) if (venv := os.environ.get("VIRTUAL_ENV")) else None ) for python in findpython.find_all(): if venv_path and python.executable.is_relative_to(venv_path): diff --git a/tests/utils/env/test_env_manager.py b/tests/utils/env/test_env_manager.py index df5cbf1a0af..71e05e4cb5c 100644 --- a/tests/utils/env/test_env_manager.py +++ b/tests/utils/env/test_env_manager.py @@ -577,6 +577,33 @@ def test_get_prefers_explicitly_activated_virtualenvs_over_env_var( assert env.base == Path(sys.base_prefix) +@pytest.mark.parametrize("env_var", ["VIRTUAL_ENV", "CONDA_PREFIX"]) +def test_get_ignores_empty_env_prefix( + manager: EnvManager, + poetry: Poetry, + in_project_venv_dir: Path, + env_var: str, + mocker: MockerFixture, +) -> None: + """An empty VIRTUAL_ENV or CONDA_PREFIX should be treated as unset. + + After ``conda deactivate``, conda can leave CONDA_PREFIX set to an + empty string. Poetry should not consider that as an active + virtualenv and should fall back to the in-project .venv instead. + + See: https://github.com/python-poetry/poetry/issues/10770 + """ + os.environ.pop("VIRTUAL_ENV", None) + os.environ.pop("CONDA_PREFIX", None) + os.environ[env_var] = "" + mocker.patch( + "poetry.utils.env.virtual_env.VirtualEnv.__init__", + lambda self, *args, **kwargs: setattr(self, "_path", args[0]), + ) + venv = manager.get() + assert venv.path == in_project_venv_dir + + def test_list( tmp_path: Path, manager: EnvManager, From d6e72c972a48c4db98e1b8e1381544d33a2b66ef Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 28 Mar 2026 05:21:19 -0700 Subject: [PATCH 11/21] Fix `publish --build` prompt behavior in non-interactive mode (#10769) (cherry picked from commit 0b812842e40ed086d6ab62b7ac9c3eedf768d5e0) --- src/poetry/console/commands/publish.py | 22 ++++++++++++------- tests/console/commands/test_publish.py | 29 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/poetry/console/commands/publish.py b/src/poetry/console/commands/publish.py index 98c0bebe896..cd9ba7d5f4a 100644 --- a/src/poetry/console/commands/publish.py +++ b/src/poetry/console/commands/publish.py @@ -72,13 +72,21 @@ def handle(self) -> int: # Building package first, if told if self.option("build"): - if publisher.files and not self.confirm( - f"There are {len(publisher.files)} files ready for" - " publishing. Build anyway?" - ): - self.line_error("Aborted!") - - return 1 + if publisher.files: + if self.io.is_interactive(): + if not self.confirm( + f"There are {len(publisher.files)} files ready for" + f" publishing in {dist_dir}. Build anyway?" + ): + self.line_error("Aborted!") + + return 1 + + else: + self.line( + f"Warning: There are {len(publisher.files)} files " + f"ready for publishing in {dist_dir}. Build anyway!" + ) self.call("build", args=f"--output {dist_dir}") diff --git a/tests/console/commands/test_publish.py b/tests/console/commands/test_publish.py index 31723ac8ce3..1f6f44ce3dc 100644 --- a/tests/console/commands/test_publish.py +++ b/tests/console/commands/test_publish.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import TYPE_CHECKING from typing import NoReturn +from unittest.mock import PropertyMock import pytest import requests @@ -215,3 +216,31 @@ def test_publish_dist_dir_and_build_options( assert "Publishing simple-project (1.2.3) to PyPI" in output assert "- Uploading simple_project-1.2.3.tar.gz" in error assert "- Uploading simple_project-1.2.3-py2.py3-none-any.whl" in error + + +def test_publish_build_no_interaction_skips_confirmation( + app_tester: ApplicationTester, mocker: MockerFixture +) -> None: + mocker.patch( + "poetry.publishing.publisher.Publisher.files", + new_callable=PropertyMock, + return_value=[Path("dist/simple_project-1.2.3-py2.py3-none-any.whl")], + ) + confirm = mocker.patch("poetry.console.commands.publish.PublishCommand.confirm") + command_call = mocker.patch("poetry.console.commands.publish.PublishCommand.call") + publisher_publish = mocker.patch("poetry.publishing.Publisher.publish") + + exit_code = app_tester.execute("publish --build --no-interaction --dry-run") + + assert exit_code == 0 + output = app_tester.io.fetch_output() + error = app_tester.io.fetch_error() + + confirm.assert_not_called() + assert "Build anyway?" not in output + assert "Build anyway?" not in error + assert ( + "Warning: There are 1 files ready for publishing in dist. Build anyway!" + ) in output + command_call.assert_called_once_with("build", args="--output dist") + assert publisher_publish.call_count == 1 From 286e43bba52ba60205e1e5c9a401019b45226bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:37:43 +0100 Subject: [PATCH 12/21] env: improve error handling if `.venv` is not a directory but a file (#10777) (cherry picked from commit 8f6d38244bc71642dd9054fe8f0a25471ffb2bd7) --- src/poetry/utils/env/env_manager.py | 10 ++++-- tests/utils/env/conftest.py | 11 +++++-- tests/utils/env/test_env_manager.py | 51 +++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/poetry/utils/env/env_manager.py b/src/poetry/utils/env/env_manager.py index 0c1d52e0433..2087b1b3f6e 100644 --- a/src/poetry/utils/env/env_manager.py +++ b/src/poetry/utils/env/env_manager.py @@ -125,7 +125,7 @@ def activate(self, python: str) -> Env: if self.use_in_project_venv(): create = False venv = self.in_project_venv - if venv.exists(): + if venv.is_dir(): # We need to check if the patch version is correct _venv = VirtualEnv(venv) current_patch = ".".join(str(v) for v in _venv.version_info[:3]) @@ -452,7 +452,7 @@ def create_venv( f"Invalid template string in 'virtualenvs.prompt' setting: {e}" ) from e - if not venv.exists(): + if not venv.is_dir(): if create_venv is False: self._io.write_error_line( "" @@ -463,6 +463,12 @@ def create_venv( return self.get_system_env() + if venv.is_file(): + self._io.write_error_line( + f"{venv} is not a virtual environment but a file. Removing it." + ) + venv.unlink() + self._io.write_error_line( f"Creating virtualenv {name} in" f" {venv_path if not WINDOWS else get_real_windows_path(venv_path)!s}" diff --git a/tests/utils/env/conftest.py b/tests/utils/env/conftest.py index c5250195854..8e24245e1b6 100644 --- a/tests/utils/env/conftest.py +++ b/tests/utils/env/conftest.py @@ -4,6 +4,8 @@ import pytest +from cleo.io.buffered_io import BufferedIO + from poetry.utils.env import EnvManager @@ -19,5 +21,10 @@ def poetry(project_factory: ProjectFactory, fixture_dir: FixtureDirGetter) -> Po @pytest.fixture -def manager(poetry: Poetry) -> EnvManager: - return EnvManager(poetry) +def io() -> BufferedIO: + return BufferedIO() + + +@pytest.fixture +def manager(poetry: Poetry, io: BufferedIO) -> EnvManager: + return EnvManager(poetry, io) diff --git a/tests/utils/env/test_env_manager.py b/tests/utils/env/test_env_manager.py index 71e05e4cb5c..cc6878968c6 100644 --- a/tests/utils/env/test_env_manager.py +++ b/tests/utils/env/test_env_manager.py @@ -32,6 +32,7 @@ from collections.abc import Iterator from unittest.mock import MagicMock + from cleo.io.buffered_io import BufferedIO from pytest import LogCaptureFixture from pytest_mock import MockerFixture @@ -470,6 +471,56 @@ def test_activate_with_in_project_setting_does_not_fail_if_no_venvs_dir( assert not envs_file.exists() +def test_activate_with_in_project_setting_if_venv_is_file( + manager: EnvManager, + poetry: Poetry, + io: BufferedIO, + config: Config, + tmp_path: Path, + mocker: MockerFixture, + venv_flags_default: dict[str, bool], + mocked_python_register: MockedPythonRegister, +) -> None: + if "VIRTUAL_ENV" in os.environ: + del os.environ["VIRTUAL_ENV"] + + config.merge( + { + "virtualenvs": { + "path": str(tmp_path / "virtualenvs"), + "in-project": True, + } + } + ) + + mocked_python_register("3.7.1") + m = mocker.patch("poetry.utils.env.EnvManager.build_venv") + + venv_path = poetry.file.path.parent / ".venv" + assert not venv_path.exists() + venv_path.touch() + assert venv_path.is_file() + + manager.activate("python3.7") + + m.assert_called_with( + poetry.file.path.parent / ".venv", + executable=Path("/usr/bin/python3.7"), + flags=venv_flags_default, + prompt="simple-project-py3.7", + ) + + envs_file = TOMLFile(tmp_path / "virtualenvs" / "envs.toml") + assert not envs_file.exists() + + # The .venv file is removed, but no .venv is created because we mocked build_venv. + assert not venv_path.exists() + assert ( + f"{venv_path} is not a virtual environment but a file. Removing it." + in io.fetch_error() + ) + + def test_deactivate_non_activated_but_existing( tmp_path: Path, manager: EnvManager, From 2ff2845af03539c98d2279b46074c908594427c4 Mon Sep 17 00:00:00 2001 From: Affan Amir Mir <54717435+Affanmir@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:49:58 +0500 Subject: [PATCH 13/21] fix: pass auth via Request constructor instead of calling HTTPBasicAuth on unprepared Request (#10748) Co-authored-by: Claude Opus 4.6 (cherry picked from commit 1449345a57f3b72cf8c14dd89b6502732aa10345) --- src/poetry/utils/authenticator.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/poetry/utils/authenticator.py b/src/poetry/utils/authenticator.py index 05b83a5400b..5052019af45 100644 --- a/src/poetry/utils/authenticator.py +++ b/src/poetry/utils/authenticator.py @@ -189,13 +189,15 @@ def request( self, method: str, url: str, raise_for_status: bool = True, **kwargs: Any ) -> requests.Response: headers = kwargs.get("headers") - request = requests.Request(method, url, headers=headers) credential = self.get_credentials_for_url(url) + auth = None if credential.username is not None or credential.password is not None: - request = requests.auth.HTTPBasicAuth( + auth = requests.auth.HTTPBasicAuth( credential.username or "", credential.password or "" - )(request) + ) + + request = requests.Request(method, url, headers=headers, auth=auth) session = self.get_session(url=url) prepared_request = session.prepare_request(request) From 859d4439f2caf147010330beae1ad61274f009d4 Mon Sep 17 00:00:00 2001 From: George Waters Date: Sun, 29 Mar 2026 01:53:06 -0400 Subject: [PATCH 14/21] Update init & new commands for PEP 639 (License) (#10787) (cherry picked from commit afb12f6ea17cd709ab73a21b931bb293e7d97280) --- src/poetry/layouts/layout.py | 4 ++-- tests/console/commands/conftest.py | 4 ++-- tests/console/commands/test_init.py | 30 ++++++++++++++--------------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/poetry/layouts/layout.py b/src/poetry/layouts/layout.py index e8d730c577e..768b3737191 100644 --- a/src/poetry/layouts/layout.py +++ b/src/poetry/layouts/layout.py @@ -32,7 +32,7 @@ description = "" authors = [ ] -license = {} +license = "" readme = "" requires-python = "" dependencies = [ @@ -158,7 +158,7 @@ def generate_project_content( project_content["authors"].append(author) if self._license: - project_content["license"]["text"] = self._license + project_content["license"] = self._license else: project_content.remove("license") diff --git a/tests/console/commands/conftest.py b/tests/console/commands/conftest.py index 85a6d729ea6..1058ad5b22b 100644 --- a/tests/console/commands/conftest.py +++ b/tests/console/commands/conftest.py @@ -30,7 +30,7 @@ def init_basic_toml() -> str: authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" readme = "README.md" requires-python = ">=3.6" """ @@ -55,7 +55,7 @@ def new_basic_toml() -> str: authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" readme = "README.md" requires-python = ">=3.6" dependencies = [ diff --git a/tests/console/commands/test_init.py b/tests/console/commands/test_init.py index 3d12e3015c5..d4904ecd4af 100644 --- a/tests/console/commands/test_init.py +++ b/tests/console/commands/test_init.py @@ -145,7 +145,7 @@ def test_interactive_with_dependencies( authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.6" dependencies = [ "pendulum (>=2.0.0,<3.0.0)", @@ -201,7 +201,7 @@ def test_interactive_with_dependencies_and_no_selection( authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.6" """ @@ -269,7 +269,7 @@ def test_interactive_with_git_dependencies( authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.6" dependencies = [ "demo @ git+https://github.com/demo/demo.git" @@ -364,7 +364,7 @@ def test_interactive_with_git_dependencies_with_reference( authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.6" dependencies = [ "demo @ git+https://github.com/demo/demo.git@develop" @@ -412,7 +412,7 @@ def test_interactive_with_git_dependencies_and_other_name( authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.6" dependencies = [ "demo @ git+https://github.com/demo/pyproject-demo.git" @@ -467,7 +467,7 @@ def test_interactive_with_directory_dependency( authors = [ {{name = "Your Name",email = "you@example.com"}} ] -license = {{text = "MIT"}} +license = "MIT" requires-python = ">=3.6" dependencies = [ "demo @ {demo_uri}" @@ -521,7 +521,7 @@ def test_interactive_with_directory_dependency_and_other_name( authors = [ {{name = "Your Name",email = "you@example.com"}} ] -license = {{text = "MIT"}} +license = "MIT" requires-python = ">=3.6" dependencies = [ "demo @ {demo_uri}" @@ -576,7 +576,7 @@ def test_interactive_with_file_dependency( authors = [ {{name = "Your Name",email = "you@example.com"}} ] -license = {{text = "MIT"}} +license = "MIT" requires-python = ">=3.6" dependencies = [ "demo @ {demo_uri}" @@ -623,7 +623,7 @@ def test_interactive_with_wrong_dependency_inputs( authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.8" dependencies = [ "foo (==1.19.2)", @@ -660,7 +660,7 @@ def test_python_option(tester: CommandTester) -> None: authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.6" """ @@ -691,7 +691,7 @@ def test_predefined_dependency(tester: CommandTester, repo: TestRepository) -> N authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.6" dependencies = [ "pendulum (>=2.0.0,<3.0.0)" @@ -733,7 +733,7 @@ def test_predefined_and_interactive_dependencies( authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.6" dependencies = [ "pendulum (>=2.0.0,<3.0.0)", @@ -768,7 +768,7 @@ def test_predefined_dev_dependency(tester: CommandTester, repo: TestRepository) authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.6" dependencies = [ ] @@ -814,7 +814,7 @@ def test_predefined_and_interactive_dev_dependencies( authors = [ {name = "Your Name",email = "you@example.com"} ] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.6" dependencies = [ ] @@ -861,7 +861,7 @@ def test_predefined_all_options(tester: CommandTester, repo: TestRepository) -> authors = [ {name = "Foo Bar",email = "foo@example.com"} ] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.8" dependencies = [ "pendulum (>=2.0.0,<3.0.0)" From d76a2f67641ef1499065bdc8a0246448cbcf781c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sun, 29 Mar 2026 09:55:48 +0200 Subject: [PATCH 15/21] chore: require new poetry-core version (#10790) (cherry picked from commit f3c6f3c90473e9d49ab985e4ee072f72de79cb3f) --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index e33de6f0fcf..cb6f90a7d30 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "anyio" @@ -1347,14 +1347,14 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "poetry-core" -version = "2.3.1" +version = "2.3.2" description = "Poetry PEP 517 Build Backend" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "poetry_core-2.3.1-py3-none-any.whl", hash = "sha256:db1cf63b782570deb38bfba61e2304a553eef0740dc17959a50cc0f5115ee634"}, - {file = "poetry_core-2.3.1.tar.gz", hash = "sha256:96f791d5d7d4e040f3983d76779425cf9532690e2756a24fd5ca0f86af19ef82"}, + {file = "poetry_core-2.3.2-py3-none-any.whl", hash = "sha256:23df641b64f87fbb4ce1873c1915a4d4bb1b7d808c596e4307edc073e68d7234"}, + {file = "poetry_core-2.3.2.tar.gz", hash = "sha256:20cb71be27b774628da9f384effd9183dfceb53bcef84063248a8672aa47031f"}, ] [[package]] @@ -2196,4 +2196,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "9ce6acbe11341f1c5fc0a8df9bfe5fe9237fedb54c90a756baf344b1c2b62e43" +content-hash = "69cddd52ffdedc12c4b7445a9f359d67a4a1dc4b5d139d261a2c228a832bf39a" diff --git a/pyproject.toml b/pyproject.toml index ef3cbb7cf0d..6d691e7ce52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "2.3.2" description = "Python dependency management and packaging made easy." requires-python = ">=3.10,<4.0" dependencies = [ - "poetry-core (==2.3.1)", + "poetry-core (==2.3.2)", "build (>=1.2.1,<2.0.0)", "cachecontrol[filecache] (>=0.14.0,<0.15.0)", "cleo (>=2.1.0,<3.0.0)", From e068177d1bfef65de4c55cf71c36de27057f10e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:24:17 +0200 Subject: [PATCH 16/21] installer: fix path traversal (#10792) (cherry picked from commit ed59537ac3709cfbdbf95d957de801c13872991a) --- src/poetry/installation/wheel_installer.py | 9 +++- tests/installation/test_wheel_installer.py | 48 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/poetry/installation/wheel_installer.py b/src/poetry/installation/wheel_installer.py index 57ee02d9a98..a086ca7361a 100644 --- a/src/poetry/installation/wheel_installer.py +++ b/src/poetry/installation/wheel_installer.py @@ -44,7 +44,14 @@ def write_to_fs( from installer.utils import copyfileobj_with_hashing from installer.utils import make_file_executable - target_path = Path(self.scheme_dict[scheme]) / path + target_dir = Path(self.scheme_dict[scheme]).resolve() + target_path = (target_dir / path).resolve() + + if not target_path.is_relative_to(target_dir): + raise ValueError( + f"Attempting to write {path} outside of the target directory" + ) + if target_path.exists(): # Contrary to the base library we don't raise an error here since it can # break pkgutil-style and pkg_resource-style namespace packages. diff --git a/tests/installation/test_wheel_installer.py b/tests/installation/test_wheel_installer.py index 98e3f4cf97e..33e95dd970a 100644 --- a/tests/installation/test_wheel_installer.py +++ b/tests/installation/test_wheel_installer.py @@ -81,3 +81,51 @@ def test_enable_bytecode_compilation( assert not list(cache_dir.glob("*.opt-2.pyc")) else: assert not cache_dir.exists() + + +def test_install_dir_is_symlink(tmp_path: Path, demo_wheel: Path) -> None: + target_dir = tmp_path / "target" + target_dir.mkdir() + symlink_dir = tmp_path / "symlink" + symlink_dir.symlink_to(target_dir, target_is_directory=True) + + env = MockEnv(path=symlink_dir) + + installer = WheelInstaller(env) + installer.install(demo_wheel) + + assert (Path(env.paths["purelib"]) / "demo").exists() + + +@pytest.fixture +def wheel_with_path_traversal(tmp_path: Path) -> Path: + import zipfile + + wheel = tmp_path / "traversal-0.1-py3-none-any.whl" + files = { + "traversal/__init__.py": b"", + "../../traversal.txt": b"", + "traversal-0.1.dist-info/WHEEL": ( + b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" + ), + "traversal-0.1.dist-info/METADATA": ( + b"Metadata-Version: 2.1\nName: traversal\nVersion: 0.1\n" + ), + } + files["traversal-0.1.dist-info/RECORD"] = ( + "\n".join([f"{k},," for k in files] + ["traversal-0.1.dist-info/RECORD,,"]) + + "\n" + ).encode() + + with zipfile.ZipFile(wheel, "w") as z: + for k, v in files.items(): + z.writestr(k, v) + + return wheel + + +def test_path_traversal(env: MockEnv, wheel_with_path_traversal: Path) -> None: + installer = WheelInstaller(env) + with pytest.raises(ValueError): + installer.install(wheel_with_path_traversal) + assert not (env.path.parent / "traversal.txt").exists() From 89f09aad49ed7e6223ea2b8ebdf941e87bb5d5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:48:28 +0200 Subject: [PATCH 17/21] fix long path issue on Windows (#10794) (cherry picked from commit a16fbb1750545d70b59c61f3ef9bdb4e0c26bfd3) --- src/poetry/console/commands/env/remove.py | 5 +- src/poetry/console/commands/python/list.py | 6 +- src/poetry/installation/wheel_installer.py | 11 +++- src/poetry/utils/_compat.py | 28 +++++++++ src/poetry/utils/env/python/manager.py | 3 +- tests/utils/test_compat.py | 71 ++++++++++++++++++++++ 6 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 tests/utils/test_compat.py diff --git a/src/poetry/console/commands/env/remove.py b/src/poetry/console/commands/env/remove.py index a492959ebbe..f19e9aa505f 100644 --- a/src/poetry/console/commands/env/remove.py +++ b/src/poetry/console/commands/env/remove.py @@ -7,6 +7,7 @@ from cleo.helpers import option from poetry.console.commands.command import Command +from poetry.utils._compat import is_relative_to if TYPE_CHECKING: @@ -54,8 +55,8 @@ def handle(self) -> int: self.line(f"Deleted virtualenv: {venv.path}") if remove_all_envs or is_in_project: for venv in manager.list(): - if not is_in_project or venv.path.is_relative_to( - self.poetry.pyproject_path.parent + if not is_in_project or is_relative_to( + venv.path, self.poetry.pyproject_path.parent ): manager.remove_venv(venv.path) self.line(f"Deleted virtualenv: {venv.path}") diff --git a/src/poetry/console/commands/python/list.py b/src/poetry/console/commands/python/list.py index f957f0f84f0..7d5a1cea502 100644 --- a/src/poetry/console/commands/python/list.py +++ b/src/poetry/console/commands/python/list.py @@ -10,6 +10,7 @@ from poetry.config.config import Config from poetry.console.commands.command import Command +from poetry.utils._compat import is_relative_to from poetry.utils.env.python import Python @@ -107,9 +108,8 @@ def handle(self) -> int: implementation = implementations.get( pv.implementation.lower(), pv.implementation ) - is_poetry_managed = ( - pv.executable is None - or pv.executable.resolve().is_relative_to(python_installation_path) + is_poetry_managed = pv.executable is None or is_relative_to( + pv.executable.resolve(), python_installation_path ) if self.option("managed") and not is_poetry_managed: diff --git a/src/poetry/installation/wheel_installer.py b/src/poetry/installation/wheel_installer.py index a086ca7361a..3f132692422 100644 --- a/src/poetry/installation/wheel_installer.py +++ b/src/poetry/installation/wheel_installer.py @@ -14,6 +14,7 @@ from poetry.__version__ import __version__ from poetry.utils._compat import WINDOWS +from poetry.utils._compat import is_relative_to logger = logging.getLogger(__name__) @@ -47,9 +48,15 @@ def write_to_fs( target_dir = Path(self.scheme_dict[scheme]).resolve() target_path = (target_dir / path).resolve() - if not target_path.is_relative_to(target_dir): + # Use is_relative_to() instead of Path.is_relative_to() + # because the latter does not work if one of both paths + # has a Windows long path prefix and the other path has not. + # (A long path prefix may be added when calling resolve().) + if not is_relative_to(target_path, target_dir): raise ValueError( - f"Attempting to write {path} outside of the target directory" + f"Attempting to write {path} outside of the target directory\n" + f"Target directory: {target_dir}\n" + f"Target path: {target_path}" ) if target_path.exists(): diff --git a/src/poetry/utils/_compat.py b/src/poetry/utils/_compat.py index 2a82fa6d77d..8cf12720694 100644 --- a/src/poetry/utils/_compat.py +++ b/src/poetry/utils/_compat.py @@ -5,6 +5,7 @@ import warnings from contextlib import suppress +from pathlib import Path if sys.version_info < (3, 11): @@ -52,6 +53,33 @@ def getencoding() -> str: return locale.getencoding() +def is_relative_to(path1: Path, path2: Path) -> bool: + """ + Checks if path1 is relative to path2. + + Works also if one of both paths has a Windows long path prefix. + A long path prefix may be added when calling Path.resolve(). + """ + if WINDOWS: + # Work around an issue that is_relative_to() does not work if + # one of both paths has a long path prefix and the other path has not. + long_path_prefix = "\\\\?\\" + long_path_unc_prefix = f"{long_path_prefix}UNC\\" + + def remove_long_path_prefix(path: Path) -> Path: + if (path_str := str(path)).startswith(long_path_prefix): + if path_str.startswith(long_path_unc_prefix): + path = Path("\\\\" + path_str.removeprefix(long_path_unc_prefix)) + else: + path = Path(path_str.removeprefix(long_path_prefix)) + return path + + path1 = remove_long_path_prefix(path1) + path2 = remove_long_path_prefix(path2) + + return path1.is_relative_to(path2) + + def __getattr__(name: str) -> object: if name == "metadata": warnings.warn( diff --git a/src/poetry/utils/env/python/manager.py b/src/poetry/utils/env/python/manager.py index aa3bbdc1e9c..9585c0688c0 100644 --- a/src/poetry/utils/env/python/manager.py +++ b/src/poetry/utils/env/python/manager.py @@ -23,6 +23,7 @@ from poetry.core.constraints.version import VersionConstraint from poetry.core.constraints.version import parse_constraint +from poetry.utils._compat import is_relative_to from poetry.utils.env.python.exceptions import NoCompatiblePythonVersionFoundError from poetry.utils.env.python.providers import PoetryPythonPathProvider from poetry.utils.env.python.providers import ShutilWhichPythonProvider @@ -87,7 +88,7 @@ def find_all(cls) -> Iterator[Python]: Path(venv) if (venv := os.environ.get("VIRTUAL_ENV")) else None ) for python in findpython.find_all(): - if venv_path and python.executable.is_relative_to(venv_path): + if venv_path and is_relative_to(python.executable, venv_path): continue yield cls(python=python) diff --git a/tests/utils/test_compat.py b/tests/utils/test_compat.py new file mode 100644 index 00000000000..8e935fbe55a --- /dev/null +++ b/tests/utils/test_compat.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import sys + +from pathlib import Path + +import pytest + +from poetry.utils._compat import is_relative_to + + +@pytest.mark.parametrize( + ("path1", "path2", "expected"), + [ + ("a", "a", True), + ("a/b", "a/b", True), + ("a/b", "a", True), + ("a", "a/b", False), + ("a/b/c/d", "a/b", True), + ("a/b", "a/b/c/d", False), + ], +) +def test_is_relative_to(path1: str, path2: str, expected: bool) -> None: + assert is_relative_to(Path(path1), Path(path2)) is expected + + +@pytest.mark.parametrize( + ("path1", "path2", "expected"), + [ + ("/", "/", True), + ("/a/b", "/a/b", True), + ("/a/b", "/a", True), + ("/a", "/a/b", False), + ("/a/b/c/d", "/a/b", True), + ("/a/b", "/a/b/c/d", False), + ], +) +@pytest.mark.skipif(sys.platform == "win32", reason="non-Windows paths") +def test_is_relative_to_non_win32(path1: str, path2: str, expected: bool) -> None: + assert is_relative_to(Path(path1), Path(path2)) is expected + + +@pytest.mark.parametrize( + ("path1", "path2", "expected"), + [ + ("C:\\", "C:\\", True), + (r"C:\a\b", r"C:\a\b", True), + (r"C:\a\b", r"C:\a", True), + (r"C:\a", r"C:\a\b", False), + (r"C:\a\b\c\d", r"C:\a\b", True), + (r"C:\a\b", r"C:\a\b\c\d", False), + (r"C:\a\b", r"D:\a", False), + (r"C:\a\b", "D:\\", False), + (r"\\server\a\b", r"\\server\a", True), + (r"\\server\a", r"\\server\a\b", False), + (r"\\server2\a\b", r"\\server\a", False), + # long path prefix + (r"\\?\C:\a\b", r"\\?\C:\a", True), + (r"\\?\C:\a\b", r"C:\a", True), + (r"C:\a\b", r"\\?\C:\a", True), + (r"\\?\C:\a", r"\\?\C:\a\b", False), + # long path UNC prefix + (r"\\?\UNC\server\a\b", r"\\?\UNC\server\a", True), + (r"\\?\UNC\server\a\b", r"\\server\a", True), + (r"\\server\a\b", r"\\?\UNC\server\a", True), + (r"\\?\UNC\server\a", r"\\?\UNC\server\a\b", False), + ], +) +@pytest.mark.skipif(sys.platform != "win32", reason="Windows paths") +def test_is_relative_to_win32(path1: str, path2: str, expected: bool) -> None: + assert is_relative_to(Path(path1), Path(path2)) is expected From 3d0151ac03b5286e557ed1518b815ad225d52cb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:29:24 +0200 Subject: [PATCH 18/21] release: bump version to 2.3.3 --- CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8777e22e28..c7b29f87d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Change Log +## [2.3.3] - 2026-03-29 + +### Fixed + +- **Fix a path traversal vulnerability in the wheel installer that could allow malicious wheel files to write files outside the intended installation directory** ([#10792](https://github.com/python-poetry/poetry/pull/10792)). +- Fix an issue where `git` dependencies from annotated tags could not be updated ([#10719](https://github.com/python-poetry/poetry/pull/10719)). +- Fix an issue where empty `VIRTUAL_ENV` or `CONDA_PREFIX` environment variables (e.g., after `conda deactivate`) would cause Poetry to incorrectly detect an active virtualenv ([#10784](https://github.com/python-poetry/poetry/pull/10784)). +- Fix an issue where an incomprehensible error message was printed when `.venv` was a file instead of a directory ([#10777](https://github.com/python-poetry/poetry/pull/10777)). +- Fix an issue where HTTP Basic Authentication credentials could be corrupted during request preparation, causing authentication failures with long tokens ([#10748](https://github.com/python-poetry/poetry/pull/10748)). +- Fix an issue where `poetry publish --no-interaction --build` requested user interaction ([#10769](https://github.com/python-poetry/poetry/pull/10769)). +- Fix an issue where `poetry init` and `poetry new` created a deprecated `project.license` format ([#10787](https://github.com/python-poetry/poetry/pull/10787)). + +### Docs + +- Clarify the differences between `poetry install` and `poetry update` ([#10713](https://github.com/python-poetry/poetry/pull/10713)). +- Clarify the section of fields in the `pyproject.toml` examples ([#10753](https://github.com/python-poetry/poetry/pull/10753)). +- Add a note about the different installation location when Python from the Microsoft Store is used ([#10759](https://github.com/python-poetry/poetry/pull/10759)). +- Fix the system requirements for Poetry ([#10739](https://github.com/python-poetry/poetry/pull/10739)). +- Fix the `poetry cache clear` example ([#10749](https://github.com/python-poetry/poetry/pull/10749)). +- Fix the link to `pipx` installation instructions ([#10783](https://github.com/python-poetry/poetry/pull/10783)). + +### poetry-core ([`2.3.2`](https://github.com/python-poetry/poetry-core/releases/tag/2.3.2)) + +- Fix an issue where `platform_release` could not be parsed on Debian Trixie ([#930](https://github.com/python-poetry/poetry-core/pull/930)). +- Fix an issue where using `project.readme.text` in the `pyproject.toml` file resulted in broken metadata ([#914](https://github.com/python-poetry/poetry-core/pull/914)). +- Fix an issue where dependency groups were considered equal when their resolved dependencies were equal, even if the groups themselves were not ([#919](https://github.com/python-poetry/poetry-core/pull/919)). +- Fix an issue where removing a dependency from a group that included another group resulted in other dependencies being added to the included group ([#922](https://github.com/python-poetry/poetry-core/pull/922)). +- Fix an issue where PEP 735 `include-group` entries were lost when `[tool.poetry.group]` also defined `include-groups` for the same group ([#924](https://github.com/python-poetry/poetry-core/pull/924)). +- Fix an issue where the union of ` not in ` constraints was wrongly treated as always satisfied ([#925](https://github.com/python-poetry/poetry-core/pull/925)). +- Fix an issue where a post release with a local version identifier was wrongly allowed by a `>` version constraint ([#921](https://github.com/python-poetry/poetry-core/pull/921)). +- Fix an issue where a version with the local version identifier `0` was treated as equal to the corresponding public version ([#920](https://github.com/python-poetry/poetry-core/pull/920)). +- Fix an issue where a `!= ` constraint wrongly disallowed pre releases and post releases of the specified version ([#929](https://github.com/python-poetry/poetry-core/pull/929)). +- Fix an issue where `in` and `not in` constraints were wrongly not allowed by specific compound constraints ([#927](https://github.com/python-poetry/poetry-core/pull/927)). + + ## [2.3.2] - 2026-02-01 ### Changed @@ -2658,7 +2693,8 @@ Initial release -[Unreleased]: https://github.com/python-poetry/poetry/compare/2.3.2...main +[Unreleased]: https://github.com/python-poetry/poetry/compare/2.3.3...main +[2.3.3]: https://github.com/python-poetry/poetry/releases/tag/2.3.3 [2.3.2]: https://github.com/python-poetry/poetry/releases/tag/2.3.2 [2.3.1]: https://github.com/python-poetry/poetry/releases/tag/2.3.1 [2.3.0]: https://github.com/python-poetry/poetry/releases/tag/2.3.0 diff --git a/pyproject.toml b/pyproject.toml index 6d691e7ce52..467a8be9d1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "poetry" -version = "2.3.2" +version = "2.3.3" description = "Python dependency management and packaging made easy." requires-python = ">=3.10,<4.0" dependencies = [ From 506c09db69a127f6fc2c54958d4f5fdc0ea378cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:21:51 +0200 Subject: [PATCH 19/21] perf: use `os.path.abspath()` instead of `Path.resolve()` (#10821) (cherry picked from commit a029a41ea4adb1b35e9d1ba4fe391cd1c77804d8) --- src/poetry/installation/wheel_installer.py | 24 +++-- tests/installation/test_wheel_installer.py | 101 +++++++++++++++++++-- 2 files changed, 110 insertions(+), 15 deletions(-) diff --git a/src/poetry/installation/wheel_installer.py b/src/poetry/installation/wheel_installer.py index 3f132692422..fd0600982b6 100644 --- a/src/poetry/installation/wheel_installer.py +++ b/src/poetry/installation/wheel_installer.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import os import platform import sys @@ -14,7 +15,6 @@ from poetry.__version__ import __version__ from poetry.utils._compat import WINDOWS -from poetry.utils._compat import is_relative_to logger = logging.getLogger(__name__) @@ -45,14 +45,20 @@ def write_to_fs( from installer.utils import copyfileobj_with_hashing from installer.utils import make_file_executable - target_dir = Path(self.scheme_dict[scheme]).resolve() - target_path = (target_dir / path).resolve() - - # Use is_relative_to() instead of Path.is_relative_to() - # because the latter does not work if one of both paths - # has a Windows long path prefix and the other path has not. - # (A long path prefix may be added when calling resolve().) - if not is_relative_to(target_path, target_dir): + # See https://docs.python.org/3/library/zipfile.html#zipfile.Path: + # When handling untrusted archives, + # consider resolving filenames using os.path.abspath() + # and checking against the target directory with os.path.commonpath(). + # + # Attention: Path.absolute() is not sufficient because it does not + # normalize, i.e. does not remove "..". + # + # We want to avoid Path.resolve() because it is significantly slower + # than os.path.abspath()! + target_dir = Path(os.path.abspath(self.scheme_dict[scheme])) + target_path = Path(os.path.abspath(target_dir / path)) + + if not target_path.is_relative_to(target_dir): raise ValueError( f"Attempting to write {path} outside of the target directory\n" f"Target directory: {target_dir}\n" diff --git a/tests/installation/test_wheel_installer.py b/tests/installation/test_wheel_installer.py index 33e95dd970a..3ab60a59101 100644 --- a/tests/installation/test_wheel_installer.py +++ b/tests/installation/test_wheel_installer.py @@ -10,6 +10,7 @@ from poetry.core.constraints.version import parse_constraint from poetry.installation.wheel_installer import WheelInstaller +from poetry.utils._compat import WINDOWS from poetry.utils.env import MockEnv @@ -21,7 +22,7 @@ @pytest.fixture def env(tmp_path: Path) -> MockEnv: - return MockEnv(path=tmp_path) + return MockEnv(path=tmp_path / "env") @pytest.fixture(scope="module") @@ -97,14 +98,20 @@ def test_install_dir_is_symlink(tmp_path: Path, demo_wheel: Path) -> None: assert (Path(env.paths["purelib"]) / "demo").exists() -@pytest.fixture -def wheel_with_path_traversal(tmp_path: Path) -> Path: +@pytest.fixture(params=[False, True]) # relative path +def wheel_with_path_traversal(tmp_path: Path, request: pytest.FixtureRequest) -> Path: import zipfile + traversal_path = ( + "../../traversal.txt" + if request.param + else (tmp_path / "traversal.txt").as_posix() + ) + wheel = tmp_path / "traversal-0.1-py3-none-any.whl" files = { "traversal/__init__.py": b"", - "../../traversal.txt": b"", + traversal_path: b"path traversal", "traversal-0.1.dist-info/WHEEL": ( b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" ), @@ -124,8 +131,90 @@ def wheel_with_path_traversal(tmp_path: Path) -> Path: return wheel -def test_path_traversal(env: MockEnv, wheel_with_path_traversal: Path) -> None: +@pytest.mark.parametrize("existing", [False, True]) +def test_no_path_traversal( + env: MockEnv, wheel_with_path_traversal: Path, existing: bool +) -> None: + target = env.path.parent / "traversal.txt" + if existing: + target.write_text("original", encoding="utf-8") installer = WheelInstaller(env) with pytest.raises(ValueError): installer.install(wheel_with_path_traversal) - assert not (env.path.parent / "traversal.txt").exists() + + if existing: + assert target.exists() + assert target.read_text(encoding="utf-8") == "original" + else: + assert not target.exists() + + +@pytest.fixture(params=[False, True]) # relative path +def wheel_with_symlink(tmp_path: Path, request: pytest.FixtureRequest) -> Path: + import stat + import zipfile + + wheel = tmp_path / "symlink-0.1-py3-none-any.whl" + files = { + "symlink/__init__.py": b"", + "symlink-0.1.dist-info/WHEEL": ( + b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" + ), + "symlink-0.1.dist-info/METADATA": ( + b"Metadata-Version: 2.1\nName: symlink-pkg\nVersion: 0.1\n" + ), + } + + symlink_entry = "symlink/traversal_link" + symlink_target = ( + b"../../target" + if request.param + else (tmp_path / "target").as_posix().encode("utf-8") + ) + traversal_file = "symlink/traversal_link/traversal.txt" + + record_lines = [f"{k},," for k in files] + record_lines.append(f"{symlink_entry},,") + record_lines.append(f"{traversal_file},,") + record_lines.append("symlink-0.1.dist-info/RECORD,,") + files["symlink-0.1.dist-info/RECORD"] = ("\n".join(record_lines) + "\n").encode() + + with zipfile.ZipFile(wheel, "w") as z: + for k, v in files.items(): + z.writestr(k, v) + + # Add a ZIP entry whose external attributes mark it as a symlink. + # The entry's data is the symlink target, pointing outside the + # installation directory. + info = zipfile.ZipInfo(symlink_entry) + info.create_system = 3 # unix + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + z.writestr(info, symlink_target) + + z.writestr(traversal_file, b"path traversal") + + return wheel + + +@pytest.mark.parametrize("existing", [False, True]) +def test_no_path_traversal_via_symlink( + tmp_path: Path, env: MockEnv, wheel_with_symlink: Path, existing: bool +) -> None: + target_dir = tmp_path / "target" + target_dir.mkdir() + target = target_dir / "traversal.txt" + if existing: + target.write_text("original", encoding="utf-8") + + installer = WheelInstaller(env) + with pytest.raises(FileNotFoundError if WINDOWS else NotADirectoryError): + installer.install(wheel_with_symlink) + + traversal_link = Path(env.paths["purelib"]) / "symlink" / "traversal_link" + assert traversal_link.exists() + assert not traversal_link.is_symlink() # not even extracted as symlink + assert target_dir.exists() + if existing: + assert target.read_text(encoding="utf-8") == "original" + else: + assert not list(target_dir.iterdir()) From e512e7fc5557251c7c9c59d0029506e77db1ea18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:50:20 +0200 Subject: [PATCH 20/21] fix: refuse to write files outside the target directory during sdist extraction (#10837) This has already been ensured with newer Python versions. Now, it is ensured with all supported versions, that means also with 3.10.0 - 3.10.12 and 3.11.0 - 3.11.4. Co-authored-by: Koda Reef (cherry picked from commit 47e97340cae50d3698aac858732788861ba8dd1f) --- src/poetry/utils/helpers.py | 37 ++++- tests/conftest.py | 82 ++++++++++ tests/installation/test_wheel_installer.py | 90 +---------- tests/utils/test_helpers.py | 167 +++++++++++++++++++++ 4 files changed, 292 insertions(+), 84 deletions(-) diff --git a/src/poetry/utils/helpers.py b/src/poetry/utils/helpers.py index 4ef24b6de54..e8d8dc09e86 100644 --- a/src/poetry/utils/helpers.py +++ b/src/poetry/utils/helpers.py @@ -415,7 +415,7 @@ def extractall(source: Path, dest: Path, zip: bool) -> None: else: # These versions of python shipped with a broken tarfile data_filter, per # https://github.com/python/cpython/issues/107845. - broken_tarfile_filter = {(3, 9, 17), (3, 10, 12), (3, 11, 4)} + broken_tarfile_filter = {(3, 10, 12), (3, 11, 4)} with tarfile.open(source) as archive: if ( hasattr(tarfile, "data_filter") @@ -423,4 +423,37 @@ def extractall(source: Path, dest: Path, zip: bool) -> None: ): archive.extractall(dest, filter="data") else: - archive.extractall(dest) + # Validate all member paths before extraction + # + # Attention: Path.absolute() is not sufficient because it does not + # normalize, i.e. does not remove "..". + # + # We want to avoid Path.resolve() because it is significantly slower + # than os.path.abspath()! + dest = Path(os.path.abspath(dest)) + safe_members = [] + for member in archive.getmembers(): + member_path = Path(os.path.abspath(dest / member.name)) + if not member_path.is_relative_to(dest): + raise ValueError( + f"Refusing to extract {member.name}: " + f"would write outside {dest}" + ) + if member.issym(): + link_target = Path( + os.path.abspath(member_path.parent / member.linkname) + ) + if not link_target.is_relative_to(dest): + raise ValueError( + f"Refusing symlink {member.name}: " + f"target {member.linkname} outside {dest}" + ) + elif member.islnk(): + link_target = Path(os.path.abspath(dest / member.linkname)) + if not link_target.is_relative_to(dest): + raise ValueError( + f"Refusing hardlink {member.name}: " + f"target {member.linkname} outside {dest}" + ) + safe_members.append(member) + archive.extractall(dest, members=safe_members) diff --git a/tests/conftest.py b/tests/conftest.py index ceed631183e..961e44710ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1047,3 +1047,85 @@ def register( return bin_dir return register + + +@pytest.fixture(params=[False, True]) # relative path +def wheel_with_path_traversal(tmp_path: Path, request: pytest.FixtureRequest) -> Path: + import zipfile + + traversal_path = ( + "../../traversal.txt" + if request.param + else (tmp_path / "traversal.txt").as_posix() + ) + + wheel = tmp_path / "traversal-0.1-py3-none-any.whl" + files = { + "traversal/__init__.py": b"", + traversal_path: b"path traversal", + "traversal-0.1.dist-info/WHEEL": ( + b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" + ), + "traversal-0.1.dist-info/METADATA": ( + b"Metadata-Version: 2.1\nName: traversal\nVersion: 0.1\n" + ), + } + files["traversal-0.1.dist-info/RECORD"] = ( + "\n".join([f"{k},," for k in files] + ["traversal-0.1.dist-info/RECORD,,"]) + + "\n" + ).encode() + + with zipfile.ZipFile(wheel, "w") as z: + for k, v in files.items(): + z.writestr(k, v) + + return wheel + + +@pytest.fixture(params=[False, True]) # relative path +def wheel_with_path_traversal_via_symlink( + tmp_path: Path, request: pytest.FixtureRequest +) -> Path: + import stat + import zipfile + + wheel = tmp_path / "symlink-0.1-py3-none-any.whl" + files = { + "symlink/__init__.py": b"", + "symlink-0.1.dist-info/WHEEL": ( + b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" + ), + "symlink-0.1.dist-info/METADATA": ( + b"Metadata-Version: 2.1\nName: symlink-pkg\nVersion: 0.1\n" + ), + } + + symlink_entry = "symlink/traversal_link" + symlink_target = ( + b"../../target" + if request.param + else (tmp_path / "target").as_posix().encode("utf-8") + ) + traversal_file = "symlink/traversal_link/traversal.txt" + + record_lines = [f"{k},," for k in files] + record_lines.append(f"{symlink_entry},,") + record_lines.append(f"{traversal_file},,") + record_lines.append("symlink-0.1.dist-info/RECORD,,") + files["symlink-0.1.dist-info/RECORD"] = ("\n".join(record_lines) + "\n").encode() + + with zipfile.ZipFile(wheel, "w") as z: + for k, v in files.items(): + z.writestr(k, v) + + # Add a ZIP entry whose external attributes mark it as a symlink. + # The entry's data is the symlink target, pointing outside the + # installation directory. + info = zipfile.ZipInfo(symlink_entry) + info.create_system = 3 # unix + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + z.writestr(info, symlink_target) + + z.writestr(traversal_file, b"path traversal") + + return wheel diff --git a/tests/installation/test_wheel_installer.py b/tests/installation/test_wheel_installer.py index 3ab60a59101..bdd38867b86 100644 --- a/tests/installation/test_wheel_installer.py +++ b/tests/installation/test_wheel_installer.py @@ -98,43 +98,11 @@ def test_install_dir_is_symlink(tmp_path: Path, demo_wheel: Path) -> None: assert (Path(env.paths["purelib"]) / "demo").exists() -@pytest.fixture(params=[False, True]) # relative path -def wheel_with_path_traversal(tmp_path: Path, request: pytest.FixtureRequest) -> Path: - import zipfile - - traversal_path = ( - "../../traversal.txt" - if request.param - else (tmp_path / "traversal.txt").as_posix() - ) - - wheel = tmp_path / "traversal-0.1-py3-none-any.whl" - files = { - "traversal/__init__.py": b"", - traversal_path: b"path traversal", - "traversal-0.1.dist-info/WHEEL": ( - b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" - ), - "traversal-0.1.dist-info/METADATA": ( - b"Metadata-Version: 2.1\nName: traversal\nVersion: 0.1\n" - ), - } - files["traversal-0.1.dist-info/RECORD"] = ( - "\n".join([f"{k},," for k in files] + ["traversal-0.1.dist-info/RECORD,,"]) - + "\n" - ).encode() - - with zipfile.ZipFile(wheel, "w") as z: - for k, v in files.items(): - z.writestr(k, v) - - return wheel - - @pytest.mark.parametrize("existing", [False, True]) def test_no_path_traversal( env: MockEnv, wheel_with_path_traversal: Path, existing: bool ) -> None: + """see also test_extractall_wheel_no_path_traversal in test_helpers.py""" target = env.path.parent / "traversal.txt" if existing: target.write_text("original", encoding="utf-8") @@ -149,57 +117,15 @@ def test_no_path_traversal( assert not target.exists() -@pytest.fixture(params=[False, True]) # relative path -def wheel_with_symlink(tmp_path: Path, request: pytest.FixtureRequest) -> Path: - import stat - import zipfile - - wheel = tmp_path / "symlink-0.1-py3-none-any.whl" - files = { - "symlink/__init__.py": b"", - "symlink-0.1.dist-info/WHEEL": ( - b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" - ), - "symlink-0.1.dist-info/METADATA": ( - b"Metadata-Version: 2.1\nName: symlink-pkg\nVersion: 0.1\n" - ), - } - - symlink_entry = "symlink/traversal_link" - symlink_target = ( - b"../../target" - if request.param - else (tmp_path / "target").as_posix().encode("utf-8") - ) - traversal_file = "symlink/traversal_link/traversal.txt" - - record_lines = [f"{k},," for k in files] - record_lines.append(f"{symlink_entry},,") - record_lines.append(f"{traversal_file},,") - record_lines.append("symlink-0.1.dist-info/RECORD,,") - files["symlink-0.1.dist-info/RECORD"] = ("\n".join(record_lines) + "\n").encode() - - with zipfile.ZipFile(wheel, "w") as z: - for k, v in files.items(): - z.writestr(k, v) - - # Add a ZIP entry whose external attributes mark it as a symlink. - # The entry's data is the symlink target, pointing outside the - # installation directory. - info = zipfile.ZipInfo(symlink_entry) - info.create_system = 3 # unix - info.external_attr = (stat.S_IFLNK | 0o777) << 16 - z.writestr(info, symlink_target) - - z.writestr(traversal_file, b"path traversal") - - return wheel - - @pytest.mark.parametrize("existing", [False, True]) def test_no_path_traversal_via_symlink( - tmp_path: Path, env: MockEnv, wheel_with_symlink: Path, existing: bool + tmp_path: Path, + env: MockEnv, + wheel_with_path_traversal_via_symlink: Path, + existing: bool, ) -> None: + """see also test_extractall_wheel_no_path_traversal_via_symlink + in test_helpers.py""" target_dir = tmp_path / "target" target_dir.mkdir() target = target_dir / "traversal.txt" @@ -208,7 +134,7 @@ def test_no_path_traversal_via_symlink( installer = WheelInstaller(env) with pytest.raises(FileNotFoundError if WINDOWS else NotADirectoryError): - installer.install(wheel_with_symlink) + installer.install(wheel_with_path_traversal_via_symlink) traversal_link = Path(env.paths["purelib"]) / "symlink" / "traversal_link" assert traversal_link.exists() diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index 32119ba07b2..010637c693f 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -1,7 +1,10 @@ from __future__ import annotations import base64 +import contextlib import re +import sys +import tarfile from pathlib import Path from typing import TYPE_CHECKING @@ -13,10 +16,12 @@ from poetry.core.utils.helpers import parse_requires from requests.exceptions import ChunkedEncodingError +from poetry.utils._compat import WINDOWS from poetry.utils.helpers import Downloader from poetry.utils.helpers import HTTPRangeRequestSupportedError from poetry.utils.helpers import download_file from poetry.utils.helpers import ensure_path +from poetry.utils.helpers import extractall from poetry.utils.helpers import get_file_hash from poetry.utils.helpers import get_highest_priority_hash_type @@ -341,3 +346,165 @@ def test_ensure_path_directory(tmp_path: Path) -> None: path.mkdir() assert ensure_path(path=path, is_directory=True) is path + + +@pytest.mark.parametrize("relative", [False, True]) +@pytest.mark.parametrize("existing", [False, True]) +def test_extractall_sdist_no_path_traversal( + tmp_path: Path, relative: bool, existing: bool +) -> None: + import io + import tarfile + + archive = tmp_path / "traversal.tar.gz" + dest = tmp_path / "dest" + dest.mkdir() + + target = tmp_path / "traversal.txt" + if existing: + target.write_text("original", encoding="utf-8") + + with tarfile.open(archive, "w:gz") as tar: + b = b"path traversal" + t = tarfile.TarInfo("../traversal.txt" if relative else target.as_posix()) + t.size = len(b) + tar.addfile(t, io.BytesIO(b)) + + has_data_filter = hasattr(tarfile, "data_filter") + # The stdlib implementation just strips the leading "/" from absolute paths + # and extracts them relative to the target directory (except for Windows). + # We do not care and raise an error. + raises = ( + relative + or WINDOWS + or not has_data_filter + or sys.version_info[:3] in {(3, 10, 12), (3, 11, 4)} + ) + exceptions: tuple[type[Exception], ...] + if has_data_filter: + if relative: + exceptions = (tarfile.OutsideDestinationError, ValueError) + else: + exceptions = (tarfile.AbsolutePathError, ValueError) + else: + # tarfile.OutsideDestinationError does not exist + exceptions = (ValueError,) + + with pytest.raises(exceptions) if raises else contextlib.nullcontext(): + extractall(source=archive, dest=dest, zip=False) + + if existing: + assert target.exists() + assert target.read_text(encoding="utf-8") == "original" + else: + assert not target.exists() + if not raises: + # check that expected location exists, otherwise we have to check + # that there is no traversal in an unexpected location + assert (dest / target.as_posix().lstrip("/")).exists() + + +@pytest.mark.parametrize("link_type", [tarfile.SYMTYPE, tarfile.LNKTYPE]) +@pytest.mark.parametrize("relative", [False, True]) +@pytest.mark.parametrize("existing", [False, True]) +def test_extractall_sdist_no_symlink_path_traversal( + tmp_path: Path, link_type: bytes, relative: bool, existing: bool +) -> None: + import io + import tarfile + + archive = tmp_path / "traversal.tar.gz" + dest = tmp_path / "dest" + dest.mkdir() + + target = tmp_path / "traversal.txt" + if existing: + target.write_text("original", encoding="utf-8") + + with tarfile.open(archive, "w:gz") as tar: + # We use a link in a subdirectory to test the difference + # between symlinks and hardlinks: + # symlinks are relative to the directory of the symlink, + # while hardlinks are relative to the root of the archive + s = tarfile.TarInfo("sub/link") + s.type = link_type + if relative: + s.linkname = ( + "../../traversal.txt" + if link_type == tarfile.SYMTYPE + else "../traversal.txt" + ) + else: + s.linkname = target.as_posix() + tar.addfile(s) + p = b"path traversal" + f = tarfile.TarInfo("sub/link") + f.size = len(p) + tar.addfile(f, io.BytesIO(p)) + + exceptions: tuple[type[Exception], ...] + if hasattr(tarfile, "data_filter"): + exceptions = ( + tarfile.AbsoluteLinkError, + tarfile.LinkOutsideDestinationError, + ValueError, + ) + else: + # tarfile.OutsideDestinationError does not exist + exceptions = (ValueError,) + + with pytest.raises(exceptions): + extractall(source=archive, dest=dest, zip=False) + + if existing: + assert target.exists() + assert target.read_text(encoding="utf-8") == "original" + else: + assert not target.exists() + + +@pytest.mark.parametrize("existing", [False, True]) +def test_extractall_wheel_no_path_traversal( + tmp_path: Path, wheel_with_path_traversal: Path, existing: bool +) -> None: + """see also test_no_path_traversal in test_wheel_installer.py""" + dest = tmp_path / "dest" / "dir" + dest.mkdir(parents=True) + target = tmp_path / "traversal.txt" + if existing: + target.write_text("original", encoding="utf-8") + + extractall(source=wheel_with_path_traversal, dest=dest, zip=True) + + if existing: + assert target.exists() + assert target.read_text(encoding="utf-8") == "original" + else: + assert not target.exists() + + # target is "../.." but also check ".." just to be sure + assert not (dest.parent / "traversal.txt").exists() + + +@pytest.mark.parametrize("existing", [False, True]) +def test_extractall_wheel_no_path_traversal_via_symlink( + tmp_path: Path, wheel_with_path_traversal_via_symlink: Path, existing: bool +) -> None: + """see also test_no_path_traversal_via_symlink in test_wheel_installer.py""" + dest = tmp_path / "dest" / "dir" + dest.mkdir(parents=True) + target_dir = tmp_path / "target" + target_dir.mkdir() + target = target_dir / "traversal.txt" + if existing: + target.write_text("original", encoding="utf-8") + + with pytest.raises(FileNotFoundError if WINDOWS else NotADirectoryError): + extractall(source=wheel_with_path_traversal_via_symlink, dest=dest, zip=True) + + assert target_dir.exists() + if existing: + assert target.exists() + assert target.read_text(encoding="utf-8") == "original" + else: + assert not target.exists() From 7c7af71ba206dadd2ff7eda19b9a4c90c4349754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:59:46 +0200 Subject: [PATCH 21/21] release: bump version to 2.3.4 --- CHANGELOG.md | 11 ++++++++++- pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7b29f87d14..fe272fbf1ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change Log +## [2.3.4] - 2026-04-12 + +### Fixed + +- Fix a performance regression in the wheel installer that was introduced in Poetry 2.3.3 ([#10821](https://github.com/python-poetry/poetry/pull/10821)). +- Fix a path traversal vulnerability in sdist extraction on Python 3.10.0-3.10.12 and 3.11.0-3.11.4 that could allow malicious tarball files to write files outside the target directory ([#10837](https://github.com/python-poetry/poetry/pull/10837)). + + ## [2.3.3] - 2026-03-29 ### Fixed @@ -2693,7 +2701,8 @@ Initial release -[Unreleased]: https://github.com/python-poetry/poetry/compare/2.3.3...main +[Unreleased]: https://github.com/python-poetry/poetry/compare/2.3.4...main +[2.3.4]: https://github.com/python-poetry/poetry/releases/tag/2.3.4 [2.3.3]: https://github.com/python-poetry/poetry/releases/tag/2.3.3 [2.3.2]: https://github.com/python-poetry/poetry/releases/tag/2.3.2 [2.3.1]: https://github.com/python-poetry/poetry/releases/tag/2.3.1 diff --git a/pyproject.toml b/pyproject.toml index 467a8be9d1e..4a0ded3d141 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "poetry" -version = "2.3.3" +version = "2.3.4" description = "Python dependency management and packaging made easy." requires-python = ">=3.10,<4.0" dependencies = [