diff --git a/e2e/core/test_python_precompiled_minimum_release_age b/e2e/core/test_python_precompiled_minimum_release_age new file mode 100644 index 0000000000..a50ae8d786 --- /dev/null +++ b/e2e/core/test_python_precompiled_minimum_release_age @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +export MISE_PYTHON_COMPILE=0 + +latest=$(mise latest python) +filtered=$(mise latest python --minimum-release-age 2025-01-01) + +[[ -n $filtered ]] || fail "expected filtered python latest to be non-empty" +[[ $filtered =~ ^[0-9]+(\.[0-9]+)*$ ]] || fail "expected filtered python latest to be a version, got $filtered" +[[ $filtered != "$latest" ]] || fail "expected minimum_release_age to hide precompiled python latest $latest" + +json=$(mise ls-remote python --json --minimum-release-age 2025-01-01) +if ! jq -e 'length > 0 and all(.[]; (.created_at != null and .created_at < "2025-01-02T00:00:00Z"))' <<<"$json" >/dev/null; then + fail "expected precompiled python versions to include created_at before the cutoff: $json" +fi diff --git a/src/plugins/core/python.rs b/src/plugins/core/python.rs index 07704a477c..9828a96977 100644 --- a/src/plugins/core/python.rs +++ b/src/plugins/core/python.rs @@ -189,6 +189,23 @@ impl PythonPlugin { } } } + fn python_build_definition_created_at(&self) -> eyre::Result> { + let output = crate::cmd!( + "git", + "-C", + self.python_build_path(), + "-c", + format!("safe.directory={}", self.python_build_path().display()), + "log", + "--format=%cI", + "--diff-filter=A", + "--name-only", + "--", + "plugins/python-build/share/python-build", + ) + .read()?; + Ok(parse_python_build_definition_created_at(&output)) + } async fn fetch_precompiled_remote_versions( &self, @@ -764,8 +781,9 @@ impl Backend for PythonPlugin { .fetch_precompiled_remote_versions() .await? .iter() - .map(|(v, _, _)| VersionInfo { + .map(|(v, date, _)| VersionInfo { version: v.clone(), + created_at: python_precompiled_created_at(date), ..Default::default() }) .collect()) @@ -773,6 +791,12 @@ impl Backend for PythonPlugin { self.install_or_update_python_build(None)?; let python_build_bin = self.python_build_bin(); let python_build_str = python_build_bin.to_string_lossy().to_string(); + let definition_created_at = self + .python_build_definition_created_at() + .inspect_err(|err| { + debug!("failed to get python-build definition timestamps: {err:#}") + }) + .unwrap_or_default(); plugins::core::run_fetch_task_with_timeout_async(async move || { let output = crate::cmd::cmd_read_async_inherited_env( &python_build_str, @@ -786,6 +810,7 @@ impl Backend for PythonPlugin { .filter(|s| !regex!(r"\dt(-dev)?$").is_match(s)) .map(|s| VersionInfo { version: s.to_string(), + created_at: definition_created_at.get(s).cloned(), ..Default::default() }) .sorted_by_cached_key(|v| python_version_sort_key(&v.version)) @@ -974,6 +999,42 @@ impl Backend for PythonPlugin { } } +fn parse_python_build_definition_created_at(output: &str) -> BTreeMap { + let mut created_at = BTreeMap::new(); + let mut current_timestamp = None; + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if !line.starts_with("plugins/") && crate::duration::parse_into_timestamp(line).is_ok() { + current_timestamp = Some(line.to_string()); + continue; + } + if let Some(version) = line.strip_prefix("plugins/python-build/share/python-build/") + && !version.contains('/') + && let Some(timestamp) = ¤t_timestamp + { + created_at + .entry(version.to_string()) + .or_insert_with(|| timestamp.clone()); + } + } + created_at +} + +fn python_precompiled_created_at(date: &str) -> Option { + if date.len() != 8 || !date.chars().all(|c| c.is_ascii_digit()) { + return None; + } + Some(format!( + "{}-{}-{}T00:00:00Z", + &date[..4], + &date[4..6], + &date[6..] + )) +} + fn python_precompiled_url_path(settings: &Settings) -> String { if cfg!(windows) || cfg!(linux) || cfg!(macos) { format!( @@ -1130,6 +1191,47 @@ mod tests { assert!(PythonOptions::new(&opts).lockfile_options().is_empty()); } + #[test] + fn parses_python_build_definition_created_at() { + let output = "\ +2026-06-11T12:34:56+00:00 +plugins/python-build/share/python-build/3.14.6 +plugins/python-build/share/python-build/3.13.8 + +2026-06-01T01:02:03+00:00 +plugins/python-build/share/python-build/3.14.5 +plugins/python-build/share/python-build/patches/3.14.5/foo.patch +"; + + assert_eq!( + parse_python_build_definition_created_at(output), + BTreeMap::from([ + ( + "3.13.8".to_string(), + "2026-06-11T12:34:56+00:00".to_string() + ), + ( + "3.14.5".to_string(), + "2026-06-01T01:02:03+00:00".to_string() + ), + ( + "3.14.6".to_string(), + "2026-06-11T12:34:56+00:00".to_string() + ), + ]) + ); + } + + #[test] + fn parses_python_precompiled_created_at() { + assert_eq!( + python_precompiled_created_at("20260611").as_deref(), + Some("2026-06-11T00:00:00Z") + ); + assert_eq!(python_precompiled_created_at("2026-06-11"), None); + assert_eq!(python_precompiled_created_at("notadate"), None); + } + #[test] fn test_resolve_python_arch_windows_x64() { assert_eq!(resolve_python_arch("windows", "x64"), "x86_64");