Skip to content

Commit

Permalink
Add support for locking and installing scripts
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Dec 25, 2024
1 parent f872b56 commit a9c8dcb
Show file tree
Hide file tree
Showing 6 changed files with 186 additions and 23 deletions.
7 changes: 7 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3089,6 +3089,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.
#[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 @@ -31,14 +31,15 @@ use uv_resolver::{
FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, RequiresPython,
ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker,
};
use uv_scripts::{Pep723Item, 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 @@ -79,6 +80,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 @@ -91,29 +93,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(
&Pep723Item::Script(script.clone()),
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 @@ -130,7 +155,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 @@ -183,6 +183,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 @@ -1483,14 +1489,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 @@ -1501,7 +1515,7 @@ async fn run_project(
&cache,
printer,
globals.preview,
)
))
.await
}
ProjectCommand::Add(args) => {
Expand Down
Loading

0 comments on commit a9c8dcb

Please sign in to comment.