From 56a3a7a47907d2bc42bc2684a19c8780ce1d7505 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:45:58 +0000 Subject: [PATCH 1/2] feat(pipx): add per-tool registry URL --- docs/dev-tools/backends/pipx.md | 17 ++++ src/backend/pipx.rs | 174 +++++++++++++++++++++----------- 2 files changed, 132 insertions(+), 59 deletions(-) diff --git a/docs/dev-tools/backends/pipx.md b/docs/dev-tools/backends/pipx.md index a4b49c1f12a..22facd51599 100644 --- a/docs/dev-tools/backends/pipx.md +++ b/docs/dev-tools/backends/pipx.md @@ -105,6 +105,23 @@ import Settings from '/components/settings.vue'; The following [tool-options](/dev-tools/#tool-options) are available for the `pipx` backend—these go in `[tools]` in `mise.toml`. +### `registry_url` + +Set the package registry URL mise uses to resolve versions for this tool. The URL must contain a +`{}` placeholder for the package name. This overrides the `pipx.registry_url` setting only +for this tool; installation registry arguments remain configured separately through `uvx_args` or +`pipx_args`. + +```toml +[tools] +"pipx:my-tool" = { + version = "latest", + registry_url = "https://packages.example.com/pypi/{}/json", + uvx_args = "--extra-index-url https://packages.example.com/pypi/simple", + pipx_args = "--pip-args='--extra-index-url https://packages.example.com/pypi/simple'" +} +``` + ### `install_env` Set environment variables for `uv tool install` or `pipx install`. mise still diff --git a/src/backend/pipx.rs b/src/backend/pipx.rs index 45999cfc518..83582064ee8 100644 --- a/src/backend/pipx.rs +++ b/src/backend/pipx.rs @@ -12,6 +12,7 @@ use crate::env; #[cfg(unix)] use crate::file; use crate::github::{self, GithubRelease}; +use crate::hash::hash_to_str; use crate::http::HTTP_FETCH; use crate::install_context::InstallContext; use crate::plugins::PEP440_PRERELEASE_REGEX; @@ -43,7 +44,6 @@ const UV_EXCLUDE_NEWER_VERSION: &str = "0.2.22"; #[derive(Debug)] pub struct PIPXBackend { ba: Arc, - latest_version_cache: CacheManager>, } #[derive(Debug, Clone, Copy)] @@ -70,6 +70,10 @@ impl<'a> PipxOptions<'a> { self.values.str("pipx_args") } + fn registry_url(&self) -> Option<&'a str> { + self.values.str("registry_url") + } + fn uvx_args(&self) -> Option<&'a str> { self.values.str("uvx_args") } @@ -138,10 +142,10 @@ impl Backend for PIPXBackend { false } - async fn _list_remote_versions(&self, _config: &Arc) -> eyre::Result> { + async fn _list_remote_versions(&self, config: &Arc) -> eyre::Result> { let versions: Vec = match self.tool_name().parse()? { PipxRequest::Pypi(package) => { - let registry_url = Self::get_registry_url()?; + let registry_url = self.get_registry_url(config).await?; if registry_url.contains("/json") { debug!("Fetching JSON for {}", package); let url = registry_url.replace("{}", &package); @@ -199,53 +203,53 @@ impl Backend for PIPXBackend { .collect()) } - async fn latest_stable_version(&self, _config: &Arc) -> eyre::Result> { - let this = self; + async fn latest_stable_version(&self, config: &Arc) -> eyre::Result> { + let package = match self.tool_name().parse()? { + PipxRequest::Pypi(package) => package, + PipxRequest::Git(_) => return Ok(None), + }; + let registry_url = self.get_registry_url(config).await?; + let latest_version_cache = self.latest_version_cache(®istry_url); timeout::run_with_timeout_async( async || { - this.latest_version_cache - .get_or_try_init_async(async || match this.tool_name().parse()? { - PipxRequest::Pypi(package) => { - let registry_url = Self::get_registry_url()?; - if registry_url.contains("/json") { - debug!("Fetching JSON for {}", package); - let url = registry_url.replace("{}", &package); - let pkg: PypiPackage = HTTP_FETCH.json(url).await?; - Ok(Self::latest_stable_from_pypi_package(pkg)) - } else { - debug!("Fetching HTML for {}", package); - let url = registry_url.replace("{}", &package); - let html = HTTP_FETCH.get_html(url).await?; - - // PEP-0503 - let version_re = regex!(r#"href=["'][^"']*/([^/]+)\.tar\.gz(?:#(md5|sha1|sha224|sha256|sha384|sha512)=[0-9A-Fa-f]+)?["']"#); - - let version = version_re - .captures_iter(&html) - .filter_map(|cap| { - let filename = cap.get(1)?.as_str(); - let escaped_package = regex::escape(&package); - // PEP-503: normalize package names by replacing hyphens with character class that allows -, _, . - let re_str = escaped_package.replace(r"\-", r"[\-_.]"); - let re_str = format!("^{re_str}-(.+)$"); - let pkg_re = regex::Regex::new(&re_str).ok()?; - let pkg_version = - pkg_re.captures(filename)?.get(1)?.as_str(); - Some(pkg_version.to_string()) - }) - .filter(|v| { - !v.contains("dev") - && !v.contains("a") - && !v.contains("b") - && !v.contains("rc") - }) - .sorted_by_cached_key(|v| Versioning::new(v)) - .next_back(); - - Ok(version) - } + latest_version_cache + .get_or_try_init_async(async || { + if registry_url.contains("/json") { + debug!("Fetching JSON for {}", package); + let url = registry_url.replace("{}", &package); + let pkg: PypiPackage = HTTP_FETCH.json(url).await?; + Ok(Self::latest_stable_from_pypi_package(pkg)) + } else { + debug!("Fetching HTML for {}", package); + let url = registry_url.replace("{}", &package); + let html = HTTP_FETCH.get_html(url).await?; + + // PEP-0503 + let version_re = regex!(r#"href=["'][^"']*/([^/]+)\.tar\.gz(?:#(md5|sha1|sha224|sha256|sha384|sha512)=[0-9A-Fa-f]+)?["']"#); + + let version = version_re + .captures_iter(&html) + .filter_map(|cap| { + let filename = cap.get(1)?.as_str(); + let escaped_package = regex::escape(&package); + // PEP-503: normalize package names by replacing hyphens with character class that allows -, _, . + let re_str = escaped_package.replace(r"\-", r"[\-_.]"); + let re_str = format!("^{re_str}-(.+)$"); + let pkg_re = regex::Regex::new(&re_str).ok()?; + let pkg_version = pkg_re.captures(filename)?.get(1)?.as_str(); + Some(pkg_version.to_string()) + }) + .filter(|v| { + !v.contains("dev") + && !v.contains("a") + && !v.contains("b") + && !v.contains("rc") + }) + .sorted_by_cached_key(|v| Versioning::new(v)) + .next_back(); + + Ok(version) } - _ => Ok(None), }) .await }, @@ -264,7 +268,7 @@ impl Backend for PIPXBackend { async fn resolve_exact_version( &self, - _config: &Arc, + config: &Arc, version: &str, ) -> eyre::Result> { // Git-sourced tools resolve versions from repo tags, which cannot be @@ -278,7 +282,7 @@ impl Backend for PIPXBackend { // Surface malformed registry configuration at resolve time like // remote discovery would — installation only sees the derived index // URL, which skips this validation. - Self::get_registry_url()?; + self.get_registry_url(config).await?; // PEP 440 allows non-semver versions (1.2.3.4, 1.2.3rc1, 1.2.3.post1) // — those keep using remote discovery. A full semver request is // exact; `pipx install pkg==version` / `uv tool install` fail when it @@ -530,14 +534,18 @@ impl PIPXBackend { } pub fn from_arg(ba: BackendArg) -> Self { - Self { - latest_version_cache: CacheManagerBuilder::new( - ba.cache_path.join("latest_version.msgpack.z"), - ) - .with_fresh_duration(Settings::get().fetch_remote_versions_cache()) - .build(), - ba: Arc::new(ba), - } + Self { ba: Arc::new(ba) } + } + + fn latest_version_cache(&self, registry_url: &str) -> CacheManager> { + let registry_hash = hash_to_str(®istry_url); + CacheManagerBuilder::new( + self.ba + .cache_path + .join(format!("latest_version_{registry_hash}.msgpack.z")), + ) + .with_fresh_duration(Settings::get().fetch_remote_versions_cache()) + .build() } fn get_index_url() -> eyre::Result { @@ -576,8 +584,13 @@ impl PIPXBackend { Ok(url) } - fn get_registry_url() -> eyre::Result { - let registry_url = Settings::get().pipx.registry_url.clone(); + async fn get_registry_url(&self, config: &Arc) -> eyre::Result { + let raw_options = config.get_tool_opts_with_overrides(&self.ba).await?; + let options = PipxOptions::new(&raw_options); + let registry_url = options + .registry_url() + .map(str::to_owned) + .unwrap_or_else(|| Settings::get().pipx.registry_url.clone()); debug!("Pipx registry URL: {}", registry_url); @@ -977,6 +990,49 @@ mod tests { ); } + #[tokio::test] + async fn per_tool_registry_url_resolves_latest_version() { + use crate::backend::Backend; + + let mut server = mockito::Server::new_async().await; + let registry = server + .mock("GET", "/pypi/private-tool/json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::json!({ + "releases": { + "1.0.0": [{ + "upload_time": "2026-01-01T00:00:00", + "upload_time_iso_8601": "2026-01-01T00:00:00Z", + "yanked": false + }] + } + }) + .to_string(), + ) + .create_async() + .await; + let config = crate::config::Config::get().await.unwrap(); + let backend = PIPXBackend::from_arg( + format!( + "pipx:private-tool[registry_url='{}/pypi/{{}}/json']", + server.url() + ) + .into(), + ); + + assert_eq!( + backend + .latest_stable_version(&config) + .await + .unwrap() + .as_deref(), + Some("1.0.0") + ); + registry.assert_async().await; + } + #[tokio::test] async fn non_semver_versions_require_remote_discovery() { use crate::backend::Backend; From 78deea6c207263f800b1d22aa3cee9f8c3128807 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:04:13 +0000 Subject: [PATCH 2/2] fix(pipx): isolate remote version cache by registry --- src/backend/pipx.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/backend/pipx.rs b/src/backend/pipx.rs index 83582064ee8..1760e3fc8d5 100644 --- a/src/backend/pipx.rs +++ b/src/backend/pipx.rs @@ -142,6 +142,13 @@ impl Backend for PIPXBackend { false } + async fn remote_version_cache_context(&self, config: &Arc) -> Result> { + match self.tool_name().parse()? { + PipxRequest::Pypi(_) => self.get_registry_url(config).await.map(Some), + PipxRequest::Git(_) => Ok(None), + } + } + async fn _list_remote_versions(&self, config: &Arc) -> eyre::Result> { let versions: Vec = match self.tool_name().parse()? { PipxRequest::Pypi(package) => { @@ -1033,6 +1040,76 @@ mod tests { registry.assert_async().await; } + #[tokio::test] + async fn per_tool_registries_isolate_remote_version_cache() { + use crate::backend::Backend; + + let mut first_server = mockito::Server::new_async().await; + let first_registry = first_server + .mock("GET", "/pypi/private-tool/json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::json!({ + "releases": { + "1.0.0": [{ + "upload_time": "2026-01-01T00:00:00", + "upload_time_iso_8601": "2026-01-01T00:00:00Z", + "yanked": false + }] + } + }) + .to_string(), + ) + .create_async() + .await; + let mut second_server = mockito::Server::new_async().await; + let second_registry = second_server + .mock("GET", "/pypi/private-tool/json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::json!({ + "releases": { + "2.0.0": [{ + "upload_time": "2026-02-01T00:00:00", + "upload_time_iso_8601": "2026-02-01T00:00:00Z", + "yanked": false + }] + } + }) + .to_string(), + ) + .create_async() + .await; + let config = crate::config::Config::get().await.unwrap(); + let first_backend = PIPXBackend::from_arg( + format!( + "pipx:private-tool[registry_url='{}/pypi/{{}}/json']", + first_server.url() + ) + .into(), + ); + let second_backend = PIPXBackend::from_arg( + format!( + "pipx:private-tool[registry_url='{}/pypi/{{}}/json']", + second_server.url() + ) + .into(), + ); + + assert_eq!( + first_backend.list_remote_versions(&config).await.unwrap(), + vec!["1.0.0"] + ); + assert_eq!( + second_backend.list_remote_versions(&config).await.unwrap(), + vec!["2.0.0"] + ); + first_registry.assert_async().await; + second_registry.assert_async().await; + } + #[tokio::test] async fn non_semver_versions_require_remote_discovery() { use crate::backend::Backend;