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/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index a8713dfc8764..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(None).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/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..a347d5cc73b6 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,61 @@ 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()) + "# + })?; + + // Our tests change files in <1s, so we must disable CPython bytecode caching with `-B` or we'll + // get stale files, see https://github.com/python/cpython/issues/75953. + let mut command = context.run(); + let command_with_args = command + .arg("--preview") + .arg("--directory") + .arg("project") + .arg("main"); + uv_snapshot!(context.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: project/.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(()) +}