From 5291dfbdd03ff0a6c056db7e58951187ad8a88f2 Mon Sep 17 00:00:00 2001 From: Zach Date: Sun, 24 May 2026 01:40:28 -0600 Subject: [PATCH] feat(config): add `--describe` flag to `pixi config list` Adds an annotated TOML-style listing of every available configuration option with its type, default, and a short explanation. Keys that are currently set print uncommented with their value; the rest are emitted as commented stubs showing the default. `--describe [KEY]` describes one key. `--describe --json` emits a structured array suitable for tooling. Closes #1457. --- crates/pixi_cli/src/config.rs | 201 +++++++++++ crates/pixi_config/src/lib.rs | 337 +++++++++++++++++++ docs/reference/cli/pixi/config/list.md | 2 + docs/reference/cli/pixi/config/list_extender | 2 + tests/integration_python/test_main_cli.py | 78 +++++ 5 files changed, 620 insertions(+) diff --git a/crates/pixi_cli/src/config.rs b/crates/pixi_cli/src/config.rs index 752494e86b..00811125af 100644 --- a/crates/pixi_cli/src/config.rs +++ b/crates/pixi_cli/src/config.rs @@ -79,6 +79,10 @@ struct ListArgs { #[arg(long)] json: bool, + /// Describe every configuration option with its type, default, and a short explanation + #[arg(long)] + describe: bool, + #[clap(flatten)] common: CommonArgs, } @@ -162,6 +166,19 @@ pub async fn execute(args: Args) -> miette::Result<()> { Subcommand::List(args) => { let mut config = load_config(&args.common)?; + if args.describe { + let out = render_describe(&config, args.key.as_deref(), args.json)?; + writeln!(std::io::stdout(), "{out}") + .map_err(|e| { + if e.kind() == std::io::ErrorKind::BrokenPipe { + std::process::exit(0); + } + e + }) + .into_diagnostic()?; + return Ok(()); + } + if let Some(key) = args.key { partial_config(&mut config, &key)?; } @@ -325,6 +342,190 @@ fn alter_config( Ok(()) } +fn render_describe( + config: &Config, + key_filter: Option<&str>, + json: bool, +) -> miette::Result { + let descriptions = config.describe_keys(); + + let selected: Vec<&pixi_config::ConfigOptionDescription> = if let Some(k) = key_filter { + let matches: Vec<_> = descriptions.iter().filter(|o| o.key == k).collect(); + if matches.is_empty() { + return Err(miette::miette!( + "Unknown configuration key '{}'. Run `pixi config list --describe` to list every available key.", + k + )); + } + matches + } else { + descriptions.iter().collect() + }; + + let doc = toml_edit::ser::to_string_pretty(config) + .into_diagnostic() + .and_then(|s| s.parse::().into_diagnostic())?; + + if json { + let arr: Vec = selected + .iter() + .map(|opt| { + let value = if opt.key.contains("") { + serde_json::Value::Null + } else { + lookup_dotted(doc.as_table(), opt.key) + .map(toml_item_to_json) + .unwrap_or(serde_json::Value::Null) + }; + serde_json::json!({ + "key": opt.key, + "description": opt.description, + "type": opt.value_type, + "default": opt.default, + "value": value, + }) + }) + .collect(); + return serde_json::to_string_pretty(&arr).into_diagnostic(); + } + + let mut out = String::new(); + for (i, opt) in selected.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str("# "); + out.push_str(opt.description); + out.push('\n'); + out.push_str("# Type: "); + out.push_str(opt.value_type); + out.push('\n'); + out.push_str("# Default: "); + out.push_str(opt.default); + out.push('\n'); + + let current = if opt.key.contains("") { + None + } else { + lookup_dotted(doc.as_table(), opt.key).map(format_toml_value) + }; + + match current { + Some(value) => { + out.push_str(opt.key); + out.push_str(" = "); + out.push_str(&value); + out.push('\n'); + } + None => { + out.push_str("# "); + out.push_str(opt.key); + out.push_str(" = "); + out.push_str(opt.default); + out.push('\n'); + } + } + } + Ok(out) +} + +fn lookup_dotted<'a>(table: &'a toml_edit::Table, key: &str) -> Option<&'a toml_edit::Item> { + let mut parts = key.split('.'); + let first = parts.next()?; + let mut item = table.get(first)?; + for part in parts { + item = item.as_table().and_then(|t| t.get(part))?; + } + Some(item) +} + +fn format_toml_value(item: &toml_edit::Item) -> String { + match item { + toml_edit::Item::Value(v) => v.to_string().trim().to_string(), + toml_edit::Item::Table(t) => table_to_inline(t).to_string().trim().to_string(), + toml_edit::Item::ArrayOfTables(arr) => { + let mut out = toml_edit::Array::new(); + for t in arr { + out.push(table_to_inline(t)); + } + out.to_string().trim().to_string() + } + toml_edit::Item::None => String::new(), + } +} + +fn table_to_inline(table: &toml_edit::Table) -> toml_edit::InlineTable { + let mut inline = toml_edit::InlineTable::new(); + for (k, v) in table.iter() { + if let Some(val) = item_to_value(v) { + inline.insert(k, val); + } + } + inline +} + +fn item_to_value(item: &toml_edit::Item) -> Option { + match item { + toml_edit::Item::Value(v) => Some(v.clone()), + toml_edit::Item::Table(t) => Some(toml_edit::Value::InlineTable(table_to_inline(t))), + toml_edit::Item::ArrayOfTables(arr) => { + let mut out = toml_edit::Array::new(); + for t in arr { + out.push(table_to_inline(t)); + } + Some(toml_edit::Value::Array(out)) + } + toml_edit::Item::None => None, + } +} + +fn toml_item_to_json(item: &toml_edit::Item) -> serde_json::Value { + match item { + toml_edit::Item::Value(v) => toml_value_to_json(v), + toml_edit::Item::Table(t) => { + let mut map = serde_json::Map::new(); + for (k, v) in t.iter() { + map.insert(k.to_string(), toml_item_to_json(v)); + } + serde_json::Value::Object(map) + } + toml_edit::Item::ArrayOfTables(arr) => serde_json::Value::Array( + arr.iter() + .map(|t| { + let mut map = serde_json::Map::new(); + for (k, v) in t.iter() { + map.insert(k.to_string(), toml_item_to_json(v)); + } + serde_json::Value::Object(map) + }) + .collect(), + ), + toml_edit::Item::None => serde_json::Value::Null, + } +} + +fn toml_value_to_json(v: &toml_edit::Value) -> serde_json::Value { + match v { + toml_edit::Value::String(s) => serde_json::Value::String(s.value().to_string()), + toml_edit::Value::Integer(i) => serde_json::Value::Number((*i.value()).into()), + toml_edit::Value::Float(f) => serde_json::Number::from_f64(*f.value()) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + toml_edit::Value::Boolean(b) => serde_json::Value::Bool(*b.value()), + toml_edit::Value::Datetime(d) => serde_json::Value::String(d.to_string()), + toml_edit::Value::Array(arr) => { + serde_json::Value::Array(arr.iter().map(toml_value_to_json).collect()) + } + toml_edit::Value::InlineTable(t) => { + let mut map = serde_json::Map::new(); + for (k, v) in t.iter() { + map.insert(k.to_string(), toml_value_to_json(v)); + } + serde_json::Value::Object(map) + } + } +} + // Trick to show only relevant field of the Config fn partial_config(config: &mut Config, key: &str) -> miette::Result<()> { let mut new = Config::default(); diff --git a/crates/pixi_config/src/lib.rs b/crates/pixi_config/src/lib.rs index 4415cb69cf..80c4e3904a 100644 --- a/crates/pixi_config/src/lib.rs +++ b/crates/pixi_config/src/lib.rs @@ -27,6 +27,312 @@ use url::Url; const EXPERIMENTAL: &str = "experimental"; +/// User-facing metadata for a single configuration key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConfigOptionDescription { + pub key: &'static str, + pub description: &'static str, + pub value_type: &'static str, + pub default: &'static str, +} + +static CONFIG_OPTION_DESCRIPTIONS: &[ConfigOptionDescription] = &[ + ConfigOptionDescription { + key: "authentication-override-file", + description: "Path to the file containing the authentication token.", + value_type: "path", + default: "(unset)", + }, + ConfigOptionDescription { + key: "cache", + description: "Per-cache-directory overrides. See `cache.*` keys; can also be set as a JSON object.", + value_type: "table", + default: "(empty)", + }, + ConfigOptionDescription { + key: "cache.build-tool-environments", + description: "Cache directory for build-tool environments. Inherits from `cache.root` when unset.", + value_type: "path", + default: "(inherited)", + }, + ConfigOptionDescription { + key: "cache.conda-packages", + description: "Cache directory for the conda package store (`pkgs`). Inherits from `cache.root` when unset.", + value_type: "path", + default: "(inherited)", + }, + ConfigOptionDescription { + key: "cache.detached-environments", + description: "Root directory for detached environments. Inherits from `cache.root` when unset.", + value_type: "path", + default: "(inherited)", + }, + ConfigOptionDescription { + key: "cache.exec-environments", + description: "Cache directory for environments created by `pixi exec`. Inherits from `cache.root` when unset.", + value_type: "path", + default: "(inherited)", + }, + ConfigOptionDescription { + key: "cache.netfs-redirect", + description: "How node-local caches are redirected when the resolved cache root sits on a network filesystem.", + value_type: "one of `auto`, `always`, `never`", + default: "\"auto\"", + }, + ConfigOptionDescription { + key: "cache.pypi-mapping", + description: "Cache directory for the conda → PyPI name-mapping. Inherits from `cache.root` when unset.", + value_type: "path", + default: "(inherited)", + }, + ConfigOptionDescription { + key: "cache.pypi-wheels", + description: "Cache directory for the uv wheel cache. Inherits from `cache.root` when unset.", + value_type: "path", + default: "(inherited)", + }, + ConfigOptionDescription { + key: "cache.repodata", + description: "Cache directory for repodata. Inherits from `cache.root` when unset.", + value_type: "path", + default: "(inherited)", + }, + ConfigOptionDescription { + key: "cache.root", + description: "Root directory used to derive every per-kind cache subdirectory. Overrides the `PIXI_CACHE_DIR` / `RATTLER_CACHE_DIR` / XDG lookup.", + value_type: "path", + default: "(system default)", + }, + ConfigOptionDescription { + key: "concurrency", + description: "Concurrency limits. See `concurrency.*` keys; can also be set as a JSON object.", + value_type: "table", + default: "(defaults)", + }, + ConfigOptionDescription { + key: "concurrency.downloads", + description: "Maximum number of concurrent downloads.", + value_type: "integer", + default: "50", + }, + ConfigOptionDescription { + key: "concurrency.solves", + description: "Maximum number of concurrent solves.", + value_type: "integer", + default: "(available parallelism)", + }, + ConfigOptionDescription { + key: "default-channels", + description: "Channels selected when running `pixi init` or `pixi global install`. Once a workspace exists, its manifest channels are used instead.", + value_type: "array of channel names or URLs", + default: "[]", + }, + ConfigOptionDescription { + key: "detached-environments", + description: "Where to store environments. `true` keeps them in the cache directory, `false` keeps them in `.pixi/`, or set an explicit path.", + value_type: "bool or path", + default: "false", + }, + ConfigOptionDescription { + key: "experimental", + description: "Opt-in experimental features. See `experimental.*` keys; can also be set as a JSON object.", + value_type: "table", + default: "(empty)", + }, + ConfigOptionDescription { + key: "experimental.use-environment-activation-cache", + description: "Cache activated environment variables between commands.", + value_type: "bool", + default: "false", + }, + ConfigOptionDescription { + key: "mirrors", + description: "Mirror redirects, mapping a channel URL to a list of mirror URLs.", + value_type: "map of URL → array of URLs", + default: "{}", + }, + ConfigOptionDescription { + key: "pinning-strategy", + description: "Version pinning strategy used by `pixi add`.", + value_type: "one of `semver`, `no-pin`, `exact-version`, `latest-up`, `major`, `minor`", + default: "\"semver\"", + }, + ConfigOptionDescription { + key: "proxy-config", + description: "HTTP/HTTPS proxy configuration. See `proxy-config.*` keys; can also be set as a JSON object.", + value_type: "table", + default: "(empty)", + }, + ConfigOptionDescription { + key: "proxy-config.http", + description: "Proxy URL used for HTTP requests.", + value_type: "URL", + default: "(unset)", + }, + ConfigOptionDescription { + key: "proxy-config.https", + description: "Proxy URL used for HTTPS requests.", + value_type: "URL", + default: "(unset)", + }, + ConfigOptionDescription { + key: "proxy-config.non-proxy-hosts", + description: "Hosts that should bypass the configured proxy.", + value_type: "array of strings", + default: "[]", + }, + ConfigOptionDescription { + key: "pypi-config", + description: "PyPI registry configuration. See `pypi-config.*` keys; can also be set as a JSON object.", + value_type: "table", + default: "(empty)", + }, + ConfigOptionDescription { + key: "pypi-config.allow-insecure-host", + description: "PyPI hosts for which TLS verification is skipped.", + value_type: "array of strings", + default: "[]", + }, + ConfigOptionDescription { + key: "pypi-config.extra-index-urls", + description: "Additional PyPI index URLs queried after the primary one.", + value_type: "array of URLs", + default: "[]", + }, + ConfigOptionDescription { + key: "pypi-config.index-url", + description: "Primary PyPI index URL.", + value_type: "URL", + default: "(unset)", + }, + ConfigOptionDescription { + key: "pypi-config.keyring-provider", + description: "Keyring provider used to fetch PyPI credentials.", + value_type: "one of `disabled`, `subprocess`", + default: "\"disabled\"", + }, + ConfigOptionDescription { + key: "repodata-config", + description: "Repodata fetch configuration. See `repodata-config.*` keys; can also be set as a JSON object.", + value_type: "table", + default: "(empty)", + }, + ConfigOptionDescription { + key: "repodata-config.disable-bzip2", + description: "Skip downloading the bzip2-compressed repodata variant.", + value_type: "bool", + default: "false", + }, + ConfigOptionDescription { + key: "repodata-config.disable-sharded", + description: "Skip downloading sharded repodata.", + value_type: "bool", + default: "false", + }, + ConfigOptionDescription { + key: "repodata-config.disable-zstd", + description: "Skip downloading the zstd-compressed repodata variant.", + value_type: "bool", + default: "false", + }, + ConfigOptionDescription { + key: "run-post-link-scripts", + description: "Whether package post-link scripts are executed. `insecure` runs them (they can execute arbitrary code).", + value_type: "one of `false`, `insecure`", + default: "\"false\"", + }, + ConfigOptionDescription { + key: "allow-symbolic-links", + description: "Allow symbolic links during package installation.", + value_type: "bool", + default: "(platform default)", + }, + ConfigOptionDescription { + key: "allow-hard-links", + description: "Allow hard links during package installation.", + value_type: "bool", + default: "(platform default)", + }, + ConfigOptionDescription { + key: "allow-ref-links", + description: "Allow ref links (copy-on-write) during package installation.", + value_type: "bool", + default: "(platform default)", + }, + ConfigOptionDescription { + key: "s3-options", + description: "Per-bucket S3 configuration. See `s3-options..*` keys; can also be set as a JSON object.", + value_type: "map of bucket name → table", + default: "{}", + }, + ConfigOptionDescription { + key: "s3-options.", + description: "Configuration for the S3 bucket ``. Can be set as a JSON object covering all three subkeys.", + value_type: "table", + default: "(empty)", + }, + ConfigOptionDescription { + key: "s3-options..endpoint-url", + description: "Endpoint URL for the S3 bucket ``.", + value_type: "URL", + default: "(unset)", + }, + ConfigOptionDescription { + key: "s3-options..force-path-style", + description: "Use path-style addressing for the S3 bucket ``.", + value_type: "bool", + default: "false", + }, + ConfigOptionDescription { + key: "s3-options..region", + description: "Region for the S3 bucket ``.", + value_type: "string", + default: "(unset)", + }, + ConfigOptionDescription { + key: "shell", + description: "Shell integration settings. See `shell.*` keys; can also be set as a JSON object.", + value_type: "table", + default: "(empty)", + }, + ConfigOptionDescription { + key: "shell.change-ps1", + description: "Add the `(pixi)` prefix to the shell prompt when an environment is active.", + value_type: "bool", + default: "true", + }, + ConfigOptionDescription { + key: "shell.force-activate", + description: "Force re-activation of the environment on every command, bypassing the activation cache.", + value_type: "bool", + default: "false", + }, + ConfigOptionDescription { + key: "shell.source-completion-scripts", + description: "Source the environment's autocompletion scripts when entering `pixi shell`.", + value_type: "bool", + default: "true", + }, + ConfigOptionDescription { + key: "tls-no-verify", + description: "Disable TLS certificate verification for all network connections. Use only for testing or trusted internal networks.", + value_type: "bool", + default: "false", + }, + ConfigOptionDescription { + key: "tls-root-certs", + description: "Which TLS root certificate store to use. `SSL_CERT_FILE` / `SSL_CERT_DIR` take precedence when set.", + value_type: "one of `webpki`, `system`", + default: "\"system\" (or \"webpki\" on rustls builds)", + }, + ConfigOptionDescription { + key: "tool-platform", + description: "Platform used when installing build backends and other tools. Useful when you want tooling for a different platform than the current one.", + value_type: "platform", + default: "(current platform)", + }, +]; + /// Controls which root certificates to use for TLS connections. /// /// Note: This setting only has an effect when pixi is built with the `rustls` feature. @@ -2041,6 +2347,11 @@ impl Config { ] } + /// Metadata for every configuration key, parallel to [`Self::get_keys`]. + pub fn describe_keys(&self) -> &'static [ConfigOptionDescription] { + CONFIG_OPTION_DESCRIPTIONS + } + /// Merge the `other` config into `self`. /// The `other` config will have higher priority #[must_use] @@ -4093,4 +4404,30 @@ UNUSED = "unused" let err = config.validate().unwrap_err(); assert!(format!("{err}").contains("cache.conda-packages")); } + + #[test] + fn describe_keys_matches_get_keys() { + let config = Config::default(); + let keys: Set<&str> = config.get_keys().iter().copied().collect(); + let described: Set<&str> = config.describe_keys().iter().map(|opt| opt.key).collect(); + let missing: Vec<&str> = keys.difference(&described).copied().collect(); + let extra: Vec<&str> = described.difference(&keys).copied().collect(); + assert!( + missing.is_empty() && extra.is_empty(), + "describe_keys() out of sync with get_keys(); missing: {missing:?}, extra: {extra:?}" + ); + } + + #[test] + fn describe_keys_have_non_empty_metadata() { + for opt in Config::default().describe_keys() { + assert!( + !opt.description.is_empty(), + "{} has no description", + opt.key + ); + assert!(!opt.value_type.is_empty(), "{} has no value_type", opt.key); + assert!(!opt.default.is_empty(), "{} has no default", opt.key); + } + } } diff --git a/docs/reference/cli/pixi/config/list.md b/docs/reference/cli/pixi/config/list.md index 5f00ae1dd8..8be1888c74 100644 --- a/docs/reference/cli/pixi/config/list.md +++ b/docs/reference/cli/pixi/config/list.md @@ -20,6 +20,8 @@ pixi config list [OPTIONS] [KEY] ## Options - `--json` : Output in JSON format +- `--describe` +: Describe every configuration option with its type, default, and a short explanation ## Config Options - `--local (-l)` diff --git a/docs/reference/cli/pixi/config/list_extender b/docs/reference/cli/pixi/config/list_extender index 3739222d85..c15443a5d1 100644 --- a/docs/reference/cli/pixi/config/list_extender +++ b/docs/reference/cli/pixi/config/list_extender @@ -7,5 +7,7 @@ pixi config list default-channels pixi config list --json pixi config list --system pixi config list -g +pixi config list --describe +pixi config list --describe tls-no-verify ``` --8<-- [end:example] diff --git a/tests/integration_python/test_main_cli.py b/tests/integration_python/test_main_cli.py index 9e06312c79..a5a49322bd 100644 --- a/tests/integration_python/test_main_cli.py +++ b/tests/integration_python/test_main_cli.py @@ -543,6 +543,84 @@ def test_config_allow_links(pixi: Path, tmp_pixi_workspace: Path, dummy_channel_ ) +def test_config_describe(pixi: Path, tmp_pixi_workspace: Path, dummy_channel_1: str) -> None: + manifest_path = tmp_pixi_workspace / "pixi.toml" + verify_cli_command([pixi, "init", "--channel", dummy_channel_1, tmp_pixi_workspace]) + + verify_cli_command( + [pixi, "config", "list", "--describe", "--manifest-path", manifest_path], + stdout_contains=[ + "# Type: bool", + "# Default: false", + "# tls-no-verify = false", + "# default-channels = []", + "# concurrency.solves = (available parallelism)", + "# s3-options..region = (unset)", + ], + ) + + verify_cli_command( + [pixi, "config", "list", "--describe", "tls-no-verify", "--manifest-path", manifest_path], + stdout_contains=[ + "# Disable TLS certificate verification", + "# Type: bool", + "# tls-no-verify = false", + ], + stdout_excludes=["default-channels", "concurrency.solves"], + ) + + verify_cli_command( + [ + pixi, + "config", + "set", + "--manifest-path", + manifest_path, + "--local", + "tls-no-verify", + "true", + ] + ) + verify_cli_command( + [pixi, "config", "list", "--describe", "tls-no-verify", "--manifest-path", manifest_path], + stdout_contains=["tls-no-verify = true"], + stdout_excludes=["# tls-no-verify ="], + ) + + result = verify_cli_command( + [ + pixi, + "config", + "list", + "--describe", + "--json", + "tls-no-verify", + "--manifest-path", + manifest_path, + ] + ) + parsed = json.loads(result.stdout) + assert len(parsed) == 1 + assert parsed[0]["key"] == "tls-no-verify" + assert parsed[0]["type"] == "bool" + assert parsed[0]["default"] == "false" + assert parsed[0]["value"] is True + + verify_cli_command( + [ + pixi, + "config", + "list", + "--describe", + "not-a-real-key", + "--manifest-path", + manifest_path, + ], + ExitCode.FAILURE, + stderr_contains="Unknown configuration key", + ) + + def test_dont_add_broken_dep(pixi: Path, tmp_pixi_workspace: Path, dummy_channel_1: str) -> None: manifest_path = tmp_pixi_workspace / "pixi.toml"