Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for locking PEP 723 scripts #10135

Merged
merged 1 commit into from
Jan 8, 2025
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
7 changes: 7 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3108,6 +3108,13 @@ pub struct LockArgs {
#[arg(long, conflicts_with = "check_exists", conflicts_with = "check")]
pub dry_run: bool,

/// Lock the specified Python script, rather than the current project.
///
/// If provided, uv will lock the script (based on its inline metadata table, in adherence with
/// PEP 723) to a `.lock` file adjacent to the script itself.
#[arg(long)]
pub script: Option<PathBuf>,

#[command(flatten)]
pub resolver: ResolverArgs,

Expand Down
63 changes: 44 additions & 19 deletions crates/uv/src/commands/project/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,15 @@ use uv_resolver::{
FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, RequiresPython,
ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker,
};
use uv_scripts::{Pep723ItemRef, Pep723Script};
use uv_settings::PythonInstallMirrors;
use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy};
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceMember};

use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger};
use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::{ProjectError, ProjectInterpreter};
use crate::commands::project::{ProjectError, ProjectInterpreter, ScriptInterpreter};
use crate::commands::reporters::ResolverReporter;
use crate::commands::{diagnostics, pip, ExitStatus};
use crate::printer::Printer;
Expand Down Expand Up @@ -80,6 +81,7 @@ pub(crate) async fn lock(
python: Option<String>,
install_mirrors: PythonInstallMirrors,
settings: ResolverSettings,
script: Option<Pep723Script>,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
connectivity: Connectivity,
Expand All @@ -92,29 +94,52 @@ pub(crate) async fn lock(
preview: PreviewMode,
) -> anyhow::Result<ExitStatus> {
// Find the project requirements.
let workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?;
let workspace;
let target = if let Some(script) = script.as_ref() {
LockTarget::Script(script)
} else {
workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?;
LockTarget::Workspace(&workspace)
};

// Determine the lock mode.
let interpreter;
let mode = if frozen {
LockMode::Frozen
} else {
interpreter = ProjectInterpreter::discover(
&workspace,
project_dir,
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
connectivity,
native_tls,
allow_insecure_host,
&install_mirrors,
no_config,
cache,
printer,
)
.await?
.into_interpreter();
interpreter = match target {
LockTarget::Workspace(workspace) => ProjectInterpreter::discover(
workspace,
project_dir,
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
connectivity,
native_tls,
allow_insecure_host,
&install_mirrors,
no_config,
cache,
printer,
)
.await?
.into_interpreter(),
LockTarget::Script(script) => ScriptInterpreter::discover(
Pep723ItemRef::Script(script),
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
connectivity,
native_tls,
allow_insecure_host,
&install_mirrors,
no_config,
cache,
printer,
)
.await?
.into_interpreter(),
};

if locked {
LockMode::Locked(&interpreter)
Expand All @@ -131,7 +156,7 @@ pub(crate) async fn lock(
// Perform the lock operation.
match do_safe_lock(
mode,
(&workspace).into(),
target,
settings.as_ref(),
LowerBound::Warn,
&state,
Expand Down
114 changes: 112 additions & 2 deletions crates/uv/src/commands/project/lock_target.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use itertools::Either;

use uv_configuration::{LowerBound, SourceStrategy};
use uv_distribution::LoweredRequirement;
use uv_distribution_types::IndexLocations;
use uv_normalize::{GroupName, PackageName};
use uv_pep508::RequirementOrigin;
use uv_pypi_types::{Conflicts, Requirement, SupportedEnvironments, VerbatimParsedUrl};
use uv_resolver::{Lock, LockVersion, RequiresPython, VERSION};
use uv_scripts::Pep723Script;
use uv_workspace::dependency_groups::DependencyGroupError;
use uv_workspace::{Workspace, WorkspaceMember};

Expand All @@ -16,6 +20,7 @@ use crate::commands::project::{find_requires_python, ProjectError};
#[derive(Debug, Copy, Clone)]
pub(crate) enum LockTarget<'lock> {
Workspace(&'lock Workspace),
Script(&'lock Pep723Script),
}

impl<'lock> From<&'lock Workspace> for LockTarget<'lock> {
Expand All @@ -24,26 +29,53 @@ impl<'lock> From<&'lock Workspace> for LockTarget<'lock> {
}
}

impl<'lock> From<&'lock Pep723Script> for LockTarget<'lock> {
fn from(script: &'lock Pep723Script) -> Self {
LockTarget::Script(script)
}
}

impl<'lock> LockTarget<'lock> {
/// Return the set of requirements that are attached to the target directly, as opposed to being
/// attached to any members within the target.
pub(crate) fn requirements(self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
match self {
Self::Workspace(workspace) => workspace.requirements(),
Self::Script(script) => script.metadata.dependencies.clone().unwrap_or_default(),
}
}

/// Returns the set of overrides for the [`LockTarget`].
pub(crate) fn overrides(self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
match self {
Self::Workspace(workspace) => workspace.overrides(),
Self::Script(script) => script
.metadata
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.override_dependencies.as_ref())
.into_iter()
.flatten()
.cloned()
.collect(),
}
}

/// Returns the set of constraints for the [`LockTarget`].
pub(crate) fn constraints(self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
match self {
Self::Workspace(workspace) => workspace.constraints(),
Self::Script(script) => script
.metadata
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.constraint_dependencies.as_ref())
.into_iter()
.flatten()
.cloned()
.collect(),
}
}

Expand All @@ -57,20 +89,23 @@ impl<'lock> LockTarget<'lock> {
> {
match self {
Self::Workspace(workspace) => workspace.dependency_groups(),
Self::Script(_) => Ok(BTreeMap::new()),
}
}

/// Returns the set of all members within the target.
pub(crate) fn members_requirements(self) -> impl Iterator<Item = Requirement> + 'lock {
match self {
Self::Workspace(workspace) => workspace.members_requirements(),
Self::Workspace(workspace) => Either::Left(workspace.members_requirements()),
Self::Script(_) => Either::Right(std::iter::empty()),
}
}

/// Returns the set of all dependency groups within the target.
pub(crate) fn group_requirements(self) -> impl Iterator<Item = Requirement> + 'lock {
match self {
Self::Workspace(workspace) => workspace.group_requirements(),
Self::Workspace(workspace) => Either::Left(workspace.group_requirements()),
Self::Script(_) => Either::Right(std::iter::empty()),
}
}

Expand All @@ -90,48 +125,74 @@ impl<'lock> LockTarget<'lock> {

members
}
Self::Script(_) => Vec::new(),
}
}

