mirror of
https://github.com/astral-sh/uv.git
synced 2025-08-04 19:08:04 +00:00
Merge 1386f72996
into 4ed9c5791b
This commit is contained in:
commit
45c961ce2a
6 changed files with 236 additions and 7 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -5160,6 +5160,7 @@ dependencies = [
|
|||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shellexpand",
|
||||
"thiserror 2.0.12",
|
||||
"toml",
|
||||
"tracing",
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,33 @@ name = "pytorch"
|
|||
url = "https://download.pytorch.org/whl/cpu"
|
||||
```
|
||||
|
||||
Indexes are prioritized in the order in which they’re 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 they’re 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]]`
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue