Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Warn users on "~=" python-version #8284

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/uv-pep440/src/version_specifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ impl VersionSpecifier {
&self.operator
}

/// Get the version, e.g. `<=` in `<= 2.0.0`
/// Get the version, e.g. `2.0.0` in `<= 2.0.0`
pub fn version(&self) -> &Version {
&self.version
}
Expand Down
13 changes: 12 additions & 1 deletion crates/uv-resolver/src/requires_python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ use std::collections::Bound;
use std::ops::Deref;

use uv_distribution_filename::WheelFilename;
use uv_pep440::{release_specifiers_to_ranges, Version, VersionSpecifier, VersionSpecifiers};
use uv_pep440::{
release_specifiers_to_ranges, Operator, Version, VersionSpecifier, VersionSpecifiers,
};
use uv_pep508::{MarkerExpression, MarkerTree, MarkerValueVersion};

/// The `Requires-Python` requirement specifier.
Expand Down Expand Up @@ -278,6 +280,15 @@ impl RequiresPython {
}
}

/// Returns `true` if the `Requires-Python` specifier is set to a tilde-equal version
/// without specifying a patch version. (e.g. `~=3.11`)
pub fn is_tilde_exact_without_patch(&self) -> bool {
self.specifiers.len() == 1
&& self.specifiers.iter().next().map_or(false, |spec| {
*spec.operator() == Operator::TildeEqual && spec.version().release().len() == 2
})
}

/// Returns the [`RequiresPythonBound`] truncated to the major and minor version.
pub fn bound_major_minor(&self) -> LowerBound {
match self.range.lower().as_ref() {
Expand Down
22 changes: 22 additions & 0 deletions crates/uv-resolver/src/requires_python/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,25 @@ fn is_exact_without_patch() {
assert_eq!(requires_python.is_exact_without_patch(), expected);
}
}

#[test]
fn is_tilde_exact_without_patch() {
let test_cases = [
("~=3.11", true),
("~=2.7", true),
("~=3.10, <3.11", false),
("~=3.10, <=3.11", false),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While unlikely, this sort of version should not throw a warning, correct?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I think not)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can leave it without a warning. It's an odd version specifier to write and likely not what the user intended, but it's not one that specifically matches this case.

("~=3.11.0", false),
("==3.12", false),
(">=3.12", false),
(">3.10", false),
("<4.0", false),
(">=3.10, <3.11", false),
("", false),
];
for (version, expected) in test_cases {
let version_specifiers = VersionSpecifiers::from_str(version).unwrap();
let requires_python = RequiresPython::from_specifiers(&version_specifiers);
assert_eq!(requires_python.is_tilde_exact_without_patch(), expected);
}
}
4 changes: 4 additions & 0 deletions crates/uv/src/commands/project/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,10 @@ async fn do_lock(
warn_user_once!("The workspace `requires-python` value (`{requires_python}`) does not contain a lower bound. Add a lower bound to indicate the minimum compatible Python version (e.g., `{default}`).");
} else if requires_python.is_exact_without_patch() {
warn_user_once!("The workspace `requires-python` value (`{requires_python}`) contains an exact match without a patch version. When omitted, the patch version is implicitly `0` (e.g., `{requires_python}.0`). Did you mean `{requires_python}.*`?");
} else if requires_python.is_tilde_exact_without_patch() {
let py_ver = interpreter.python_version();
let major_ver = interpreter.python_major();
warn_user_once!("The workspace `requires-python` value (`{requires_python}`) contains a compatible release match without a patch version. This will be interpreted as `>={py_ver}, =={major_ver}.*`. Did you mean `{requires_python}.0` to freeze the minor version?");
}
requires_python
} else {
Expand Down
65 changes: 65 additions & 0 deletions crates/uv/tests/it/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3754,6 +3754,71 @@ fn lock_requires_python_exact() -> Result<()> {
Ok(())
}

/// Lock a requirement from PyPI with an ~= Python bound.
#[test]
fn lock_requires_python_compatible_specifier() -> Result<()> {
let context = TestContext::new("3.12");

let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "warehouse"
version = "1.0.0"
requires-python = "~=3.12"
dependencies = ["lxml"]
"#,
)?;

uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 2 packages in [TIME]
"###);

pyproject_toml.write_str(
r#"
[project]
name = "warehouse"
version = "1.0.0"
requires-python = "~=3.12, <3.13"
dependencies = ["lxml"]
"#,
)?;

uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 2 packages in [TIME]
"###);

pyproject_toml.write_str(
r#"
[project]
name = "warehouse"
version = "1.0.0"
requires-python = "~=3.12.0"
dependencies = ["lxml"]
"#,
)?;

uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 2 packages in [TIME]
"###);
Ok(())
}

/// Fork, even with a single dependency, if the minimum Python version is increased.
#[test]
fn lock_requires_python_fork() -> Result<()> {
Expand Down
Loading