diff --git a/Cargo.lock b/Cargo.lock index 67320ec94..e5881d8f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1572,6 +1572,7 @@ dependencies = [ "lazy_static", "linked-hash-map", "regex", + "serde", "similar", "yaml-rust", ] @@ -4168,6 +4169,8 @@ dependencies = [ "requirements-txt", "reqwest", "rustc-hash", + "serde", + "serde_json", "tempfile", "textwrap", "thiserror", diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 51ad3acda..2400e52ea 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -41,7 +41,7 @@ anstream = { workspace = true } anyhow = { workspace = true } base64 = { workspace = true } chrono = { workspace = true } -clap = { workspace = true, features = ["derive"] } +clap = { workspace = true, features = ["derive", "string"] } clap_complete_command = { workspace = true } console = { workspace = true } ctrlc = { workspace = true } @@ -56,6 +56,8 @@ owo-colors = { workspace = true } pubgrub = { workspace = true } pyproject-toml = { workspace = true } rustc-hash = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } tempfile = { workspace = true } textwrap = { workspace = true } thiserror = { workspace = true } @@ -79,7 +81,7 @@ assert_cmd = { version = "2.0.12" } assert_fs = { version = "1.1.0" } filetime = { version = "0.2.23" } indoc = { version = "2.0.4" } -insta = { version = "1.34.0", features = ["filters"] } +insta = { version = "1.34.0", features = ["filters", "json"] } predicates = { version = "3.0.4" } regex = { version = "1.10.3" } reqwest = { version = "0.11.23", features = ["blocking"], default-features = false } @@ -94,4 +96,3 @@ pypi = [] git = [] # Introduces a dependency on Maturin. maturin = [] - diff --git a/crates/uv/build.rs b/crates/uv/build.rs new file mode 100644 index 000000000..f192acd86 --- /dev/null +++ b/crates/uv/build.rs @@ -0,0 +1,80 @@ +use std::{fs, path::Path, process::Command}; + +fn main() { + // The workspace root directory is not available without walking up the tree + // https://github.com/rust-lang/cargo/issues/3946 + let workspace_root = Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("..") + .join(".."); + + commit_info(&workspace_root); + + #[allow(clippy::disallowed_methods)] + let target = std::env::var("TARGET").unwrap(); + println!("cargo:rustc-env=RUST_HOST_TARGET={target}"); +} + +fn commit_info(workspace_root: &Path) { + // If not in a git repository, do not attempt to retrieve commit information + let git_dir = workspace_root.join(".git"); + if !git_dir.exists() { + return; + } + + let git_head_path = git_dir.join("HEAD"); + println!( + "cargo:rerun-if-changed={}", + git_head_path.as_path().display() + ); + + let git_head_contents = fs::read_to_string(git_head_path); + if let Ok(git_head_contents) = git_head_contents { + // The contents are either a commit or a reference in the following formats + // - "" when the head is detached + // - "ref " when working on a branch + // If a commit, checking if the HEAD file has changed is sufficient + // If a ref, we need to add the head file for that ref to rebuild on commit + let mut git_ref_parts = git_head_contents.split_whitespace(); + git_ref_parts.next(); + if let Some(git_ref) = git_ref_parts.next() { + let git_ref_path = git_dir.join(git_ref); + println!( + "cargo:rerun-if-changed={}", + git_ref_path.as_path().display() + ); + } + } + + let output = match Command::new("git") + .arg("log") + .arg("-1") + .arg("--date=short") + .arg("--abbrev=9") + .arg("--format=%H %h %cd %(describe)") + .output() + { + Ok(output) if output.status.success() => output, + _ => return, + }; + let stdout = String::from_utf8(output.stdout).unwrap(); + let mut parts = stdout.split_whitespace(); + let mut next = || parts.next().unwrap(); + println!("cargo:rustc-env=UV_COMMIT_HASH={}", next()); + println!("cargo:rustc-env=UV_COMMIT_SHORT_HASH={}", next()); + println!("cargo:rustc-env=UV_COMMIT_DATE={}", next()); + + // Describe can fail for some commits + // https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-emdescribeoptionsem + if let Some(describe) = parts.next() { + let mut describe_parts = describe.split('-'); + println!( + "cargo:rustc-env=UV_LAST_TAG={}", + describe_parts.next().unwrap() + ); + // If this is the tagged commit, this component will be missing + println!( + "cargo:rustc-env=UV_LAST_TAG_DISTANCE={}", + describe_parts.next().unwrap_or("0") + ); + } +} diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 0634348fa..acaf252fb 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -10,6 +10,7 @@ pub(crate) use pip_install::pip_install; pub(crate) use pip_sync::pip_sync; pub(crate) use pip_uninstall::pip_uninstall; pub(crate) use venv::venv; +pub(crate) use version::version; mod cache_clean; mod cache_dir; @@ -20,6 +21,7 @@ mod pip_sync; mod pip_uninstall; mod reporters; mod venv; +mod version; #[derive(Copy, Clone)] pub(crate) enum ExitStatus { @@ -72,3 +74,9 @@ pub(super) struct ChangeEvent { dist: T, kind: ChangeEventKind, } + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub(crate) enum VersionFormat { + Text, + Json, +} diff --git a/crates/uv/src/commands/version.rs b/crates/uv/src/commands/version.rs new file mode 100644 index 000000000..6a1167a9a --- /dev/null +++ b/crates/uv/src/commands/version.rs @@ -0,0 +1,20 @@ +use anyhow::Result; + +use crate::commands::VersionFormat; + +/// Display version information +pub(crate) fn version(output_format: VersionFormat, buffer: &mut dyn std::io::Write) -> Result<()> { + let version_info = crate::version::version(); + + match output_format { + VersionFormat::Text => { + writeln!(buffer, "uv {}", &version_info)?; + } + VersionFormat::Json => { + serde_json::to_writer_pretty(&mut *buffer, &version_info)?; + // Add a trailing newline + writeln!(buffer)?; + } + }; + Ok(()) +} diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 9c934d04b..3d5c86ddb 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -24,7 +24,7 @@ use uv_traits::{ ConfigSettingEntry, ConfigSettings, NoBuild, PackageNameSpecifier, SetupPyStrategy, }; -use crate::commands::{extra_name_with_clap_error, ExitStatus, Upgrade}; +use crate::commands::{extra_name_with_clap_error, ExitStatus, Upgrade, VersionFormat}; use crate::compat::CompatArgs; use crate::requirements::RequirementsSource; @@ -50,11 +50,12 @@ mod confirm; mod logging; mod printer; mod requirements; +mod version; const DEFAULT_VENV_NAME: &str = ".venv"; #[derive(Parser)] -#[command(author, version, about)] +#[command(author, version, long_version = crate::version::version(), about)] #[command(propagate_version = true)] #[allow(clippy::struct_excessive_bools)] struct Cli { @@ -122,6 +123,11 @@ enum Commands { /// Remove all items from the cache. #[clap(hide = true)] Clean(CleanArgs), + /// Display uv's version + Version { + #[arg(long, value_enum, default_value = "text")] + output_format: VersionFormat, + }, /// Generate shell completion #[clap(alias = "--generate-shell-completion", hide = true)] GenerateShellCompletion { shell: clap_complete_command::Shell }, @@ -1126,6 +1132,10 @@ async fn run() -> Result { ) .await } + Commands::Version { output_format } => { + commands::version(output_format, &mut stdout())?; + Ok(ExitStatus::Success) + } Commands::GenerateShellCompletion { shell } => { shell.generate(&mut Cli::command(), &mut stdout()); Ok(ExitStatus::Success) diff --git a/crates/uv/src/version.rs b/crates/uv/src/version.rs new file mode 100644 index 000000000..02bdd7744 --- /dev/null +++ b/crates/uv/src/version.rs @@ -0,0 +1,147 @@ +//! Code for representing uv's release version number. +// See also +use serde::Serialize; +use std::fmt; + +/// Information about the git repository where uv was built from. +#[derive(Serialize)] +pub(crate) struct CommitInfo { + short_commit_hash: String, + commit_hash: String, + commit_date: String, + last_tag: Option, + commits_since_last_tag: u32, +} + +/// uv's version. +#[derive(Serialize)] +pub(crate) struct VersionInfo { + /// uv's version, such as "0.5.1" + version: String, + /// Information about the git commit we may have been built from. + /// + /// `None` if not built from a git repo or if retrieval failed. + commit_info: Option, +} +impl fmt::Display for VersionInfo { + /// Formatted version information: "[+] ( )" + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.version)?; + + if let Some(ref ci) = self.commit_info { + if ci.commits_since_last_tag > 0 { + write!(f, "+{}", ci.commits_since_last_tag)?; + } + write!(f, " ({} {})", ci.short_commit_hash, ci.commit_date)?; + } + + Ok(()) + } +} + +impl From for clap::builder::Str { + fn from(val: VersionInfo) -> Self { + val.to_string().into() + } +} + +/// Returns information about uv's version. +pub(crate) fn version() -> VersionInfo { + // Environment variables are only read at compile-time + macro_rules! option_env_str { + ($name:expr) => { + option_env!($name).map(|s| s.to_string()) + }; + } + + // This version is pulled from Cargo.toml and set by Cargo + let version = option_env_str!("CARGO_PKG_VERSION").unwrap(); + + // Commit info is pulled from git and set by `build.rs` + let commit_info = option_env_str!("UV_COMMIT_HASH").map(|commit_hash| CommitInfo { + short_commit_hash: option_env_str!("UV_COMMIT_SHORT_HASH").unwrap(), + commit_hash, + commit_date: option_env_str!("UV_COMMIT_DATE").unwrap(), + last_tag: option_env_str!("UV_LAST_TAG"), + commits_since_last_tag: option_env_str!("UV_LAST_TAG_DISTANCE") + .as_deref() + .map_or(0, |value| value.parse::().unwrap_or(0)), + }); + + VersionInfo { + version, + commit_info, + } +} + +#[cfg(test)] +mod tests { + use insta::{assert_display_snapshot, assert_json_snapshot}; + + use super::{CommitInfo, VersionInfo}; + + #[test] + fn version_formatting() { + let version = VersionInfo { + version: "0.0.0".to_string(), + commit_info: None, + }; + assert_display_snapshot!(version, @"0.0.0"); + } + + #[test] + fn version_formatting_with_commit_info() { + let version = VersionInfo { + version: "0.0.0".to_string(), + commit_info: Some(CommitInfo { + short_commit_hash: "53b0f5d92".to_string(), + commit_hash: "53b0f5d924110e5b26fbf09f6fd3a03d67b475b7".to_string(), + last_tag: Some("v0.0.1".to_string()), + commit_date: "2023-10-19".to_string(), + commits_since_last_tag: 0, + }), + }; + assert_display_snapshot!(version, @"0.0.0 (53b0f5d92 2023-10-19)"); + } + + #[test] + fn version_formatting_with_commits_since_last_tag() { + let version = VersionInfo { + version: "0.0.0".to_string(), + commit_info: Some(CommitInfo { + short_commit_hash: "53b0f5d92".to_string(), + commit_hash: "53b0f5d924110e5b26fbf09f6fd3a03d67b475b7".to_string(), + last_tag: Some("v0.0.1".to_string()), + commit_date: "2023-10-19".to_string(), + commits_since_last_tag: 24, + }), + }; + assert_display_snapshot!(version, @"0.0.0+24 (53b0f5d92 2023-10-19)"); + } + + #[test] + fn version_serializable() { + let version = VersionInfo { + version: "0.0.0".to_string(), + commit_info: Some(CommitInfo { + short_commit_hash: "53b0f5d92".to_string(), + commit_hash: "53b0f5d924110e5b26fbf09f6fd3a03d67b475b7".to_string(), + last_tag: Some("v0.0.1".to_string()), + commit_date: "2023-10-19".to_string(), + commits_since_last_tag: 0, + }), + }; + assert_json_snapshot!(version, @r###" + { + "version": "0.0.0", + "commit_info": { + "short_commit_hash": "53b0f5d92", + "commit_hash": "53b0f5d924110e5b26fbf09f6fd3a03d67b475b7", + "commit_date": "2023-10-19", + "last_tag": "v0.0.1", + "commits_since_last_tag": 0 + } + } + "###); + } +}