diff --git a/Cargo.lock b/Cargo.lock index 8995c40f..eddcf5ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -357,9 +357,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] @@ -559,9 +559,11 @@ dependencies = [ "human-panic", "insta-cmd", "openssl", + "pep508_rs", "tempfile", "termcolor", "thiserror", + "url", ] [[package]] @@ -704,9 +706,9 @@ dependencies = [ [[package]] name = "idna" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ "unicode-bidi", "unicode-normalization", @@ -1039,9 +1041,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project-lite" @@ -1617,9 +1619,9 @@ checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" [[package]] name = "url" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", "idna", diff --git a/README.md b/README.md index 90b17e5d..316d5cf5 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Commands: fix Auto-fix fixable lint conflicts fmt Format the project's Python code init Initialize the current project + install Install a Python package (defaults to $HOME/.huak/bin) lint Lint the project's Python code new Create a new project at publish Builds and uploads current project to a registry diff --git a/crates/huak-cli/Cargo.toml b/crates/huak-cli/Cargo.toml index aceacbb9..b033e00e 100644 --- a/crates/huak-cli/Cargo.toml +++ b/crates/huak-cli/Cargo.toml @@ -25,8 +25,10 @@ huak-workspace = { path = "../huak-workspace" } human-panic.workspace = true # included to build PyPi Wheels (see .github/workflow/README.md) openssl = { version = "0.10.57", features = ["vendored"], optional = true } +pep508_rs.workspace = true termcolor.workspace = true thiserror.workspace = true +url = "2.5.0" [dev-dependencies] huak-dev = { path = "../huak-dev" } diff --git a/crates/huak-cli/src/cli.rs b/crates/huak-cli/src/cli.rs index 7590e634..8d0cd2dd 100644 --- a/crates/huak-cli/src/cli.rs +++ b/crates/huak-cli/src/cli.rs @@ -3,8 +3,8 @@ use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{self, Shell}; use huak_home::huak_home_dir; use huak_package_manager::ops::{ - self, AddOptions, BuildOptions, CleanOptions, FormatOptions, LintOptions, PublishOptions, - RemoveOptions, TestOptions, UpdateOptions, + self, install as install_op, AddOptions, BuildOptions, CleanOptions, FormatOptions, + LintOptions, PublishOptions, RemoveOptions, TestOptions, UpdateOptions, }; use huak_package_manager::{ Config, Error as HuakError, HuakResult, InstallOptions, TerminalOptions, Verbosity, @@ -13,8 +13,10 @@ use huak_package_manager::{ use huak_python_manager::RequestedVersion; use huak_toolchain::{Channel, LocalTool}; use huak_workspace::{resolve_root, PathMarker}; +use pep508_rs::Requirement; use std::{env::current_dir, path::PathBuf, process::ExitCode, str::FromStr}; use termcolor::ColorChoice; +use url::Url; /// A Python package manager written in Rust inspired by Cargo. #[derive(Parser)] @@ -113,6 +115,23 @@ enum Commands { #[arg(last = true)] trailing: Option>, }, + /// Install a Python package (defaults to $HOME/.huak/bin). + Install { + /// The Python package to install. + #[arg(required = true)] + package: Requirement, + /// The Python version to use. TODO(cnpryer): https://github.com/cnpryer/huak/issues/850 + #[arg(long, alias = "py", required = false)] + python_version: Option, + /// The package index to use. TODO(cnpryer): Deps (document this) + #[arg( + long, + alias = "index-url", + default_value = "https://pypi.python.org/simple", + required = false + )] // TODO(cnpryer): Names + package_index_url: Url, + }, /// Lint the project's Python code. Lint { /// Address any fixable lints. @@ -380,6 +399,11 @@ fn exec_command(cmd: Commands, config: &mut Config) -> HuakResult<()> { &install_options, ) } + Commands::Install { + package, + python_version, + package_index_url, + } => install(&package, python_version, &package_index_url, config), Commands::Lint { fix, no_types, @@ -555,6 +579,15 @@ fn init( } } +fn install( + package: &Requirement, + python_version: Option, + package_index_url: &Url, + config: &Config, +) -> HuakResult<()> { + install_op(package, python_version, package_index_url.as_str(), config) +} + fn lint(config: &Config, options: &LintOptions) -> HuakResult<()> { ops::lint_project(config, options) } diff --git a/crates/huak-cli/tests/mod.rs b/crates/huak-cli/tests/mod.rs index 06af1ea0..b6bbe091 100644 --- a/crates/huak-cli/tests/mod.rs +++ b/crates/huak-cli/tests/mod.rs @@ -49,10 +49,10 @@ mod tests { assert_cmd_snapshot!(Command::new("huak").arg("init").arg("--help")); } - // #[test] - // fn test_install_help() { - // assert_cmd_snapshot!(Command::new("huak").arg("install").arg("--help")); - // } + #[test] + fn test_install_help() { + assert_cmd_snapshot!(Command::new("huak").arg("install").arg("--help")); + } #[test] fn test_lint_help() { 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 341bfd52..50f5d4cf 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 @@ -21,6 +21,7 @@ Commands: fix Auto-fix fixable lint conflicts fmt Format the project's Python code init Initialize the current project + install Install a Python package (defaults to $HOME/.huak/bin) lint Lint the project's Python code new Create a new project at publish Builds and uploads current project to a registry 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 32a5db5a..70be3242 100644 --- a/crates/huak-cli/tests/snapshots/r#mod__tests__help.snap +++ b/crates/huak-cli/tests/snapshots/r#mod__tests__help.snap @@ -21,6 +21,7 @@ Commands: fix Auto-fix fixable lint conflicts fmt Format the project's Python code init Initialize the current project + install Install a Python package (defaults to $HOME/.huak/bin) lint Lint the project's Python code new Create a new project at publish Builds and uploads current project to a registry diff --git a/crates/huak-cli/tests/snapshots/r#mod__tests__install_help.snap b/crates/huak-cli/tests/snapshots/r#mod__tests__install_help.snap new file mode 100644 index 00000000..43de1904 --- /dev/null +++ b/crates/huak-cli/tests/snapshots/r#mod__tests__install_help.snap @@ -0,0 +1,32 @@ +--- +source: crates/huak-cli/tests/mod.rs +info: + program: huak + args: + - install + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Install a Python package (defaults to $HOME/.huak/bin) + +Usage: huak install [OPTIONS] + +Arguments: + The Python package to install + +Options: + --python-version + The Python version to use. TODO(cnpryer): https://github.com/cnpryer/huak/issues/850 + --package-index-url + The package index to use. TODO(cnpryer): Deps (document this) [default: https://pypi.python.org/simple] + -q, --quiet + + --no-color + + -h, --help + Print help + +----- stderr ----- + diff --git a/crates/huak-package-manager/src/ops/install.rs b/crates/huak-package-manager/src/ops/install.rs index 0dcf958a..4b1d2969 100644 --- a/crates/huak-package-manager/src/ops/install.rs +++ b/crates/huak-package-manager/src/ops/install.rs @@ -1,6 +1,51 @@ -use crate::HuakResult; +use huak_python_manager::{RequestedVersion, Version}; +use huak_toolchain::{Channel, LocalTool, LocalToolchain}; +use pep508_rs::Requirement; + +use super::toolchain::{add_tool_to_toolchain, install_minimal_toolchain}; +use crate::{Config, Error, HuakResult}; // TODO(cnpryer): https://github.com/cnpryer/huak/issues/850 -pub fn _install() -> HuakResult<()> { - todo!() +pub fn install( + package: &Requirement, + python_version: Option, + _package_index_url: &str, + config: &Config, +) -> HuakResult<()> { + // TODO(cnpryer): Since we're treating the bin dir as a toolchain that'd mean Huak home is + // the root of that toolchain (given toolchain bins are standard at roots). + let Some(home) = config.home.as_ref() else { + return Err(Error::HuakHomeNotFound); + }; + + // TODO(cnpryer): Smarter installs + if home.join("bin").join(&package.name).exists() { + return config + .terminal() + .print_warning(format!("'{}' is already installed", &package.name)); + } + + if !home.join("bin").exists() { + std::fs::create_dir_all(home)?; + + // TODO(cnpryer): https://github.com/cnpryer/huak/issues/871 + let channel = python_version + .map(|it| { + Channel::Version(Version { + major: it.major, + minor: it.minor, + patch: it.patch, + }) + }) + .unwrap_or_default(); + + install_minimal_toolchain(home, channel, config)?; + } + + // TODO(cnpryer): Toolchains have names. The bin directory is used as a toolchain + // but there's no intention behind a toolchain named 'bin'. + let bin = LocalToolchain::new(home); + let package = LocalTool::from_spec(package.name.clone(), package.to_string()); + + add_tool_to_toolchain(&package, &bin, config) } diff --git a/crates/huak-package-manager/src/ops/mod.rs b/crates/huak-package-manager/src/ops/mod.rs index b3d0ddda..803d8f6f 100644 --- a/crates/huak-package-manager/src/ops/mod.rs +++ b/crates/huak-package-manager/src/ops/mod.rs @@ -25,6 +25,7 @@ pub use build::{build_project, BuildOptions}; pub use clean::{clean_project, CleanOptions}; pub use format::{format_project, FormatOptions}; pub use init::{init_app_project, init_lib_project, init_python_env}; +pub use install::install; pub use lint::{lint_project, LintOptions}; pub use new::{new_app_project, new_lib_project}; pub use publish::{publish_project, PublishOptions}; diff --git a/crates/huak-package-manager/src/ops/run.rs b/crates/huak-package-manager/src/ops/run.rs index dd6ddcdd..656e3472 100644 --- a/crates/huak-package-manager/src/ops/run.rs +++ b/crates/huak-package-manager/src/ops/run.rs @@ -403,10 +403,9 @@ where cmd.envs(env); } - cmd.args(args).current_dir(&config.cwd); - dbg!(&cmd); - - config.terminal().run_command(&mut cmd) + config + .terminal() + .run_command(cmd.args(args).current_dir(&config.cwd)) } fn item_as_args(item: &Item) -> Option> { diff --git a/crates/huak-package-manager/src/ops/toolchain.rs b/crates/huak-package-manager/src/ops/toolchain.rs index cbbd131d..67d217b2 100644 --- a/crates/huak-package-manager/src/ops/toolchain.rs +++ b/crates/huak-package-manager/src/ops/toolchain.rs @@ -26,7 +26,16 @@ pub fn add_tool(tool: &LocalTool, channel: Option<&Channel>, config: &Config) -> // Resolve a toolchain if a channel is provided. Otherwise resolve the curerent. let toolchain = config.workspace().resolve_local_toolchain(channel)?; - let args = ["-m", "pip", "install", &tool.name]; + add_tool_to_toolchain(tool, &toolchain, config) +} + +// TODO(cnpryer): Refactor +pub(crate) fn add_tool_to_toolchain( + tool: &LocalTool, + toolchain: &LocalToolchain, + config: &Config, +) -> HuakResult<()> { + let args = ["-m", "pip", "install", tool.spec().unwrap_or(&tool.name)]; let venv = PythonEnvironment::new(toolchain.root().join(".venv"))?; let mut terminal = config.terminal(); @@ -115,7 +124,7 @@ pub fn install_toolchain( return Err(Error::LocalToolchainExists(path)); } - if let Err(e) = install(path.clone(), channel, config) { + if let Err(e) = install(&path, channel, config) { teardown(parent.join(&channel_string), config)?; Err(e) } else { @@ -124,7 +133,56 @@ pub fn install_toolchain( } #[allow(clippy::too_many_lines)] -fn install(path: PathBuf, channel: Channel, config: &Config) -> HuakResult<()> { +pub(crate) fn install(path: &PathBuf, channel: Channel, config: &Config) -> HuakResult<()> { + let mut terminal = config.terminal(); + + let toolchain = match install_minimal_toolchain(path, channel, config) { + Ok(it) => it, + Err(Error::LocalToolchainExists(_)) => { + return terminal + .print_warning(format!("Toolchain already exists at {}", path.display())) + } + Err(e) => return Err(e), + }; + let venv = PythonEnvironment::new(toolchain.root().join(".venv"))?; + + // Register more tools to the toolchain + for name in ["ruff", "mypy", "pytest"] { + terminal.print_custom("Installing", name, Color::Green, true)?; + + let mut cmd: Command = Command::new(venv.python_path()); + cmd.current_dir(&config.cwd) + .args(["-m", "pip", "install", name]); + + terminal.run_command(&mut cmd)?; + + let Some(p) = venv.executable_module_path(name) else { + return Err(Error::PythonModuleNotFound(name.to_string())); + }; + + if toolchain.register_tool(&p, name, false).is_err() { + toolchain.register_tool(&p, name, true)?; + // TODO(cnpryer): Handle errors + } + } + + terminal.print_custom( + "Finished", + format!( + "installed '{}' ({})", + toolchain.name(), + toolchain.root().display() + ), + Color::Green, + true, + ) +} + +pub(crate) fn install_minimal_toolchain( + path: &PathBuf, + channel: Channel, + config: &Config, +) -> HuakResult { let mut toolchain = LocalToolchain::new(path); toolchain.set_channel(channel); @@ -137,12 +195,7 @@ fn install(path: PathBuf, channel: Channel, config: &Config) -> HuakResult<()> { // 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(()); + return Err(Error::LocalToolchainExists(path.clone())); } for p in [toolchain.bin(), toolchain.downloads()] { @@ -258,36 +311,7 @@ fn install(path: PathBuf, channel: Channel, config: &Config) -> HuakResult<()> { // TODO(cnpryer): Handle errors } - // Register more tools to the toolchain - for name in ["ruff", "mypy", "pytest"] { - terminal.print_custom("Installing", name, Color::Green, true)?; - - let mut cmd: Command = Command::new(venv.python_path()); - cmd.current_dir(&config.cwd) - .args(["-m", "pip", "install", name]); - - terminal.run_command(&mut cmd)?; - - let Some(p) = venv.executable_module_path(name) else { - return Err(Error::PythonModuleNotFound(name.to_string())); - }; - - if toolchain.register_tool(&p, name, false).is_err() { - toolchain.register_tool(&p, name, true)?; - // TODO(cnpryer): Handle errors - } - } - - terminal.print_custom( - "Finished", - format!( - "installed '{}' ({})", - toolchain.name(), - toolchain.root().display() - ), - Color::Green, - true, - ) + Ok(toolchain) } /// Resolve available toolchains and display their names as a list. Display the following with @@ -347,6 +371,10 @@ pub fn remove_tool(tool: &LocalTool, channel: Option<&Channel>, config: &Config) let cmd = cmd.args(args).current_dir(&config.cwd); let tool = toolchain.tool(&tool.name); + let Some(path) = tool.path.as_ref() else { + return Err(Error::InternalError(format!("'{}' has no path", tool.name))); + // TODO(cnpryer) + }; terminal.print_custom( "Updating", @@ -358,7 +386,7 @@ pub fn remove_tool(tool: &LocalTool, channel: Option<&Channel>, config: &Config) terminal.set_verbosity(Verbosity::Quiet); terminal.run_command(cmd)?; - remove_path_with_scope(&tool.path, toolchain.root())?; + remove_path_with_scope(path, toolchain.root())?; terminal.set_verbosity(Verbosity::Normal); terminal.print_custom( @@ -408,8 +436,13 @@ fn run( .join(py_bin_name()) .join("python"), )) + } else if let Some(it) = tool.path { + Command::new(it) } else { - Command::new(tool.path) + return Err(Error::InternalError(format!( + "failed to run tool '{}", + tool.name + ))); }; cmd.args(args).current_dir(&config.cwd); @@ -470,10 +503,7 @@ pub fn update_toolchain( .tools() .into_iter() .filter(|it| it.name != "python") - .chain([LocalTool { - name: "pip".to_string(), - path: toolchain.bin().join("pip"), - }]) + .chain([]) .collect() }; @@ -481,7 +511,11 @@ pub fn update_toolchain( let args = ["-m", "pip", "install", "--upgrade"]; for tool in tools { - let mut cmd = Command::new(&py.path); + let Some(p) = py.path.as_ref() else { + return Err(Error::PythonNotFound); + }; + + let mut cmd = Command::new(p); terminal.print_custom("Updating", &tool.name, Color::Green, true)?; terminal.set_verbosity(Verbosity::Quiet); diff --git a/crates/huak-toolchain/src/lib.rs b/crates/huak-toolchain/src/lib.rs index 5003f318..b430380e 100644 --- a/crates/huak-toolchain/src/lib.rs +++ b/crates/huak-toolchain/src/lib.rs @@ -59,7 +59,7 @@ //! let py_bin = bin.join("python"); //! //! assert_eq!(&py.name, "python"); -//! assert_eq!(py.path, py_bin); +//! assert_eq!(py.path.unwrap(), py_bin); //! ``` //! //! Use `toolchain.try_with_proxy_tool(tool)` to attempt to create a proxy file installed to the toolchain. @@ -175,10 +175,7 @@ impl LocalToolchain { 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.push(LocalTool::from(self.bin().join(file_name))); } } } diff --git a/crates/huak-toolchain/src/tools.rs b/crates/huak-toolchain/src/tools.rs index d3400c21..b990387d 100644 --- a/crates/huak-toolchain/src/tools.rs +++ b/crates/huak-toolchain/src/tools.rs @@ -11,30 +11,38 @@ use std::{fmt::Display, path::PathBuf, str::FromStr}; /// let path = PathBuf::new(); /// let tool = LocalTool::new(&path); /// -/// assert_eq!(&path, &tool.path); +/// assert_eq!(path, tool.path.unwrap()); /// ``` #[derive(Clone, Debug)] pub struct LocalTool { pub name: String, - pub path: PathBuf, + pub path: Option, + spec: Option, } impl LocalTool { pub fn new>(path: T) -> Self { // TODO(cnpryer): More robust - let path = path.into(); + Self::from(path.into()) + } + + #[must_use] + pub fn spec(&self) -> Option<&String> { + self.spec.as_ref() + } + #[must_use] + pub fn from_spec(name: String, spec: String) -> Self { Self { - name: name_from_path(&path) - .map(ToString::to_string) - .unwrap_or_default(), - path, + name, + path: None, + spec: Some(spec), } } #[must_use] pub fn exists(&self) -> bool { - self.path.exists() + self.path.as_ref().map_or(false, |it| it.exists()) } } @@ -51,3 +59,15 @@ impl FromStr for LocalTool { Ok(LocalTool::new(s)) } } + +impl From for LocalTool { + fn from(value: PathBuf) -> Self { + LocalTool { + name: name_from_path(&value) + .map(ToString::to_string) + .unwrap_or_default(), + path: Some(value.clone()), + spec: None, + } + } +}