diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 8f8cb7185bc8..a0d040aad585 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -25,7 +25,7 @@ use uv_python::{ }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_scripts::Pep723Script; -use uv_warnings::warn_user_once; +use uv_warnings::warn_user; use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceError}; use crate::commands::pip::loggers::{ @@ -111,11 +111,15 @@ pub(crate) async fn run( .and_then(PythonVersionFile::into_version) { Some(request) - // (3) `Requires-Python` in `pyproject.toml` + // (3) `Requires-Python` in the script } else { - script.metadata.requires_python.map(|requires_python| { - PythonRequest::Version(VersionRequest::Range(requires_python)) - }) + script + .metadata + .requires_python + .as_ref() + .map(|requires_python| { + PythonRequest::Version(VersionRequest::Range(requires_python.clone())) + }) }; let client_builder = BaseClientBuilder::new() @@ -134,6 +138,16 @@ pub(crate) async fn run( .await? .into_interpreter(); + if let Some(requires_python) = script.metadata.requires_python.as_ref() { + if !requires_python.contains(interpreter.python_version()) { + warn_user!( + "Python {} does not satisfy the script's `requires-python` specifier: `{}`", + interpreter.python_version(), + requires_python + ); + } + } + // Install the script requirements, if necessary. Otherwise, use an isolated environment. if let Some(dependencies) = script.metadata.dependencies { // // Collect any `tool.uv.sources` from the script. @@ -224,28 +238,28 @@ pub(crate) async fn run( ); } if !extras.is_empty() { - warn_user_once!("Extras are not supported for Python scripts with inline metadata"); + warn_user!("Extras are not supported for Python scripts with inline metadata"); } if !dev { - warn_user_once!("`--no-dev` is not supported for Python scripts with inline metadata"); + warn_user!("`--no-dev` is not supported for Python scripts with inline metadata"); } if package.is_some() { - warn_user_once!( + warn_user!( "`--package` is a no-op for Python scripts with inline metadata, which always run in isolation" ); } if locked { - warn_user_once!( + warn_user!( "`--locked` is a no-op for Python scripts with inline metadata, which always run in isolation" ); } if frozen { - warn_user_once!( + warn_user!( "`--frozen` is a no-op for Python scripts with inline metadata, which always run in isolation" ); } if isolated { - warn_user_once!( + warn_user!( "`--isolated` is a no-op for Python scripts with inline metadata, which always run in isolation" ); } @@ -293,30 +307,30 @@ pub(crate) async fn run( if no_project { // If the user ran with `--no-project` and provided a project-only setting, warn. if !extras.is_empty() { - warn_user_once!("Extras have no effect when used alongside `--no-project`"); + warn_user!("Extras have no effect when used alongside `--no-project`"); } if !dev { - warn_user_once!("`--no-dev` has no effect when used alongside `--no-project`"); + warn_user!("`--no-dev` has no effect when used alongside `--no-project`"); } if locked { - warn_user_once!("`--locked` has no effect when used alongside `--no-project`"); + warn_user!("`--locked` has no effect when used alongside `--no-project`"); } if frozen { - warn_user_once!("`--frozen` has no effect when used alongside `--no-project`"); + warn_user!("`--frozen` has no effect when used alongside `--no-project`"); } } else if project.is_none() { // If we can't find a project and the user provided a project-only setting, warn. if !extras.is_empty() { - warn_user_once!("Extras have no effect when used outside of a project"); + warn_user!("Extras have no effect when used outside of a project"); } if !dev { - warn_user_once!("`--no-dev` has no effect when used outside of a project"); + warn_user!("`--no-dev` has no effect when used outside of a project"); } if locked { - warn_user_once!("`--locked` has no effect when used outside of a project"); + warn_user!("`--locked` has no effect when used outside of a project"); } if frozen { - warn_user_once!("`--frozen` has no effect when used outside of a project"); + warn_user!("`--frozen` has no effect when used outside of a project"); } } diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index ffa5261acfca..ee0d30f10a78 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -386,6 +386,68 @@ fn run_pep723_script() -> Result<()> { Ok(()) } +#[test] +fn run_pep723_script_requires_python() -> Result<()> { + let context = TestContext::new_with_versions(&["3.8", "3.11"]); + + // If we have a `.python-version` that's incompatible with the script, we should error. + let python_version = context.temp_dir.child(PYTHON_VERSION_FILENAME); + python_version.write_str("3.8")?; + + // If the script contains a PEP 723 tag, we should install its requirements. + let test_script = context.temp_dir.child("main.py"); + test_script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "iniconfig", + # ] + # /// + + import iniconfig + + x: str | int = "hello" + print(x) + "# + })?; + + uv_snapshot!(context.filters(), context.run().arg("main.py"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Reading inline script metadata from: main.py + warning: Python 3.8.[X] does not satisfy the script's `requires-python` specifier: `>=3.11` + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + Traceback (most recent call last): + File "main.py", line 10, in + x: str | int = "hello" + TypeError: unsupported operand type(s) for |: 'type' and 'type' + "###); + + // Delete the `.python-version` file to allow the script to run. + fs_err::remove_file(&python_version)?; + + uv_snapshot!(context.filters(), context.run().arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + hello + + ----- stderr ----- + Reading inline script metadata from: main.py + Resolved 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "###); + + Ok(()) +} + /// Run a `.pyw` script. The script should be executed with `pythonw.exe`. #[test] #[cfg(windows)]