From e46c24d3cf2425c45868d946d7e240f3c6489960 Mon Sep 17 00:00:00 2001 From: bluss Date: Mon, 29 Jul 2024 21:53:10 +0200 Subject: [PATCH] Implement `uv run --directory` (#5566) ## Summary uv run --directory means that one doesn't have to change to a project's directory to run programs from it. It makes it possible to use projects as if they are tool installations. To support this, first the code reading .python-version was updated so that it can read such markers outside the current directory. Note the minor change this causes (if I'm right), described in the commit. ## Test Plan One test has been added. ## --directory Not sure what the name of the argument should be, but it's following uv sync's directory for now. Other alternatives could be "--project". Uv run and uv tool run should probably find common agreement on this (relevant for project-locked tools). I've implemented this same change in Rye, some time ago, and then we went with --pyproject `<`path to pyproject.toml file`>`. I think using pyproject.toml file path and not directory was probably a mistake, an overgeneralization one doesn't need. --- crates/uv-cli/src/lib.rs | 4 ++ crates/uv-python/src/version_files.rs | 45 ++++++++++++----- crates/uv/src/commands/project/mod.rs | 4 +- crates/uv/src/commands/project/run.rs | 15 ++++-- crates/uv/src/commands/python/install.rs | 2 +- crates/uv/src/commands/python/pin.rs | 4 +- crates/uv/src/commands/venv.rs | 2 +- crates/uv/src/lib.rs | 1 + crates/uv/src/settings.rs | 3 ++ crates/uv/tests/run.rs | 61 ++++++++++++++++++++++++ 10 files changed, 119 insertions(+), 22 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 2aae67a06aea..42732d07233f 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1932,6 +1932,10 @@ pub struct RunArgs { /// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path. #[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)] pub python: Option, + + /// The path to the project. Defaults to the current working directory. + #[arg(long, hide = true)] + pub directory: Option, } #[derive(Args)] diff --git a/crates/uv-python/src/version_files.rs b/crates/uv-python/src/version_files.rs index 22cad953cd83..b3b36de54203 100644 --- a/crates/uv-python/src/version_files.rs +++ b/crates/uv-python/src/version_files.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use fs_err as fs; use tracing::debug; @@ -13,15 +15,17 @@ pub static PYTHON_VERSIONS_FILENAME: &str = ".python-versions"; /// /// Prefers `.python-versions` then `.python-version`. /// If only one Python version is desired, use [`request_from_version_files`] which prefers the `.python-version` file. -pub async fn requests_from_version_file() -> Result>, std::io::Error> { - if let Some(versions) = read_versions_file().await? { +pub async fn requests_from_version_file( + directory: Option<&Path>, +) -> Result>, std::io::Error> { + if let Some(versions) = read_versions_file(directory).await? { Ok(Some( versions .into_iter() .map(|version| PythonRequest::parse(&version)) .collect(), )) - } else if let Some(version) = read_version_file().await? { + } else if let Some(version) = read_version_file(directory).await? { Ok(Some(vec![PythonRequest::parse(&version)])) } else { Ok(None) @@ -30,12 +34,17 @@ pub async fn requests_from_version_file() -> Result>, /// Read a [`PythonRequest`] from a version file, if present. /// +/// Find the version file inside directory, or the current directory +/// if None. +/// /// Prefers `.python-version` then the first entry of `.python-versions`. /// If multiple Python versions are desired, use [`requests_from_version_files`] instead. -pub async fn request_from_version_file() -> Result, std::io::Error> { - if let Some(version) = read_version_file().await? { +pub async fn request_from_version_file( + directory: Option<&Path>, +) -> Result, std::io::Error> { + if let Some(version) = read_version_file(directory).await? { Ok(Some(PythonRequest::parse(&version))) - } else if let Some(versions) = read_versions_file().await? { + } else if let Some(versions) = read_versions_file(directory).await? { Ok(versions .into_iter() .next() @@ -52,10 +61,17 @@ pub async fn write_version_file(version: &str) -> Result<(), std::io::Error> { fs::tokio::write(PYTHON_VERSION_FILENAME, format!("{version}\n")).await } -async fn read_versions_file() -> Result>, std::io::Error> { - match fs::tokio::read_to_string(PYTHON_VERSIONS_FILENAME).await { +async fn read_versions_file( + directory: Option<&Path>, +) -> Result>, std::io::Error> { + let file_path = directory.map(|pth| pth.join(PYTHON_VERSIONS_FILENAME)); + let path = file_path + .as_deref() + .unwrap_or(Path::new(PYTHON_VERSIONS_FILENAME)); + + match fs::tokio::read_to_string(path).await { Ok(content) => { - debug!("Reading requests from `{PYTHON_VERSIONS_FILENAME}`"); + debug!("Reading requests from `{}`", path.display()); Ok(Some( content .lines() @@ -73,10 +89,15 @@ async fn read_versions_file() -> Result>, std::io::Error> { } } -async fn read_version_file() -> Result, std::io::Error> { - match fs::tokio::read_to_string(PYTHON_VERSION_FILENAME).await { +async fn read_version_file(directory: Option<&Path>) -> Result, std::io::Error> { + let file_path = directory.map(|pth| pth.join(PYTHON_VERSION_FILENAME)); + let path = file_path + .as_deref() + .unwrap_or(Path::new(PYTHON_VERSION_FILENAME)); + + match fs::tokio::read_to_string(path).await { Ok(content) => { - debug!("Reading requests from `{PYTHON_VERSION_FILENAME}`"); + debug!("Reading requests from `{}`", path.display()); Ok(content .lines() .find(|line| { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 8ba42c0f6709..57cf674888f9 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -161,7 +161,9 @@ impl FoundInterpreter { let python_request = if let Some(request) = python_request { Some(request) // (2) Request from `.python-version` - } else if let Some(request) = request_from_version_file().await? { + } else if let Some(request) = + request_from_version_file(Some(workspace.install_path())).await? + { Some(request) // (3) `Requires-Python` in `pyproject.toml` } else { diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 92b9bf4b811a..53ac62d44bf8 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -44,6 +44,7 @@ pub(crate) async fn run( extras: ExtrasSpecification, dev: bool, python: Option, + directory: Option, settings: ResolverInstallerSettings, isolated: bool, preview: PreviewMode, @@ -89,6 +90,12 @@ pub(crate) async fn run( let reporter = PythonDownloadReporter::single(printer); + let directory = if let Some(directory) = directory { + directory.simple_canonicalize()? + } else { + std::env::current_dir()? + }; + // Determine whether the command to execute is a PEP 723 script. let script_interpreter = if let RunCommand::Python(target, _) = &command { if let Some(metadata) = uv_scripts::read_pep723_metadata(&target).await? { @@ -102,7 +109,7 @@ pub(crate) async fn run( 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().await? { + } else if let Some(request) = request_from_version_file(Some(&directory)).await? { Some(request) // (3) `Requires-Python` in `pyproject.toml` } else { @@ -167,15 +174,13 @@ pub(crate) async fn run( // We need a workspace, but we don't need to have a current package, we can be e.g. in // the root of a virtual workspace and then switch into the selected package. Some(VirtualProject::Project( - Workspace::discover(&std::env::current_dir()?, &DiscoveryOptions::default()) + Workspace::discover(&directory, &DiscoveryOptions::default()) .await? .with_current_project(package.clone()) .with_context(|| format!("Package `{package}` not found in workspace"))?, )) } else { - match VirtualProject::discover(&std::env::current_dir()?, &DiscoveryOptions::default()) - .await - { + match VirtualProject::discover(&directory, &DiscoveryOptions::default()).await { Ok(project) => Some(project), Err(WorkspaceError::MissingPyprojectToml) => None, Err(WorkspaceError::NonWorkspace(_)) => None, diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index b258314f2ed1..ae7bb3d46d40 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -55,7 +55,7 @@ pub(crate) async fn install( } None } else { - requests_from_version_file().await? + requests_from_version_file(None).await? }; version_file_requests.unwrap_or_else(|| vec![PythonRequest::Any]) } else { diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index 8f756a13a801..23ca8dada3f0 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -49,7 +49,7 @@ pub(crate) async fn pin( let Some(request) = request else { // Display the current pinned Python version - if let Some(pins) = requests_from_version_file().await? { + if let Some(pins) = requests_from_version_file(None).await? { for pin in pins { writeln!(printer.stdout(), "{}", pin.to_canonical_string())?; if let Some(virtual_project) = &virtual_project { @@ -126,7 +126,7 @@ pub(crate) async fn pin( request.to_canonical_string() }; - let existing = request_from_version_file().await.ok().flatten(); + let existing = request_from_version_file(None).await.ok().flatten(); write_version_file(&output).await?; if let Some(existing) = existing diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index bc9ba04963c8..6ebab8b3adfe 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -140,7 +140,7 @@ async fn venv_impl( let mut interpreter_request = python_request.map(PythonRequest::parse); if preview.is_enabled() && interpreter_request.is_none() { - interpreter_request = request_from_version_file().await.into_diagnostic()?; + interpreter_request = request_from_version_file(None).await.into_diagnostic()?; } if preview.is_disabled() && relocatable { warn_user_once!("`--relocatable` is experimental and may change without warning"); diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index bd1e0eb1d524..4620bd33e0b6 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -916,6 +916,7 @@ async fn run_project( args.extras, args.dev, args.python, + args.directory, args.settings, globals.isolated, globals.preview, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 4861af55fcba..9c3dedd54945 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -193,6 +193,7 @@ pub(crate) struct RunSettings { pub(crate) with_requirements: Vec, pub(crate) package: Option, pub(crate) python: Option, + pub(crate) directory: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, } @@ -217,6 +218,7 @@ impl RunSettings { refresh, package, python, + directory, } = args; Self { @@ -235,6 +237,7 @@ impl RunSettings { .collect(), package, python, + directory, refresh: Refresh::from(refresh), settings: ResolverInstallerSettings::combine( resolver_installer_options(installer, build), diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index a5bf5e149dac..0c11785a18a1 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -5,6 +5,8 @@ use assert_cmd::assert::OutputAssertExt; use assert_fs::{fixture::ChildPath, prelude::*}; use indoc::indoc; +use uv_python::PYTHON_VERSION_FILENAME; + use common::{uv_snapshot, TestContext}; mod common; @@ -823,3 +825,62 @@ fn run_editable() -> Result<()> { Ok(()) } + +#[test] +fn run_from_directory() -> Result<()> { + // default 3.11 so that the .python-version is meaningful + let context = TestContext::new_with_versions(&["3.11", "3.12"]); + + let project_dir = context.temp_dir.child("project"); + project_dir.create_dir_all()?; + project_dir + .child(PYTHON_VERSION_FILENAME) + .write_str("3.12")?; + + let pyproject_toml = project_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.11, <4" + dependencies = [] + + [project.scripts] + main = "main:main" + "# + })?; + let main_script = project_dir.child("main.py"); + main_script.write_str(indoc! { r" + import platform + + def main(): + print(platform.python_version()) + " + })?; + + let mut command = context.run(); + let command_with_args = command + .arg("--preview") + .arg("--directory") + .arg("project") + .arg("main"); + + let mut filters = context.filters(); + filters.push((r"project(\\|/).venv", "[VENV]")); + uv_snapshot!(filters, command_with_args, @r###" + success: true + exit_code: 0 + ----- stdout ----- + 3.12.[X] + + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtualenv at: [VENV] + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + foo==1.0.0 (from file://[TEMP_DIR]/project) + "###); + + Ok(()) +}