diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index e39d55056..ea4fc07bf 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4934,6 +4934,14 @@ pub enum AuthCommand { /// Credentials are only stored in this directory when the plaintext backend is used, as /// opposed to the native backend, which uses the system keyring. Dir(AuthDirArgs), + /// Act as a credential helper for external tools. + /// + /// Implements the Bazel credential helper protocol to provide credentials + /// to external tools via JSON over stdin/stdout. + /// + /// This command is typically invoked by external tools. + #[command(hide = true)] + Helper(AuthHelperArgs), } #[derive(Args)] @@ -6215,6 +6223,30 @@ pub struct AuthDirArgs { pub service: Option, } +#[derive(Args)] +pub struct AuthHelperArgs { + #[command(subcommand)] + pub command: AuthHelperCommand, + + /// The credential helper protocol to use + #[arg(long, value_enum, required = true)] + pub protocol: AuthHelperProtocol, +} + +/// Credential helper protocols supported by uv +#[derive(Debug, Copy, Clone, PartialEq, Eq, clap::ValueEnum)] +pub enum AuthHelperProtocol { + /// Bazel credential helper protocol as described in [the + /// spec](https://github.com/bazelbuild/proposals/blob/main/designs/2022-06-07-bazel-credential-helpers.md) + Bazel, +} + +#[derive(Subcommand)] +pub enum AuthHelperCommand { + /// Retrieve credentials for a URI + Get, +} + #[derive(Args)] pub struct GenerateShellCompletionArgs { /// The shell to generate the completion script for diff --git a/crates/uv-preview/src/lib.rs b/crates/uv-preview/src/lib.rs index 1a7ce34d6..146c6e580 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -26,6 +26,7 @@ bitflags::bitflags! { const WORKSPACE_DIR = 1 << 14; const WORKSPACE_LIST = 1 << 15; const SBOM_EXPORT = 1 << 16; + const AUTH_HELPER = 1 << 17; } } @@ -52,6 +53,7 @@ impl PreviewFeatures { Self::WORKSPACE_DIR => "workspace-dir", Self::WORKSPACE_LIST => "workspace-list", Self::SBOM_EXPORT => "sbom-export", + Self::AUTH_HELPER => "auth-helper", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -106,6 +108,7 @@ impl FromStr for PreviewFeatures { "workspace-dir" => Self::WORKSPACE_DIR, "workspace-list" => Self::WORKSPACE_LIST, "sbom-export" => Self::SBOM_EXPORT, + "auth-helper" => Self::AUTH_HELPER, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; diff --git a/crates/uv/src/commands/auth/helper.rs b/crates/uv/src/commands/auth/helper.rs new file mode 100644 index 000000000..320e0216e --- /dev/null +++ b/crates/uv/src/commands/auth/helper.rs @@ -0,0 +1,151 @@ +use std::collections::HashMap; +use std::fmt::Write; +use std::io::Read; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +use uv_auth::{AuthBackend, Credentials, PyxTokenStore}; +use uv_client::BaseClientBuilder; +use uv_preview::{Preview, PreviewFeatures}; +use uv_redacted::DisplaySafeUrl; +use uv_warnings::warn_user; + +use crate::{commands::ExitStatus, printer::Printer, settings::NetworkSettings}; + +/// Request format for the Bazel credential helper protocol. +#[derive(Debug, Deserialize)] +struct BazelCredentialRequest { + uri: DisplaySafeUrl, +} + +impl BazelCredentialRequest { + fn from_str(s: &str) -> Result { + serde_json::from_str(s).context("Failed to parse credential request as JSON") + } + + fn from_stdin() -> Result { + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .context("Failed to read from stdin")?; + + Self::from_str(&buffer) + } +} + +/// Response format for the Bazel credential helper protocol. +#[derive(Debug, Serialize, Default)] +struct BazelCredentialResponse { + headers: HashMap>, +} + +impl TryFrom for BazelCredentialResponse { + fn try_from(creds: Credentials) -> Result { + let header_str = creds + .to_header_value() + .to_str() + // TODO: this is infallible in practice + .context("Failed to convert header value to string")? + .to_owned(); + + Ok(Self { + headers: HashMap::from([("Authorization".to_owned(), vec![header_str])]), + }) + } + + type Error = anyhow::Error; +} + +async fn credentials_for_url( + url: &DisplaySafeUrl, + preview: Preview, + network_settings: &NetworkSettings, +) -> Result> { + let pyx_store = PyxTokenStore::from_settings()?; + + // Use only the username from the URL, if present - discarding the password + let url_credentials = Credentials::from_url(url); + let username = url_credentials.as_ref().and_then(|c| c.username()); + if url_credentials + .as_ref() + .map(|c| c.password().is_some()) + .unwrap_or(false) + { + debug!("URL '{url}' contain a password; ignoring"); + } + + if pyx_store.is_known_domain(url) { + if username.is_some() { + bail!( + "Cannot specify a username for URLs under {}", + url.host() + .map(|host| host.to_string()) + .unwrap_or(url.to_string()) + ); + } + let client = BaseClientBuilder::new( + network_settings.connectivity, + network_settings.native_tls, + network_settings.allow_insecure_host.clone(), + preview, + network_settings.timeout, + network_settings.retries, + ) + .auth_integration(uv_client::AuthIntegration::NoAuthMiddleware) + .build(); + let token = pyx_store + .access_token(client.for_host(pyx_store.api()).raw_client(), 0) + .await + .context("Authentication failure")? + .context("No access token found")?; + return Ok(Some(Credentials::bearer(token.into_bytes()))); + } + let backend = AuthBackend::from_settings(preview).await?; + let credentials = match &backend { + AuthBackend::System(provider) => provider.fetch(url, username).await, + AuthBackend::TextStore(store, _lock) => store.get_credentials(url, username).cloned(), + }; + Ok(credentials) +} + +/// Implement the Bazel credential helper protocol. +/// +/// Reads a JSON request from stdin containing a URI, looks up credentials +/// for that URI using uv's authentication backends, and writes a JSON response +/// to stdout containing HTTP headers (if credentials are found). +/// +/// Protocol specification TLDR: +/// - Input (stdin): `{"uri": "https://example.com/path"}` +/// - Output (stdout): `{"headers": {"Authorization": ["Basic ..."]}}` or `{"headers": {}}` +/// - Errors: Written to stderr with non-zero exit code +/// +/// Full spec is [available here](https://github.com/bazelbuild/proposals/blob/main/designs/2022-06-07-bazel-credential-helpers.md) +pub(crate) async fn helper( + preview: Preview, + network_settings: &NetworkSettings, + printer: Printer, +) -> Result { + if !preview.is_enabled(PreviewFeatures::AUTH_HELPER) { + warn_user!( + "The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features {}` to disable this warning", + PreviewFeatures::AUTH_HELPER + ); + } + + let request = BazelCredentialRequest::from_stdin()?; + + // TODO: make this logic generic over the protocol by providing `request.uri` from a + // trait - that should help with adding new protocols + let credentials = credentials_for_url(&request.uri, preview, network_settings).await?; + + let response = serde_json::to_string( + &credentials + .map(BazelCredentialResponse::try_from) + .unwrap_or_else(|| Ok(BazelCredentialResponse::default()))?, + ) + .context("Failed to serialize response as JSON")?; + writeln!(printer.stdout_important(), "{response}")?; + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/auth/mod.rs b/crates/uv/src/commands/auth/mod.rs index e446e2b1b..d3dd655bf 100644 --- a/crates/uv/src/commands/auth/mod.rs +++ b/crates/uv/src/commands/auth/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod dir; +pub(crate) mod helper; pub(crate) mod login; pub(crate) mod logout; pub(crate) mod token; diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index a399c1daa..72e9daa3e 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -10,6 +10,7 @@ use owo_colors::OwoColorize; use tracing::debug; pub(crate) use auth::dir::dir as auth_dir; +pub(crate) use auth::helper::helper as auth_helper; pub(crate) use auth::login::login as auth_login; pub(crate) use auth::logout::logout as auth_logout; pub(crate) use auth::token::token as auth_token; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 0ca6ca7e4..5ecc3d02c 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -28,10 +28,10 @@ use uv_cache_info::Timestamp; #[cfg(feature = "self-update")] use uv_cli::SelfUpdateArgs; use uv_cli::{ - AuthCommand, AuthNamespace, BuildBackendCommand, CacheCommand, CacheNamespace, Cli, Commands, - PipCommand, PipNamespace, ProjectCommand, PythonCommand, PythonNamespace, SelfCommand, - SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, WorkspaceCommand, WorkspaceNamespace, - compat::CompatArgs, + AuthCommand, AuthHelperCommand, AuthNamespace, BuildBackendCommand, CacheCommand, + CacheNamespace, Cli, Commands, PipCommand, PipNamespace, ProjectCommand, PythonCommand, + PythonNamespace, SelfCommand, SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, + WorkspaceCommand, WorkspaceNamespace, compat::CompatArgs, }; use uv_client::BaseClientBuilder; use uv_configuration::min_stack_size; @@ -546,6 +546,22 @@ async fn run(mut cli: Cli) -> Result { commands::auth_dir(args.service.as_ref(), printer)?; Ok(ExitStatus::Success) } + Commands::Auth(AuthNamespace { + command: AuthCommand::Helper(args), + }) => { + use uv_cli::AuthHelperProtocol; + + // Validate protocol (currently only Bazel is supported) + match args.protocol { + AuthHelperProtocol::Bazel => {} + } + + match args.command { + AuthHelperCommand::Get => { + commands::auth_helper(globals.preview, &globals.network_settings, printer).await + } + } + } Commands::Help(args) => commands::help( args.command.unwrap_or_default().as_slice(), printer, diff --git a/crates/uv/tests/it/auth.rs b/crates/uv/tests/it/auth.rs index f6ceb6e48..4ce3efa29 100644 --- a/crates/uv/tests/it/auth.rs +++ b/crates/uv/tests/it/auth.rs @@ -1925,3 +1925,205 @@ fn native_auth_host_fallback() -> Result<()> { Ok(()) } + +/// Test credential helper with basic auth credentials +#[test] +fn bazel_helper_basic_auth() { + let context = TestContext::new("3.12"); + + // Store credentials + uv_snapshot!(context.filters(), context.auth_login() + .arg("https://test.example.com") + .arg("--username").arg("testuser") + .arg("--password").arg("testpass"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for testuser@https://test.example.com/ + "###); + + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), + input=r#"{"uri":"https://test.example.com/path"}"#, + @r#" + success: true + exit_code: 0 + ----- stdout ----- + {"headers":{"Authorization":["Basic dGVzdHVzZXI6dGVzdHBhc3M="]}} + + ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning + "# + ); +} + +/// Test credential helper with token credentials +#[test] +fn bazel_helper_token() { + let context = TestContext::new("3.12"); + + // Store token + uv_snapshot!(context.filters(), context.auth_login() + .arg("https://api.example.com") + .arg("--token").arg("mytoken123"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for https://api.example.com/ + "###); + + // Test credential helper - tokens are stored as Basic auth with __token__ username + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), + input=r#"{"uri":"https://api.example.com/v1/endpoint"}"#, + @r#" + success: true + exit_code: 0 + ----- stdout ----- + {"headers":{"Authorization":["Basic X190b2tlbl9fOm15dG9rZW4xMjM="]}} + + ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning + "# + ); +} + +/// Test credential helper with no credentials found +#[test] +fn bazel_helper_no_credentials() { + let context = TestContext::new("3.12"); + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), + input=r#"{"uri":"https://unknown.example.com/path"}"#, + @r#" + success: true + exit_code: 0 + ----- stdout ----- + {"headers":{}} + + ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning + "# + ); +} + +/// Test credential helper with invalid JSON input +#[test] +fn bazel_helper_invalid_json() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), + input="not json", + @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning + error: Failed to parse credential request as JSON + Caused by: expected ident at line 1 column 2 + " + ); +} + +/// Test credential helper with invalid URI +#[test] +fn bazel_helper_invalid_uri() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), + input=r#"{"uri":"not a url"}"#, + @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning + error: Failed to parse credential request as JSON + Caused by: relative URL without a base: "not a url" at line 1 column 18 + "# + ); +} + +/// Test credential helper with username in URI +#[test] +fn bazel_helper_username_in_uri() { + let context = TestContext::new("3.12"); + + // Store credentials with specific username + uv_snapshot!(context.filters(), context.auth_login() + .arg("https://test.example.com") + .arg("--username").arg("specificuser") + .arg("--password").arg("specificpass"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for specificuser@https://test.example.com/ + "###); + + // Test with username in URI + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), + input=r#"{"uri":"https://specificuser@test.example.com/path"}"#, + @r#" + success: true + exit_code: 0 + ----- stdout ----- + {"headers":{"Authorization":["Basic c3BlY2lmaWN1c2VyOnNwZWNpZmljcGFzcw=="]}} + + ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning + "# + ); +} + +/// Test credential helper with unknown username in URI +#[test] +fn bazel_helper_unknown_username_in_uri() { + let context = TestContext::new("3.12"); + + // Store credentials with specific username + uv_snapshot!(context.filters(), context.auth_login() + .arg("https://test.example.com") + .arg("--username").arg("specificuser") + .arg("--password").arg("specificpass"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for specificuser@https://test.example.com/ + "###); + + // Test with username in URI + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get"), + input=r#"{"uri":"https://differentuser@test.example.com/path"}"#, + @r#" + success: true + exit_code: 0 + ----- stdout ----- + {"headers":{}} + + ----- stderr ----- + warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning + "# + ); +} diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 869c92305..4fd4a7811 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -3,9 +3,10 @@ use std::borrow::BorrowMut; use std::ffi::OsString; +use std::io::Write as _; use std::iter::Iterator; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitStatus, Output}; +use std::process::{Command, ExitStatus, Output, Stdio}; use std::str::FromStr; use std::{env, io}; use uv_python::downloads::ManagedPythonDownloadList; @@ -1457,6 +1458,14 @@ impl TestContext { command } + /// Create a `uv auth helper --protocol bazel get` command. + pub fn auth_helper(&self) -> Command { + let mut command = Self::new_command(); + command.arg("auth").arg("helper"); + self.add_shared_options(&mut command, false); + command + } + /// Create a `uv auth token` command. pub fn auth_token(&self) -> Command { let mut command = Self::new_command(); @@ -1656,6 +1665,7 @@ impl TestContext { self.filters(), "diff_lock", Some(WindowsFilters::Platform), + None, ); assert!(status.success(), "{snapshot}"); let new_lock = fs_err::read_to_string(&lock_path).unwrap(); @@ -1839,9 +1849,10 @@ pub fn run_and_format>( filters: impl AsRef<[(T, T)]>, function_name: &str, windows_filters: Option, + input: Option<&str>, ) -> (String, Output) { let (snapshot, output, _) = - run_and_format_with_status(command, filters, function_name, windows_filters); + run_and_format_with_status(command, filters, function_name, windows_filters, input); (snapshot, output) } @@ -1854,6 +1865,7 @@ pub fn run_and_format_with_status>( filters: impl AsRef<[(T, T)]>, function_name: &str, windows_filters: Option, + input: Option<&str>, ) -> (String, Output, ExitStatus) { let program = command .borrow_mut() @@ -1873,10 +1885,30 @@ pub fn run_and_format_with_status>( ); } - let output = command - .borrow_mut() - .output() - .unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}")); + let output = if let Some(input) = input { + let mut child = command + .borrow_mut() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}")); + child + .stdin + .as_mut() + .expect("Failed to open stdin") + .write_all(input.as_bytes()) + .expect("Failed to write to stdin"); + + child + .wait_with_output() + .unwrap_or_else(|err| panic!("Failed to read output from {program}: {err}")) + } else { + command + .borrow_mut() + .output() + .unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}")) + }; eprintln!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Unfiltered output ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); eprintln!( @@ -2075,19 +2107,25 @@ macro_rules! uv_snapshot { }}; ($filters:expr, $spawnable:expr, @$snapshot:literal) => {{ // Take a reference for backwards compatibility with the vec-expecting insta filters. - let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Platform)); + let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Platform), None); + ::insta::assert_snapshot!(snapshot, @$snapshot); + output + }}; + ($filters:expr, $spawnable:expr, input=$input:expr, @$snapshot:literal) => {{ + // Take a reference for backwards compatibility with the vec-expecting insta filters. + let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Platform), Some($input)); ::insta::assert_snapshot!(snapshot, @$snapshot); output }}; ($filters:expr, windows_filters=false, $spawnable:expr, @$snapshot:literal) => {{ // Take a reference for backwards compatibility with the vec-expecting insta filters. - let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), None); + let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), None, None); ::insta::assert_snapshot!(snapshot, @$snapshot); output }}; ($filters:expr, universal_windows_filters=true, $spawnable:expr, @$snapshot:literal) => {{ // Take a reference for backwards compatibility with the vec-expecting insta filters. - let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Universal)); + let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Universal), None); ::insta::assert_snapshot!(snapshot, @$snapshot); output }}; diff --git a/crates/uv/tests/it/ecosystem.rs b/crates/uv/tests/it/ecosystem.rs index a3804f426..aae175834 100644 --- a/crates/uv/tests/it/ecosystem.rs +++ b/crates/uv/tests/it/ecosystem.rs @@ -110,6 +110,7 @@ fn lock_ecosystem_package(python_version: &str, name: &str) -> Result<()> { context.filters(), name, Some(common::WindowsFilters::Platform), + None, ); let lock = context.read("uv.lock"); diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index a4d8288ec..3bc7e011c 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -7832,7 +7832,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 | WORKSPACE_LIST | SBOM_EXPORT, + 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 | SBOM_EXPORT | AUTH_HELPER, ), }, python_preference: Managed, @@ -8060,7 +8060,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 | WORKSPACE_LIST | SBOM_EXPORT, + 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 | SBOM_EXPORT | AUTH_HELPER, ), }, python_preference: Managed,