use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use either::Either;
use thiserror::Error;
use uv_auth::CredentialsCache;
use uv_distribution_filename::DistExtension;
use uv_distribution_types::{
Index, IndexLocations, IndexMetadata, IndexName, Origin, Requirement, RequirementSource,
};
use uv_fs::{Simplified, normalize_absolute_path, normalize_path};
use uv_git_types::{GitLfs, GitReference, GitUrl, GitUrlParseError};
use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_pep440::VersionSpecifiers;
use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository};
use uv_pypi_types::{
ConflictItem, ParsedGitDirectoryUrl, ParsedGitPathUrl, ParsedUrl, ParsedUrlError,
VerbatimParsedUrl,
};
use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
use uv_workspace::Workspace;
use uv_workspace::pyproject::{PyProjectToml, Source, Sources};
use crate::metadata::GitWorkspaceMember;
#[derive(Debug, Clone)]
pub struct LoweredRequirement(Requirement);
#[derive(Debug, Clone, Copy)]
enum RequirementOrigin {
Project,
Workspace,
}
impl LoweredRequirement {
pub(crate) fn from_requirement<'data>(
requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
project_name: Option<&'data PackageName>,
project_dir: &'data Path,
project_sources: &'data BTreeMap<PackageName, Sources>,
project_indexes: &'data [Index],
extra: Option<&ExtraName>,
group: Option<&GroupName>,
locations: &'data IndexLocations,
workspace: &'data Workspace,
git_member: Option<&'data GitWorkspaceMember<'data>>,
editable: bool,
credentials_cache: &'data CredentialsCache,
) -> impl Iterator<Item = Result<Self, LoweringError>> + use<'data> + 'data {
let (sources, origin) = if let Some(source) = project_sources.get(&requirement.name) {
(Some(source), RequirementOrigin::Project)
} else if let Some(source) = workspace.sources().get(&requirement.name) {
(Some(source), RequirementOrigin::Workspace)
} else {
(None, RequirementOrigin::Project)
};
let sources = sources.map(|sources| {
sources
.iter()
.filter(|source| {
if let Some(target) = source.extra() {
if extra != Some(target) {
return false;
}
}
if let Some(target) = source.group() {
if group != Some(target) {
return false;
}
}
true
})
.cloned()
.collect::<Sources>()
});
if workspace.packages().contains_key(&requirement.name) {
if project_name.is_none_or(|project_name| *project_name != requirement.name) {
let Some(sources) = sources.as_ref() else {
return Either::Left(std::iter::once(Err(
LoweringError::MissingWorkspaceSource(requirement.name.clone()),
)));
};
for source in sources.iter() {
match source {
Source::Git { .. } => {
return Either::Left(std::iter::once(Err(
LoweringError::NonWorkspaceSource(
requirement.name.clone(),
SourceKind::Git,
),
)));
}
Source::Url { .. } => {
return Either::Left(std::iter::once(Err(
LoweringError::NonWorkspaceSource(
requirement.name.clone(),
SourceKind::Url,
),
)));
}
Source::Path { .. } => {
return Either::Left(std::iter::once(Err(
LoweringError::NonWorkspaceSource(
requirement.name.clone(),
SourceKind::Path,
),
)));
}
Source::Registry { .. } => {
return Either::Left(std::iter::once(Err(
LoweringError::NonWorkspaceSource(
requirement.name.clone(),
SourceKind::Registry,
),
)));
}
Source::Workspace { .. } => {
}
}
}
}
}
let Some(sources) = sources else {
return Either::Left(std::iter::once(Self::preserve_git_source(
requirement,
git_member,
)));
};
let remaining = {
let mut total = MarkerTree::FALSE;
for source in sources.iter() {
total.or(source.marker());
}
let mut remaining = total.negate();
remaining.and(requirement.marker);
Self(Requirement {
marker: remaining,
..Requirement::from(requirement.clone())
})
};
Either::Right(
sources
.into_iter()
.map(move |source| {
let (source, mut marker) = match source {
Source::Git {
git,
subdirectory,
path,
rev,
tag,
branch,
lfs,
marker,
..
} => {
let source = git_source(
&git,
subdirectory.map(Box::<Path>::from),
path.map(Box::<Path>::from).map(PathBuf::from),
rev,
tag,
branch,
lfs,
)?;
(source, marker)
}
Source::Url {
url,
subdirectory,
marker,
..
} => {
let source =
url_source(&requirement, url, subdirectory.map(Box::<Path>::from))?;
(source, marker)
}
Source::Path {
path,
editable,
package,
marker,
..
} => {
let source = path_source(
path,
git_member,
origin,
project_dir,
workspace.install_path(),
editable,
package,
)?;
(source, marker)
}
Source::Registry {
index,
marker,
extra,
group,
} => {
let Some(index) = locations
.indexes()
.filter(|index| matches!(index.origin, Some(Origin::Cli)))
.chain(project_indexes.iter())
.chain(workspace.indexes().iter())
.find(|Index { name, .. }| {
name.as_ref().is_some_and(|name| *name == index)
})
else {
let hint = missing_index_hint(locations, &index);
return Err(LoweringError::MissingIndex {
package: requirement.name.clone(),
index,
hint,
});
};
if let Some(credentials) = index.credentials() {
credentials_cache.store_credentials(index.raw_url(), credentials);
}
let index = IndexMetadata {
url: index.url.clone(),
format: index.format,
};
let conflict = project_name.and_then(|project_name| {
if let Some(extra) = extra {
Some(ConflictItem::from((project_name.clone(), extra)))
} else {
group.map(|group| {
ConflictItem::from((project_name.clone(), group))
})
}
});
let source = registry_source(&requirement, index, conflict);
(source, marker)
}
Source::Workspace {
workspace: is_workspace,
marker,
..
} => {
if !is_workspace {
return Err(LoweringError::WorkspaceFalse);
}
let member = workspace
.packages()
.get(&requirement.name)
.ok_or_else(|| {
LoweringError::UndeclaredWorkspacePackage(
requirement.name.clone(),
)
})?
.clone();
let url = VerbatimUrl::from_absolute_path(member.root())?;
let install_path = url.to_file_path().map_err(|()| {
LoweringError::RelativeTo(io::Error::other(
"Invalid path in file URL",
))
})?;
let source = if let Some(git_member) = &git_member {
let subdirectory =
uv_fs::relative_to(member.root(), git_member.fetch_root)
.expect("Workspace member must be relative");
let subdirectory = normalize_path(subdirectory);
RequirementSource::GitDirectory {
git: git_member.git_source.git.clone(),
subdirectory: if subdirectory == PathBuf::new() {
None
} else {
Some(subdirectory.into_owned().into_boxed_path())
},
url,
}
} else {
let value = workspace.required_members().get(&requirement.name);
let is_required_member = value.is_some();
let editability = value.copied().flatten();
if member.pyproject_toml().is_package(!is_required_member) {
RequirementSource::Directory {
install_path: install_path.into_boxed_path(),
url,
editable: Some(editability.unwrap_or(editable)),
r#virtual: Some(false),
}
} else {
RequirementSource::Directory {
install_path: install_path.into_boxed_path(),
url,
editable: Some(false),
r#virtual: Some(true),
}
}
};
(source, marker)
}
};
marker.and(requirement.marker);
Ok(Self(Requirement {
name: requirement.name.clone(),
extras: requirement.extras.clone(),
groups: Box::new([]),
marker,
source,
origin: requirement.origin.clone(),
}))
})
.chain(std::iter::once(Ok(remaining)))
.filter(|requirement| match requirement {
Ok(requirement) => !requirement.0.marker.is_false(),
Err(_) => true,
}),
)
}
pub fn from_non_workspace_requirement<'data>(
requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
dir: &'data Path,
sources: &'data BTreeMap<PackageName, Sources>,
indexes: &'data [Index],
locations: &'data IndexLocations,
credentials_cache: &'data CredentialsCache,
) -> impl Iterator<Item = Result<Self, LoweringError>> + 'data {
let source = sources.get(&requirement.name).cloned();
let Some(source) = source else {
return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement)))));
};
let source = source
.iter()
.filter(|source| {
source.extra().is_none_or(|target| {
requirement
.marker
.top_level_extra_name()
.is_some_and(|extra| &*extra == target)
})
})
.cloned()
.collect::<Sources>();
let remaining = {
let mut total = MarkerTree::FALSE;
for source in source.iter() {
total.or(source.marker());
}
let mut remaining = total.negate();
remaining.and(requirement.marker);
Self(Requirement {
marker: remaining,
..Requirement::from(requirement.clone())
})
};
Either::Right(
source
.into_iter()
.map(move |source| {
let (source, mut marker) = match source {
Source::Git {
git,
subdirectory,
path,
rev,
tag,
branch,
lfs,
marker,
..
} => {
let source = git_source(
&git,
subdirectory.map(Box::<Path>::from),
path.map(Box::<Path>::from).map(PathBuf::from),
rev,
tag,
branch,
lfs,
)?;
(source, marker)
}
Source::Url {
url,
subdirectory,
marker,
..
} => {
let source =
url_source(&requirement, url, subdirectory.map(Box::<Path>::from))?;
(source, marker)
}
Source::Path {
path,
editable,
package,
marker,
..
} => {
let source = path_source(
path,
None,
RequirementOrigin::Project,
dir,
dir,
editable,
package,
)?;
(source, marker)
}
Source::Registry { index, marker, .. } => {
let Some(index) = locations
.indexes()
.filter(|index| matches!(index.origin, Some(Origin::Cli)))
.chain(indexes.iter())
.find(|Index { name, .. }| {
name.as_ref().is_some_and(|name| *name == index)
})
else {
let hint = missing_index_hint(locations, &index);
return Err(LoweringError::MissingIndex {
package: requirement.name.clone(),
index,
hint,
});
};
if let Some(credentials) = index.credentials() {
credentials_cache.store_credentials(index.raw_url(), credentials);
}
let index = IndexMetadata {
url: index.url.clone(),
format: index.format,
};
let conflict = None;
let source = registry_source(&requirement, index, conflict);
(source, marker)
}
Source::Workspace { .. } => {
return Err(LoweringError::WorkspaceMember);
}
};
marker.and(requirement.marker);
Ok(Self(Requirement {
name: requirement.name.clone(),
extras: requirement.extras.clone(),
groups: Box::new([]),
marker,
source,
origin: requirement.origin.clone(),
}))
})
.chain(std::iter::once(Ok(remaining)))
.filter(|requirement| match requirement {
Ok(requirement) => !requirement.0.marker.is_false(),
Err(_) => true,
}),
)
}
pub(crate) fn preserve_git_source(
requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
git_member: Option<&GitWorkspaceMember>,
) -> Result<Self, LoweringError> {
let Some(git_member) = git_member else {
return Ok(Self(Requirement::from(requirement)));
};
let Some(VersionOrUrl::Url(url)) = &requirement.version_or_url else {
return Ok(Self(Requirement::from(requirement)));
};
let (install_path, is_archive) = match &url.parsed_url {
ParsedUrl::Directory(directory) => (directory.install_path.as_ref(), false),
ParsedUrl::Path(path) => (path.install_path.as_ref(), true),
_ => return Ok(Self(Requirement::from(requirement))),
};
let install_path = git_path(install_path)?;
let fetch_root = git_path(git_member.fetch_root)?;
if !install_path.starts_with(&fetch_root) {
return Ok(Self(Requirement::from(requirement)));
}
Ok(Self(Requirement {
name: requirement.name,
groups: Box::new([]),
extras: requirement.extras,
marker: requirement.marker,
source: if is_archive {
git_archive_source_from_path(&install_path, git_member)?
} else {
git_directory_source_from_path(&install_path, git_member)?
},
origin: requirement.origin,
}))
}
pub fn into_inner(self) -> Requirement {
self.0
}
}
#[derive(Debug, Error)]
pub enum LoweringError {
#[error(
"`{0}` is included as a workspace member, but is missing an entry in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`)"
)]
MissingWorkspaceSource(PackageName),
#[error(
"`{0}` is included as a workspace member, but references a {1} in `tool.uv.sources`. Workspace members must be declared as workspace sources (e.g., `{0} = {{ workspace = true }}`)."
)]
NonWorkspaceSource(PackageName, SourceKind),
#[error(
"`{0}` references a workspace in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`), but is not a workspace member"
)]
UndeclaredWorkspacePackage(PackageName),
#[error("Can only specify one of: `rev`, `tag`, or `branch`")]
MoreThanOneGitRef,
#[error(transparent)]
GitUrlParse(#[from] GitUrlParseError),
#[error("Package `{package}` references an undeclared index: `{index}`")]
MissingIndex {
package: PackageName,
index: IndexName,
hint: Option<String>,
},
#[error("Workspace members are not allowed in non-workspace contexts")]
WorkspaceMember,
#[error(transparent)]
InvalidUrl(#[from] DisplaySafeUrlError),
#[error(transparent)]
InvalidVerbatimUrl(#[from] uv_pep508::VerbatimUrlError),
#[error("Fragments are not allowed in URLs: `{0}`")]
ForbiddenFragment(DisplaySafeUrl),
#[error(
"`{0}` is associated with a URL source, but references a Git repository. Consider using a Git source instead (e.g., `{0} = {{ git = \"{1}\" }}`)"
)]
MissingGitSource(PackageName, DisplaySafeUrl),
#[error("`workspace = false` is not yet supported")]
WorkspaceFalse,
#[error("Source with `editable = true` must refer to a local directory, not a file: `{0}`")]
EditableFile(String),
#[error("Source with `package = true` must refer to a local directory, not a file: `{0}`")]
PackagedFile(String),
#[error(
"Git repository references local file source, but only directories are supported as transitive Git dependencies: `{0}`"
)]
GitFile(String),
#[error(transparent)]
ParsedUrl(#[from] ParsedUrlError),
#[error("Path must be UTF-8: `{0}`")]
NonUtf8Path(PathBuf),
#[error(transparent)] RelativeTo(io::Error),
}
impl uv_errors::Hint for LoweringError {
fn hints(&self) -> uv_errors::Hints<'_> {
match self {
Self::MissingIndex {
hint: Some(hint), ..
} => uv_errors::Hints::from(hint.clone()),
_ => uv_errors::Hints::none(),
}
}
}
#[derive(Debug, Copy, Clone)]
pub enum SourceKind {
Path,
Url,
Git,
Registry,
}
impl std::fmt::Display for SourceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Path => write!(f, "path"),
Self::Url => write!(f, "URL"),
Self::Git => write!(f, "Git"),
Self::Registry => write!(f, "registry"),
}
}
}
fn missing_index_hint(locations: &IndexLocations, index: &IndexName) -> Option<String> {
let config_index = locations
.simple_indexes()
.filter(|idx| !matches!(idx.origin, Some(Origin::Cli)))
.find(|idx| idx.name.as_ref().is_some_and(|name| *name == *index));
config_index.and_then(|idx| {
let source = match idx.origin {
Some(Origin::User) => "a user-level `uv.toml`",
Some(Origin::System) => "a system-level `uv.toml`",
Some(Origin::Project) => "a project-level `uv.toml`",
Some(Origin::Cli | Origin::RequirementsTxt) | None => return None,
};
Some(format!(
"Index `{index}` was found in {source}, but indexes \
referenced via `tool.uv.sources` must be defined in the project's \
`pyproject.toml`"
))
})
}
fn git_source(
git: &DisplaySafeUrl,
subdirectory: Option<Box<Path>>,
path: Option<PathBuf>,
rev: Option<String>,
tag: Option<String>,
branch: Option<String>,
lfs: Option<bool>,
) -> Result<RequirementSource, LoweringError> {
let reference = match (rev, tag, branch) {
(None, None, None) => GitReference::DefaultBranch,
(Some(rev), None, None) => GitReference::from_rev(rev),
(None, Some(tag), None) => GitReference::Tag(tag),
(None, None, Some(branch)) => GitReference::Branch(branch),
_ => return Err(LoweringError::MoreThanOneGitRef),
};
let mut url = DisplaySafeUrl::parse(&format!("git+{git}"))?;
if let Some(rev) = reference.as_str() {
let path = format!("{}@{}", url.path(), rev);
url.set_path(&path);
}
let mut frags: Vec<String> = Vec::new();
if let Some(subdirectory) = subdirectory.as_ref() {
let subdirectory = subdirectory
.to_str()
.ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
frags.push(format!("subdirectory={subdirectory}"));
}
let lfs = GitLfs::from(lfs);
if lfs.enabled() {
frags.push("lfs=true".to_string());
}
if let Some(path) = path.as_ref() {
let path = path
.to_str()
.ok_or_else(|| LoweringError::NonUtf8Path(path.clone()))?;
frags.push(format!("path={path}"));
}
if !frags.is_empty() {
url.set_fragment(Some(&frags.join("&")));
}
let url = VerbatimUrl::from_url(url);
let repository = git.clone();
let git = GitUrl::from_fields(repository, reference, None, lfs)?;
if let Some(path) = path {
let ext = match DistExtension::from_path(&path) {
Ok(ext) => ext,
Err(err) => {
return Err(ParsedUrlError::MissingExtensionPath(path, err).into());
}
};
Ok(RequirementSource::GitPath {
url,
git,
install_path: path,
ext,
})
} else {
Ok(RequirementSource::GitDirectory {
url,
git,
subdirectory,
})
}
}
fn url_source(
requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
url: DisplaySafeUrl,
subdirectory: Option<Box<Path>>,
) -> Result<RequirementSource, LoweringError> {
let mut verbatim_url = url.clone();
if verbatim_url.fragment().is_some() {
return Err(LoweringError::ForbiddenFragment(url));
}
if let Some(subdirectory) = subdirectory.as_ref() {
let subdirectory = subdirectory
.to_str()
.ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
verbatim_url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
}
let ext = match DistExtension::from_path(url.path()) {
Ok(ext) => ext,
Err(..) if looks_like_git_repository(&url) => {
return Err(LoweringError::MissingGitSource(
requirement.name.clone(),
url.clone(),
));
}
Err(err) => {
return Err(ParsedUrlError::MissingExtensionUrl(url.to_string(), err).into());
}
};
let verbatim_url = VerbatimUrl::from_url(verbatim_url);
Ok(RequirementSource::Url {
location: url,
subdirectory,
ext,
url: verbatim_url,
})
}
fn registry_source(
requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
index: IndexMetadata,
conflict: Option<ConflictItem>,
) -> RequirementSource {
match &requirement.version_or_url {
None => RequirementSource::Registry {
specifier: VersionSpecifiers::empty(),
index: Some(index),
conflict,
},
Some(VersionOrUrl::VersionSpecifier(version)) => RequirementSource::Registry {
specifier: version.clone(),
index: Some(index),
conflict,
},
Some(VersionOrUrl::Url(_)) => RequirementSource::Registry {
specifier: VersionSpecifiers::empty(),
index: Some(index),
conflict,
},
}
}
fn path_source(
path: impl AsRef<Path>,
git_member: Option<&GitWorkspaceMember>,
origin: RequirementOrigin,
project_dir: &Path,
workspace_root: &Path,
editable: Option<bool>,
package: Option<bool>,
) -> Result<RequirementSource, LoweringError> {
let path = path.as_ref();
let base = match origin {
RequirementOrigin::Project => project_dir,
RequirementOrigin::Workspace => workspace_root,
};
let url = VerbatimUrl::from_path(path, base)?.with_given(path.to_string_lossy());
let install_path = url
.to_file_path()
.map_err(|()| LoweringError::RelativeTo(io::Error::other("Invalid path in file URL")))?;
let is_dir = if let Ok(metadata) = install_path.metadata() {
metadata.is_dir()
} else {
install_path.extension().is_none()
};
if is_dir {
if let Some(git_member) = git_member {
return git_directory_source_from_path(install_path, git_member);
}
if editable == Some(true) {
Ok(RequirementSource::Directory {
install_path: install_path.into_boxed_path(),
url,
editable,
r#virtual: Some(false),
})
} else {
let is_package = package.unwrap_or_else(|| {
let pyproject_path = install_path.join("pyproject.toml");
fs_err::read_to_string(&pyproject_path)
.ok()
.and_then(|contents| PyProjectToml::from_string(contents, pyproject_path).ok())
.map(|pyproject_toml| pyproject_toml.is_package(false))
.unwrap_or(true)
});
let r#virtual = !is_package;
Ok(RequirementSource::Directory {
install_path: install_path.into_boxed_path(),
url,
editable: Some(false),
r#virtual: Some(r#virtual),
})
}
} else {
if let Some(git_member) = git_member {
return git_archive_source_from_path(install_path, git_member);
}
if editable == Some(true) {
return Err(LoweringError::EditableFile(url.to_string()));
}
if package == Some(true) {
return Err(LoweringError::PackagedFile(url.to_string()));
}
Ok(RequirementSource::Path {
ext: DistExtension::from_path(&install_path)
.map_err(|err| ParsedUrlError::MissingExtensionPath(path.to_path_buf(), err))?,
install_path: install_path.into_boxed_path(),
url,
})
}
}
fn git_directory_source_from_path(
install_path: impl AsRef<Path>,
git_member: &GitWorkspaceMember,
) -> Result<RequirementSource, LoweringError> {
let git = git_member.git_source.git.clone();
let install_path = git_path(install_path.as_ref())?;
let fetch_root = git_path(git_member.fetch_root)?;
let subdirectory =
uv_fs::relative_to(install_path, fetch_root).map_err(LoweringError::RelativeTo)?;
let subdirectory = normalize_path(subdirectory);
let subdirectory = if subdirectory == PathBuf::new() {
None
} else {
Some(subdirectory.into_owned().into_boxed_path())
};
let url = DisplaySafeUrl::from(ParsedGitDirectoryUrl {
url: git.clone(),
subdirectory: subdirectory.clone(),
});
Ok(RequirementSource::GitDirectory {
git,
subdirectory,
url: VerbatimUrl::from_url(url),
})
}
fn git_archive_source_from_path(
install_path: impl AsRef<Path>,
git_member: &GitWorkspaceMember,
) -> Result<RequirementSource, LoweringError> {
let git = git_member.git_source.git.clone();
let install_path = git_path(install_path.as_ref())?;
let fetch_root = git_path(git_member.fetch_root)?;
let install_path =
uv_fs::relative_to(install_path, fetch_root).map_err(LoweringError::RelativeTo)?;
let install_path = normalize_path(install_path).into_owned();
let ext = DistExtension::from_path(&install_path)
.map_err(|err| ParsedUrlError::MissingExtensionPath(install_path.clone(), err))?;
let url = DisplaySafeUrl::from(ParsedGitPathUrl {
url: git.clone(),
install_path: install_path.clone(),
ext,
});
Ok(RequirementSource::GitPath {
git,
install_path,
ext,
url: VerbatimUrl::from_url(url),
})
}
fn git_path(path: &Path) -> Result<PathBuf, LoweringError> {
path.simple_canonicalize()
.or_else(|_| normalize_absolute_path(path))
.map_err(LoweringError::RelativeTo)
}