From a20427ae58a7b6fc12957450b35cf4b594ebb9a0 Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Sun, 15 Sep 2024 16:45:15 -0700 Subject: [PATCH 01/19] Initial work --- crates/uv-cli/src/lib.rs | 22 ++++-- crates/uv-scripts/src/lib.rs | 31 ++++++++ crates/uv/src/commands/project/init.rs | 105 ++++++++++++++++++++++++- crates/uv/src/lib.rs | 1 + crates/uv/src/settings.rs | 19 +++-- 5 files changed, 166 insertions(+), 12 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index bed35cbee13e..e757bcc712c5 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2263,6 +2263,9 @@ impl ExternalCommand { #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct InitArgs { + #[arg(required_if_eq("script", "true"))] + pub script_file_path: Option, + /// The path to use for the project. /// /// Defaults to the current working directory. Accepts relative and absolute @@ -2271,12 +2274,13 @@ pub struct InitArgs { /// 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. + #[arg(conflicts_with = "script")] 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. @@ -2313,17 +2317,23 @@ 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 which adheres to the PEP-723 specification. + #[arg(long, alias="script", conflicts_with_all=["app", "lib"])] + pub r#script: bool, + /// Do not create a `README.md` file. - #[arg(long)] + #[arg(long, conflicts_with = "script")] pub no_readme: bool, /// Do not create a `.python-version` file for the project. @@ -2331,14 +2341,14 @@ pub struct InitArgs { /// By default, uv will create a `.python-version` file containing the minor version of /// the discovered Python interpreter, which will cause subsequent uv commands to use that /// version. - #[arg(long)] + #[arg(long, conflicts_with = "script")] pub no_pin_python: bool, /// Avoid discovering a workspace and create a standalone project. /// /// By default, uv searches for workspaces in the current directory or any /// parent directory. - #[arg(long, alias = "no-project")] + #[arg(long, alias = "no-project", conflicts_with = "script")] pub no_workspace: bool, /// The Python interpreter to use to determine the minimum supported Python version. diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index b56b9b533791..986d5e0b3f65 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -95,6 +95,35 @@ impl Pep723Script { }) } + pub async fn create_new_script( + script_path: impl AsRef, + requires_python: &VersionSpecifiers, + ) -> Result<(), Pep723Error> { + let script_name = script_path + .as_ref() + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| Pep723Error::InvalidFilename)?; + + let script = indoc::formatdoc! {r#" + # /// script + # requires-python = "{requires_python}" + # dependencies = [] + # /// + + def main(): + print("Hello from {name}!") + + if __name__ == "__main__": + main() + "#, + requires_python = requires_python, + name = script_name, + }; + + Ok(fs_err::tokio::write(script_path, 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,6 +190,8 @@ pub enum Pep723Error { Utf8(#[from] std::str::Utf8Error), #[error(transparent)] Toml(#[from] toml::de::Error), + #[error("Invalid filename supplied")] + InvalidFilename, } #[derive(Debug, Clone, Eq, PartialEq)] diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index bda3af8fcef9..01aeb9ea9796 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; @@ -14,6 +14,8 @@ use uv_python::{ PythonVersionFile, VersionRequest, }; use uv_resolver::RequiresPython; +use uv_scripts::Pep723Script; +use uv_warnings::warn_user_once; use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut}; use uv_workspace::{DiscoveryOptions, MemberDiscovery, Workspace, WorkspaceError}; @@ -25,6 +27,7 @@ use crate::printer::Printer; /// Add one or more packages to the project requirements. #[allow(clippy::single_match_else, clippy::fn_params_excessive_bools)] pub(crate) async fn init( + script_file_path: Option, explicit_path: Option, name: Option, package: bool, @@ -40,6 +43,30 @@ pub(crate) async fn init( cache: &Cache, printer: Printer, ) -> Result { + // If user seeks to initialize a new script, process the request immediately + if let InitProjectKind::Script = project_kind { + if let Some(file) = script_file_path { + project_kind + .init_script( + &PathBuf::from(file), + python, + connectivity, + python_preference, + python_downloads, + cache, + printer, + no_workspace, + no_readme, + package, + native_tls, + ) + .await?; + return Ok(ExitStatus::Success); + } else { + anyhow::bail!("Filename not provided for script"); + } + } + // Default to the current directory if a path was not provided. let path = match explicit_path { None => CWD.to_path_buf(), @@ -392,6 +419,7 @@ pub(crate) enum InitProjectKind { #[default] Application, Library, + Script, } impl InitProjectKind { @@ -428,6 +456,10 @@ impl InitProjectKind { ) .await } + InitProjectKind::Script => { + dbg!("Script should be initialized directly via init_script"); + anyhow::bail!("Error during script initialization") + } } } @@ -571,6 +603,77 @@ impl InitProjectKind { Ok(()) } + + async fn init_script( + self, + file: &PathBuf, + python: Option, + connectivity: Connectivity, + python_preference: PythonPreference, + python_downloads: PythonDownloads, + cache: &Cache, + printer: Printer, + no_workspace: bool, + no_readme: 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 let Ok(_) = fs_err::metadata(file) { + anyhow::bail!("Script {} already exists", file.to_str().unwrap()); + } + + 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(&*CWD, 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(); + + let requires_python = + RequiresPython::greater_than_equal_version(&interpreter.python_minor_version()); + + if let Some(path) = file.parent() { + fs_err::create_dir_all(path)?; + } + Pep723Script::create_new_script(file, requires_python.specifiers()).await?; + + Ok(()) + } } /// Generate the `[project]` section of a `pyproject.toml`. diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 0ee3a7c2720f..8958806e07c1 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1072,6 +1072,7 @@ async fn run_project( let cache = cache.init()?; commands::init( + args.script_file_path, args.path, args.name, args.package, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 1bf7a398d050..d1844d4f9cf6 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -152,6 +152,7 @@ impl CacheSettings { #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct InitSettings { + pub(crate) script_file_path: Option, pub(crate) path: Option, pub(crate) name: Option, pub(crate) package: bool, @@ -167,6 +168,7 @@ impl InitSettings { #[allow(clippy::needless_pass_by_value)] pub(crate) fn resolve(args: InitArgs, _filesystem: Option) -> Self { let InitArgs { + script_file_path, path, name, r#virtual, @@ -174,22 +176,29 @@ 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) => InitProjectKind::Application, + (false, true, false) => InitProjectKind::Library, + (false, false, true) => InitProjectKind::Script, + (false, false, false) => InitProjectKind::default(), + // We can enumerate these, or combine into a single catch all + (true, true, false) => unreachable!("`app` and `lib` are mutually exclusive"), + (true, false, true) => unreachable!("`app` and `script` are mutually exclusive"), + (false, true, true) => unreachable!("`lib` and `script` are mutually exclusive"), + (true, true, true) => unreachable!("`app`, `lib`, and `script` are mutually exclusive"), }; let package = flag(package || r#virtual, no_package).unwrap_or(kind.packaged_by_default()); Self { + script_file_path, path, name, package, From f0fcf95700f547e4a71f01c726e0b2311caa6bff Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Sun, 15 Sep 2024 17:39:42 -0700 Subject: [PATCH 02/19] Minor fixes and code quality --- crates/uv-cli/src/lib.rs | 17 ++--- crates/uv-scripts/src/lib.rs | 16 +++-- crates/uv/src/commands/project/add.rs | 32 +++------ crates/uv/src/commands/project/init.rs | 95 +++++++++++++++++--------- crates/uv/src/lib.rs | 1 - crates/uv/src/settings.rs | 3 - 6 files changed, 88 insertions(+), 76 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index e757bcc712c5..cde852cfac1a 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2263,18 +2263,15 @@ impl ExternalCommand { #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct InitArgs { - #[arg(required_if_eq("script", "true"))] - pub script_file_path: Option, - - /// 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. - #[arg(conflicts_with = "script")] + #[arg(required_if_eq("script", "true"))] pub path: Option, /// The name of the project. @@ -2333,7 +2330,7 @@ pub struct InitArgs { pub r#script: bool, /// Do not create a `README.md` file. - #[arg(long, conflicts_with = "script")] + #[arg(long)] pub no_readme: bool, /// Do not create a `.python-version` file for the project. @@ -2341,14 +2338,14 @@ pub struct InitArgs { /// By default, uv will create a `.python-version` file containing the minor version of /// the discovered Python interpreter, which will cause subsequent uv commands to use that /// version. - #[arg(long, conflicts_with = "script")] + #[arg(long)] pub no_pin_python: bool, /// Avoid discovering a workspace and create a standalone project. /// /// By default, uv searches for workspaces in the current directory or any /// parent directory. - #[arg(long, alias = "no-project", conflicts_with = "script")] + #[arg(long, alias = "no-project")] pub no_workspace: bool, /// The Python interpreter to use to determine the minimum supported Python version. diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 986d5e0b3f65..9a025f22df73 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -105,19 +105,23 @@ impl Pep723Script { .and_then(|name| name.to_str()) .ok_or_else(|| Pep723Error::InvalidFilename)?; - let script = indoc::formatdoc! {r#" - # /// script - # requires-python = "{requires_python}" - # dependencies = [] - # /// + let default_metadata = indoc::formatdoc! {r#" + requires-python = "{requires_python}" + dependencies = [] + "#, + requires_python = requires_python, + }; + let metadata = serialize_metadata(&default_metadata); + let script = indoc::formatdoc! {r#" + {metadata} def main(): print("Hello from {name}!") if __name__ == "__main__": main() "#, - requires_python = requires_python, + metadata = metadata, name = script_name, }; diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 57f54c5f5e24..8798d3c390da 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -28,7 +28,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; @@ -47,6 +47,8 @@ use crate::commands::{pip, project, ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::{ResolverInstallerSettings, ResolverInstallerSettingsRef}; +use super::init::get_python_requirement_for_new_script; + /// Add one or more packages to the project requirements. #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn add( @@ -127,34 +129,16 @@ 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(&*CWD, 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, + let requires_python = get_python_requirement_for_new_script( + &python, + 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()); + .await?; Pep723Script::create(&script, requires_python.specifiers()).await? }; diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 01aeb9ea9796..bfb026bf2f65 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -27,7 +27,6 @@ use crate::printer::Printer; /// Add one or more packages to the project requirements. #[allow(clippy::single_match_else, clippy::fn_params_excessive_bools)] pub(crate) async fn init( - script_file_path: Option, explicit_path: Option, name: Option, package: bool, @@ -45,10 +44,10 @@ pub(crate) async fn init( ) -> Result { // If user seeks to initialize a new script, process the request immediately if let InitProjectKind::Script = project_kind { - if let Some(file) = script_file_path { + if let Some(script_path) = explicit_path { project_kind .init_script( - &PathBuf::from(file), + &PathBuf::from(script_path), python, connectivity, python_preference, @@ -57,6 +56,7 @@ pub(crate) async fn init( printer, no_workspace, no_readme, + no_pin_python, package, native_tls, ) @@ -606,7 +606,7 @@ impl InitProjectKind { async fn init_script( self, - file: &PathBuf, + script_path: &PathBuf, python: Option, connectivity: Connectivity, python_preference: PythonPreference, @@ -615,6 +615,7 @@ impl InitProjectKind { printer: Printer, no_workspace: bool, no_readme: bool, + no_pin_python: bool, package: bool, native_tls: bool, ) -> Result<()> { @@ -628,54 +629,84 @@ impl InitProjectKind { warn_user_once!("`--package` is a no-op for Python scripts, which are standalone"); } + if let Some(path) = script_path.to_str() { + if !path.ends_with(".py") { + anyhow::bail!("Script must end with .py extension"); + } + } + let client_builder = BaseClientBuilder::new() .connectivity(connectivity) .native_tls(native_tls); let reporter = PythonDownloadReporter::single(printer); - if let Ok(_) = fs_err::metadata(file) { - anyhow::bail!("Script {} already exists", file.to_str().unwrap()); + if let Ok(_) = fs_err::tokio::metadata(script_path).await { + anyhow::bail!("Script {} already exists", script_path.to_str().unwrap()); } - 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(&*CWD, 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, + let requires_python = get_python_requirement_for_new_script( + &python, + no_pin_python, 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()); + .await?; - if let Some(path) = file.parent() { - fs_err::create_dir_all(path)?; + if let Some(path) = script_path.parent() { + fs_err::tokio::create_dir_all(path).await?; } - Pep723Script::create_new_script(file, requires_python.specifiers()).await?; + Pep723Script::create_new_script(script_path, requires_python.specifiers()).await?; Ok(()) } } +pub(crate) async fn get_python_requirement_for_new_script( + python: &Option, + no_pin_python: bool, + python_preference: PythonPreference, + python_downloads: PythonDownloads, + client_builder: &BaseClientBuilder<'_>, + cache: &Cache, + reporter: &PythonDownloadReporter, +) -> Result { + let python_request = if let Some(request) = python.as_deref() { + // (1) Explicit request from user + PythonRequest::parse(request) + } else if let (false, Some(request)) = ( + no_pin_python, + PythonVersionFile::discover(&*CWD, 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(), + )) +} + /// Generate the `[project]` section of a `pyproject.toml`. fn pyproject_project( name: &PackageName, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 8958806e07c1..0ee3a7c2720f 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1072,7 +1072,6 @@ async fn run_project( let cache = cache.init()?; commands::init( - args.script_file_path, args.path, args.name, args.package, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index d1844d4f9cf6..b282f9fdfbe2 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -152,7 +152,6 @@ impl CacheSettings { #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct InitSettings { - pub(crate) script_file_path: Option, pub(crate) path: Option, pub(crate) name: Option, pub(crate) package: bool, @@ -168,7 +167,6 @@ impl InitSettings { #[allow(clippy::needless_pass_by_value)] pub(crate) fn resolve(args: InitArgs, _filesystem: Option) -> Self { let InitArgs { - script_file_path, path, name, r#virtual, @@ -198,7 +196,6 @@ impl InitSettings { let package = flag(package || r#virtual, no_package).unwrap_or(kind.packaged_by_default()); Self { - script_file_path, path, name, package, From 7a9b2dd1b1d51c88c93356874bcd8af869c95693 Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Sun, 15 Sep 2024 18:37:12 -0700 Subject: [PATCH 03/19] Add tests --- crates/uv-cli/src/lib.rs | 3 + crates/uv/src/commands/project/init.rs | 10 +- crates/uv/tests/init.rs | 173 +++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 2 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index cde852cfac1a..4b43f652664b 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2326,6 +2326,9 @@ pub struct InitArgs { /// Create a script. /// /// A script is a standalone file which adheres to the PEP-723 specification. + /// + /// Python version the script depends on is defined by (in descending order of priority) + /// user input (via --python), .python-version file (if present, ignore with no_pin_python), or any. #[arg(long, alias="script", conflicts_with_all=["app", "lib"])] pub r#script: bool, diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index bfb026bf2f65..8325fb612da2 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -47,7 +47,7 @@ pub(crate) async fn init( if let Some(script_path) = explicit_path { project_kind .init_script( - &PathBuf::from(script_path), + &PathBuf::from(&script_path), python, connectivity, python_preference, @@ -61,6 +61,12 @@ pub(crate) async fn init( native_tls, ) .await?; + + writeln!( + printer.stderr(), + "Initialized script `{}`", + script_path.cyan() + )?; return Ok(ExitStatus::Success); } else { anyhow::bail!("Filename not provided for script"); @@ -631,7 +637,7 @@ impl InitProjectKind { if let Some(path) = script_path.to_str() { if !path.ends_with(".py") { - anyhow::bail!("Script must end with .py extension"); + anyhow::bail!("Script name must end in .py extension"); } } diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index 4c7092219266..b2023dcacffe 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -401,6 +401,179 @@ 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 `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(): + 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 `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(): + 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 `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(): + print("Hello from dir.py!") + + if __name__ == "__main__": + main() + "### + ); + }); + + Ok(()) +} + +// Init script should fail if file path exists or doesn't end with .py +#[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 `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: Script name_conflict.py already exists + "###); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script").arg("new_issue"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Script name must end in .py extension + "###); + + Ok(()) +} + /// Run `uv init --lib` with an existing py.typed file #[test] fn init_py_typed_exists() -> Result<()> { From 3b5d411794087fd3fdd0d8d42f0a2eec88bf9306 Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Sun, 15 Sep 2024 18:48:26 -0700 Subject: [PATCH 04/19] Remove unnecessary comment --- crates/uv/src/settings.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index b282f9fdfbe2..886c6b86ba91 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -186,7 +186,6 @@ impl InitSettings { (false, true, false) => InitProjectKind::Library, (false, false, true) => InitProjectKind::Script, (false, false, false) => InitProjectKind::default(), - // We can enumerate these, or combine into a single catch all (true, true, false) => unreachable!("`app` and `lib` are mutually exclusive"), (true, false, true) => unreachable!("`app` and `script` are mutually exclusive"), (false, true, true) => unreachable!("`lib` and `script` are mutually exclusive"), From af979785bb8fda5f1257cb654be6fbdb2e28855b Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Sun, 15 Sep 2024 19:02:26 -0700 Subject: [PATCH 05/19] Improve comment clarity --- crates/uv-cli/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 4b43f652664b..4316d81fc1f2 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2327,8 +2327,9 @@ pub struct InitArgs { /// /// A script is a standalone file which adheres to the PEP-723 specification. /// - /// Python version the script depends on is defined by (in descending order of priority) - /// user input (via --python), .python-version file (if present, ignore with no_pin_python), or any. + /// By default, the Python version the script depends on is the system version; can be + /// manually specified with the --python argument (takes absolute precedence) or a + /// .python-version file (ignore with --no_pin_python). #[arg(long, alias="script", conflicts_with_all=["app", "lib"])] pub r#script: bool, From e9ffff695dd7d5895de1baeda94b326cf37a2ecf Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Thu, 19 Sep 2024 16:52:52 -0700 Subject: [PATCH 06/19] Cleanup, docs --- crates/uv-cli/src/lib.rs | 2 +- crates/uv/src/commands/project/init.rs | 6 +++--- crates/uv/src/settings.rs | 5 +---- crates/uv/tests/init.rs | 10 +++++----- docs/guides/scripts.md | 13 +++++++++++-- docs/reference/cli.md | 4 ++++ 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 4316d81fc1f2..104a62267a64 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2330,7 +2330,7 @@ pub struct InitArgs { /// By default, the Python version the script depends on is the system version; can be /// manually specified with the --python argument (takes absolute precedence) or a /// .python-version file (ignore with --no_pin_python). - #[arg(long, alias="script", conflicts_with_all=["app", "lib"])] + #[arg(long, alias="script", conflicts_with_all=["app", "lib", "package"])] pub r#script: bool, /// Do not create a `README.md` file. diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 8325fb612da2..fbd7f7766d57 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -64,7 +64,7 @@ pub(crate) async fn init( writeln!( printer.stderr(), - "Initialized script `{}`", + "Initialized script at `{}`", script_path.cyan() )?; return Ok(ExitStatus::Success); @@ -647,8 +647,8 @@ impl InitProjectKind { let reporter = PythonDownloadReporter::single(printer); - if let Ok(_) = fs_err::tokio::metadata(script_path).await { - anyhow::bail!("Script {} already exists", script_path.to_str().unwrap()); + if script_path.try_exists()? { + anyhow::bail!("Script already exists at {}", script_path.to_str().unwrap()); } let requires_python = get_python_requirement_for_new_script( diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 886c6b86ba91..52e6ca190c8b 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -186,10 +186,7 @@ impl InitSettings { (false, true, false) => InitProjectKind::Library, (false, false, true) => InitProjectKind::Script, (false, false, false) => InitProjectKind::default(), - (true, true, false) => unreachable!("`app` and `lib` are mutually exclusive"), - (true, false, true) => unreachable!("`app` and `script` are mutually exclusive"), - (false, true, true) => unreachable!("`lib` and `script` are mutually exclusive"), - (true, true, true) => unreachable!("`app`, `lib`, and `script` are mutually exclusive"), + (_, _, _) => 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 b2023dcacffe..b49749d6f3d2 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -417,7 +417,7 @@ fn init_script() -> Result<()> { ----- stdout ----- ----- stderr ----- - Initialized script `hello.py` + Initialized script at `hello.py` "###); let script = fs_err::read_to_string(&script)?; @@ -468,7 +468,7 @@ fn init_script_python_version() -> Result<()> { ----- stdout ----- ----- stderr ----- - Initialized script `version.py` + Initialized script at `version.py` "###); let script = fs_err::read_to_string(&script)?; @@ -510,7 +510,7 @@ fn init_script_create_directory() -> Result<()> { ----- stdout ----- ----- stderr ----- - Initialized script `test/dir.py` + Initialized script at `test/dir.py` "###); let script = fs_err::read_to_string(&script)?; @@ -550,7 +550,7 @@ fn init_script_file_conflicts() -> Result<()> { ----- stdout ----- ----- stderr ----- - Initialized script `name_conflict.py` + Initialized script at `name_conflict.py` "###); uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script").arg("name_conflict.py"), @r###" @@ -559,7 +559,7 @@ fn init_script_file_conflicts() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Script name_conflict.py already exists + error: Script already exists at name_conflict.py "###); uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script").arg("new_issue"), @r###" diff --git a/docs/guides/scripts.md b/docs/guides/scripts.md index 4a67bc94c572..dfbef8030d1b 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 a14a93eff1d5..f11f3849a170 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -510,6 +510,10 @@ uv init [OPTIONS] [PATH]
--quiet, -q

Do not print any output

+
--script

Create a script with inline metadata.

+ +

A Python script is a file intended for standalone execution with or without dependencies.

+
--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>)

