Add native keyring integration for git credentials

This commit is contained in:
John Mumm 2025-08-18 16:15:45 +01:00
parent 56f9074d2a
commit 7f3adc36fb
No known key found for this signature in database
GPG Key ID: 73D2271AFDC26EA8
12 changed files with 342 additions and 81 deletions

View File

@ -20,12 +20,12 @@ static UV_SERVICE_PREFIX: &str = "uv-credentials:";
///
/// See pip's implementation for reference
/// <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L102>
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct KeyringProvider {
backend: KeyringProviderBackend,
}
#[derive(Debug)]
#[derive(Clone, Debug)]
pub(crate) enum KeyringProviderBackend {
/// Use system keyring integration to fetch credentials.
Native,
@ -50,6 +50,11 @@ impl KeyringProvider {
}
}
/// Whether the backend is [`KeyringProviderBackend::Native`].
pub fn is_native(&self) -> bool {
matches!(self.backend, KeyringProviderBackend::Native)
}
/// Store credentials for the given [`Url`] to the keyring if the
/// keyring provider backend is `Native`.
#[instrument(skip_all, fields(url = % url.to_string(), username))]
@ -109,7 +114,22 @@ impl KeyringProvider {
}
}
/// Fetch credentials for the given [`Url`] from the keyring.
/// Fetch credentials for the given [`DisplaySafeUrl`] from the keyring if the backend is
/// [`KeyringProviderBackend::Native`].
#[instrument(skip_all, fields(url = % url.to_string(), username))]
pub async fn fetch_if_native(
&self,
url: &DisplaySafeUrl,
username: Option<&str>,
) -> Option<Credentials> {
if self.is_native() {
self.fetch(url, username).await
} else {
None
}
}
/// Fetch credentials for the given [`DisplaySafeUrl`] from the keyring.
///
/// Returns [`None`] if no password was found for the username or if any errors
/// are encountered in the keyring backend.
@ -160,6 +180,10 @@ impl KeyringProvider {
};
}
if credentials.is_some() {
debug!("Found credentials in keyring for {url}");
}
credentials.map(|(username, password)| Credentials::basic(Some(username), Some(password)))
}

View File