/// Return the list of packages.
pub(crate) fn packages(self) -> &'lock BTreeMap<PackageName, WorkspaceMember> {
match self {
Self::Workspace(workspace) => workspace.packages(),
Self::Script(_) => {
static EMPTY: BTreeMap<PackageName, WorkspaceMember> = BTreeMap::new();
&EMPTY
}
}
}

/// Returns the set of supported environments for the [`LockTarget`].
pub(crate) fn environments(self) -> Option<&'lock SupportedEnvironments> {
match self {
Self::Workspace(workspace) => workspace.environments(),
Self::Script(_) => {
// TODO(charlie): Add support for environments in scripts.
None
}
}
}

/// Returns the set of conflicts for the [`LockTarget`].
pub(crate) fn conflicts(self) -> Conflicts {
match self {
Self::Workspace(workspace) => workspace.conflicts(),
Self::Script(_) => Conflicts::empty(),
}
}

/// Return the `Requires-Python` bound for the [`LockTarget`].
pub(crate) fn requires_python(self) -> Option<RequiresPython> {
match self {
Self::Workspace(workspace) => find_requires_python(workspace),
Self::Script(script) => script
.metadata
.requires_python
.as_ref()
.map(RequiresPython::from_specifiers),
}
}

/// Return the path to the lock root.
pub(crate) fn install_path(self) -> &'lock Path {
match self {
Self::Workspace(workspace) => workspace.install_path(),
Self::Script(script) => script.path.parent().unwrap(),
}
}

/// Return the path to the lockfile.
pub(crate) fn lock_path(self) -> PathBuf {
match self {
// `uv.lock`
Self::Workspace(workspace) => workspace.install_path().join("uv.lock"),
// `script.py.lock`
Self::Script(script) => {
let mut file_name = match script.path.file_name() {
Some(f) => f.to_os_string(),
None => panic!("Script path has no file name"),
};
file_name.push(".lock");
script.path.with_file_name(file_name)
}
}
}

Expand Down Expand Up @@ -223,6 +284,55 @@ impl<'lock> LockTarget<'lock> {
.map(|requirement| requirement.with_origin(RequirementOrigin::Workspace))
.collect::<Vec<_>>())
}
Self::Script(script) => {
// Collect any `tool.uv.index` from the script.
let empty = Vec::default();
let indexes = match sources {
SourceStrategy::Enabled => script
.metadata
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.top_level.index.as_deref())
.unwrap_or(&empty),
SourceStrategy::Disabled => &empty,
};

// Collect any `tool.uv.sources` from the script.
let empty = BTreeMap::default();
let sources = match sources {
SourceStrategy::Enabled => script
.metadata
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.sources.as_ref())
.unwrap_or(&empty),
SourceStrategy::Disabled => &empty,
};

Ok(requirements
.into_iter()
.flat_map(|requirement| {
let requirement_name = requirement.name.clone();
LoweredRequirement::from_non_workspace_requirement(
requirement,
script.path.parent().unwrap(),
sources,
indexes,
locations,
LowerBound::Allow,
)
.map(move |requirement| match requirement {
Ok(requirement) => Ok(requirement.into_inner()),
Err(err) => Err(uv_distribution::MetadataError::LoweringError(
requirement_name.clone(),
Box::new(err),
)),
})
})
.collect::<Result<_, _>>()?)
}
}
}
}
18 changes: 16 additions & 2 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
script: Some(script),
..
}) = &**command
{
Pep723Script::read(&script).await?.map(Pep723Item::Script)
} else if let ProjectCommand::Lock(uv_cli::LockArgs {
script: Some(script),
..
}) = &**command
{
Pep723Script::read(&script).await?.map(Pep723Item::Script)
} else {
Expand Down Expand Up @@ -1508,14 +1514,22 @@ async fn run_project(
.combine(Refresh::from(args.settings.upgrade.clone())),
);

commands::lock(
// Unwrap the script.
let script = script.map(|script| match script {
Pep723Item::Script(script) => script,
Pep723Item::Stdin(_) => unreachable!("`uv lock` does not support stdin"),
Pep723Item::Remote(_) => unreachable!("`uv lock` does not support remote files"),
});

Box::pin(commands::lock(
project_dir,
args.locked,
args.frozen,
args.dry_run,
args.python,
args.install_mirrors,
args.settings,
script,
globals.python_preference,
globals.python_downloads,
globals.connectivity,
Expand All @@ -1526,7 +1540,7 @@ async fn run_project(
&cache,
printer,
globals.preview,
)
))
.await
}
ProjectCommand::Add(args) => {
Expand Down
Loading
Loading