This commit is contained in:
Marc Abramowitz 2025-06-25 12:24:19 +02:00 committed by GitHub
commit 45c961ce2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 236 additions and 7 deletions

1
Cargo.lock generated
View file

@ -5160,6 +5160,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"shellexpand",
"thiserror 2.0.12",
"toml",
"tracing",

View file

@ -158,6 +158,7 @@ serde = { version = "1.0.210", features = ["derive", "rc"] }
serde-untagged = { version = "0.1.6" }
serde_json = { version = "1.0.128" }
sha2 = { version = "0.10.8" }
shellexpand = { version = "3.1.0" }
smallvec = { version = "1.13.2" }
spdx = { version = "0.10.6" }
syn = { version = "2.0.77" }

View file

@ -45,6 +45,7 @@ rustc-hash = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
shellexpand = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }

View file

@ -1,7 +1,7 @@
use std::path::Path;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;
use uv_auth::{AuthPolicy, Credentials};
@ -11,9 +11,8 @@ use crate::index_name::{IndexName, IndexNameError};
use crate::origin::Origin;
use crate::{IndexStatusCodeStrategy, IndexUrl, IndexUrlError, SerializableStatusCode};
#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct Index {
/// The name of the index.
///
@ -82,6 +81,7 @@ pub struct Index {
/// url = "https://pypi.org/simple"
/// publish-url = "https://upload.pypi.org/legacy/"
/// ```
#[serde(rename = "publish-url")]
pub publish_url: Option<DisplaySafeUrl>,
/// When uv should use authentication for requests to the index.
///
@ -102,7 +102,7 @@ pub struct Index {
/// url = "https://<omitted>/simple"
/// ignore-error-codes = [401, 403]
/// ```
#[serde(default)]
#[serde(default, rename = "ignore-error-codes")]
pub ignore_error_codes: Option<Vec<SerializableStatusCode>>,
}
@ -293,6 +293,102 @@ impl FromStr for Index {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_index_environment_variable_expansion() {
// Test URL with environment variables that have defaults
// This way we don't need to set environment variables
let toml_content = r#"
name = "test-index"
url = "https://${TEST_INDEX_HOST:-example.com}:${TEST_INDEX_PORT:-8080}/simple"
publish-url = "https://${TEST_PUBLISH_HOST:-upload.example.com}/upload"
explicit = true
"#;
let index: Index = toml::from_str(toml_content).expect("Failed to deserialize index");
assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
assert_eq!(index.url.to_string(), "https://example.com:8080/simple");
assert_eq!(
index.publish_url.as_ref().unwrap().to_string(),
"https://upload.example.com/upload"
);
assert!(index.explicit);
}
#[test]
fn test_index_without_environment_variables() {
// Test normal URL without environment variables
let toml_content = r#"
name = "normal-index"
url = "https://pypi.org/simple"
default = true
"#;
let index: Index = toml::from_str(toml_content).expect("Failed to deserialize index");
assert_eq!(index.name.as_ref().unwrap().as_ref(), "normal-index");
assert_eq!(index.url.to_string(), "https://pypi.org/simple");
assert!(index.default);
assert!(!index.explicit);
}
#[test]
fn test_index_missing_environment_variable() {
// Test with missing environment variable - should fail gracefully
let toml_content = r#"
name = "missing-var-index"
url = "https://${MISSING_VAR}/simple"
"#;
let result: Result<Index, _> = toml::from_str(toml_content);
assert!(result.is_err());
let error_message = result.unwrap_err().to_string();
assert!(error_message.contains("Failed to expand environment variables"));
}
#[test]
fn test_index_tilde_expansion() {
// Test tilde expansion for local paths
let toml_content = r#"
name = "local-index"
url = "~/my-index"
"#;
let index: Index = toml::from_str(toml_content).expect("Failed to deserialize index");
assert_eq!(index.name.as_ref().unwrap().as_ref(), "local-index");
// The URL should have tilde expanded to the home directory
let url_str = index.url.to_string();
assert!(
!url_str.contains('~'),
"Tilde should be expanded: {url_str}"
);
}
#[test]
fn test_index_home_environment_variable() {
// Test using HOME environment variable which should always exist
let toml_content = r#"
name = "home-index"
url = "file://${HOME}/my-local-packages"
"#;
let index: Index = toml::from_str(toml_content).expect("Failed to deserialize index");
assert_eq!(index.name.as_ref().unwrap().as_ref(), "home-index");
// The URL should have HOME expanded to the actual home directory
let url_str = index.url.to_string();
assert!(url_str.starts_with("file://"));
assert!(!url_str.contains("${HOME}"));
assert!(url_str.contains("/my-local-packages"));
}
}
/// An [`IndexUrl`] along with the metadata necessary to query the index.
#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
pub struct IndexMetadata {
@ -384,3 +480,73 @@ pub enum IndexSourceError {
#[error("Index included a name, but the name was empty")]
EmptyName,
}
impl<'de> Deserialize<'de> for Index {
fn deserialize<D>(deserializer: D) -> Result<Index, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct IndexRaw {
name: Option<IndexName>,
url: String,
#[serde(default)]
explicit: bool,
#[serde(default)]
default: bool,
#[serde(default)]
format: IndexFormat,
publish_url: Option<String>,
#[serde(default)]
authenticate: AuthPolicy,
#[serde(default)]
ignore_error_codes: Option<Vec<SerializableStatusCode>>,
}
let raw = IndexRaw::deserialize(deserializer)?;
// Expand environment variables in the URL
let expanded_url = shellexpand::full(&raw.url).map_err(|e| {
D::Error::custom(format!(
"Failed to expand environment variables in URL '{}': {}",
raw.url, e
))
})?;
// Parse the expanded URL
let url = IndexUrl::parse(&expanded_url, None)
.map_err(|e| D::Error::custom(format!("Failed to parse URL '{expanded_url}': {e}")))?;
// Expand environment variables in publish_url if present
let publish_url = if let Some(publish_url_str) = raw.publish_url {
let expanded_publish_url = shellexpand::full(&publish_url_str).map_err(|e| {
D::Error::custom(format!(
"Failed to expand environment variables in publish URL '{publish_url_str}': {e}"
))
})?;
Some(expanded_publish_url.parse().map_err(|e| {
D::Error::custom(format!(
"Failed to parse publish URL '{expanded_publish_url}': {e}"
))
})?)
} else {
None
};
Ok(Index {
name: raw.name,
url,
explicit: raw.explicit,
default: raw.default,
origin: None,
format: raw.format,
publish_url,
authenticate: raw.authenticate,
ignore_error_codes: raw.ignore_error_codes,
})
}
}

View file

@ -18,7 +18,33 @@ name = "pytorch"
url = "https://download.pytorch.org/whl/cpu"
```
Indexes are prioritized in the order in which theyre defined, such that the first index listed in
### Environment variable expansion
Index URLs support environment variable expansion using `${VARIABLE}` or `${VARIABLE:-default}`
syntax, as well as tilde (`~`) expansion for home directories. This is particularly useful for
injecting authentication credentials or configuring different environments:
```toml
[[tool.uv.index]]
name = "artifactory"
# Environment variables with default values
url = "https://${ARTIFACTORY_HOST:-artifactory.company.com}/artifactory/api/pypi/pypi-local/simple"
# Environment variables for authentication
publish-url = "https://${ARTIFACTORY_USER}:${ARTIFACTORY_API_TOKEN}@artifactory.company.com/artifactory/api/pypi/pypi-local"
[[tool.uv.index]]
name = "local-packages"
# Home directory expansion
url = "file://${HOME}/my-packages"
# Tilde expansion (equivalent to above)
url = "file://~/my-packages"
```
The `${VARIABLE:-default}` syntax provides a fallback value when the environment variable is not
set. If an environment variable is referenced without a default (e.g., `${REQUIRED_VAR}`) and is not
set, uv will return an error when parsing the configuration.
Indexes are prioritized in the order in which they're defined, such that the first index listed in
the configuration file is the first index consulted when resolving dependencies, with indexes
provided via the command line taking precedence over those in the configuration file.
@ -122,7 +148,7 @@ malicious package to be installed instead of the internal package. See, for exam
[the `torchtriton` attack](https://pytorch.org/blog/compromised-nightly-dependency/) from
December 2022.
To opt in to alternate index behaviors, use the`--index-strategy` command-line option, or the
To opt in to alternate index behaviors, use the `--index-strategy` command-line option, or the
`UV_INDEX_STRATEGY` environment variable, which supports the following values:
- `first-index` (default): Search for each package across all indexes, limiting the candidate
@ -275,7 +301,7 @@ follow the same prioritization rules:
- The default index is always treated as lowest priority, whether defined via the legacy
`--index-url` argument, the recommended `--default-index` argument, or a `[[tool.uv.index]]` entry
with `default = true`.
- Indexes are consulted in the order in which theyre defined, either via the legacy
- Indexes are consulted in the order in which they're defined, either via the legacy
`--extra-index-url` argument, the recommended `--index` argument, or `[[tool.uv.index]]` entries.
In effect, `--index-url` and `--extra-index-url` can be thought of as unnamed `[[tool.uv.index]]`

View file

@ -34,6 +34,29 @@ name = "private-registry"
url = "https://pkgs.dev.azure.com/<ORGANIZATION>/<PROJECT>/_packaging/<FEED>/pypi/simple/"
```
### Environment variable expansion for authentication
Instead of embedding credentials directly in URLs, you can use environment variable expansion to
inject authentication tokens dynamically. This approach is more secure and flexible:
```toml title="pyproject.toml"
[[tool.uv.index]]
name = "private-registry"
# Use environment variables for secure authentication
url = "https://${AZURE_USER:-dummy}:${AZURE_TOKEN}@pkgs.dev.azure.com/<ORGANIZATION>/<PROJECT>/_packaging/<FEED>/pypi/simple/"
publish-url = "https://${AZURE_USER:-dummy}:${AZURE_TOKEN}@pkgs.dev.azure.com/<ORGANIZATION>/<PROJECT>/_packaging/<FEED>/pypi/upload/"
```
Then set your credentials via environment variables:
```bash
export AZURE_USER="dummy" # or any username, Azure doesn't validate this
export AZURE_TOKEN="your-personal-access-token"
```
This eliminates the need to use the separate `UV_INDEX_*` environment variables, as the credentials
are expanded directly into the URL.
### Authenticate with an Azure access token
If there is a personal access token (PAT) available (e.g.,
@ -143,6 +166,11 @@ To use Google Artifact Registry, add the index to your project:
[[tool.uv.index]]
name = "private-registry"
url = "https://<REGION>-python.pkg.dev/<PROJECT>/<REPOSITORY>"
# Or with environment variables for dynamic configuration
[[tool.uv.index]]
name = "private-registry"
url = "https://oauth2accesstoken:${GCP_ACCESS_TOKEN}@${GCP_REGION:-us-central1}-python.pkg.dev/${GCP_PROJECT}/${GCP_REPOSITORY}"
```
### Authenticate with a Google access token
@ -262,6 +290,12 @@ The index can be declared like so:
[[tool.uv.index]]
name = "private-registry"
url = "https://<DOMAIN>-<ACCOUNT_ID>.d.codeartifact.<REGION>.amazonaws.com/pypi/<REPOSITORY>/simple/"
# Or with environment variables for dynamic configuration
[[tool.uv.index]]
name = "private-registry"
url = "https://aws:${AWS_CODEARTIFACT_TOKEN}@${AWS_DOMAIN}-${AWS_ACCOUNT_ID}.d.codeartifact.${AWS_REGION:-us-east-1}.amazonaws.com/pypi/${AWS_REPOSITORY}/simple/"
publish-url = "https://aws:${AWS_CODEARTIFACT_TOKEN}@${AWS_DOMAIN}-${AWS_ACCOUNT_ID}.d.codeartifact.${AWS_REGION:-us-east-1}.amazonaws.com/pypi/${AWS_REPOSITORY}/"
```
### Authenticate with an AWS access token