From ff114082d8aefe07e169d7eee456becf7d8e2842 Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Thu, 19 Sep 2024 17:57:47 -0700 Subject: [PATCH 07/19] Fix lint errors --- crates/uv-cli/src/lib.rs | 2 +- crates/uv/src/commands/project/init.rs | 13 ++++++++----- docs/reference/cli.md | 10 ++++++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 14775ae42e65..853375b575a3 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2336,7 +2336,7 @@ pub struct InitArgs { /// /// By default, the Python version the script depends on is the system version; can be /// manually specified with the --python argument (takes absolute precedence) or a - /// .python-version file (ignore with --no_pin_python). + /// .python-version file (ignore with --`no_pin_python`). #[arg(long, alias="script", conflicts_with_all=["app", "lib", "package"])] pub r#script: bool, diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index fbd7f7766d57..72abb2542d4e 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -68,9 +68,8 @@ pub(crate) async fn init( script_path.cyan() )?; return Ok(ExitStatus::Success); - } else { - anyhow::bail!("Filename not provided for script"); } + anyhow::bail!("Filename not provided for script"); } // Default to the current directory if a path was not provided. @@ -463,7 +462,7 @@ impl InitProjectKind { .await } InitProjectKind::Script => { - dbg!("Script should be initialized directly via init_script"); + debug!("Script should be initialized directly via init_script"); anyhow::bail!("Error during script initialization") } } @@ -610,6 +609,7 @@ impl InitProjectKind { Ok(()) } + #[allow(clippy::fn_params_excessive_bools)] async fn init_script( self, script_path: &PathBuf, @@ -636,8 +636,11 @@ impl InitProjectKind { } if let Some(path) = script_path.to_str() { - if !path.ends_with(".py") { - anyhow::bail!("Script name must end in .py extension"); + if !std::path::Path::new(path) + .extension() + .map_or(false, |ext| ext.eq_ignore_ascii_case("py")) + { + anyhow::bail!("Script must end in .py extension"); } } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5da921ba486b..a525eef894af 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -394,9 +394,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.

@@ -516,9 +516,11 @@ uv init [OPTIONS] [PATH]
--quiet, -q

Do not print any output

-
--script

Create a script with inline metadata.

+
--script

Create a script.

-

A Python script is a file intended for standalone execution with or without dependencies.

+

A script is a standalone file which adheres to the PEP-723 specification.

+ +

By default, the Python version the script depends on is the system version; can be manually specified with the --python argument (takes absolute precedence) or a .python-version file (ignore with --no_pin_python).

--verbose, -v

Use verbose output.

From 4146b3eb0bf3dee4026e27a68e297e781d8516f7 Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Thu, 19 Sep 2024 18:04:50 -0700 Subject: [PATCH 08/19] Fix test --- crates/uv/tests/init.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index e6a0d7b05f49..9062dec8ca91 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -568,7 +568,7 @@ fn init_script_file_conflicts() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Script name must end in .py extension + error: Script must end in .py extension "###); Ok(()) From 58026d26805ccc9df407cdf2f96ac774607654a1 Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Thu, 19 Sep 2024 18:36:46 -0700 Subject: [PATCH 09/19] Don't require .py extension --- crates/uv/src/commands/project/init.rs | 10 ---------- crates/uv/tests/init.rs | 11 +---------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 72abb2542d4e..1fadfe699592 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -634,16 +634,6 @@ impl InitProjectKind { if package { warn_user_once!("`--package` is a no-op for Python scripts, which are standalone"); } - - if let Some(path) = script_path.to_str() { - if !std::path::Path::new(path) - .extension() - .map_or(false, |ext| ext.eq_ignore_ascii_case("py")) - { - anyhow::bail!("Script must end in .py extension"); - } - } - let client_builder = BaseClientBuilder::new() .connectivity(connectivity) .native_tls(native_tls); diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index 9062dec8ca91..8101b17b161a 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -536,7 +536,7 @@ fn init_script_create_directory() -> Result<()> { Ok(()) } -// Init script should fail if file path exists or doesn't end with .py +// Init script should fail if file path exists #[test] fn init_script_file_conflicts() -> Result<()> { let context = TestContext::new("3.12"); @@ -562,15 +562,6 @@ fn init_script_file_conflicts() -> Result<()> { error: Script already exists at name_conflict.py "###); - uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script").arg("new_issue"), @r###" - success: false - exit_code: 2 - ----- stdout ----- - - ----- stderr ----- - error: Script must end in .py extension - "###); - Ok(()) } From 8abf888a0f4ad9d7d56c3bb15733f1b6d07b7016 Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Tue, 24 Sep 2024 00:24:38 -0700 Subject: [PATCH 10/19] Some updates (will fail tests) --- crates/uv-cli/src/lib.rs | 2 +- crates/uv-scripts/src/lib.rs | 17 +- crates/uv/src/commands/project/add.rs | 6 +- crates/uv/src/commands/project/init.rs | 388 ++++++++++++------------- crates/uv/src/commands/project/mod.rs | 44 ++- 5 files changed, 249 insertions(+), 208 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 853375b575a3..3e64c356acdb 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2332,7 +2332,7 @@ pub struct InitArgs { /// Create a script. /// - /// A script is a standalone file which adheres to the PEP-723 specification. + /// A script is a standalone file which adheres to the PEP 723 specification. /// /// By default, the Python version the script depends on is the system version; can be /// manually specified with the --python argument (takes absolute precedence) or a diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 9a025f22df73..40288f838261 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,15 +95,16 @@ impl Pep723Script { }) } - pub async fn create_new_script( - script_path: impl AsRef, + pub async fn create( + script_path: &PathBuf, requires_python: &VersionSpecifiers, ) -> Result<(), Pep723Error> { let script_name = script_path - .as_ref() .file_name() .and_then(|name| name.to_str()) - .ok_or_else(|| Pep723Error::InvalidFilename)?; + .ok_or_else(|| { + Pep723Error::InvalidFilename(script_path.to_string_lossy().into_owned()) + })?; let default_metadata = indoc::formatdoc! {r#" requires-python = "{requires_python}" @@ -115,7 +116,7 @@ impl Pep723Script { let script = indoc::formatdoc! {r#" {metadata} - def main(): + def main() -> None: print("Hello from {name}!") if __name__ == "__main__": @@ -194,8 +195,8 @@ pub enum Pep723Error { Utf8(#[from] std::str::Utf8Error), #[error(transparent)] Toml(#[from] toml::de::Error), - #[error("Invalid filename supplied")] - InvalidFilename, + #[error("Invalid filename `{0}` supplied")] + InvalidFilename(String), } #[derive(Debug, Clone, Eq, PartialEq)] diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 5c5efbd9c6bb..3790f10b2df3 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -48,7 +48,7 @@ use crate::commands::{pip, project, ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::{ResolverInstallerSettings, ResolverInstallerSettingsRef}; -use super::init::get_python_requirement_for_new_script; +use super::get_python_requirement_for_new_script; /// Add one or more packages to the project requirements. #[allow(clippy::fn_params_excessive_bools)] @@ -131,7 +131,7 @@ pub(crate) async fn add( script } else { let requires_python = get_python_requirement_for_new_script( - &python, + python.as_deref(), false, python_preference, python_downloads, @@ -140,7 +140,7 @@ pub(crate) async fn add( &reporter, ) .await?; - Pep723Script::create(&script, requires_python.specifiers()).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 1fadfe699592..fb806132a74f 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -1,4 +1,5 @@ use std::fmt::Write; +use std::io::Write as FileWrite; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; @@ -24,13 +25,15 @@ use crate::commands::reporters::PythonDownloadReporter; use crate::commands::ExitStatus; use crate::printer::Printer; +use super::get_python_requirement_for_new_script; + /// Add one or more packages to the project requirements. #[allow(clippy::single_match_else, clippy::fn_params_excessive_bools)] pub(crate) async fn init( explicit_path: Option, name: Option, package: bool, - project_kind: InitProjectKind, + init_kind: InitKind, no_readme: bool, no_pin_python: bool, python: Option, @@ -43,108 +46,112 @@ pub(crate) async fn init( printer: Printer, ) -> Result { // If user seeks to initialize a new script, process the request immediately - if let InitProjectKind::Script = project_kind { - if let Some(script_path) = explicit_path { - project_kind - .init_script( - &PathBuf::from(&script_path), - python, - connectivity, - python_preference, - python_downloads, - cache, - printer, - no_workspace, - no_readme, - no_pin_python, - package, - native_tls, - ) - .await?; + match init_kind { + InitKind::Script => { + if let Some(script_path) = explicit_path { + init_kind + .init_script( + &PathBuf::from(&script_path), + python, + connectivity, + python_preference, + python_downloads, + cache, + printer, + no_workspace, + no_readme, + no_pin_python, + package, + native_tls, + ) + .await?; - writeln!( - printer.stderr(), - "Initialized script at `{}`", - script_path.cyan() - )?; - return Ok(ExitStatus::Success); + writeln!( + printer.stderr(), + "Initialized script at `{}`", + script_path.cyan() + )?; + return Ok(ExitStatus::Success); + } + anyhow::bail!("Filename not provided for script"); } - anyhow::bail!("Filename not provided for script"); - } + InitKind::Project(project_kind) => { + // Default to the current directory if a path was not provided. + let path = match explicit_path { + None => CWD.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 current directory if a path was not provided. - let path = match explicit_path { - None => CWD.to_path_buf(), - Some(ref path) => std::path::absolute(path)?, - }; + // 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")?; - // 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?; - PackageName::new(name.to_string())? - } - }; + // 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())?; + } + } - 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() + )?; + } + } - 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) } } - - Ok(ExitStatus::Success) } /// Initialize a project (and, implicitly, a workspace root) at the given path. @@ -419,12 +426,103 @@ async fn init_project( Ok(()) } +#[derive(Debug, Copy, Clone)] +pub(crate) enum InitKind { + Project(InitProjectKind), + Script, +} + +impl Default for InitKind { + fn default() -> Self { + InitKind::Project(InitProjectKind::default()) + } +} + #[derive(Debug, Copy, Clone, Default)] pub(crate) enum InitProjectKind { #[default] Application, Library, - Script, +} + +impl InitKind { + #[allow(clippy::fn_params_excessive_bools)] + async fn init_script( + self, + script_path: &PathBuf, + 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); + + let metadata = fs_err::tokio::metadata(script_path).await?; + let mut content_future = None; + + if metadata.is_dir() { + anyhow::bail!("{} is a directory", script_path.to_str().unwrap()); + } else if metadata.is_file() { + if Pep723Script::read(script_path).await?.is_some() { + anyhow::bail!("{} is a PEP 723 script", script_path.to_str().unwrap()); + } + + content_future = Some(fs_err::tokio::read(&script_path)) + } + + let requires_python = get_python_requirement_for_new_script( + python.as_deref(), + no_pin_python, + python_preference, + python_downloads, + &client_builder, + cache, + &reporter, + ) + .await?; + + if let Some(parent_path) = script_path.parent() { + fs_err::tokio::create_dir_all(parent_path).await?; + } + + let existing_contents = if let Some(contents) = content_future { + Some(contents.await?) + } else { + None + }; + + Pep723Script::create(script_path, requires_python.specifiers()).await?; + + if let Some(contents) = existing_contents { + let mut file = fs_err::OpenOptions::new() + .write(true) + .append(true) + .open(script_path)?; + file.write_all(&contents)?; + } + + Ok(()) + } } impl InitProjectKind { @@ -461,10 +559,6 @@ impl InitProjectKind { ) .await } - InitProjectKind::Script => { - debug!("Script should be initialized directly via init_script"); - anyhow::bail!("Error during script initialization") - } } } @@ -608,102 +702,6 @@ impl InitProjectKind { Ok(()) } - - #[allow(clippy::fn_params_excessive_bools)] - async fn init_script( - self, - script_path: &PathBuf, - 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 script_path.try_exists()? { - anyhow::bail!("Script already exists at {}", script_path.to_str().unwrap()); - } - - let requires_python = get_python_requirement_for_new_script( - &python, - no_pin_python, - python_preference, - python_downloads, - &client_builder, - cache, - &reporter, - ) - .await?; - - if let Some(path) = script_path.parent() { - fs_err::tokio::create_dir_all(path).await?; - } - Pep723Script::create_new_script(script_path, requires_python.specifiers()).await?; - - Ok(()) - } -} - -pub(crate) async fn get_python_requirement_for_new_script( - python: &Option, - no_pin_python: bool, - python_preference: PythonPreference, - python_downloads: PythonDownloads, - client_builder: &BaseClientBuilder<'_>, - cache: &Cache, - reporter: &PythonDownloadReporter, -) -> Result { - let python_request = if let Some(request) = python.as_deref() { - // (1) Explicit request from user - PythonRequest::parse(request) - } else if let (false, Some(request)) = ( - no_pin_python, - PythonVersionFile::discover(&*CWD, 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(), - )) } /// Generate the `[project]` section of a `pyproject.toml`. diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 5324ac9e393e..37a6b25340a2 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -15,7 +15,7 @@ use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClient use uv_configuration::{Concurrency, Constraints, ExtrasSpecification, Reinstall, Upgrade}; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; -use uv_fs::Simplified; +use uv_fs::{Simplified, CWD}; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_python::{ @@ -1207,6 +1207,48 @@ pub(crate) async fn update_environment( }) } +pub(crate) async fn get_python_requirement_for_new_script( + python: Option<&str>, + 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.as_deref() { + // (1) Explicit request from user + PythonRequest::parse(request) + } else if let (false, Some(request)) = ( + no_pin_python, + PythonVersionFile::discover(&*CWD, 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, From 998ce39e51920de8eeda290dcb480132a14ca2e1 Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Tue, 24 Sep 2024 10:58:37 -0700 Subject: [PATCH 11/19] Fix tests --- crates/uv-scripts/src/lib.rs | 15 +++++++-- crates/uv/src/commands/mod.rs | 2 +- crates/uv/src/commands/project/init.rs | 42 ++++++++++++-------------- crates/uv/src/commands/project/mod.rs | 2 +- crates/uv/src/settings.rs | 15 ++++----- crates/uv/tests/init.rs | 35 ++++++++++++++++++--- docs/guides/scripts.md | 4 +-- docs/reference/cli.md | 2 +- 8 files changed, 74 insertions(+), 43 deletions(-) diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 40288f838261..d02be7dca1eb 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -98,6 +98,7 @@ impl Pep723Script { pub async fn create( script_path: &PathBuf, requires_python: &VersionSpecifiers, + existing_contents: Option>, ) -> Result<(), Pep723Error> { let script_name = script_path .file_name() @@ -114,7 +115,14 @@ impl Pep723Script { }; let metadata = serialize_metadata(&default_metadata); - let script = indoc::formatdoc! {r#" + let script = if let Some(existing_contents) = existing_contents { + indoc::formatdoc! {r#" + {metadata} + {existing_contents} + "#, metadata = metadata, + existing_contents = String::from_utf8(existing_contents).map_err(|e| Pep723Error::Utf8(e.utf8_error()))?} + } else { + indoc::formatdoc! {r#" {metadata} def main() -> None: print("Hello from {name}!") @@ -122,8 +130,9 @@ impl Pep723Script { if __name__ == "__main__": main() "#, - metadata = metadata, - name = script_name, + metadata = metadata, + name = script_name, + } }; Ok(fs_err::tokio::write(script_path, script).await?) diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index e9caaa99c415..8cfc3d9b0910 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/init.rs b/crates/uv/src/commands/project/init.rs index fb806132a74f..1d5c9c2222e6 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -1,5 +1,4 @@ use std::fmt::Write; -use std::io::Write as FileWrite; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; @@ -446,6 +445,11 @@ pub(crate) enum InitProjectKind { } impl InitKind { + /// Checks if this is a project, packaged by default. + pub(crate) fn project_packaged_by_default(self) -> bool { + matches!(self, InitKind::Project(InitProjectKind::Library)) + } + #[allow(clippy::fn_params_excessive_bools)] async fn init_script( self, @@ -477,17 +481,22 @@ impl InitKind { let reporter = PythonDownloadReporter::single(printer); - let metadata = fs_err::tokio::metadata(script_path).await?; + let metadata = fs_err::tokio::metadata(script_path).await; let mut content_future = None; - if metadata.is_dir() { - anyhow::bail!("{} is a directory", script_path.to_str().unwrap()); - } else if metadata.is_file() { - if Pep723Script::read(script_path).await?.is_some() { - anyhow::bail!("{} is a PEP 723 script", script_path.to_str().unwrap()); - } + if let Ok(metadata) = metadata { + if metadata.is_dir() { + anyhow::bail!("{} is a directory", script_path.to_str().unwrap()); + } else if metadata.is_file() { + if Pep723Script::read(script_path).await?.is_some() { + anyhow::bail!( + "{} is already a PEP 723 script", + script_path.to_str().unwrap() + ); + } - content_future = Some(fs_err::tokio::read(&script_path)) + content_future = Some(fs_err::tokio::read(&script_path)); + } } let requires_python = get_python_requirement_for_new_script( @@ -511,15 +520,7 @@ impl InitKind { None }; - Pep723Script::create(script_path, requires_python.specifiers()).await?; - - if let Some(contents) = existing_contents { - let mut file = fs_err::OpenOptions::new() - .write(true) - .append(true) - .open(script_path)?; - file.write_all(&contents)?; - } + Pep723Script::create(script_path, requires_python.specifiers(), existing_contents).await?; Ok(()) } @@ -562,11 +563,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, diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 37a6b25340a2..d95945a315cb 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1216,7 +1216,7 @@ pub(crate) async fn get_python_requirement_for_new_script( cache: &Cache, reporter: &PythonDownloadReporter, ) -> anyhow::Result { - let python_request = if let Some(request) = python.as_deref() { + let python_request = if let Some(request) = python { // (1) Explicit request from user PythonRequest::parse(request) } else if let (false, Some(request)) = ( diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 30afd910da1c..11f75c83397b 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -36,7 +36,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 resolved global settings to use for any invocation of the CLI. #[allow(clippy::struct_excessive_bools)] @@ -155,7 +155,7 @@ pub(crate) struct InitSettings { 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, @@ -182,14 +182,15 @@ impl InitSettings { } = args; let kind = match (app, lib, script) { - (true, false, false) => InitProjectKind::Application, - (false, true, false) => InitProjectKind::Library, - (false, false, true) => InitProjectKind::Script, - (false, false, false) => InitProjectKind::default(), + (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()); + let package = + flag(package || r#virtual, no_package).unwrap_or(kind.project_packaged_by_default()); Self { path, diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index 8101b17b161a..75161cf0c9cf 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -431,7 +431,7 @@ fn init_script() -> Result<()> { # dependencies = [] # /// - def main(): + def main() -> None: print("Hello from hello.py!") if __name__ == "__main__": @@ -482,7 +482,7 @@ fn init_script_python_version() -> Result<()> { # dependencies = [] # /// - def main(): + def main() -> None: print("Hello from version.py!") if __name__ == "__main__": @@ -524,7 +524,7 @@ fn init_script_create_directory() -> Result<()> { # dependencies = [] # /// - def main(): + def main() -> None: print("Hello from dir.py!") if __name__ == "__main__": @@ -536,7 +536,7 @@ fn init_script_create_directory() -> Result<()> { Ok(()) } -// Init script should fail if file path exists +// 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"); @@ -559,9 +559,34 @@ fn init_script_file_conflicts() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Script already exists at name_conflict.py + error: name_conflict.py is already a PEP 723 script "###); + 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(()) } diff --git a/docs/guides/scripts.md b/docs/guides/scripts.md index dfbef8030d1b..cf25eaacf73c 100644 --- a/docs/guides/scripts.md +++ b/docs/guides/scripts.md @@ -127,11 +127,11 @@ 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. -## Creating a python script +## 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). -It allows for selecting python versions and defining dependencies. Use `uv init --script` to +It allows for selecting Python versions and defining dependencies. Use `uv init --script` to initialize scripts with the inline metadata: ```console diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a525eef894af..117e6b8f6bfc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -518,7 +518,7 @@ uv init [OPTIONS] [PATH]
--script

Create a script.

-

A script is a standalone file which adheres to the PEP-723 specification.

+

A script is a standalone file which adheres to the PEP 723 specification.

By default, the Python version the script depends on is the system version; can be manually specified with the --python argument (takes absolute precedence) or a .python-version file (ignore with --no_pin_python).

From 77cb72b6855923b743cc30f8036101018a130002 Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Tue, 24 Sep 2024 11:06:42 -0700 Subject: [PATCH 12/19] Fix redundant name --- crates/uv/src/commands/project/init.rs | 2 +- crates/uv/src/settings.rs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 1852cc918ead..583d2eb2bc7b 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -453,7 +453,7 @@ pub(crate) enum InitProjectKind { impl InitKind { /// Checks if this is a project, packaged by default. - pub(crate) fn project_packaged_by_default(self) -> bool { + pub(crate) fn packaged_by_default(self) -> bool { matches!(self, InitKind::Project(InitProjectKind::Library)) } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index d49fdfb649e8..b08fc1ebba93 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -195,8 +195,7 @@ impl InitSettings { (_, _, _) => unreachable!("`app`, `lib`, and `script` are mutually exclusive"), }; - let package = - flag(package || r#virtual, no_package).unwrap_or(kind.project_packaged_by_default()); + let package = flag(package || r#virtual, no_package).unwrap_or(kind.packaged_by_default()); Self { path, From bfdab1d1fceb0ab1506d0f4dbcf7e828119a7f9a Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Tue, 24 Sep 2024 11:09:30 -0700 Subject: [PATCH 13/19] Fix lint --- crates/uv/src/commands/project/init.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 583d2eb2bc7b..155aea358144 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -508,7 +508,7 @@ impl InitKind { let requires_python = get_python_requirement_for_new_script( python.as_deref(), - &*CWD, + &CWD, no_pin_python, python_preference, python_downloads, From 7f15b0a99f3c0b083a3c178ceb6eb4e7856d3d21 Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Tue, 24 Sep 2024 11:23:06 -0700 Subject: [PATCH 14/19] Modify test --- crates/uv/tests/init.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index f477e0c82401..bc210856b501 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -563,7 +563,7 @@ fn init_script_file_conflicts() -> Result<()> { "###); let contents = "print(\"Hello, world!\")"; - fs_err::write(child.join("existing_script.py"), &contents)?; + 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 From d0c4ae239356e567aa64cb0c74e94acdadb6be49 Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Tue, 24 Sep 2024 12:03:32 -0700 Subject: [PATCH 15/19] Change name --- crates/uv/src/commands/project/add.rs | 4 ++-- crates/uv/src/commands/project/init.rs | 4 ++-- crates/uv/src/commands/project/mod.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 73d03ab15205..1f600ed3207e 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -48,7 +48,7 @@ use crate::commands::{pip, project, ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::{ResolverInstallerSettings, ResolverInstallerSettingsRef}; -use super::get_python_requirement_for_new_script; +use super::get_python_requirement_for_script; /// Add one or more packages to the project requirements. #[allow(clippy::fn_params_excessive_bools)] @@ -131,7 +131,7 @@ pub(crate) async fn add( let script = if let Some(script) = Pep723Script::read(&script).await? { script } else { - let requires_python = get_python_requirement_for_new_script( + let requires_python = get_python_requirement_for_script( python.as_deref(), &project_dir.to_path_buf(), false, diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 155aea358144..ceb9dc573040 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -24,7 +24,7 @@ use crate::commands::reporters::PythonDownloadReporter; use crate::commands::ExitStatus; use crate::printer::Printer; -use super::get_python_requirement_for_new_script; +use super::get_python_requirement_for_script; /// Add one or more packages to the project requirements. #[allow(clippy::single_match_else, clippy::fn_params_excessive_bools)] @@ -506,7 +506,7 @@ impl InitKind { } } - let requires_python = get_python_requirement_for_new_script( + let requires_python = get_python_requirement_for_script( python.as_deref(), &CWD, no_pin_python, diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 0725ef678fb4..845797c01c0a 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1265,7 +1265,7 @@ pub(crate) async fn update_environment( }) } -pub(crate) async fn get_python_requirement_for_new_script( +pub(crate) async fn get_python_requirement_for_script( python: Option<&str>, directory: &PathBuf, no_pin_python: bool, From 2a761fa51cab899f86ebee4f828b77f250dddafd Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Tue, 24 Sep 2024 12:06:18 -0700 Subject: [PATCH 16/19] Clarify error message --- crates/uv/src/commands/project/init.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index ceb9dc573040..d3168ae3b31b 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -493,7 +493,7 @@ impl InitKind { if let Ok(metadata) = metadata { if metadata.is_dir() { - anyhow::bail!("{} is a directory", script_path.to_str().unwrap()); + anyhow::bail!("{} is an existing directory", script_path.to_str().unwrap()); } else if metadata.is_file() { if Pep723Script::read(script_path).await?.is_some() { anyhow::bail!( From c04dada3d1c45be5ce4482b9fdd92c24f019780c Mon Sep 17 00:00:00 2001 From: Tej Singh Date: Wed, 25 Sep 2024 11:04:50 -0700 Subject: [PATCH 17/19] Small change --- crates/uv/src/commands/project/init.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index d3168ae3b31b..57bbd2811362 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -489,7 +489,7 @@ impl InitKind { let reporter = PythonDownloadReporter::single(printer); let metadata = fs_err::tokio::metadata(script_path).await; - let mut content_future = None; + let mut existing_content = None; if let Ok(metadata) = metadata { if metadata.is_dir() { @@ -502,7 +502,7 @@ impl InitKind { ); } - content_future = Some(fs_err::tokio::read(&script_path)); + existing_content = Some(fs_err::tokio::read(&script_path).await?); } } @@ -522,13 +522,7 @@ impl InitKind { fs_err::tokio::create_dir_all(parent_path).await?; } - let existing_contents = if let Some(contents) = content_future { - Some(contents.await?) - } else { - None - }; - - Pep723Script::create(script_path, requires_python.specifiers(), existing_contents).await?; + Pep723Script::create(script_path, requires_python.specifiers(), existing_content).await?; Ok(()) } From 09d18a639997e61ce2f8915683b657b3c1a6f303 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 25 Sep 2024 18:22:24 -0400 Subject: [PATCH 18/19] Make stock script ruff-compliant --- crates/uv-scripts/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index d02be7dca1eb..4767f8f3e160 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -124,9 +124,11 @@ impl Pep723Script { } else { indoc::formatdoc! {r#" {metadata} + def main() -> None: print("Hello from {name}!") + if __name__ == "__main__": main() "#, From 222ee174140212625ccb9145a3c4649af0630847 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 25 Sep 2024 18:37:38 -0400 Subject: [PATCH 19/19] Minor tweaks --- crates/uv-cli/src/lib.rs | 12 +- crates/uv-scripts/src/lib.rs | 24 +-- crates/uv/src/commands/project/add.rs | 8 +- crates/uv/src/commands/project/init.rs | 225 +++++++++++++------------ crates/uv/src/commands/project/mod.rs | 7 +- crates/uv/src/settings.rs | 2 +- crates/uv/tests/init.rs | 56 +++--- docs/reference/cli.md | 6 +- 8 files changed, 178 insertions(+), 162 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 3b3bbf5a6d4e..d46df6b4012b 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2310,7 +2310,7 @@ pub struct InitArgs { /// target path, the project will be added as a workspace member of the /// parent, unless `--no-workspace` is provided. #[arg(required_if_eq("script", "true"))] - pub path: Option, + pub path: Option, /// The name of the project. /// @@ -2363,11 +2363,13 @@ pub struct InitArgs { /// Create a script. /// - /// A script is a standalone file which adheres to the PEP 723 specification. + /// 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, the Python version the script depends on is the system version; can be - /// manually specified with the --python argument (takes absolute precedence) or a - /// .python-version file (ignore with --`no_pin_python`). + /// 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, diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 4767f8f3e160..f257eb3f71c8 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -95,32 +95,32 @@ impl Pep723Script { }) } + /// Create a PEP 723 script at the given path. pub async fn create( - script_path: &PathBuf, + file: impl AsRef, requires_python: &VersionSpecifiers, existing_contents: Option>, ) -> Result<(), Pep723Error> { - let script_name = script_path + let file = file.as_ref(); + + let script_name = file .file_name() .and_then(|name| name.to_str()) - .ok_or_else(|| { - Pep723Error::InvalidFilename(script_path.to_string_lossy().into_owned()) - })?; + .ok_or_else(|| Pep723Error::InvalidFilename(file.to_string_lossy().to_string()))?; let default_metadata = indoc::formatdoc! {r#" requires-python = "{requires_python}" dependencies = [] "#, - requires_python = requires_python, }; let metadata = serialize_metadata(&default_metadata); let script = if let Some(existing_contents) = existing_contents { indoc::formatdoc! {r#" {metadata} - {existing_contents} - "#, metadata = metadata, - existing_contents = String::from_utf8(existing_contents).map_err(|e| Pep723Error::Utf8(e.utf8_error()))?} + {content} + "#, + content = String::from_utf8(existing_contents).map_err(|err| Pep723Error::Utf8(err.utf8_error()))?} } else { indoc::formatdoc! {r#" {metadata} @@ -137,7 +137,7 @@ impl Pep723Script { } }; - Ok(fs_err::tokio::write(script_path, script).await?) + Ok(fs_err::tokio::write(file, script).await?) } /// Replace the existing metadata in the file with new metadata and write the updated content. @@ -211,7 +211,7 @@ pub enum Pep723Error { } #[derive(Debug, Clone, Eq, PartialEq)] -struct ScriptTag { +pub struct ScriptTag { /// The content of the script before the metadata block. prelude: String, /// The metadata block. @@ -249,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/project/add.rs b/crates/uv/src/commands/project/add.rs index 1f600ed3207e..89f3bd0c198b 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -42,14 +42,12 @@ 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; use crate::settings::{ResolverInstallerSettings, ResolverInstallerSettingsRef}; -use super::get_python_requirement_for_script; - /// Add one or more packages to the project requirements. #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn add( @@ -131,9 +129,9 @@ pub(crate) async fn add( let script = if let Some(script) = Pep723Script::read(&script).await? { script } else { - let requires_python = get_python_requirement_for_script( + let requires_python = script_python_requirement( python.as_deref(), - &project_dir.to_path_buf(), + project_dir, false, python_preference, python_downloads, diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 57bbd2811362..8d85dba265eb 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -14,23 +14,21 @@ use uv_python::{ PythonVersionFile, VersionRequest, }; use uv_resolver::RequiresPython; -use uv_scripts::Pep723Script; +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; -use super::get_python_requirement_for_script; - /// Add one or more packages to the project requirements. #[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, init_kind: InitKind, @@ -47,32 +45,31 @@ pub(crate) async fn init( ) -> Result { match init_kind { InitKind::Script => { - if let Some(script_path) = explicit_path { - init_kind - .init_script( - &PathBuf::from(&script_path), - python, - connectivity, - python_preference, - python_downloads, - cache, - printer, - no_workspace, - no_readme, - no_pin_python, - package, - native_tls, - ) - .await?; + let Some(path) = explicit_path.as_deref() else { + anyhow::bail!("Script initialization requires a file path") + }; - writeln!( - printer.stderr(), - "Initialized script at `{}`", - script_path.cyan() - )?; - return Ok(ExitStatus::Success); - } - anyhow::bail!("Filename not provided for script"); + init_script( + path, + python, + connectivity, + python_preference, + python_downloads, + cache, + printer, + no_workspace, + no_readme, + no_pin_python, + package, + native_tls, + ) + .await?; + + writeln!( + printer.stderr(), + "Initialized script at `{}`", + path.user_display().cyan() + )?; } InitKind::Project(project_kind) => { // Default to the current directory if a path was not provided. @@ -147,10 +144,84 @@ pub(crate) async fn init( )?; } } + } + } + + 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() + ); + } - Ok(ExitStatus::Success) + 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. @@ -432,9 +503,12 @@ 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, } @@ -444,88 +518,21 @@ impl Default for InitKind { } } +/// 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 { - /// Checks if this is a project, packaged by default. + /// Returns `true` if the project should be packaged by default. pub(crate) fn packaged_by_default(self) -> bool { matches!(self, InitKind::Project(InitProjectKind::Library)) } - - #[allow(clippy::fn_params_excessive_bools)] - async fn init_script( - self, - script_path: &PathBuf, - 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); - - let metadata = fs_err::tokio::metadata(script_path).await; - let mut existing_content = None; - - if let Ok(metadata) = metadata { - if metadata.is_dir() { - anyhow::bail!("{} is an existing directory", script_path.to_str().unwrap()); - } else if metadata.is_file() { - if Pep723Script::read(script_path).await?.is_some() { - anyhow::bail!( - "{} is already a PEP 723 script", - script_path.to_str().unwrap() - ); - } - - existing_content = Some(fs_err::tokio::read(&script_path).await?); - } - } - - let requires_python = get_python_requirement_for_script( - python.as_deref(), - &CWD, - no_pin_python, - python_preference, - python_downloads, - &client_builder, - cache, - &reporter, - ) - .await?; - - if let Some(parent_path) = script_path.parent() { - fs_err::tokio::create_dir_all(parent_path).await?; - } - - Pep723Script::create(script_path, requires_python.specifiers(), existing_content).await?; - - Ok(()) - } } impl InitProjectKind { @@ -709,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 845797c01c0a..e1aad07593f0 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,9 +1265,10 @@ pub(crate) async fn update_environment( }) } -pub(crate) async fn get_python_requirement_for_script( +/// Determine the [`RequiresPython`] requirement for a PEP 723 script. +pub(crate) async fn script_python_requirement( python: Option<&str>, - directory: &PathBuf, + directory: &Path, no_pin_python: bool, python_preference: PythonPreference, python_downloads: PythonDownloads, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index b08fc1ebba93..7a1ec6a4ce2d 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -158,7 +158,7 @@ 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: InitKind, diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index bc210856b501..0a8f99307f1c 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -426,16 +426,18 @@ fn init_script() -> Result<()> { }, { assert_snapshot!( script, @r###" - # /// script - # requires-python = ">=3.12" - # dependencies = [] - # /// + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// - def main() -> None: - print("Hello from hello.py!") - if __name__ == "__main__": - main() + def main() -> None: + print("Hello from hello.py!") + + + if __name__ == "__main__": + main() "### ); }); @@ -477,16 +479,18 @@ fn init_script_python_version() -> Result<()> { }, { assert_snapshot!( script, @r###" - # /// script - # requires-python = ">=3.11" - # dependencies = [] - # /// + # /// script + # requires-python = ">=3.11" + # dependencies = [] + # /// + + + def main() -> None: + print("Hello from version.py!") - def main() -> None: - print("Hello from version.py!") - if __name__ == "__main__": - main() + if __name__ == "__main__": + main() "### ); }); @@ -519,16 +523,18 @@ fn init_script_create_directory() -> Result<()> { }, { assert_snapshot!( script, @r###" - # /// script - # requires-python = ">=3.12" - # dependencies = [] - # /// + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + - def main() -> None: - print("Hello from dir.py!") + def main() -> None: + print("Hello from dir.py!") - if __name__ == "__main__": - main() + + if __name__ == "__main__": + main() "### ); }); @@ -559,7 +565,7 @@ fn init_script_file_conflicts() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: name_conflict.py is already a PEP 723 script + error: `name_conflict.py` is already a PEP 723 script; use `uv run` to execute it "###); let contents = "print(\"Hello, world!\")"; diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b4c381ba97f0..41f7173ac872 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -552,9 +552,11 @@ uv init [OPTIONS] [PATH]
--script

Create a script.

-

A script is a standalone file which adheres to the PEP 723 specification.

+

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.

-

By default, the Python version the script depends on is the system version; can be manually specified with the --python argument (takes absolute precedence) or a .python-version file (ignore with --no_pin_python).

+

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.