@ -398,8 +398,9 @@ impl AuthMiddleware {
.as_ref()
.is_ok_and(|response| response.error_for_status_ref().is_ok())
{
if let (Some(index_url), Some(keyring)) = (index_url, &self.keyring) {
keyring.store_if_native(index_url, &credentials).await;
if let Some(keyring) = &self.keyring {
let url = index_url.unwrap_or(&url);
keyring.store_if_native(url, &credentials).await;
}
trace!("Updating cached credentials for {url} to {credentials:?}");
self.cache().insert(&url, credentials);
@ -575,7 +576,7 @@ impl AuthMiddleware {
// But, in the absence of an index URL, we cache the result per realm. So in that case,
// if a keyring implementation returns different credentials for different URLs in the
// same realm we will use the wrong credentials.
} else if let Some(credentials) = match self.keyring {
} else { match self.keyring {
Some(ref keyring) => {
// The subprocess keyring provider is _slow_ so we do not perform fetches for all
// URLs; instead, we fetch if there's a username or if the user has requested to
@ -603,12 +604,7 @@ impl AuthMiddleware {
}
}
None => None,
} {
debug!("Found credentials in keyring for {url}");
Some(credentials)
} else {
None
}
} }
.map(Arc::new);
// Register the fetch for this key

View File

@ -3,7 +3,6 @@ use std::fmt::{Debug, Formatter};
use std::hash::{Hash, Hasher};
use std::ops::Deref;
use url::Url;
use uv_redacted::DisplaySafeUrl;
use crate::cache_key::{CacheKey, CacheKeyHasher};
@ -186,7 +185,7 @@ impl Hash for RepositoryUrl {
}
impl Deref for RepositoryUrl {
type Target = Url;
type Target = DisplaySafeUrl;
fn deref(&self) -> &Self::Target {
&self.0

View File

@ -28,6 +28,7 @@ use url::ParseError;
use url::Url;
use uv_auth::Credentials;
use uv_auth::KeyringProvider;
use uv_auth::{AuthMiddleware, Indexes};
use uv_configuration::{KeyringProviderType, TrustedHost};
use uv_fs::Simplified;
@ -325,6 +326,7 @@ impl<'a> BaseClientBuilder<'a> {
dangerous_client,
raw_dangerous_client,
timeout,
keyring_provider: self.keyring.to_provider(),
}
}
@ -351,6 +353,7 @@ impl<'a> BaseClientBuilder<'a> {
raw_client: existing.raw_client.clone(),
raw_dangerous_client: existing.raw_dangerous_client.clone(),
timeout: existing.timeout,
keyring_provider: self.keyring.to_provider(),
}
}
@ -524,6 +527,8 @@ pub struct BaseClient {
allow_insecure_host: Vec<TrustedHost>,
/// The number of retries to attempt on transient errors.
retries: u32,
/// Backend for providing credentials from a keyring.
keyring_provider: Option<KeyringProvider>,
}
#[derive(Debug, Clone, Copy)]
@ -571,6 +576,11 @@ impl BaseClient {
pub fn retry_policy(&self) -> ExponentialBackoff {
ExponentialBackoff::builder().build_with_max_retries(self.retries)
}
/// The [`KeyringProvider`] if one exists.
pub fn keyring_provider(&self) -> &Option<KeyringProvider> {
&self.keyring_provider
}
}
/// Wrapper around [`ClientWithMiddleware`] that manages redirects.

View File

@ -1559,6 +1559,12 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
self.reporter
.clone()
.map(|reporter| reporter.into_git_reporter()),
client
.unmanaged
.cached_client()
.uncached()
.keyring_provider()
.as_ref(),
)
.await?;
@ -1763,6 +1769,12 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
self.reporter
.clone()
.map(|reporter| reporter.into_git_reporter()),
client
.unmanaged
.cached_client()
.uncached()
.keyring_provider()
.as_ref(),
)
.await?;
@ -2010,6 +2022,12 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
self.reporter
.clone()
.map(|reporter| reporter.into_git_reporter()),
client
.unmanaged
.cached_client()
.uncached()
.keyring_provider()
.as_ref(),
)
.await?;

View File

@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, RwLock};
use tracing::trace;
use uv_auth::Credentials;
use uv_auth::{Credentials, KeyringProvider};
use uv_cache_key::RepositoryUrl;
use uv_redacted::DisplaySafeUrl;
@ -16,7 +16,20 @@ pub struct GitStore(RwLock<HashMap<RepositoryUrl, Arc<Credentials>>>);
impl GitStore {
/// Insert [`Credentials`] for the given URL into the store.
pub fn insert(&self, url: RepositoryUrl, credentials: Credentials) -> Option<Arc<Credentials>> {
///
/// If a native keyring provider is available, the credentials will also be
/// persisted to the system keyring for future use.
///
/// Returns the previously stored credentials for this URL, if any.
pub async fn insert(
&self,
url: RepositoryUrl,
credentials: Credentials,
keyring_provider: Option<&KeyringProvider>,
) -> Option<Arc<Credentials>> {
if let Some(keyring_provider) = keyring_provider {
keyring_provider.store_if_native(&url, &credentials).await;
}
self.0.write().unwrap().insert(url, Arc::new(credentials))
}
@ -29,10 +42,15 @@ impl GitStore {
/// Populate the global authentication store with credentials on a Git URL, if there are any.
///
/// Returns `true` if the store was updated.
pub fn store_credentials_from_url(url: &DisplaySafeUrl) -> bool {
pub async fn store_credentials_from_url(
url: &DisplaySafeUrl,
keyring_provider: Option<&KeyringProvider>,
) -> bool {
if let Some(credentials) = Credentials::from_url(url) {
trace!("Caching credentials for {url}");
GIT_STORE.insert(RepositoryUrl::new(url), credentials);
GIT_STORE
.insert(RepositoryUrl::new(url), credentials, keyring_provider)
.await;
true
} else {
false

View File

@ -459,7 +459,7 @@ impl GitCheckout {
/// The `remote_url` argument is the git remote URL where we want to fetch from.
fn fetch(
repo: &mut GitRepository,
remote_url: &Url,
remote_url: &DisplaySafeUrl,
reference: ReferenceOrOid<'_>,
client: &ClientWithMiddleware,
disable_ssl: bool,
@ -601,7 +601,7 @@ fn fetch(
/// Attempts to use `git` CLI installed on the system to fetch a repository.
fn fetch_with_cli(
repo: &mut GitRepository,
url: &Url,
url: &DisplaySafeUrl,
refspecs: &[String],
tags: bool,
disable_ssl: bool,
@ -733,7 +733,7 @@ enum FastPathRev {
/// [^1]: <https://developer.github.com/v3/repos/commits/#get-the-sha-1-of-a-commit-reference>
fn github_fast_path(
git: &mut GitRepository,
url: &Url,
url: &DisplaySafeUrl,
reference: ReferenceOrOid<'_>,
client: &ClientWithMiddleware,
) -> Result<FastPathRev> {

View File

@ -9,6 +9,7 @@ use fs_err::tokio as fs;
use reqwest_middleware::ClientWithMiddleware;
use tracing::debug;
use uv_auth::KeyringProvider;
use uv_cache_key::{RepositoryUrl, cache_digest};
use uv_fs::LockedFile;
use uv_git_types::{GitHubRepository, GitOid, GitReference, GitUrl};
@ -148,6 +149,7 @@ impl GitResolver {
offline: bool,
cache: PathBuf,
reporter: Option<Arc<dyn Reporter>>,
keyring_provider: Option<&KeyringProvider>,
) -> Result<Fetch, GitResolverError> {
debug!("Fetching source distribution from Git: {url}");
@ -187,8 +189,9 @@ impl GitResolver {
source
};
let fetch = tokio::task::spawn_blocking(move || source.fetch())
.await?
let fetch = source
.fetch(keyring_provider)
.await
.map_err(GitResolverError::Git)?;
// Insert the resolved URL into the in-memory cache. This ensures that subsequent fetches

View File

@ -10,6 +10,7 @@ use anyhow::Result;
use reqwest_middleware::ClientWithMiddleware;
use tracing::{debug, instrument};
use uv_auth::KeyringProvider;
use uv_cache_key::{RepositoryUrl, cache_digest};
use uv_git_types::{GitOid, GitReference, GitUrl};
use uv_redacted::DisplaySafeUrl;
@ -71,7 +72,7 @@ impl GitSource {
/// Fetch the underlying Git repository at the given revision.
#[instrument(skip(self), fields(repository = %self.git.repository(), rev = ?self.git.precise()))]
pub fn fetch(self) -> Result<Fetch> {
pub async fn fetch(self, keyring_provider: Option<&KeyringProvider>) -> Result<Fetch> {
// Compute the canonical URL for the repository.
let canonical = RepositoryUrl::new(self.git.repository());
@ -79,66 +80,97 @@ impl GitSource {
let ident = cache_digest(&canonical);
let db_path = self.cache.join("db").join(&ident);
let git_store_credentials = GIT_STORE.get(&canonical);
let credentials = match (&git_store_credentials, keyring_provider) {
(Some(creds), _) if creds.password().is_some() => git_store_credentials,
(_, Some(keyring)) => {
let repo_username = self.git.repository().username();
let username = if !repo_username.is_empty() {
Some(repo_username)
} else {
git_store_credentials
.as_ref()
.and_then(|creds| creds.username())
};
let mut git = self.git.repository().clone();
git.remove_credentials();
keyring
.fetch_if_native(&git, username)
.await
.map(Arc::new)
.or(git_store_credentials)
}
_ => git_store_credentials,
};
// Authenticate the URL, if necessary.
let remote = if let Some(credentials) = GIT_STORE.get(&canonical) {
let remote = if let Some(credentials) = credentials {
Cow::Owned(credentials.apply(self.git.repository().clone()))
} else {
Cow::Borrowed(self.git.repository())
};
let git = self.git.clone();
let reporter = self.reporter.clone();
let remote_clone = remote.as_ref().clone();
// Fetch the commit, if we don't already have it. Wrapping this section in a closure makes
// it easier to short-circuit this in the cases where we do have the commit.
let (db, actual_rev, maybe_task) = || -> Result<(GitDatabase, GitOid, Option<usize>)> {
let git_remote = GitRemote::new(&remote);
let maybe_db = git_remote.db_at(&db_path).ok();
let (db, actual_rev, maybe_task) =
tokio::task::spawn_blocking(move || -> Result<(GitDatabase, GitOid, Option<usize>)> {
let remote = remote_clone;
let git_remote = GitRemote::new(&remote);
let maybe_db = git_remote.db_at(&db_path).ok();
// If we have a locked revision, and we have a pre-existing database which has that
// revision, then no update needs to happen.
if let (Some(rev), Some(db)) = (self.git.precise(), &maybe_db) {
if db.contains(rev) {
debug!("Using existing Git source `{}`", self.git.repository());
return Ok((maybe_db.unwrap(), rev, None));
// If we have a locked revision, and we have a pre-existing database which has that
// revision, then no update needs to happen.
if let (Some(rev), Some(db)) = (git.precise(), &maybe_db) {
if db.contains(rev) {
debug!("Using existing Git source `{}`", git.repository());
return Ok((maybe_db.unwrap(), rev, None));
}
}
}
// If the revision isn't locked, but it looks like it might be an exact commit hash,
// and we do have a pre-existing database, then check whether it is, in fact, a commit
// hash. If so, treat it like it's locked.
if let Some(db) = &maybe_db {
if let GitReference::BranchOrTagOrCommit(maybe_commit) = self.git.reference() {
if let Ok(oid) = maybe_commit.parse::<GitOid>() {
if db.contains(oid) {
// This reference is an exact commit. Treat it like it's
// locked.
debug!("Using existing Git source `{}`", self.git.repository());
return Ok((maybe_db.unwrap(), oid, None));
// If the revision isn't locked, but it looks like it might be an exact commit hash,
// and we do have a pre-existing database, then check whether it is, in fact, a commit
// hash. If so, treat it like it's locked.
if let Some(db) = &maybe_db {
if let GitReference::BranchOrTagOrCommit(maybe_commit) = git.reference() {
if let Ok(oid) = maybe_commit.parse::<GitOid>() {
if db.contains(oid) {
// This reference is an exact commit. Treat it like it's
// locked.
debug!("Using existing Git source `{}`", git.repository());
return Ok((maybe_db.unwrap(), oid, None));
}
}
}
}
}
// ... otherwise, we use this state to update the Git database. Note that we still check
// for being offline here, for example in the situation that we have a locked revision
// but the database doesn't have it.
debug!("Updating Git source `{}`", self.git.repository());
// ... otherwise, we use this state to update the Git database. Note that we still check
// for being offline here, for example in the situation that we have a locked revision
// but the database doesn't have it.
debug!("Updating Git source `{}`", git.repository());
// Report the checkout operation to the reporter.
let task = self.reporter.as_ref().map(|reporter| {
reporter.on_checkout_start(git_remote.url(), self.git.reference().as_rev())
});
// Report the checkout operation to the reporter.
let task = reporter.as_ref().map(|reporter| {
reporter.on_checkout_start(git_remote.url(), git.reference().as_rev())
});
let (db, actual_rev) = git_remote.checkout(
&db_path,
maybe_db,
self.git.reference(),
self.git.precise(),
&self.client,
self.disable_ssl,
self.offline,
)?;
let (db, actual_rev) = git_remote.checkout(
&db_path,
maybe_db,
git.reference(),
git.precise(),
&self.client,
self.disable_ssl,
self.offline,
)?;
Ok((db, actual_rev, task))
}()?;
Ok((db, actual_rev, task))
})
.await??;
// Dont use the full hash, in order to contribute less to reaching the
// path length limit on Windows.
@ -158,7 +190,7 @@ impl GitSource {
// Report the checkout operation to the reporter.
if let Some(task) = maybe_task {
if let Some(reporter) = self.reporter.as_ref() {
reporter.on_checkout_complete(remote.as_ref(), actual_rev.as_str(), task);
reporter.on_checkout_complete(&remote, actual_rev.as_str(), task);
}
}

View File

@ -18,8 +18,8 @@ use uv_cache_key::RepositoryUrl;
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{
Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DevMode, DryRun,
EditableMode, ExtrasSpecification, ExtrasSpecificationWithDefaults, InstallOptions, Preview,
PreviewFeatures, SourceStrategy,
EditableMode, ExtrasSpecification, ExtrasSpecificationWithDefaults, InstallOptions,
KeyringProviderType, Preview, PreviewFeatures, SourceStrategy,
};
use uv_dispatch::BuildDispatch;
use uv_distribution::{DistributionDatabase, LoweredExtraBuildDependencies};
@ -650,7 +650,9 @@ pub(crate) async fn add(
&extras_of_dependency,
index,
&mut toml,
)?;
settings.resolver.keyring_provider,
)
.await?;
// Validate any indexes that were provided on the command-line to ensure
// they point to existing non-empty directories when using path URLs.
@ -768,7 +770,7 @@ pub(crate) async fn add(
}
}
fn edits(
async fn edits(
requirements: Vec<Requirement>,
target: &AddTarget,
editable: Option<bool>,
@ -780,6 +782,7 @@ fn edits(
extras: &[ExtraName],
index: Option<&IndexName>,
toml: &mut PyProjectTomlMut,
keyring_provider: KeyringProviderType,
) -> Result<Vec<DependencyEdit>> {
let mut edits = Vec::<DependencyEdit>::with_capacity(requirements.len());
for mut requirement in requirements {
@ -853,13 +856,33 @@ fn edits(
extra,
group,
}) => {
let credentials = uv_auth::Credentials::from_url(&git);
if let Some(credentials) = credentials {
debug!("Caching credentials for: {git}");
GIT_STORE.insert(RepositoryUrl::new(&git), credentials);
// Redact the credentials.
if let Some(credentials) = uv_auth::Credentials::from_url(&git) {
let username = git.username().to_string();
// Redact the credentials from the URL.
git.remove_credentials();
let credentials = if credentials.password().is_some() {
Some(credentials)
} else {
if let Some(keyring_provider) = keyring_provider.to_provider() {
keyring_provider
.fetch_if_native(&git, Some(&username))
.await
} else {
Some(credentials)
}
};
debug!("Caching credentials for: {git}");
if let Some(credentials) = credentials {
GIT_STORE
.insert(
RepositoryUrl::new(&git),
credentials,
keyring_provider.to_provider().as_ref(),
)
.await;
}
}
Some(Source::Git {
git,

View File

@ -8,6 +8,7 @@ use itertools::Itertools;
use owo_colors::OwoColorize;
use serde::Serialize;
use tracing::warn;
use uv_auth::KeyringProvider;
use uv_cache::Cache;
use uv_cli::SyncFormat;
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
@ -719,7 +720,7 @@ pub(super) async fn do_sync(
index_locations.cache_index_credentials();
// Populate credentials from the target.
store_credentials_from_target(target);
store_credentials_from_target(target, keyring_provider.to_provider().as_ref()).await;
// Initialize the registry client.
let client = RegistryClientBuilder::try_from(client_builder)?
@ -875,7 +876,10 @@ fn apply_editable_mode(resolution: Resolution, editable: EditableMode) -> Resolu
///
/// These credentials can come from any of `tool.uv.sources`, `tool.uv.dev-dependencies`,
/// `project.dependencies`, and `project.optional-dependencies`.
fn store_credentials_from_target(target: InstallTarget<'_>) {
async fn store_credentials_from_target(
target: InstallTarget<'_>,
keyring_provider: Option<&KeyringProvider>,
) {
// Iterate over any indexes in the target.
for index in target.indexes() {
if let Some(credentials) = index.credentials() {
@ -891,7 +895,7 @@ fn store_credentials_from_target(target: InstallTarget<'_>) {
for source in target.sources() {
match source {
Source::Git { git, .. } => {
uv_git::store_credentials_from_url(git);
uv_git::store_credentials_from_url(git, keyring_provider).await;
}
Source::Url { url, .. } => {
uv_auth::store_credentials_from_url(url);
@ -907,7 +911,7 @@ fn store_credentials_from_target(target: InstallTarget<'_>) {
};
match &url.parsed_url {
ParsedUrl::Git(ParsedGitUrl { url, .. }) => {
uv_git::store_credentials_from_url(url.repository());
uv_git::store_credentials_from_url(url.repository(), keyring_provider).await;
}
ParsedUrl::Archive(ParsedArchiveUrl { url, .. }) => {
uv_auth::store_credentials_from_url(url);

View File

@ -12624,6 +12624,140 @@ fn add_package_persist_system_keyring_credentials() -> Result<()> {
Ok(())
}
/// Add a package from a private git repo using credentials stored in the system keyring.
///
/// TODO(john): This test currently relies on the system keyring not yet having the
/// credentials for the git repo the test adds from. We need to update the test to clear those
/// credentials, possibly both defensively at the beginning and at the end to clean up.
#[cfg(feature = "keyring-tests")]
#[cfg(feature = "git")]
#[test]
fn add_git_private_persist_system_keyring_credentials() -> Result<()> {
let context = TestContext::new("3.12");
let token = decode_token(READ_ONLY_GITHUB_TOKEN);
// Configure `pyproject.toml` with native keyring provider.
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.11, <4"
dependencies = []
[tool.uv]
keyring-provider = "native"
"#
})?;
// Try to add a private Git package with only username.
uv_snapshot!(context.filters(), context.add().arg("uv-private-pypackage @ git+https://git@github.com/astral-test/uv-private-pypackage"), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× Failed to download and build `uv-private-pypackage @ git+https://github.com/astral-test/uv-private-pypackage`
Git operation failed
failed to clone into: [CACHE_DIR]/git-v0/db/8401f5508e3e612d
process didn't exit successfully: `/usr/bin/git fetch --force --update-head-ok 'https://github.com/astral-test/uv-private-pypackage' '+HEAD:refs/remotes/origin/HEAD'` (exit status: 128)
--- stderr
fatal: could not read Username for 'https://github.com': terminal prompts disabled
help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing.
"
);
// Try again with credentials. This should persist the credentials to the system keyring.
uv_snapshot!(context.filters(), context.add().arg(format!("uv-private-pypackage @ git+https://git:{token}@github.com/astral-test/uv-private-pypackage")), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ uv-private-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-private-pypackage@d780faf0ac91257d4d5a4f0c5a0e4509608c0071)
");
// Remove the package and clean cache.
uv_snapshot!(context.filters(), context.remove().arg("uv-private-pypackage"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
Uninstalled 1 package in [TIME]
- uv-private-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-private-pypackage@d780faf0ac91257d4d5a4f0c5a0e4509608c0071)
");
let filters: Vec<_> = context
.filters()
.into_iter()
.chain([
// The cache entry does not have a stable key, so we filter it out.
(
r"\[CACHE_DIR\](\\|\/)(.+)(\\|\/).*",
"[CACHE_DIR]/$2/[ENTRY]",
),
// The file count varies by operating system, so we filter it out.
("Removed \\d+ files?", "Removed [N] files"),
])
.collect();
uv_snapshot!(&filters, context.clean(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Clearing cache at: [CACHE_DIR]/
Removed [N] files ([SIZE])
");
// Try to add the original package again with username only. This should use
// credentials stored in the system keyring.
uv_snapshot!(context.filters(), context.add().arg("uv-private-pypackage @ git+https://git@github.com/astral-test/uv-private-pypackage"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ uv-private-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-private-pypackage@d780faf0ac91257d4d5a4f0c5a0e4509608c0071)
");
// Verify that `pyproject.toml` doesn't contain credentials
let pyproject_toml = context.read("pyproject.toml");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject_toml, @r###"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.11, <4"
dependencies = [
"uv-private-pypackage",
]
[tool.uv]
keyring-provider = "native"
[tool.uv.sources]
uv-private-pypackage = { git = "https://github.com/astral-test/uv-private-pypackage" }
"###
);
});
Ok(())
}
/// If uv receives a 302 redirect to a cross-origin server, it should not forward
/// credentials. In the absence of a netrc entry for the new location,
/// it should fail.