diff --git a/crates/uv-client/src/base_client.rs b/crates/uv-client/src/base_client.rs index 0c897957d9e0b..05cf99eb60c20 100644 --- a/crates/uv-client/src/base_client.rs +++ b/crates/uv-client/src/base_client.rs @@ -88,6 +88,8 @@ pub struct BaseClientBuilder<'a> { cross_origin_credential_policy: CrossOriginCredentialsPolicy, /// Optional custom reqwest client to use instead of creating a new one. custom_client: Option, + /// Optional installer name to use in linehaul metadata. + installer_name: Option, } /// The policy for handling HTTP redirects. @@ -142,6 +144,7 @@ impl Default for BaseClientBuilder<'_> { redirect_policy: RedirectPolicy::default(), cross_origin_credential_policy: CrossOriginCredentialsPolicy::Secure, custom_client: None, + installer_name: None, } } } @@ -162,6 +165,7 @@ impl BaseClientBuilder<'_> { retries, connectivity, timeout, + installer_name: None, ..Self::default() } } @@ -251,6 +255,12 @@ impl<'a> BaseClientBuilder<'a> { self } + #[must_use] + pub fn installer_name(mut self, name: impl Into) -> Self { + self.installer_name = Some(name.into()); + self + } + #[must_use] pub fn proxy(mut self, proxy: Proxy) -> Self { self.proxies.push(proxy); @@ -358,7 +368,7 @@ impl<'a> BaseClientBuilder<'a> { // Add linehaul metadata. if let Some(markers) = self.markers { - let linehaul = LineHaul::new(markers, self.platform); + let linehaul = LineHaul::new(markers, self.platform, self.installer_name.as_deref()); if let Ok(output) = serde_json::to_string(&linehaul) { let _ = write!(user_agent_string, " {output}"); } diff --git a/crates/uv-client/src/linehaul.rs b/crates/uv-client/src/linehaul.rs index 8d159152dd530..170f94e0e6d42 100644 --- a/crates/uv-client/src/linehaul.rs +++ b/crates/uv-client/src/linehaul.rs @@ -61,9 +61,14 @@ pub struct LineHaul { /// . /// This metadata is added to the user agent to enrich PyPI statistics. impl LineHaul { - /// Initializes Linehaul information based on PEP 508 markers. + /// Initializes Linehaul information based on PEP 508 markers. The installer name + /// defaults to `uv` if not provided. #[instrument(name = "linehaul", skip_all)] - pub fn new(markers: &MarkerEnvironment, platform: Option<&Platform>) -> Self { + pub fn new( + markers: &MarkerEnvironment, + platform: Option<&Platform>, + installer_name: Option<&str>, + ) -> Self { // https://github.com/pypa/pip/blob/24.0/src/pip/_internal/network/session.py#L87 let looks_like_ci = [ EnvVars::BUILD_BUILDID, @@ -121,7 +126,7 @@ impl LineHaul { Self { installer: Option::from(Installer { - name: Some("uv".to_string()), + name: Some(installer_name.unwrap_or("uv").to_string()), version: Some(version().to_string()), }), python: Some(markers.python_full_version().version.to_string()), diff --git a/crates/uv-client/tests/it/user_agent_version.rs b/crates/uv-client/tests/it/user_agent_version.rs index bc3e7deef27b2..dd7766c637c43 100644 --- a/crates/uv-client/tests/it/user_agent_version.rs +++ b/crates/uv-client/tests/it/user_agent_version.rs @@ -10,6 +10,7 @@ use hyper_util::rt::TokioIo; use insta::{assert_json_snapshot, assert_snapshot, with_settings}; use std::str::FromStr; use tokio::net::TcpListener; +use tokio::task::JoinHandle; use url::Url; use uv_cache::Cache; use uv_client::RegistryClientBuilder; @@ -19,11 +20,13 @@ use uv_platform_tags::{Arch, Os, Platform}; use uv_redacted::DisplaySafeUrl; use uv_version::version; -#[tokio::test] -async fn test_user_agent_has_version() -> Result<()> { +/// Spawns a dummy HTTP server that echoes back the User-Agent header. +/// Returns the server URL and the server task handle. +async fn spawn_user_agent_echo_server() -> Result<(DisplaySafeUrl, JoinHandle<()>)> { // Set up the TCP listener on a random available port let listener = TcpListener::bind("127.0.0.1:0").await?; let addr = listener.local_addr()?; + let url = DisplaySafeUrl::from_str(&format!("http://{addr}"))?; // Spawn the server loop in a background task let server_task = tokio::spawn(async move { @@ -50,12 +53,18 @@ async fn test_user_agent_has_version() -> Result<()> { }); }); + Ok((url, server_task)) +} + +#[tokio::test] +async fn test_user_agent_has_version() -> Result<()> { + let (url, server_task) = spawn_user_agent_echo_server().await?; + // Initialize uv-client let cache = Cache::temp()?.init()?; let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); // Send request to our dummy server - let url = DisplaySafeUrl::from_str(&format!("http://{addr}"))?; let res = client .cached_client() .uncached() @@ -81,34 +90,7 @@ async fn test_user_agent_has_version() -> Result<()> { #[tokio::test] async fn test_user_agent_has_linehaul() -> Result<()> { - // Set up the TCP listener on a random available port - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - - // Spawn the server loop in a background task - let server_task = tokio::spawn(async move { - let svc = service_fn(move |req: Request| { - // Get User Agent Header and send it back in the response - let user_agent = req - .headers() - .get(USER_AGENT) - .and_then(|v| v.to_str().ok()) - .map(ToString::to_string) - .unwrap_or_default(); // Empty Default - future::ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from(user_agent)))) - }); - // Start Server (not wrapped in loop {} since we want a single response server) - // If you want server to accept multiple connections, wrap it in loop {} - let (socket, _) = listener.accept().await.unwrap(); - let socket = TokioIo::new(socket); - tokio::task::spawn(async move { - http1::Builder::new() - .serve_connection(socket, svc) - .with_upgrades() - .await - .expect("Server Started"); - }); - }); + let (url, server_task) = spawn_user_agent_echo_server().await?; // Add some representative markers for an Ubuntu CI runner let markers = MarkerEnvironment::try_from(MarkerEnvironmentBuilder { @@ -153,7 +135,6 @@ async fn test_user_agent_has_linehaul() -> Result<()> { let client = builder.build(); // Send request to our dummy server - let url = DisplaySafeUrl::from_str(&format!("http://{addr}"))?; let res = client .cached_client() .uncached() @@ -259,3 +240,73 @@ async fn test_user_agent_has_linehaul() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn test_user_agent_installer_name_override() -> Result<()> { + let (url, server_task) = spawn_user_agent_echo_server().await?; + + // Add some representative markers for an Ubuntu CI runner + let markers = MarkerEnvironment::try_from(MarkerEnvironmentBuilder { + implementation_name: "cpython", + implementation_version: "3.12.2", + os_name: "posix", + platform_machine: "x86_64", + platform_python_implementation: "CPython", + platform_release: "6.5.0-1016-azure", + platform_system: "Linux", + platform_version: "#16~22.04.1-Ubuntu SMP Fri Feb 16 15:42:02 UTC 2024", + python_full_version: "3.12.2", + python_version: "3.12", + sys_platform: "linux", + }) + .unwrap(); + + // Initialize uv-client with custom installer name + let cache = Cache::temp()?.init()?; + let base_client = BaseClientBuilder::default() + .markers(&markers) + .installer_name("uv-pip"); + let client = RegistryClientBuilder::new(base_client, cache).build(); + + // Send request to our dummy server + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await?; + + // Check the HTTP status + assert!(res.status().is_success()); + + // Check User Agent + let body = res.text().await?; + + // Wait for the server task to complete, to be a good citizen. + server_task.await?; + + let (prefix, linehaul_json) = body + .split_once(' ') + .expect("Failed to split User-Agent header"); + + let linehaul: LineHaul = + serde_json::from_str(linehaul_json).expect("Failed to deserialize linehaul"); + let installer = linehaul + .installer + .expect("linehaul installer field is missing"); + insta::with_settings!({ + filters => vec![(version(), "")] + }, { + // make sure the installer name in the prefix is kept as `uv` + assert_snapshot!(prefix, @r#"uv/ "#); + assert_json_snapshot!(installer, @r#" + { + "name": "uv-pip", + "version": "" + } + "#); + }); + + Ok(()) +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 67ddec3d31226..f37b28813c48e 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -462,6 +462,13 @@ async fn run(mut cli: Cli) -> Result { globals.network_settings.retries, ); + // Set the installer name to "uv-pip" for pip commands. + let client_builder = if matches!(*cli.command, Commands::Pip(_)) { + client_builder.installer_name("uv-pip") + } else { + client_builder + }; + match *cli.command { Commands::Auth(AuthNamespace { command: AuthCommand::Login(args), diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 497a6ec58166d..7933090b09fb3 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -773,8 +773,12 @@ impl TestContext { "Activate with: source $1/[BIN]/activate".to_string(), )); filters.push(( - r"Activate with: source (.*)/bin/activate(?:\.\w+)?".to_string(), - "Activate with: source $1/[BIN]/activate".to_string(), + r"Activate with: Scripts\\activate".to_string(), + "Activate with: source [BIN]/activate".to_string(), + )); + filters.push(( + r"Activate with: source (.*/|)bin/activate(?:\.\w+)?".to_string(), + "Activate with: source $1[BIN]/activate".to_string(), )); // Filter non-deterministic temporary directory names @@ -892,6 +896,12 @@ impl TestContext { )) .unwrap(); + // Ensure the tests aren't sensitive to the running user's shell without forcing + // `bash` on Windows + if cfg!(not(windows)) { + command.env(EnvVars::SHELL, "bash"); + } + command // When running the tests in a venv, ignore that venv, otherwise we'll capture warnings. .env_remove(EnvVars::VIRTUAL_ENV) diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index a8302e3e4f0d2..5cd76d8e8d460 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -2694,7 +2694,7 @@ fn python_install_no_cache() { "); } -#[cfg(target_os = "macos")] +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] #[test] fn python_install_emulated_macos() { let context: TestContext = TestContext::new_with_versions(&[]) @@ -2702,6 +2702,20 @@ fn python_install_emulated_macos() { .with_managed_python_dirs() .with_python_download_cache(); + let arch_status = Command::new("/usr/bin/arch") + .arg("-x86_64") + .arg("true") + .status(); + if !arch_status.is_ok_and(|x| x.success()) { + // Rosetta is not available to run the x86_64 interpreter + // fail the test in CI, otherwise skip it + if env::var("CI").is_ok() { + panic!("x86_64 emulation is not available on this CI runner"); + } + debug!("Skipping test because x86_64 emulation is not available"); + return; + } + // Before installation, `uv python list` should not show the x86_64 download uv_snapshot!(context.filters(), context.python_list().arg("3.13"), @r" success: true diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index ced7a778232c7..2134c28a6d051 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -2848,7 +2848,7 @@ fn tool_install_warn_path() { .arg("black==24.1.1") .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) - .env_remove(EnvVars::PATH), @r###" + .env_remove(EnvVars::PATH), @r#" success: true exit_code: 0 ----- stdout ----- @@ -2865,7 +2865,7 @@ fn tool_install_warn_path() { + platformdirs==4.2.0 Installed 2 executables: black, blackd warning: `[TEMP_DIR]/bin` is not on your PATH. To use installed tools, run `export PATH="[TEMP_DIR]/bin:$PATH"` or `uv tool update-shell`. - "###); + "#); } /// Test installing and reinstalling with an invalid receipt. diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index 651803aa41acd..fa5a0161dceff 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -1714,7 +1714,7 @@ fn create_venv_current_working_directory() { ----- stderr ----- Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] Creating virtual environment at: . - Activate with: source bin/activate + Activate with: source [BIN]/activate " );