From 718eaf6c87158bb6dc75c5d94afba8865a6d4d67 Mon Sep 17 00:00:00 2001 From: konstin Date: Thu, 18 Sep 2025 11:04:41 +0200 Subject: [PATCH 1/5] Document why uv discards upper bounds We're regularly get questions about this. The DPO thread is the best ressource, but it's also a long read, so I summarized some points for uv's decision. --- docs/reference/internals/resolver.md | 51 +++++++++++++++++++++------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/docs/reference/internals/resolver.md b/docs/reference/internals/resolver.md index b4acdab345ccb..78173231bb941 100644 --- a/docs/reference/internals/resolver.md +++ b/docs/reference/internals/resolver.md @@ -156,19 +156,44 @@ Windows, Linux and macOS. To ensure that a resolution with `requires-python = ">=3.9"` can actually be installed for the included Python versions, uv requires that all dependencies have the same minimum Python version. Package versions that declare a higher minimum Python version, e.g., `requires-python = ">=3.10"`, -are rejected, because a resolution with that version can't be installed on Python 3.9. For -simplicity and forward compatibility, only lower bounds in `requires-python` are respected. For -example, if a package declares `requires-python = ">=3.8,<4"`, the `<4` marker is not propagated to -the entire resolution. - -This default is a problem for packages that use the version-dependent C API of CPython, such as -numpy. Each numpy release support 4 Python minor versions, e.g., numpy 2.0.0 has wheels for CPython -3.9 through 3.12 and declares `requires-python = ">=3.9"`, while numpy 2.1.0 has wheels for CPython -3.10 through 3.13 and declares `requires-python = ">=3.10"`. The means that when we resolve a -`numpy>=2,<3` requirement in a project with `requires-python = ">=3.9"`, we resolve numpy 2.0.0 and -the lockfile doesn't install on Python 3.13 or newer. To alleviate this, whenever we reject a -version due to a too high Python requirement, we fork on that Python version. This behavior is -controlled by `--fork-strategy`. In the example case, upon encountering numpy 2.1.0 we fork into +are rejected, because a resolution with that version can't be installed on Python 3.9. This ensures +that when you are on an old Python version, you can install old packages, instead of getting newer +packages that require newer Python syntax or standard library features. + +uv ignores upper-bounds on `requires-python`, with special handling for packages with only +ABI-specific wheels. For example, if a package declares `requires-python = ">=3.8,<4"`, the `<4` +marker is not propagated. There is a detailed discussion with drawbacks and alternatives in +[#4022](https://github.com/astral-sh/uv/issues/4022) and this +[DPO thread](https://discuss.python.org/t/requires-python-upper-limits/12663), informing uv's choice +as a combination of aspects: + +For most project it's not possible to determine whether they will be compatible with a new Python +version once its released, so blocking newer versions in advance would only block users from +upgrading or testing newer Python versions. The exception are packages which use the unstable C ABI +or internals of CPython such as its bytecode format, some of which uv handles by inspecting the +wheel tags. + +When a project uses a `requires-python` upper bound in a new release, while it wasn't using one in +older release, attempts to install on a too new Python will instead resolve to the older version +without the bound, wrongly circumventing the bound. + +Another aspect is that when uv resolves for the whole `requires-python` range, so it would require +placing an upper bound on it when any used package has an upper bound, and would potentially resolve +differently for different upper bounds. + +Note that this is different for Conda, as the Conda solver also determines the Python version, so it +can choose a lower Python version instead. Similarly, Conda can change metadata after a release, so +it can update compatibility for a new Python version, while metadata on PyPI cannot be changed once +published. + +This choice on the other hand is a problem for packages that use the version-dependent C API of +CPython, such as numpy. Each numpy release support 4 Python minor versions, e.g., numpy 2.0.0 has +wheels for CPython 3.9 through 3.12 and declares `requires-python = ">=3.9"`, while numpy 2.1.0 has +wheels for CPython 3.10 through 3.13 and declares `requires-python = ">=3.10"`. The means that when +we resolve a `numpy>=2,<3` requirement in a project with `requires-python = ">=3.9"`, we resolve +numpy 2.0.0 and the lockfile doesn't install on Python 3.13 or newer. To alleviate this, whenever we +reject a version due to a too high Python requirement, we fork on that Python version. This behavior +is controlled by `--fork-strategy`. In the example case, upon encountering numpy 2.1.0 we fork into Python versions `>=3.9,<3.10` and `>=3.10` and resolve two different numpy versions: ``` From 38fbf8424b1badeb115c97444b385240a9786e5d Mon Sep 17 00:00:00 2001 From: konsti Date: Tue, 23 Sep 2025 15:19:24 +0200 Subject: [PATCH 2/5] Update docs/reference/internals/resolver.md Co-authored-by: Zanie Blue --- docs/reference/internals/resolver.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/internals/resolver.md b/docs/reference/internals/resolver.md index 78173231bb941..fb6eb05c2e70a 100644 --- a/docs/reference/internals/resolver.md +++ b/docs/reference/internals/resolver.md @@ -169,7 +169,7 @@ as a combination of aspects: For most project it's not possible to determine whether they will be compatible with a new Python version once its released, so blocking newer versions in advance would only block users from -upgrading or testing newer Python versions. The exception are packages which use the unstable C ABI +upgrading or testing newer Python versions. The exceptions are packages which use the unstable C ABI or internals of CPython such as its bytecode format, some of which uv handles by inspecting the wheel tags. From 4dea685c906db5753776b92ef4961bf27b0261c0 Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 23 Sep 2025 16:04:12 +0200 Subject: [PATCH 3/5] Review --- docs/reference/internals/resolver.md | 58 +++++++++++++++------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/docs/reference/internals/resolver.md b/docs/reference/internals/resolver.md index fb6eb05c2e70a..a98b943b68bb2 100644 --- a/docs/reference/internals/resolver.md +++ b/docs/reference/internals/resolver.md @@ -162,39 +162,45 @@ packages that require newer Python syntax or standard library features. uv ignores upper-bounds on `requires-python`, with special handling for packages with only ABI-specific wheels. For example, if a package declares `requires-python = ">=3.8,<4"`, the `<4` -marker is not propagated. There is a detailed discussion with drawbacks and alternatives in +part is ignored. There is a detailed discussion with drawbacks and alternatives in [#4022](https://github.com/astral-sh/uv/issues/4022) and this -[DPO thread](https://discuss.python.org/t/requires-python-upper-limits/12663), informing uv's choice -as a combination of aspects: - -For most project it's not possible to determine whether they will be compatible with a new Python -version once its released, so blocking newer versions in advance would only block users from -upgrading or testing newer Python versions. The exceptions are packages which use the unstable C ABI -or internals of CPython such as its bytecode format, some of which uv handles by inspecting the -wheel tags. - -When a project uses a `requires-python` upper bound in a new release, while it wasn't using one in -older release, attempts to install on a too new Python will instead resolve to the older version -without the bound, wrongly circumventing the bound. - -Another aspect is that when uv resolves for the whole `requires-python` range, so it would require -placing an upper bound on it when any used package has an upper bound, and would potentially resolve -differently for different upper bounds. +[DPO thread](https://discuss.python.org/t/requires-python-upper-limits/12663), this section +summarizes the aspects most relevant to uv's design. + +For most projects, it's not possible to determine whether they will be compatible with a new version +before it's released, so blocking newer versions in advance would block users from upgrading or +testing newer Python versions. The exceptions are packages which use the unstable C ABI or internals +of CPython such as its bytecode format. + +Introducing a `requires-python` upper bound to a project that previously wasn't using one will not +prevent the project from being used on a too recent Python version. Instead of failing, the resolver +will pick to the older version without the bound, circumventing the bound. + +For a resolution that is as universally installable as possible, uv ensure that the selected +dependency versions are compatible with the `requires-python` range of the project. For example, for +a project with `requires-python = ">=3.12"`, uv will not use a dependency version with +`requires-python = ">=3.13"`, as otherwise the resolution is not installable on Python 3.12, which +the project declares to support. Applying the same logic to upper bounds means that bumping the +upper Python version bound on a project makes it compatible with less dependency versions, +potentially failing to resolve when no version of a dependency supports the required range. (Bumping +the lower Python version bound has the inverse effect, it only increases the set of supported +dependency versions.) Note that this is different for Conda, as the Conda solver also determines the Python version, so it can choose a lower Python version instead. Similarly, Conda can change metadata after a release, so it can update compatibility for a new Python version, while metadata on PyPI cannot be changed once published. -This choice on the other hand is a problem for packages that use the version-dependent C API of -CPython, such as numpy. Each numpy release support 4 Python minor versions, e.g., numpy 2.0.0 has -wheels for CPython 3.9 through 3.12 and declares `requires-python = ">=3.9"`, while numpy 2.1.0 has -wheels for CPython 3.10 through 3.13 and declares `requires-python = ">=3.10"`. The means that when -we resolve a `numpy>=2,<3` requirement in a project with `requires-python = ">=3.9"`, we resolve -numpy 2.0.0 and the lockfile doesn't install on Python 3.13 or newer. To alleviate this, whenever we -reject a version due to a too high Python requirement, we fork on that Python version. This behavior -is controlled by `--fork-strategy`. In the example case, upon encountering numpy 2.1.0 we fork into -Python versions `>=3.9,<3.10` and `>=3.10` and resolve two different numpy versions: +Ignoring an upper bound is a problem for packages that use the version-dependent C API of CPython, +such as numpy. As of writing, each numpy release support 4 Python minor versions, e.g., numpy 2.0.0 +has wheels for CPython 3.9 through 3.12 and declares `requires-python = ">=3.9"`, while numpy 2.1.0 +has wheels for CPython 3.10 through 3.13 and declares `requires-python = ">=3.10"`. The means that +when we resolve a `numpy>=2,<3` requirement in a project with `requires-python = ">=3.9"`, we +resolve numpy 2.0.0 and the lockfile doesn't install on Python 3.13 or newer. To alleviate this, +whenever we reject a version when it requires a newer Python version, we fork by splitting the +resolution markers on that Python version. This behavior can be controlled by `--fork-strategy`. In +the example case, upon encountering numpy 2.1.0 we fork into Python versions `>=3.9,<3.10` and +`>=3.10` and resolve two different numpy versions: ``` numpy==2.0.0; python_version >= "3.9" and python_version < "3.10" From c4277e0ff65ef2e56deab07db6e2ce3d68c293cb Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 23 Sep 2025 16:23:45 +0200 Subject: [PATCH 4/5] Add note about wheel pruning --- docs/reference/internals/resolver.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/reference/internals/resolver.md b/docs/reference/internals/resolver.md index a98b943b68bb2..8df06173cb241 100644 --- a/docs/reference/internals/resolver.md +++ b/docs/reference/internals/resolver.md @@ -207,6 +207,11 @@ numpy==2.0.0; python_version >= "3.9" and python_version < "3.10" numpy==2.1.0; python_version >= "3.10" ``` +There's one case where uv does consider the upper bound: When the project uses an upper on requires +Python, such as `requires-python = "==3.13.*"` for an application that only deploys to Python 3.13, +uv prunes wheels from the lockfile that are outside the range (e.g., `cp312` and `cp314`). This is +post-processing step and does not influence the resolution itself. + ## URL dependencies In uv, a dependency can either be a registry dependency, a package with a version specifier or the From b5a0c7a91cab3d7909cb620fb94880c154cc496a Mon Sep 17 00:00:00 2001 From: konstin Date: Thu, 9 Oct 2025 16:09:36 +0200 Subject: [PATCH 5/5] Phrasing --- docs/reference/internals/resolver.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/reference/internals/resolver.md b/docs/reference/internals/resolver.md index 8df06173cb241..9dcd21c81e312 100644 --- a/docs/reference/internals/resolver.md +++ b/docs/reference/internals/resolver.md @@ -174,9 +174,9 @@ of CPython such as its bytecode format. Introducing a `requires-python` upper bound to a project that previously wasn't using one will not prevent the project from being used on a too recent Python version. Instead of failing, the resolver -will pick to the older version without the bound, circumventing the bound. +will pick an older version without the bound, circumventing the bound. -For a resolution that is as universally installable as possible, uv ensure that the selected +For the resolution to be as universally installable as possible, uv ensures that the selected dependency versions are compatible with the `requires-python` range of the project. For example, for a project with `requires-python = ">=3.12"`, uv will not use a dependency version with `requires-python = ">=3.13"`, as otherwise the resolution is not installable on Python 3.12, which @@ -187,17 +187,17 @@ the lower Python version bound has the inverse effect, it only increases the set dependency versions.) Note that this is different for Conda, as the Conda solver also determines the Python version, so it -can choose a lower Python version instead. Similarly, Conda can change metadata after a release, so -it can update compatibility for a new Python version, while metadata on PyPI cannot be changed once +can choose a lower Python version instead. Conda can also change metadata after a release, so it can +update compatibility for a new Python version, while metadata on PyPI cannot be changed once published. -Ignoring an upper bound is a problem for packages that use the version-dependent C API of CPython, -such as numpy. As of writing, each numpy release support 4 Python minor versions, e.g., numpy 2.0.0 +Ignoring an upper bound is a problem for packages such as numpy which use the version-dependent C +API of CPython. As of writing, each numpy release support 4 Python minor versions, e.g., numpy 2.0.0 has wheels for CPython 3.9 through 3.12 and declares `requires-python = ">=3.9"`, while numpy 2.1.0 has wheels for CPython 3.10 through 3.13 and declares `requires-python = ">=3.10"`. The means that -when we resolve a `numpy>=2,<3` requirement in a project with `requires-python = ">=3.9"`, we -resolve numpy 2.0.0 and the lockfile doesn't install on Python 3.13 or newer. To alleviate this, -whenever we reject a version when it requires a newer Python version, we fork by splitting the +when uv resolves a `numpy>=2,<3` requirement in a project with `requires-python = ">=3.9"`, it +selects numpy 2.0.0 and the lockfile doesn't install on Python 3.13 or newer. To alleviate this, +whenever uv rejects a version that requires a newer Python version, we fork by splitting the resolution markers on that Python version. This behavior can be controlled by `--fork-strategy`. In the example case, upon encountering numpy 2.1.0 we fork into Python versions `>=3.9,<3.10` and `>=3.10` and resolve two different numpy versions: @@ -207,10 +207,10 @@ numpy==2.0.0; python_version >= "3.9" and python_version < "3.10" numpy==2.1.0; python_version >= "3.10" ``` -There's one case where uv does consider the upper bound: When the project uses an upper on requires -Python, such as `requires-python = "==3.13.*"` for an application that only deploys to Python 3.13, -uv prunes wheels from the lockfile that are outside the range (e.g., `cp312` and `cp314`). This is -post-processing step and does not influence the resolution itself. +There's one case where uv does consider the upper bound: When the project uses an upper bound on +requires Python, such as `requires-python = "==3.13.*"` for an application that only deploys to +Python 3.13. uv prunes wheels from the lockfile that are outside the range (e.g., `cp312` and +`cp314`) in a post-processing step, which does not influence the resolution itself. ## URL dependencies