Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 7 additions & 4 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1800,7 +1800,7 @@ pub struct PipInstallArgs {
#[arg(group = "sources")]
pub package: Vec<String>,

/// Install all packages listed in the given `requirements.txt` or `pylock.toml` files.
/// Install all packages listed in the given `requirements.txt`, PEP 723 scripts, or `pylock.toml` files.
///
/// If a `pyproject.toml`, `setup.py`, or `setup.cfg` file is provided, uv will extract the
/// requirements for the relevant project.
Expand Down Expand Up @@ -3136,7 +3136,8 @@ pub struct RunArgs {
#[arg(long)]
pub with_editable: Vec<comma::CommaSeparatedRequirements>,

/// Run with all packages listed in the given `requirements.txt` files.
/// Run with all packages listed in the given `requirements.txt` files or PEP 723 Python
/// scripts.
///
/// The same environment semantics as `--with` apply.
///
Expand Down Expand Up @@ -4379,7 +4380,8 @@ pub struct ToolRunArgs {
#[arg(long)]
pub with_editable: Vec<comma::CommaSeparatedRequirements>,

/// Run with all packages listed in the given `requirements.txt` files.
/// Run with all packages listed in the given `requirements.txt` files or PEP 723 Python
/// scripts.
Comment thread
zanieb marked this conversation as resolved.
#[arg(long, value_delimiter = ',', value_parser = parse_maybe_file_path)]
pub with_requirements: Vec<Maybe<PathBuf>>,
Comment thread
zanieb marked this conversation as resolved.

Expand Down Expand Up @@ -4486,7 +4488,8 @@ pub struct ToolInstallArgs {
#[arg(short = 'w', long)]
pub with: Vec<comma::CommaSeparatedRequirements>,

/// Include all requirements listed in the given `requirements.txt` files.
/// Run with all packages listed in the given `requirements.txt` files or PEP 723 Python
/// scripts.
#[arg(long, value_delimiter = ',', value_parser = parse_maybe_file_path)]
pub with_requirements: Vec<Maybe<PathBuf>>,

Expand Down
1 change: 1 addition & 0 deletions crates/uv-requirements/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ uv-pypi-types = { workspace = true }
uv-redacted = { workspace = true }
uv-requirements-txt = { workspace = true, features = ["http"] }
uv-resolver = { workspace = true, features = ["clap"] }
uv-scripts = { workspace = true }
uv-types = { workspace = true }
uv-warnings = { workspace = true }
uv-workspace = { workspace = true }
Expand Down
9 changes: 9 additions & 0 deletions crates/uv-requirements/src/sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ pub enum RequirementsSource {
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`).
Expand Down Expand Up @@ -44,6 +46,12 @@ impl RequirementsSource {
.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"))
{
// TODO(blueraft): Support scripts without an extension.
Ok(Self::Pep723Script(path))
} else if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
Expand Down Expand Up @@ -290,6 +298,7 @@ impl std::fmt::Display for RequirementsSource {
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)
Expand Down
122 changes: 121 additions & 1 deletion crates/uv-requirements/src/specification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,15 @@ use tracing::instrument;
use uv_cache_key::CanonicalUrl;
use uv_client::BaseClientBuilder;
use uv_configuration::{DependencyGroups, NoBinary, NoBuild};
use uv_distribution_types::Requirement;
use uv_distribution_types::{Index, Requirement};
use uv_distribution_types::{
IndexUrl, NameRequirementSpecification, UnresolvedRequirement,
UnresolvedRequirementSpecification,
};
use uv_fs::{CWD, Simplified};
use uv_normalize::{ExtraName, PackageName, PipGroupName};
use uv_requirements_txt::{RequirementsTxt, RequirementsTxtRequirement};
use uv_scripts::{Pep723Error, Pep723Item, Pep723Script};
use uv_warnings::warn_user;
use uv_workspace::pyproject::PyProjectToml;

Expand Down Expand Up @@ -181,6 +182,125 @@ impl RequirementsSpecification {
..Self::default()
}
}
RequirementsSource::Pep723Script(path) => {
let script = match Pep723Script::read(&path).await {
Ok(Some(script)) => Pep723Item::Script(script),
Ok(None) => {
return Err(anyhow::anyhow!(
"`{}` does not contain inline script metadata",
path.user_display(),
));
}
Err(Pep723Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(anyhow::anyhow!(
"Failed to read `{}` (not found)",
path.user_display(),
));
}
Err(err) => return Err(err.into()),
};

let metadata = script.metadata();

let requirements = metadata
.dependencies
.as_ref()
.map(|dependencies| {
dependencies
.iter()
.map(|dependency| {
UnresolvedRequirementSpecification::from(Requirement::from(
dependency.to_owned(),
))
})
.collect::<Vec<UnresolvedRequirementSpecification>>()
})
.unwrap_or_default();

if let Some(tool_uv) = metadata.tool.as_ref().and_then(|tool| tool.uv.as_ref()) {
let constraints = tool_uv
.constraint_dependencies
.as_ref()
.map(|dependencies| {
dependencies
.iter()
.map(|dependency| {
NameRequirementSpecification::from(Requirement::from(
dependency.to_owned(),
))
})
.collect::<Vec<NameRequirementSpecification>>()
})
.unwrap_or_default();

let overrides = tool_uv
.override_dependencies
.as_ref()
.map(|dependencies| {
dependencies
.iter()
.map(|dependency| {
UnresolvedRequirementSpecification::from(Requirement::from(
dependency.to_owned(),
))
})
.collect::<Vec<UnresolvedRequirementSpecification>>()
})
.unwrap_or_default();

Self {
requirements,
constraints,
overrides,
index_url: tool_uv
.top_level
.index_url
.as_ref()
.map(|index| Index::from(index.clone()).url),
extra_index_urls: tool_uv
.top_level
.extra_index_url
.as_ref()
.into_iter()
.flat_map(|urls| {
urls.iter().map(|index| Index::from(index.clone()).url)
})
.collect(),
no_index: tool_uv.top_level.no_index.unwrap_or_default(),
find_links: tool_uv
.top_level
.find_links
.as_ref()
.into_iter()
.flat_map(|urls| {
urls.iter().map(|index| Index::from(index.clone()).url)
})
.collect(),
no_binary: NoBinary::from_args(
tool_uv.top_level.no_binary,
tool_uv
.top_level
.no_binary_package
.clone()
.unwrap_or_default(),
),
no_build: NoBuild::from_args(
tool_uv.top_level.no_build,
tool_uv
.top_level
.no_build_package
.clone()
.unwrap_or_default(),
),
..Self::default()
}
} else {
Self {
requirements,
..Self::default()
}
}
}
RequirementsSource::SetupPy(path) | RequirementsSource::SetupCfg(path) => {
if !path.is_file() {
return Err(anyhow::anyhow!("File not found: `{}`", path.user_display()));
Expand Down
3 changes: 3 additions & 0 deletions crates/uv/src/commands/project/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ pub(crate) async fn add(
RequirementsSource::SetupPy(_) => {
bail!("Adding requirements from a `setup.py` is not supported in `uv add`");
}
RequirementsSource::Pep723Script(_) => {
bail!("Adding requirements from a PEP 723 script is not supported in `uv add`");
}
RequirementsSource::SetupCfg(_) => {
bail!("Adding requirements from a `setup.cfg` is not supported in `uv add`");
}
Expand Down
65 changes: 65 additions & 0 deletions crates/uv/tests/it/pip_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,71 @@ werkzeug==3.0.1
Ok(())
}

#[test]
fn install_with_dependencies_from_script() -> Result<()> {
let context = TestContext::new("3.12");
let script = context.temp_dir.child("script.py");
script.write_str(indoc! {r#"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "anyio",
# ]
# ///

import anyio
"#})?;

uv_snapshot!(context.pip_install()
.arg("-r")
.arg("script.py")
.arg("--strict"), @r"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 3 packages in [TIME]
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
+ anyio==4.3.0
+ idna==3.6
+ sniffio==1.3.1
"
);

// Update the script file.
script.write_str(indoc! {r#"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "anyio",
# "iniconfig",
# ]
# ///

import anyio
"#})?;

uv_snapshot!(context.pip_install()
.arg("-r")
.arg("script.py")
.arg("--strict"), @r"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 4 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"
);

Ok(())
}

/// Install a `pyproject.toml` file with a `poetry` section.
#[test]
fn install_pyproject_toml_poetry() -> Result<()> {
Expand Down
Loading
Loading