diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 0602e3f4a..f0a37a04c 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2301,20 +2301,21 @@ impl ExternalCommand { #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct InitArgs { - /// The path to use for the project. + /// The path to use for the project/script. /// - /// Defaults to the current working directory. Accepts relative and absolute - /// paths. + /// Defaults to the current working directory when initializing an app or library; + /// required when initializing a script. Accepts relative and absolute paths. /// /// If a `pyproject.toml` is found in any of the parent directories of the /// target path, the project will be added as a workspace member of the /// parent, unless `--no-workspace` is provided. - pub path: Option, + #[arg(required_if_eq("script", "true"))] + pub path: Option, /// The name of the project. /// /// Defaults to the name of the directory. - #[arg(long)] + #[arg(long, conflicts_with = "script")] pub name: Option, /// Create a virtual project, rather than a package. @@ -2351,15 +2352,27 @@ pub struct InitArgs { /// By default, an application is not intended to be built and distributed as a Python package. /// The `--package` option can be used to create an application that is distributable, e.g., if /// you want to distribute a command-line interface via PyPI. - #[arg(long, alias = "application", conflicts_with = "lib")] + #[arg(long, alias = "application", conflicts_with_all = ["lib", "script"])] pub r#app: bool, /// Create a project for a library. /// /// A library is a project that is intended to be built and distributed as a Python package. - #[arg(long, alias = "library", conflicts_with = "app")] + #[arg(long, alias = "library", conflicts_with_all=["app", "script"])] pub r#lib: bool, + /// Create a script. + /// + /// A script is a standalone file with embedded metadata enumerating its dependencies, along + /// with any Python version requirements, as defined in the PEP 723 specification. + /// + /// PEP 723 scripts can be executed directly with `uv run`. + /// + /// By default, adds a requirement on the system Python version; use `--python` to specify an + /// alternative Python version requirement. + #[arg(long, alias="script", conflicts_with_all=["app", "lib", "package"])] + pub r#script: bool, + /// Do not create a `README.md` file. #[arg(long)] pub no_readme: bool, diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index b56b9b533..f257eb3f7 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -65,7 +65,7 @@ impl Pep723Script { /// Reads a Python script and generates a default PEP 723 metadata table. /// /// See: - pub async fn create( + pub async fn init( file: impl AsRef, requires_python: &VersionSpecifiers, ) -> Result { @@ -95,6 +95,51 @@ impl Pep723Script { }) } + /// Create a PEP 723 script at the given path. + pub async fn create( + file: impl AsRef, + requires_python: &VersionSpecifiers, + existing_contents: Option>, + ) -> Result<(), Pep723Error> { + let file = file.as_ref(); + + let script_name = file + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| Pep723Error::InvalidFilename(file.to_string_lossy().to_string()))?; + + let default_metadata = indoc::formatdoc! {r#" + requires-python = "{requires_python}" + dependencies = [] + "#, + }; + let metadata = serialize_metadata(&default_metadata); + + let script = if let Some(existing_contents) = existing_contents { + indoc::formatdoc! {r#" + {metadata} + {content} + "#, + content = String::from_utf8(existing_contents).map_err(|err| Pep723Error::Utf8(err.utf8_error()))?} + } else { + indoc::formatdoc! {r#" + {metadata} + + def main() -> None: + print("Hello from {name}!") + + + if __name__ == "__main__": + main() + "#, + metadata = metadata, + name = script_name, + } + }; + + Ok(fs_err::tokio::write(file, script).await?) + } + /// Replace the existing metadata in the file with new metadata and write the updated content. pub async fn write(&self, metadata: &str) -> Result<(), Pep723Error> { let content = format!( @@ -161,10 +206,12 @@ pub enum Pep723Error { Utf8(#[from] std::str::Utf8Error), #[error(transparent)] Toml(#[from] toml::de::Error), + #[error("Invalid filename `{0}` supplied")] + InvalidFilename(String), } #[derive(Debug, Clone, Eq, PartialEq)] -struct ScriptTag { +pub struct ScriptTag { /// The content of the script before the metadata block. prelude: String, /// The metadata block. @@ -202,7 +249,7 @@ impl ScriptTag { /// - Postlude: `import requests\n\nprint("Hello, World!")\n` /// /// See: - fn parse(contents: &[u8]) -> Result, Pep723Error> { + pub fn parse(contents: &[u8]) -> Result, Pep723Error> { // Identify the opening pragma. let Some(index) = FINDER.find(contents) else { return Ok(None); diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 23723e93f..7a612a53f 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -24,7 +24,7 @@ pub(crate) use pip::tree::pip_tree; pub(crate) use pip::uninstall::pip_uninstall; pub(crate) use project::add::add; pub(crate) use project::export::export; -pub(crate) use project::init::{init, InitProjectKind}; +pub(crate) use project::init::{init, InitKind, InitProjectKind}; pub(crate) use project::lock::lock; pub(crate) use project::remove::remove; pub(crate) use project::run::{run, RunCommand}; diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index b30037d7e..89f3bd0c1 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -29,7 +29,7 @@ use uv_python::{ PythonPreference, PythonRequest, PythonVersionFile, VersionRequest, }; use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification}; -use uv_resolver::{FlatIndex, RequiresPython}; +use uv_resolver::FlatIndex; use uv_scripts::Pep723Script; use uv_types::{BuildIsolation, HashStrategy}; use uv_warnings::warn_user_once; @@ -42,7 +42,7 @@ use crate::commands::pip::loggers::{ }; use crate::commands::pip::operations::Modifications; use crate::commands::pip::resolution_environment; -use crate::commands::project::ProjectError; +use crate::commands::project::{script_python_requirement, ProjectError}; use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter}; use crate::commands::{pip, project, ExitStatus, SharedState}; use crate::printer::Printer; @@ -129,35 +129,18 @@ pub(crate) async fn add( let script = if let Some(script) = Pep723Script::read(&script).await? { script } else { - let python_request = if let Some(request) = python.as_deref() { - // (1) Explicit request from user - PythonRequest::parse(request) - } else if let Some(request) = PythonVersionFile::discover(project_dir, false, false) - .await? - .and_then(PythonVersionFile::into_version) - { - // (2) Request from `.python-version` - request - } else { - // (3) Assume any Python version - PythonRequest::Default - }; - - let interpreter = PythonInstallation::find_or_download( - Some(&python_request), - EnvironmentPreference::Any, + let requires_python = script_python_requirement( + python.as_deref(), + project_dir, + false, python_preference, python_downloads, &client_builder, cache, - Some(&reporter), + &reporter, ) - .await? - .into_interpreter(); - - let requires_python = - RequiresPython::greater_than_equal_version(&interpreter.python_minor_version()); - Pep723Script::create(&script, requires_python.specifiers()).await? + .await?; + Pep723Script::init(&script, requires_python.specifiers()).await? }; let python_request = if let Some(request) = python.as_deref() { diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 34ca18e32..8d85dba26 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -1,5 +1,5 @@ use std::fmt::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use owo_colors::OwoColorize; @@ -8,16 +8,18 @@ use pep508_rs::PackageName; use tracing::{debug, warn}; use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity}; -use uv_fs::Simplified; +use uv_fs::{Simplified, CWD}; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, VersionRequest, }; use uv_resolver::RequiresPython; +use uv_scripts::{Pep723Script, ScriptTag}; +use uv_warnings::warn_user_once; use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut}; use uv_workspace::{DiscoveryOptions, MemberDiscovery, Workspace, WorkspaceError}; -use crate::commands::project::find_requires_python; +use crate::commands::project::{find_requires_python, script_python_requirement}; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::ExitStatus; use crate::printer::Printer; @@ -26,10 +28,10 @@ use crate::printer::Printer; #[allow(clippy::single_match_else, clippy::fn_params_excessive_bools)] pub(crate) async fn init( project_dir: &Path, - explicit_path: Option, + explicit_path: Option, name: Option, package: bool, - project_kind: InitProjectKind, + init_kind: InitKind, no_readme: bool, no_pin_python: bool, python: Option, @@ -41,81 +43,187 @@ pub(crate) async fn init( cache: &Cache, printer: Printer, ) -> Result { - // Default to the current directory if a path was not provided. - let path = match explicit_path { - None => project_dir.to_path_buf(), - Some(ref path) => std::path::absolute(path)?, - }; + match init_kind { + InitKind::Script => { + let Some(path) = explicit_path.as_deref() else { + anyhow::bail!("Script initialization requires a file path") + }; - // Make sure a project does not already exist in the given directory. - if path.join("pyproject.toml").exists() { - let path = std::path::absolute(&path).unwrap_or_else(|_| path.simplified().to_path_buf()); - anyhow::bail!( - "Project is already initialized in `{}` (`pyproject.toml` file exists)", - path.display().cyan() - ); - } + init_script( + path, + python, + connectivity, + python_preference, + python_downloads, + cache, + printer, + no_workspace, + no_readme, + no_pin_python, + package, + native_tls, + ) + .await?; - // Default to the directory name if a name was not provided. - let name = match name { - Some(name) => name, - None => { - let name = path - .file_name() - .and_then(|path| path.to_str()) - .context("Missing directory name")?; - - PackageName::new(name.to_string())? - } - }; - - init_project( - &path, - &name, - package, - project_kind, - no_readme, - no_pin_python, - python, - no_workspace, - python_preference, - python_downloads, - connectivity, - native_tls, - cache, - printer, - ) - .await?; - - // Create the `README.md` if it does not already exist. - if !no_readme { - let readme = path.join("README.md"); - if !readme.exists() { - fs_err::write(readme, String::new())?; - } - } - - match explicit_path { - // Initialized a project in the current directory. - None => { - writeln!(printer.stderr(), "Initialized project `{}`", name.cyan())?; - } - // Initialized a project in the given directory. - Some(path) => { - let path = - std::path::absolute(&path).unwrap_or_else(|_| path.simplified().to_path_buf()); writeln!( printer.stderr(), - "Initialized project `{}` at `{}`", - name.cyan(), - path.display().cyan() + "Initialized script at `{}`", + path.user_display().cyan() )?; } + InitKind::Project(project_kind) => { + // Default to the current directory if a path was not provided. + let path = match explicit_path { + None => project_dir.to_path_buf(), + Some(ref path) => std::path::absolute(path)?, + }; + + // Make sure a project does not already exist in the given directory. + if path.join("pyproject.toml").exists() { + let path = + std::path::absolute(&path).unwrap_or_else(|_| path.simplified().to_path_buf()); + anyhow::bail!( + "Project is already initialized in `{}` (`pyproject.toml` file exists)", + path.display().cyan() + ); + } + + // Default to the directory name if a name was not provided. + let name = match name { + Some(name) => name, + None => { + let name = path + .file_name() + .and_then(|path| path.to_str()) + .context("Missing directory name")?; + + PackageName::new(name.to_string())? + } + }; + + init_project( + &path, + &name, + package, + project_kind, + no_readme, + no_pin_python, + python, + no_workspace, + python_preference, + python_downloads, + connectivity, + native_tls, + cache, + printer, + ) + .await?; + + // Create the `README.md` if it does not already exist. + if !no_readme { + let readme = path.join("README.md"); + if !readme.exists() { + fs_err::write(readme, String::new())?; + } + } + + match explicit_path { + // Initialized a project in the current directory. + None => { + writeln!(printer.stderr(), "Initialized project `{}`", name.cyan())?; + } + // Initialized a project in the given directory. + Some(path) => { + let path = std::path::absolute(&path) + .unwrap_or_else(|_| path.simplified().to_path_buf()); + writeln!( + printer.stderr(), + "Initialized project `{}` at `{}`", + name.cyan(), + path.display().cyan() + )?; + } + } + } } Ok(ExitStatus::Success) } +#[allow(clippy::fn_params_excessive_bools)] +async fn init_script( + script_path: &Path, + python: Option, + connectivity: Connectivity, + python_preference: PythonPreference, + python_downloads: PythonDownloads, + cache: &Cache, + printer: Printer, + no_workspace: bool, + no_readme: bool, + no_pin_python: bool, + package: bool, + native_tls: bool, +) -> Result<()> { + if no_workspace { + warn_user_once!("`--no_workspace` is a no-op for Python scripts, which are standalone"); + } + if no_readme { + warn_user_once!("`--no_readme` is a no-op for Python scripts, which are standalone"); + } + if package { + warn_user_once!("`--package` is a no-op for Python scripts, which are standalone"); + } + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls); + + let reporter = PythonDownloadReporter::single(printer); + + // If the file already exists, read its content. + let content = match fs_err::tokio::read(script_path).await { + Ok(metadata) => { + // If the file is already a script, raise an error. + if ScriptTag::parse(&metadata)?.is_some() { + anyhow::bail!( + "`{}` is already a PEP 723 script; use `{}` to execute it", + script_path.simplified_display().cyan(), + "uv run".green() + ); + } + + Some(metadata) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => { + return Err(anyhow::Error::from(err).context(format!( + "Failed to read script at `{}`", + script_path.simplified_display().cyan() + ))); + } + }; + + let requires_python = script_python_requirement( + python.as_deref(), + &CWD, + no_pin_python, + python_preference, + python_downloads, + &client_builder, + cache, + &reporter, + ) + .await?; + + if let Some(parent) = script_path.parent() { + fs_err::tokio::create_dir_all(parent).await?; + } + + Pep723Script::create(script_path, requires_python.specifiers(), content).await?; + + Ok(()) +} + /// Initialize a project (and, implicitly, a workspace root) at the given path. #[allow(clippy::fn_params_excessive_bools)] async fn init_project( @@ -395,13 +503,38 @@ async fn init_project( Ok(()) } +/// The kind of entity to initialize (either a PEP 723 script or a Python project). +#[derive(Debug, Copy, Clone)] +pub(crate) enum InitKind { + /// Initialize a Python project. + Project(InitProjectKind), + /// Initialize a PEP 723 script. + Script, +} + +impl Default for InitKind { + fn default() -> Self { + InitKind::Project(InitProjectKind::default()) + } +} + +/// The kind of Python project to initialize (either an application or a library). #[derive(Debug, Copy, Clone, Default)] pub(crate) enum InitProjectKind { + /// Initialize a Python application. #[default] Application, + /// Initialize a Python library. Library, } +impl InitKind { + /// Returns `true` if the project should be packaged by default. + pub(crate) fn packaged_by_default(self) -> bool { + matches!(self, InitKind::Project(InitProjectKind::Library)) + } +} + impl InitProjectKind { /// Initialize this project kind at the target path. async fn init( @@ -439,11 +572,6 @@ impl InitProjectKind { } } - /// Whether this project kind is packaged by default. - pub(crate) fn packaged_by_default(self) -> bool { - matches!(self, InitProjectKind::Library) - } - async fn init_application( self, name: &PackageName, @@ -588,13 +716,13 @@ fn pyproject_project( no_readme: bool, ) -> String { indoc::formatdoc! {r#" - [project] - name = "{name}" - version = "0.1.0" - description = "Add your description here"{readme} - requires-python = "{requires_python}" - dependencies = [] - "#, + [project] + name = "{name}" + version = "0.1.0" + description = "Add your description here"{readme} + requires-python = "{requires_python}" + dependencies = [] + "#, readme = if no_readme { "" } else { "\nreadme = \"README.md\"" }, requires_python = requires_python.specifiers(), } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 3a686acc6..e1aad0759 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1,5 +1,5 @@ use std::fmt::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use itertools::Itertools; use owo_colors::OwoColorize; @@ -1265,6 +1265,50 @@ pub(crate) async fn update_environment( }) } +/// Determine the [`RequiresPython`] requirement for a PEP 723 script. +pub(crate) async fn script_python_requirement( + python: Option<&str>, + directory: &Path, + no_pin_python: bool, + python_preference: PythonPreference, + python_downloads: PythonDownloads, + client_builder: &BaseClientBuilder<'_>, + cache: &Cache, + reporter: &PythonDownloadReporter, +) -> anyhow::Result { + let python_request = if let Some(request) = python { + // (1) Explicit request from user + PythonRequest::parse(request) + } else if let (false, Some(request)) = ( + no_pin_python, + PythonVersionFile::discover(directory, false, false) + .await? + .and_then(PythonVersionFile::into_version), + ) { + // (2) Request from `.python-version` + request + } else { + // (3) Assume any Python version + PythonRequest::Any + }; + + let interpreter = PythonInstallation::find_or_download( + Some(&python_request), + EnvironmentPreference::Any, + python_preference, + python_downloads, + client_builder, + cache, + Some(reporter), + ) + .await? + .into_interpreter(); + + Ok(RequiresPython::greater_than_equal_version( + &interpreter.python_minor_version(), + )) +} + /// Warn if the user provides (e.g.) an `--index-url` in a requirements file. fn warn_on_requirements_txt_setting( spec: &RequirementsSpecification, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 93d192f4a..fbadfb569 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -39,7 +39,7 @@ use uv_warnings::warn_user_once; use uv_workspace::pyproject::DependencyType; use crate::commands::ToolRunCommand; -use crate::commands::{pip::operations::Modifications, InitProjectKind}; +use crate::commands::{pip::operations::Modifications, InitKind, InitProjectKind}; /// The default publish URL. const PYPI_PUBLISH_URL: &str = "https://upload.pypi.org/legacy/"; @@ -158,10 +158,10 @@ impl CacheSettings { #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct InitSettings { - pub(crate) path: Option, + pub(crate) path: Option, pub(crate) name: Option, pub(crate) package: bool, - pub(crate) kind: InitProjectKind, + pub(crate) kind: InitKind, pub(crate) no_readme: bool, pub(crate) no_pin_python: bool, pub(crate) no_workspace: bool, @@ -180,17 +180,19 @@ impl InitSettings { no_package, app, lib, + script, no_readme, no_pin_python, no_workspace, python, } = args; - let kind = match (app, lib) { - (true, false) => InitProjectKind::Application, - (false, true) => InitProjectKind::Library, - (false, false) => InitProjectKind::default(), - (true, true) => unreachable!("`app` and `lib` are mutually exclusive"), + let kind = match (app, lib, script) { + (true, false, false) => InitKind::Project(InitProjectKind::Application), + (false, true, false) => InitKind::Project(InitProjectKind::Library), + (false, false, true) => InitKind::Script, + (false, false, false) => InitKind::default(), + (_, _, _) => unreachable!("`app`, `lib`, and `script` are mutually exclusive"), }; let package = flag(package || r#virtual, no_package).unwrap_or(kind.packaged_by_default()); diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index 02891fa2e..0a8f99307 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -401,6 +401,201 @@ fn init_library() -> Result<()> { Ok(()) } +// General init --script correctness test +#[test] +fn init_script() -> Result<()> { + let context = TestContext::new("3.12"); + + let child = context.temp_dir.child("foo"); + child.create_dir_all()?; + + let script = child.join("hello.py"); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script").arg("hello.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Initialized script at `hello.py` + "###); + + let script = fs_err::read_to_string(&script)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + script, @r###" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + + + def main() -> None: + print("Hello from hello.py!") + + + if __name__ == "__main__": + main() + "### + ); + }); + + uv_snapshot!(context.filters(), context.run().current_dir(&child).arg("python").arg("hello.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello from hello.py! + + ----- stderr ----- + "###); + + Ok(()) +} + +// Ensure python versions passed as arguments are present in file metadata +#[test] +fn init_script_python_version() -> Result<()> { + let context = TestContext::new("3.11"); + + let child = context.temp_dir.child("foo"); + child.create_dir_all()?; + + let script = child.join("version.py"); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script").arg("version.py").arg("--python").arg("3.11"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Initialized script at `version.py` + "###); + + let script = fs_err::read_to_string(&script)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + script, @r###" + # /// script + # requires-python = ">=3.11" + # dependencies = [] + # /// + + + def main() -> None: + print("Hello from version.py!") + + + if __name__ == "__main__": + main() + "### + ); + }); + + Ok(()) +} + +// Init script should create parent directories if they don't exist +#[test] +fn init_script_create_directory() -> Result<()> { + let context = TestContext::new("3.12"); + + let child = context.temp_dir.child("foo"); + child.create_dir_all()?; + + let script = child.join("test").join("dir.py"); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script").arg("test/dir.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Initialized script at `test/dir.py` + "###); + + let script = fs_err::read_to_string(&script)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + script, @r###" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + + + def main() -> None: + print("Hello from dir.py!") + + + if __name__ == "__main__": + main() + "### + ); + }); + + Ok(()) +} + +// Init script should fail if file is already a PEP 723 script +#[test] +fn init_script_file_conflicts() -> Result<()> { + let context = TestContext::new("3.12"); + + let child = context.temp_dir.child("foo"); + child.create_dir_all()?; + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script").arg("name_conflict.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Initialized script at `name_conflict.py` + "###); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script").arg("name_conflict.py"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `name_conflict.py` is already a PEP 723 script; use `uv run` to execute it + "###); + + let contents = "print(\"Hello, world!\")"; + fs_err::write(child.join("existing_script.py"), contents)?; + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script").arg("existing_script.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Initialized script at `existing_script.py` + "###); + + let existing_script = fs_err::read_to_string(child.join("existing_script.py"))?; + + assert_snapshot!( + existing_script, @r###" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + + print("Hello, world!") + "### + ); + + Ok(()) +} + /// Run `uv init --lib` with an existing py.typed file #[test] fn init_py_typed_exists() -> Result<()> { diff --git a/docs/guides/scripts.md b/docs/guides/scripts.md index 4a67bc94c..cf25eaacf 100644 --- a/docs/guides/scripts.md +++ b/docs/guides/scripts.md @@ -127,11 +127,20 @@ Multiple dependencies can be requested by repeating with `--with` option. Note that if `uv run` is used in a _project_, these dependencies will be included _in addition_ to the project's dependencies. To opt-out of this behavior, use the `--no-project` flag. -## Declaring script dependencies +## Creating a Python script Python recently added a standard format for [inline script metadata](https://packaging.python.org/en/latest/specifications/inline-script-metadata/#inline-script-metadata). -This allows the dependencies for a script to be declared in the script itself. +It allows for selecting Python versions and defining dependencies. Use `uv init --script` to +initialize scripts with the inline metadata: + +```console +$ uv init --script example.py --python 3.12 +``` + +## Declaring script dependencies + +The inline metadata format allows the dependencies for a script to be declared in the script itself. uv supports adding and updating inline script metadata for you. Use `uv add --script` to declare the dependencies for the script: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0972e9480..e342ca91b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -412,9 +412,9 @@ uv init [OPTIONS] [PATH]

Arguments

-
PATH

The path to use for the project.

+
PATH

The path to use for the project/script.

-

Defaults to the current working directory. Accepts relative and absolute paths.

+

Defaults to the current working directory when initializing an app or library; required when initializing a script. Accepts relative and absolute paths.

If a pyproject.toml is found in any of the parent directories of the target path, the project will be added as a workspace member of the parent, unless --no-workspace is provided.

@@ -550,6 +550,14 @@ uv init [OPTIONS] [PATH]
--quiet, -q

Do not print any output

+
--script

Create a script.

+ +

A script is a standalone file with embedded metadata enumerating its dependencies, along with any Python version requirements, as defined in the PEP 723 specification.

+ +

PEP 723 scripts can be executed directly with uv run.

+ +

By default, adds a requirement on the system Python version; use --python to specify an alternative Python version requirement.

+
--verbose, -v

Use verbose output.

You can configure fine-grained logging using the RUST_LOG environment variable. (<https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives>)