mirror of https://github.com/astral-sh/uv
Merge branch 'main' into sysinfo
This commit is contained in:
commit
05edb1a988
|
|
@ -49,3 +49,4 @@ jobs:
|
|||
title: "Sync latest Python releases"
|
||||
body: "Automated update for Python releases."
|
||||
base: "main"
|
||||
draft: true
|
||||
|
|
|
|||
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -3,6 +3,33 @@
|
|||
<!-- prettier-ignore-start -->
|
||||
|
||||
|
||||
## 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 `<workspace root>/dist/` rather than `<workspace root>/child/dist/`.
|
||||
|
||||
### Python
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<PackageName>,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<SelfSigned> {
|
||||
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<Incoming>,
|
||||
) -> future::Ready<Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error>>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TestServerBuilder<'a> {
|
||||
// Custom server response function
|
||||
svc_fn: Option<ServerSvcFn>,
|
||||
// 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<Result<()>>, 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<Incoming>| {
|
||||
// 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<Incoming>| 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<Result<()>>, 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<Result<()>>, 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<Result<()>>, SocketAddr)> {
|
||||
TestServerBuilder::new()
|
||||
.with_ca_cert(ca_cert)
|
||||
.with_server_cert(server_cert)
|
||||
.with_mutual_tls(true)
|
||||
.start()
|
||||
.await
|
||||
}
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
mod http_util;
|
||||
mod remote_metadata;
|
||||
mod ssl_certs;
|
||||
mod user_agent_version;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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::<reqwest_middleware::Error>().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::<std::io::Error>()
|
||||
&& let Some(wrapped_err) = io_err.get_ref()
|
||||
&& let Some(tls_err) = wrapped_err.downcast_ref::<rustls::Error>()
|
||||
&& 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::<reqwest_middleware::Error>().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::<std::io::Error>()
|
||||
&& let Some(wrapped_err) = io_err.get_ref()
|
||||
&& let Some(tls_err) = wrapped_err.downcast_ref::<rustls::Error>()
|
||||
&& 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::<reqwest_middleware::Error>().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::<std::io::Error>()
|
||||
&& let Some(wrapped_err) = io_err.get_ref()
|
||||
&& let Some(tls_err) = wrapped_err.downcast_ref::<rustls::Error>()
|
||||
&& matches!(tls_err, rustls::Error::NoCertificatesPresented)
|
||||
{
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
assert!(expected_err);
|
||||
|
||||
// Fin.
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -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<Option<String>, 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<hyper::body::Incoming>| {
|
||||
// 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<hyper::body::Incoming>| {
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -63,45 +63,69 @@ impl DisplaySafeUrl {
|
|||
pub fn parse(input: &str) -> Result<Self, DisplaySafeUrlError> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ExitStatus> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub(crate) mod dir;
|
||||
pub(crate) mod list;
|
||||
pub(crate) mod metadata;
|
||||
|
|
|
|||
|
|
@ -1746,6 +1746,9 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
|
|||
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 } => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -142,4 +142,5 @@ mod workflow;
|
|||
mod extract;
|
||||
mod workspace;
|
||||
mod workspace_dir;
|
||||
mod workspace_list;
|
||||
mod workspace_metadata;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<name>`.
|
||||
|
||||
!!! 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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
<small class="added-in">added in `0.9.10`</small>
|
||||
|
||||
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`
|
||||
<small class="added-in">added in `0.1.14`</small>
|
||||
|
||||
Custom certificate bundle file path for SSL connections.
|
||||
|
||||
Takes precedence over `UV_NATIVE_TLS` when set.
|
||||
|
||||
### `SSL_CLIENT_CERT`
|
||||
<small class="added-in">added in `0.2.11`</small>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue