uv/crates/uv-requirements/src/sources.rs

329 lines
14 KiB
Rust

use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use console::Term;
use uv_fs::{CWD, Simplified};
use uv_requirements_txt::RequirementsTxtRequirement;
#[derive(Debug, Clone)]
pub enum RequirementsSource {
/// A package was provided on the command line (e.g., `pip install flask`).
Package(RequirementsTxtRequirement),
/// An editable path was provided on the command line (e.g., `pip install -e ../flask`).
Editable(RequirementsTxtRequirement),
/// Dependencies were provided via a PEP 723 script.
Pep723Script(PathBuf),
/// Dependencies were provided via a `pylock.toml` file.
PylockToml(PathBuf),
/// Dependencies were provided via a `requirements.txt` file (e.g., `pip install -r requirements.txt`).
RequirementsTxt(PathBuf),
/// Dependencies were provided via a `pyproject.toml` file (e.g., `pip-compile pyproject.toml`).
PyprojectToml(PathBuf),
/// Dependencies were provided via a `setup.py` file (e.g., `pip-compile setup.py`).
SetupPy(PathBuf),
/// Dependencies were provided via a `setup.cfg` file (e.g., `pip-compile setup.cfg`).
SetupCfg(PathBuf),
/// Dependencies were provided via an unsupported Conda `environment.yml` file (e.g., `pip install -r environment.yml`).
EnvironmentYml(PathBuf),
/// An extensionless file that could be either a PEP 723 script or a requirements.txt file.
/// We detect the format when reading the file.
Extensionless(PathBuf),
}
impl RequirementsSource {
/// Parse a [`RequirementsSource`] from a [`PathBuf`]. The file type is determined by the file
/// extension and, in some cases, the file contents.
pub fn from_requirements_file(path: PathBuf) -> Result<Self> {
if path.ends_with("pyproject.toml") {
Ok(Self::PyprojectToml(path))
} else if path.ends_with("setup.py") {
Ok(Self::SetupPy(path))
} else if path.ends_with("setup.cfg") {
Ok(Self::SetupCfg(path))
} else if path.ends_with("environment.yml") {
Ok(Self::EnvironmentYml(path))
} else if path
.file_name()
.is_some_and(|file_name| file_name.to_str().is_some_and(is_pylock_toml))
{
Ok(Self::PylockToml(path))
} else if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("py") || ext.eq_ignore_ascii_case("pyw"))
{
Ok(Self::Pep723Script(path))
} else if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
{
Err(anyhow::anyhow!(
"`{}` is not a valid PEP 751 filename: expected TOML file to start with `pylock.` and end with `.toml` (e.g., `pylock.toml`, `pylock.dev.toml`)",
path.user_display(),
))
} else if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("txt") || ext.eq_ignore_ascii_case("in"))
{
Ok(Self::RequirementsTxt(path))
} else if path.extension().is_none() {
// If we don't have an extension, mark it as extensionless so we can detect
// the format later (either a PEP 723 script or a requirements.txt file).
Ok(Self::Extensionless(path))
} else {
Ok(Self::RequirementsTxt(path))
}
}
/// Parse a [`RequirementsSource`] from a `requirements.txt` file.
pub fn from_requirements_txt(path: PathBuf) -> Result<Self> {
for file_name in ["pyproject.toml", "setup.py", "setup.cfg"] {
if path.ends_with(file_name) {
return Err(anyhow::anyhow!(
"The file `{}` appears to be a `{}` file, but requirements must be specified in `requirements.txt` format",
path.user_display(),
file_name
));
}
}
if path
.file_name()
.and_then(OsStr::to_str)
.is_some_and(is_pylock_toml)
{
return Err(anyhow::anyhow!(
"The file `{}` appears to be a `pylock.toml` file, but requirements must be specified in `requirements.txt` format",
path.user_display(),
));
} else if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
{
return Err(anyhow::anyhow!(
"The file `{}` appears to be a TOML file, but requirements must be specified in `requirements.txt` format",
path.user_display(),
));
}
Ok(Self::RequirementsTxt(path))
}
/// Parse a [`RequirementsSource`] from a `constraints.txt` file.
pub fn from_constraints_txt(path: PathBuf) -> Result<Self> {
for file_name in ["pyproject.toml", "setup.py", "setup.cfg"] {
if path.ends_with(file_name) {
return Err(anyhow::anyhow!(
"The file `{}` appears to be a `{}` file, but constraints must be specified in `requirements.txt` format",
path.user_display(),
file_name
));
}
}
if path
.file_name()
.and_then(OsStr::to_str)
.is_some_and(is_pylock_toml)
{
return Err(anyhow::anyhow!(
"The file `{}` appears to be a `pylock.toml` file, but constraints must be specified in `requirements.txt` format",
path.user_display(),
));
} else if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
{
return Err(anyhow::anyhow!(
"The file `{}` appears to be a TOML file, but constraints must be specified in `requirements.txt` format",
path.user_display(),
));
}
Ok(Self::RequirementsTxt(path))
}
/// Parse a [`RequirementsSource`] from an `overrides.txt` file.
pub fn from_overrides_txt(path: PathBuf) -> Result<Self> {
for file_name in ["pyproject.toml", "setup.py", "setup.cfg"] {
if path.ends_with(file_name) {
return Err(anyhow::anyhow!(
"The file `{}` appears to be a `{}` file, but overrides must be specified in `requirements.txt` format",
path.user_display(),
file_name
));
}
}
if path
.file_name()
.and_then(OsStr::to_str)
.is_some_and(is_pylock_toml)
{
return Err(anyhow::anyhow!(
"The file `{}` appears to be a `pylock.toml` file, but overrides must be specified in `requirements.txt` format",
path.user_display(),
));
} else if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
{
return Err(anyhow::anyhow!(
"The file `{}` appears to be a TOML file, but overrides must be specified in `requirements.txt` format",
path.user_display(),
));
}
Ok(Self::RequirementsTxt(path))
}
/// Parse a [`RequirementsSource`] from a user-provided string, assumed to be a positional
/// package (e.g., `uv pip install flask`).
///
/// If the user provided a value that appears to be a `requirements.txt` file or a local
/// directory, prompt them to correct it (if the terminal is interactive).
pub fn from_package_argument(name: &str) -> Result<Self> {
// If the user provided a `requirements.txt` file without `-r` (as in
// `uv pip install requirements.txt`), prompt them to correct it.
#[allow(clippy::case_sensitive_file_extension_comparisons)]
if (name.ends_with(".txt") || name.ends_with(".in")) && Path::new(&name).is_file() {
let term = Term::stderr();
if term.is_term() {
let prompt = format!(
"`{name}` looks like a local requirements file but was passed as a package name. Did you mean `-r {name}`?"
);
let confirmation =
uv_console::confirm(&prompt, &term, true).context("Confirm prompt failed")?;
if confirmation {
return Self::from_requirements_file(name.into());
}
}
}
// Similarly, if the user provided a `pyproject.toml` file without `-r` (as in
// `uv pip install pyproject.toml`), prompt them to correct it.
if (name == "pyproject.toml"
|| name == "setup.py"
|| name == "setup.cfg"
|| is_pylock_toml(name))
&& Path::new(&name).is_file()
{
let term = Term::stderr();
if term.is_term() {
let prompt = format!(
"`{name}` looks like a local metadata file but was passed as a package name. Did you mean `-r {name}`?"
);
let confirmation =
uv_console::confirm(&prompt, &term, true).context("Confirm prompt failed")?;
if confirmation {
return Self::from_requirements_file(name.into());
}
}
}
let requirement = RequirementsTxtRequirement::parse(name, &*CWD, false)
.with_context(|| format!("Failed to parse: `{name}`"))?;
Ok(Self::Package(requirement))
}
/// Parse a [`RequirementsSource`] from a user-provided string, assumed to be a `--with`
/// package (e.g., `uvx --with flask ruff`).
///
/// If the user provided a value that appears to be a `requirements.txt` file or a local
/// directory, prompt them to correct it (if the terminal is interactive).
pub fn from_with_package_argument(name: &str) -> Result<Self> {
// If the user provided a `requirements.txt` file without `--with-requirements` (as in
// `uvx --with requirements.txt ruff`), prompt them to correct it.
#[allow(clippy::case_sensitive_file_extension_comparisons)]
if (name.ends_with(".txt") || name.ends_with(".in")) && Path::new(&name).is_file() {
let term = Term::stderr();
if term.is_term() {
let prompt = format!(
"`{name}` looks like a local requirements file but was passed as a package name. Did you mean `--with-requirements {name}`?"
);
let confirmation =
uv_console::confirm(&prompt, &term, true).context("Confirm prompt failed")?;
if confirmation {
return Self::from_requirements_file(name.into());
}
}
}
// Similarly, if the user provided a `pyproject.toml` file without `--with-requirements` (as in
// `uvx --with pyproject.toml ruff`), prompt them to correct it.
if (name == "pyproject.toml"
|| name == "setup.py"
|| name == "setup.cfg"
|| is_pylock_toml(name))
&& Path::new(&name).is_file()
{
let term = Term::stderr();
if term.is_term() {
let prompt = format!(
"`{name}` looks like a local metadata file but was passed as a package name. Did you mean `--with-requirements {name}`?"
);
let confirmation =
uv_console::confirm(&prompt, &term, true).context("Confirm prompt failed")?;
if confirmation {
return Self::from_requirements_file(name.into());
}
}
}
let requirement = RequirementsTxtRequirement::parse(name, &*CWD, false)
.with_context(|| format!("Failed to parse: `{name}`"))?;
Ok(Self::Package(requirement))
}
/// Parse an editable [`RequirementsSource`] (e.g., `uv pip install -e .`).
pub fn from_editable(name: &str) -> Result<Self> {
let requirement = RequirementsTxtRequirement::parse(name, &*CWD, true)
.with_context(|| format!("Failed to parse: `{name}`"))?;
Ok(Self::Editable(requirement))
}
/// Parse a package [`RequirementsSource`] (e.g., `uv pip install ruff`).
pub fn from_package(name: &str) -> Result<Self> {
let requirement = RequirementsTxtRequirement::parse(name, &*CWD, false)
.with_context(|| format!("Failed to parse: `{name}`"))?;
Ok(Self::Package(requirement))
}
/// Returns `true` if the source allows extras to be specified.
pub fn allows_extras(&self) -> bool {
matches!(
self,
Self::PylockToml(_) | Self::PyprojectToml(_) | Self::SetupPy(_) | Self::SetupCfg(_)
)
}
/// Returns `true` if the source allows groups to be specified.
pub fn allows_groups(&self) -> bool {
matches!(self, Self::PylockToml(_) | Self::PyprojectToml(_))
}
}
impl std::fmt::Display for RequirementsSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Package(package) => write!(f, "{package:?}"),
Self::Editable(path) => write!(f, "-e {path:?}"),
Self::PylockToml(path)
| Self::RequirementsTxt(path)
| Self::Pep723Script(path)
| Self::PyprojectToml(path)
| Self::SetupPy(path)
| Self::SetupCfg(path)
| Self::EnvironmentYml(path)
| Self::Extensionless(path) => {
write!(f, "{}", path.simplified_display())
}
}
}
}
/// Returns `true` if a file name matches the `pylock.toml` pattern defined in PEP 751.
#[allow(clippy::case_sensitive_file_extension_comparisons)]
pub fn is_pylock_toml(file_name: &str) -> bool {
file_name.starts_with("pylock.") && file_name.ends_with(".toml")
}