diff --git a/.github/workflows/sync-python-releases.yml b/.github/workflows/sync-python-releases.yml index 229fae20e..50ec4fe0c 100644 --- a/.github/workflows/sync-python-releases.yml +++ b/.github/workflows/sync-python-releases.yml @@ -49,3 +49,4 @@ jobs: title: "Sync latest Python releases" body: "Automated update for Python releases." base: "main" + draft: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 28dd29f80..515f83b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,33 @@ +## 0.9.10 + +Released on 2025-11-17. + +### Enhancements + +- Add support for `SSL_CERT_DIR` ([#16473](https://github.com/astral-sh/uv/pull/16473)) +- Enforce UTF‑8-encoded license files during `uv build` ([#16699](https://github.com/astral-sh/uv/pull/16699)) +- Error when a `project.license-files` glob matches nothing ([#16697](https://github.com/astral-sh/uv/pull/16697)) +- `pip install --target` (and `sync`) install Python if necessary ([#16694](https://github.com/astral-sh/uv/pull/16694)) +- Account for `python_downloads_json_url` in pre-release Python version warnings ([#16737](https://github.com/astral-sh/uv/pull/16737)) +- Support HTTP/HTTPS URLs in `uv python --python-downloads-json-url` ([#16542](https://github.com/astral-sh/uv/pull/16542)) + +### Preview features + +- Add support for `--upgrade` in `uv python install` ([#16676](https://github.com/astral-sh/uv/pull/16676)) +- Fix handling of `python install --default` for pre-release Python versions ([#16706](https://github.com/astral-sh/uv/pull/16706)) +- Add `uv workspace list` to list workspace members ([#16691](https://github.com/astral-sh/uv/pull/16691)) + +### Bug fixes + +- Don't check file URLs for ambiguously parsed credentials ([#16759](https://github.com/astral-sh/uv/pull/16759)) + +### Documentation + +- Add a "storage" reference document ([#15954](https://github.com/astral-sh/uv/pull/15954)) + ## 0.9.9 Released on 2025-11-12. @@ -38,7 +65,6 @@ Released on 2025-11-12. - Fix `CMD` path in FastAPI Dockerfile ([#16701](https://github.com/astral-sh/uv/pull/16701)) - ## 0.9.8 Released on 2025-11-07. @@ -257,25 +283,25 @@ There are no breaking changes to [`uv_build`](https://docs.astral.sh/uv/concepts ### Breaking changes - **Python 3.14 is now the default stable version** - + The default Python version has changed from 3.13 to 3.14. This applies to Python version installation when no Python version is requested, e.g., `uv python install`. By default, uv will use the system Python version if present, so this may not cause changes to general use of uv. For example, if Python 3.13 is installed already, then `uv venv` will use that version. If no Python versions are installed on a machine and automatic downloads are enabled, uv will now use 3.14 instead of 3.13, e.g., for `uv venv` or `uvx python`. This change will not affect users who are using a `.python-version` file to pin to a specific Python version. - **Allow use of free-threaded variants in Python 3.14+ without explicit opt-in** ([#16142](https://github.com/astral-sh/uv/pull/16142)) - + Previously, free-threaded variants of Python were considered experimental and required explicit opt-in (i.e., with `3.14t`) for usage. Now uv will allow use of free-threaded Python 3.14+ interpreters without explicit selection. The GIL-enabled build of Python will still be preferred, e.g., when performing an installation with `uv python install 3.14`. However, e.g., if a free-threaded interpreter comes before a GIL-enabled build on the `PATH`, it will be used. This change does not apply to free-threaded Python 3.13 interpreters, which will continue to require opt-in. - **Use Python 3.14 stable Docker images** ([#16150](https://github.com/astral-sh/uv/pull/16150)) - + Previously, the Python 3.14 images had an `-rc` suffix, e.g., `python:3.14-rc-alpine` or `python:3.14-rc-trixie`. Now, the `-rc` suffix has been removed to match the stable [upstream images](https://hub.docker.com/_/python). The `-rc` images tags will no longer be updated. This change should not break existing workflows. - **Upgrade Alpine Docker image to Alpine 3.22** - + Previously, the `uv:alpine` Docker image was based on Alpine 3.21. Now, this image is based on Alpine 3.22. The previous image can be recovered with `uv:alpine3.21` and will continue to be updated until a future release. - **Upgrade Debian Docker images to Debian 13 "Trixie"** - + Previously, the `uv:debian` and `uv:debian-slim` Docker images were based on Debian 12 "Bookworm". Now, these images are based on Debian 13 "Trixie". The previous images can be recovered with `uv:bookworm` and `uv:bookworm-slim` and will continue to be updated until a future release. - **Fix incorrect output path when a trailing `/` is used in `uv build`** ([#15133](https://github.com/astral-sh/uv/pull/15133)) - + When using `uv build` in a workspace, the artifacts are intended to be written to a `dist` directory in the workspace root. A bug caused workspace root determination to fail when the input path included a trailing `/` causing the `dist` directory to be placed in the child directory. This bug has been fixed in this release. For example, `uv build child/` is used, the output path will now be in `/dist/` rather than `/child/dist/`. ### Python diff --git a/Cargo.lock b/Cargo.lock index 3f1febda7..0b10ae003 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1214,7 +1214,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.0", + "windows-sys 0.59.0", ] [[package]] @@ -2126,9 +2126,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.2" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade6dfcba0dfb62ad59e59e7241ec8912af34fd29e0e743e3db992bd278e8b65" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" dependencies = [ "console 0.16.1", "portable-atomic", @@ -2285,7 +2285,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] @@ -2623,7 +2623,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.60.2", ] [[package]] @@ -3872,9 +3872,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5301,7 +5301,7 @@ dependencies = [ [[package]] name = "uv" -version = "0.9.9" +version = "0.9.10" dependencies = [ "anstream", "anyhow", @@ -5513,7 +5513,7 @@ dependencies = [ [[package]] name = "uv-build" -version = "0.9.9" +version = "0.9.10" dependencies = [ "anstream", "anyhow", @@ -5710,16 +5710,19 @@ dependencies = [ "itertools 0.14.0", "jiff", "percent-encoding", + "rcgen", "reqwest", "rkyv", "rmp-serde", "rustc-hash", + "rustls", "serde", "serde_json", "sysinfo", "tempfile", "thiserror 2.0.17", "tokio", + "tokio-rustls", "tokio-util", "tracing", "url", @@ -6807,7 +6810,7 @@ dependencies = [ [[package]] name = "uv-version" -version = "0.9.9" +version = "0.9.10" [[package]] name = "uv-virtualenv" diff --git a/Cargo.toml b/Cargo.toml index c779e2111..8404e6e2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -211,15 +211,17 @@ byteorder = { version = "1.5.0" } filetime = { version = "0.2.25" } http-body-util = { version = "0.1.2" } hyper = { version = "1.4.1", features = ["server", "http1"] } -hyper-util = { version = "0.1.8", features = ["tokio"] } +hyper-util = { version = "0.1.8", features = ["tokio", "server", "http1"] } ignore = { version = "0.4.23" } insta = { version = "1.40.0", features = ["json", "filters", "redactions"] } predicates = { version = "3.1.2" } rcgen = { version = "0.14.5", features = ["crypto", "pem", "ring"], default-features = false } +rustls = { version = "0.23.29", default-features = false } similar = { version = "2.6.0" } temp-env = { version = "0.3.6" } test-case = { version = "3.3.1" } test-log = { version = "0.2.16", features = ["trace"], default-features = false } +tokio-rustls = { version = "0.26.2", default-features = false } whoami = { version = "1.6.0" } [workspace.metadata.cargo-shear] diff --git a/crates/uv-build/Cargo.toml b/crates/uv-build/Cargo.toml index 8262ee6dd..ae0fdb1a4 100644 --- a/crates/uv-build/Cargo.toml +++ b/crates/uv-build/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-build" -version = "0.9.9" +version = "0.9.10" edition = { workspace = true } rust-version = { workspace = true } homepage = { workspace = true } diff --git a/crates/uv-build/pyproject.toml b/crates/uv-build/pyproject.toml index adacbde82..01714254d 100644 --- a/crates/uv-build/pyproject.toml +++ b/crates/uv-build/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uv-build" -version = "0.9.9" +version = "0.9.10" description = "The uv build backend" authors = [{ name = "Astral Software Inc.", email = "hey@astral.sh" }] requires-python = ">=3.8" diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 488ac192b..fb1dc5870 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -7167,6 +7167,11 @@ pub enum WorkspaceCommand { /// /// If used outside of a workspace, i.e., if a `pyproject.toml` cannot be found, uv will exit with an error. Dir(WorkspaceDirArgs), + /// List the members of a workspace. + /// + /// Displays newline separated names of workspace members. + #[command(hide = true)] + List(WorkspaceListArgs), } #[derive(Args, Debug)] @@ -7179,6 +7184,9 @@ pub struct WorkspaceDirArgs { pub package: Option, } +#[derive(Args, Debug)] +pub struct WorkspaceListArgs; + /// See [PEP 517](https://peps.python.org/pep-0517/) and /// [PEP 660](https://peps.python.org/pep-0660/) for specifications of the parameters. #[derive(Subcommand)] diff --git a/crates/uv-client/Cargo.toml b/crates/uv-client/Cargo.toml index 42c68cfe6..e0742ba96 100644 --- a/crates/uv-client/Cargo.toml +++ b/crates/uv-client/Cargo.toml @@ -72,6 +72,9 @@ http-body-util = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } insta = { workspace = true } +rcgen = { workspace = true } +rustls = { workspace = true } tokio = { workspace = true } +tokio-rustls = { workspace = true } wiremock = { workspace = true } tempfile = { workspace = true } diff --git a/crates/uv-client/src/base_client.rs b/crates/uv-client/src/base_client.rs index 97192dcd8..c66c4a038 100644 --- a/crates/uv-client/src/base_client.rs +++ b/crates/uv-client/src/base_client.rs @@ -363,7 +363,9 @@ impl<'a> BaseClientBuilder<'a> { let _ = write!(user_agent_string, " {output}"); } - // Check for the presence of an `SSL_CERT_FILE`. + // Checks for the presence of `SSL_CERT_FILE`. + // Certificate loading support is delegated to `rustls-native-certs`. + // See https://github.com/rustls/rustls-native-certs/blob/813790a297ad4399efe70a8e5264ca1b420acbec/src/lib.rs#L118-L125 let ssl_cert_file_exists = env::var_os(EnvVars::SSL_CERT_FILE).is_some_and(|path| { let path_exists = Path::new(&path).exists(); if !path_exists { @@ -375,11 +377,61 @@ impl<'a> BaseClientBuilder<'a> { path_exists }); + // Checks for the presence of `SSL_CERT_DIR`. + // Certificate loading support is delegated to `rustls-native-certs`. + // See https://github.com/rustls/rustls-native-certs/blob/813790a297ad4399efe70a8e5264ca1b420acbec/src/lib.rs#L118-L125 + let ssl_cert_dir_exists = env::var_os(EnvVars::SSL_CERT_DIR) + .filter(|v| !v.is_empty()) + .is_some_and(|dirs| { + // Parse `SSL_CERT_DIR`, with support for multiple entries using + // a platform-specific delimiter (`:` on Unix, `;` on Windows) + let (existing, missing): (Vec<_>, Vec<_>) = + env::split_paths(&dirs).partition(|p| p.exists()); + + if existing.is_empty() { + let end_note = if missing.len() == 1 { + "The directory does not exist." + } else { + "The entries do not exist." + }; + warn_user_once!( + "Ignoring invalid `SSL_CERT_DIR`. {end_note}: {}.", + missing + .iter() + .map(Simplified::simplified_display) + .join(", ") + .cyan() + ); + return false; + } + + // Warn on any missing entries + if !missing.is_empty() { + let end_note = if missing.len() == 1 { + "The following directory does not exist:" + } else { + "The following entries do not exist:" + }; + warn_user_once!( + "Invalid entries in `SSL_CERT_DIR`. {end_note}: {}.", + missing + .iter() + .map(Simplified::simplified_display) + .join(", ") + .cyan() + ); + } + + // Proceed while ignoring missing entries + true + }); + // Create a secure client that validates certificates. let raw_client = self.create_client( &user_agent_string, timeout, ssl_cert_file_exists, + ssl_cert_dir_exists, Security::Secure, self.redirect_policy, ); @@ -389,6 +441,7 @@ impl<'a> BaseClientBuilder<'a> { &user_agent_string, timeout, ssl_cert_file_exists, + ssl_cert_dir_exists, Security::Insecure, self.redirect_policy, ); @@ -401,6 +454,7 @@ impl<'a> BaseClientBuilder<'a> { user_agent: &str, timeout: Duration, ssl_cert_file_exists: bool, + ssl_cert_dir_exists: bool, security: Security, redirect_policy: RedirectPolicy, ) -> Client { @@ -419,7 +473,7 @@ impl<'a> BaseClientBuilder<'a> { Security::Insecure => client_builder.danger_accept_invalid_certs(true), }; - let client_builder = if self.native_tls || ssl_cert_file_exists { + let client_builder = if self.native_tls || ssl_cert_file_exists || ssl_cert_dir_exists { client_builder.tls_built_in_native_certs(true) } else { client_builder.tls_built_in_webpki_certs(true) diff --git a/crates/uv-client/tests/it/http_util.rs b/crates/uv-client/tests/it/http_util.rs new file mode 100644 index 000000000..b879699b3 --- /dev/null +++ b/crates/uv-client/tests/it/http_util.rs @@ -0,0 +1,382 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use futures::future; +use http_body_util::combinators::BoxBody; +use http_body_util::{BodyExt, Full}; +use hyper::body::{Bytes, Incoming}; +use hyper::header::USER_AGENT; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use hyper_util::server::conn::auto::Builder; +use rcgen::{ + BasicConstraints, Certificate, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, + Issuer, KeyPair, KeyUsagePurpose, SanType, date_time_ymd, +}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::server::WebPkiClientVerifier; +use rustls::{RootCertStore, ServerConfig}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio_rustls::TlsAcceptor; + +use uv_fs::Simplified; + +/// An issued certificate, together with the subject keypair. +#[derive(Debug)] +pub(crate) struct SelfSigned { + /// An issued certificate. + pub public: Certificate, + /// The certificate's subject signing key. + pub private: KeyPair, +} + +/// Defines the base location for temporary generated certs. +/// +/// See [`TestContext::test_bucket_dir`] for implementation rationale. +pub(crate) fn test_cert_dir() -> PathBuf { + std::env::temp_dir() + .simple_canonicalize() + .expect("failed to canonicalize temp dir") + .join("uv") + .join("tests") + .join("certs") +} + +/// Generates a self-signed server certificate for `uv-test-server`, `localhost` and `127.0.0.1`. +/// This certificate is standalone and not issued by a self-signed Root CA. +/// +/// Use sparingly as generation of certs is a slow operation. +pub(crate) fn generate_self_signed_certs() -> Result { + let mut params = CertificateParams::default(); + params.is_ca = IsCa::NoCa; + params.not_before = date_time_ymd(1975, 1, 1); + params.not_after = date_time_ymd(4096, 1, 1); + params.key_usages.push(KeyUsagePurpose::DigitalSignature); + params.key_usages.push(KeyUsagePurpose::KeyEncipherment); + params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ServerAuth); + params + .distinguished_name + .push(DnType::OrganizationName, "Astral Software Inc."); + params + .distinguished_name + .push(DnType::CommonName, "uv-test-server"); + params + .subject_alt_names + .push(SanType::DnsName("uv-test-server".try_into()?)); + params + .subject_alt_names + .push(SanType::DnsName("localhost".try_into()?)); + params + .subject_alt_names + .push(SanType::IpAddress("127.0.0.1".parse()?)); + let private = KeyPair::generate()?; + let public = params.self_signed(&private)?; + + Ok(SelfSigned { public, private }) +} + +/// Generates a self-signed root CA, server certificate, and client certificate. +/// There are no intermediate certs generated as part of this function. +/// The server certificate is for `uv-test-server`, `localhost` and `127.0.0.1` issued by this CA. +/// The client certificate is for `uv-test-client` issued by this CA. +/// +/// Use sparingly as generation of these certs is a very slow operation. +pub(crate) fn generate_self_signed_certs_with_ca() -> Result<(SelfSigned, SelfSigned, SelfSigned)> { + // Generate the CA + let mut ca_params = CertificateParams::default(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); // root cert + ca_params.not_before = date_time_ymd(1975, 1, 1); + ca_params.not_after = date_time_ymd(4096, 1, 1); + ca_params.key_usages.push(KeyUsagePurpose::DigitalSignature); + ca_params.key_usages.push(KeyUsagePurpose::KeyCertSign); + ca_params.key_usages.push(KeyUsagePurpose::CrlSign); + ca_params + .distinguished_name + .push(DnType::OrganizationName, "Astral Software Inc."); + ca_params + .distinguished_name + .push(DnType::CommonName, "uv-test-ca"); + ca_params + .subject_alt_names + .push(SanType::DnsName("uv-test-ca".try_into()?)); + let ca_private_key = KeyPair::generate()?; + let ca_public_cert = ca_params.self_signed(&ca_private_key)?; + let ca_cert_issuer = Issuer::new(ca_params, &ca_private_key); + + // Generate server cert issued by this CA + let mut server_params = CertificateParams::default(); + server_params.is_ca = IsCa::NoCa; + server_params.not_before = date_time_ymd(1975, 1, 1); + server_params.not_after = date_time_ymd(4096, 1, 1); + server_params.use_authority_key_identifier_extension = true; + server_params + .key_usages + .push(KeyUsagePurpose::DigitalSignature); + server_params + .key_usages + .push(KeyUsagePurpose::KeyEncipherment); + server_params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ServerAuth); + server_params + .distinguished_name + .push(DnType::OrganizationName, "Astral Software Inc."); + server_params + .distinguished_name + .push(DnType::CommonName, "uv-test-server"); + server_params + .subject_alt_names + .push(SanType::DnsName("uv-test-server".try_into()?)); + server_params + .subject_alt_names + .push(SanType::DnsName("localhost".try_into()?)); + server_params + .subject_alt_names + .push(SanType::IpAddress("127.0.0.1".parse()?)); + let server_private_key = KeyPair::generate()?; + let server_public_cert = server_params.signed_by(&server_private_key, &ca_cert_issuer)?; + + // Generate client cert issued by this CA + let mut client_params = CertificateParams::default(); + client_params.is_ca = IsCa::NoCa; + client_params.not_before = date_time_ymd(1975, 1, 1); + client_params.not_after = date_time_ymd(4096, 1, 1); + client_params.use_authority_key_identifier_extension = true; + client_params + .key_usages + .push(KeyUsagePurpose::DigitalSignature); + client_params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ClientAuth); + client_params + .distinguished_name + .push(DnType::OrganizationName, "Astral Software Inc."); + client_params + .distinguished_name + .push(DnType::CommonName, "uv-test-client"); + client_params + .subject_alt_names + .push(SanType::DnsName("uv-test-client".try_into()?)); + let client_private_key = KeyPair::generate()?; + let client_public_cert = client_params.signed_by(&client_private_key, &ca_cert_issuer)?; + + let ca_self_signed = SelfSigned { + public: ca_public_cert, + private: ca_private_key, + }; + let server_self_signed = SelfSigned { + public: server_public_cert, + private: server_private_key, + }; + let client_self_signed = SelfSigned { + public: client_public_cert, + private: client_private_key, + }; + + Ok((ca_self_signed, server_self_signed, client_self_signed)) +} + +// Plain is fine for now; Arc/Box could be used later if we need to support move. +type ServerSvcFn = + fn( + Request, + ) -> future::Ready>, hyper::Error>>; + +#[derive(Default)] +pub(crate) struct TestServerBuilder<'a> { + // Custom server response function + svc_fn: Option, + // CA certificate + ca_cert: Option<&'a SelfSigned>, + // Server certificate + server_cert: Option<&'a SelfSigned>, + // Enable mTLS Verification + mutual_tls: bool, +} + +impl<'a> TestServerBuilder<'a> { + pub(crate) fn new() -> Self { + Self { + svc_fn: None, + server_cert: None, + ca_cert: None, + mutual_tls: false, + } + } + + #[expect(unused)] + /// Provide a custom server response function. + pub(crate) fn with_svc_fn(mut self, svc_fn: ServerSvcFn) -> Self { + self.svc_fn = Some(svc_fn); + self + } + + /// Provide the server certificate. This will enable TLS (HTTPS). + pub(crate) fn with_server_cert(mut self, server_cert: &'a SelfSigned) -> Self { + self.server_cert = Some(server_cert); + self + } + + /// CA certificate used to build the `RootCertStore` for client verification. + /// Requires `with_server_cert`. + pub(crate) fn with_ca_cert(mut self, ca_cert: &'a SelfSigned) -> Self { + self.ca_cert = Some(ca_cert); + self + } + + /// Enforce mutual TLS (client cert auth). + /// Requires `with_server_cert` and `with_ca_cert`. + pub(crate) fn with_mutual_tls(mut self, mutual: bool) -> Self { + self.mutual_tls = mutual; + self + } + + /// Starts the HTTP(S) server with optional mTLS enforcement. + pub(crate) async fn start(self) -> Result<(JoinHandle>, SocketAddr)> { + // Validate builder input combinations + if self.ca_cert.is_some() && self.server_cert.is_none() { + anyhow::bail!("server certificate is required when CA certificate is provided"); + } + if self.mutual_tls && (self.ca_cert.is_none() || self.server_cert.is_none()) { + anyhow::bail!("ca certificate is required for mTLS"); + } + + // 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()?; + + // Setup TLS Config (if any) + let tls_acceptor = if let Some(server_cert) = self.server_cert { + // Prepare Server Cert and KeyPair + let server_key = PrivateKeyDer::try_from(server_cert.private.serialize_der()).unwrap(); + let server_cert = vec![CertificateDer::from(server_cert.public.der().to_vec())]; + + // Setup CA Verifier + let client_verifier = if let Some(ca_cert) = self.ca_cert { + let mut root_store = RootCertStore::empty(); + root_store + .add(CertificateDer::from(ca_cert.public.der().to_vec())) + .expect("failed to add CA cert"); + if self.mutual_tls { + // Setup mTLS CA config + WebPkiClientVerifier::builder(root_store.into()) + .build() + .expect("failed to setup client verifier") + } else { + // Only load the CA roots + WebPkiClientVerifier::builder(root_store.into()) + .allow_unauthenticated() + .build() + .expect("failed to setup client verifier") + } + } else { + WebPkiClientVerifier::no_client_auth() + }; + + let mut tls_config = ServerConfig::builder() + .with_client_cert_verifier(client_verifier) + .with_single_cert(server_cert, server_key)?; + tls_config.alpn_protocols = vec![b"http/1.1".to_vec(), b"http/1.0".to_vec()]; + + Some(TlsAcceptor::from(Arc::new(tls_config))) + } else { + None + }; + + // Setup Response Handler + let svc_fn = if let Some(custom_svc_fn) = self.svc_fn { + custom_svc_fn + } else { + |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 + let response_content = Full::new(Bytes::from(user_agent)) + .map_err(|_| unreachable!()) + .boxed(); + // If we ever want a true echo server, we can use instead + // let response_content = req.into_body().boxed(); + // although uv-client doesn't expose post currently. + future::ok::<_, hyper::Error>(Response::new(response_content)) + } + }; + + // Spawn the server loop in a background task + let server_task = tokio::spawn(async move { + let svc = service_fn(move |req: Request| svc_fn(req)); + + let (tcp_stream, _remote_addr) = listener + .accept() + .await + .context("Failed to accept TCP connection")?; + + // Start Server (not wrapped in loop {} since we want a single response server) + // If we want server to accept multiple connections, we can wrap it in loop {} + // but we'll need to ensure to handle termination signals in the tests otherwise + // it may never stop. + if let Some(tls_acceptor) = tls_acceptor { + let tls_stream = tls_acceptor + .accept(tcp_stream) + .await + .context("Failed to accept TLS connection")?; + let socket = TokioIo::new(tls_stream); + tokio::task::spawn(async move { + Builder::new(TokioExecutor::new()) + .serve_connection(socket, svc) + .await + .expect("HTTPS Server Started"); + }); + } else { + let socket = TokioIo::new(tcp_stream); + tokio::task::spawn(async move { + Builder::new(TokioExecutor::new()) + .serve_connection(socket, svc) + .await + .expect("HTTP Server Started"); + }); + } + + Ok(()) + }); + + Ok((server_task, addr)) + } +} + +/// Single Request HTTP server that echoes the User Agent Header. +pub(crate) async fn start_http_user_agent_server() -> Result<(JoinHandle>, SocketAddr)> { + TestServerBuilder::new().start().await +} + +/// Single Request HTTPS server that echoes the User Agent Header. +pub(crate) async fn start_https_user_agent_server( + server_cert: &SelfSigned, +) -> Result<(JoinHandle>, SocketAddr)> { + TestServerBuilder::new() + .with_server_cert(server_cert) + .start() + .await +} + +/// Single Request HTTPS mTLS server that echoes the User Agent Header. +pub(crate) async fn start_https_mtls_user_agent_server( + ca_cert: &SelfSigned, + server_cert: &SelfSigned, +) -> Result<(JoinHandle>, SocketAddr)> { + TestServerBuilder::new() + .with_ca_cert(ca_cert) + .with_server_cert(server_cert) + .with_mutual_tls(true) + .start() + .await +} diff --git a/crates/uv-client/tests/it/main.rs b/crates/uv-client/tests/it/main.rs index c6f2b96e8..13674f930 100644 --- a/crates/uv-client/tests/it/main.rs +++ b/crates/uv-client/tests/it/main.rs @@ -1,2 +1,4 @@ +mod http_util; mod remote_metadata; +mod ssl_certs; mod user_agent_version; diff --git a/crates/uv-client/tests/it/remote_metadata.rs b/crates/uv-client/tests/it/remote_metadata.rs index 14ba42e03..b6461027c 100644 --- a/crates/uv-client/tests/it/remote_metadata.rs +++ b/crates/uv-client/tests/it/remote_metadata.rs @@ -21,11 +21,11 @@ async fn remote_metadata_with_and_without_cache() -> Result<()> { let filename = WheelFilename::from_str(url.rsplit_once('/').unwrap().1)?; let dist = BuiltDist::DirectUrl(DirectUrlBuiltDist { filename, - location: Box::new(DisplaySafeUrl::parse(url).unwrap()), - url: VerbatimUrl::from_str(url).unwrap(), + location: Box::new(DisplaySafeUrl::parse(url)?), + url: VerbatimUrl::from_str(url)?, }); let capabilities = IndexCapabilities::default(); - let metadata = client.wheel_metadata(&dist, &capabilities).await.unwrap(); + let metadata = client.wheel_metadata(&dist, &capabilities).await?; assert_eq!(metadata.version.to_string(), "4.66.1"); } diff --git a/crates/uv-client/tests/it/ssl_certs.rs b/crates/uv-client/tests/it/ssl_certs.rs new file mode 100644 index 000000000..b634b425b --- /dev/null +++ b/crates/uv-client/tests/it/ssl_certs.rs @@ -0,0 +1,333 @@ +use std::str::FromStr; + +use anyhow::Result; +use rustls::AlertDescription; +use url::Url; + +use uv_cache::Cache; +use uv_client::BaseClientBuilder; +use uv_client::RegistryClientBuilder; +use uv_redacted::DisplaySafeUrl; +use uv_static::EnvVars; + +use crate::http_util::{ + generate_self_signed_certs, generate_self_signed_certs_with_ca, + start_https_mtls_user_agent_server, start_https_user_agent_server, test_cert_dir, +}; + +// SAFETY: This test is meant to run with single thread configuration +#[tokio::test] +#[allow(unsafe_code)] +async fn ssl_env_vars() -> Result<()> { + // Ensure our environment is not polluted with anything that may affect `rustls-native-certs` + unsafe { + std::env::remove_var(EnvVars::UV_NATIVE_TLS); + std::env::remove_var(EnvVars::SSL_CERT_FILE); + std::env::remove_var(EnvVars::SSL_CERT_DIR); + std::env::remove_var(EnvVars::SSL_CLIENT_CERT); + } + + // Create temporary cert dirs + let cert_dir = test_cert_dir(); + fs_err::create_dir_all(&cert_dir).expect("Failed to create test cert bucket"); + let cert_dir = + tempfile::TempDir::new_in(cert_dir).expect("Failed to create test cert directory"); + let does_not_exist_cert_dir = cert_dir.path().join("does_not_exist"); + + // Generate self-signed standalone cert + let standalone_server_cert = generate_self_signed_certs()?; + let standalone_public_pem_path = cert_dir.path().join("standalone_public.pem"); + let standalone_private_pem_path = cert_dir.path().join("standalone_private.pem"); + + // Generate self-signed CA, server, and client certs + let (ca_cert, server_cert, client_cert) = generate_self_signed_certs_with_ca()?; + let ca_public_pem_path = cert_dir.path().join("ca_public.pem"); + let ca_private_pem_path = cert_dir.path().join("ca_private.pem"); + let server_public_pem_path = cert_dir.path().join("server_public.pem"); + let server_private_pem_path = cert_dir.path().join("server_private.pem"); + let client_combined_pem_path = cert_dir.path().join("client_combined.pem"); + + // Persist the certs in PKCS8 format as the env vars expect a path on disk + fs_err::write( + standalone_public_pem_path.as_path(), + standalone_server_cert.public.pem(), + )?; + fs_err::write( + standalone_private_pem_path.as_path(), + standalone_server_cert.private.serialize_pem(), + )?; + fs_err::write(ca_public_pem_path.as_path(), ca_cert.public.pem())?; + fs_err::write( + ca_private_pem_path.as_path(), + ca_cert.private.serialize_pem(), + )?; + fs_err::write(server_public_pem_path.as_path(), server_cert.public.pem())?; + fs_err::write( + server_private_pem_path.as_path(), + server_cert.private.serialize_pem(), + )?; + fs_err::write( + client_combined_pem_path.as_path(), + // SSL_CLIENT_CERT expects a "combined" cert with the public and private key. + format!( + "{}\n{}", + client_cert.public.pem(), + client_cert.private.serialize_pem() + ), + )?; + + // ** Set SSL_CERT_FILE to non-existent location + // ** Then verify our request fails to establish a connection + + unsafe { + std::env::set_var(EnvVars::SSL_CERT_FILE, does_not_exist_cert_dir.as_os_str()); + } + let (server_task, addr) = start_https_user_agent_server(&standalone_server_cert).await?; + let url = DisplaySafeUrl::from_str(&format!("https://{addr}"))?; + let cache = Cache::temp()?.init()?; + let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await; + unsafe { + std::env::remove_var(EnvVars::SSL_CERT_FILE); + } + + // Validate the client error + let Some(reqwest_middleware::Error::Middleware(middleware_error)) = res.err() else { + panic!("expected middleware error"); + }; + let reqwest_error = middleware_error + .chain() + .find_map(|err| { + err.downcast_ref::().map(|err| { + if let reqwest_middleware::Error::Reqwest(inner) = err { + inner + } else { + panic!("expected reqwest error") + } + }) + }) + .expect("expected reqwest error"); + assert!(reqwest_error.is_connect()); + + // Validate the server error + let server_res = server_task.await?; + let expected_err = if let Err(anyhow_err) = server_res + && let Some(io_err) = anyhow_err.downcast_ref::() + && let Some(wrapped_err) = io_err.get_ref() + && let Some(tls_err) = wrapped_err.downcast_ref::() + && matches!( + tls_err, + rustls::Error::AlertReceived(AlertDescription::UnknownCA) + ) { + true + } else { + false + }; + assert!(expected_err); + + // ** Set SSL_CERT_FILE to our public certificate + // ** Then verify our request successfully establishes a connection + + unsafe { + std::env::set_var( + EnvVars::SSL_CERT_FILE, + standalone_public_pem_path.as_os_str(), + ); + } + let (server_task, addr) = start_https_user_agent_server(&standalone_server_cert).await?; + let url = DisplaySafeUrl::from_str(&format!("https://{addr}"))?; + let cache = Cache::temp()?.init()?; + let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await; + assert!(res.is_ok()); + let _ = server_task.await?; // wait for server shutdown + unsafe { + std::env::remove_var(EnvVars::SSL_CERT_FILE); + } + + // ** Set SSL_CERT_DIR to our cert dir as well as some other dir that does not exist + // ** Then verify our request still successfully establishes a connection + + unsafe { + std::env::set_var( + EnvVars::SSL_CERT_DIR, + std::env::join_paths(vec![ + cert_dir.path().as_os_str(), + does_not_exist_cert_dir.as_os_str(), + ])?, + ); + } + let (server_task, addr) = start_https_user_agent_server(&standalone_server_cert).await?; + let url = DisplaySafeUrl::from_str(&format!("https://{addr}"))?; + let cache = Cache::temp()?.init()?; + let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await; + assert!(res.is_ok()); + let _ = server_task.await?; // wait for server shutdown + unsafe { + std::env::remove_var(EnvVars::SSL_CERT_DIR); + } + + // ** Set SSL_CERT_DIR to only the dir that does not exist + // ** Then verify our request fails to establish a connection + + unsafe { + std::env::set_var(EnvVars::SSL_CERT_DIR, does_not_exist_cert_dir.as_os_str()); + } + let (server_task, addr) = start_https_user_agent_server(&standalone_server_cert).await?; + let url = DisplaySafeUrl::from_str(&format!("https://{addr}"))?; + let cache = Cache::temp()?.init()?; + let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await; + unsafe { + std::env::remove_var(EnvVars::SSL_CERT_DIR); + } + + // Validate the client error + let Some(reqwest_middleware::Error::Middleware(middleware_error)) = res.err() else { + panic!("expected middleware error"); + }; + let reqwest_error = middleware_error + .chain() + .find_map(|err| { + err.downcast_ref::().map(|err| { + if let reqwest_middleware::Error::Reqwest(inner) = err { + inner + } else { + panic!("expected reqwest error") + } + }) + }) + .expect("expected reqwest error"); + assert!(reqwest_error.is_connect()); + + // Validate the server error + let server_res = server_task.await?; + let expected_err = if let Err(anyhow_err) = server_res + && let Some(io_err) = anyhow_err.downcast_ref::() + && let Some(wrapped_err) = io_err.get_ref() + && let Some(tls_err) = wrapped_err.downcast_ref::() + && matches!( + tls_err, + rustls::Error::AlertReceived(AlertDescription::UnknownCA) + ) { + true + } else { + false + }; + assert!(expected_err); + + // *** mTLS Tests + + // ** Set SSL_CERT_FILE to our CA and SSL_CLIENT_CERT to our client cert + // ** Then verify our request still successfully establishes a connection + + // We need to set SSL_CERT_FILE or SSL_CERT_DIR to our CA as we need to tell + // our HTTP client that we trust certificates issued by our self-signed CA. + // This inherently also tests that our server cert is also validated as part + // of the certificate path validation algorithm. + unsafe { + std::env::set_var(EnvVars::SSL_CERT_FILE, ca_public_pem_path.as_os_str()); + std::env::set_var( + EnvVars::SSL_CLIENT_CERT, + client_combined_pem_path.as_os_str(), + ); + } + let (server_task, addr) = start_https_mtls_user_agent_server(&ca_cert, &server_cert).await?; + let url = DisplaySafeUrl::from_str(&format!("https://{addr}"))?; + let cache = Cache::temp()?.init()?; + let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await; + assert!(res.is_ok()); + let _ = server_task.await?; // wait for server shutdown + unsafe { + std::env::remove_var(EnvVars::SSL_CERT_FILE); + std::env::remove_var(EnvVars::SSL_CLIENT_CERT); + } + + // ** Set SSL_CERT_FILE to our CA and unset SSL_CLIENT_CERT + // ** Then verify our request fails to establish a connection + + unsafe { + std::env::set_var(EnvVars::SSL_CERT_FILE, ca_public_pem_path.as_os_str()); + } + let (server_task, addr) = start_https_mtls_user_agent_server(&ca_cert, &server_cert).await?; + let url = DisplaySafeUrl::from_str(&format!("https://{addr}"))?; + let cache = Cache::temp()?.init()?; + let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await; + unsafe { + std::env::remove_var(EnvVars::SSL_CERT_FILE); + } + + // Validate the client error + let Some(reqwest_middleware::Error::Middleware(middleware_error)) = res.err() else { + panic!("expected middleware error"); + }; + let reqwest_error = middleware_error + .chain() + .find_map(|err| { + err.downcast_ref::().map(|err| { + if let reqwest_middleware::Error::Reqwest(inner) = err { + inner + } else { + panic!("expected reqwest error") + } + }) + }) + .expect("expected reqwest error"); + assert!(reqwest_error.is_connect()); + + // Validate the server error + let server_res = server_task.await?; + let expected_err = if let Err(anyhow_err) = server_res + && let Some(io_err) = anyhow_err.downcast_ref::() + && let Some(wrapped_err) = io_err.get_ref() + && let Some(tls_err) = wrapped_err.downcast_ref::() + && matches!(tls_err, rustls::Error::NoCertificatesPresented) + { + true + } else { + false + }; + assert!(expected_err); + + // Fin. + Ok(()) +} diff --git a/crates/uv-client/tests/it/user_agent_version.rs b/crates/uv-client/tests/it/user_agent_version.rs index ea120cdcb..4c3d0751c 100644 --- a/crates/uv-client/tests/it/user_agent_version.rs +++ b/crates/uv-client/tests/it/user_agent_version.rs @@ -1,17 +1,12 @@ +use std::str::FromStr; + use anyhow::Result; -use futures::future; -use http_body_util::Full; -use hyper::body::Bytes; -use hyper::header::USER_AGENT; -use hyper::server::conn::http1; -use hyper::service::service_fn; -use hyper::{Request, Response}; -use hyper_util::rt::TokioIo; use insta::{assert_json_snapshot, assert_snapshot, with_settings}; use std::io::Read; use std::str::FromStr; use tokio::net::TcpListener; use url::Url; + use uv_cache::Cache; use uv_client::RegistryClientBuilder; use uv_client::{BaseClientBuilder, LineHaul}; @@ -42,37 +37,12 @@ fn get_version_codename() -> Result, std::io::Error> { Ok(None) } +use crate::http_util::start_http_user_agent_server; #[tokio::test] async fn test_user_agent_has_version() -> 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"); - }); - }); + // Initialize dummy http server + let (server_task, addr) = start_http_user_agent_server().await?; // Initialize uv-client let cache = Cache::temp()?.init()?; @@ -118,41 +88,15 @@ async fn test_user_agent_has_version() -> Result<()> { }); // Wait for the server task to complete, to be a good citizen. - server_task.await?; + let _ = server_task.await?; Ok(()) } #[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"); - }); - }); + // Initialize dummy http server + let (server_task, addr) = start_http_user_agent_server().await?; // Add some representative markers for an Ubuntu CI runner let markers = MarkerEnvironment::try_from(MarkerEnvironmentBuilder { @@ -167,8 +111,7 @@ async fn test_user_agent_has_linehaul() -> Result<()> { python_full_version: "3.12.2", python_version: "3.12", sys_platform: "linux", - }) - .unwrap(); + })?; // Initialize uv-client let cache = Cache::temp()?.init()?; @@ -213,7 +156,7 @@ async fn test_user_agent_has_linehaul() -> Result<()> { let body = res.text().await?; // Wait for the server task to complete, to be a good citizen. - server_task.await?; + let _ = server_task.await?; // Unpack User-Agent with linehaul let (uv_version, uv_linehaul) = body diff --git a/crates/uv-preview/src/lib.rs b/crates/uv-preview/src/lib.rs index dd849d040..c617169a8 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -24,6 +24,7 @@ bitflags::bitflags! { const INIT_PROJECT_FLAG = 1 << 12; const WORKSPACE_METADATA = 1 << 13; const WORKSPACE_DIR = 1 << 14; + const WORKSPACE_LIST = 1 << 15; } } @@ -48,6 +49,7 @@ impl PreviewFeatures { Self::INIT_PROJECT_FLAG => "init-project-flag", Self::WORKSPACE_METADATA => "workspace-metadata", Self::WORKSPACE_DIR => "workspace-dir", + Self::WORKSPACE_LIST => "workspace-list", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -100,6 +102,7 @@ impl FromStr for PreviewFeatures { "init-project-flag" => Self::INIT_PROJECT_FLAG, "workspace-metadata" => Self::WORKSPACE_METADATA, "workspace-dir" => Self::WORKSPACE_DIR, + "workspace-list" => Self::WORKSPACE_LIST, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; diff --git a/crates/uv-redacted/src/lib.rs b/crates/uv-redacted/src/lib.rs index c6cb2d5aa..ddd1a5dfc 100644 --- a/crates/uv-redacted/src/lib.rs +++ b/crates/uv-redacted/src/lib.rs @@ -63,45 +63,69 @@ impl DisplaySafeUrl { pub fn parse(input: &str) -> Result { let url = Url::parse(input)?; - // Reject some ambiguous cases, e.g., `https://user/name:password@domain/a/b/c` - // - // In this case the user *probably* meant to have a username of "user/name", but both RFC - // 3986 and WHATWG URL expect the userinfo (RFC 3986) or authority (WHATWG) to not contain a - // non-percent-encoded slash or other special character. - // - // This ends up being moderately annoying to detect, since the above gets parsed into a - // "valid" WHATWG URL where the host is `used` and the pathname is - // `/name:password@domain/a/b/c` rather than causing a parse error. - // - // To detect it, we use a heuristic: if the password component is missing but the path or - // fragment contain a `:` followed by a `@`, then we assume the URL is ambiguous. - if url.password().is_none() - && (url - .path() - .find(':') - .is_some_and(|pos| url.path()[pos..].contains('@')) - || url - .fragment() - .map(|fragment| { - fragment - .find(':') - .is_some_and(|pos| fragment[pos..].contains('@')) - }) - .unwrap_or(false)) - // If the above is true, we should always expect to find these in the given URL - && let Some(col_pos) = input.find(':') - && let Some(at_pos) = input.rfind('@') - { - // Our ambiguous URL probably has credentials in it, so we don't want to blast it out in - // the error message. We somewhat aggressively replace everything between the scheme's - // ':' and the lastmost `@` with `***`. - let redacted_path = format!("{}***{}", &input[0..=col_pos], &input[at_pos..]); - return Err(DisplaySafeUrlError::AmbiguousAuthority(redacted_path)); - } + Self::reject_ambiguous_credentials(input, &url)?; Ok(Self(url)) } + /// Reject some ambiguous cases, e.g., `https://user/name:password@domain/a/b/c` + /// + /// In this case the user *probably* meant to have a username of "user/name", but both RFC + /// 3986 and WHATWG URL expect the userinfo (RFC 3986) or authority (WHATWG) to not contain a + /// non-percent-encoded slash or other special character. + /// + /// This ends up being moderately annoying to detect, since the above gets parsed into a + /// "valid" WHATWG URL where the host is `used` and the pathname is + /// `/name:password@domain/a/b/c` rather than causing a parse error. + /// + /// To detect it, we use a heuristic: if the password component is missing but the path or + /// fragment contain a `:` followed by a `@`, then we assume the URL is ambiguous. + fn reject_ambiguous_credentials(input: &str, url: &Url) -> Result<(), DisplaySafeUrlError> { + // `git://`, `http://`, and `https://` URLs may carry credentials, while `file://` URLs + // on Windows may contain both sigils, but it's always safe, e.g. + // `file://C:/Users/ferris/project@home/workspace`. + if url.scheme() == "file" { + return Ok(()); + } + + if url.password().is_some() { + return Ok(()); + } + + // Check for the suspicious pattern. + if !url + .path() + .find(':') + .is_some_and(|pos| url.path()[pos..].contains('@')) + && !url + .fragment() + .map(|fragment| { + fragment + .find(':') + .is_some_and(|pos| fragment[pos..].contains('@')) + }) + .unwrap_or(false) + { + return Ok(()); + } + + // If the previous check passed, we should always expect to find these in the given URL. + let (Some(col_pos), Some(at_pos)) = (input.find(':'), input.rfind('@')) else { + if cfg!(debug_assertions) { + unreachable!( + "`:` or `@` sign missing in URL that was confirmed to contain them: {input}" + ); + } + return Ok(()); + }; + + // Our ambiguous URL probably has credentials in it, so we don't want to blast it out in + // the error message. We somewhat aggressively replace everything between the scheme's + // ':' and the lastmost `@` with `***`. + let redacted_path = format!("{}***{}", &input[0..=col_pos], &input[at_pos..]); + Err(DisplaySafeUrlError::AmbiguousAuthority(redacted_path)) + } + /// Create a new [`DisplaySafeUrl`] from a [`Url`]. /// /// Unlike [`Self::parse`], this doesn't perform any ambiguity checks. @@ -453,4 +477,15 @@ mod tests { } } } + + #[test] + fn parse_url_not_ambiguous() { + #[allow(clippy::single_element_loop)] + for url in &[ + // https://github.com/astral-sh/uv/issues/16756 + "file:///C:/jenkins/ython_Environment_Manager_PR-251@2/venv%201/workspace", + ] { + DisplaySafeUrl::parse(url).unwrap(); + } + } } diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 53e1d98f1..1f9cf3d21 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -586,9 +586,19 @@ impl EnvVars { pub const XDG_BIN_HOME: &'static str = "XDG_BIN_HOME"; /// Custom certificate bundle file path for SSL connections. + /// + /// Takes precedence over `UV_NATIVE_TLS` when set. #[attr_added_in("0.1.14")] pub const SSL_CERT_FILE: &'static str = "SSL_CERT_FILE"; + /// Custom path for certificate bundles for SSL connections. + /// Multiple entries are supported separated using a platform-specific + /// delimiter (`:` on Unix, `;` on Windows). + /// + /// Takes precedence over `UV_NATIVE_TLS` when set. + #[attr_added_in("0.9.10")] + pub const SSL_CERT_DIR: &'static str = "SSL_CERT_DIR"; + /// If set, uv will use this file for mTLS authentication. /// This should be a single file containing both the certificate and the private key in PEM format. #[attr_added_in("0.2.11")] diff --git a/crates/uv-version/Cargo.toml b/crates/uv-version/Cargo.toml index 4e41fc464..445ff8736 100644 --- a/crates/uv-version/Cargo.toml +++ b/crates/uv-version/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-version" -version = "0.9.9" +version = "0.9.10" edition = { workspace = true } rust-version = { workspace = true } homepage = { workspace = true } diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 3be7b8b51..9b7fc8828 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv" -version = "0.9.9" +version = "0.9.10" edition = { workspace = true } rust-version = { workspace = true } homepage = { workspace = true } diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index b1b1fbbda..252f43faf 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -69,6 +69,7 @@ use uv_python::PythonEnvironment; use uv_scripts::Pep723Script; pub(crate) use venv::venv; pub(crate) use workspace::dir::dir; +pub(crate) use workspace::list::list; pub(crate) use workspace::metadata::metadata; use crate::printer::Printer; diff --git a/crates/uv/src/commands/workspace/list.rs b/crates/uv/src/commands/workspace/list.rs new file mode 100644 index 000000000..4689d6f38 --- /dev/null +++ b/crates/uv/src/commands/workspace/list.rs @@ -0,0 +1,36 @@ +use std::fmt::Write; +use std::path::Path; + +use anyhow::Result; + +use owo_colors::OwoColorize; +use uv_preview::{Preview, PreviewFeatures}; +use uv_warnings::warn_user; +use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache}; + +use crate::commands::ExitStatus; +use crate::printer::Printer; + +/// List workspace members +pub(crate) async fn list( + project_dir: &Path, + preview: Preview, + printer: Printer, +) -> Result { + if !preview.is_enabled(PreviewFeatures::WORKSPACE_LIST) { + warn_user!( + "The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::WORKSPACE_LIST + ); + } + + let workspace_cache = WorkspaceCache::default(); + let workspace = + Workspace::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache).await?; + + for name in workspace.packages().keys() { + writeln!(printer.stdout(), "{}", name.cyan())?; + } + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/workspace/mod.rs b/crates/uv/src/commands/workspace/mod.rs index 99de8246b..6ee08d3e2 100644 --- a/crates/uv/src/commands/workspace/mod.rs +++ b/crates/uv/src/commands/workspace/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod dir; +pub(crate) mod list; pub(crate) mod metadata; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index b1a1a2711..84f6411a7 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1746,6 +1746,9 @@ async fn run(mut cli: Cli) -> Result { WorkspaceCommand::Dir(args) => { commands::dir(args.package, &project_dir, globals.preview, printer).await } + WorkspaceCommand::List(_args) => { + commands::list(&project_dir, globals.preview, printer).await + } }, Commands::BuildBackend { command } => spawn_blocking(move || match command { BuildBackendCommand::BuildSdist { sdist_directory } => { diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 40a131e10..a7e975ffc 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -1093,6 +1093,14 @@ impl TestContext { command } + /// Create a `uv workspace list` command with options shared across scenarios. + pub fn workspace_list(&self) -> Command { + let mut command = Self::new_command(); + command.arg("workspace").arg("list"); + self.add_shared_options(&mut command, false); + command + } + /// Create a `uv export` command with options shared across scenarios. pub fn export(&self) -> Command { let mut command = Self::new_command(); diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index 20cb3820f..489a45228 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -142,4 +142,5 @@ mod workflow; mod extract; mod workspace; mod workspace_dir; +mod workspace_list; mod workspace_metadata; diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 890f693fe..f57510b33 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -7831,7 +7831,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST, ), }, python_preference: Managed, @@ -8059,7 +8059,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST, ), }, python_preference: Managed, diff --git a/crates/uv/tests/it/workspace_list.rs b/crates/uv/tests/it/workspace_list.rs new file mode 100644 index 000000000..1bccac994 --- /dev/null +++ b/crates/uv/tests/it/workspace_list.rs @@ -0,0 +1,220 @@ +use anyhow::Result; +use assert_cmd::assert::OutputAssertExt; +use assert_fs::fixture::PathChild; + +use crate::common::{TestContext, copy_dir_ignore, uv_snapshot}; + +/// Test basic list output for a simple workspace with one member. +#[test] +fn workspace_list_simple() { + let context = TestContext::new("3.12"); + + // Initialize a workspace with one member + context.init().arg("foo").assert().success(); + + let workspace = context.temp_dir.child("foo"); + + uv_snapshot!(context.filters(), context.workspace_list().current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + foo + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); +} + +/// Test list output for a root workspace (workspace with a root package). +#[test] +fn workspace_list_root_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + context + .workspace_root + .join("scripts/workspaces/albatross-root-workspace"), + &workspace, + )?; + + uv_snapshot!(context.filters(), context.workspace_list().current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + albatross + bird-feeder + seeds + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); + + Ok(()) +} + +/// Test list output for a virtual workspace (no root package). +#[test] +fn workspace_list_virtual_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + context + .workspace_root + .join("scripts/workspaces/albatross-virtual-workspace"), + &workspace, + )?; + + uv_snapshot!(context.filters(), context.workspace_list().current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + albatross + bird-feeder + seeds + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); + + Ok(()) +} + +/// Test list output when run from a workspace member directory. +#[test] +fn workspace_list_from_member() -> Result<()> { + let context = TestContext::new("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + context + .workspace_root + .join("scripts/workspaces/albatross-root-workspace"), + &workspace, + )?; + + let member_dir = workspace.join("packages").join("bird-feeder"); + + uv_snapshot!(context.filters(), context.workspace_list().current_dir(&member_dir), @r" + success: true + exit_code: 0 + ----- stdout ----- + albatross + bird-feeder + seeds + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); + + Ok(()) +} + +/// Test list output for a workspace with multiple packages. +#[test] +fn workspace_list_multiple_members() { + let context = TestContext::new("3.12"); + + // Initialize workspace root + context.init().arg("pkg-a").assert().success(); + + let workspace_root = context.temp_dir.child("pkg-a"); + + // Add more members + context + .init() + .arg("pkg-b") + .current_dir(&workspace_root) + .assert() + .success(); + + context + .init() + .arg("pkg-c") + .current_dir(&workspace_root) + .assert() + .success(); + + uv_snapshot!(context.filters(), context.workspace_list().current_dir(&workspace_root), @r" + success: true + exit_code: 0 + ----- stdout ----- + pkg-a + pkg-b + pkg-c + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); +} + +/// Test list output for a single project (not a workspace). +#[test] +fn workspace_list_single_project() { + let context = TestContext::new("3.12"); + + context.init().arg("my-project").assert().success(); + + let project = context.temp_dir.child("my-project"); + + uv_snapshot!(context.filters(), context.workspace_list().current_dir(&project), @r" + success: true + exit_code: 0 + ----- stdout ----- + my-project + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); +} + +/// Test list output with excluded packages. +#[test] +fn workspace_list_with_excluded() -> Result<()> { + let context = TestContext::new("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + context + .workspace_root + .join("scripts/workspaces/albatross-project-in-excluded"), + &workspace, + )?; + + uv_snapshot!(context.filters(), context.workspace_list().current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + albatross + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); + + Ok(()) +} + +/// Test list error output when not in a project. +#[test] +fn workspace_list_no_project() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.workspace_list(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + error: No `pyproject.toml` found in current directory or any parent directory + " + ); +} diff --git a/docs/concepts/build-backend.md b/docs/concepts/build-backend.md index 101c2a3ff..51729d968 100644 --- a/docs/concepts/build-backend.md +++ b/docs/concepts/build-backend.md @@ -31,7 +31,7 @@ To use uv as a build backend in an existing project, add `uv_build` to the ```toml title="pyproject.toml" [build-system] -requires = ["uv_build>=0.9.9,<0.10.0"] +requires = ["uv_build>=0.9.10,<0.10.0"] build-backend = "uv_build" ``` diff --git a/docs/concepts/configuration-files.md b/docs/concepts/configuration-files.md index 05dcbdb35..e034bd198 100644 --- a/docs/concepts/configuration-files.md +++ b/docs/concepts/configuration-files.md @@ -42,13 +42,14 @@ default = true `pyproject.toml` files are present in a directory, configuration will be read from `uv.toml`, and `[tool.uv]` section in the accompanying `pyproject.toml` will be ignored. -uv will also discover user-level configuration at `~/.config/uv/uv.toml` (or -`$XDG_CONFIG_HOME/uv/uv.toml`) on macOS and Linux, or `%APPDATA%\uv\uv.toml` on Windows; and -system-level configuration at `/etc/uv/uv.toml` (or `$XDG_CONFIG_DIRS/uv/uv.toml`) on macOS and -Linux, or `%SYSTEMDRIVE%\ProgramData\uv\uv.toml` on Windows. +uv will also discover `uv.toml` configuration files in the user- and system-level +[configuration directories](../reference/storage.md#configuration-directories), e.g., user-level +configuration in `~/.config/uv/uv.toml`, and system-level configuration at `/etc/uv/uv.toml` on +macOS and Linux. -User-and system-level configuration must use the `uv.toml` format, rather than the `pyproject.toml` -format, as a `pyproject.toml` is intended to define a Python _project_. +!!! important + + User- and system-level configuration files cannot use the `pyproject.toml` format. If project-, user-, and system-level configuration files are found, the settings will be merged, with project-level configuration taking precedence over the user-level configuration, and user-level diff --git a/docs/concepts/preview.md b/docs/concepts/preview.md index b1f222e5a..1e411670f 100644 --- a/docs/concepts/preview.md +++ b/docs/concepts/preview.md @@ -74,6 +74,7 @@ The following preview features are available: [system-native location](../concepts/authentication/http.md#the-uv-credentials-store). - `workspace-metadata`: Allows using `uv workspace metadata`. - `workspace-dir`: Allows using `uv workspace dir`. +- `workspace-list`: Allows using `uv workspace list`. ## Disabling preview features diff --git a/docs/concepts/projects/init.md b/docs/concepts/projects/init.md index c8ee1bb3b..83f56a364 100644 --- a/docs/concepts/projects/init.md +++ b/docs/concepts/projects/init.md @@ -113,7 +113,7 @@ dependencies = [] example-pkg = "example_pkg:main" [build-system] -requires = ["uv_build>=0.9.9,<0.10.0"] +requires = ["uv_build>=0.9.10,<0.10.0"] build-backend = "uv_build" ``` @@ -136,7 +136,7 @@ dependencies = [] example-pkg = "example_pkg:main" [build-system] -requires = ["uv_build>=0.9.9,<0.10.0"] +requires = ["uv_build>=0.9.10,<0.10.0"] build-backend = "uv_build" ``` @@ -197,7 +197,7 @@ requires-python = ">=3.11" dependencies = [] [build-system] -requires = ["uv_build>=0.9.9,<0.10.0"] +requires = ["uv_build>=0.9.10,<0.10.0"] build-backend = "uv_build" ``` diff --git a/docs/concepts/projects/workspaces.md b/docs/concepts/projects/workspaces.md index 47ac7d183..d1615d185 100644 --- a/docs/concepts/projects/workspaces.md +++ b/docs/concepts/projects/workspaces.md @@ -75,7 +75,7 @@ bird-feeder = { workspace = true } members = ["packages/*"] [build-system] -requires = ["uv_build>=0.9.9,<0.10.0"] +requires = ["uv_build>=0.9.10,<0.10.0"] build-backend = "uv_build" ``` @@ -106,7 +106,7 @@ tqdm = { git = "https://github.com/tqdm/tqdm" } members = ["packages/*"] [build-system] -requires = ["uv_build>=0.9.9,<0.10.0"] +requires = ["uv_build>=0.9.10,<0.10.0"] build-backend = "uv_build" ``` @@ -188,7 +188,7 @@ dependencies = ["bird-feeder", "tqdm>=4,<5"] bird-feeder = { path = "packages/bird-feeder" } [build-system] -requires = ["uv_build>=0.9.9,<0.10.0"] +requires = ["uv_build>=0.9.10,<0.10.0"] build-backend = "uv_build" ``` diff --git a/docs/concepts/python-versions.md b/docs/concepts/python-versions.md index bcb40ccd2..65576ef78 100644 --- a/docs/concepts/python-versions.md +++ b/docs/concepts/python-versions.md @@ -121,10 +121,15 @@ present, uv will install all the Python versions listed in the file. The available Python versions are frozen for each uv release. To install new Python versions, you may need upgrade uv. +See the [storage documentation](../reference/storage.md#python-versions) for details about where +installed Python versions are stored. + ### Installing Python executables -uv installs Python executables into your `PATH` by default, e.g., `uv python install 3.12` will -install a Python executable into `~/.local/bin`, e.g., as `python3.12`. +uv installs Python executables into your `PATH` by default, e.g., on Unix `uv python install 3.12` +will install a Python executable into `~/.local/bin`, e.g., as `python3.12`. See the +[storage documentation](../reference/storage.md#python-executables) for more details about the +target directory. !!! tip diff --git a/docs/concepts/tools.md b/docs/concepts/tools.md index b721dfa8e..5362932e9 100644 --- a/docs/concepts/tools.md +++ b/docs/concepts/tools.md @@ -35,9 +35,14 @@ treated as disposable, i.e., if you run `uv cache clean` the environment will be environment is only cached to reduce the overhead of repeated invocations. If the environment is removed, a new one will be created automatically. -When installing a tool with `uv tool install`, a virtual environment is created in the uv tools -directory. The environment will not be removed unless the tool is uninstalled. If the environment is -manually deleted, the tool will fail to run. +When installing a tool with `uv tool install`, a virtual environment is created in the +[uv tools directory](../reference/storage.md#tools). The environment will not be removed unless the +tool is uninstalled. If the environment is manually deleted, the tool will fail to run. + +!!! important + + Tool environments are _not_ intended to be mutated directly. It is strongly recommended never to + mutate a tool environment manually, e.g., with a `pip` operation. ## Tool versions @@ -109,26 +114,6 @@ $ uv tool install ruff@latest $ uv tool install ruff@0.6.0 ``` -## Tools directory - -By default, the uv tools directory is named `tools` and is in the uv application state directory, -e.g., `~/.local/share/uv/tools`. The location may be customized with the `UV_TOOL_DIR` environment -variable. - -To display the path to the tool installation directory: - -```console -$ uv tool dir -``` - -Tool environments are placed in a directory with the same name as the tool package, e.g., -`.../tools/`. - -!!! important - - Tool environments are _not_ intended to be mutated directly. It is strongly recommended never to - mutate a tool environment manually, e.g., with a `pip` operation. - ## Upgrading tools Tool environments may be upgraded via `uv tool upgrade`, or re-created entirely via subsequent @@ -259,35 +244,23 @@ tool may be unusable. ## Tool executables Tool executables include all console entry points, script entry points, and binary scripts provided -by a Python package. Tool executables are symlinked into the `bin` directory on Unix and copied on -Windows. +by a Python package. Tool executables are symlinked into the +[executable directory](../reference/storage.md#tool-executables) on Unix and copied on Windows. -### The `bin` directory +!!! note -Executables are installed into the user `bin` directory following the XDG standard, e.g., -`~/.local/bin`. Unlike other directory schemes in uv, the XDG standard is used on _all platforms_ -notably including Windows and macOS — there is no clear alternative location to place executables on -these platforms. The installation directory is determined from the first available environment -variable: + Executables provided by dependencies of tool packages are not installed. -- `$UV_TOOL_BIN_DIR` -- `$XDG_BIN_HOME` -- `$XDG_DATA_HOME/../bin` -- `$HOME/.local/bin` - -Executables provided by dependencies of tool packages are not installed. - -### The `PATH` - -The `bin` directory must be in the `PATH` variable for tool executables to be available from the -shell. If it is not in the `PATH`, a warning will be displayed. The `uv tool update-shell` command -can be used to add the `bin` directory to the `PATH` in common shell configuration files. +The [executable directory](../reference/storage.md#executable-directory) must be in the `PATH` +variable for tool executables to be available from the shell. If it is not in the `PATH`, a warning +will be displayed. The `uv tool update-shell` command can be used to add the executable directory to +the `PATH` in common shell configuration files. ### Overwriting executables -Installation of tools will not overwrite executables in the `bin` directory that were not previously -installed by uv. For example, if `pipx` has been used to install a tool, `uv tool install` will -fail. The `--force` flag can be used to override this behavior. +Installation of tools will not overwrite executables in the executable directory that were not +previously installed by uv. For example, if `pipx` has been used to install a tool, +`uv tool install` will fail. The `--force` flag can be used to override this behavior. ## Relationship to `uv run` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 010e553a3..4ab337500 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -25,7 +25,7 @@ uv provides a standalone installer to download and install uv: Request a specific version by including it in the URL: ```console - $ curl -LsSf https://astral.sh/uv/0.9.9/install.sh | sh + $ curl -LsSf https://astral.sh/uv/0.9.10/install.sh | sh ``` === "Windows" @@ -41,7 +41,7 @@ uv provides a standalone installer to download and install uv: Request a specific version by including it in the URL: ```pwsh-session - PS> powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/0.9.9/install.ps1 | iex" + PS> powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/0.9.10/install.ps1 | iex" ``` !!! tip @@ -260,7 +260,8 @@ If you need to remove uv from your system, follow these steps: !!! tip - Before removing the binaries, you may want to remove any data that uv has stored. + Before removing the binaries, you may want to remove any data that uv has stored. See the + [storage reference](../reference/storage.md) for details on where uv stores data. 2. Remove the uv, uvx, and uvw binaries: diff --git a/docs/guides/integration/aws-lambda.md b/docs/guides/integration/aws-lambda.md index a6d410cf0..d4fe78251 100644 --- a/docs/guides/integration/aws-lambda.md +++ b/docs/guides/integration/aws-lambda.md @@ -92,7 +92,7 @@ the second stage, we'll copy this directory over to the final image, omitting th other unnecessary files. ```dockerfile title="Dockerfile" -FROM ghcr.io/astral-sh/uv:0.9.9 AS uv +FROM ghcr.io/astral-sh/uv:0.9.10 AS uv # First, bundle the dependencies into the task root. FROM public.ecr.aws/lambda/python:3.13 AS builder @@ -334,7 +334,7 @@ And confirm that opening http://127.0.0.1:8000/ in a web browser displays, "Hell Finally, we'll update the Dockerfile to include the local library in the deployment package: ```dockerfile title="Dockerfile" -FROM ghcr.io/astral-sh/uv:0.9.9 AS uv +FROM ghcr.io/astral-sh/uv:0.9.10 AS uv # First, bundle the dependencies into the task root. FROM public.ecr.aws/lambda/python:3.13 AS builder diff --git a/docs/guides/integration/docker.md b/docs/guides/integration/docker.md index e76e0fcd9..71668e5c4 100644 --- a/docs/guides/integration/docker.md +++ b/docs/guides/integration/docker.md @@ -31,7 +31,7 @@ $ docker run --rm -it ghcr.io/astral-sh/uv:debian uv --help The following distroless images are available: - `ghcr.io/astral-sh/uv:latest` -- `ghcr.io/astral-sh/uv:{major}.{minor}.{patch}`, e.g., `ghcr.io/astral-sh/uv:0.9.9` +- `ghcr.io/astral-sh/uv:{major}.{minor}.{patch}`, e.g., `ghcr.io/astral-sh/uv:0.9.10` - `ghcr.io/astral-sh/uv:{major}.{minor}`, e.g., `ghcr.io/astral-sh/uv:0.8` (the latest patch version) @@ -95,7 +95,7 @@ And the following derived images are available: As with the distroless image, each derived image is published with uv version tags as `ghcr.io/astral-sh/uv:{major}.{minor}.{patch}-{base}` and -`ghcr.io/astral-sh/uv:{major}.{minor}-{base}`, e.g., `ghcr.io/astral-sh/uv:0.9.9-alpine`. +`ghcr.io/astral-sh/uv:{major}.{minor}-{base}`, e.g., `ghcr.io/astral-sh/uv:0.9.10-alpine`. In addition, starting with `0.8` each derived image also sets `UV_TOOL_BIN_DIR` to `/usr/local/bin` to allow `uv tool install` to work as expected with the default user. @@ -136,7 +136,7 @@ Note this requires `curl` to be available. In either case, it is best practice to pin to a specific uv version, e.g., with: ```dockerfile -COPY --from=ghcr.io/astral-sh/uv:0.9.9 /uv /uvx /bin/ +COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /uvx /bin/ ``` !!! tip @@ -154,7 +154,7 @@ COPY --from=ghcr.io/astral-sh/uv:0.9.9 /uv /uvx /bin/ Or, with the installer: ```dockerfile -ADD https://astral.sh/uv/0.9.9/install.sh /uv-installer.sh +ADD https://astral.sh/uv/0.9.10/install.sh /uv-installer.sh ``` ### Installing a project @@ -590,5 +590,5 @@ Verified OK !!! tip These examples use `latest`, but best practice is to verify the attestation for a specific - version tag, e.g., `ghcr.io/astral-sh/uv:0.9.9`, or (even better) the specific image digest, + version tag, e.g., `ghcr.io/astral-sh/uv:0.9.10`, or (even better) the specific image digest, such as `ghcr.io/astral-sh/uv:0.5.27@sha256:5adf09a5a526f380237408032a9308000d14d5947eafa687ad6c6a2476787b4f`. diff --git a/docs/guides/integration/fastapi.md b/docs/guides/integration/fastapi.md index 868553684..eeebba532 100644 --- a/docs/guides/integration/fastapi.md +++ b/docs/guides/integration/fastapi.md @@ -113,7 +113,7 @@ WORKDIR /app RUN uv sync --frozen --no-cache # Run the application. -CMD ["/app/.venv/bin/fastapi", "run", "main.py", "--port", "80", "--host", "0.0.0.0"] +CMD ["/app/.venv/bin/fastapi", "run", "app/main.py", "--port", "80", "--host", "0.0.0.0"] ``` Build the Docker image with: diff --git a/docs/guides/integration/github.md b/docs/guides/integration/github.md index a6d5aaa34..461de313b 100644 --- a/docs/guides/integration/github.md +++ b/docs/guides/integration/github.md @@ -47,7 +47,7 @@ jobs: uses: astral-sh/setup-uv@v6 with: # Install a specific version of uv. - version: "0.9.9" + version: "0.9.10" ``` ## Setting up Python diff --git a/docs/guides/integration/pre-commit.md b/docs/guides/integration/pre-commit.md index 24ed3c55d..d12b2def4 100644 --- a/docs/guides/integration/pre-commit.md +++ b/docs/guides/integration/pre-commit.md @@ -19,7 +19,7 @@ To make sure your `uv.lock` file is up to date even if your `pyproject.toml` fil repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.9.9 + rev: 0.9.10 hooks: - id: uv-lock ``` @@ -30,7 +30,7 @@ To keep a `requirements.txt` file in sync with your `uv.lock` file: repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.9.9 + rev: 0.9.10 hooks: - id: uv-export ``` @@ -41,7 +41,7 @@ To compile requirements files: repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.9.9 + rev: 0.9.10 hooks: # Compile requirements - id: pip-compile @@ -54,7 +54,7 @@ To compile alternative requirements files, modify `args` and `files`: repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.9.9 + rev: 0.9.10 hooks: # Compile requirements - id: pip-compile @@ -68,7 +68,7 @@ To run the hook over multiple files at the same time, add additional entries: repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.9.9 + rev: 0.9.10 hooks: # Compile requirements - id: pip-compile diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 9bae80e46..a228ec127 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -1004,11 +1004,22 @@ the fact that Windows' real main thread is only 1MB. That thread has size The standard `SHELL` posix env var. +### `SSL_CERT_DIR` +added in `0.9.10` + +Custom path for certificate bundles for SSL connections. +Multiple entries are supported separated using a platform-specific +delimiter (`:` on Unix, `;` on Windows). + +Takes precedence over `UV_NATIVE_TLS` when set. + ### `SSL_CERT_FILE` added in `0.1.14` Custom certificate bundle file path for SSL connections. +Takes precedence over `UV_NATIVE_TLS` when set. + ### `SSL_CLIENT_CERT` added in `0.2.11` diff --git a/docs/reference/index.md b/docs/reference/index.md index 0c9102ef9..474b4121f 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -5,6 +5,7 @@ The reference section provides information about specific parts of uv: - [Commands](./cli.md): A reference for uv's command line interface. - [Settings](./settings.md): A reference for uv's configuration schema. - [Resolver](./internals/resolver.md): Details about the internals of uv's resolver. +- [Storage](./storage.md): Information about where uv stores data on your system. - [Policies](./policies/index.md): uv's versioning policy, platform support policy, and license. Looking for a broader overview? Check out the [concepts](../concepts/index.md) documentation. diff --git a/docs/reference/installer.md b/docs/reference/installer.md index 609a8f7ec..5128bc262 100644 --- a/docs/reference/installer.md +++ b/docs/reference/installer.md @@ -2,9 +2,7 @@ ## Changing the installation path -By default, uv is installed to `~/.local/bin`. If `XDG_BIN_HOME` is set, it will be used instead. -Similarly, if `XDG_DATA_HOME` is set, the target directory will be inferred as -`XDG_DATA_HOME/../bin`. +By default, uv is installed in the user [executable directory](./storage.md#executable-directory). To change the installation path, use `UV_INSTALL_DIR`: @@ -20,6 +18,12 @@ To change the installation path, use `UV_INSTALL_DIR`: PS> powershell -ExecutionPolicy ByPass -c {$env:UV_INSTALL_DIR = "C:\Custom\Path";irm https://astral.sh/uv/install.ps1 | iex} ``` +!!! note + + Changing the installation path only affects where the uv binary is installed. uv will still store + its data (cache, Python installations, tools, etc.) in the default locations. See the + [storage reference](./storage.md) for details on these locations and how to customize them. + ## Disabling shell modifications The installer may also update your shell profiles to ensure the uv binary is on your `PATH`. To diff --git a/docs/reference/storage.md b/docs/reference/storage.md new file mode 100644 index 000000000..44cd20151 --- /dev/null +++ b/docs/reference/storage.md @@ -0,0 +1,200 @@ +# Storage + +## Storage directories + +uv uses the following high-level directories for storage. + +For each location, uv checks for the existence of environment variables in the given order and uses +the first path found. + +The paths of storage directories are platform-specific. uv follows the +[XDG](https://specifications.freedesktop.org/basedir-spec/latest/) conventions on Linux and macOS +and the [Known Folder](https://learn.microsoft.com/en-us/windows/win32/shell/known-folders) scheme +on Windows. + +### Temporary directory + +The temporary directory is used for ephemeral data. + +=== "Unix" + + 1. `$TMPDIR` + 1. `/tmp` + +=== "Windows" + + 1. `%TMP%` + 1. `%TEMP%` + 1. `%USERPROFILE%` + +### Cache directory + +The cache directory is used for data that is disposable, but is useful to be long-lived. + +=== "Unix" + + 1. `$XDG_CACHE_HOME/uv` + 1. `$HOME/.cache/uv` + +=== "Windows" + + 1. `%LOCALAPPDATA%\uv\cache` + 1. `uv\cache` within [`FOLDERID_LocalAppData`](https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid#FOLDERID_LocalAppData) + +### Persistent data directory + +The persistent data directory is used for non-disposable data. + +=== "Unix" + + 1. `$XDG_DATA_HOME/uv` + 1. `$HOME/.local/share/uv` + 1. `$CWD/.uv` + +=== "Windows" + + 1. `%APPDATA%\uv\data` + 1. `.\.uv` + +### Configuration directories + +The configuration directories are used to store changes to uv's settings. + +User-level configuration + +=== "Unix" + + 1. `$XDG_CONFIG_HOME/uv` + 1. `$HOME/.config/uv` + +=== "Windows" + + 1. `%APPDATA%\uv` + 1. `uv` within [`FOLDERID_RoamingAppData`](https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid#FOLDERID_RoamingAppData) + +System-level configuration + +=== "Unix" + + 1. `$XDG_CONFIG_DIRS/uv` + 1. `/etc/uv` + +=== "Windows" + + 1. `%PROGRAMDATA%\uv` + 1. `uv` within [`FOLDERID_AppDataProgramData`](https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid#FOLDERID_AppDataProgramData) + +### Executable directory + +The executable directory is used to store files that can be run by the user, i.e., a directory that +should be on the `PATH`. + +=== "Unix" + + 1. `$XDG_BIN_HOME` + 1. `$XDG_DATA_HOME/../bin` + 1. `$HOME/.local/bin` + +=== "Windows" + + 1. `%XDG_BIN_HOME%` + 1. `%XDG_DATA_HOME%\..\bin` + 1. `%USERPROFILE%\.local\bin` + +## Types of data + +### Dependency cache + +uv uses a local cache to avoid re-downloading and re-building dependencies. + +By default, the cache is stored in the [cache directory](#cache-directory) but it can be overridden +via command line arguments, environment variables, or settings as detailed in +[the cache documentation](../concepts/cache.md#cache-directory). When the cache is disabled, the +cache will be stored in a [temporary directory](#temporary-directory). + +Use `uv cache dir` to show the current cache directory path. + +!!! important + + For optimal performance, the cache directory needs to be on the same filesystem as virtual + environments. + +### Python versions + +uv can install managed [Python versions](../concepts/python-versions.md), e.g., with +`uv python install`. + +By default, Python versions managed by uv are stored in a `python/` subdirectory of the +[persistent data directory](#persistent-data-directory), e.g., `~/.local/share/uv/python`. + +Use `uv python dir` to show the Python installation directory. + +Use the `UV_PYTHON_INSTALL_DIR` environment variable to override the installation directory. + +!!! note + + Changing where Python is installed will not be automatically reflected in existing virtual environments; they will keep referring to the old location, and will need to be updated manually (e.g. by re-creating them). + +### Python executables + +uv installs executables for [Python versions](#python-versions), e.g., `python3.13`. + +By default, Python executables are stored in the [executable directory](#executable-directory). + +Use `uv python dir --bin` to show the Python executable directory. + +Use the `UV_PYTHON_BIN_DIR` environment variable to override the Python executable directory. + +### Tools + +uv can install Python packages as [command-line tools](../concepts/tools.md) using +`uv tool install`. + +By default, tools are installed in a `tools/` subdirectory of the +[persistent data directory](#persistent-data-directory), e.g., `~/.local/share/uv/tools`. + +Use `uv tool dir` to show the tool installation directory. + +Use the `UV_TOOL_DIR` environment variable to configure the installation directory. + +### Tool executables + +uv installs executables for installed [tools](#tools), e.g., `ruff`. + +By default, tool executables are stored in the [executable directory](#executable-directory). + +Use `uv tool dir --bin` to show the tool executable directory. + +Use the `UV_TOOL_BIN_DIR` environment variable to configure the tool executable directory. + +### The uv executable + +When using uv's [standalone installer](./installer.md) to install uv, the `uv` and `uvx` executables +are installed into the [executable directory](#executable-directory). + +Use the `UV_INSTALL_DIR` environment variable to configure uv's installation directory. + +### Configuration files + +uv's behavior can be configured through TOML files. + +Configuration files are discovered in the [configuration directories](#configuration-directories). + +For more details, see the [configuration files documentation](../concepts/configuration-files.md). + +### Project virtual environments + +When working on [projects](../concepts/projects/index.md), uv creates a dedicated virtual +environment for each project. + +By default, project virtual environments are created in `.venv` in the project or workspace root, +i.e., next to the `pyproject.toml`. + +Use the `UV_PROJECT_ENVIRONMENT` environment variable to override this location. For more details, +see the +[projects environment documentation](../concepts/projects/config.md#project-environment-path). + +### Script virtual environments + +When running [scripts with inline metadata](../guides/scripts.md), uv creates a dedicated virtual +environment for each script in the [cache directory](#cache-directory). diff --git a/mkdocs.template.yml b/mkdocs.template.yml index 50f9f4e01..e6823f3db 100644 --- a/mkdocs.template.yml +++ b/mkdocs.template.yml @@ -144,6 +144,7 @@ plugins: Reference: - reference/cli.md - reference/settings.md + - reference/storage.md - reference/environment.md - reference/installer.md extra_css: @@ -236,6 +237,7 @@ nav: - Commands: reference/cli.md - Settings: reference/settings.md - Environment variables: reference/environment.md + - Storage: reference/storage.md - Installer options: reference/installer.md - Troubleshooting: - reference/troubleshooting/index.md diff --git a/pyproject.toml b/pyproject.toml index 1e0dde167..959b59596 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "uv" -version = "0.9.9" +version = "0.9.10" description = "An extremely fast Python package and project manager, written in Rust." authors = [{ name = "Astral Software Inc.", email = "hey@astral.sh" }] requires-python = ">=3.8"