diff --git a/docs/html/topics/more-dependency-resolution.md b/docs/html/topics/more-dependency-resolution.md
index c0afd3a4e28..c048acd8528 100644
--- a/docs/html/topics/more-dependency-resolution.md
+++ b/docs/html/topics/more-dependency-resolution.md
@@ -160,16 +160,22 @@ Pip's current implementation of the provider implements
* If Requires-Python is present only consider that
* If there are causes of resolution conflict (backtrack causes) then
- only consider them until there are no longer any resolution conflicts
-
-Pip's current implementation of the provider implements `get_preference` as
-follows:
-
-* Prefer if any of the known requirements is "direct", e.g. points to an
- explicit URL.
-* If equal, prefer if any requirement is "pinned", i.e. contains
- operator ``===`` or ``==``.
-* Order user-specified requirements by the order they are specified.
-* If equal, prefers "non-free" requirements, i.e. contains at least one
- operator, such as ``>=`` or ``<``.
-* If equal, order alphabetically for consistency (helps debuggability).
+ only consider them until there are no longer any resolution conflicts
+
+Pip's current implementation of the provider implements `get_preference`
+for known requirements with the following preferences in the following order:
+
+* Any requirement that is "direct", e.g., points to an explicit URL.
+* Any requirement that is "pinned", i.e., contains the operator ``===``
+ or ``==`` without a wildcard.
+* Any requirement that imposes an upper version limit, i.e., contains the
+ operator ``<``, ``<=``, ``~=``, or ``==`` with a wildcard. Because
+ pip prioritizes the latest version, preferring explicit upper bounds
+ can rule out infeasible candidates sooner. This does not imply that
+ upper bounds are good practice; they can make dependency management
+ and resolution harder.
+* Order user-specified requirements as they are specified, placing
+ other requirements afterward.
+* Any "non-free" requirement, i.e., one that contains at least one
+ operator, such as ``>=`` or ``!=``.
+* Alphabetical order for consistency (aids debuggability).
diff --git a/news/13273.feature.rst b/news/13273.feature.rst
new file mode 100644
index 00000000000..ad3177ec3dd
--- /dev/null
+++ b/news/13273.feature.rst
@@ -0,0 +1 @@
+Improved heuristics for determining the order of dependency resolution.
diff --git a/src/pip/_internal/resolution/resolvelib/provider.py b/src/pip/_internal/resolution/resolvelib/provider.py
index afdffe8191e..617a993cb03 100644
--- a/src/pip/_internal/resolution/resolvelib/provider.py
+++ b/src/pip/_internal/resolution/resolvelib/provider.py
@@ -161,14 +161,20 @@ def get_preference(
Currently pip considers the following in order:
- * Prefer if any of the known requirements is "direct", e.g. points to an
- explicit URL.
- * If equal, prefer if any requirement is "pinned", i.e. contains
- operator ``===`` or ``==``.
- * Order user-specified requirements by the order they are specified.
- * If equal, prefers "non-free" requirements, i.e. contains at least one
- operator, such as ``>=`` or ``<``.
- * If equal, order alphabetically for consistency (helps debuggability).
+ * Any requirement that is "direct", e.g., points to an explicit URL.
+ * Any requirement that is "pinned", i.e., contains the operator ``===``
+ or ``==`` without a wildcard.
+ * Any requirement that imposes an upper version limit, i.e., contains the
+ operator ``<``, ``<=``, ``~=``, or ``==`` with a wildcard. Because
+ pip prioritizes the latest version, preferring explicit upper bounds
+ can rule out infeasible candidates sooner. This does not imply that
+ upper bounds are good practice; they can make dependency management
+ and resolution harder.
+ * Order user-specified requirements as they are specified, placing
+ other requirements afterward.
+ * Any "non-free" requirement, i.e., one that contains at least one
+ operator, such as ``>=`` or ``!=``.
+ * Alphabetical order for consistency (aids debuggability).
"""
try:
next(iter(information[identifier]))
@@ -193,12 +199,17 @@ def get_preference(
direct = candidate is not None
pinned = any(((op[:2] == "==") and ("*" not in ver)) for op, ver in operators)
+ upper_bounded = any(
+ ((op in ("<", "<=", "~=")) or (op == "==" and "*" in ver))
+ for op, ver in operators
+ )
unfree = bool(operators)
requested_order = self._user_requested.get(identifier, math.inf)
return (
not direct,
not pinned,
+ not upper_bounded,
requested_order,
not unfree,
identifier,
diff --git a/tests/unit/resolution_resolvelib/test_provider.py b/tests/unit/resolution_resolvelib/test_provider.py
index 690217e85ce..8db40ddc298 100644
--- a/tests/unit/resolution_resolvelib/test_provider.py
+++ b/tests/unit/resolution_resolvelib/test_provider.py
@@ -42,7 +42,7 @@ def build_req_info(
{"pinned-package": [build_req_info("pinned-package==1.0")]},
[],
{},
- (False, False, math.inf, False, "pinned-package"),
+ (False, False, True, math.inf, False, "pinned-package"),
),
# Star-specified package, i.e. with "*"
(
@@ -50,7 +50,7 @@ def build_req_info(
{"star-specified-package": [build_req_info("star-specified-package==1.*")]},
[],
{},
- (False, True, math.inf, False, "star-specified-package"),
+ (False, True, False, math.inf, False, "star-specified-package"),
),
# Package that caused backtracking
(
@@ -58,7 +58,7 @@ def build_req_info(
{"backtrack-package": [build_req_info("backtrack-package")]},
[build_req_info("backtrack-package")],
{},
- (False, True, math.inf, True, "backtrack-package"),
+ (False, True, True, math.inf, True, "backtrack-package"),
),
# Root package requested by user
(
@@ -66,15 +66,15 @@ def build_req_info(
{"root-package": [build_req_info("root-package")]},
[],
{"root-package": 1},
- (False, True, 1, True, "root-package"),
+ (False, True, True, 1, True, "root-package"),
),
# Unfree package (with specifier operator)
(
"unfree-package",
- {"unfree-package": [build_req_info("unfree-package<1")]},
+ {"unfree-package": [build_req_info("unfree-package!=1")]},
[],
{},
- (False, True, math.inf, False, "unfree-package"),
+ (False, True, True, math.inf, False, "unfree-package"),
),
# Free package (no operator)
(
@@ -82,7 +82,47 @@ def build_req_info(
{"free-package": [build_req_info("free-package")]},
[],
{},
- (False, True, math.inf, True, "free-package"),
+ (False, True, True, math.inf, True, "free-package"),
+ ),
+ # Upper bounded with <= operator
+ (
+ "upper-bound-lte-package",
+ {
+ "upper-bound-lte-package": [
+ build_req_info("upper-bound-lte-package<=2.0")
+ ]
+ },
+ [],
+ {},
+ (False, True, False, math.inf, False, "upper-bound-lte-package"),
+ ),
+ # Upper bounded with < operator
+ (
+ "upper-bound-lt-package",
+ {"upper-bound-lt-package": [build_req_info("upper-bound-lt-package<2.0")]},
+ [],
+ {},
+ (False, True, False, math.inf, False, "upper-bound-lt-package"),
+ ),
+ # Upper bounded with ~= operator
+ (
+ "upper-bound-compatible-package",
+ {
+ "upper-bound-compatible-package": [
+ build_req_info("upper-bound-compatible-package~=1.0")
+ ]
+ },
+ [],
+ {},
+ (False, True, False, math.inf, False, "upper-bound-compatible-package"),
+ ),
+ # Not upper bounded, using only >= operator
+ (
+ "lower-bound-package",
+ {"lower-bound-package": [build_req_info("lower-bound-package>=1.0")]},
+ [],
+ {},
+ (False, True, True, math.inf, False, "lower-bound-package"),
),
],
)