diff --git a/src/compile/ir/tasks/mod.rs b/src/compile/ir/tasks/mod.rs index 1587006e..b91d9786 100644 --- a/src/compile/ir/tasks/mod.rs +++ b/src/compile/ir/tasks/mod.rs @@ -46,4 +46,5 @@ pub mod publish_pipeline_artifact; pub mod publish_test_results; pub mod use_dotnet; pub mod use_node; +pub mod use_python_version; pub mod vstest; diff --git a/src/compile/ir/tasks/use_python_version.rs b/src/compile/ir/tasks/use_python_version.rs new file mode 100644 index 00000000..305a11d6 --- /dev/null +++ b/src/compile/ir/tasks/use_python_version.rs @@ -0,0 +1,263 @@ +//! Typed builder for `UsePythonVersion@0`. +//! +//! ADO task reference: +//! + +use super::common::bool_input; +use crate::compile::ir::step::TaskStep; + +/// Target architecture for the Python interpreter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Architecture { + /// 32-bit (x86) Python interpreter. + X86, + /// 64-bit (x64) Python interpreter (default). + X64, + /// ARM 64-bit Python interpreter. + Arm64, +} + +impl Architecture { + /// Return the ADO input token for this architecture. + pub fn as_ado_str(self) -> &'static str { + match self { + Self::X86 => "x86", + Self::X64 => "x64", + Self::Arm64 => "arm64", + } + } +} + +/// Builder for a [`TaskStep`] invoking `UsePythonVersion@0`. +/// +/// Selects and installs the requested Python version on the build agent and +/// adds it to the PATH. Equivalent to the `UsePythonVersion@0` Azure DevOps +/// task. +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct UsePythonVersion { + version_spec: String, + architecture: Option, + add_to_path: Option, + disable_download_from_registry: Option, + allow_unstable: Option, + github_token: Option, + display_name: Option, +} + +impl UsePythonVersion { + /// Required input: `versionSpec` — the Python version spec to install + /// (e.g. `"3.x"`, `"3.11"`, `"3.12"`). ADO default: `"3.x"`. + pub fn new(version_spec: impl Into) -> Self { + Self { + version_spec: version_spec.into(), + architecture: None, + add_to_path: None, + disable_download_from_registry: None, + allow_unstable: None, + github_token: None, + display_name: None, + } + } + + /// `architecture` — target architecture for the Python interpreter. + /// Allowed values: `x86`, `x64`, `arm64`. ADO default: `"x64"`. + pub fn architecture(mut self, value: Architecture) -> Self { + self.architecture = Some(value); + self + } + + /// `addToPath` — whether to prepend the retrieved Python version to the + /// PATH environment variable. ADO default: `true`. + pub fn add_to_path(mut self, value: bool) -> Self { + self.add_to_path = Some(value); + self + } + + /// `disableDownloadFromRegistry` — disable downloading Python releases + /// from the GitHub Actions python registry. ADO default: `false`. + pub fn disable_download_from_registry(mut self, value: bool) -> Self { + self.disable_download_from_registry = Some(value); + self + } + + /// `allowUnstable` — allow downloading unstable (pre-release) Python + /// versions. Only meaningful when `disableDownloadFromRegistry` is + /// `false`. ADO default: `false`. + pub fn allow_unstable(mut self, value: bool) -> Self { + self.allow_unstable = Some(value); + self + } + + /// `githubToken` — GitHub token used to authenticate against the GitHub + /// Actions python registry. Only meaningful when + /// `disableDownloadFromRegistry` is `false`. + pub fn github_token(mut self, value: impl Into) -> Self { + self.github_token = Some(value.into()); + self + } + + /// Override the default `displayName` + /// (`"Install Python "`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "UsePythonVersion@0", + self.display_name + .unwrap_or_else(|| format!("Install Python {}", self.version_spec)), + ) + .with_input("versionSpec", self.version_spec); + if let Some(v) = self.architecture { + t = t.with_input("architecture", v.as_ado_str()); + } + if let Some(v) = self.add_to_path { + t = t.with_input("addToPath", bool_input(v)); + } + if let Some(v) = self.disable_download_from_registry { + t = t.with_input("disableDownloadFromRegistry", bool_input(v)); + } + if let Some(v) = self.allow_unstable { + t = t.with_input("allowUnstable", bool_input(v)); + } + if let Some(v) = self.github_token { + t = t.with_input("githubToken", v); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_task_and_version_spec() { + let t = UsePythonVersion::new("3.x").into_step(); + assert_eq!(t.task, "UsePythonVersion@0"); + assert_eq!(t.display_name, "Install Python 3.x"); + assert_eq!( + t.inputs.get("versionSpec").map(String::as_str), + Some("3.x") + ); + } + + #[test] + fn optional_inputs_not_emitted_by_default() { + let t = UsePythonVersion::new("3.11").into_step(); + assert!(t.inputs.get("architecture").is_none()); + assert!(t.inputs.get("addToPath").is_none()); + assert!(t.inputs.get("disableDownloadFromRegistry").is_none()); + assert!(t.inputs.get("allowUnstable").is_none()); + assert!(t.inputs.get("githubToken").is_none()); + } + + #[test] + fn architecture_variants() { + for (arch, expected) in [ + (Architecture::X86, "x86"), + (Architecture::X64, "x64"), + (Architecture::Arm64, "arm64"), + ] { + let t = UsePythonVersion::new("3.x").architecture(arch).into_step(); + assert_eq!( + t.inputs.get("architecture").map(String::as_str), + Some(expected) + ); + } + } + + #[test] + fn add_to_path_false() { + let t = UsePythonVersion::new("3.x").add_to_path(false).into_step(); + assert_eq!( + t.inputs.get("addToPath").map(String::as_str), + Some("false") + ); + } + + #[test] + fn disable_download_from_registry() { + let t = UsePythonVersion::new("3.x") + .disable_download_from_registry(true) + .into_step(); + assert_eq!( + t.inputs + .get("disableDownloadFromRegistry") + .map(String::as_str), + Some("true") + ); + } + + #[test] + fn allow_unstable_and_github_token() { + let t = UsePythonVersion::new("3.13") + .allow_unstable(true) + .github_token("$(MY_GITHUB_TOKEN)") + .into_step(); + assert_eq!( + t.inputs.get("allowUnstable").map(String::as_str), + Some("true") + ); + assert_eq!( + t.inputs.get("githubToken").map(String::as_str), + Some("$(MY_GITHUB_TOKEN)") + ); + } + + #[test] + fn display_name_override() { + let t = UsePythonVersion::new("3.11") + .with_display_name("Install Python 3.11 for tests") + .into_step(); + assert_eq!(t.display_name, "Install Python 3.11 for tests"); + assert_eq!( + t.inputs.get("versionSpec").map(String::as_str), + Some("3.11") + ); + } + + #[test] + fn all_optional_inputs() { + let t = UsePythonVersion::new("3.12") + .architecture(Architecture::X64) + .add_to_path(true) + .disable_download_from_registry(false) + .allow_unstable(false) + .github_token("ghp_token") + .into_step(); + assert_eq!(t.task, "UsePythonVersion@0"); + assert_eq!( + t.inputs.get("versionSpec").map(String::as_str), + Some("3.12") + ); + assert_eq!( + t.inputs.get("architecture").map(String::as_str), + Some("x64") + ); + assert_eq!( + t.inputs.get("addToPath").map(String::as_str), + Some("true") + ); + assert_eq!( + t.inputs + .get("disableDownloadFromRegistry") + .map(String::as_str), + Some("false") + ); + assert_eq!( + t.inputs.get("allowUnstable").map(String::as_str), + Some("false") + ); + assert_eq!( + t.inputs.get("githubToken").map(String::as_str), + Some("ghp_token") + ); + } +} diff --git a/src/runtimes/python/extension.rs b/src/runtimes/python/extension.rs index 3305cb21..c55b78f8 100644 --- a/src/runtimes/python/extension.rs +++ b/src/runtimes/python/extension.rs @@ -4,6 +4,7 @@ use super::{PYTHON_BASH_COMMANDS, PythonRuntimeConfig}; use crate::compile::extensions::{CompileContext, CompilerExtension, Declarations, ExtensionPhase}; use crate::compile::ir::step::{Step, TaskStep}; use crate::compile::ir::tasks::pip_authenticate::PipAuthenticate; +use crate::compile::ir::tasks::use_python_version::UsePythonVersion; use crate::validate; use anyhow::Result; @@ -125,8 +126,7 @@ management, install it first with `pip install uv`.\n" /// Build the typed [`TaskStep`] for installing Python. fn python_install_task_step(config: &PythonRuntimeConfig) -> TaskStep { let version = config.version().unwrap_or("3.x"); - TaskStep::new("UsePythonVersion@0", format!("Install Python {version}")) - .with_input("versionSpec", version) + UsePythonVersion::new(version).into_step() } /// Build the typed [`TaskStep`] for pip authentication.