Allow reading requirements from scripts with HTTP(S) paths (#16891)

## Summary

Closes https://github.com/astral-sh/uv/issues/16890.
This commit is contained in:
Charlie Marsh 2025-12-02 15:42:44 -08:00 committed by GitHub
parent 9fc07c8773
commit 87adf14fdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 128 additions and 19 deletions

View File

@ -65,8 +65,6 @@ impl RequirementsSource {
} else if path } else if path
.extension() .extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("txt") || ext.eq_ignore_ascii_case("in")) .is_some_and(|ext| ext.eq_ignore_ascii_case("txt") || ext.eq_ignore_ascii_case("in"))
|| path.starts_with("http://")
|| path.starts_with("https://")
{ {
Ok(Self::RequirementsTxt(path)) Ok(Self::RequirementsTxt(path))
} else if path.extension().is_none() { } else if path.extension().is_none() {

View File

@ -33,6 +33,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use rustc_hash::FxHashSet; use rustc_hash::FxHashSet;
use tracing::instrument; use tracing::instrument;
use url::Url;
use uv_cache_key::CanonicalUrl; use uv_cache_key::CanonicalUrl;
use uv_client::BaseClientBuilder; use uv_client::BaseClientBuilder;
@ -45,8 +46,9 @@ use uv_distribution_types::{
use uv_fs::{CWD, Simplified}; use uv_fs::{CWD, Simplified};
use uv_normalize::{ExtraName, PackageName, PipGroupName}; use uv_normalize::{ExtraName, PackageName, PipGroupName};
use uv_pypi_types::PyProjectToml; use uv_pypi_types::PyProjectToml;
use uv_redacted::DisplaySafeUrl;
use uv_requirements_txt::{RequirementsTxt, RequirementsTxtRequirement, SourceCache}; use uv_requirements_txt::{RequirementsTxt, RequirementsTxtRequirement, SourceCache};
use uv_scripts::{Pep723Error, Pep723Metadata, Pep723Script}; use uv_scripts::Pep723Metadata;
use uv_warnings::warn_user; use uv_warnings::warn_user;
use crate::{RequirementsSource, SourceTree}; use crate::{RequirementsSource, SourceTree};
@ -269,8 +271,8 @@ impl RequirementsSpecification {
Self::from_requirements_txt(requirements_txt) Self::from_requirements_txt(requirements_txt)
} }
RequirementsSource::PyprojectToml(path) => { RequirementsSource::PyprojectToml(path) => {
let contents = match fs_err::tokio::read_to_string(&path).await { let content = match fs_err::tokio::read_to_string(&path).await {
Ok(contents) => contents, Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(anyhow::anyhow!("File not found: `{}`", path.user_display())); return Err(anyhow::anyhow!("File not found: `{}`", path.user_display()));
} }
@ -282,7 +284,7 @@ impl RequirementsSpecification {
)); ));
} }
}; };
let pyproject_toml = toml::from_str::<PyProjectToml>(&contents) let pyproject_toml = toml::from_str::<PyProjectToml>(&content)
.with_context(|| format!("Failed to parse: `{}`", path.user_display()))?; .with_context(|| format!("Failed to parse: `{}`", path.user_display()))?;
Self { Self {
@ -291,7 +293,15 @@ impl RequirementsSpecification {
} }
} }
RequirementsSource::Pep723Script(path) => { RequirementsSource::Pep723Script(path) => {
let script = match Pep723Script::read(&path).await { let content = if let Some(content) = cache.get(path.as_path()) {
content.clone()
} else {
let content = read_file(path, client_builder).await?;
cache.insert(path.clone(), content.clone());
content
};
let metadata = match Pep723Metadata::parse(content.as_bytes()) {
Ok(Some(script)) => script, Ok(Some(script)) => script,
Ok(None) => { Ok(None) => {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
@ -299,16 +309,10 @@ impl RequirementsSpecification {
path.user_display(), 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()), Err(err) => return Err(err.into()),
}; };
Self::from_pep723_metadata(&script.metadata) Self::from_pep723_metadata(&metadata)
} }
RequirementsSource::SetupPy(path) => { RequirementsSource::SetupPy(path) => {
if !path.is_file() { if !path.is_file() {
@ -347,11 +351,10 @@ impl RequirementsSpecification {
)); ));
} }
RequirementsSource::Extensionless(path) => { RequirementsSource::Extensionless(path) => {
// Read the file content.
let content = if let Some(content) = cache.get(path.as_path()) { let content = if let Some(content) = cache.get(path.as_path()) {
content.clone() content.clone()
} else { } else {
let content = uv_fs::read_to_string_transcode(&path).await?; let content = read_file(path, client_builder).await?;
cache.insert(path.clone(), content.clone()); cache.insert(path.clone(), content.clone());
content content
}; };
@ -741,3 +744,32 @@ pub struct GroupsSpecification {
/// The enabled groups. /// The enabled groups.
pub groups: Vec<PipGroupName>, pub groups: Vec<PipGroupName>,
} }
/// Read the contents of a path, fetching over HTTP(S) if necessary.
async fn read_file(path: &Path, client_builder: &BaseClientBuilder<'_>) -> Result<String> {
// If the path is a URL, fetch it over HTTP(S).
if path.starts_with("http://") || path.starts_with("https://") {
// Only continue if we are absolutely certain no local file exists.
//
// We don't do this check on Windows since the file path would
// be invalid anyway, and thus couldn't refer to a local file.
if !cfg!(unix) || matches!(path.try_exists(), Ok(false)) {
let url = DisplaySafeUrl::parse(&path.to_string_lossy())?;
let client = client_builder.build();
let response = client
.for_host(&url)
.get(Url::from(url.clone()))
.send()
.await?;
response.error_for_status_ref()?;
return Ok(response.text().await?);
}
}
// Read the file content.
let content = uv_fs::read_to_string_transcode(path).await?;
Ok(content)
}

View File

@ -15,7 +15,7 @@ use indoc::{formatdoc, indoc};
use insta::assert_snapshot; use insta::assert_snapshot;
use std::path::Path; use std::path::Path;
use url::Url; use url::Url;
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method, matchers::path};
use uv_cache_key::{RepositoryUrl, cache_digest}; use uv_cache_key::{RepositoryUrl, cache_digest};
use uv_fs::Simplified; use uv_fs::Simplified;
@ -7393,6 +7393,83 @@ fn add_extensionless_script() -> Result<()> {
Ok(()) Ok(())
} }
/// Add from a remote PEP 723 script via `-r`.
#[tokio::test]
async fn add_requirements_from_remote_script() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;
// Create a mock server that serves a PEP 723 script.
let server = MockServer::start().await;
let script_content = indoc! {r#"
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "anyio>=4",
# "rich",
# ]
# ///
import anyio
from rich.pretty import pprint
pprint("Hello, world!")
"#};
Mock::given(method("GET"))
.and(path("/script"))
.respond_with(ResponseTemplate::new(200).set_body_string(script_content))
.mount(&server)
.await;
let script_url = format!("{}/script", server.uri());
uv_snapshot!(context.filters(), context.add().arg("-r").arg(&script_url), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 8 packages in [TIME]
Prepared 7 packages in [TIME]
Installed 7 packages in [TIME]
+ anyio==4.3.0
+ idna==3.6
+ markdown-it-py==3.0.0
+ mdurl==0.1.2
+ pygments==2.17.2
+ rich==13.7.1
+ sniffio==1.3.1
"###);
let pyproject_toml_content = context.read("pyproject.toml");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject_toml_content, @r###"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"anyio>=4",
"rich>=13.7.1",
]
"###
);
});
Ok(())
}
/// Remove a dependency that is present in multiple places. /// Remove a dependency that is present in multiple places.
#[test] #[test]
fn remove_repeated() -> Result<()> { fn remove_repeated() -> Result<()> {

View File

@ -2907,7 +2907,9 @@ fn tool_run_with_incompatible_build_constraints() -> Result<()> {
#[test] #[test]
fn tool_run_with_dependencies_from_script() -> Result<()> { fn tool_run_with_dependencies_from_script() -> Result<()> {
let context = TestContext::new("3.12").with_filtered_counts(); let context = TestContext::new("3.12")
.with_filtered_counts()
.with_filtered_missing_file_error();
let script_contents = indoc! {r#" let script_contents = indoc! {r#"
# /// script # /// script
@ -2992,7 +2994,7 @@ fn tool_run_with_dependencies_from_script() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
error: Failed to read `missing_file.py` (not found) error: failed to read from file `missing_file.py`: [OS ERROR 2]
"); ");
Ok(()) Ok(())