mirror of https://github.com/astral-sh/uv
Allow `--with-requirements` to load extensionless inline-metadata scripts (#16744)
Resolves https://github.com/astral-sh/uv/issues/16732 This diff treats extensionless files that contain [PEP 723](https://peps.python.org/pep-0723/) metadata as scripts when resolving `--with-requirements`, so inline metadata works even when the script doesn’t end in `.py`. --------- Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
parent
b086eabe5f
commit
f3cdfac93e
|
|
@ -6,6 +6,7 @@ use console::Term;
|
|||
|
||||
use uv_fs::{CWD, Simplified};
|
||||
use uv_requirements_txt::RequirementsTxtRequirement;
|
||||
use uv_scripts::Pep723Script;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RequirementsSource {
|
||||
|
|
@ -14,7 +15,7 @@ pub enum RequirementsSource {
|
|||
/// 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),
|
||||
Pep723Script(Box<Pep723ScriptSource>),
|
||||
/// 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`).
|
||||
|
|
@ -50,8 +51,7 @@ impl RequirementsSource {
|
|||
.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))
|
||||
Ok(Self::Pep723Script(Pep723ScriptSource::new(path)))
|
||||
} else if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
|
||||
|
|
@ -60,6 +60,21 @@ impl RequirementsSource {
|
|||
"`{}` 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, attempt to detect a PEP 723 script, and
|
||||
// fall back to `requirements.txt` format if not.
|
||||
match Pep723Script::read_sync(&path) {
|
||||
Ok(Some(script)) => Ok(Self::Pep723Script(Pep723ScriptSource::with_script(
|
||||
path, script,
|
||||
))),
|
||||
Ok(None) => Ok(Self::RequirementsTxt(path)),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
} else {
|
||||
Ok(Self::RequirementsTxt(path))
|
||||
}
|
||||
|
|
@ -291,14 +306,43 @@ impl RequirementsSource {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pep723ScriptSource {
|
||||
path: PathBuf,
|
||||
script: Option<Pep723Script>,
|
||||
}
|
||||
|
||||
impl Pep723ScriptSource {
|
||||
fn new(path: PathBuf) -> Box<Self> {
|
||||
Box::new(Self { path, script: None })
|
||||
}
|
||||
|
||||
fn with_script(path: PathBuf, script: Pep723Script) -> Box<Self> {
|
||||
Box::new(Self {
|
||||
path,
|
||||
script: Some(script),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn script(&self) -> Option<&Pep723Script> {
|
||||
self.script.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
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::Pep723Script(source) => {
|
||||
write!(f, "{}", source.path().simplified_display())
|
||||
}
|
||||
Self::PylockToml(path)
|
||||
| Self::RequirementsTxt(path)
|
||||
| Self::Pep723Script(path)
|
||||
| Self::PyprojectToml(path)
|
||||
| Self::SetupPy(path)
|
||||
| Self::SetupCfg(path)
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ use uv_fs::{CWD, Simplified};
|
|||
use uv_normalize::{ExtraName, PackageName, PipGroupName};
|
||||
use uv_pypi_types::PyProjectToml;
|
||||
use uv_requirements_txt::{RequirementsTxt, RequirementsTxtRequirement};
|
||||
use uv_scripts::{Pep723Error, Pep723Item, Pep723Script};
|
||||
use uv_scripts::{Pep723Item, Pep723Script};
|
||||
use uv_warnings::warn_user;
|
||||
|
||||
use crate::{RequirementsSource, SourceTree};
|
||||
|
|
@ -184,22 +184,20 @@ 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(),
|
||||
));
|
||||
RequirementsSource::Pep723Script(source) => {
|
||||
let script = if let Some(script) = source.script() {
|
||||
Pep723Item::Script(script.clone())
|
||||
} else {
|
||||
match Pep723Script::read(source.path()).await {
|
||||
Ok(Some(script)) => Pep723Item::Script(script),
|
||||
Ok(None) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"`{}` does not contain inline script metadata",
|
||||
source.path().user_display(),
|
||||
));
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -175,28 +175,16 @@ impl Pep723Script {
|
|||
///
|
||||
/// See: <https://peps.python.org/pep-0723/>
|
||||
pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> {
|
||||
let contents = fs_err::tokio::read(&file).await?;
|
||||
let file = file.as_ref();
|
||||
let contents = fs_err::tokio::read(file).await?;
|
||||
Self::from_contents(file, &contents)
|
||||
}
|
||||
|
||||
// Extract the `script` tag.
|
||||
let ScriptTag {
|
||||
prelude,
|
||||
metadata,
|
||||
postlude,
|
||||
} = match ScriptTag::parse(&contents) {
|
||||
Ok(Some(tag)) => tag,
|
||||
Ok(None) => return Ok(None),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
// Parse the metadata.
|
||||
let metadata = Pep723Metadata::from_str(&metadata)?;
|
||||
|
||||
Ok(Some(Self {
|
||||
path: std::path::absolute(file)?,
|
||||
metadata,
|
||||
prelude,
|
||||
postlude,
|
||||
}))
|
||||
/// Read the PEP 723 `script` metadata from a Python file using blocking I/O.
|
||||
pub fn read_sync(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> {
|
||||
let file = file.as_ref();
|
||||
let contents = fs_err::read(file)?;
|
||||
Self::from_contents(file, &contents)
|
||||
}
|
||||
|
||||
/// Reads a Python script and generates a default PEP 723 metadata table.
|
||||
|
|
@ -349,6 +337,29 @@ impl Pep723Script {
|
|||
.and_then(|uv| uv.sources.as_ref())
|
||||
.unwrap_or(&EMPTY)
|
||||
}
|
||||
|
||||
fn from_contents(path: &Path, contents: &[u8]) -> Result<Option<Self>, Pep723Error> {
|
||||
let script_tag = match ScriptTag::parse(contents) {
|
||||
Ok(Some(tag)) => tag,
|
||||
Ok(None) => return Ok(None),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let ScriptTag {
|
||||
prelude,
|
||||
metadata,
|
||||
postlude,
|
||||
} = script_tag;
|
||||
|
||||
let metadata = Pep723Metadata::from_str(&metadata)?;
|
||||
|
||||
Ok(Some(Self {
|
||||
path: std::path::absolute(path)?,
|
||||
metadata,
|
||||
prelude,
|
||||
postlude,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// PEP 723 metadata as parsed from a `script` comment block.
|
||||
|
|
|
|||
|
|
@ -2646,8 +2646,7 @@ fn tool_run_with_incompatible_build_constraints() -> Result<()> {
|
|||
fn tool_run_with_dependencies_from_script() -> Result<()> {
|
||||
let context = TestContext::new("3.12").with_filtered_counts();
|
||||
|
||||
let script = context.temp_dir.child("script.py");
|
||||
script.write_str(indoc! {r#"
|
||||
let script_contents = indoc! {r#"
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
|
|
@ -2656,7 +2655,13 @@ fn tool_run_with_dependencies_from_script() -> Result<()> {
|
|||
# ///
|
||||
|
||||
import anyio
|
||||
"#})?;
|
||||
"#};
|
||||
|
||||
let script = context.temp_dir.child("script.py");
|
||||
script.write_str(script_contents)?;
|
||||
|
||||
let script_without_extension = context.temp_dir.child("script-no-ext");
|
||||
script_without_extension.write_str(script_contents)?;
|
||||
|
||||
// script dependencies (anyio) are now installed.
|
||||
uv_snapshot!(context.filters(), context.tool_run()
|
||||
|
|
@ -2684,6 +2689,20 @@ fn tool_run_with_dependencies_from_script() -> Result<()> {
|
|||
+ sniffio==1.3.1
|
||||
");
|
||||
|
||||
uv_snapshot!(context.filters(), context.tool_run()
|
||||
.arg("--with-requirements")
|
||||
.arg("script-no-ext")
|
||||
.arg("black")
|
||||
.arg("script-no-ext")
|
||||
.arg("-q"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Resolved [N] packages in [TIME]
|
||||
");
|
||||
|
||||
// Error when the script is not a valid PEP723 script.
|
||||
let script = context.temp_dir.child("not_pep723_script.py");
|
||||
script.write_str("import anyio")?;
|
||||
|
|
@ -2710,7 +2729,7 @@ fn tool_run_with_dependencies_from_script() -> Result<()> {
|
|||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
error: Failed to read `missing_file.py` (not found)
|
||||
error: failed to read from file `missing_file.py`: No such file or directory (os error 2)
|
||||
");
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
Loading…
Reference in New Issue