diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index c61eec1867e6..d993fdc34660 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -154,17 +154,23 @@ pub(crate) enum FoundInterpreter { Environment(PythonEnvironment), } -impl FoundInterpreter { - /// Discover the interpreter to use in the current [`Workspace`]. - pub(crate) async fn discover( - workspace: &Workspace, +/// The resolved Python request and requirement for a [`Workspace`]. +#[derive(Debug, Clone)] +pub(crate) struct WorkspacePython { + /// The resolved Python request, computed by considering (1) any explicit request from the user + /// via `--python`, (2) any implicit request from the user via `.python-version`, and (3) any + /// `Requires-Python` specifier in the `pyproject.toml`. + python_request: Option, + /// The resolved Python requirement for the project, computed by taking the intersection of all + /// `Requires-Python` specifiers in the workspace. + requires_python: Option, +} + +impl WorkspacePython { + /// Determine the [`WorkspacePython`] for the current [`Workspace`]. + pub(crate) async fn from_request( python_request: Option, - python_preference: PythonPreference, - python_fetch: PythonFetch, - connectivity: Connectivity, - native_tls: bool, - cache: &Cache, - printer: Printer, + workspace: &Workspace, ) -> Result { let requires_python = find_requires_python(workspace)?; @@ -182,6 +188,31 @@ impl FoundInterpreter { .map(|specifiers| PythonRequest::Version(VersionRequest::Range(specifiers.clone()))) }; + Ok(Self { + python_request, + requires_python, + }) + } +} + +impl FoundInterpreter { + /// Discover the interpreter to use in the current [`Workspace`]. + pub(crate) async fn discover( + workspace: &Workspace, + python_request: Option, + python_preference: PythonPreference, + python_fetch: PythonFetch, + connectivity: Connectivity, + native_tls: bool, + cache: &Cache, + printer: Printer, + ) -> Result { + // Resolve the Python request and requirement for the workspace. + let WorkspacePython { + python_request, + requires_python, + } = WorkspacePython::from_request(python_request, workspace).await?; + // Read from the virtual environment first. match find_environment(workspace, cache) { Ok(venv) => { diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 54c2f4eff2fa..a35201dca984 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -27,7 +27,7 @@ use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceError}; use crate::commands::pip::operations::Modifications; use crate::commands::project::environment::CachedEnvironment; -use crate::commands::project::ProjectError; +use crate::commands::project::{ProjectError, WorkspacePython}; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{pip, project, ExitStatus, SharedState}; use crate::printer::Printer; @@ -206,9 +206,16 @@ pub(crate) async fn run( .connectivity(connectivity) .native_tls(native_tls); - // Note we force preview on during `uv run` for now since the entire interface is in preview - PythonInstallation::find_or_fetch( + // Resolve the Python request and requirement for the workspace. + let WorkspacePython { python_request, .. } = WorkspacePython::from_request( python.as_deref().map(PythonRequest::parse), + project.workspace(), + ) + .await?; + + // Note we force preview on during `uv run` for now since the entire interface is in preview. + PythonInstallation::find_or_fetch( + python_request, EnvironmentPreference::Any, python_preference, python_fetch, diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index 0405c5f45322..5395622fae5f 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -915,3 +915,102 @@ fn run_without_output() -> Result<()> { Ok(()) } + +/// Ensure that we can import from the root project when layering `--with` requirements. +#[test] +fn run_isolated_python_version() -> Result<()> { + let context = TestContext::new_with_versions(&["3.8", "3.12"]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.8" + dependencies = ["anyio"] + "# + })?; + + let src = context.temp_dir.child("src").child("foo"); + src.create_dir_all()?; + + let init = src.child("__init__.py"); + init.touch()?; + + let main = context.temp_dir.child("main.py"); + main.write_str(indoc! { r" + import sys + + print((sys.version_info.major, sys.version_info.minor)) + " + })?; + + uv_snapshot!(context.filters(), context.run().arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + (3, 8) + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning + Using Python 3.8.[X] interpreter at: [PYTHON-3.8] + Creating virtualenv at: .venv + Resolved 6 packages in [TIME] + Prepared 6 packages in [TIME] + Installed 6 packages in [TIME] + + anyio==4.3.0 + + exceptiongroup==1.2.0 + + foo==1.0.0 (from file://[TEMP_DIR]/) + + idna==3.6 + + sniffio==1.3.1 + + typing-extensions==4.10.0 + "###); + + uv_snapshot!(context.filters(), context.run().arg("--isolated").arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + (3, 8) + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning + Resolved 6 packages in [TIME] + Prepared 5 packages in [TIME] + Installed 6 packages in [TIME] + + anyio==4.3.0 + + exceptiongroup==1.2.0 + + foo==1.0.0 (from file://[TEMP_DIR]/) + + idna==3.6 + + sniffio==1.3.1 + + typing-extensions==4.10.0 + "###); + + // Set the `.python-version` to `3.12`. + context + .temp_dir + .child(PYTHON_VERSION_FILENAME) + .write_str("3.12")?; + + uv_snapshot!(context.filters(), context.run().arg("--isolated").arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + (3, 12) + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning + Resolved 6 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + foo==1.0.0 (from file://[TEMP_DIR]/) + + idna==3.6 + + sniffio==1.3.1 + "###); + + Ok(()) +}