From 0fac1ff9b737e11f795164ffd18d0fad13df2f2f Mon Sep 17 00:00:00 2001 From: Chris Pryer <14341145+cnpryer@users.noreply.github.com> Date: Fri, 3 Nov 2023 11:37:21 -0400 Subject: [PATCH] Add `huak-toolchain` (789) --- Cargo.lock | 13 + Cargo.toml | 2 + crates/huak-cli/Cargo.toml | 1 + crates/huak-cli/src/cli.rs | 100 ++- crates/huak-cli/src/main.rs | 29 +- crates/huak-home/src/lib.rs | 7 +- crates/huak-package-manager/Cargo.toml | 3 + crates/huak-package-manager/src/config.rs | 3 +- crates/huak-package-manager/src/error.rs | 12 + crates/huak-package-manager/src/fs.rs | 4 +- crates/huak-package-manager/src/lib.rs | 1 + crates/huak-package-manager/src/metadata.rs | 4 + crates/huak-package-manager/src/ops/mod.rs | 5 + crates/huak-package-manager/src/ops/python.rs | 12 +- .../huak-package-manager/src/ops/toolchain.rs | 675 ++++++++++++++++++ crates/huak-package-manager/src/settings.rs | 34 + crates/huak-package-manager/src/sys.rs | 41 +- crates/huak-package-manager/src/workspace.rs | 72 ++ crates/huak-python-manager/Cargo.toml | 4 +- .../scripts/generate_python_releases.py | 10 + crates/huak-python-manager/src/cli.rs | 18 +- crates/huak-python-manager/src/error.rs | 6 +- crates/huak-python-manager/src/install.rs | 4 +- crates/huak-python-manager/src/lib.rs | 46 +- crates/huak-python-manager/src/releases.rs | 12 + crates/huak-python-manager/src/resolve.rs | 289 ++++++-- crates/huak-python-manager/src/version.rs | 3 +- crates/huak-toolchain/Cargo.toml | 15 + crates/huak-toolchain/README.md | 3 + crates/huak-toolchain/src/channel.rs | 78 ++ crates/huak-toolchain/src/error.rs | 24 + crates/huak-toolchain/src/install.rs | 170 +++++ crates/huak-toolchain/src/lib.rs | 272 +++++++ crates/huak-toolchain/src/path.rs | 17 + crates/huak-toolchain/src/resolve.rs | 73 ++ crates/huak-toolchain/src/tools.rs | 41 ++ dev-resources/planning.md | 14 +- pyproject.toml | 3 + 38 files changed, 2021 insertions(+), 99 deletions(-) create mode 100644 crates/huak-package-manager/src/ops/toolchain.rs create mode 100644 crates/huak-package-manager/src/settings.rs create mode 100644 crates/huak-toolchain/Cargo.toml create mode 100644 crates/huak-toolchain/README.md create mode 100644 crates/huak-toolchain/src/channel.rs create mode 100644 crates/huak-toolchain/src/error.rs create mode 100644 crates/huak-toolchain/src/install.rs create mode 100644 crates/huak-toolchain/src/lib.rs create mode 100644 crates/huak-toolchain/src/path.rs create mode 100644 crates/huak-toolchain/src/resolve.rs create mode 100644 crates/huak-toolchain/src/tools.rs diff --git a/Cargo.lock b/Cargo.lock index 565cb8d0..a6afb33b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -548,6 +548,7 @@ dependencies = [ "huak-home", "huak-package-manager", "huak-python-manager", + "huak-toolchain", "human-panic", "insta-cmd", "openssl", @@ -571,9 +572,11 @@ dependencies = [ "clap", "git2", "glob", + "hex", "huak-dev", "huak-home", "huak-python-manager", + "huak-toolchain", "indexmap 2.0.2", "lazy_static", "pep440_rs", @@ -582,6 +585,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2", "tempfile", "termcolor", "thiserror", @@ -607,6 +611,15 @@ dependencies = [ "zstd", ] +[[package]] +name = "huak-toolchain" +version = "0.0.0" +dependencies = [ + "huak-python-manager", + "pep440_rs", + "thiserror", +] + [[package]] name = "human-panic" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 15c8a80f..fb0ed091 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,11 +12,13 @@ license = "MIT" clap = { version = "4.4.2", features = ["cargo", "derive"] } colored = "2.0.4" glob = "0.3.1" +hex = "0.4.3" human-panic = "1.1.5" lazy_static = "1.4.0" pep440_rs = "0.3.11" pep508_rs = "0.2.1" regex = "1.10.2" +sha2 = "0.10.8" tempfile = "3.7.1" termcolor = "1.2.0" thiserror = "1.0.48" diff --git a/crates/huak-cli/Cargo.toml b/crates/huak-cli/Cargo.toml index 22d289fd..d0208190 100644 --- a/crates/huak-cli/Cargo.toml +++ b/crates/huak-cli/Cargo.toml @@ -20,6 +20,7 @@ colored.workspace = true huak-home = { path = "../huak-home" } huak-package-manager = { path = "../huak-package-manager"} huak-python-manager = { path = "../huak-python-manager" } +huak-toolchain = { path = "../huak-toolchain" } human-panic.workspace = true # included to build PyPi Wheels (see .github/workflow/README.md) openssl = { version = "0.10.57", features = ["vendored"], optional = true } diff --git a/crates/huak-cli/src/cli.rs b/crates/huak-cli/src/cli.rs index 322e6206..5c4e786d 100644 --- a/crates/huak-cli/src/cli.rs +++ b/crates/huak-cli/src/cli.rs @@ -11,6 +11,7 @@ use huak_package_manager::{ Verbosity, WorkspaceOptions, }; use huak_python_manager::RequestedVersion; +use huak_toolchain::{Channel, LocalTool}; use std::{env::current_dir, path::PathBuf, process::ExitCode, str::FromStr}; use termcolor::ColorChoice; @@ -156,6 +157,12 @@ enum Commands { #[arg(last = true)] trailing: Option>, }, + /// Manage toolchains. + #[clap(alias = "tc")] + Toolchain { + #[command(subcommand)] + command: Toolchain, + }, /// Update the project's dependencies. Update { #[arg(num_args = 0..)] @@ -188,21 +195,69 @@ enum Python { #[derive(Subcommand)] enum Toolchain { - /// List available toolchains. - List, - /// Use an available toolchain. - Use { - /// The version of Python to use. - #[arg(required = true)] - version: RequestedVersion, + /// Add a tool to a toolchain. + Add { + /// A tool to add. + tool: LocalTool, + /// Add a tool to a specific channel. + #[arg(long, required = false)] + channel: Option, + }, + /// Display information about a toolchain. + Info { + /// The toolchain channel to display information for. + #[arg(long, required = false)] + channel: Option, }, /// Install a toolchain. Install { - /// The version of Python to install. - #[arg(required = true)] - version: RequestedVersion, + /// The toolchain channel to install. + #[arg(required = false)] + channel: Option, /// The path to install a toolchain to. - target: PathBuf, + #[arg(required = false)] + target: Option, // TODO(cnpryer): Could default to home dir toolchains dir. + }, + /// List available toolchains. + List, + /// Remove a tool from a toolchain. + Remove { + /// A tool to add. + tool: LocalTool, + /// Remove a tool from a specific channel. + #[arg(long, required = false)] + channel: Option, + }, + /// Run a tool installed to a toolchain. + Run { + /// The tool to run. + tool: LocalTool, + /// The toolchain channel to run a tool from. + #[arg(long, required = false)] + channel: Option, + /// Args to run the tool with. + #[arg(num_args = 1.., required = false)] + trailing: Option>, + }, + /// Uninstall a toolchain. + Uninstall { + /// The toolchain channel to uninstall. + #[arg(required = false)] + channel: Option, + }, + /// Update the current toolchain. + Update { + /// A tool to update. + #[arg(required = false)] + tool: Option, // TODO(cnpryer): Either include @version or add version arg. + /// The toolchain channel to update. + #[arg(long, required = false)] + channel: Option, + }, + /// Use an available toolchain. + Use { + /// The toolchain channel to use. + channel: Channel, }, } @@ -345,6 +400,7 @@ fn exec_command(cmd: Commands, config: &mut Config) -> HuakResult<()> { }; test(config, &options) } + Commands::Toolchain { command } => toolchain(command, config), Commands::Update { dependencies, trailing, @@ -459,7 +515,7 @@ fn python(command: Python, config: &Config) -> HuakResult<()> { match command { Python::List => ops::list_python(config), Python::Use { version } => ops::use_python(&version, config), - Python::Install { version } => ops::install_python(&version), + Python::Install { version } => ops::install_python(version), } } @@ -475,6 +531,26 @@ fn test(config: &Config, options: &TestOptions) -> HuakResult<()> { ops::test_project(config, options) } +fn toolchain(command: Toolchain, config: &Config) -> HuakResult<()> { + match command { + Toolchain::Add { tool, channel } => ops::add_tool(&tool, channel, config), + Toolchain::Info { channel } => ops::toolchain_info(channel.as_ref(), config), + Toolchain::Install { channel, target } => ops::install_toolchain(channel, target, config), + Toolchain::List => ops::list_toolchains(config), + Toolchain::Remove { tool, channel } => ops::remove_tool(&tool, channel.as_ref(), config), + Toolchain::Run { + tool, + channel, + trailing, + } => ops::run_tool(&tool, channel.as_ref(), trailing, config), + Toolchain::Uninstall { channel } => ops::uninstall_toolchain(channel.as_ref(), config), + Toolchain::Update { tool, channel } => { + ops::update_toolchain(tool, channel.as_ref(), config) + } + Toolchain::Use { channel } => ops::use_toolchain(&channel, config), + } +} + fn update( dependencies: Option>, config: &Config, diff --git a/crates/huak-cli/src/main.rs b/crates/huak-cli/src/main.rs index a6d2d2ef..8c69c7bb 100644 --- a/crates/huak-cli/src/main.rs +++ b/crates/huak-cli/src/main.rs @@ -6,8 +6,13 @@ mod cli; use clap::Parser; use cli::Cli; use colored::Colorize; +use huak_home::huak_home_dir; use human_panic::setup_panic; -use std::process::{exit, ExitCode}; +use std::{ + env, + fs::create_dir_all, + process::{exit, ExitCode}, +}; mod error; @@ -16,6 +21,28 @@ mod error; pub fn main() -> ExitCode { setup_panic!(); + // Get home directory path. + let Some(home) = huak_home_dir() else { + eprintln!( + "{}{} failed to resolve huak's home directory", + "error".red(), + ":".bold() + ); + return ExitCode::FAILURE; + }; + + // If the home directory doesn't exist then spawn one. We only report an error if the + // spawn fails due to anything other than the directory already existing. + if !home.exists() { + if let Err(e) = create_dir_all(home) { + if e.kind() != std::io::ErrorKind::AlreadyExists { + eprintln!("{}{} {}", "error".red(), ":".bold(), e); + return ExitCode::FAILURE; + } + } + } + + // Capture and run CLI input. match Cli::parse().run() { Ok(0) => ExitCode::SUCCESS, // Lazy-like exit of a subprocess failure. TODO: https://github.com/cnpryer/huak/issues/631 diff --git a/crates/huak-home/src/lib.rs b/crates/huak-home/src/lib.rs index 257f8817..b1dd1350 100644 --- a/crates/huak-home/src/lib.rs +++ b/crates/huak-home/src/lib.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{env, path::PathBuf}; /// Huak's home directory is located at ~/.huak. /// @@ -11,7 +11,10 @@ use std::path::PathBuf; /// On windows the `USERPROFILE` environment variable is used if it exists. #[must_use] pub fn huak_home_dir() -> Option { - home_dir().map(|p| p.join(".huak")) + env::var("HUAK_HOME") + .ok() + .map(PathBuf::from) + .or(home_dir().map(|p| p.join(".huak"))) } #[cfg(windows)] diff --git a/crates/huak-package-manager/Cargo.toml b/crates/huak-package-manager/Cargo.toml index da63dfdf..32b90310 100644 --- a/crates/huak-package-manager/Cargo.toml +++ b/crates/huak-package-manager/Cargo.toml @@ -29,6 +29,9 @@ regex.workspace = true huak-python-manager = { path = "../huak-python-manager" } huak-home = { path = "../huak-home" } lazy_static.workspace = true +huak-toolchain = { path = "../huak-toolchain" } +hex.workspace = true +sha2.workspace = true [dev-dependencies] huak-dev = { path = "../huak-dev" } diff --git a/crates/huak-package-manager/src/config.rs b/crates/huak-package-manager/src/config.rs index 259fbb71..542ecd11 100644 --- a/crates/huak-package-manager/src/config.rs +++ b/crates/huak-package-manager/src/config.rs @@ -1,6 +1,5 @@ -use std::path::PathBuf; - use huak_home::huak_home_dir; +use std::path::PathBuf; use crate::{sys::Terminal, workspace::Workspace, TerminalOptions}; diff --git a/crates/huak-package-manager/src/error.rs b/crates/huak-package-manager/src/error.rs index c0f1e301..af4cac34 100644 --- a/crates/huak-package-manager/src/error.rs +++ b/crates/huak-package-manager/src/error.rs @@ -4,6 +4,8 @@ use thiserror::Error as ThisError; pub type HuakResult = Result; +// TODO(cnpryer): If errors are given "a problem..." prompts there could be redundancy in messages. +// These prompts feel more like application experience than library needs. #[derive(ThisError, Debug)] pub enum Error { #[error("a problem with argument parsing occurred: {0}")] @@ -22,8 +24,16 @@ pub enum Error { HuakConfigurationError(String), #[error("a problem occurred resolving huak's home directory")] HuakHomeNotFound, + #[error("a toolchain cannot be found")] + HuakToolchainNotFound, + #[error("{0}")] // See TODO note above. + HuakToolchainError(#[from] huak_toolchain::Error), + #[error("a toolchain already exists: {0}")] + LocalToolchainExists(PathBuf), #[error("a problem with huak's internals occurred: {0}")] InternalError(String), + #[error("a checksum is invalid: {0}")] + InvalidChecksum(String), #[error("a version number could not be parsed: {0}")] InvalidVersionString(String), #[error("a problem occurred with json deserialization: {0}")] @@ -60,6 +70,8 @@ pub enum Error { TOMLDeserializationError(#[from] toml::de::Error), #[error("a problem with toml serialization occurred {0}")] TOMLSerializationError(#[from] toml::ser::Error), + #[error("{0}")] + TOMLEditError(#[from] toml_edit::TomlError), #[error("a problem with toml deserialization occurred: {0}")] TOMLEditDeserializationError(#[from] toml_edit::de::Error), #[error("a problem with toml serialization occurred {0}")] diff --git a/crates/huak-package-manager/src/fs.rs b/crates/huak-package-manager/src/fs.rs index 3b38ba57..6c932505 100644 --- a/crates/huak-package-manager/src/fs.rs +++ b/crates/huak-package-manager/src/fs.rs @@ -12,8 +12,8 @@ pub fn copy_dir>(from: T, to: T, options: &CopyDirOptions) -> R if from.is_dir() { for entry in fs::read_dir(from)?.filter_map(Result::ok) { - let entry_path = entry.path(); - if options.exclude.contains(&entry_path) { + let entry_as_path = entry.path(); + if options.exclude.contains(&entry_as_path) { continue; } diff --git a/crates/huak-package-manager/src/lib.rs b/crates/huak-package-manager/src/lib.rs index e0c94eca..e29abc3d 100644 --- a/crates/huak-package-manager/src/lib.rs +++ b/crates/huak-package-manager/src/lib.rs @@ -56,6 +56,7 @@ mod metadata; pub mod ops; mod package; mod python_environment; +mod settings; mod sys; mod workspace; diff --git a/crates/huak-package-manager/src/metadata.rs b/crates/huak-package-manager/src/metadata.rs index ad26b1e7..c02953c2 100644 --- a/crates/huak-package-manager/src/metadata.rs +++ b/crates/huak-package-manager/src/metadata.rs @@ -248,6 +248,10 @@ impl Metadata { .entry(name.to_string()) .or_insert(entrypoint.to_string()); } + + pub fn tool(&self) -> Option<&Table> { + self.tool.as_ref() + } } impl Default for Metadata { diff --git a/crates/huak-package-manager/src/ops/mod.rs b/crates/huak-package-manager/src/ops/mod.rs index 7a6ca1d1..82774146 100644 --- a/crates/huak-package-manager/src/ops/mod.rs +++ b/crates/huak-package-manager/src/ops/mod.rs @@ -12,6 +12,7 @@ mod python; mod remove; mod run; mod test; +mod toolchain; mod update; mod version; @@ -33,6 +34,10 @@ pub use remove::{remove_project_dependencies, RemoveOptions}; pub use run::run_command_str; use std::{path::PathBuf, process::Command}; pub use test::{test_project, TestOptions}; +pub use toolchain::{ + add_tool, install_toolchain, list_toolchains, remove_tool, run_tool, toolchain_info, + uninstall_toolchain, update_toolchain, use_toolchain, +}; pub use update::{update_project_dependencies, UpdateOptions}; pub use version::display_project_version; diff --git a/crates/huak-package-manager/src/ops/python.rs b/crates/huak-package-manager/src/ops/python.rs index 35ebde2f..dd400cba 100644 --- a/crates/huak-package-manager/src/ops/python.rs +++ b/crates/huak-package-manager/src/ops/python.rs @@ -4,7 +4,8 @@ use crate::{ }; use huak_home::huak_home_dir; use huak_python_manager::{ - install_with_target, resolve_release, Options, RequestedVersion, Strategy, + install_with_target, release_options_from_requested_version, resolve_release, RequestedVersion, + Strategy, }; use std::process::Command; use termcolor::Color; @@ -62,15 +63,12 @@ pub fn use_python(version: &RequestedVersion, config: &Config) -> HuakResult<()> config.terminal().run_command(&mut cmd) } -pub fn install_python(version: &RequestedVersion) -> HuakResult<()> { +pub fn install_python(version: RequestedVersion) -> HuakResult<()> { // Use default selection strategy to find the best match for the requested version. - let strategy = Strategy::Selection(Options { - version: Some(version.clone()), - ..Default::default() - }); + let strategy = Strategy::Selection(release_options_from_requested_version(version)?); let Some(release) = resolve_release(&strategy) else { - return Err(Error::PythonReleaseNotFound(version.to_string())); + return Err(Error::PythonReleaseNotFound(strategy.to_string())); }; // Always install to Huak's toolchain. diff --git a/crates/huak-package-manager/src/ops/toolchain.rs b/crates/huak-package-manager/src/ops/toolchain.rs new file mode 100644 index 00000000..03268339 --- /dev/null +++ b/crates/huak-package-manager/src/ops/toolchain.rs @@ -0,0 +1,675 @@ +use crate::{settings::SettingsDb, Config, Error, HuakResult, Verbosity}; +use huak_home::huak_home_dir; +use huak_python_manager::{ + resolve_release, PythonManager, Release, ReleaseArchitecture, ReleaseBuildConfiguration, + ReleaseKind, ReleaseOption, ReleaseOptions, ReleaseOs, RequestedVersion, Strategy, Version, +}; +use huak_toolchain::{Channel, DescriptorParts, LocalTool, LocalToolchain}; +use sha2::{Digest, Sha256}; +use std::{ + env::consts::OS, + path::{Path, PathBuf}, + process::Command, + str::FromStr, +}; +use termcolor::Color; +use toml_edit::value; + +/// Resolve the target toolchain if a user provides one, otherwise get the current toolchain +/// for the current workspace. If no toolchain is found then emit "error: no toolchain found". +/// Add the user-provided tool to the toolchain. If the tool is +/// already installed to the toolchain, and a version is provided that's different from the +/// installed tool, then replace the installed tool with the desired version. +pub fn add_tool(tool: &LocalTool, channel: Option, config: &Config) -> HuakResult<()> { + let channel = if channel.is_none() { + Some(Channel::Default) + } else { + channel + }; + + // Resolve a toolchain if a channel is provided. Otherwise resolve the curerent. + let toolchain = config + .workspace() + .resolve_local_toolchain(channel.as_ref())?; + + let tool = toolchain.tool(&tool.name); + let args = [ + "-m".to_string(), + "pip".to_string(), + "install".to_string(), + tool.to_string(), + ]; + let py = toolchain.tool("python"); + let py_bin = py_bin(toolchain.downloads().join("python")); + + let mut terminal = config.terminal(); + + let mut cmd = Command::new(py.path); + let cmd = cmd.args(args).current_dir(&config.cwd); + + terminal.print_custom( + "Updating", + format!("adding {} to {}", &tool.name, toolchain.name()), + Color::Green, + true, + )?; + + terminal.set_verbosity(Verbosity::Quiet); + + // terminal.set_verbosity(Verbosity::Quiet); + terminal.run_command(cmd)?; + + toolchain.register_tool_from_path(py_bin.join(&tool.name), &tool.name, false)?; + + terminal.set_verbosity(Verbosity::Normal); + + terminal.print_custom( + "Success", + format!("{} was added to '{}'", &tool.name, toolchain.name()), + Color::Green, + true, + ) +} + +/// Resolve the target toolchain if a user provides one, otherwise get the current toolchain +/// for the current workspace. If no toolchain is found then emit "error: no toolchain found". +/// +/// Display the toolchain's information: +/// +/// Toolchain: +/// Path: +/// Channel: +/// Tools: +/// python () +/// ruff () +/// mypy () +/// pytest () +pub fn toolchain_info(channel: Option<&Channel>, config: &Config) -> HuakResult<()> { + let toolchain = config.workspace().resolve_local_toolchain(channel)?; + + config + .terminal() + .print_without_status(toolchain.info(), Color::White) +} + +/// Resolve and install a toolchain to some target directory using a channel. +pub fn install_toolchain( + channel: Option, + target: Option, + config: &Config, +) -> HuakResult<()> { + // If a toolchain cannot be resolved with a channel or the current config data then the default + // will be installed if it doesn't already exist. + let ws = config.workspace(); + + if let Ok(toolchain) = ws.resolve_local_toolchain(channel.as_ref()) { + return Err(Error::LocalToolchainExists(toolchain.root().clone())); + } + + // If no target path is provided we always install to Huak's toolchain directory + let Some(parent) = target.or(huak_home_dir().map(|it| it.join("toolchains"))) else { + return Err(Error::InternalError( + "target path is invalid or missing".to_string(), + )); + }; + + let channel = channel.unwrap_or_default(); + let channel_string = channel.to_string(); + let path = parent.join(&channel_string); + + if path.exists() { + return Err(Error::LocalToolchainExists(path)); + } + + if let Err(e) = install(path.clone(), channel, config) { + teardown(parent.join(&channel_string), config)?; + Err(e) + } else { + let settings = parent.join("settings.toml"); + let Some(mut db) = SettingsDb::try_from(settings) + .ok() + .or(Some(SettingsDb::default())) + else { + return Err(Error::InternalError( + "failed to create settings db".to_string(), + )); + }; + let table = db.doc_mut().as_table_mut(); + let key = format!("{}", config.cwd.display()); + table["toolchains"][key] = value(format!("{}", path.display())); + + Ok(()) + } +} + +#[allow(clippy::too_many_lines)] +fn install(path: PathBuf, channel: Channel, config: &Config) -> HuakResult<()> { + let mut toolchain = LocalToolchain::new(path); + + toolchain.set_channel(channel); + + let name = toolchain.name(); + + // We'll emit messages to the terminal for each tool installed. + let mut terminal = config.terminal(); + + // Get the tool 'python' from the toolchain. + let py = toolchain.tool("python"); + + // If 'python' is already installed we don't install it. + if py.exists() { + terminal.print_warning(format!( + "Toolchain already exists at {}", + toolchain.bin().display() + ))?; + + return Ok(()); + } + + let root = toolchain.root(); + for p in [root.join("bin"), root.join("downloads"), root.join("venvs")] { + std::fs::create_dir_all(p)?; + } + + // Determine what Python release data to use for the install. + let release = python_release_from_channel(toolchain.channel()).expect("release"); + let release_string = release.to_string(); + + let msg = if matches!(toolchain.channel(), Channel::Default) { + format!("toolchain '{}' ({})", toolchain.name(), release) + } else { + format!("toolchain '{}'", toolchain.name()) + }; + + terminal.print_custom("Installing", msg, Color::Green, true)?; + + // Begin preparing to install 'python'. + terminal.print_custom( + "Preparing", + format!("release validation for {release}"), + Color::Green, + true, + )?; + + // Set up a manager to help with the Python installation process. + let py_manager = PythonManager::new(); + + // Download the release for installation. + let buff = py_manager.download(release)?; + let release_bytes = buff.as_slice(); + + // If the checksum we generate from the downloaded data does not match the checksum we get + // with the toolchain tool then we don't install it. + let checksum = generate_checksum(release_bytes); + if !checksum.eq_ignore_ascii_case(release.checksum) { + return Err(Error::InvalidChecksum(release.to_string())); + } + + terminal.print_custom("Success", format!("verified {release}"), Color::Green, true)?; + terminal.print_custom( + "Downloading", + format!("release from {}", release.url), + Color::Green, + true, + )?; + + // Extract the downloaded release to the toolchain's downloads directory. + let downloads_dir = toolchain.downloads(); + terminal.print_custom( + "Extracting", + format!("{} to {}", release_string, downloads_dir.display()), + Color::Green, + true, + )?; + + // Unpack the encoded archive bytes into the toolchains downloads dir. + py_manager.unpack(release_bytes, &downloads_dir, true)?; + + // Get the path to the unpacked contents. + let py_bin = py_bin(toolchain.downloads().join("python)")); + let py_path = maybe_exe(py_bin.join(format!( + "python{}.{}", + release.version.major, release.version.minor + ))); + + terminal.print_custom("Installing", release_string, Color::Green, true)?; + + // Use the installed python + let py = LocalTool::new(py_path); + + if py.exists() { + terminal.print_custom( + "Preparing", + "toolchain's virtual environment", + Color::Green, + true, + )?; + } else { + return Err(Error::InternalError(format!( + "'{}' could not be found", + py.name + ))); + } + + // Python is used from a dedicated virtual environment. + let from = toolchain.root().join("venvs"); + std::fs::create_dir_all(&from)?; + + let mut cmd: Command = Command::new(py.path); + cmd.current_dir(&from).args(["-m", "venv", &name]); + terminal.run_command(&mut cmd)?; + + let venv = from.join(name); + let path = venv.join(python_bin_name()).join("python"); + + terminal.print_custom( + "Success", + format!("prepared virtual environment for '{}'", toolchain.name()), + Color::Green, + true, + )?; + + terminal.print_custom( + "Updating", + "toolchain bin with python".to_string(), + Color::Green, + true, + )?; + + // Try to link the tool in the bin directory as a proxy. If that fails copy the tool entirely. + if toolchain + .register_tool_from_path(&path, "python", false) + .is_err() + { + if let Err(e) = toolchain.register_tool_from_path(&path, "python", true) { + return Err(Error::HuakToolchainError(e)); + } + } + + terminal.print_custom( + "Success", + format!("installed python to {}", toolchain.bin().display()), + Color::Green, + true, + )?; + + let py = toolchain.tool("python"); + + for name in default_python_tools() { + terminal.set_verbosity(Verbosity::Quiet); + + let mut cmd: Command = Command::new(&py.path); + cmd.current_dir(&config.cwd) + .args(["-m", "pip", "install", name]); + + terminal.run_command(&mut cmd)?; + + // If the python is a symlink then use the bin its linked to. Otherwise use the venv path. + let path = py_bin.join(name); + + // Register the installed python module as a proxy. + toolchain.register_tool_from_path(&path, name, false)?; + + terminal.set_verbosity(Verbosity::Normal); + terminal.print_custom( + "Success", + format!("installed {name} to {}", toolchain.bin().display()), + Color::Green, + true, + )?; + } + + terminal.print_custom( + "Finished", + format!( + "installed '{}' to {}", + toolchain.name(), + toolchain.root().display() + ), + Color::Green, + true, + ) +} + +/// Resolve available toolchains and display their names as a list. Display the following with +/// +/// Current toolchain: +/// +/// Installed toolchains: +/// 1: +/// 2: +/// 3: +pub fn list_toolchains(config: &Config) -> HuakResult<()> { + let mut terminal = config.terminal(); + + if let Ok(current_toolchain) = config.workspace().resolve_local_toolchain(None) { + terminal.print_custom( + "Current:", + current_toolchain.root().display(), + Color::Cyan, + true, + )?; + } + + terminal.print_custom("Installed", "", Color::Green, true)?; + + if let Some(toolchains) = resolve_installed_toolchains(config) { + for (i, toolchain) in toolchains.iter().enumerate() { + config.terminal().print_custom( + format!("{:>5})", i + 1), + format!("{:<16}", toolchain.name()), + Color::Green, + true, + )?; + } + } + + Ok(()) +} + +/// Resolve the target toolchain but don't perform and installs if none can be found. If a toolchain +/// can be resolved (located) then remove the tool. If the tool is not installed to the toolchain then +/// exit silently. +pub fn remove_tool(tool: &LocalTool, channel: Option<&Channel>, config: &Config) -> HuakResult<()> { + if tool.name == "python" { + unimplemented!() + } + + // Resolve a toolchain if a channel is provided. Otherwise resolve the curerent. + let toolchain = config.workspace().resolve_local_toolchain(channel)?; + + let tool = toolchain.tool(&tool.name); + let args = ["-m", "pip", "uninstall", &tool.name, "-y"]; + let py = toolchain.tool("python"); + + let mut terminal = config.terminal(); + + let mut cmd = Command::new(py.path); + let cmd = cmd.args(args).current_dir(&config.cwd); + + terminal.print_custom( + "Updating", + format!("removing {} from '{}'", &tool.name, toolchain.name()), + Color::Green, + true, + )?; + + terminal.set_verbosity(Verbosity::Quiet); + + terminal.run_command(cmd)?; + + remove_path_with_scope(&tool.path, toolchain.root())?; + + terminal.set_verbosity(Verbosity::Normal); + + terminal.print_custom( + "Success", + format!("{} was uninstalled", &tool.name), + Color::Green, + true, + ) +} + +/// Resolve the target toolchain but don't perform and installs if none can be found. If a toolchain +/// can be resolved (located) then run the tool. If the tool is not installed to the toolchain then +/// emit "error: a problem occurred running a tool: {tool} is not installed" +pub fn run_tool( + tool: &LocalTool, + channel: Option<&Channel>, + trailing: Option>, + config: &Config, +) -> HuakResult<()> { + let ws = config.workspace(); + + let toolchain = ws.resolve_local_toolchain(channel)?; + + run( + toolchain.tool(&tool.name), + trailing.unwrap_or_default().as_slice(), + config, + ) +} + +fn run(tool: LocalTool, args: &[String], config: &Config) -> HuakResult<()> { + let mut terminal = config.terminal(); + let mut cmd: Command = Command::new(tool.path); + cmd.args(args).current_dir(&config.cwd); + terminal.run_command(&mut cmd) +} + +/// Resolve the target toolchain but don't perform and installs if none can be found. If a toolchain +/// can be resolved (located) then uninstall it. +pub fn uninstall_toolchain(channel: Option<&Channel>, config: &Config) -> HuakResult<()> { + let toolchain = config.workspace().resolve_local_toolchain(channel)?; + + let mut terminal = config.terminal(); + + terminal.print_custom( + "Updating", + format!( + "uninstalling '{}' ({})", + toolchain.name(), + toolchain.root().display() + ), + Color::Green, + true, + )?; + + // TODO: Outside home + remove_path_with_scope(toolchain.root(), config.home.as_ref().expect("huak home"))?; + + if let Some(parent) = toolchain.root().parent() { + let settings = parent.join("settings.toml"); + + let Some(mut db) = SettingsDb::try_from(settings) + .ok() + .or(Some(SettingsDb::default())) + else { + return Err(Error::InternalError( + "failed to create settings db".to_string(), + )); + }; + let table = db.doc_mut().as_table_mut(); + let key = format!("{}", config.cwd.display()); + table.remove(&key); + } + + terminal.print_custom("Success", "toolchain uninstalled", Color::Green, true) +} + +/// Resolve the target toolchain but don't perform and installs if none can be found. If a toolchain +/// can be resolved (located) then attempt to update its tools according to its channel. If the channel +/// is version-defined without a patch number then install the latest released Python for that channel. +/// Update the rest of the tools in the toolchain. +pub fn update_toolchain( + tool: Option, + channel: Option<&Channel>, + config: &Config, +) -> HuakResult<()> { + // Resolve a toolchain if a channel is provided. Otherwise resolve the curerent. + let toolchain = config.workspace().resolve_local_toolchain(channel)?; + + let mut terminal = config.terminal(); + let tools = if let Some(it) = tool { + vec![it] + } else { + toolchain + .tools() + .into_iter() + .filter(|it| it.name != "python") + .chain([LocalTool { + name: "pip".to_string(), + path: toolchain.bin().join("pip"), + }]) + .collect() + }; + + let py = toolchain.tool("python"); + + let args = ["-m", "pip", "install", "--upgrade"]; + for tool in tools { + let mut cmd = Command::new(&py.path); + + terminal.print_custom("Updating", &tool.name, Color::Green, true)?; + terminal.set_verbosity(Verbosity::Quiet); + + cmd.args(args.iter().chain([&tool.name.as_str()])) + .current_dir(&config.cwd); + + terminal.run_command(&mut cmd)?; + + terminal.set_verbosity(Verbosity::Normal); + } + + terminal.print_custom("Success", "finished updating", Color::Green, true) +} + +pub fn use_toolchain(_channel: &Channel, _config: &Config) -> HuakResult<()> { + // Resolve the target toolchain if a user provides one, otherwise get the current toolchain + // for the current workspace. If none can be found then install and use the default toolchain. + todo!() +} + +fn resolve_installed_toolchains(config: &Config) -> Option> { + let Some(home) = config.home.clone() else { + return None; + }; + + let Ok(toolchains) = std::fs::read_dir(home.join("toolchains")) else { + return None; + }; + + let mut chains = Vec::new(); + + for entry in toolchains.flatten() { + let p = entry.path(); + + if p.is_dir() && p.parent().map_or(false, |it| it == home.join("toolchains")) { + chains.push(LocalToolchain::new(p)); + } + } + + Some(chains) +} + +fn generate_checksum(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + + hex::encode(hasher.finalize()) +} + +fn python_release_from_channel(channel: &Channel) -> Option> { + let options = match channel { + Channel::Default => ReleaseOptions::default(), // TODO(cnpryer): Is there ever a case where channel default doesn't yield python default? + Channel::Version(version) => release_options_from_version(*version), + Channel::Descriptor(descriptor) => release_options_from_descriptor(descriptor), + }; + + resolve_release(&Strategy::Selection(options)) +} + +fn release_options_from_descriptor(descriptor: &DescriptorParts) -> ReleaseOptions { + let desc = descriptor.clone(); + let kind = desc.kind.unwrap_or(ReleaseKind::default().to_string()); + let os = desc.os.unwrap_or(ReleaseOs::default().to_string()); + let architecture = desc + .architecture + .unwrap_or(ReleaseArchitecture::default().to_string()); + let build_configuration = desc + .build_configuration + .unwrap_or(ReleaseBuildConfiguration::default().to_string()); + + ReleaseOptions { + kind: ReleaseOption::from_str(&kind).ok(), + version: desc.version.map(|it| { + ReleaseOption::Version(RequestedVersion { + major: it.major, + minor: it.minor, + patch: it.patch, + }) + }), + os: ReleaseOption::from_str(&os).ok(), + architecture: ReleaseOption::from_str(&architecture).ok(), + build_configuration: ReleaseOption::from_str(&build_configuration).ok(), + } +} + +fn release_options_from_version(version: Version) -> ReleaseOptions { + ReleaseOptions { + kind: Some(ReleaseOption::Kind(ReleaseKind::default())), + version: Some(ReleaseOption::Version(RequestedVersion { + major: version.major, + minor: version.minor, + patch: version.patch, + })), + os: Some(ReleaseOption::Os(ReleaseOs::default())), + architecture: Some(ReleaseOption::Architecture(ReleaseArchitecture::default())), + build_configuration: Some(ReleaseOption::BuildConfiguration( + ReleaseBuildConfiguration::default(), + )), + } +} + +fn python_bin_name() -> &'static str { + match OS { + "windows" => "Scripts", + _ => "bin", + } +} + +fn py_bin>(root: T) -> PathBuf { + root.as_ref().join("install").join(python_bin_name()) +} + +// TODO: Refactor +fn maybe_exe(path: PathBuf) -> PathBuf { + if OS == "windows" && path.extension().map_or(false, |it| it == "exe") { + path.with_extension("exe") + } else { + path + } +} + +fn default_python_tools() -> [&'static str; 3] { + ["ruff", "pytest", "mypy"] +} + +fn teardown>(path: T, config: &Config) -> HuakResult<()> { + let path = path.as_ref(); + + if let Some(home) = config.home.as_ref() { + remove_path_with_scope(path, home) + } else { + Ok(()) + } +} + +fn remove_path_with_scope(path: T, root: R) -> HuakResult<()> +where + T: AsRef, + R: AsRef, +{ + let path = path.as_ref(); + let root = root.as_ref(); + + let mut stack = vec![path.to_path_buf()]; + + while let Some(mut p) = stack.pop() { + p.pop(); + + if p == root { + if p.is_dir() { + std::fs::remove_dir_all(path)?; + return Ok(()); + } else if p.is_file() { + std::fs::remove_file(path)?; + return Ok(()); + } + } else { + stack.push(p); + } + } + + Ok(()) +} diff --git a/crates/huak-package-manager/src/settings.rs b/crates/huak-package-manager/src/settings.rs new file mode 100644 index 00000000..20bc3be6 --- /dev/null +++ b/crates/huak-package-manager/src/settings.rs @@ -0,0 +1,34 @@ +//! This module implements read and write functionality for Huak's persisted application data. +use crate::HuakResult; +use std::path::Path; +use toml_edit::Document; + +#[derive(Default)] +pub struct SettingsDb { + doc: Document, +} + +impl SettingsDb { + pub fn new(doc: Document) -> Self { + Self { doc } + } + + pub fn doc(&self) -> &Document { + &self.doc + } + + pub fn doc_mut(&mut self) -> &mut Document { + &mut self.doc + } + + pub fn try_from>(path: T) -> HuakResult { + Ok(SettingsDb::new(read_settings_file(path)?)) + } +} + +/// A helper for reading the contents of a settings.toml file. +pub(crate) fn read_settings_file>(path: T) -> HuakResult { + let doc = std::str::from_utf8(std::fs::read(path)?.as_slice())?.parse::()?; + + Ok(doc) +} diff --git a/crates/huak-package-manager/src/sys.rs b/crates/huak-package-manager/src/sys.rs index 8ee2b5fe..9b6c23af 100644 --- a/crates/huak-package-manager/src/sys.rs +++ b/crates/huak-package-manager/src/sys.rs @@ -84,7 +84,7 @@ impl Terminal { /// Print an error message. pub fn print_error(&mut self, message: T) -> HuakResult<()> { self.output - .message_stderr(&"error", Some(&message), Color::Red, false) + .message_stderr_with_status(&"error", Some(&message), Color::Red, false) } /// Prints a warning message. @@ -110,6 +110,17 @@ impl Terminal { self.print(&title, Some(&message), color, justified) } + /// Prints a message without a status. + pub fn print_without_status(&mut self, message: T, color: Color) -> HuakResult<()> + where + T: Display, + { + match self.options.verbosity { + Verbosity::Quiet => Ok(()), + _ => self.output.message_stderr(Some(&message), color), + } + } + /// Prints a message, where the status will have `color` color, and can be justified. /// The messages follows without color. /// @@ -126,7 +137,7 @@ impl Terminal { Verbosity::Quiet => Ok(()), _ => self .output - .message_stderr(status, message, color, justified), + .message_stderr_with_status(status, message, color, justified), } } @@ -245,7 +256,7 @@ impl TerminalOut { /// Prints out a message with a status. The status comes first, and is bold plus /// the given color. The status can be justified, in which case the max width that /// will right align is `DEFAULT_MESSAGE_JUSTIFIED_CHARS` chars. - fn message_stderr( + fn message_stderr_with_status( &mut self, status: &dyn Display, message: Option<&dyn Display>, @@ -257,7 +268,7 @@ impl TerminalOut { stderr.reset()?; stderr.set_color(ColorSpec::new().set_bold(true).set_fg(Some(color)))?; if justified { - write!(stderr, "{status:>12}")?; + write!(stderr, " {status:>11}")?; } else { write!(stderr, "{status}")?; stderr.set_color(ColorSpec::new().set_bold(true))?; @@ -281,6 +292,28 @@ impl TerminalOut { } Ok(()) } + + fn message_stderr( + &mut self, + message: Option<&dyn Display>, + color: termcolor::Color, + ) -> HuakResult<()> { + match *self { + TerminalOut::Stream { ref mut stderr, .. } => { + stderr.reset()?; + stderr.set_color(ColorSpec::new().set_bold(true).set_fg(Some(color)))?; + match message { + Some(message) => writeln!(stderr, "{message}")?, + None => write!(stderr, " ")?, + } + } + TerminalOut::Simple { ref mut stderr, .. } => match message { + Some(message) => writeln!(stderr, "{message}")?, + None => write!(stderr, " ")?, + }, + } + Ok(()) + } } /// Gets the name of the current shell. diff --git a/crates/huak-package-manager/src/workspace.rs b/crates/huak-package-manager/src/workspace.rs index 6be043a3..3e70906a 100644 --- a/crates/huak-package-manager/src/workspace.rs +++ b/crates/huak-package-manager/src/workspace.rs @@ -1,4 +1,5 @@ use crate::package::Package; +use crate::settings::SettingsDb; use crate::{ environment::Environment, fs, @@ -6,7 +7,9 @@ use crate::{ python_environment::{default_venv_name, venv_config_file_name}, Config, Error, HuakResult, PythonEnvironment, }; +use huak_toolchain::{Channel, LocalToolchain, LocalToolchainResolver}; use std::{path::PathBuf, process::Command}; +use toml_edit::{Item, Value}; /// The `Workspace` is a struct for resolving things like the current `Package` /// or the current `PythonEnvironment`. It can also provide a snapshot of the `Environment`, @@ -118,6 +121,18 @@ impl Workspace { Ok(python_env) } + + /// Get the current toolchain. The current toolchain is found by: + /// 1. `HUAK_TOOLCHAIN` environment variable + /// 2. [tool.huak.toolchain] pyproject.toml configuration + /// 3. ~/.huak/settings.toml configuration + pub fn resolve_local_toolchain(&self, channel: Option<&Channel>) -> HuakResult { + let Some(it) = resolve_local_toolchain(self, channel) else { + return Err(Error::HuakToolchainNotFound); + }; + + Ok(it) + } } /// A struct used to configure options for `Workspace`s. @@ -189,3 +204,60 @@ pub fn find_package_root>(from: T, stop_after: T) -> HuakResult Ok(root) } + +// TODO(cnpryer): Channel must be compatible with HUAK_TOOLCHAIN if found +fn resolve_local_toolchain( + workspace: &Workspace, + channel: Option<&Channel>, +) -> Option { + let config = &workspace.config; + + let Some(home) = config.home.as_ref() else { + return None; + }; + + let toolchains = home.join("toolchains"); + let settings = toolchains.join("settings.toml"); + + // Use an environment variable if it's active. + if let Ok(path) = std::env::var("HUAK_TOOLCHAIN").map(PathBuf::from) { + if path.exists() { + return Some(LocalToolchain::new(path)); + } + } + + // If a channel is provided then search for it from huak's toolchain directory. + if let Some(channel) = channel.as_ref() { + let resolver = LocalToolchainResolver::new(); + return resolver.from_dir(channel, toolchains); + } + + // Use workspace project metadata and return if a toolchain is listed. + if let Ok(metadata) = workspace.current_local_metadata() { + if let Some(table) = metadata.metadata().tool().and_then(|it| it.get("huak")) { + if let Some(path) = table + .get("toolchain") + .map(std::string::ToString::to_string) + .map(PathBuf::from) + { + if path.exists() { + return Some(LocalToolchain::new(path)); + } + }; + }; + }; + + // Attempt to retrieve the toolchain for the current workspace scope. + if let Some(table) = SettingsDb::try_from(settings) + .ok() + .as_ref() + .and_then(|db| db.doc().as_table()["scopes"].as_table()) + { + if let Some(Item::Value(Value::String(s))) = table.get(&format!("{}", config.cwd.display())) + { + return Some(LocalToolchain::new(PathBuf::from(s.to_string()))); + } + } + + None +} diff --git a/crates/huak-python-manager/Cargo.toml b/crates/huak-python-manager/Cargo.toml index a60063d2..41535981 100644 --- a/crates/huak-python-manager/Cargo.toml +++ b/crates/huak-python-manager/Cargo.toml @@ -11,12 +11,12 @@ license.workspace = true anyhow = "1.0.75" clap.workspace = true colored.workspace = true -hex = "0.4.3" +hex.workspace = true human-panic.workspace = true lazy_static.workspace = true regex.workspace = true reqwest = { version = "0.11.22", features = ["blocking", "json"] } -sha2 = "0.10.8" +sha2.workspace = true tar = "0.4.40" thiserror.workspace = true zstd = "0.13.0" diff --git a/crates/huak-python-manager/scripts/generate_python_releases.py b/crates/huak-python-manager/scripts/generate_python_releases.py index bba81954..449164f8 100644 --- a/crates/huak-python-manager/scripts/generate_python_releases.py +++ b/crates/huak-python-manager/scripts/generate_python_releases.py @@ -161,6 +161,16 @@ def get_checksum(url: str) -> str | None: } } +impl Display for Release<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}-{}-{}-{}-{}", + self.kind, self.version, self.os, self.architecture, self.build_configuration + ) + } +} + """ path = ROOT / "crates" / CRATE / "src" / "releases.rs" diff --git a/crates/huak-python-manager/src/cli.rs b/crates/huak-python-manager/src/cli.rs index d45d8486..3e7a56e4 100644 --- a/crates/huak-python-manager/src/cli.rs +++ b/crates/huak-python-manager/src/cli.rs @@ -39,21 +39,19 @@ enum Commands { } mod cmd { - use std::path::PathBuf; - - use super::{Error, RequestedVersion}; + use super::{Error, PathBuf, RequestedVersion}; use anyhow::Context; - use huak_python_manager::{install_with_target, resolve_release, Options, Strategy}; + use huak_python_manager::{ + install_with_target, release_options_from_requested_version, resolve_release, Strategy, + }; pub(crate) fn install(version: RequestedVersion, target: PathBuf) -> Result<(), Error> { println!("installing Python {version}..."); - let strategy = Strategy::Selection(Options { - version: Some(version), - ..Default::default() - }); - - let release = resolve_release(&strategy).context("requested release data")?; + let release = resolve_release(&Strategy::Selection( + release_options_from_requested_version(version)?, + )) + .context("requested release data")?; install_with_target(&release, target).context("failed to install with target") } diff --git a/crates/huak-python-manager/src/error.rs b/crates/huak-python-manager/src/error.rs index f1dbf394..b217cfaf 100644 --- a/crates/huak-python-manager/src/error.rs +++ b/crates/huak-python-manager/src/error.rs @@ -6,10 +6,12 @@ use thiserror::Error as ThisError; #[allow(clippy::enum_variant_names)] #[derive(ThisError, Debug)] pub enum Error { - #[error("a problem occurred attempting to parse a requested version: {0}")] - ParseRequestedVersionError(String), + #[error("a release option is invalid: {0}")] + InvalidReleaseOption(String), #[error("a version is invalid: {0}")] InvalidVersion(String), + #[error("a problem occurred attempting to parse a requested version: {0}")] + ParseRequestedVersionError(String), #[error("a problem occurred with a request: {0}")] RequestError(String), #[error("a problem with reqwest occurred: {0}")] diff --git a/crates/huak-python-manager/src/install.rs b/crates/huak-python-manager/src/install.rs index 5408a255..a0aa28f7 100644 --- a/crates/huak-python-manager/src/install.rs +++ b/crates/huak-python-manager/src/install.rs @@ -19,7 +19,7 @@ pub fn install_with_target>(release: &Release, target: T) -> Re .map_err(|e| Error::TarError(e.to_string())) } -fn download_release(release: &Release) -> Result, Error> { +pub(crate) fn download_release(release: &Release) -> Result, Error> { let mut response = reqwest::blocking::get(release.url)?; if !response.status().is_success() { @@ -35,7 +35,7 @@ fn download_release(release: &Release) -> Result, Error> { Ok(contents) } -fn validate_checksum(bytes: &[u8], checksum: &str) -> Result<(), Error> { +pub(crate) fn validate_checksum(bytes: &[u8], checksum: &str) -> Result<(), Error> { let mut hasher = Sha256::new(); hasher.update(bytes); diff --git a/crates/huak-python-manager/src/lib.rs b/crates/huak-python-manager/src/lib.rs index a0333d94..c9d6d755 100644 --- a/crates/huak-python-manager/src/lib.rs +++ b/crates/huak-python-manager/src/lib.rs @@ -39,11 +39,55 @@ pub use crate::error::Error; pub use crate::install::install_with_target; -pub use crate::resolve::{resolve_release, Options, RequestedVersion, Strategy}; +pub use crate::resolve::{ + release_options_from_requested_version, resolve_release, ReleaseArchitecture, + ReleaseBuildConfiguration, ReleaseKind, ReleaseOption, ReleaseOptions, ReleaseOs, + RequestedVersion, Strategy, +}; pub use crate::version::Version; +use install::download_release; +pub use releases::Release; +use std::path::Path; +use tar::Archive; +use zstd::stream::read::Decoder; mod error; mod install; mod releases; mod resolve; mod version; + +// A simple API for managing Python installs. +pub struct PythonManager; + +impl Default for PythonManager { + fn default() -> Self { + Self::new() + } +} + +impl PythonManager { + #[must_use] + pub fn new() -> Self { + Self + } + + pub fn download(&self, release: Release<'static>) -> Result, Error> { + download_release(&release) + } + + pub fn unpack>(&self, bytes: &[u8], to: T, decode: bool) -> Result<(), Error> { + if decode { + let decoded = + Decoder::with_buffer(bytes).map_err(|e| Error::ZstdError(e.to_string()))?; + let mut archive = Archive::new(decoded); + + // TODO(cnpryer): Support more archive formats. + archive + .unpack(to) + .map_err(|e| Error::TarError(e.to_string())) + } else { + todo!() + } + } +} diff --git a/crates/huak-python-manager/src/releases.rs b/crates/huak-python-manager/src/releases.rs index e304db16..b15e7278 100644 --- a/crates/huak-python-manager/src/releases.rs +++ b/crates/huak-python-manager/src/releases.rs @@ -1,5 +1,7 @@ //! This file was generated with `generate_python_releases.py`. +use std::fmt::Display; + use crate::Version; // TODO(cnpryer): Perf @@ -511,3 +513,13 @@ impl Release<'static> { } } } + +impl Display for Release<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}-{}-{}-{}-{}", + self.kind, self.version, self.os, self.architecture, self.build_configuration + ) + } +} diff --git a/crates/huak-python-manager/src/resolve.rs b/crates/huak-python-manager/src/resolve.rs index 26400c85..ddf357c4 100644 --- a/crates/huak-python-manager/src/resolve.rs +++ b/crates/huak-python-manager/src/resolve.rs @@ -13,19 +13,25 @@ use std::{ #[must_use] pub fn resolve_release(strategy: &Strategy) -> Option> { match strategy { - Strategy::Latest => resolve_release_with_options(&Options::default()), + Strategy::Default => resolve_release_with_options(&ReleaseOptions::default()), Strategy::Selection(options) => resolve_release_with_options(options), } } -fn resolve_release_with_options(options: &Options) -> Option> { +fn resolve_release_with_options(options: &ReleaseOptions) -> Option> { let mut candidates = RELEASES .iter() .filter(|it| { - it.kind == options.kind - && it.os == options.os - && it.architecture == options.architecture - && it.build_configuration == options.build_configuration + options.kind.as_ref().map_or(false, |a| a.eq_str(it.kind)) + && options.os.as_ref().map_or(false, |a| a.eq_str(it.os)) + && options + .architecture + .as_ref() + .map_or(false, |a| a.eq_str(it.architecture)) + && options + .build_configuration + .as_ref() + .map_or(false, |a| a.eq_str(it.build_configuration)) }) .collect::>(); @@ -35,7 +41,7 @@ fn resolve_release_with_options(options: &Options) -> Option> { // Sort releases by version in descending order (latest releases at the beginning of the vector) candidates.sort_by(|a, b| b.version.cmp(&a.version)); - if let Some(req) = options.version.as_ref() { + if let Some(ReleaseOption::Version(req)) = options.version.as_ref() { candidates .into_iter() .find(|it| req.matches_version(&it.version)) @@ -48,46 +54,229 @@ fn resolve_release_with_options(options: &Options) -> Option> { /// The strategy used for resolving a Python releases. #[derive(Default)] -pub enum Strategy<'a> { +pub enum Strategy { #[default] - /// Resolve with the latest possible Python release version for the current environment. - Latest, + /// The default resolved release is the latest minor release offset by 1 (for example if the latest + /// minor release available is 3.12 the default is 3.11). + Default, /// `Selection` - Use some selection criteria to determine the Python release. Unused /// options criteria will resolve to *best possible defaults*. - Selection(Options<'a>), + Selection(ReleaseOptions), +} + +impl Display for Strategy { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Strategy::Default => write!(f, "default"), + Strategy::Selection(options) => write!(f, "{options:?}"), + } + } } /// Options criteria used for resolving Python releases. #[derive(Debug)] -pub struct Options<'a> { - pub kind: &'a str, - pub version: Option, // TODO(cnpryer): Refactor to default as *latest available* - pub os: &'a str, - pub architecture: &'a str, - pub build_configuration: &'a str, +pub struct ReleaseOptions { + pub kind: Option, + pub version: Option, // TODO(cnpryer): Refactor to default as *latest available* + pub os: Option, + pub architecture: Option, + pub build_configuration: Option, +} + +pub fn release_options_from_requested_version( + version: RequestedVersion, +) -> Result { + Ok(ReleaseOptions { + kind: Some(ReleaseOption::Kind(ReleaseKind::default())), + version: Some(ReleaseOption::Version(version)), + os: Some(ReleaseOption::Os(ReleaseOs::default())), + architecture: Some(ReleaseOption::Architecture(ReleaseArchitecture::default())), + build_configuration: Some(ReleaseOption::BuildConfiguration( + ReleaseBuildConfiguration::default(), + )), + }) } // TODO(cnpryer): Refactor -impl Default for Options<'static> { +impl Default for ReleaseOptions { fn default() -> Self { Self { - kind: "cpython", - version: Option::default(), - os: match OS { - "macos" => "apple", - "windows" => "windows", - _ => "linux", - }, - architecture: match ARCH { - "x86_64" => "x86_64", - "aarch64" => "aarch64", - "x86" => "i686", // TODO(cnpryer): Need to look at other windows releases. - _ => unimplemented!(), - }, - build_configuration: match OS { - "windows" => "pgo", - _ => "pgo+lto", - }, + kind: Some(ReleaseOption::Kind(ReleaseKind::CPython)), + version: None, + os: Some(ReleaseOption::Os(ReleaseOs::default())), + architecture: Some(ReleaseOption::Architecture(ReleaseArchitecture::default())), + build_configuration: Some(ReleaseOption::BuildConfiguration( + ReleaseBuildConfiguration::default(), + )), + } + } +} + +/// # Options +/// +/// ## Kind +/// - "cpython" +/// +/// ## Version +/// - major.minor.patch +/// - major.minor +/// +/// ## Os +/// - "apple" +/// - "linux" +/// - "windows" +/// +/// ## Architecture +/// - "`x86_64`" +/// - "aarch64" +/// - "i686" +/// +/// ## Build Configuration +/// - "pgo+lto" +/// - "pgo +#[derive(Debug, Clone)] +pub enum ReleaseOption { + Kind(ReleaseKind), + Version(RequestedVersion), + Os(ReleaseOs), + Architecture(ReleaseArchitecture), + BuildConfiguration(ReleaseBuildConfiguration), +} + +impl ReleaseOption { + fn eq_str(&self, s: &str) -> bool { + match self { + Self::Kind(ReleaseKind::CPython) if s == "cpython" => true, + Self::Os(ReleaseOs::Apple) if s == "apple" => true, // TODO(cnpryer): Could handle macos, etc. here + Self::Os(ReleaseOs::Linux) if s == "linux" => true, + Self::Os(ReleaseOs::Windows) if s == "windows" => true, + Self::Architecture(ReleaseArchitecture::X86_64) if s == "x86_64" => true, + Self::Architecture(ReleaseArchitecture::Aarch64) if s == "aarch64" => true, + Self::Architecture(ReleaseArchitecture::I686) if s == "i686" => true, + Self::BuildConfiguration(ReleaseBuildConfiguration::PgoPlusLto) if s == "pgo+lto" => { + true + } + Self::BuildConfiguration(ReleaseBuildConfiguration::Pgo) if s == "pgo" => true, + _ => false, + } + } +} + +impl FromStr for ReleaseOption { + type Err = Error; + + fn from_str(s: &str) -> Result { + let option = match s { + "cpython" => ReleaseOption::Kind(ReleaseKind::CPython), + "apple" => ReleaseOption::Os(ReleaseOs::Apple), + "linux" => ReleaseOption::Os(ReleaseOs::Linux), + "windows" => ReleaseOption::Os(ReleaseOs::Windows), + "x86_64" => ReleaseOption::Architecture(ReleaseArchitecture::X86_64), + "aarch64" => ReleaseOption::Architecture(ReleaseArchitecture::Aarch64), + "i686" => ReleaseOption::Architecture(ReleaseArchitecture::I686), + "pgo+lto" => ReleaseOption::BuildConfiguration(ReleaseBuildConfiguration::PgoPlusLto), + "pgo" => ReleaseOption::BuildConfiguration(ReleaseBuildConfiguration::Pgo), + _ => ReleaseOption::Version(RequestedVersion::from_str(s)?), + }; + + Ok(option) + } +} + +#[derive(Debug, Clone, Default)] +pub enum ReleaseKind { + #[default] + CPython, +} + +impl Display for ReleaseKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReleaseKind::CPython => write!(f, "cpython"), + } + } +} + +#[derive(Debug, Clone)] +pub enum ReleaseOs { + Apple, + Linux, + Windows, + Unknown, +} + +impl Default for ReleaseOs { + fn default() -> Self { + match OS { + "macos" => ReleaseOs::Apple, + "windows" => ReleaseOs::Windows, + "linux" => ReleaseOs::Linux, // TODO(cnpryer) + _ => ReleaseOs::Unknown, + } + } +} + +impl Display for ReleaseOs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReleaseOs::Apple => write!(f, "apple"), + ReleaseOs::Linux => write!(f, "linux"), + ReleaseOs::Windows => write!(f, "windows"), + ReleaseOs::Unknown => write!(f, "unknown"), + } + } +} + +#[derive(Debug, Clone)] +pub enum ReleaseArchitecture { + X86_64, + Aarch64, + I686, + Unknown, +} + +impl Default for ReleaseArchitecture { + fn default() -> Self { + match ARCH { + "x86_64" => ReleaseArchitecture::X86_64, + "aarch64" => ReleaseArchitecture::Aarch64, + "i686" => ReleaseArchitecture::I686, + _ => ReleaseArchitecture::Unknown, + } + } +} + +impl Display for ReleaseArchitecture { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReleaseArchitecture::X86_64 => write!(f, "x86_64"), + ReleaseArchitecture::Aarch64 => write!(f, "aarch64"), + ReleaseArchitecture::I686 => write!(f, "i686"), + ReleaseArchitecture::Unknown => write!(f, "unknown"), + } + } +} + +#[derive(Debug, Clone)] +pub enum ReleaseBuildConfiguration { + PgoPlusLto, + Pgo, +} + +impl Default for ReleaseBuildConfiguration { + fn default() -> Self { + match OS { + "windows" => ReleaseBuildConfiguration::Pgo, + _ => ReleaseBuildConfiguration::PgoPlusLto, + } + } +} + +impl Display for ReleaseBuildConfiguration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReleaseBuildConfiguration::PgoPlusLto => write!(f, "pgo+lto"), + ReleaseBuildConfiguration::Pgo => write!(f, "pgo"), } } } @@ -111,6 +300,20 @@ impl RequestedVersion { } } +impl From for RequestedVersion { + fn from(value: Version) -> Self { + requested_version_from_version(value) + } +} + +fn requested_version_from_version(version: Version) -> RequestedVersion { + RequestedVersion { + major: version.major, + minor: version.minor, + patch: version.patch, + } +} + impl FromStr for RequestedVersion { type Err = Error; @@ -159,20 +362,20 @@ mod tests { #[test] fn test_latest() { - let latest_default = resolve_release_with_options(&Options::default()).unwrap(); - let resolved_release = resolve_release(&Strategy::Latest).unwrap(); + let latest_default = resolve_release_with_options(&ReleaseOptions::default()).unwrap(); + let resolved_release = resolve_release(&Strategy::Default).unwrap(); assert_eq!(resolved_release, latest_default); } #[test] fn test_selection() { - let resolved_release = resolve_release(&Strategy::Selection(Options { - kind: "cpython", - version: Some(RequestedVersion::from_str("3.8").unwrap()), - os: "apple", - architecture: "aarch64", - build_configuration: "pgo+lto", + let resolved_release = resolve_release(&Strategy::Selection(ReleaseOptions { + kind: ReleaseOption::from_str("cpython").ok(), + version: ReleaseOption::from_str("3.8").ok(), + os: ReleaseOption::from_str("apple").ok(), + architecture: ReleaseOption::from_str("aarch64").ok(), + build_configuration: ReleaseOption::from_str("pgo+lto").ok(), })) .unwrap(); diff --git a/crates/huak-python-manager/src/version.rs b/crates/huak-python-manager/src/version.rs index 1b878faa..d163f850 100644 --- a/crates/huak-python-manager/src/version.rs +++ b/crates/huak-python-manager/src/version.rs @@ -1,9 +1,8 @@ +use crate::error::Error; use lazy_static::lazy_static; use regex::{Captures, Regex}; use std::{cmp::Ordering, fmt::Display, str::FromStr}; -use crate::error::Error; - lazy_static! { static ref VERSION_REGEX: Regex = Regex::new(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?$").expect("version regex"); diff --git a/crates/huak-toolchain/Cargo.toml b/crates/huak-toolchain/Cargo.toml new file mode 100644 index 00000000..9c9c896d --- /dev/null +++ b/crates/huak-toolchain/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "huak-toolchain" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +huak-python-manager = { path = "../huak-python-manager" } +pep440_rs.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/huak-toolchain/README.md b/crates/huak-toolchain/README.md new file mode 100644 index 00000000..42c8fdd7 --- /dev/null +++ b/crates/huak-toolchain/README.md @@ -0,0 +1,3 @@ +# Toolchain + +The toolchain implementation for Huak. \ No newline at end of file diff --git a/crates/huak-toolchain/src/channel.rs b/crates/huak-toolchain/src/channel.rs new file mode 100644 index 00000000..5495e074 --- /dev/null +++ b/crates/huak-toolchain/src/channel.rs @@ -0,0 +1,78 @@ +use huak_python_manager::Version; +use std::{fmt::Display, str::FromStr}; + +use crate::Error; + +#[derive(Default, Clone, Debug)] +pub enum Channel { + #[default] + Default, + Version(Version), + Descriptor(DescriptorParts), +} + +/// Parse `Channel` from strings. This is useful for parsing channel inputs for applications implementing CLI. +impl FromStr for Channel { + type Err = crate::Error; + + fn from_str(s: &str) -> Result { + if s == "default" { + return Ok(Self::Default); + } + + let Ok(version) = Version::from_str(s) else { + return Err(Error::ParseChannelError(s.to_string())); + }; + + Ok(Channel::Version(version)) + } +} + +impl Display for Channel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Channel::Default => write!(f, "default"), + Channel::Version(version) => write!(f, "{version}"), + Channel::Descriptor(desc) => write!(f, "{desc}"), + } + } +} + +// Right now this is just a dynamic struct of `Release` data. +#[derive(Clone, Debug)] +pub struct DescriptorParts { + pub kind: Option, + pub version: Option, + pub os: Option, + pub architecture: Option, + pub build_configuration: Option, +} + +impl Display for DescriptorParts { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Only allocate enough for `DescriptorParts` data. + let mut parts = Vec::with_capacity(5); + + if let Some(kind) = &self.kind { + parts.push(kind.to_string()); + } + + if let Some(version) = &self.version { + parts.push(format!("{version}")); + } + + if let Some(os) = &self.os { + parts.push(os.to_string()); + } + + if let Some(architecture) = &self.architecture { + parts.push(architecture.to_string()); + } + + if let Some(build_config) = &self.build_configuration { + parts.push(build_config.to_string()); + } + + write!(f, "{}", parts.join("-")) + } +} diff --git a/crates/huak-toolchain/src/error.rs b/crates/huak-toolchain/src/error.rs new file mode 100644 index 00000000..e0dc6b20 --- /dev/null +++ b/crates/huak-toolchain/src/error.rs @@ -0,0 +1,24 @@ +use std::path::PathBuf; +use thiserror::Error as ThisError; + +#[derive(ThisError, Debug)] +pub enum Error { + #[error("a problem occurred attempting to link a file: {0}")] + FileLinkFailure(String), + #[error("a file could not be found: {0}")] + FileNotFound(PathBuf), + #[error("a problem occurred due to an invalid toolchain: {0}")] + InvalidToolchain(String), + #[error("{0}")] + IoError(#[from] std::io::Error), + #[error("a problem occurred attempting to parse a channel: {0}")] + ParseChannelError(String), + #[error("a problem occurred attempting to install python {0}")] + PythonInstallationError(String), + #[error("{0}")] + PythonManagerError(#[from] huak_python_manager::Error), + #[error("a local tool could not be found: {0}")] + LocalToolNotFound(PathBuf), + #[error("a toolchain already exists: {0}")] + LocalToolchainExistsError(PathBuf), +} diff --git a/crates/huak-toolchain/src/install.rs b/crates/huak-toolchain/src/install.rs new file mode 100644 index 00000000..8dfce361 --- /dev/null +++ b/crates/huak-toolchain/src/install.rs @@ -0,0 +1,170 @@ +use crate::{channel::DescriptorParts, Channel, Error, LocalToolchain}; +use huak_python_manager::{ + install_with_target as install_python_with_target, release_options_from_requested_version, + resolve_release as resolve_python_release, ReleaseOption, ReleaseOptions, RequestedVersion, + Strategy as PythonStrategy, +}; +#[cfg(unix)] +use std::os::unix::fs::symlink; +#[cfg(windows)] +use std::os::windows::fs::symlink_file; +use std::{ + env::consts::OS, + fs::{self, hard_link, read_link}, + path::{Path, PathBuf}, + str::FromStr, +}; + +pub fn install_toolchain_with_target( + toolchain: &LocalToolchain, + target: &PathBuf, +) -> Result<(), Error> { + if target.exists() { + Err(Error::LocalToolchainExistsError(target.clone())) + } else { + setup_toolchain(toolchain, target) + } +} + +fn setup_toolchain(toolchain: &LocalToolchain, path: &PathBuf) -> Result<(), Error> { + fs::create_dir_all(path)?; + + let downloads = path.join("downloads"); + fs::create_dir_all(&downloads)?; + + let bin = path.join("bin"); + fs::create_dir_all(&bin)?; + + // Get the path to the installed interpreter. + let py_path = maybe_exe( + downloads + .join("python") + .join("install") + .join(py_bin_name()) + .join("python3"), + ); + + install_python(toolchain, &downloads)?; + create_proxy_file(py_path, bin.join("python"))?; + + // TODO(cnpryer): Rest of tools + // todo!() + Ok(()) +} + +fn py_bin_name() -> &'static str { + #[cfg(unix)] + let name = "bin"; + + #[cfg(windows)] + let name = "Scripts"; + + name +} + +fn install_python(toolchain: &LocalToolchain, target: &PathBuf) -> Result<(), Error> { + let strategy = python_strategy_from_channel(toolchain.channel())?; + + let Some(release) = resolve_python_release(&strategy) else { + return Err(Error::PythonInstallationError(format!( + "could not resolve python with {strategy}" + ))); + }; + + Ok(install_python_with_target(&release, target)?) +} + +fn python_strategy_from_channel(channel: &Channel) -> Result { + let options = match channel { + Channel::Default => ReleaseOptions::default(), // TODO(cnpryer): Is there ever a case where channel default doesn't yield python default? + Channel::Version(version) => { + release_options_from_requested_version(RequestedVersion::from(*version))? + } + Channel::Descriptor(desc) => python_options_from_descriptor(desc)?, + }; + + Ok(PythonStrategy::Selection(options)) +} + +fn python_options_from_descriptor(desc: &DescriptorParts) -> Result { + let mut options = ReleaseOptions::default(); + + if let Some(kind) = desc.kind.as_ref() { + options.kind = ReleaseOption::from_str(kind).ok(); + } + + if let Some(version) = desc.version.as_ref() { + options.version = Some(ReleaseOption::from_str(&version.to_string())?); + } + + if let Some(architecture) = desc.architecture.as_ref() { + options.kind = ReleaseOption::from_str(architecture).ok(); + } + + if let Some(build_configuration) = desc.build_configuration.as_ref() { + options.kind = ReleaseOption::from_str(build_configuration).ok(); + } + + Ok(options) +} + +// TODO(cnpryer): +// - More robust support +// - Privileged action on windows https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links +// - Get file metadata for priv check (linux too) +// - Do symlinks not work on linux? +// - https://github.com/cnpryer/huak/issues/809 +// - check if file is already a link, if not attempt to make it one. +fn create_proxy_file>(original: T, link: T) -> Result<(), Error> { + let original = original.as_ref(); + let link = link.as_ref(); + + // If we can read the link we'll just make our own symlink of the original link's linked file + if let Ok(it) = read_link(original) { + // Do our best to get the path linked file + let p = if it.is_absolute() { + it + } else if let Some(parent) = original.parent() { + parent.join(it) + } else { + fs::canonicalize(it)? + }; + + // Attempt to create a symlink. If that doesn't work then we hardlink. If we can't hardlink we copy. + if try_symlink(p.as_path(), link).is_ok() || hard_link(p.as_path(), link).is_ok() { + Ok(()) + } else { + // Last resort is to copy the Python interpreter + println!( + " failed to link {} with {}", + link.display(), + original.display() + ); + println!(" copying {} to {}", original.display(), link.display()); + let _ = fs::copy(p.as_path(), link)?; + Ok(()) + } + } else if try_symlink(original, link).is_ok() || hard_link(original, link).is_ok() { + Ok(()) + } else { + todo!() + } +} + +fn try_symlink>(original: T, link: T) -> Result<(), Error> { + #[cfg(unix)] + let err = symlink(original, link); + + #[cfg(windows)] + let err = symlink_file(original, link); + + Ok(err?) +} + +fn maybe_exe(path: PathBuf) -> PathBuf { + if OS == "windows" && path.extension().map_or(false, |it| it == "exe") { + path.with_extension("exe") + } else { + path + } +} diff --git a/crates/huak-toolchain/src/lib.rs b/crates/huak-toolchain/src/lib.rs new file mode 100644 index 00000000..854f735d --- /dev/null +++ b/crates/huak-toolchain/src/lib.rs @@ -0,0 +1,272 @@ +//! # The toolchain implementation for Huak. +//! +//! ## Toolchain +//! +//! - Channel +//! - Path +//! - Tools +//! +//! ## Channels +//! +//! Channels are used to identify toolchains. +//! +//! - major.minor of a Python interpreter +//! - major.minor.patch of a Python interpreter +//! - Complete Python interpreter identifying chains (for example, 'cpython-3.12.0-apple-aarch64-pgo+lto') +//! - Etc. +//! +//! ## Path +//! +//! A unique toolchain is identifiable by the path it's installed to. A directory contains the entire toolchain. +//! +//! ## Tools +//! +//! Toolchains are composed of installed tools. The default tools installed are: +//! +//! - python (and Python installation management system) +//! - ruff +//! - mypy (TODO(cnpryer): May be replaced) +//! - pytest (TODO(cnpryer): May be replaced) +//! +//! ## Other +//! +//! Tools are centralized around a common Python inerpreter installed to the toolchain. The toolchain utilizes +//! a virtual environment shared by the tools in the toolchain. A bin directory contains the symlinked tools. +//! If a platform doesn't support symlinks hardlinks are used. +//! +//! ## `huak-toolchain` +//! +//! This crate implements Huak's toolchain via `Channel`, `Toolchain`, and `Tool`. +//! +//! ### `LocalToolchain` +//! +//! A directory containing `LocalTool`s for Huak to use. +//! +//! ### `LocalTool` +//! +//! A local tool that Huak can use. A `Tool` in a `Toolchain` is considered to have a `name` and a `path`. +//! +//! Local tools can be executable programs. +//! +//! ```rust +//! use huak_toolchain::prelude::*; +//! +//! let path = PathBuff::new("path/to/toolchain/"); +//! let toolchain = LocalToolchain::new(path)?; +//! let py = toolchain.tool("python"); +//! +//! assert_eq!(py.name, "python"); +//! assert_eq!(py.path, path.join("bin").join("python")) +//! ``` +//! +//! Use `toolchain.try_with_proxy_tool(tool)` to attempt to create a proxy file installed to the toolchain. +//! Use `toolchain.try_with_tool(tool)` to perform the full copy of the tool. +//! +//! The bin of the toolchain directory is intended to be added to users' scopes. So the bin directory +//! may contain full copies of executable programs or proxies to them. +//! +//! ``` +//! export PATH="/path/to/toolchain/bin/:$PATH" +//! ``` + +pub use channel::{Channel, DescriptorParts}; +pub use error::Error; +pub use install::install_toolchain_with_target; +use path::name_from_path; +pub use resolve::{Entry, LocalToolchainResolver}; +#[cfg(unix)] +use std::os::unix::fs::symlink; +use std::{ + fs::{self, hard_link, read_link}, + path::{Path, PathBuf}, +}; +pub use tools::LocalTool; + +mod channel; +mod error; +mod install; +mod path; +mod resolve; +mod tools; + +#[derive(Debug)] +pub struct LocalToolchain { + inner: LocalToolchainInner, +} + +// TODO(cnpryer): Teardown +impl LocalToolchain { + pub fn new>(path: T) -> Self { + let path = path.into(); + + Self { + inner: LocalToolchainInner { + name: name_from_path(&path) + .ok() + .map_or(String::from("default"), ToString::to_string), + channel: Channel::Default, + path, + }, + } + } + + pub fn set_channel(&mut self, channel: Channel) -> &mut Self { + self.inner.channel = channel; + self + } + + #[must_use] + pub fn name(&self) -> &String { + &self.inner.name + } + + #[must_use] + pub fn tool(&self, name: &str) -> LocalTool { + LocalTool::new(self.bin().join(name)) + } + + #[must_use] + pub fn channel(&self) -> &Channel { + &self.inner.channel + } + + #[must_use] + pub fn bin(&self) -> PathBuf { + self.root().join("bin") + } + + #[must_use] + pub fn root(&self) -> &PathBuf { + &self.inner.path + } + + #[must_use] + pub fn tools(&self) -> Vec { + let mut tools = Vec::new(); + + if let Ok(entries) = fs::read_dir(self.bin()) { + for entry in entries.flatten() { + if let Some(file_name) = entry.file_name().to_str() { + let p = entry.path(); + + if p == self.bin().join(file_name) { + tools.push(LocalTool { + name: file_name.to_string(), + path: self.bin().join(file_name), + }); + } + } + } + } + + tools + } + + #[must_use] + pub fn exists(&self) -> bool { + self.root().exists() + } + + #[must_use] + pub fn tool_is_installed(&self, name: &str) -> bool { + self.bin().join(name).exists() + } + + #[must_use] + pub fn downloads(&self) -> PathBuf { + self.root().join("downloads") + } + + #[must_use] + pub fn info(&self) -> String { + let tools = self.tools(); + + format!( + "Name: {}\nTools: {}\nPath: {}", + self.name(), + tools + .into_iter() + .map(|it| it.name) + .collect::>() + .join(", "), + self.bin().display() + ) + } + + #[must_use] + pub fn with_channel(self, channel: Channel) -> Self { + Self { + inner: LocalToolchainInner { + name: self.inner.name, + channel, + path: self.inner.path, + }, + } + } + pub fn register_tool_from_path>( + &self, + path: T, + name: &str, + allow_copy: bool, + ) -> Result<(), Error> { + let original = path.as_ref(); + let link = self.bin().join(name); + let link = link.as_ref(); + + // If we can read the link we'll just make our own symlink of the original link's linked file + let source = if original.is_symlink() { + read_link(original)? + } else { + original.to_path_buf() + }; + + let path = if source.is_absolute() { + source + } else if let Some(parent) = original.parent() { + parent.join(source) + } else { + std::fs::canonicalize(source)? + }; + + // Try to symlink. Then try hardlink. Then try fs::copy. + let path = path.as_path(); + + let Err(symlink_err) = try_symlink(path, link) else { + return Ok(()); + }; + + // If copy is allowed then try hardlink then resort to full copy. + if allow_copy { + let Err(_) = hard_link(path, link) else { + return Ok(()); + }; + let _copied = std::fs::copy(path, link)?; + Ok(()) + } else { + Err(symlink_err) + } + } +} + +#[derive(Debug)] +pub struct LocalToolchainInner { + name: String, + channel: Channel, + path: PathBuf, +} + +impl From for LocalToolchain { + fn from(value: PathBuf) -> Self { + LocalToolchain::new(value) + } +} + +fn try_symlink>(original: T, link: T) -> Result<(), Error> { + #[cfg(unix)] + let err = symlink(original, link); + + #[cfg(windows)] + let err = symlink_file(original, link); + + Ok(err?) +} diff --git a/crates/huak-toolchain/src/path.rs b/crates/huak-toolchain/src/path.rs new file mode 100644 index 00000000..dec9f175 --- /dev/null +++ b/crates/huak-toolchain/src/path.rs @@ -0,0 +1,17 @@ +use crate::Error; +use std::path::Path; + +pub(crate) fn name_from_path(path: &Path) -> Result<&str, Error> { + let Some(name) = path + .components() + .last() + .map(std::path::Component::as_os_str) + .and_then(|name| name.to_str()) + else { + return Err(Error::InvalidToolchain( + "could not parse name from path".to_string(), + )); + }; + + Ok(name) +} diff --git a/crates/huak-toolchain/src/resolve.rs b/crates/huak-toolchain/src/resolve.rs new file mode 100644 index 00000000..2d2ae7ae --- /dev/null +++ b/crates/huak-toolchain/src/resolve.rs @@ -0,0 +1,73 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::{Channel, LocalToolchain}; + +#[derive(Default)] +pub struct LocalToolchainResolver; + +impl LocalToolchainResolver { + #[must_use] + pub fn new() -> Self { + Self + } + + pub fn from_path>(self, channel: &Channel, path: T) -> Option { + if path_matches_name(channel, &path) { + Some(LocalToolchain::new(path.as_ref())) + } else { + None + } + } + + pub fn from_dir>(self, channel: &Channel, path: T) -> Option { + resolve_from_dir(channel, path) + } + + #[must_use] + pub fn from_paths(&self, channel: &Channel, paths: &[PathBuf]) -> Option { + paths + .iter() + .find(|p| path_matches_name(channel, p)) + .map(LocalToolchain::new) + } +} + +pub enum Entry { + String(String), +} + +fn resolve_from_dir>(channel: &Channel, path: T) -> Option { + let Ok(paths) = fs::read_dir(path.as_ref()) else { + return None; + }; + + // Return the first matching toolchain. + for entry in paths.flatten() { + let p = entry.path(); + if path_matches_name(channel, &p) { + return Some(LocalToolchain::new(p)); + } + } + + None +} + +fn path_matches_name>(channel: &Channel, path: T) -> bool { + match channel { + Channel::Default => path_name_matches(path, "default"), + Channel::Descriptor(descriptor) => path_name_matches(path, &descriptor.to_string()), + Channel::Version(version) => path_name_matches(path, &version.to_string()), + } +} + +fn path_name_matches(path: T, name: &str) -> bool +where + T: AsRef, +{ + path.as_ref() + .file_name() + .map_or(false, |it| it.eq_ignore_ascii_case(name)) +} diff --git a/crates/huak-toolchain/src/tools.rs b/crates/huak-toolchain/src/tools.rs new file mode 100644 index 00000000..c530edbe --- /dev/null +++ b/crates/huak-toolchain/src/tools.rs @@ -0,0 +1,41 @@ +use crate::name_from_path; +use std::{fmt::Display, path::PathBuf, str::FromStr}; + +#[derive(Clone, Debug)] +pub struct LocalTool { + pub name: String, + pub path: PathBuf, +} + +impl LocalTool { + pub fn new>(path: T) -> Self { + // TODO(cnpryer): More robust + let path = path.into(); + + Self { + name: name_from_path(&path) + .map(ToString::to_string) + .unwrap_or_default(), + path, + } + } + + #[must_use] + pub fn exists(&self) -> bool { + self.path.exists() + } +} + +impl Display for LocalTool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +impl FromStr for LocalTool { + type Err = crate::Error; + + fn from_str(s: &str) -> Result { + Ok(LocalTool::new(s)) + } +} diff --git a/dev-resources/planning.md b/dev-resources/planning.md index adbe2eae..1bcf237a 100644 --- a/dev-resources/planning.md +++ b/dev-resources/planning.md @@ -73,7 +73,7 @@ Without any other arguments `install` will: - Install the latest toolchain available if Huak doesn't already have one set up. - Install the toolchain associated with the project's Python environment if it isn't already installed. - - If a `[huak.toolchain]` table is used in the project's pyproject.toml file it will install it if it isn't already installed. + - If a `[tool.huak.toolchain]` table is used in the project's pyproject.toml file it will install it if it isn't already installed. - It will attempt to figure out which toolchain to install by checking for a related virtual environment. - If no virtual environment is associated with the project it will install a toolchain compatible with the latest version of Python available on the system. - If no Python is found on the system it will install the latest available version. @@ -121,14 +121,14 @@ In order to resolve a toolchain this behavior will follow the same logic defined As mentioned in *"Installing toolchains"*, toolchains have *versions*. Versions are paired with Python release versions. Channels can be further differentiated by information such as the release source or build type, but for now channels available to Huak users will remain the default matching a CPython release. -To use a channel for a pyproject.toml-managed project add the `[huak.toolchain]` table: +To use a channel for a pyproject.toml-managed project add the `[tool.huak.toolchain]` table: ```toml -[huak.toolchain] +[tool.huak.toolchain] channel = 3.12 ``` -See *"Pyproject.toml `[huak.toolchain]`"* for more. +See *"Pyproject.toml `[tool.huak.toolchain]`"* for more. Eventually channels won't be limited to version identifiers. @@ -175,7 +175,7 @@ Huak will utilize tools from a toolchain without the user having to manage that Toolchains can be used without Huak by activating their virtual environments. If `huak toolchain install 3.12.0 --target ~/desktop` is used a directory containing an installed Python interpreter is available for use. In order to utilize these tools users can activate the virtual environment by running: ``` -. ./desktop/huak-cpython-3.12.0-apple-aarch64/.venv/bin/activate +. ./desktop/cpython-3.12.0-apple-aarch64/.venv/bin/activate ``` #### Home directory @@ -183,8 +183,8 @@ Toolchains can be used without Huak by activating their virtual environments. If - Env file: Centralized method for updating PATH and environment for Huak usage. Includes adding bin directory to PATH. - Bin directory: Contains executable programs often used as proxies for other programs for Huak. For the toolchain usage only Huak is added to this directory. - Settings file: Settings data for Huak is stored in settings.toml. Stores defaults and project-specific configuration including toolchains and eventually Python environments. -- Toolchains directory: Contains Huak's toolchains with the following naming convention huak-{interpreter kind}-{version}-{os}-{architecture}. +- Toolchains directory: Contains Huak's toolchains with the following naming convention {interpreter kind}-{version}-{os}-{architecture}. -#### Pyproject.toml `[huak.toolchain]` +#### Pyproject.toml `[tool.huak.toolchain]` This table is used to configure the toolchain for a project. The channel field is used to indicate the requested version of the toolchain, but eventually channels can include markers unlike version requests. diff --git a/pyproject.toml b/pyproject.toml index 3e0cb7a3..42042103 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,3 +29,6 @@ strip = true [build-system] requires = ["maturin>=0.14,<0.15"] build-backend = "maturin" + +[tool.huak] +toolchain = "default" \ No newline at end of file