mirror of https://github.com/astral-sh/ruff
[red-knot] Fallback to `requires-python` if no `python-version` is specified (#16028)
## Summary Add support for the `project.requires-python` field in `pyproject.toml` files. Fall back to the resolved lower bound of `project.requires-python` if the `environment.python-version` field is `None` (or more accurately, initialize `environment.python-version with `requires-python`'s lower bound if left unspecified). ## UX design There are two options on how we can handle the fallback to `requires-python`'s lower bound: 1. Store the resolved lower bound in `environment.python-version` if that field is `None` (Implemented in this PR) 2. Store the `requires-python` constraint separately. There's no observed difference unless a user-level configuration (or any other inherited configuration is used). Let's discuss it on the given example **User configuration** ```toml [environment] python-version = "3.10" ``` **Project configuration (`pyproject.toml`)** ```toml [project] name = "test" requires-python = ">= 3.12" [tool.knot] # No environment table ``` The resolved version for 1. is 3.12 because the `requires-python` constraint precedence takes precedence over the `python-version` in the user configuration. 2. resolves to 3.10 because all `python-version` constraints take precedence before falling back to `requires-python`. Ruff implements 1. It's also the easier to implement and it does seem intuitive to me that the more local `requires-python` constraint takes precedence. ## Test plan Added CLI and unit tests.
This commit is contained in:
parent
ae1b381c06
commit
03f08283ad
|
|
@ -24,7 +24,7 @@ anyhow = { workspace = true }
|
||||||
crossbeam = { workspace = true }
|
crossbeam = { workspace = true }
|
||||||
glob = { workspace = true }
|
glob = { workspace = true }
|
||||||
notify = { workspace = true }
|
notify = { workspace = true }
|
||||||
pep440_rs = { workspace = true }
|
pep440_rs = { workspace = true, features = ["version-ranges"] }
|
||||||
rayon = { workspace = true }
|
rayon = { workspace = true }
|
||||||
rustc-hash = { workspace = true }
|
rustc-hash = { workspace = true }
|
||||||
salsa = { workspace = true }
|
salsa = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::combine::Combine;
|
use crate::combine::Combine;
|
||||||
use crate::metadata::pyproject::{Project, PyProject, PyProjectError};
|
use crate::metadata::pyproject::{Project, PyProject, PyProjectError, ResolveRequiresPythonError};
|
||||||
use crate::metadata::value::ValueSource;
|
use crate::metadata::value::ValueSource;
|
||||||
use options::KnotTomlError;
|
use options::KnotTomlError;
|
||||||
use options::Options;
|
use options::Options;
|
||||||
|
|
@ -49,7 +49,10 @@ impl ProjectMetadata {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads a project from a `pyproject.toml` file.
|
/// Loads a project from a `pyproject.toml` file.
|
||||||
pub(crate) fn from_pyproject(pyproject: PyProject, root: SystemPathBuf) -> Self {
|
pub(crate) fn from_pyproject(
|
||||||
|
pyproject: PyProject,
|
||||||
|
root: SystemPathBuf,
|
||||||
|
) -> Result<Self, ResolveRequiresPythonError> {
|
||||||
Self::from_options(
|
Self::from_options(
|
||||||
pyproject
|
pyproject
|
||||||
.tool
|
.tool
|
||||||
|
|
@ -62,22 +65,37 @@ impl ProjectMetadata {
|
||||||
|
|
||||||
/// Loads a project from a set of options with an optional pyproject-project table.
|
/// Loads a project from a set of options with an optional pyproject-project table.
|
||||||
pub(crate) fn from_options(
|
pub(crate) fn from_options(
|
||||||
options: Options,
|
mut options: Options,
|
||||||
root: SystemPathBuf,
|
root: SystemPathBuf,
|
||||||
project: Option<&Project>,
|
project: Option<&Project>,
|
||||||
) -> Self {
|
) -> Result<Self, ResolveRequiresPythonError> {
|
||||||
let name = project
|
let name = project
|
||||||
.and_then(|project| project.name.as_ref())
|
.and_then(|project| project.name.as_deref())
|
||||||
.map(|name| Name::new(&***name))
|
.map(|name| Name::new(&**name))
|
||||||
.unwrap_or_else(|| Name::new(root.file_name().unwrap_or("root")));
|
.unwrap_or_else(|| Name::new(root.file_name().unwrap_or("root")));
|
||||||
|
|
||||||
// TODO(https://github.com/astral-sh/ruff/issues/15491): Respect requires-python
|
// If the `options` don't specify a python version but the `project.requires-python` field is set,
|
||||||
Self {
|
// use that as a lower bound instead.
|
||||||
|
if let Some(project) = project {
|
||||||
|
if !options
|
||||||
|
.environment
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|env| env.python_version.is_some())
|
||||||
|
{
|
||||||
|
if let Some(requires_python) = project.resolve_requires_python_lower_bound()? {
|
||||||
|
let mut environment = options.environment.unwrap_or_default();
|
||||||
|
environment.python_version = Some(requires_python);
|
||||||
|
options.environment = Some(environment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
name,
|
name,
|
||||||
root,
|
root,
|
||||||
options,
|
options,
|
||||||
extra_configuration_paths: Vec::new(),
|
extra_configuration_paths: Vec::new(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discovers the closest project at `path` and returns its metadata.
|
/// Discovers the closest project at `path` and returns its metadata.
|
||||||
|
|
@ -145,19 +163,34 @@ impl ProjectMetadata {
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!("Found project at '{}'", project_root);
|
tracing::debug!("Found project at '{}'", project_root);
|
||||||
return Ok(ProjectMetadata::from_options(
|
|
||||||
|
let metadata = ProjectMetadata::from_options(
|
||||||
options,
|
options,
|
||||||
project_root.to_path_buf(),
|
project_root.to_path_buf(),
|
||||||
pyproject
|
pyproject
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|pyproject| pyproject.project.as_ref()),
|
.and_then(|pyproject| pyproject.project.as_ref()),
|
||||||
));
|
)
|
||||||
|
.map_err(|err| {
|
||||||
|
ProjectDiscoveryError::InvalidRequiresPythonConstraint {
|
||||||
|
source: err,
|
||||||
|
path: pyproject_path,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
return Ok(metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(pyproject) = pyproject {
|
if let Some(pyproject) = pyproject {
|
||||||
let has_knot_section = pyproject.knot().is_some();
|
let has_knot_section = pyproject.knot().is_some();
|
||||||
let metadata =
|
let metadata =
|
||||||
ProjectMetadata::from_pyproject(pyproject, project_root.to_path_buf());
|
ProjectMetadata::from_pyproject(pyproject, project_root.to_path_buf())
|
||||||
|
.map_err(
|
||||||
|
|err| ProjectDiscoveryError::InvalidRequiresPythonConstraint {
|
||||||
|
source: err,
|
||||||
|
path: pyproject_path,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
if has_knot_section {
|
if has_knot_section {
|
||||||
tracing::debug!("Found project at '{}'", project_root);
|
tracing::debug!("Found project at '{}'", project_root);
|
||||||
|
|
@ -262,15 +295,21 @@ pub enum ProjectDiscoveryError {
|
||||||
source: Box<KnotTomlError>,
|
source: Box<KnotTomlError>,
|
||||||
path: SystemPathBuf,
|
path: SystemPathBuf,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[error("Invalid `requires-python` version specifier (`{path}`): {source}")]
|
||||||
|
InvalidRequiresPythonConstraint {
|
||||||
|
source: ResolveRequiresPythonError,
|
||||||
|
path: SystemPathBuf,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
//! Integration tests for project discovery
|
//! Integration tests for project discovery
|
||||||
|
|
||||||
use crate::snapshot_project;
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
use insta::assert_ron_snapshot;
|
use insta::assert_ron_snapshot;
|
||||||
|
use red_knot_python_semantic::PythonVersion;
|
||||||
use ruff_db::system::{SystemPathBuf, TestSystem};
|
use ruff_db::system::{SystemPathBuf, TestSystem};
|
||||||
|
|
||||||
use crate::{ProjectDiscoveryError, ProjectMetadata};
|
use crate::{ProjectDiscoveryError, ProjectMetadata};
|
||||||
|
|
@ -290,7 +329,15 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(project.root(), &*root);
|
assert_eq!(project.root(), &*root);
|
||||||
|
|
||||||
snapshot_project!(project);
|
with_escaped_paths(|| {
|
||||||
|
assert_ron_snapshot!(&project, @r#"
|
||||||
|
ProjectMetadata(
|
||||||
|
name: Name("app"),
|
||||||
|
root: "/app",
|
||||||
|
options: Options(),
|
||||||
|
)
|
||||||
|
"#);
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +366,16 @@ mod tests {
|
||||||
ProjectMetadata::discover(&root, &system).context("Failed to discover project")?;
|
ProjectMetadata::discover(&root, &system).context("Failed to discover project")?;
|
||||||
|
|
||||||
assert_eq!(project.root(), &*root);
|
assert_eq!(project.root(), &*root);
|
||||||
snapshot_project!(project);
|
|
||||||
|
with_escaped_paths(|| {
|
||||||
|
assert_ron_snapshot!(&project, @r#"
|
||||||
|
ProjectMetadata(
|
||||||
|
name: Name("backend"),
|
||||||
|
root: "/app",
|
||||||
|
options: Options(),
|
||||||
|
)
|
||||||
|
"#);
|
||||||
|
});
|
||||||
|
|
||||||
// Discovering the same package from a subdirectory should give the same result
|
// Discovering the same package from a subdirectory should give the same result
|
||||||
let from_src = ProjectMetadata::discover(&root.join("db"), &system)
|
let from_src = ProjectMetadata::discover(&root.join("db"), &system)
|
||||||
|
|
@ -402,7 +458,19 @@ expected `.`, `]`
|
||||||
|
|
||||||
let sub_project = ProjectMetadata::discover(&root.join("packages/a"), &system)?;
|
let sub_project = ProjectMetadata::discover(&root.join("packages/a"), &system)?;
|
||||||
|
|
||||||
snapshot_project!(sub_project);
|
with_escaped_paths(|| {
|
||||||
|
assert_ron_snapshot!(sub_project, @r#"
|
||||||
|
ProjectMetadata(
|
||||||
|
name: Name("nested-project"),
|
||||||
|
root: "/app/packages/a",
|
||||||
|
options: Options(
|
||||||
|
src: Some(SrcOptions(
|
||||||
|
root: Some("src"),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"#);
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -440,7 +508,19 @@ expected `.`, `]`
|
||||||
|
|
||||||
let root = ProjectMetadata::discover(&root, &system)?;
|
let root = ProjectMetadata::discover(&root, &system)?;
|
||||||
|
|
||||||
snapshot_project!(root);
|
with_escaped_paths(|| {
|
||||||
|
assert_ron_snapshot!(root, @r#"
|
||||||
|
ProjectMetadata(
|
||||||
|
name: Name("project-root"),
|
||||||
|
root: "/app",
|
||||||
|
options: Options(
|
||||||
|
src: Some(SrcOptions(
|
||||||
|
root: Some("src"),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"#);
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -472,7 +552,15 @@ expected `.`, `]`
|
||||||
|
|
||||||
let sub_project = ProjectMetadata::discover(&root.join("packages/a"), &system)?;
|
let sub_project = ProjectMetadata::discover(&root.join("packages/a"), &system)?;
|
||||||
|
|
||||||
snapshot_project!(sub_project);
|
with_escaped_paths(|| {
|
||||||
|
assert_ron_snapshot!(sub_project, @r#"
|
||||||
|
ProjectMetadata(
|
||||||
|
name: Name("nested-project"),
|
||||||
|
root: "/app/packages/a",
|
||||||
|
options: Options(),
|
||||||
|
)
|
||||||
|
"#);
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -507,7 +595,19 @@ expected `.`, `]`
|
||||||
|
|
||||||
let root = ProjectMetadata::discover(&root.join("packages/a"), &system)?;
|
let root = ProjectMetadata::discover(&root.join("packages/a"), &system)?;
|
||||||
|
|
||||||
snapshot_project!(root);
|
with_escaped_paths(|| {
|
||||||
|
assert_ron_snapshot!(root, @r#"
|
||||||
|
ProjectMetadata(
|
||||||
|
name: Name("project-root"),
|
||||||
|
root: "/app",
|
||||||
|
options: Options(
|
||||||
|
environment: Some(EnvironmentOptions(
|
||||||
|
r#python-version: Some("3.10"),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"#);
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -527,27 +627,304 @@ expected `.`, `]`
|
||||||
(
|
(
|
||||||
root.join("pyproject.toml"),
|
root.join("pyproject.toml"),
|
||||||
r#"
|
r#"
|
||||||
[project]
|
[project]
|
||||||
name = "super-app"
|
name = "super-app"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
[tool.knot.src]
|
[tool.knot.src]
|
||||||
root = "this_option_is_ignored"
|
root = "this_option_is_ignored"
|
||||||
"#,
|
"#,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
root.join("knot.toml"),
|
root.join("knot.toml"),
|
||||||
r#"
|
r#"
|
||||||
[src]
|
[src]
|
||||||
root = "src"
|
root = "src"
|
||||||
"#,
|
"#,
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
.context("Failed to write files")?;
|
.context("Failed to write files")?;
|
||||||
|
|
||||||
let root = ProjectMetadata::discover(&root, &system)?;
|
let root = ProjectMetadata::discover(&root, &system)?;
|
||||||
|
|
||||||
snapshot_project!(root);
|
with_escaped_paths(|| {
|
||||||
|
assert_ron_snapshot!(root, @r#"
|
||||||
|
ProjectMetadata(
|
||||||
|
name: Name("super-app"),
|
||||||
|
root: "/app",
|
||||||
|
options: Options(
|
||||||
|
environment: Some(EnvironmentOptions(
|
||||||
|
r#python-version: Some("3.12"),
|
||||||
|
)),
|
||||||
|
src: Some(SrcOptions(
|
||||||
|
root: Some("src"),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"#);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn requires_python_major_minor() -> anyhow::Result<()> {
|
||||||
|
let system = TestSystem::default();
|
||||||
|
let root = SystemPathBuf::from("/app");
|
||||||
|
|
||||||
|
system
|
||||||
|
.memory_file_system()
|
||||||
|
.write_file(
|
||||||
|
root.join("pyproject.toml"),
|
||||||
|
r#"
|
||||||
|
[project]
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.context("Failed to write file")?;
|
||||||
|
|
||||||
|
let root = ProjectMetadata::discover(&root, &system)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
root.options
|
||||||
|
.environment
|
||||||
|
.unwrap_or_default()
|
||||||
|
.python_version
|
||||||
|
.as_deref(),
|
||||||
|
Some(&PythonVersion::PY312)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requires_python_major_only() -> anyhow::Result<()> {
|
||||||
|
let system = TestSystem::default();
|
||||||
|
let root = SystemPathBuf::from("/app");
|
||||||
|
|
||||||
|
system
|
||||||
|
.memory_file_system()
|
||||||
|
.write_file(
|
||||||
|
root.join("pyproject.toml"),
|
||||||
|
r#"
|
||||||
|
[project]
|
||||||
|
requires-python = ">=3"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.context("Failed to write file")?;
|
||||||
|
|
||||||
|
let root = ProjectMetadata::discover(&root, &system)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
root.options
|
||||||
|
.environment
|
||||||
|
.unwrap_or_default()
|
||||||
|
.python_version
|
||||||
|
.as_deref(),
|
||||||
|
Some(&PythonVersion::from((3, 0)))
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `requires-python` constraint with major, minor and patch can be simplified
|
||||||
|
/// to major and minor (e.g. 3.12.1 -> 3.12).
|
||||||
|
#[test]
|
||||||
|
fn requires_python_major_minor_patch() -> anyhow::Result<()> {
|
||||||
|
let system = TestSystem::default();
|
||||||
|
let root = SystemPathBuf::from("/app");
|
||||||
|
|
||||||
|
system
|
||||||
|
.memory_file_system()
|
||||||
|
.write_file(
|
||||||
|
root.join("pyproject.toml"),
|
||||||
|
r#"
|
||||||
|
[project]
|
||||||
|
requires-python = ">=3.12.8"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.context("Failed to write file")?;
|
||||||
|
|
||||||
|
let root = ProjectMetadata::discover(&root, &system)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
root.options
|
||||||
|
.environment
|
||||||
|
.unwrap_or_default()
|
||||||
|
.python_version
|
||||||
|
.as_deref(),
|
||||||
|
Some(&PythonVersion::PY312)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requires_python_beta_version() -> anyhow::Result<()> {
|
||||||
|
let system = TestSystem::default();
|
||||||
|
let root = SystemPathBuf::from("/app");
|
||||||
|
|
||||||
|
system
|
||||||
|
.memory_file_system()
|
||||||
|
.write_file(
|
||||||
|
root.join("pyproject.toml"),
|
||||||
|
r#"
|
||||||
|
[project]
|
||||||
|
requires-python = ">= 3.13.0b0"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.context("Failed to write file")?;
|
||||||
|
|
||||||
|
let root = ProjectMetadata::discover(&root, &system)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
root.options
|
||||||
|
.environment
|
||||||
|
.unwrap_or_default()
|
||||||
|
.python_version
|
||||||
|
.as_deref(),
|
||||||
|
Some(&PythonVersion::PY313)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requires_python_greater_than_major_minor() -> anyhow::Result<()> {
|
||||||
|
let system = TestSystem::default();
|
||||||
|
let root = SystemPathBuf::from("/app");
|
||||||
|
|
||||||
|
system
|
||||||
|
.memory_file_system()
|
||||||
|
.write_file(
|
||||||
|
root.join("pyproject.toml"),
|
||||||
|
r#"
|
||||||
|
[project]
|
||||||
|
# This is somewhat nonsensical because 3.12.1 > 3.12 is true.
|
||||||
|
# That's why simplifying the constraint to >= 3.12 is correct
|
||||||
|
requires-python = ">3.12"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.context("Failed to write file")?;
|
||||||
|
|
||||||
|
let root = ProjectMetadata::discover(&root, &system)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
root.options
|
||||||
|
.environment
|
||||||
|
.unwrap_or_default()
|
||||||
|
.python_version
|
||||||
|
.as_deref(),
|
||||||
|
Some(&PythonVersion::PY312)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `python-version` takes precedence if both `requires-python` and `python-version` are configured.
|
||||||
|
#[test]
|
||||||
|
fn requires_python_and_python_version() -> anyhow::Result<()> {
|
||||||
|
let system = TestSystem::default();
|
||||||
|
let root = SystemPathBuf::from("/app");
|
||||||
|
|
||||||
|
system
|
||||||
|
.memory_file_system()
|
||||||
|
.write_file(
|
||||||
|
root.join("pyproject.toml"),
|
||||||
|
r#"
|
||||||
|
[project]
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[tool.knot.environment]
|
||||||
|
python-version = "3.10"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.context("Failed to write file")?;
|
||||||
|
|
||||||
|
let root = ProjectMetadata::discover(&root, &system)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
root.options
|
||||||
|
.environment
|
||||||
|
.unwrap_or_default()
|
||||||
|
.python_version
|
||||||
|
.as_deref(),
|
||||||
|
Some(&PythonVersion::PY310)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requires_python_less_than() -> anyhow::Result<()> {
|
||||||
|
let system = TestSystem::default();
|
||||||
|
let root = SystemPathBuf::from("/app");
|
||||||
|
|
||||||
|
system
|
||||||
|
.memory_file_system()
|
||||||
|
.write_file(
|
||||||
|
root.join("pyproject.toml"),
|
||||||
|
r#"
|
||||||
|
[project]
|
||||||
|
requires-python = "<3.12"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.context("Failed to write file")?;
|
||||||
|
|
||||||
|
let Err(error) = ProjectMetadata::discover(&root, &system) else {
|
||||||
|
return Err(anyhow!("Expected project discovery to fail because the `requires-python` doesn't specify a lower bound (it only specifies an upper bound)."));
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_error_eq(&error, "Invalid `requires-python` version specifier (`/app/pyproject.toml`): value `<3.12` does not contain a lower bound. Add a lower bound to indicate the minimum compatible Python version (e.g., `>=3.13`) or specify a version in `environment.python-version`.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requires_python_no_specifiers() -> anyhow::Result<()> {
|
||||||
|
let system = TestSystem::default();
|
||||||
|
let root = SystemPathBuf::from("/app");
|
||||||
|
|
||||||
|
system
|
||||||
|
.memory_file_system()
|
||||||
|
.write_file(
|
||||||
|
root.join("pyproject.toml"),
|
||||||
|
r#"
|
||||||
|
[project]
|
||||||
|
requires-python = ""
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.context("Failed to write file")?;
|
||||||
|
|
||||||
|
let Err(error) = ProjectMetadata::discover(&root, &system) else {
|
||||||
|
return Err(anyhow!("Expected project discovery to fail because the `requires-python` specifiers are empty and don't define a lower bound."));
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_error_eq(&error, "Invalid `requires-python` version specifier (`/app/pyproject.toml`): value `` does not contain a lower bound. Add a lower bound to indicate the minimum compatible Python version (e.g., `>=3.13`) or specify a version in `environment.python-version`.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requires_python_too_large_major_version() -> anyhow::Result<()> {
|
||||||
|
let system = TestSystem::default();
|
||||||
|
let root = SystemPathBuf::from("/app");
|
||||||
|
|
||||||
|
system
|
||||||
|
.memory_file_system()
|
||||||
|
.write_file(
|
||||||
|
root.join("pyproject.toml"),
|
||||||
|
r#"
|
||||||
|
[project]
|
||||||
|
requires-python = ">=999.0"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.context("Failed to write file")?;
|
||||||
|
|
||||||
|
let Err(error) = ProjectMetadata::discover(&root, &system) else {
|
||||||
|
return Err(anyhow!("Expected project discovery to fail because of the requires-python major version that is larger than 255."));
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_error_eq(&error, "Invalid `requires-python` version specifier (`/app/pyproject.toml`): The major version `999` is larger than the maximum supported value 255");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -557,15 +934,12 @@ expected `.`, `]`
|
||||||
assert_eq!(error.to_string().replace('\\', "/"), message);
|
assert_eq!(error.to_string().replace('\\', "/"), message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshots a project but with all paths using unix separators.
|
fn with_escaped_paths<R>(f: impl FnOnce() -> R) -> R {
|
||||||
#[macro_export]
|
let mut settings = insta::Settings::clone_current();
|
||||||
macro_rules! snapshot_project {
|
settings.add_dynamic_redaction(".root", |content, _path| {
|
||||||
($project:expr) => {{
|
content.as_str().unwrap().replace('\\', "/")
|
||||||
assert_ron_snapshot!($project,{
|
|
||||||
".root" => insta::dynamic_redaction(|content, _content_path| {
|
|
||||||
content.as_str().unwrap().replace("\\", "/")
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}};
|
|
||||||
}
|
settings.bind(f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
use pep440_rs::{Version, VersionSpecifiers};
|
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
|
||||||
use std::ops::Deref;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
use crate::metadata::options::Options;
|
use crate::metadata::options::Options;
|
||||||
use crate::metadata::value::{RangedValue, ValueSource, ValueSourceGuard};
|
use crate::metadata::value::{RangedValue, ValueSource, ValueSourceGuard};
|
||||||
|
use pep440_rs::{release_specifiers_to_ranges, Version, VersionSpecifiers};
|
||||||
|
use red_knot_python_semantic::PythonVersion;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
use std::collections::Bound;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
/// A `pyproject.toml` as specified in PEP 517.
|
/// A `pyproject.toml` as specified in PEP 517.
|
||||||
#[derive(Deserialize, Serialize, Debug, Default, Clone)]
|
#[derive(Deserialize, Serialize, Debug, Default, Clone)]
|
||||||
|
|
@ -55,6 +56,73 @@ pub struct Project {
|
||||||
pub requires_python: Option<RangedValue<VersionSpecifiers>>,
|
pub requires_python: Option<RangedValue<VersionSpecifiers>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Project {
|
||||||
|
pub(super) fn resolve_requires_python_lower_bound(
|
||||||
|
&self,
|
||||||
|
) -> Result<Option<RangedValue<PythonVersion>>, ResolveRequiresPythonError> {
|
||||||
|
let Some(requires_python) = self.requires_python.as_ref() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!("Resolving requires-python constraint: `{requires_python}`");
|
||||||
|
|
||||||
|
let ranges = release_specifiers_to_ranges((**requires_python).clone());
|
||||||
|
let Some((lower, _)) = ranges.bounding_range() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let version = match lower {
|
||||||
|
// Ex) `>=3.10.1` -> `>=3.10`
|
||||||
|
Bound::Included(version) => version,
|
||||||
|
|
||||||
|
// Ex) `>3.10.1` -> `>=3.10` or `>3.10` -> `>=3.10`
|
||||||
|
// The second example looks obscure at first but it is required because
|
||||||
|
// `3.10.1 > 3.10` is true but we only have two digits here. So including 3.10 is the
|
||||||
|
// right move. Overall, using `>` without a patch release is most likely bogus.
|
||||||
|
Bound::Excluded(version) => version,
|
||||||
|
|
||||||
|
// Ex) `<3.10` or ``
|
||||||
|
Bound::Unbounded => {
|
||||||
|
return Err(ResolveRequiresPythonError::NoLowerBound(
|
||||||
|
requires_python.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Take the major and minor version
|
||||||
|
let mut versions = version.release().iter().take(2);
|
||||||
|
|
||||||
|
let Some(major) = versions.next().copied() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let minor = versions.next().copied().unwrap_or_default();
|
||||||
|
|
||||||
|
tracing::debug!("Resolved requires-python constraint to: {major}.{minor}");
|
||||||
|
|
||||||
|
let major =
|
||||||
|
u8::try_from(major).map_err(|_| ResolveRequiresPythonError::TooLargeMajor(major))?;
|
||||||
|
let minor =
|
||||||
|
u8::try_from(minor).map_err(|_| ResolveRequiresPythonError::TooLargeMajor(minor))?;
|
||||||
|
|
||||||
|
Ok(Some(
|
||||||
|
requires_python
|
||||||
|
.clone()
|
||||||
|
.map_value(|_| PythonVersion::from((major, minor))),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ResolveRequiresPythonError {
|
||||||
|
#[error("The major version `{0}` is larger than the maximum supported value 255")]
|
||||||
|
TooLargeMajor(u64),
|
||||||
|
#[error("The minor version `{0}` is larger than the maximum supported value 255")]
|
||||||
|
TooLargeMinor(u64),
|
||||||
|
#[error("value `{0}` does not contain a lower bound. Add a lower bound to indicate the minimum compatible Python version (e.g., `>=3.13`) or specify a version in `environment.python-version`.")]
|
||||||
|
NoLowerBound(String),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
|
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub struct Tool {
|
pub struct Tool {
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,15 @@ impl<T> RangedValue<T> {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn map_value<R>(self, f: impl FnOnce(T) -> R) -> RangedValue<R> {
|
||||||
|
RangedValue {
|
||||||
|
value: f(self.value),
|
||||||
|
source: self.source,
|
||||||
|
range: self.range,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn into_inner(self) -> T {
|
pub fn into_inner(self) -> T {
|
||||||
self.value
|
self.value
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
---
|
|
||||||
source: crates/red_knot_project/src/metadata.rs
|
|
||||||
expression: root
|
|
||||||
---
|
|
||||||
ProjectMetadata(
|
|
||||||
name: Name("project-root"),
|
|
||||||
root: "/app",
|
|
||||||
options: Options(
|
|
||||||
src: Some(SrcOptions(
|
|
||||||
root: Some("src"),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
---
|
|
||||||
source: crates/red_knot_project/src/metadata.rs
|
|
||||||
expression: sub_project
|
|
||||||
---
|
|
||||||
ProjectMetadata(
|
|
||||||
name: Name("nested-project"),
|
|
||||||
root: "/app/packages/a",
|
|
||||||
options: Options(
|
|
||||||
src: Some(SrcOptions(
|
|
||||||
root: Some("src"),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
---
|
|
||||||
source: crates/red_knot_project/src/metadata.rs
|
|
||||||
expression: root
|
|
||||||
---
|
|
||||||
ProjectMetadata(
|
|
||||||
name: Name("project-root"),
|
|
||||||
root: "/app",
|
|
||||||
options: Options(
|
|
||||||
environment: Some(EnvironmentOptions(
|
|
||||||
r#python-version: Some("3.10"),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
---
|
|
||||||
source: crates/red_knot_project/src/metadata.rs
|
|
||||||
expression: sub_project
|
|
||||||
---
|
|
||||||
ProjectMetadata(
|
|
||||||
name: Name("nested-project"),
|
|
||||||
root: "/app/packages/a",
|
|
||||||
options: Options(),
|
|
||||||
)
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
---
|
|
||||||
source: crates/red_knot_project/src/metadata.rs
|
|
||||||
expression: root
|
|
||||||
---
|
|
||||||
ProjectMetadata(
|
|
||||||
name: Name("super-app"),
|
|
||||||
root: "/app",
|
|
||||||
options: Options(
|
|
||||||
src: Some(SrcOptions(
|
|
||||||
root: Some("src"),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
---
|
|
||||||
source: crates/red_knot_project/src/metadata.rs
|
|
||||||
expression: project
|
|
||||||
---
|
|
||||||
ProjectMetadata(
|
|
||||||
name: Name("backend"),
|
|
||||||
root: "/app",
|
|
||||||
options: Options(),
|
|
||||||
)
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
---
|
|
||||||
source: crates/red_knot_project/src/metadata.rs
|
|
||||||
expression: project
|
|
||||||
---
|
|
||||||
ProjectMetadata(
|
|
||||||
name: Name("app"),
|
|
||||||
root: "/app",
|
|
||||||
options: Options(),
|
|
||||||
)
|
|
||||||
Loading…
Reference in New Issue