From 95c733e92c3da016f94653e9de0bee52f04e6554 Mon Sep 17 00:00:00 2001 From: Chris Pryer <14341145+cnpryer@users.noreply.github.com> Date: Sun, 5 Nov 2023 22:28:26 -0500 Subject: [PATCH] Add `huak-toolchain` (first-pass) (#798) --- Cargo.lock | 15 +- Cargo.toml | 2 + crates/huak-cli/Cargo.toml | 3 +- crates/huak-cli/src/cli.rs | 100 ++- crates/huak-cli/src/main.rs | 29 +- .../tests/snapshots/r#mod__tests__help-2.snap | 3 +- .../tests/snapshots/r#mod__tests__help.snap | 3 +- 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 | 56 +- 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 | 13 + crates/huak-toolchain/src/channel.rs | 78 ++ crates/huak-toolchain/src/error.rs | 24 + crates/huak-toolchain/src/lib.rs | 295 ++++++++ crates/huak-toolchain/src/path.rs | 17 + crates/huak-toolchain/src/resolve.rs | 82 +++ crates/huak-toolchain/src/tools.rs | 53 ++ dev-resources/planning.md | 14 +- pyproject.toml | 5 +- 39 files changed, 1915 insertions(+), 111 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/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..c2901b09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -539,7 +539,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "huak" -version = "0.0.19" +version = "0.0.19-alpha1" dependencies = [ "clap", "clap_complete", @@ -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..fae9c567 100644 --- a/crates/huak-cli/Cargo.toml +++ b/crates/huak-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "huak" -version = "0.0.19" +version = "0.0.19-alpha1" description = "A Python package manager written in Rust and inspired by Cargo." repository = "https://github.com/cnpryer/huak.git" homepage = "https://github.com/cnpryer/huak.git" @@ -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-cli/tests/snapshots/r#mod__tests__help-2.snap b/crates/huak-cli/tests/snapshots/r#mod__tests__help-2.snap index eea14569..3a45e768 100644 --- a/crates/huak-cli/tests/snapshots/r#mod__tests__help-2.snap +++ b/crates/huak-cli/tests/snapshots/r#mod__tests__help-2.snap @@ -1,5 +1,5 @@ --- -source: crates/huak_cli/tests/mod.rs +source: crates/huak-cli/tests/mod.rs info: program: huak args: @@ -29,6 +29,7 @@ Commands: remove Remove dependencies from the project run Run a command within the project's environment context test Test the project's Python code + toolchain Manage toolchains update Update the project's dependencies version Display the version of the project help Print this message or the help of the given subcommand(s) diff --git a/crates/huak-cli/tests/snapshots/r#mod__tests__help.snap b/crates/huak-cli/tests/snapshots/r#mod__tests__help.snap index 2961c953..2a4c0d28 100644 --- a/crates/huak-cli/tests/snapshots/r#mod__tests__help.snap +++ b/crates/huak-cli/tests/snapshots/r#mod__tests__help.snap @@ -1,5 +1,5 @@ --- -source: crates/huak_cli/tests/mod.rs +source: crates/huak-cli/tests/mod.rs info: program: huak args: @@ -29,6 +29,7 @@ Commands: remove Remove dependencies from the project run Run a command within the project's environment context test Test the project's Python code + toolchain Manage toolchains update Update the project's dependencies version Display the version of the project help Print this message or the help of the given subcommand(s) 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..91634f31 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")] + ToolchainNotFound, + #[error("{0}")] // See TODO note above. + ToolchainError(#[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..1fd48751 --- /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::ToolchainError(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..a99c94f6 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::ToolchainNotFound); + }; + + 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..1e39d9d9 100644 --- a/crates/huak-python-manager/src/lib.rs +++ b/crates/huak-python-manager/src/lib.rs @@ -20,17 +20,13 @@ //! //! ```no_run //! use std::{str::FromStr, path::PathBuf}; -//! use huak_python_manager::{Options, RequestedVersion, Strategy, install_with_target, resolve_release}; -//! -//! -//! // The version of the Python to install. -//! let version = RequestedVersion::from_str("3.12").unwrap(); +//! use huak_python_manager::{ReleaseOptions, Strategy, install_with_target, resolve_release}; //! //! // Target directory to install Python to. //! let target = PathBuf::from("..."); //! //! // Use selection strategy to resolve for the best matching release available. -//! let strategy = Strategy::Selection(Options { version: Some(version), kind: "cpython", os: "apple", architecture: "aarch64", build_configuration: "pgo+lto"}); +//! let strategy = Strategy::Selection(ReleaseOptions::default()); //! //! let release = resolve_release(&strategy).unwrap(); //! @@ -38,12 +34,56 @@ //! ``` 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 install::install_with_target; +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..2db557b2 --- /dev/null +++ b/crates/huak-toolchain/README.md @@ -0,0 +1,13 @@ +# Toolchain + +The toolchain implementation for Huak. + +This crate is a work-in-progress. + +## Usage + +**This crate is designed to be used by Huak.** Install a toolchain to ~/.huak/toolchains. + +``` +huak toolchain install +``` 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/lib.rs b/crates/huak-toolchain/src/lib.rs new file mode 100644 index 00000000..b2d62207 --- /dev/null +++ b/crates/huak-toolchain/src/lib.rs @@ -0,0 +1,295 @@ +//! # 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::LocalToolchain; +//! use std::path::PathBuf; +//! +//! let path = PathBuf::from("path/to/toolchain/"); +//! let toolchain = LocalToolchain::new(&path); +//! let py = toolchain.tool("python"); +//! let bin = path.join("bin"); +//! let py_bin = bin.join("python"); +//! +//! assert_eq!(&py.name, "python"); +//! assert_eq!(py.path, py_bin); +//! ``` +//! +//! 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; +use path::name_from_path; +pub use resolve::LocalToolchainResolver; +#[cfg(unix)] +use std::os::unix::fs::symlink; +#[cfg(windows)] +use std::os::windows::fs::symlink_file; +use std::{ + fs::{self, hard_link, read_link}, + path::{Path, PathBuf}, +}; +pub use tools::LocalTool; + +mod channel; +mod error; +mod path; +mod resolve; +mod tools; + +#[derive(Debug)] +pub struct LocalToolchain { + inner: LocalToolchainInner, +} + +/// The local toolchain for Huak. +/// +/// A local toolchain is created for different channels. The channel determines its +/// release installs and its path. +/// +/// A `LocalToolchain` is meant to be used as an API for toolchain management on some +/// filesystem. +/// +/// ```rust +/// use std::path::PathBuf; +/// use huak_toolchain::LocalToolchain; +/// +/// +/// let root = PathBuf::new(); +/// let toolchain = LocalToolchain::new(&root); +/// +/// assert_eq!(toolchain.root(), &root); +/// assert_eq!(toolchain.bin(), root.join("bin")); +/// ``` +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..e1d94e83 --- /dev/null +++ b/crates/huak-toolchain/src/resolve.rs @@ -0,0 +1,82 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::{Channel, LocalToolchain}; + +/// A struct used to resolve `LocalToolchain`s from some filesystem. +/// +/// `LocalToolchainResolver`s are used to search for toolchains on the filesystem using +/// `Channel`s. +/// +/// ```rust +/// use std::path::PathBuf; +/// use huak_toolchain::{Channel, LocalToolchainResolver}; +/// +/// let dir = PathBuf::new(); +/// let resolver = LocalToolchainResolver::new(); +/// let toolchain = resolver.from_dir(&Channel::Default, dir); +/// ``` +#[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) + } +} + +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..d3400c21 --- /dev/null +++ b/crates/huak-toolchain/src/tools.rs @@ -0,0 +1,53 @@ +use crate::name_from_path; +use std::{fmt::Display, path::PathBuf, str::FromStr}; + +/// The local tool for Huak's toolchain system. +/// +/// A `LocalTool` provides a small wrapper for tool paths. +/// ```rust +/// use std::path::PathBuf; +/// use huak_toolchain::LocalTool; +/// +/// let path = PathBuf::new(); +/// let tool = LocalTool::new(&path); +/// +/// assert_eq!(&path, &tool.path); +/// ``` +#[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 a653b68f..4ac35709 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. @@ -135,14 +135,14 @@ In order to resolve a toolchain this behavior will follow the same logic defined `` is the the requested toolchain channel to use. Versions can be used for channels. *Using* `"3.12"` would key the current scope with the toolchain channel `"3.12"` into the settings.toml db. The resolved toolchain would include Python installed with the latest default release options available for that channel. -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. ##### `Channel` @@ -193,7 +193,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 @@ -201,8 +201,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..2fa33466 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "huak" -version = "0.0.19" +version = "0.0.19a1" description = "A Python package manager written in Rust and inspired by Cargo." authors = [ {email = "cnpryer@gmail.com"}, @@ -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