Support tool.uv in PEP 723 scripts (#5990)

## Summary

This includes both _settings_ and _sources.

Closes https://github.com/astral-sh/uv/issues/5855.
This commit is contained in:
Charlie Marsh 2024-08-09 23:11:10 -04:00 committed by GitHub
parent 19ac9af167
commit f10c28225c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 625 additions and 299 deletions

2
Cargo.lock generated
View file

@ -5110,6 +5110,8 @@ dependencies = [
"serde",
"thiserror",
"toml",
"uv-settings",
"uv-workspace",
]
[[package]]

View file

@ -2,7 +2,7 @@ pub use distribution_database::{DistributionDatabase, HttpArchivePointer, LocalA
pub use download::LocalWheel;
pub use error::Error;
pub use index::{BuiltWheelIndex, RegistryWheelIndex};
pub use metadata::{ArchiveMetadata, Metadata, RequiresDist};
pub use metadata::{ArchiveMetadata, LoweredRequirement, Metadata, RequiresDist};
pub use reporter::Reporter;
mod archive;

View file

@ -17,6 +17,218 @@ use uv_warnings::warn_user_once;
use uv_workspace::pyproject::Source;
use uv_workspace::Workspace;
#[derive(Debug, Clone)]
pub struct LoweredRequirement(Requirement);
impl LoweredRequirement {
/// Combine `project.dependencies` or `project.optional-dependencies` with `tool.uv.sources`.
pub(crate) fn from_requirement(
requirement: pep508_rs::Requirement<VerbatimParsedUrl>,
project_name: &PackageName,
project_dir: &Path,
project_sources: &BTreeMap<PackageName, Source>,
workspace: &Workspace,
preview: PreviewMode,
) -> Result<Self, LoweringError> {
let source = project_sources
.get(&requirement.name)
.or(workspace.sources().get(&requirement.name))
.cloned();
let workspace_package_declared =
// We require that when you use a package that's part of the workspace, ...
!workspace.packages().contains_key(&requirement.name)
// ... it must be declared as a workspace dependency (`workspace = true`), ...
|| matches!(
source,
Some(Source::Workspace {
// By using toml, we technically support `workspace = false`.
workspace: true
})
)
// ... except for recursive self-inclusion (extras that activate other extras), e.g.
// `framework[machine_learning]` depends on `framework[cuda]`.
|| &requirement.name == project_name;
if !workspace_package_declared {
return Err(LoweringError::UndeclaredWorkspacePackage);
}
let Some(source) = source else {
let has_sources = !project_sources.is_empty() || !workspace.sources().is_empty();
// Support recursive editable inclusions.
if has_sources
&& requirement.version_or_url.is_none()
&& &requirement.name != project_name
{
warn_user_once!(
"Missing version constraint (e.g., a lower bound) for `{}`",
requirement.name
);
}
return Ok(Self(Requirement::from(requirement)));
};
if preview.is_disabled() {
warn_user_once!("`uv.sources` is experimental and may change without warning");
}
let source = match source {
Source::Git {
git,
subdirectory,
rev,
tag,
branch,
} => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
git_source(&git, subdirectory, rev, tag, branch)?
}
Source::Url { url, subdirectory } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
url_source(url, subdirectory)?
}
Source::Path { path, editable } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
path_source(
path,
project_dir,
workspace.install_path(),
editable.unwrap_or(false),
)?
}
Source::Registry { index } => registry_source(&requirement, index)?,
Source::Workspace {
workspace: is_workspace,
} => {
if !is_workspace {
return Err(LoweringError::WorkspaceFalse);
}
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
let member = workspace
.packages()
.get(&requirement.name)
.ok_or(LoweringError::UndeclaredWorkspacePackage)?
.clone();
// Say we have:
// ```
// root
// ├── main_workspace <- We want to the path from here ...
// │ ├── pyproject.toml
// │ └── uv.lock
// └──current_workspace
// └── packages
// └── current_package <- ... to here.
// └── pyproject.toml
// ```
// The path we need in the lockfile: `../current_workspace/packages/current_project`
// member root: `/root/current_workspace/packages/current_project`
// workspace install root: `/root/current_workspace`
// relative to workspace: `packages/current_project`
// workspace lock root: `../current_workspace`
// relative to main workspace: `../current_workspace/packages/current_project`
let relative_to_workspace = relative_to(member.root(), workspace.install_path())
.map_err(LoweringError::RelativeTo)?;
let relative_to_main_workspace = workspace.lock_path().join(relative_to_workspace);
let url = VerbatimUrl::parse_absolute_path(member.root())?
.with_given(relative_to_main_workspace.to_string_lossy());
RequirementSource::Directory {
install_path: member.root().clone(),
lock_path: relative_to_main_workspace,
url,
editable: true,
}
}
Source::CatchAll { .. } => {
// Emit a dedicated error message, which is an improvement over Serde's default error.
return Err(LoweringError::InvalidEntry);
}
};
Ok(Self(Requirement {
name: requirement.name,
extras: requirement.extras,
marker: requirement.marker,
source,
origin: requirement.origin,
}))
}
/// Lower a [`pep508_rs::Requirement`] in a non-workspace setting (for example, in a PEP 723
/// script, which runs in an isolated context).
pub fn from_non_workspace_requirement(
requirement: pep508_rs::Requirement<VerbatimParsedUrl>,
dir: &Path,
sources: &BTreeMap<PackageName, Source>,
preview: PreviewMode,
) -> Result<Self, LoweringError> {
let source = sources.get(&requirement.name).cloned();
let Some(source) = source else {
return Ok(Self(Requirement::from(requirement)));
};
if preview.is_disabled() {
warn_user_once!("`uv.sources` is experimental and may change without warning");
}
let source = match source {
Source::Git {
git,
subdirectory,
rev,
tag,
branch,
} => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
git_source(&git, subdirectory, rev, tag, branch)?
}
Source::Url { url, subdirectory } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
url_source(url, subdirectory)?
}
Source::Path { path, editable } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
path_source(path, dir, dir, editable.unwrap_or(false))?
}
Source::Registry { index } => registry_source(&requirement, index)?,
Source::Workspace { .. } => {
return Err(LoweringError::WorkspaceMember);
}
Source::CatchAll { .. } => {
// Emit a dedicated error message, which is an improvement over Serde's default
// error.
return Err(LoweringError::InvalidEntry);
}
};
Ok(Self(Requirement {
name: requirement.name,
extras: requirement.extras,
marker: requirement.marker,
source,
origin: requirement.origin,
}))
}
/// Convert back into a [`Requirement`].
pub fn into_inner(self) -> Requirement {
self.0
}
}
/// An error parsing and merging `tool.uv.sources` with
/// `project.{dependencies,optional-dependencies}`.
#[derive(Debug, Error)]
@ -27,6 +239,8 @@ pub enum LoweringError {
MoreThanOneGitRef,
#[error("Unable to combine options in `tool.uv.sources`")]
InvalidEntry,
#[error("Workspace members are not allowed in non-workspace contexts")]
WorkspaceMember,
#[error(transparent)]
InvalidUrl(#[from] url::ParseError),
#[error(transparent)]
@ -47,211 +261,95 @@ pub enum LoweringError {
RelativeTo(io::Error),
}
/// Combine `project.dependencies` or `project.optional-dependencies` with `tool.uv.sources`.
pub(crate) fn lower_requirement(
requirement: pep508_rs::Requirement<VerbatimParsedUrl>,
project_name: &PackageName,
project_dir: &Path,
project_sources: &BTreeMap<PackageName, Source>,
workspace: &Workspace,
preview: PreviewMode,
) -> Result<Requirement, LoweringError> {
let source = project_sources
.get(&requirement.name)
.or(workspace.sources().get(&requirement.name))
.cloned();
/// Convert a Git source into a [`RequirementSource`].
fn git_source(
git: &Url,
subdirectory: Option<String>,
rev: Option<String>,
tag: Option<String>,
branch: Option<String>,
) -> Result<RequirementSource, LoweringError> {
let reference = match (rev, tag, branch) {
(None, None, None) => GitReference::DefaultBranch,
(Some(rev), None, None) => {
if rev.starts_with("refs/") {
GitReference::NamedRef(rev.clone())
} else if rev.len() == 40 {
GitReference::FullCommit(rev.clone())
} else {
GitReference::ShortCommit(rev.clone())
}
}
(None, Some(tag), None) => GitReference::Tag(tag),
(None, None, Some(branch)) => GitReference::Branch(branch),
_ => return Err(LoweringError::MoreThanOneGitRef),
};
let workspace_package_declared =
// We require that when you use a package that's part of the workspace, ...
!workspace.packages().contains_key(&requirement.name)
// ... it must be declared as a workspace dependency (`workspace = true`), ...
|| matches!(
source,
Some(Source::Workspace {
// By using toml, we technically support `workspace = false`.
workspace: true,
..
})
)
// ... except for recursive self-inclusion (extras that activate other extras), e.g.
// `framework[machine_learning]` depends on `framework[cuda]`.
|| &requirement.name == project_name;
if !workspace_package_declared {
return Err(LoweringError::UndeclaredWorkspacePackage);
// Create a PEP 508-compatible URL.
let mut url = Url::parse(&format!("git+{git}"))?;
if let Some(rev) = reference.as_str() {
url.set_path(&format!("{}@{}", url.path(), rev));
}
if let Some(subdirectory) = &subdirectory {
url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
}
let url = VerbatimUrl::from_url(url);
let repository = git.clone();
Ok(RequirementSource::Git {
url,
repository,
reference,
precise: None,
subdirectory: subdirectory.map(PathBuf::from),
})
}
/// Convert a URL source into a [`RequirementSource`].
fn url_source(url: Url, subdirectory: Option<String>) -> Result<RequirementSource, LoweringError> {
let mut verbatim_url = url.clone();
if verbatim_url.fragment().is_some() {
return Err(LoweringError::ForbiddenFragment(url));
}
if let Some(subdirectory) = &subdirectory {
verbatim_url.set_fragment(Some(subdirectory));
}
let Some(source) = source else {
let has_sources = !project_sources.is_empty() || !workspace.sources().is_empty();
// Support recursive editable inclusions.
if has_sources && requirement.version_or_url.is_none() && &requirement.name != project_name
{
let ext = DistExtension::from_path(url.path())
.map_err(|err| ParsedUrlError::MissingExtensionUrl(url.to_string(), err))?;
let verbatim_url = VerbatimUrl::from_url(verbatim_url);
Ok(RequirementSource::Url {
location: url,
subdirectory: subdirectory.map(PathBuf::from),
ext,
url: verbatim_url,
})
}
/// Convert a registry source into a [`RequirementSource`].
fn registry_source(
requirement: &pep508_rs::Requirement<VerbatimParsedUrl>,
index: String,
) -> Result<RequirementSource, LoweringError> {
match &requirement.version_or_url {
None => {
warn_user_once!(
"Missing version constraint (e.g., a lower bound) for `{}`",
requirement.name
);
}
return Ok(Requirement::from(requirement));
};
if preview.is_disabled() {
warn_user_once!("`uv.sources` is experimental and may change without warning");
}
let source = match source {
Source::Git {
git,
subdirectory,
rev,
tag,
branch,
} => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
let reference = match (rev, tag, branch) {
(None, None, None) => GitReference::DefaultBranch,
(Some(rev), None, None) => {
if rev.starts_with("refs/") {
GitReference::NamedRef(rev.clone())
} else if rev.len() == 40 {
GitReference::FullCommit(rev.clone())
} else {
GitReference::ShortCommit(rev.clone())
}
}
(None, Some(tag), None) => GitReference::Tag(tag),
(None, None, Some(branch)) => GitReference::Branch(branch),
_ => return Err(LoweringError::MoreThanOneGitRef),
};
// Create a PEP 508-compatible URL.
let mut url = Url::parse(&format!("git+{git}"))?;
if let Some(rev) = reference.as_str() {
url.set_path(&format!("{}@{}", url.path(), rev));
}
if let Some(subdirectory) = &subdirectory {
url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
}
let url = VerbatimUrl::from_url(url);
let repository = git.clone();
RequirementSource::Git {
url,
repository,
reference,
precise: None,
subdirectory: subdirectory.map(PathBuf::from),
}
}
Source::Url { url, subdirectory } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
let mut verbatim_url = url.clone();
if verbatim_url.fragment().is_some() {
return Err(LoweringError::ForbiddenFragment(url));
}
if let Some(subdirectory) = &subdirectory {
verbatim_url.set_fragment(Some(subdirectory));
}
let ext = DistExtension::from_path(url.path())
.map_err(|err| ParsedUrlError::MissingExtensionUrl(url.to_string(), err))?;
let verbatim_url = VerbatimUrl::from_url(verbatim_url);
RequirementSource::Url {
location: url,
subdirectory: subdirectory.map(PathBuf::from),
ext,
url: verbatim_url,
}
}
Source::Path { path, editable } => {
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
path_source(
path,
project_dir,
workspace.install_path(),
editable.unwrap_or(false),
)?
}
Source::Registry { index } => match requirement.version_or_url {
None => {
warn_user_once!(
"Missing version constraint (e.g., a lower bound) for `{}`",
requirement.name
);
RequirementSource::Registry {
specifier: VersionSpecifiers::empty(),
index: Some(index),
}
}
Some(VersionOrUrl::VersionSpecifier(version)) => RequirementSource::Registry {
specifier: version,
Ok(RequirementSource::Registry {
specifier: VersionSpecifiers::empty(),
index: Some(index),
},
Some(VersionOrUrl::Url(_)) => return Err(LoweringError::ConflictingUrls),
},
Source::Workspace {
workspace: is_workspace,
} => {
if !is_workspace {
return Err(LoweringError::WorkspaceFalse);
}
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
return Err(LoweringError::ConflictingUrls);
}
let member = workspace
.packages()
.get(&requirement.name)
.ok_or(LoweringError::UndeclaredWorkspacePackage)?
.clone();
// Say we have:
// ```
// root
// ├── main_workspace <- We want to the path from here ...
// │ ├── pyproject.toml
// │ └── uv.lock
// └──current_workspace
// └── packages
// └── current_package <- ... to here.
// └── pyproject.toml
// ```
// The path we need in the lockfile: `../current_workspace/packages/current_project`
// member root: `/root/current_workspace/packages/current_project`
// workspace install root: `/root/current_workspace`
// relative to workspace: `packages/current_project`
// workspace lock root: `../current_workspace`
// relative to main workspace: `../current_workspace/packages/current_project`
let relative_to_workspace = relative_to(member.root(), workspace.install_path())
.map_err(LoweringError::RelativeTo)?;
let relative_to_main_workspace = workspace.lock_path().join(relative_to_workspace);
let url = VerbatimUrl::parse_absolute_path(member.root())?
.with_given(relative_to_main_workspace.to_string_lossy());
RequirementSource::Directory {
install_path: member.root().clone(),
lock_path: relative_to_main_workspace,
url,
editable: true,
}
})
}
Source::CatchAll { .. } => {
// Emit a dedicated error message, which is an improvement over Serde's default error.
return Err(LoweringError::InvalidEntry);
}
};
Ok(Requirement {
name: requirement.name,
extras: requirement.extras,
marker: requirement.marker,
source,
origin: requirement.origin,
})
Some(VersionOrUrl::VersionSpecifier(version)) => Ok(RequirementSource::Registry {
specifier: version.clone(),
index: Some(index),
}),
Some(VersionOrUrl::Url(_)) => Err(LoweringError::ConflictingUrls),
}
}
/// Convert a path string to a file or directory source.

View file

@ -3,14 +3,16 @@ use std::path::Path;
use thiserror::Error;
use crate::metadata::lowering::LoweringError;
pub use crate::metadata::requires_dist::RequiresDist;
use pep440_rs::{Version, VersionSpecifiers};
use pypi_types::{HashDigest, Metadata23};
use uv_configuration::{PreviewMode, SourceStrategy};
use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_workspace::WorkspaceError;
pub use crate::metadata::lowering::LoweredRequirement;
use crate::metadata::lowering::LoweringError;
pub use crate::metadata::requires_dist::RequiresDist;
mod lowering;
mod requires_dist;

View file

@ -5,8 +5,7 @@ use uv_configuration::{PreviewMode, SourceStrategy};
use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES};
use uv_workspace::{DiscoveryOptions, ProjectWorkspace};
use crate::metadata::lowering::lower_requirement;
use crate::metadata::MetadataError;
use crate::metadata::{LoweredRequirement, MetadataError};
use crate::Metadata;
#[derive(Debug, Clone)]
@ -91,7 +90,7 @@ impl RequiresDist {
.cloned()
.map(|requirement| {
let requirement_name = requirement.name.clone();
lower_requirement(
LoweredRequirement::from_requirement(
requirement,
&metadata.name,
project_workspace.project_root(),
@ -99,6 +98,7 @@ impl RequiresDist {
project_workspace.workspace(),
preview_mode,
)
.map(LoweredRequirement::into_inner)
.map_err(|err| MetadataError::LoweringError(requirement_name.clone(), err))
})
.collect::<Result<Vec<_>, _>>()?;
@ -114,7 +114,7 @@ impl RequiresDist {
.into_iter()
.map(|requirement| {
let requirement_name = requirement.name.clone();
lower_requirement(
LoweredRequirement::from_requirement(
requirement,
&metadata.name,
project_workspace.project_root(),
@ -122,6 +122,7 @@ impl RequiresDist {
project_workspace.workspace(),
preview_mode,
)
.map(LoweredRequirement::into_inner)
.map_err(|err| MetadataError::LoweringError(requirement_name.clone(), err))
})
.collect::<Result<_, _>>()?;

View file

@ -11,6 +11,8 @@ workspace = true
pep440_rs = { workspace = true }
pep508_rs = { workspace = true }
pypi-types = { workspace = true }
uv-settings = { workspace = true }
uv-workspace = { workspace = true }
fs-err = { workspace = true, features = ["tokio"] }
memchr = { workspace = true }

View file

@ -1,21 +1,87 @@
use memchr::memmem::Finder;
use pypi_types::VerbatimParsedUrl;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use memchr::memmem::Finder;
use serde::Deserialize;
use thiserror::Error;
use pep508_rs::PackageName;
use pypi_types::VerbatimParsedUrl;
use uv_settings::{GlobalOptions, ResolverInstallerOptions};
use uv_workspace::pyproject::Source;
static FINDER: LazyLock<Finder> = LazyLock::new(|| Finder::new(b"# /// script"));
/// A PEP 723 script, including its [`Pep723Metadata`].
#[derive(Debug)]
pub struct Pep723Script {
pub path: PathBuf,
pub metadata: Pep723Metadata,
}
impl Pep723Script {
/// Read the PEP 723 `script` metadata from a Python file, if it exists.
///
/// See: <https://peps.python.org/pep-0723/>
pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> {
let metadata = Pep723Metadata::read(&file).await?;
Ok(metadata.map(|metadata| Self {
path: file.as_ref().to_path_buf(),
metadata,
}))
}
}
/// PEP 723 metadata as parsed from a `script` comment block.
///
/// See: <https://peps.python.org/pep-0723/>
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Pep723Metadata {
pub dependencies: Option<Vec<pep508_rs::Requirement<VerbatimParsedUrl>>>,
pub requires_python: Option<pep440_rs::VersionSpecifiers>,
pub tool: Option<Tool>,
}
impl Pep723Metadata {
/// Read the PEP 723 `script` metadata from a Python file, if it exists.
///
/// See: <https://peps.python.org/pep-0723/>
pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> {
let contents = match fs_err::tokio::read(file).await {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
// Extract the `script` tag.
let Some(contents) = extract_script_tag(&contents)? else {
return Ok(None);
};
// Parse the metadata.
let metadata = toml::from_str(&contents)?;
Ok(Some(metadata))
}
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct Tool {
pub uv: Option<ToolUv>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolUv {
#[serde(flatten)]
pub globals: GlobalOptions,
#[serde(flatten)]
pub top_level: ResolverInstallerOptions,
pub sources: Option<BTreeMap<PackageName, Source>>,
}
#[derive(Debug, Error)]

View file

@ -7,7 +7,7 @@ use uv_configuration::{ConfigSettings, IndexStrategy, KeyringProviderType, Targe
use uv_python::{PythonDownloads, PythonPreference, PythonVersion};
use uv_resolver::{AnnotationStyle, ExcludeNewer, PrereleaseMode, ResolutionMode};
use crate::{FilesystemOptions, PipOptions};
use crate::{FilesystemOptions, Options, PipOptions};
pub trait Combine {
/// Combine two values, preferring the values in `self`.
@ -37,6 +37,16 @@ impl Combine for Option<FilesystemOptions> {
}
}
impl Combine for Option<Options> {
/// Combine the options used in two [`Options`]s. Retains the root of `self`.
fn combine(self, other: Option<Options>) -> Option<Options> {
match (self, other) {
(Some(a), Some(b)) => Some(a.combine(b)),
(a, b) => a.or(b),
}
}
}
impl Combine for Option<PipOptions> {
fn combine(self, other: Option<PipOptions>) -> Option<PipOptions> {
match (self, other) {

View file

@ -154,6 +154,12 @@ impl FilesystemOptions {
}
}
impl From<Options> for FilesystemOptions {
fn from(options: Options) -> Self {
Self(options)
}
}
/// Returns the path to the user configuration directory.
///
/// This is similar to the `config_dir()` returned by the `dirs` crate, but it uses the

View file

@ -69,6 +69,17 @@ pub struct Options {
managed: serde::de::IgnoredAny,
}
impl Options {
/// Construct an [`Options`] with the given global and top-level settings.
pub fn simple(globals: GlobalOptions, top_level: ResolverInstallerOptions) -> Self {
Self {
globals,
top_level,
..Default::default()
}
}
}
/// Global settings, relevant to all invocations.
#[allow(dead_code)]
#[derive(Debug, Clone, Default, Deserialize, CombineOptions, OptionsMetadata)]

View file

@ -22,7 +22,7 @@ pub(crate) use project::add::add;
pub(crate) use project::init::init;
pub(crate) use project::lock::lock;
pub(crate) use project::remove::remove;
pub(crate) use project::run::run;
pub(crate) use project::run::{parse_script, run};
pub(crate) use project::sync::sync;
pub(crate) use project::tree::tree;
pub(crate) use python::dir::dir as python_dir;

View file

@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt::Write;
use std::path::{Path, PathBuf};
@ -9,11 +10,11 @@ use owo_colors::OwoColorize;
use tokio::process::Command;
use tracing::debug;
use pypi_types::Requirement;
use uv_cache::Cache;
use uv_cli::ExternalCommand;
use uv_client::{BaseClientBuilder, Connectivity};
use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode};
use uv_distribution::LoweredRequirement;
use uv_fs::{PythonExt, Simplified, CWD};
use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::PackageName;
@ -22,6 +23,7 @@ use uv_python::{
PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, VersionRequest,
};
use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_scripts::Pep723Script;
use uv_warnings::warn_user_once;
use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceError};
@ -39,6 +41,7 @@ use crate::settings::ResolverInstallerSettings;
/// Run a command.
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) async fn run(
script: Option<Pep723Script>,
command: ExternalCommand,
requirements: Vec<RequirementsSource>,
show_resolution: bool,
@ -87,7 +90,7 @@ pub(crate) async fn run(
}
// Parse the input command.
let command = RunCommand::from(command);
let command = RunCommand::from(&command);
// Initialize any shared state.
let state = SharedState::default();
@ -97,88 +100,106 @@ pub(crate) async fn run(
// Determine whether the command to execute is a PEP 723 script.
let temp_dir;
let script_interpreter = if let RunCommand::Python(target, _) = &command {
if let Some(metadata) = uv_scripts::read_pep723_metadata(&target).await? {
writeln!(
printer.stderr(),
"Reading inline script metadata from: {}",
target.user_display().cyan()
let script_interpreter = if let Some(script) = script {
writeln!(
printer.stderr(),
"Reading inline script metadata from: {}",
script.path.user_display().cyan()
)?;
// (1) Explicit request from user
let python_request = if let Some(request) = python.as_deref() {
Some(PythonRequest::parse(request))
// (2) Request from `.python-version`
} else if let Some(request) = request_from_version_file(&CWD).await? {
Some(request)
// (3) `Requires-Python` in `pyproject.toml`
} else {
script.metadata.requires_python.map(|requires_python| {
PythonRequest::Version(VersionRequest::Range(requires_python))
})
};
let client_builder = BaseClientBuilder::new()
.connectivity(connectivity)
.native_tls(native_tls);
let interpreter = PythonInstallation::find_or_download(
python_request,
EnvironmentPreference::Any,
python_preference,
python_downloads,
&client_builder,
cache,
Some(&download_reporter),
)
.await?
.into_interpreter();
// Install the script requirements, if necessary. Otherwise, use an isolated environment.
if let Some(dependencies) = script.metadata.dependencies {
// // Collect any `tool.uv.sources` from the script.
let empty = BTreeMap::default();
let script_sources = script
.metadata
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.sources.as_ref())
.unwrap_or(&empty);
let script_dir = script.path.parent().expect("script path has no parent");
let requirements = dependencies
.into_iter()
.map(|requirement| {
LoweredRequirement::from_non_workspace_requirement(
requirement,
script_dir,
script_sources,
preview,
)
.map(LoweredRequirement::into_inner)
})
.collect::<Result<_, _>>()?;
let spec = RequirementsSpecification::from_requirements(requirements);
let environment = CachedEnvironment::get_or_create(
spec,
interpreter,
&settings,
&state,
if show_resolution {
Box::new(DefaultResolveLogger)
} else {
Box::new(SummaryResolveLogger)
},
if show_resolution {
Box::new(DefaultInstallLogger)
} else {
Box::new(SummaryInstallLogger)
},
preview,
connectivity,
concurrency,
native_tls,
cache,
printer,
)
.await?;
Some(environment.into_interpreter())
} else {
// Create a virtual environment.
temp_dir = cache.environment()?;
let environment = uv_virtualenv::create_venv(
temp_dir.path(),
interpreter,
uv_virtualenv::Prompt::None,
false,
false,
false,
)?;
// (1) Explicit request from user
let python_request = if let Some(request) = python.as_deref() {
Some(PythonRequest::parse(request))
// (2) Request from `.python-version`
} else if let Some(request) = request_from_version_file(&CWD).await? {
Some(request)
// (3) `Requires-Python` in `pyproject.toml`
} else {
metadata.requires_python.map(|requires_python| {
PythonRequest::Version(VersionRequest::Range(requires_python))
})
};
let client_builder = BaseClientBuilder::new()
.connectivity(connectivity)
.native_tls(native_tls);
let interpreter = PythonInstallation::find_or_download(
python_request,
EnvironmentPreference::Any,
python_preference,
python_downloads,
&client_builder,
cache,
Some(&download_reporter),
)
.await?
.into_interpreter();
// Install the script requirements, if necessary. Otherwise, use an isolated environment.
if let Some(dependencies) = metadata.dependencies {
let requirements = dependencies.into_iter().map(Requirement::from).collect();
let spec = RequirementsSpecification::from_requirements(requirements);
let environment = CachedEnvironment::get_or_create(
spec,
interpreter,
&settings,
&state,
if show_resolution {
Box::new(DefaultResolveLogger)
} else {
Box::new(SummaryResolveLogger)
},
if show_resolution {
Box::new(DefaultInstallLogger)
} else {
Box::new(SummaryInstallLogger)
},
preview,
connectivity,
concurrency,
native_tls,
cache,
printer,
)
.await?;
Some(environment.into_interpreter())
} else {
// Create a virtual environment.
temp_dir = cache.environment()?;
let environment = uv_virtualenv::create_venv(
temp_dir.path(),
interpreter,
uv_virtualenv::Prompt::None,
false,
false,
false,
)?;
Some(environment.into_interpreter())
}
} else {
None
Some(environment.into_interpreter())
}
} else {
None
@ -590,6 +611,19 @@ pub(crate) async fn run(
}
}
/// Read a [`Pep723Script`] from the given command.
pub(crate) async fn parse_script(command: &ExternalCommand) -> Result<Option<Pep723Script>> {
// Parse the input command.
let command = RunCommand::from(command);
let RunCommand::Python(target, _) = &command else {
return Ok(None);
};
// Read the PEP 723 `script` metadata from the target script.
Ok(Pep723Script::read(&target).await?)
}
/// Returns `true` if we can skip creating an additional ephemeral environment in `uv run`.
fn can_skip_ephemeral(
spec: Option<&RequirementsSpecification>,
@ -682,8 +716,8 @@ impl std::fmt::Display for RunCommand {
}
}
impl From<ExternalCommand> for RunCommand {
fn from(command: ExternalCommand) -> Self {
impl From<&ExternalCommand> for RunCommand {
fn from(command: &ExternalCommand) -> Self {
let (target, args) = command.split();
let Some(target) = target else {

View file

@ -23,11 +23,12 @@ use uv_cli::{SelfCommand, SelfNamespace};
use uv_configuration::Concurrency;
use uv_fs::CWD;
use uv_requirements::RequirementsSource;
use uv_settings::{Combine, FilesystemOptions};
use uv_scripts::Pep723Script;
use uv_settings::{Combine, FilesystemOptions, Options};
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::{DiscoveryOptions, Workspace};
use crate::commands::{ExitStatus, ToolRunCommand};
use crate::commands::{parse_script, ExitStatus, ToolRunCommand};
use crate::printer::Printer;
use crate::settings::{
CacheSettings, GlobalSettings, PipCheckSettings, PipCompileSettings, PipFreezeSettings,
@ -130,6 +131,27 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
project.combine(user)
};
// If the target is a PEP 723 script, parse it.
let script = if let Commands::Project(command) = &*cli.command {
if let ProjectCommand::Run(uv_cli::RunArgs { command, .. }) = &**command {
parse_script(command).await?
} else {
None
}
} else {
None
};
// If the target is a PEP 723 script, merge the metadata into the filesystem metadata.
let filesystem = script
.as_ref()
.map(|script| &script.metadata)
.and_then(|metadata| metadata.tool.as_ref())
.and_then(|tool| tool.uv.as_ref())
.map(|uv| Options::simple(uv.globals.clone(), uv.top_level.clone()))
.map(FilesystemOptions::from)
.combine(filesystem);
// Resolve the global settings.
let globals = GlobalSettings::resolve(&cli.command, &cli.global_args, filesystem.as_ref());
@ -682,7 +704,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
.await
}
Commands::Project(project) => {
run_project(project, globals, filesystem, cache, printer).await
run_project(project, script, globals, filesystem, cache, printer).await
}
#[cfg(feature = "self-update")]
Commands::Self_(SelfNamespace {
@ -957,6 +979,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
/// Run a [`ProjectCommand`].
async fn run_project(
project_command: Box<ProjectCommand>,
script: Option<Pep723Script>,
globals: GlobalSettings,
filesystem: Option<FilesystemOptions>,
cache: Cache,
@ -1026,7 +1049,8 @@ async fn run_project(
)
.collect::<Vec<_>>();
commands::run(
Box::pin(commands::run(
script,
args.command,
requirements,
args.show_resolution || globals.verbose > 0,
@ -1047,7 +1071,7 @@ async fn run_project(
globals.native_tls,
&cache,
printer,
)
))
.await
}
ProjectCommand::Sync(args) => {

View file

@ -335,6 +335,76 @@ fn run_pep723_script() -> Result<()> {
Ok(())
}
/// Run a PEP 723-compatible script with `tool.uv` metadata.
#[test]
fn run_pep723_script_metadata() -> Result<()> {
let context = TestContext::new("3.12");
// If the script contains a PEP 723 tag, we should install its requirements.
let test_script = context.temp_dir.child("main.py");
test_script.write_str(indoc! { r#"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "iniconfig>1",
# ]
#
# [tool.uv]
# resolution = "lowest-direct"
# ///
import iniconfig
"#
})?;
// Running the script should fail without network access.
uv_snapshot!(context.filters(), context.run().arg("--preview").arg("main.py"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Reading inline script metadata from: main.py
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==1.0.1
"###);
// Respect `tool.uv.sources`.
let test_script = context.temp_dir.child("main.py");
test_script.write_str(indoc! { r#"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "uv-public-pypackage",
# ]
#
# [tool.uv.sources]
# uv-public-pypackage = { git = "https://github.com/astral-test/uv-public-pypackage", rev = "0dacfd662c64cb4ceb16e6cf65a157a8b715b979" }
# ///
import uv_public_pypackage
"#
})?;
// The script should succeed with the specified source.
uv_snapshot!(context.filters(), context.run().arg("--preview").arg("main.py"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Reading inline script metadata from: main.py
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@0dacfd662c64cb4ceb16e6cf65a157a8b715b979)
"###);
Ok(())
}
/// With `managed = false`, we should avoid installing the project itself.
#[test]
fn run_managed_false() -> Result<()> {