diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index ac3704ec8..8f77936ad 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2346,6 +2346,17 @@ impl ExternalCommand { } } +#[derive(Debug, Default, Copy, Clone, clap::ValueEnum)] +pub enum AuthorFrom { + /// Fetch the author information from some sources (e.g., Git) automatically. + #[default] + Auto, + /// Fetch the author information from Git configuration only. + Git, + /// Do not infer the author information. + None, +} + #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct InitArgs { @@ -2432,6 +2443,14 @@ pub struct InitArgs { #[arg(long)] pub no_readme: bool, + /// Fill in the `authors` field in the `pyproject.toml`. + /// + /// By default, uv will attempt to infer the author information from some sources (e.g., Git) (`auto`). + /// Use `--author-from git` to only infer from Git configuration. + /// Use `--author-from none` to avoid inferring the author information. + #[arg(long, value_enum)] + pub author_from: Option, + /// Do not create a `.python-version` file for the project. /// /// By default, uv will create a `.python-version` file containing the minor version of diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 79a4a8ffc..ef22f2469 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -1,11 +1,13 @@ use std::fmt::Write; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use anyhow::{anyhow, Context, Result}; use owo_colors::OwoColorize; use tracing::{debug, warn}; use uv_cache::Cache; +use uv_cli::AuthorFrom; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{VersionControlError, VersionControlSystem}; use uv_fs::{Simplified, CWD}; @@ -36,6 +38,7 @@ pub(crate) async fn init( init_kind: InitKind, vcs: Option, no_readme: bool, + author_from: Option, no_pin_python: bool, python: Option, no_workspace: bool, @@ -62,6 +65,7 @@ pub(crate) async fn init( printer, no_workspace, no_readme, + author_from, no_pin_python, package, native_tls, @@ -111,6 +115,7 @@ pub(crate) async fn init( project_kind, vcs, no_readme, + author_from, no_pin_python, python, no_workspace, @@ -165,6 +170,7 @@ async fn init_script( printer: Printer, no_workspace: bool, no_readme: bool, + author_from: Option, no_pin_python: bool, package: bool, native_tls: bool, @@ -175,6 +181,9 @@ async fn init_script( if no_readme { warn_user_once!("`--no_readme` is a no-op for Python scripts, which are standalone"); } + if author_from.is_some() { + warn_user_once!("`--author-from` 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"); } @@ -237,6 +246,7 @@ async fn init_project( project_kind: InitProjectKind, vcs: Option, no_readme: bool, + author_from: Option, no_pin_python: bool, python: Option, no_workspace: bool, @@ -475,6 +485,7 @@ async fn init_project( &requires_python, python_request.as_ref(), vcs, + author_from, no_readme, package, ) @@ -564,6 +575,7 @@ impl InitProjectKind { requires_python: &RequiresPython, python_request: Option<&PythonRequest>, vcs: Option, + author_from: Option, no_readme: bool, package: bool, ) -> Result<()> { @@ -575,6 +587,7 @@ impl InitProjectKind { requires_python, python_request, vcs, + author_from, no_readme, package, ) @@ -587,6 +600,7 @@ impl InitProjectKind { requires_python, python_request, vcs, + author_from, no_readme, package, ) @@ -603,11 +617,24 @@ impl InitProjectKind { requires_python: &RequiresPython, python_request: Option<&PythonRequest>, vcs: Option, + author_from: Option, no_readme: bool, package: bool, ) -> Result<()> { + fs_err::create_dir_all(path)?; + + // Do no fill in `authors` for non-packaged applications unless explicitly requested. + let author_from = author_from.unwrap_or_else(|| { + if package { + AuthorFrom::default() + } else { + AuthorFrom::None + } + }); + let author = get_author_info(path, author_from); + // Create the `pyproject.toml` - let mut pyproject = pyproject_project(name, requires_python, no_readme); + let mut pyproject = pyproject_project(name, requires_python, author.as_ref(), no_readme); // Include additional project configuration for packaged applications if package { @@ -620,8 +647,6 @@ impl InitProjectKind { pyproject.push_str(pyproject_build_system()); } - fs_err::create_dir_all(path)?; - // Create the source structure. if package { // Create `src/{name}/__init__.py`, if it doesn't exist already. @@ -684,6 +709,7 @@ impl InitProjectKind { requires_python: &RequiresPython, python_request: Option<&PythonRequest>, vcs: Option, + author_from: Option, no_readme: bool, package: bool, ) -> Result<()> { @@ -691,14 +717,17 @@ impl InitProjectKind { return Err(anyhow!("Library projects must be packaged")); } + fs_err::create_dir_all(path)?; + + let author = get_author_info(path, author_from.unwrap_or_default()); + // Create the `pyproject.toml` - let mut pyproject = pyproject_project(name, requires_python, no_readme); + let mut pyproject = pyproject_project(name, requires_python, author.as_ref(), no_readme); // Always include a build system if the project is packaged. pyproject.push('\n'); pyproject.push_str(pyproject_build_system()); - fs_err::create_dir_all(path)?; fs_err::write(path.join("pyproject.toml"), pyproject)?; // Create `src/{name}/__init__.py`, if it doesn't exist already. @@ -742,21 +771,42 @@ impl InitProjectKind { } } +#[derive(Debug)] +enum Author { + Name(String), + Email(String), + NameEmail { name: String, email: String }, +} + +impl Author { + fn to_toml_string(&self) -> String { + match self { + Self::NameEmail { name, email } => { + format!("{{ name = \"{name}\", email = \"{email}\" }}") + } + Self::Name(name) => format!("{{ name = \"{name}\" }}"), + Self::Email(email) => format!("{{ email = \"{email}\" }}"), + } + } +} + /// Generate the `[project]` section of a `pyproject.toml`. fn pyproject_project( name: &PackageName, requires_python: &RequiresPython, + author: Option<&Author>, no_readme: bool, ) -> String { indoc::formatdoc! {r#" [project] name = "{name}" version = "0.1.0" - description = "Add your description here"{readme} + description = "Add your description here"{readme}{authors} requires-python = "{requires_python}" dependencies = [] "#, readme = if no_readme { "" } else { "\nreadme = \"README.md\"" }, + authors = author.map_or_else(String::new, |author| format!("\nauthors = [\n {} \n]", author.to_toml_string())), requires_python = requires_python.specifiers(), } } @@ -826,3 +876,63 @@ fn init_vcs(path: &Path, vcs: Option) -> Result<()> { Ok(()) } + +/// Try to get the author information. +/// +/// Currently, this only tries to get the author information from git. +fn get_author_info(path: &Path, author_from: AuthorFrom) -> Option { + if matches!(author_from, AuthorFrom::None) { + return None; + } + if matches!(author_from, AuthorFrom::Auto | AuthorFrom::Git) { + match get_author_from_git(path) { + Ok(author) => return Some(author), + Err(err) => warn!("Failed to get author from git: {err}"), + } + } + + None +} + +/// Fetch the default author from git configuration. +fn get_author_from_git(path: &Path) -> Result { + let Ok(git) = which::which("git") else { + anyhow::bail!("`git` not found in PATH") + }; + + let mut name = None; + let mut email = None; + + let output = Command::new(&git) + .arg("config") + .arg("get") + .arg("user.name") + .current_dir(path) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output()?; + if output.status.success() { + name = Some(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + + let output = Command::new(&git) + .arg("config") + .arg("get") + .arg("user.email") + .current_dir(path) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output()?; + if output.status.success() { + email = Some(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + + let author = match (name, email) { + (Some(name), Some(email)) => Author::NameEmail { name, email }, + (Some(name), None) => Author::Name(name), + (None, Some(email)) => Author::Email(email), + (None, None) => anyhow::bail!("No author information found"), + }; + + Ok(author) +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index ef7e9513f..73836748d 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1198,6 +1198,7 @@ async fn run_project( args.kind, args.vcs, args.no_readme, + args.author_from, args.no_pin_python, args.python, args.no_workspace, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index d0ec6e30f..b9048eeef 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -8,7 +8,7 @@ use url::Url; use uv_cache::{CacheArgs, Refresh}; use uv_cli::{ options::{flag, resolver_installer_options, resolver_options}, - BuildArgs, ExportArgs, PublishArgs, ToolUpgradeArgs, + AuthorFrom, BuildArgs, ExportArgs, PublishArgs, ToolUpgradeArgs, }; use uv_cli::{ AddArgs, ColorChoice, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe, @@ -164,6 +164,7 @@ pub(crate) struct InitSettings { pub(crate) kind: InitKind, pub(crate) vcs: Option, pub(crate) no_readme: bool, + pub(crate) author_from: Option, pub(crate) no_pin_python: bool, pub(crate) no_workspace: bool, pub(crate) python: Option, @@ -184,6 +185,7 @@ impl InitSettings { script, vcs, no_readme, + author_from, no_pin_python, no_workspace, python, @@ -206,6 +208,7 @@ impl InitSettings { kind, vcs, no_readme, + author_from, no_pin_python, no_workspace, python: python.and_then(Maybe::into_option), diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index c735eeab6..8ec8d3d22 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -3,6 +3,7 @@ use std::process::Command; use anyhow::Result; +use assert_cmd::prelude::OutputAssertExt; use assert_fs::prelude::*; use indoc::indoc; use insta::assert_snapshot; @@ -2188,13 +2189,14 @@ fn init_vcs_none() { /// Run `uv init` from within a Git repository. Do not try to reinitialize one. #[test] -fn init_inside_git_repo() -> Result<()> { +fn init_inside_git_repo() { let context = TestContext::new("3.12"); Command::new("git") .arg("init") .current_dir(&context.temp_dir) - .status()?; + .assert() + .success(); let child = context.temp_dir.child("foo"); @@ -2220,8 +2222,6 @@ fn init_inside_git_repo() -> Result<()> { "###); child.child(".gitignore").assert(predicate::path::missing()); - - Ok(()) } #[test] @@ -2252,3 +2252,134 @@ fn init_git_not_installed() { error: Attempted to initialize a Git repository, but `git` was not found in PATH "###); } + +#[test] +fn init_with_author() { + let context = TestContext::new("3.12"); + + // Create a Git repository and set the author. + Command::new("git") + .arg("init") + .current_dir(&context.temp_dir) + .assert() + .success(); + Command::new("git") + .arg("config") + .arg("--local") + .arg("user.name") + .arg("Alice") + .current_dir(&context.temp_dir) + .assert() + .success(); + Command::new("git") + .arg("config") + .arg("--local") + .arg("user.email") + .arg("alice@example.com") + .current_dir(&context.temp_dir) + .assert() + .success(); + + // `authors` is not filled for non-package application by default, + context.init().arg("foo").assert().success(); + let pyproject = context.read("foo/pyproject.toml"); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject, @r#" + [project] + name = "foo" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + "# + ); + }); + + // use `--author-from auto` to explicitly fill it. + context + .init() + .arg("bar") + .arg("--author-from") + .arg("auto") + .assert() + .success(); + let pyproject = context.read("bar/pyproject.toml"); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject, @r#" + [project] + name = "bar" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + authors = [ + { name = "Alice", email = "alice@example.com" } + ] + requires-python = ">=3.12" + dependencies = [] + "# + ); + }); + + // Fill `authors` for library by default, + context.init().arg("baz").arg("--lib").assert().success(); + let pyproject = context.read("baz/pyproject.toml"); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject, @r#" + [project] + name = "baz" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + authors = [ + { name = "Alice", email = "alice@example.com" } + ] + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "# + ); + }); + + // use `--authors-from none` to prevent it. + context + .init() + .arg("qux") + .arg("--lib") + .arg("--author-from") + .arg("none") + .assert() + .success(); + let pyproject = context.read("qux/pyproject.toml"); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject, @r#" + [project] + name = "qux" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "# + ); + }); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 34a03105b..ae61828ed 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -440,6 +440,19 @@ uv init [OPTIONS] [PATH]

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.

+
--author-from author-from

Fill in the authors field in the pyproject.toml.

+ +

By default, uv will attempt to infer the author information from some sources (e.g., Git) (auto). Use --author-from git to only infer from Git configuration. Use --author-from none to avoid inferring the author information.

+ +

Possible values:

+ +
    +
  • auto: Fetch the author information from some sources (e.g., Git) automatically
  • + +
  • git: Fetch the author information from Git configuration only
  • + +
  • none: Do not infer the author information
  • +
--cache-dir cache-dir

Path to the cache directory.

Defaults to $HOME/Library/Caches/uv on macOS, $XDG_CACHE_HOME/uv or $HOME/.cache/uv on Linux, and %LOCALAPPDATA%\uv\cache on Windows.