use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use super::shared::registry_self_dependency;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StaleUvLockFinding {
pub(crate) lock: PathBuf,
pub(crate) declared_in: PathBuf,
pub(crate) dependency: String,
pub(crate) requirement: String,
pub(crate) locked_requirement: String,
}
#[cfg(test)]
pub(crate) fn check_generated_uv_lock_freshness(generated_paths: &HashSet<PathBuf>) -> Option<anyhow::Error> {
check_generated_uv_lock_freshness_tolerating_pending_publish(generated_paths, None)
}
fn collect_generated_uv_lock_findings(generated_paths: &HashSet<PathBuf>) -> Vec<StaleUvLockFinding> {
let mut directories = BTreeSet::new();
for path in generated_paths {
if path.file_name().and_then(|name| name.to_str()) != Some("pyproject.toml") {
continue;
}
if let Some(dir) = path.parent() {
directories.insert(dir.to_path_buf());
}
}
let mut findings = Vec::new();
for dir in &directories {
findings.extend(stale_uv_lock_findings(dir));
}
tracing::debug!(
manifest_dirs = directories.len(),
findings = findings.len(),
"checked generated pyproject.toml files against their committed uv.lock"
);
findings
}
pub(crate) fn check_generated_uv_lock_freshness_tolerating_pending_publish(
generated_paths: &HashSet<PathBuf>,
resolved_cfg: Option<&crate::core::config::ResolvedCrateConfig>,
) -> Option<anyhow::Error> {
let findings = collect_generated_uv_lock_findings(generated_paths);
if findings.is_empty() {
return None;
}
let Some(self_dependency) = resolved_cfg.and_then(|cfg| {
registry_self_dependency(
cfg,
"python",
|package| package.name.clone(),
crate::e2e::codegen::python::config::normalize_python_version,
)
}) else {
return Some(anyhow::anyhow!(stale_uv_lock_message(&findings)));
};
let (pending, real): (Vec<_>, Vec<_>) = findings.into_iter().partition(|finding| {
finding.dependency == self_dependency.name && finding.requirement == self_dependency.requirement
});
if !pending.is_empty() {
tracing::warn!(
"{} committed uv.lock pin(s) below require this crate's own version, which is not on the \
registry yet -- expected after a version bump; resolves once the release publishes:\n{}",
pending.len(),
stale_uv_lock_message(&pending)
);
}
if real.is_empty() {
None
} else {
Some(anyhow::anyhow!(stale_uv_lock_message(&real)))
}
}
pub(crate) fn stale_uv_lock_findings(pyproject_dir: &Path) -> Vec<StaleUvLockFinding> {
let manifest_path = pyproject_dir.join("pyproject.toml");
let lock_path = pyproject_dir.join("uv.lock");
if !manifest_path.is_file() {
return Vec::new();
}
let Ok(manifest_text) = std::fs::read_to_string(&manifest_path) else {
return Vec::new();
};
let Ok(manifest_toml) = toml::from_str::<toml::Value>(&manifest_text) else {
return Vec::new();
};
let Ok(lock_text) = std::fs::read_to_string(&lock_path) else {
return Vec::new();
};
let Ok(lock_toml) = toml::from_str::<toml::Value>(&lock_text) else {
return Vec::new();
};
let Some(project) = manifest_toml.get("project") else {
return Vec::new();
};
let Some(project_name) = project.get("name").and_then(toml::Value::as_str) else {
return Vec::new();
};
let Some(dependencies) = project.get("dependencies").and_then(toml::Value::as_array) else {
return Vec::new();
};
let locked = locked_uv_requirements(&lock_toml, project_name);
if locked.is_empty() {
return Vec::new();
}
let overridden = uv_source_override_names(&manifest_toml);
let mut findings = Vec::new();
for entry in dependencies {
let Some(raw) = entry.as_str() else { continue };
let Some((name, requirement)) = parse_pep508_requirement(raw) else {
continue;
};
let normalized = normalize_pep503_name(&name);
if overridden.contains(&normalized) {
continue;
}
let Some(locked_requirement) = locked.get(&normalized) else {
continue;
};
if locked_requirement.trim() == requirement.trim() {
continue;
}
findings.push(StaleUvLockFinding {
lock: lock_path.clone(),
declared_in: manifest_path.clone(),
dependency: name,
requirement,
locked_requirement: locked_requirement.clone(),
});
}
findings.sort_by(|left, right| left.dependency.cmp(&right.dependency));
findings
}
fn locked_uv_requirements(lock: &toml::Value, project_name: &str) -> BTreeMap<String, String> {
let normalized_project = normalize_pep503_name(project_name);
let root_requires_dist = lock
.get("package")
.and_then(toml::Value::as_array)
.and_then(|packages| {
packages.iter().find(|package| {
package
.get("name")
.and_then(toml::Value::as_str)
.is_some_and(|name| normalize_pep503_name(name) == normalized_project)
})
})
.and_then(|package| package.get("metadata"))
.and_then(|metadata| metadata.get("requires-dist"))
.and_then(toml::Value::as_array);
if let Some(entries) = root_requires_dist {
let map = requires_dist_map(entries);
if !map.is_empty() {
return map;
}
}
lock.get("manifest")
.and_then(|manifest| manifest.get("requirements"))
.and_then(toml::Value::as_array)
.map(|entries| requires_dist_map(entries))
.unwrap_or_default()
}
fn requires_dist_map(entries: &[toml::Value]) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
for entry in entries {
let Some(table) = entry.as_table() else { continue };
if table.contains_key("marker") || table.contains_key("extra") {
continue;
}
let Some(name) = table.get("name").and_then(toml::Value::as_str) else {
continue;
};
let specifier = table.get("specifier").and_then(toml::Value::as_str).unwrap_or("");
map.insert(normalize_pep503_name(name), specifier.to_string());
}
map
}
fn uv_source_override_names(manifest_toml: &toml::Value) -> HashSet<String> {
manifest_toml
.get("tool")
.and_then(|tool| tool.get("uv"))
.and_then(|uv| uv.get("sources"))
.and_then(toml::Value::as_table)
.map(|table| table.keys().map(|name| normalize_pep503_name(name)).collect())
.unwrap_or_default()
}
fn parse_pep508_requirement(raw: &str) -> Option<(String, String)> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.contains(';') || trimmed.contains('@') || trimmed.contains('[') {
return None;
}
let name_len = trimmed
.find(|character: char| !(character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')))
.unwrap_or(trimmed.len());
if name_len == 0 {
return None;
}
let (name, specifier) = trimmed.split_at(name_len);
Some((name.to_string(), specifier.trim().to_string()))
}
fn normalize_pep503_name(name: &str) -> String {
let mut normalized = String::with_capacity(name.len());
let mut previous_was_separator = false;
for character in name.chars() {
if matches!(character, '-' | '_' | '.') {
if !previous_was_separator {
normalized.push('-');
}
previous_was_separator = true;
} else {
normalized.push(character.to_ascii_lowercase());
previous_was_separator = false;
}
}
normalized
}
fn stale_uv_lock_message(findings: &[StaleUvLockFinding]) -> String {
let mut message = format!(
"{} committed uv.lock specifier(s) disagree with a pyproject.toml alef generated; `uv sync \
--locked` (and frozen-lockfile CI jobs) will fail with \"The lockfile at `uv.lock` needs to be \
updated\":",
findings.len()
);
for finding in findings {
message.push_str(&format!(
"\n - {}: `{}` is required as `{}` by {}, but the lock records `{}`. Fix with: uv lock \
--project {}",
finding.lock.display(),
finding.dependency,
finding.requirement,
finding.declared_in.display(),
finding.locked_requirement,
finding.lock.parent().unwrap_or(Path::new(".")).display(),
));
}
message.push_str(
"\nA pin held back on purpose belongs in pyproject.toml -- a lockfile cannot record an exception \
to its own resolution.",
);
message
}
#[cfg(test)]
#[path = "uv_tests.rs"]
mod tests;