diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index c9e92ded3157..b4b0a176a47b 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2472,7 +2472,7 @@ pub struct RunArgs { /// Run a Python module. /// /// Equivalent to `python -m `. - #[arg(short, long)] + #[arg(short, long, conflicts_with = "script")] pub module: bool, /// Omit non-development dependencies. @@ -2554,6 +2554,13 @@ pub struct RunArgs { #[arg(long, conflicts_with = "locked")] pub frozen: bool, + /// Run the given path as a Python script. + /// + /// Using `--script` will attempt to parse the path as a PEP 723 script, + /// irrespective of its extension. + #[arg(long, short, conflicts_with = "module")] + pub script: bool, + #[command(flatten)] pub installer: ResolverInstallerArgs, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index d6b0dadc039c..d6c94592c9d7 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1021,7 +1021,11 @@ impl std::fmt::Display for RunCommand { } impl RunCommand { - pub(crate) fn from_args(command: &ExternalCommand, module: bool) -> anyhow::Result { + pub(crate) fn from_args( + command: &ExternalCommand, + module: bool, + script: bool, + ) -> anyhow::Result { let (target, args) = command.split(); let Some(target) = target else { return Ok(Self::Empty); @@ -1029,6 +1033,8 @@ impl RunCommand { if module { return Ok(Self::PythonModule(target.clone(), args.to_vec())); + } else if script { + return Ok(Self::PythonScript(target.clone().into(), args.to_vec())); } let target_path = PathBuf::from(target); diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 7eb87fcae77c..4e236d470ebd 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -132,10 +132,13 @@ async fn run(cli: Cli) -> Result { // Parse the external command, if necessary. let run_command = if let Commands::Project(command) = &*cli.command { if let ProjectCommand::Run(uv_cli::RunArgs { - command, module, .. + command, + module, + script, + .. }) = &**command { - Some(RunCommand::from_args(command, *module)?) + Some(RunCommand::from_args(command, *module, *script)?) } else { None } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 55a372a45385..582e20787cc6 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -248,6 +248,7 @@ impl RunSettings { module: _, only_dev, no_editable, + script: _, command: _, with, with_editable, diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index 645e495927c9..dd9751e2a8a5 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -5,6 +5,7 @@ use anyhow::Result; use assert_cmd::assert::OutputAssertExt; use assert_fs::{fixture::ChildPath, prelude::*}; use indoc::indoc; +use predicates::str::contains; use std::path::Path; use uv_python::PYTHON_VERSION_FILENAME; @@ -2164,3 +2165,69 @@ fn run_script_without_build_system() -> Result<()> { Ok(()) } + +#[test] +fn run_script_explicit() -> Result<()> { + let context = TestContext::new("3.12"); + + let test_script = context.temp_dir.child("script"); + test_script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "iniconfig", + # ] + # /// + import iniconfig + print("Hello, world!") + "# + })?; + + uv_snapshot!(context.filters(), context.run().arg("--script").arg("script"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello, world! + + ----- stderr ----- + Reading inline script metadata from: script + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "###); + + Ok(()) +} + +#[test] +fn run_script_explicit_no_file() { + let context = TestContext::new("3.12"); + context + .run() + .arg("--script") + .arg("script") + .assert() + .stderr(contains("can't open file")) + .stderr(contains("[Errno 2] No such file or directory")); +} + +#[cfg(target_family = "unix")] +#[test] +fn run_script_explicit_directory() -> Result<()> { + let context = TestContext::new("3.12"); + + fs_err::create_dir(context.temp_dir.child("script"))?; + + uv_snapshot!(context.filters(), context.run().arg("--script").arg("script"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: failed to read from file `script` + Caused by: Is a directory (os error 21) + "###); + + Ok(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4b0c56f3071c..83292120a0fe 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -371,6 +371,10 @@ uv run [OPTIONS]
  • lowest-direct: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies
  • +
    --script, -s

    Run the given path as a Python script.

    + +

    Using --script will attempt to parse the path as a PEP 723 script, irrespective of its extension.

    +
    --upgrade, -U

    Allow package upgrades, ignoring pinned versions in any existing output file. Implies --refresh

    --upgrade-package, -P upgrade-package

    Allow upgrades for a specific package, ignoring pinned versions in any existing output file. Implies --refresh-package