Skip to content

Commit 87adf14

Browse files
Allow reading requirements from scripts with HTTP(S) paths (#16891)
## Summary Closes #16890.
1 parent 9fc07c8 commit 87adf14

File tree

4 files changed

+128
-19
lines changed

4 files changed

+128
-19
lines changed

crates/uv-requirements/src/sources.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,6 @@ impl RequirementsSource {
6565
} else if path
6666
.extension()
6767
.is_some_and(|ext| ext.eq_ignore_ascii_case("txt") || ext.eq_ignore_ascii_case("in"))
68-
|| path.starts_with("http://")
69-
|| path.starts_with("https://")
7068
{
7169
Ok(Self::RequirementsTxt(path))
7270
} else if path.extension().is_none() {

crates/uv-requirements/src/specification.rs

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ use std::path::{Path, PathBuf};
3333
use anyhow::{Context, Result};
3434
use rustc_hash::FxHashSet;
3535
use tracing::instrument;
36+
use url::Url;
3637

3738
use uv_cache_key::CanonicalUrl;
3839
use uv_client::BaseClientBuilder;
@@ -45,8 +46,9 @@ use uv_distribution_types::{
4546
use uv_fs::{CWD, Simplified};
4647
use uv_normalize::{ExtraName, PackageName, PipGroupName};
4748
use uv_pypi_types::PyProjectToml;
49+
use uv_redacted::DisplaySafeUrl;
4850
use uv_requirements_txt::{RequirementsTxt, RequirementsTxtRequirement, SourceCache};
49-
use uv_scripts::{Pep723Error, Pep723Metadata, Pep723Script};
51+
use uv_scripts::Pep723Metadata;
5052
use uv_warnings::warn_user;
5153

5254
use crate::{RequirementsSource, SourceTree};
@@ -269,8 +271,8 @@ impl RequirementsSpecification {
269271
Self::from_requirements_txt(requirements_txt)
270272
}
271273
RequirementsSource::PyprojectToml(path) => {
272-
let contents = match fs_err::tokio::read_to_string(&path).await {
273-
Ok(contents) => contents,
274+
let content = match fs_err::tokio::read_to_string(&path).await {
275+
Ok(content) => content,
274276
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
275277
return Err(anyhow::anyhow!("File not found: `{}`", path.user_display()));
276278
}
@@ -282,7 +284,7 @@ impl RequirementsSpecification {
282284
));
283285
}
284286
};
285-
let pyproject_toml = toml::from_str::<PyProjectToml>(&contents)
287+
let pyproject_toml = toml::from_str::<PyProjectToml>(&content)
286288
.with_context(|| format!("Failed to parse: `{}`", path.user_display()))?;
287289

288290
Self {
@@ -291,24 +293,26 @@ impl RequirementsSpecification {
291293
}
292294
}
293295
RequirementsSource::Pep723Script(path) => {
294-
let script = match Pep723Script::read(&path).await {
296+
let content = if let Some(content) = cache.get(path.as_path()) {
297+
content.clone()
298+
} else {
299+
let content = read_file(path, client_builder).await?;
300+
cache.insert(path.clone(), content.clone());
301+
content
302+
};
303+
304+
let metadata = match Pep723Metadata::parse(content.as_bytes()) {
295305
Ok(Some(script)) => script,
296306
Ok(None) => {
297307
return Err(anyhow::anyhow!(
298308
"`{}` does not contain inline script metadata",
299309
path.user_display(),
300310
));
301311
}
302-
Err(Pep723Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
303-
return Err(anyhow::anyhow!(
304-
"Failed to read `{}` (not found)",
305-
path.user_display(),
306-
));
307-
}
308312
Err(err) => return Err(err.into()),
309313
};
310314

311-
Self::from_pep723_metadata(&script.metadata)
315+
Self::from_pep723_metadata(&metadata)
312316
}
313317
RequirementsSource::SetupPy(path) => {
314318
if !path.is_file() {
@@ -347,11 +351,10 @@ impl RequirementsSpecification {
347351
));
348352
}
349353
RequirementsSource::Extensionless(path) => {
350-
// Read the file content.
351354
let content = if let Some(content) = cache.get(path.as_path()) {
352355
content.clone()
353356
} else {
354-
let content = uv_fs::read_to_string_transcode(&path).await?;
357+
let content = read_file(path, client_builder).await?;
355358
cache.insert(path.clone(), content.clone());
356359
content
357360
};
@@ -741,3 +744,32 @@ pub struct GroupsSpecification {
741744
/// The enabled groups.
742745
pub groups: Vec<PipGroupName>,
743746
}
747+
748+
/// Read the contents of a path, fetching over HTTP(S) if necessary.
749+
async fn read_file(path: &Path, client_builder: &BaseClientBuilder<'_>) -> Result<String> {
750+
// If the path is a URL, fetch it over HTTP(S).
751+
if path.starts_with("http://") || path.starts_with("https://") {
752+
// Only continue if we are absolutely certain no local file exists.
753+
//
754+
// We don't do this check on Windows since the file path would
755+
// be invalid anyway, and thus couldn't refer to a local file.
756+
if !cfg!(unix) || matches!(path.try_exists(), Ok(false)) {
757+
let url = DisplaySafeUrl::parse(&path.to_string_lossy())?;
758+
759+
let client = client_builder.build();
760+
let response = client
761+
.for_host(&url)
762+
.get(Url::from(url.clone()))
763+
.send()
764+
.await?;
765+
766+
response.error_for_status_ref()?;
767+
768+
return Ok(response.text().await?);
769+
}
770+
}
771+
772+
// Read the file content.
773+
let content = uv_fs::read_to_string_transcode(path).await?;
774+
Ok(content)
775+
}

crates/uv/tests/it/edit.rs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use indoc::{formatdoc, indoc};
1515
use insta::assert_snapshot;
1616
use std::path::Path;
1717
use url::Url;
18-
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
18+
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method, matchers::path};
1919

2020
use uv_cache_key::{RepositoryUrl, cache_digest};
2121
use uv_fs::Simplified;
@@ -7393,6 +7393,83 @@ fn add_extensionless_script() -> Result<()> {
73937393
Ok(())
73947394
}
73957395

7396+
/// Add from a remote PEP 723 script via `-r`.
7397+
#[tokio::test]
7398+
async fn add_requirements_from_remote_script() -> Result<()> {
7399+
let context = TestContext::new("3.12");
7400+
7401+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
7402+
pyproject_toml.write_str(indoc! {r#"
7403+
[project]
7404+
name = "project"
7405+
version = "0.1.0"
7406+
requires-python = ">=3.12"
7407+
dependencies = []
7408+
"#})?;
7409+
7410+
// Create a mock server that serves a PEP 723 script.
7411+
let server = MockServer::start().await;
7412+
let script_content = indoc! {r#"
7413+
# /// script
7414+
# requires-python = ">=3.12"
7415+
# dependencies = [
7416+
# "anyio>=4",
7417+
# "rich",
7418+
# ]
7419+
# ///
7420+
import anyio
7421+
from rich.pretty import pprint
7422+
pprint("Hello, world!")
7423+
"#};
7424+
7425+
Mock::given(method("GET"))
7426+
.and(path("/script"))
7427+
.respond_with(ResponseTemplate::new(200).set_body_string(script_content))
7428+
.mount(&server)
7429+
.await;
7430+
7431+
let script_url = format!("{}/script", server.uri());
7432+
7433+
uv_snapshot!(context.filters(), context.add().arg("-r").arg(&script_url), @r###"
7434+
success: true
7435+
exit_code: 0
7436+
----- stdout -----
7437+
7438+
----- stderr -----
7439+
Resolved 8 packages in [TIME]
7440+
Prepared 7 packages in [TIME]
7441+
Installed 7 packages in [TIME]
7442+
+ anyio==4.3.0
7443+
+ idna==3.6
7444+
+ markdown-it-py==3.0.0
7445+
+ mdurl==0.1.2
7446+
+ pygments==2.17.2
7447+
+ rich==13.7.1
7448+
+ sniffio==1.3.1
7449+
"###);
7450+
7451+
let pyproject_toml_content = context.read("pyproject.toml");
7452+
7453+
insta::with_settings!({
7454+
filters => context.filters(),
7455+
}, {
7456+
assert_snapshot!(
7457+
pyproject_toml_content, @r###"
7458+
[project]
7459+
name = "project"
7460+
version = "0.1.0"
7461+
requires-python = ">=3.12"
7462+
dependencies = [
7463+
"anyio>=4",
7464+
"rich>=13.7.1",
7465+
]
7466+
"###
7467+
);
7468+
});
7469+
7470+
Ok(())
7471+
}
7472+
73967473
/// Remove a dependency that is present in multiple places.
73977474
#[test]
73987475
fn remove_repeated() -> Result<()> {

crates/uv/tests/it/tool_run.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2907,7 +2907,9 @@ fn tool_run_with_incompatible_build_constraints() -> Result<()> {
29072907

29082908
#[test]
29092909
fn tool_run_with_dependencies_from_script() -> Result<()> {
2910-
let context = TestContext::new("3.12").with_filtered_counts();
2910+
let context = TestContext::new("3.12")
2911+
.with_filtered_counts()
2912+
.with_filtered_missing_file_error();
29112913

29122914
let script_contents = indoc! {r#"
29132915
# /// script
@@ -2992,7 +2994,7 @@ fn tool_run_with_dependencies_from_script() -> Result<()> {
29922994
----- stdout -----
29932995
29942996
----- stderr -----
2995-
error: Failed to read `missing_file.py` (not found)
2997+
error: failed to read from file `missing_file.py`: [OS ERROR 2]
29962998
");
29972999

29983000
Ok(())

0 commit comments

Comments
 (0)