diff --git a/src/bin/huak/cli.rs b/src/bin/huak/cli.rs index a00802de..56b50b85 100644 --- a/src/bin/huak/cli.rs +++ b/src/bin/huak/cli.rs @@ -2,12 +2,14 @@ use crate::error::{CliResult, Error}; use clap::{Command, CommandFactory, Parser, Subcommand}; use clap_complete::{self, Shell}; use huak::{ - ops::{self, find_workspace, OperationConfig}, - BuildOptions, CleanOptions, Error as HuakError, FormatOptions, HuakResult, - InstallerOptions, LintOptions, PublishOptions, TerminalOptions, - TestOptions, Verbosity, WorkspaceOptions, + ops::{ + self, AddOptions, BuildOptions, CleanOptions, FormatOptions, + InstallOptions, LintOptions, PublishOptions, RemoveOptions, + TestOptions, UpdateOptions, + }, + Config, Error as HuakError, HuakResult, Terminal, Verbosity, Version, + WorkspaceOptions, }; -use pep440_rs::Version; use std::{ fs::File, io::Write, @@ -49,6 +51,15 @@ enum Commands { #[arg(last = true)] trailing: Option>, }, + /// Remove tarball and wheel from the built project. + Clean { + #[arg(long, required = false)] + /// Remove all .pyc files. + include_pyc: bool, + #[arg(long, required = false)] + /// Remove all __pycache__ directories. + include_pycache: bool, + }, /// Generates a shell completion script for supported shells. Completion { #[arg(short, long, value_name = "shell")] @@ -62,15 +73,6 @@ enum Commands { /// If this flag is passed the --shell is required uninstall: bool, }, - /// Remove tarball and wheel from the built project. - Clean { - #[arg(long, required = false)] - /// Remove all .pyc files. - include_pyc: bool, - #[arg(long, required = false)] - /// Remove all __pycache__ directories. - include_pycache: bool, - }, /// Auto-fix fixable lint conflicts Fix { /// Pass trailing arguments with `--`. @@ -196,43 +198,47 @@ enum Python { // Command gating for Huak. impl Cli { pub fn run(self) -> CliResult<()> { - let workspace_root = - find_workspace().unwrap_or(std::env::current_dir()?); + let cwd = std::env::current_dir()?; let verbosity = match self.quiet { true => Verbosity::Quiet, false => Verbosity::Normal, }; - let mut operation_config = OperationConfig { - workspace_root, - terminal_options: TerminalOptions { verbosity }, - ..Default::default() + let mut terminal = Terminal::new(); + terminal.set_verbosity(verbosity); + let mut config = Config { + workspace_root: cwd.to_path_buf(), + cwd, + terminal, }; match self.command { - Commands::Activate => activate(operation_config), + Commands::Activate => activate(&mut config), Commands::Add { dependencies, group, trailing, } => { - operation_config.installer_options = - Some(InstallerOptions { args: trailing }); - add(dependencies, group, operation_config) + let options = Some(AddOptions { + install_options: Some(InstallOptions { args: trailing }), + args: None, + }); + add(dependencies, group, &mut config, options) } Commands::Build { trailing } => { - operation_config.build_options = - Some(BuildOptions { args: trailing }); - build(operation_config) + let options = Some(BuildOptions { + args: trailing, + install_options: None, + }); + build(&mut config, options) } Commands::Clean { include_pyc, include_pycache, } => { - let options = CleanOptions { + let options = Some(CleanOptions { include_pycache, include_compiled_bytecode: include_pyc, - }; - operation_config.clean_options = Some(options); - clean(operation_config) + }); + clean(&mut config, options) } Commands::Completion { shell, @@ -253,63 +259,56 @@ impl Cli { } } Commands::Fix { trailing } => { - operation_config.lint_options = Some(LintOptions { + let options = Some(LintOptions { args: trailing, include_types: false, + install_options: None, }); - if let Some(options) = operation_config.lint_options.as_mut() { - if let Some(args) = options.args.as_mut() { - args.push("--fix".to_string()); - } - } - fix(operation_config) + fix(&mut config, options) } Commands::Fmt { check, trailing } => { - operation_config.format_options = - Some(FormatOptions { args: trailing }); - if check { - if let Some(options) = - operation_config.format_options.as_mut() - { - if let Some(args) = options.args.as_mut() { - args.push("--check".to_string()); - } else { - options.args = Some(vec!["--check".to_string()]); - } - } + let mut args = if check { + vec!["--check".to_string()] + } else { + Vec::new() + }; + if let Some(it) = trailing { + args.extend(it); } - fmt(operation_config) + let options = Some(FormatOptions { + args: Some(args), + install_options: None, + }); + fmt(&mut config, options) } Commands::Init { app, lib, no_vcs } => { - operation_config.workspace_root = std::env::current_dir()?; - operation_config.workspace_options = - Some(WorkspaceOptions { uses_git: !no_vcs }); - init(app, lib, operation_config) + config.workspace_root = config.cwd.clone(); + let options = Some(WorkspaceOptions { uses_git: !no_vcs }); + init(app, lib, &mut config, options) } Commands::Install { groups, trailing } => { - operation_config.installer_options = - Some(InstallerOptions { args: trailing }); - install(groups, operation_config) + let options = Some(InstallOptions { args: trailing }); + install(groups, &mut config, options) } Commands::Lint { fix, no_types, trailing, } => { - operation_config.lint_options = Some(LintOptions { - args: trailing, + let mut args = if fix { + vec!["--fix".to_string()] + } else { + Vec::new() + }; + if let Some(it) = trailing { + args.extend(it); + } + let options = Some(LintOptions { + args: Some(args), include_types: !no_types, + install_options: None, }); - if fix { - if let Some(options) = - operation_config.lint_options.as_mut() - { - if let Some(args) = options.args.as_mut() { - args.push("--fix".to_string()); - } - } - } - lint(operation_config) + lint(&mut config, options) } Commands::New { path, @@ -317,55 +316,64 @@ impl Cli { lib, no_vcs, } => { - operation_config.workspace_root = PathBuf::from(path); - operation_config.workspace_options = - Some(WorkspaceOptions { uses_git: !no_vcs }); - new(app, lib, operation_config) + config.workspace_root = + std::fs::canonicalize(PathBuf::from(path))?; + let options = Some(WorkspaceOptions { uses_git: !no_vcs }); + new(app, lib, &mut config, options) } Commands::Publish { trailing } => { - operation_config.publish_options = - Some(PublishOptions { args: trailing }); - publish(operation_config) + let options = Some(PublishOptions { + args: trailing, + install_options: None, + }); + publish(&mut config, options) } - Commands::Python { command } => python(command, operation_config), + Commands::Python { command } => python(command, &mut config), Commands::Remove { dependencies, group, trailing, } => { - operation_config.installer_options = - Some(InstallerOptions { args: trailing }); - remove(dependencies, group, operation_config) + let options = Some(RemoveOptions { + install_options: Some(InstallOptions { args: trailing }), + args: None, + }); + remove(dependencies, group, &mut config, options) } - Commands::Run { command } => run(command, operation_config), + Commands::Run { command } => run(command, &mut config), Commands::Test { trailing } => { - operation_config.test_options = - Some(TestOptions { args: trailing }); - test(operation_config) + let options = Some(TestOptions { + args: trailing, + install_options: None, + }); + test(&mut config, options) } Commands::Update { dependencies, group, trailing, } => { - operation_config.installer_options = - Some(InstallerOptions { args: trailing }); - update(dependencies, group, operation_config) + let options = Some(UpdateOptions { + install_options: Some(InstallOptions { args: trailing }), + args: None, + }); + update(dependencies, group, &mut config, options) } - Commands::Version => version(operation_config), + Commands::Version => version(&mut config), } .map_err(|e| Error::new(e, ExitCode::FAILURE)) } } -fn activate(operation_config: OperationConfig) -> HuakResult<()> { - ops::activate_venv(&operation_config) +fn activate(config: &mut Config) -> HuakResult<()> { + ops::activate_python_environment(config) } fn add( dependencies: Vec, group: Option, - operation_config: OperationConfig, + config: &mut Config, + options: Option, ) -> HuakResult<()> { let deps = dependencies .iter() @@ -373,130 +381,142 @@ fn add( .collect::>(); match group.as_ref() { Some(it) => { - ops::add_project_optional_dependencies(&deps, it, &operation_config) + ops::add_project_optional_dependencies(&deps, it, config, options) } - None => ops::add_project_dependencies(&deps, &operation_config), + None => ops::add_project_dependencies(&deps, config, options), } } -fn build(operation_config: OperationConfig) -> HuakResult<()> { - ops::build_project(&operation_config) +fn build(config: &mut Config, options: Option) -> HuakResult<()> { + ops::build_project(config, options) } -fn clean(operation_config: OperationConfig) -> HuakResult<()> { - ops::clean_project(&operation_config) +fn clean(config: &mut Config, options: Option) -> HuakResult<()> { + ops::clean_project(config, options) } -fn fix(operation_config: OperationConfig) -> HuakResult<()> { - ops::lint_project(&operation_config) +fn fix(config: &mut Config, options: Option) -> HuakResult<()> { + ops::lint_project(config, options) } -fn fmt(operation_config: OperationConfig) -> HuakResult<()> { - ops::format_project(&operation_config) +fn fmt(config: &mut Config, options: Option) -> HuakResult<()> { + ops::format_project(config, options) } fn init( app: bool, _lib: bool, - operation_config: OperationConfig, + config: &mut Config, + options: Option, ) -> HuakResult<()> { if app { - ops::init_app_project(&operation_config) + ops::init_app_project(config, options) } else { - ops::init_lib_project(&operation_config) + ops::init_lib_project(config, options) } } fn install( groups: Option>, - operation_config: OperationConfig, + config: &mut Config, + options: Option, ) -> HuakResult<()> { if let Some(it) = groups { - ops::install_project_optional_dependencies(&it, &operation_config) + if it.contains(&"all".to_string()) { + ops::install_project_dependencies(config, options.clone())?; + } + ops::install_project_optional_dependencies(&it, config, options) } else { - ops::install_project_dependencies(&operation_config) + ops::install_project_dependencies(config, options) } } -fn lint(operation_config: OperationConfig) -> HuakResult<()> { - ops::lint_project(&operation_config) +fn lint(config: &mut Config, options: Option) -> HuakResult<()> { + ops::lint_project(config, options) } fn new( app: bool, _lib: bool, - operation_config: OperationConfig, + config: &mut Config, + options: Option, ) -> HuakResult<()> { if app { - ops::new_app_project(&operation_config) + ops::new_app_project(config, options) } else { - ops::new_lib_project(&operation_config) + ops::new_lib_project(config, options) } } -fn publish(operation_config: OperationConfig) -> HuakResult<()> { - ops::publish_project(&operation_config) +fn publish( + config: &mut Config, + options: Option, +) -> HuakResult<()> { + ops::publish_project(config, options) } -fn python( - command: Python, - operation_config: OperationConfig, -) -> HuakResult<()> { +fn python(command: Python, config: &mut Config) -> HuakResult<()> { match command { - Python::List => ops::list_python(&operation_config), - Python::Use { version } => { - ops::use_python(version.0, &operation_config) - } + Python::List => ops::list_python(config), + Python::Use { version } => ops::use_python(version.0.as_str(), config), } } fn remove( dependencies: Vec, group: Option, - operation_config: OperationConfig, + config: &mut Config, + options: Option, ) -> HuakResult<()> { match group.as_ref() { Some(it) => ops::remove_project_optional_dependencies( &dependencies, it, - &operation_config, + config, + options, ), None => { - ops::remove_project_dependencies(&dependencies, &operation_config) + ops::remove_project_dependencies(&dependencies, config, options) } } } -fn run( - command: Vec, - operation_config: OperationConfig, -) -> HuakResult<()> { - ops::run_command_str(&command.join(" "), &operation_config) +fn run(command: Vec, config: &mut Config) -> HuakResult<()> { + ops::run_command_str(&command.join(" "), config) } -fn test(operation_config: OperationConfig) -> HuakResult<()> { - ops::test_project(&operation_config) +fn test(config: &mut Config, options: Option) -> HuakResult<()> { + ops::test_project(config, options) } fn update( dependencies: Option>, - groups: Option, - operation_config: OperationConfig, + group: Option, + config: &mut Config, + options: Option, ) -> HuakResult<()> { - match groups.as_ref() { - Some(it) => ops::update_project_optional_dependencies( - dependencies, - it, - &operation_config, - ), - None => { - ops::update_project_dependencies(dependencies, &operation_config) + match group.as_ref() { + Some(it) => { + if it == "all" { + ops::update_project_dependencies( + dependencies.clone(), + config, + options.clone(), + )?; + } + ops::update_project_optional_dependencies( + dependencies, + it, + config, + options, + ) } + None => ops::update_project_dependencies(dependencies, config, options), } } -fn version(operation_config: OperationConfig) -> HuakResult<()> { - ops::display_project_version(&operation_config) +fn version(config: &mut Config) -> HuakResult<()> { + ops::display_project_version(config) } fn generate_shell_completion_script() { @@ -659,14 +679,7 @@ impl FromStr for PythonVersion { ExitCode::FAILURE, ) })?; - if version.release.len() > 2 { - return Err(Error::new( - HuakError::InternalError(format!( - "{s} is invalid, use major.minor" - )), - ExitCode::FAILURE, - )); - } + Ok(Self(version.to_string())) } } diff --git a/src/huak/error.rs b/src/huak/error.rs index 774ab7cd..ecb2e756 100644 --- a/src/huak/error.rs +++ b/src/huak/error.rs @@ -10,16 +10,10 @@ pub enum Error { BuildOptionsMissingError, #[error("a problem with argument parsing occurred: {0}")] ClapError(#[from] clap::Error), - #[error("a problem with dependency resolution occurred: {0}")] - DependencyResolutionError(String), #[error("a directory already exists: {0}")] DirectoryExists(PathBuf), #[error("a problem with the environment occurred: {0}")] EnvVarError(#[from] std::env::VarError), - #[error("a problem with the pseudo-terminal occurred: {0}")] - FormatterError(String), - #[error("a problem occurred with resolving format options")] - FormatOptionsMissingError, #[error("a problem with git occurred: {0}")] GitError(#[from] git2::Error), #[error("a problem occurred with the glob package: {0}")] @@ -36,36 +30,12 @@ pub enum Error { JSONSerdeError(#[from] serde_json::Error), #[error("a problem with io occurred: {0}")] IOError(#[from] io::Error), - #[error("a problem with the linter occurred: {0}")] - LinterError(String), - #[error("a problem occurred with resolving lint options")] - LintOptionsMissingError, - #[error("a problem with building the project occurred")] - PackageBuildError, - #[error("a problem occurred initializing a package from a string")] - PackageFromStringError(String), - #[error("a problem with the package index occurred: {0}")] - PackageIndexError(String), - #[error("a problem with package installation occurred: {0}")] - PackageInstallationError(String), - #[error("a problem with the package version operator occurred: {0}")] - PackageInvalidVersionOperator(String), - #[error("a problem with the package version occurred: {0}")] - PackageInvalidVersion(String), - #[error("a problem with the package version specifier occurred")] - PackageVersionSpecifierError, - #[error("a project file could not be found")] - ProjectFileNotFound, - #[error("a pyproject.toml already exists")] - ProjectTomlExistsError, - #[error("a problem with locating the project's version number occurred")] - ProjectVersionNotFound, - #[error("a problem occurred attempting to locate the project's root")] - ProjectRootMissingError, - #[error("a problem occurred with resolving publish options")] - PublishOptionsMissingError, - #[error("an installed python module could not be found: {0}")] - PythonModuleMissingError(String), + #[error("a problem occurred initializing a dependency from a string")] + DependencyFromStringError(String), + #[error("a manifest file could not be found")] + ProjectManifestNotFoundError, + #[error("a manifest file already exists")] + ProjectManifestExistsError, #[error("a python interpreter could not be found")] PythonNotFoundError, #[error("a feature is unimplemented: {0}")] @@ -73,15 +43,13 @@ pub enum Error { #[error( "a problem occurred parsing the virtual environment's config file: {0}" )] - VenvInvalidConfigFile(String), - #[error("a venv could not be found")] - VenvNotFoundError, + VenvInvalidConfigFileError(String), + #[error("a python environment could not be found")] + PythonEnvironmentNotFoundError, #[error("a regex error occurred: {0}")] RegexError(#[from] regex::Error), #[error("a http request failed: {0}")] ReqwestError(#[from] reqwest::Error), - #[error("a problem with the test utility occurred: {0}")] - TestingError(String), #[error("a problem with toml deserialization occurred: {0}")] TOMLDeserializationError(#[from] toml::de::Error), #[error("a problem with toml serialization occurred {0}")] @@ -92,6 +60,6 @@ pub enum Error { TOMLEditSerializationError(#[from] toml_edit::ser::Error), #[error("a problem with utf-8 parsing occurred: {0}")] Utf8Error(#[from] std::str::Utf8Error), - #[error("{0}")] - CommandError(String), + #[error("a workspace could not be found")] + WorkspaceNotFoundError, } diff --git a/src/huak/fs.rs b/src/huak/fs.rs index 344a28bb..b9ef75f0 100644 --- a/src/huak/fs.rs +++ b/src/huak/fs.rs @@ -96,7 +96,7 @@ pub fn find_root_file_bottom_up>( /// Get the last component of a path. For example this function would return /// "dir" from the following path: /// /some/path/to/some/dir -pub fn last_path_component(path: impl AsRef) -> HuakResult { +pub fn last_path_component>(path: T) -> HuakResult { let path = path.as_ref(); let path = path .components() diff --git a/src/huak/git.rs b/src/huak/git.rs index db40fccf..d08f8f92 100644 --- a/src/huak/git.rs +++ b/src/huak/git.rs @@ -83,7 +83,7 @@ cython_debug/ /// Initialize a directory on a local system as a git repository /// and return the Repository. -pub fn init(path: impl AsRef) -> HuakResult { +pub fn init>(path: T) -> HuakResult { Repository::init(path).map_err(Error::GitError) } diff --git a/src/huak/lib.rs b/src/huak/lib.rs index 6a6a10b1..3c74bbae 100644 --- a/src/huak/lib.rs +++ b/src/huak/lib.rs @@ -1,25 +1,24 @@ pub use error::{Error, HuakResult}; -use fs::last_path_component; use indexmap::IndexMap; use pep440_rs::{ - parse_version_specifiers, Operator as VersionOperator, Version, - VersionSpecifier, + parse_version_specifiers, Version as Version440, VersionSpecifier, }; -use pyproject_toml::PyProjectToml as ProjectToml; +use pyproject_toml::{Contact, License, PyProjectToml as ProjectToml, ReadMe}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::{ + cmp::Ordering, collections::hash_map::RandomState, env::consts::OS, - ffi::OsString, + ffi::{OsStr, OsString}, + fmt::Display, fs::File, io::{BufRead, BufReader}, path::{Path, PathBuf}, process::Command, str::FromStr, }; -use sys::Terminal; -pub use sys::{TerminalOptions, Verbosity}; +pub use sys::{Terminal, Verbosity}; use toml::Table; mod error; @@ -28,11 +27,130 @@ mod git; pub mod ops; mod sys; -const DEFAULT_VIRTUAL_ENVIRONMENT_NAME: &str = ".venv"; -const VIRTUAL_ENVIRONMENT_CONFIG_FILE_NAME: &str = "pyvenv.cfg"; +const DEFAULT_VENV_NAME: &str = ".venv"; +const VENV_CONFIG_FILE_NAME: &str = "pyvenv.cfg"; const VERSION_OPERATOR_CHARACTERS: [char; 5] = ['=', '~', '!', '>', '<']; const VIRTUAL_ENV_ENV_VAR: &str = "VIRUTAL_ENV"; const CONDA_ENV_ENV_VAR: &str = "CONDA_PREFIX"; +const DEFAULT_PROJECT_VERSION_STR: &str = "0.0.1"; +const DEFAULT_MANIFEST_FILE_NAME: &str = "pyproject.toml"; + +/// Configuration for Huak. +pub struct Config { + /// The configured workspace root. + pub workspace_root: PathBuf, + /// The current working directory. + pub cwd: PathBuf, + /// A terminal to use. + pub terminal: Terminal, +} + +impl Config { + /// Establish the workspace. + pub fn workspace(&self) -> HuakResult { + let stop_after = match OS { + "windows" => std::env::var_os("SYSTEMROOT") + .map(PathBuf::from) + .unwrap_or(PathBuf::from("\\")), + _ => PathBuf::from("/"), + }; + + let root = match fs::find_root_file_bottom_up( + DEFAULT_MANIFEST_FILE_NAME, + &self.workspace_root, + &stop_after, + ) { + Ok(it) => it + .ok_or(Error::WorkspaceNotFoundError)? + .parent() + .ok_or(Error::InternalError( + "failed to parse parent directory".to_string(), + ))? + .to_path_buf(), + Err(e) => return Err(e), + }; + + let mut terminal = Terminal::new(); + terminal.set_verbosity(*self.terminal.verbosity()); + let ws = Workspace { + root, + config: Config { + workspace_root: self.workspace_root.to_path_buf(), + cwd: self.cwd.to_path_buf(), + terminal, + }, + }; + + Ok(ws) + } +} + +pub struct Workspace { + root: PathBuf, + config: Config, +} + +impl Workspace { + /// Resolve the current project. + fn current_project(&self) -> HuakResult { + Project::new(&self.root) + } + + /// Resolve the current Python environment. + fn current_python_environment(&mut self) -> HuakResult { + let path = find_venv_root(&self.config.cwd, &self.root)?; + let env = PythonEnvironment::new(path)?; + + Ok(env) + } + + /// Create a new Python environment to use based on the config data. + fn new_python_environment(&mut self) -> HuakResult { + let python_path = match python_paths().next() { + Some(it) => it.1, + None => return Err(Error::PythonNotFoundError), + }; + + let name = DEFAULT_VENV_NAME; + let path = self.root.join(name); + + let args = ["-m", "venv", name]; + let mut cmd = Command::new(python_path); + cmd.args(args).current_dir(&self.root); + + self.config.terminal.run_command(&mut cmd)?; + + PythonEnvironment::new(path) + } +} + +/// Search for a Python virtual environment. +/// 1. If VIRTUAL_ENV exists then a venv is active; use it. +/// 2. Walk from configured cwd up searching for dir containing the Python environment config file. +/// 3. Stop after searching `stop_after`. +pub fn find_venv_root>( + from: T, + stop_after: T, +) -> HuakResult { + if let Ok(path) = std::env::var("VIRTUAL_ENV") { + return Ok(PathBuf::from(path)); + } + + let file_path = match fs::find_root_file_bottom_up( + VENV_CONFIG_FILE_NAME, + from, + stop_after, + ) { + Ok(it) => it.ok_or(Error::PythonEnvironmentNotFoundError)?, + Err(_) => return Err(Error::PythonEnvironmentNotFoundError), + }; + + let root = file_path.parent().ok_or(Error::InternalError( + "failed to establish parent directory".to_string(), + ))?; + + Ok(root.to_path_buf()) +} /// A Python project can be anything from a script to automate some process to a /// production web application. Projects consist of Python source code and a @@ -41,144 +159,168 @@ const CONDA_ENV_ENV_VAR: &str = "CONDA_PREFIX"; /// project. See PEP 621. #[derive(Default, Debug)] pub struct Project { - /// A value to indicate the type of the project (app, library, etc.). - project_type: ProjectType, - /// Data about the project's layout. - project_layout: ProjectLayout, - /// The project's pyproject.toml file containing metadata about the project. - /// See https://peps.python.org/pep-0621/ - pyproject_toml: PyProjectToml, + /// A value to indicate the kind of the project (app, library, etc.). + kind: ProjectKind, + /// The project's manifest data. + manifest: Manifest, + /// The absolute path to the project's manifest file. + manifest_path: PathBuf, } impl Project { - /// Create a new project. - pub fn new() -> Project { - Project { - project_type: ProjectType::Library, - project_layout: ProjectLayout { - root: PathBuf::new(), - pyproject_toml_path: PathBuf::new(), - }, - pyproject_toml: PyProjectToml::new(), + /// Initialize a `Project` from its root path. + pub fn new>(path: T) -> HuakResult { + let root = std::fs::canonicalize(path)?; + + let manifest_path = root.join(DEFAULT_MANIFEST_FILE_NAME); + if !manifest_path.exists() { + return Err(Error::ProjectManifestNotFoundError); } - } - /// Create a project from its manifest file path. - pub fn from_manifest(path: impl AsRef) -> HuakResult { - let path = path.as_ref(); - let mut project = Project::new(); - project.pyproject_toml = PyProjectToml::from_path(path)?; - project.project_layout = ProjectLayout { - root: path - .parent() - .ok_or(Error::ProjectRootMissingError)? - .to_path_buf(), - pyproject_toml_path: path.to_path_buf(), + let pyproject_toml = + PyProjectToml::new(root.join(DEFAULT_MANIFEST_FILE_NAME))?; + + let mut project = Project { + kind: ProjectKind::Library, + manifest: Manifest::from(pyproject_toml), + manifest_path, }; + + // If the manifest contains any scripts the project is considered an application. + // TODO: Should be entry points. + if project.manifest.scripts.is_some() { + project.kind = ProjectKind::Application; + } + Ok(project) } /// Get the absolute path to the root directory of the project. - pub fn root(&self) -> &PathBuf { - &self.project_layout.root + pub fn root(&self) -> Option<&Path> { + self.manifest_path.parent() } /// Get the name of the project. - pub fn name(&self) -> HuakResult<&str> { - self.pyproject_toml - .project_name() - .ok_or(Error::InternalError("project name not found".to_string())) + pub fn name(&self) -> &String { + &self.manifest.name } /// Get the version of the project. - pub fn version(&self) -> HuakResult<&str> { - self.pyproject_toml - .project_version() - .ok_or(Error::InternalError( - "project version not found".to_string(), - )) + pub fn version(&self) -> Option<&String> { + self.manifest.version.as_ref() } /// Get the path to the manifest file. pub fn manifest_path(&self) -> &PathBuf { - &self.project_layout.pyproject_toml_path + &self.manifest_path } /// Get the project type. - pub fn project_type(&self) -> &ProjectType { - &self.project_type + pub fn kind(&self) -> &ProjectKind { + &self.kind } /// Get the Python project's pyproject.toml file. - pub fn pyproject_toml(&self) -> &PyProjectToml { - &self.pyproject_toml + pub fn manifest(&self) -> &Manifest { + &self.manifest } - /// Get the Python project's main dependencies listed in the project file. - pub fn dependencies(&self) -> Option<&Vec> { - self.pyproject_toml.dependencies() + /// Get the Python project's main dependencies listed in the manifest. + pub fn dependencies(&self) -> Option<&Vec> { + self.manifest.dependencies.as_ref() } - /// Get the Python project's optional dependencies listed in the project file. + /// Get the Python project's optional dependencies listed in the manifest. pub fn optional_dependencies( &self, - ) -> Option<&IndexMap>> { - self.pyproject_toml.optional_dependencies() + ) -> Option<&IndexMap>> { + self.manifest.optional_dependencies.as_ref() } - /// Get a group of optional dependencies from the Python project's manifest file. + /// Get a group of optional dependencies from the Python project's manifest. pub fn optional_dependencey_group( &self, - group_name: &str, - ) -> Option<&Vec> { - self.pyproject_toml.optional_dependencey_group(group_name) + group: &str, + ) -> Option<&Vec> { + self.manifest + .optional_dependencies + .as_ref() + .and_then(|item| item.get(group)) } - /// Add a Python package as a dependency to the project's manifest file. - pub fn add_dependency(&mut self, package_str: &str) -> HuakResult<()> { - if !self.contains_dependency(package_str)? { - self.pyproject_toml.add_dependency(package_str); + /// Add a Python package as a dependency to the project's manifest. + pub fn add_dependency(&mut self, dependency: Dependency) -> HuakResult<()> { + if self.contains_dependency(&dependency)? { + return Ok(()); } + self.manifest + .dependencies + .get_or_insert(Vec::new()) + .push(dependency); Ok(()) } - /// Add a Python package as a dependency to the project' project file. + /// Add a Python package as a dependency to the project' manifest. pub fn add_optional_dependency( &mut self, - package_str: &str, - group_name: &str, + dependency: Dependency, + group: &str, ) -> HuakResult<()> { - if !self - .contains_optional_dependency(package_str, group_name) - .unwrap_or_default() - { - self.pyproject_toml - .add_optional_dependency(package_str, group_name) + if self.contains_optional_dependency(&dependency, group)? { + return Ok(()); } + self.manifest + .optional_dependencies + .get_or_insert(IndexMap::new()) + .get_mut(group) + .unwrap_or(&mut Vec::new()) + .push(dependency); Ok(()) } - /// Remove a dependency from the project's manifest file. - pub fn remove_dependency(&mut self, package_str: &str) { - self.pyproject_toml.remove_dependency(package_str); + /// Remove a dependency from the project's manifest. + pub fn remove_dependency( + &mut self, + dependency: &Dependency, + ) -> HuakResult<()> { + if !self.contains_dependency(dependency)? { + return Ok(()); + } + if let Some(deps) = self.manifest.dependencies.as_mut() { + if let Some(i) = deps.iter().position(|item| item.eq(dependency)) { + deps.remove(i); + }; + } + Ok(()) } - /// Remove an optional dependency from the project's manifest file. + /// Remove an optional dependency from the project's manifest. pub fn remove_optional_dependency( &mut self, - package_str: &str, - group_name: &str, - ) { - self.pyproject_toml - .remove_optional_dependency(package_str, group_name); + dependency: &Dependency, + group: &str, + ) -> HuakResult<()> { + if !self.contains_optional_dependency(dependency, group)? { + return Ok(()); + } + if let Some(deps) = self.manifest.optional_dependencies.as_mut() { + if let Some(g) = deps.get_mut(group) { + if let Some(i) = g.iter().position(|item| item.eq(dependency)) { + g.remove(i); + }; + }; + } + Ok(()) } - /// Check if the project has a dependency listed in its manifest file. - pub fn contains_dependency(&self, package_str: &str) -> HuakResult { - let package = Package::from_str(package_str)?; + /// Check if the project has a dependency listed in its manifest. + pub fn contains_dependency( + &self, + dependency: &Dependency, + ) -> HuakResult { if let Some(deps) = self.dependencies() { - for dep in deps { - if Package::from_str(dep)?.name() == package.name() { + for d in deps { + if d.eq(dependency) { return Ok(true); } } @@ -186,63 +328,129 @@ impl Project { Ok(false) } - /// Check if the project has an optional dependency listed in its manifest file. + /// Check if the project has an optional dependency listed in its manifest. pub fn contains_optional_dependency( &self, - package_str: &str, + dependency: &Dependency, group: &str, ) -> HuakResult { - if let Some(groups) = - self.pyproject_toml.optional_dependencey_group(group) - { - if groups.is_empty() { - return Ok(false); - } - let package = Package::from_str(package_str)?; - for dep in groups { - if Package::from_str(dep)?.name() == package.name() { - return Ok(true); + if let Some(deps) = self.manifest.optional_dependencies.as_ref() { + if let Some(g) = deps.get(group) { + if deps.is_empty() { + return Ok(false); + } + for d in g { + if d.eq(dependency) { + return Ok(true); + } } } } Ok(false) } - /// Check if the project has a dependency listed in its manifest file as part of any group. + /// Check if the project has a dependency listed in its manifest as part of any group. pub fn contains_dependency_any( &self, - package_str: &str, + dependency: &Dependency, ) -> HuakResult { - if self.contains_dependency(package_str).unwrap_or_default() { + if self.contains_dependency(dependency).unwrap_or_default() { return Ok(true); } - if let Some(groups) = self.pyproject_toml.optional_dependencies() { - if groups.is_empty() { + + if let Some(deps) = self.manifest.optional_dependencies.as_ref() { + if deps.is_empty() { return Ok(false); } - let package = Package::from_str(package_str)?; - for dep in groups.values().flatten() { - if Package::from_str(dep)?.name() == package.name() { + for d in deps.values().flatten() { + if d.eq(dependency) { return Ok(true); } } } Ok(false) } -} -impl From for Project { - fn from(value: ProjectType) -> Self { - let mut project = Project::new(); - project.project_type = value; - project + /// Write the manifest file. + /// Note that this method currently only supports writing a pyproject.toml. + pub fn write_manifest(&self) -> HuakResult<()> { + // If the manifest file isn't a pyproject.toml then fail. (TODO: other manifests) + if self + .manifest_path + .file_name() + .and_then(|raw_file_name| raw_file_name.to_str()) + != Some(DEFAULT_MANIFEST_FILE_NAME) + { + return Err(Error::UnimplementedError(format!( + "unsupported manifest file {}", + self.manifest_path.display() + ))); + } + + // If a valie file already exists merge with it and write the file. + let file = if self.manifest_path.exists() { + self.merge_pyproject_toml(PyProjectToml::new(&self.manifest_path)?) + } else { + self.merge_pyproject_toml(PyProjectToml::default()) + }; + + file.write_file(&self.manifest_path) + } + + /// Merge the project's manifest data with other pyproject.toml data. + /// This method prioritizes manfiest data the project utilizes. For everything else + /// the other data is retained. + /// 1. toml <- manifest + /// 2. toml <- other.exclude(manfiest) + fn merge_pyproject_toml(&self, other: PyProjectToml) -> PyProjectToml { + let mut pyproject_toml = other; + pyproject_toml.set_project_name(self.manifest.name.clone()); + if self.manifest.version.is_some() { + pyproject_toml.set_project_version(self.manifest.version.clone()); + } + if self.manifest.description.is_some() { + pyproject_toml + .set_project_description(self.manifest.description.clone()); + } + if self.manifest.authors.is_some() { + pyproject_toml.set_project_authors(self.manifest.authors.clone()); + } + if self.manifest.scripts.is_some() { + pyproject_toml.set_project_scripts(self.manifest.scripts.clone()); + } + if self.manifest.license.is_some() { + pyproject_toml.set_project_license(self.manifest.license.clone()); + } + if self.manifest.readme.is_some() { + pyproject_toml.set_project_readme(self.manifest.readme.clone()); + } + if self.manifest.dependencies.is_some() { + pyproject_toml.set_project_dependencies( + self.manifest.dependencies.as_ref().map(|deps| { + deps.iter().map(|dep| dep.to_string()).collect() + }), + ); + } + if self.manifest.optional_dependencies.is_some() { + pyproject_toml.set_project_optional_dependencies( + self.manifest.optional_dependencies.as_ref().map(|groups| { + IndexMap::from_iter(groups.iter().map(|(group, deps)| { + ( + group.clone(), + deps.iter().map(|dep| dep.to_string()).collect(), + ) + })) + }), + ); + } + pyproject_toml } } /// A project type might indicate if a project is an application-like project or a /// library-like project. #[derive(Default, Eq, PartialEq, Debug)] -pub enum ProjectType { +pub enum ProjectKind { /// Library-like projects are essentially anything that isn’t an application. An /// example would be a typical Python package distributed to PyPI. #[default] @@ -252,16 +460,150 @@ pub enum ProjectType { Application, } -/// Data about the project's layout. The project layout includes the location of -/// important files and directories. +/// Manifest data for `Project`s. +/// +/// The manifest contains information about the project including its name, version, +/// dependencies, etc. #[derive(Default, Debug)] -pub struct ProjectLayout { - /// The absolute path to the root directory of the project. - root: PathBuf, - /// The absolute path to the pyproject.toml file. - pyproject_toml_path: PathBuf, +pub struct Manifest { + authors: Option>, + dependencies: Option>, + description: Option, + scripts: Option>, + license: Option, + name: String, + optional_dependencies: Option>>, + readme: Option, + version: Option, +} + +/// Initialize a `Manifest` from `PyProjectToml`. +impl From for Manifest { + fn from(value: PyProjectToml) -> Self { + let project = match value.project.as_ref() { + Some(it) => it, + None => return Self::default(), + }; + + Self { + authors: project.authors.clone(), + dependencies: project.dependencies.as_ref().map(|items| items + .iter() + .map(|item| { + Dependency::from_str(item) + .expect("failed to parse toml dependencies") + }) + .collect::>()), + description: project.description.clone(), + scripts: project.scripts.clone(), + license: project.license.clone(), + name: project.name.clone(), + // TODO: fmt? + optional_dependencies: project.optional_dependencies.as_ref().map(|groups| IndexMap::from_iter(groups.iter().map(|(group, deps)| (group.clone(), deps.iter().map(|dep| Dependency::from_str(dep).expect("failed to parse toml optinoal dependencies")).collect())))), + readme: project.readme.clone(), + version: project.version.clone(), + } + } +} + +#[derive(Debug)] +/// A Python `Dependency` struct. +pub struct Dependency { + /// The dependency's name unmodified. + name: String, + /// The canonical dependency name. + canonical_name: String, + /// The dependency's PEP440 version specifiers. + version_specifiers: Option>, +} + +impl Dependency { + /// Get the dependency name with its version specifiers as a &str. + pub fn dependency_string(&self) -> String { + let specs = match self.version_specifiers.as_ref() { + Some(it) => it, + None => { + return self.name.to_string(); + } + }; + + format!( + "{}{}", + self.name, + specs + .iter() + .map(|spec| spec + .to_string() + .split_whitespace() + .collect::>() + .join("")) + .collect::>() + .join(",") + ) + } + + fn importable_name(&self) -> HuakResult { + importable_package_name(&self.canonical_name) + } +} + +/// Display the dependency with the following format "{name}{version specs}" +/// where version specs are comma-delimited. +impl Display for Dependency { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.dependency_string()) + } +} + +impl AsRef for Dependency { + fn as_ref(&self) -> &OsStr { + OsStr::new(self) + } +} + +/// Initilize a `Dependency` from a `&str`. +impl FromStr for Dependency { + type Err = Error; + + fn from_str(s: &str) -> Result { + let found = s + .chars() + .enumerate() + .find(|x| VERSION_OPERATOR_CHARACTERS.contains(&x.1)); + + let spec = match found { + Some(it) => &s[it.0..], + None => { + return Ok(Dependency { + name: s.to_string(), + canonical_name: canonical_package_name(s)?, + version_specifiers: None, + }); + } + }; + + let name = s.strip_suffix(&spec).unwrap_or(s).to_string(); + let specs = parse_version_specifiers(spec) + .map_err(|e| Error::DependencyFromStringError(e.to_string()))?; + + let dependency = Dependency { + name: name.to_string(), + canonical_name: canonical_package_name(name.as_ref())?, + version_specifiers: Some(specs), + }; + + Ok(dependency) + } } +impl PartialEq for Dependency { + fn eq(&self, other: &Self) -> bool { + self.canonical_name == other.canonical_name + } +} + +impl Eq for Dependency {} + /// A pyproject.toml as specified in PEP 517 #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "kebab-case")] @@ -286,31 +628,23 @@ impl std::ops::DerefMut for PyProjectToml { } impl PyProjectToml { - /// Create new pyproject.toml data. - pub fn new() -> PyProjectToml { - PyProjectToml::default() - } - - /// Create new pyproject.toml data from a pyproject.toml's path. - pub fn from_path(path: impl AsRef) -> HuakResult { + /// Initilize a `PyProjectToml` from its path. + pub fn new>(path: T) -> HuakResult { let contents = std::fs::read_to_string(path)?; let pyproject_toml: PyProjectToml = toml::from_str(&contents)?; Ok(pyproject_toml) } - /// Get the project name. pub fn project_name(&self) -> Option<&str> { self.project.as_ref().map(|project| project.name.as_str()) } - /// Set the project name listed in the toml. - pub fn set_project_name(&mut self, name: &str) { + pub fn set_project_name(&mut self, name: String) { if let Some(project) = self.project.as_mut() { - project.name = name.to_string(); + project.name = name; } } - /// Get the project version. pub fn project_version(&self) -> Option<&str> { if let Some(project) = self.project.as_ref() { return project.version.as_deref(); @@ -318,7 +652,12 @@ impl PyProjectToml { None } - /// Get the Python project's main dependencies. + pub fn set_project_version(&mut self, version: Option) { + if let Some(project) = self.project.as_mut() { + project.version = version; + } + } + pub fn dependencies(&self) -> Option<&Vec> { if let Some(project) = self.project.as_ref() { return project.dependencies.as_ref(); @@ -326,7 +665,15 @@ impl PyProjectToml { None } - /// Get all of the Python project's optional dependencies. + pub fn set_project_dependencies( + &mut self, + dependencies: Option>, + ) { + if let Some(project) = self.project.as_mut() { + project.dependencies = dependencies; + } + } + pub fn optional_dependencies( &self, ) -> Option<&IndexMap>> { @@ -336,7 +683,48 @@ impl PyProjectToml { None } - /// Get a group of optional dependencies. + pub fn set_project_optional_dependencies( + &mut self, + optional_dependencies: Option>>, + ) { + if let Some(project) = self.project.as_mut() { + project.optional_dependencies = optional_dependencies; + } + } + + pub fn set_project_license(&mut self, license: Option) { + if let Some(project) = self.project.as_mut() { + project.license = license; + } + } + + pub fn set_project_readme(&mut self, readme: Option) { + if let Some(project) = self.project.as_mut() { + project.readme = readme; + } + } + + pub fn set_project_scripts( + &mut self, + scripts: Option>, + ) { + if let Some(project) = self.project.as_mut() { + project.scripts = scripts; + } + } + + pub fn set_project_authors(&mut self, authors: Option>) { + if let Some(project) = self.project.as_mut() { + project.authors = authors; + } + } + + pub fn set_project_description(&mut self, description: Option) { + if let Some(project) = self.project.as_mut() { + project.description = description; + } + } + pub fn optional_dependencey_group( &self, group_name: &str, @@ -349,39 +737,26 @@ impl PyProjectToml { None } - /// Add a dependency. - pub fn add_dependency(&mut self, package_str: &str) { + pub fn add_dependency(&mut self, dependency: &str) { if let Some(project) = self.project.as_mut() { if let Some(dependencies) = project.dependencies.as_mut() { - dependencies.push(package_str.to_string()); + dependencies.push(dependency.to_string()); } }; } - /// Add an optional dependency. - pub fn add_optional_dependency( - &mut self, - package_str: &str, - group_name: &str, - ) { + pub fn add_optional_dependency(&mut self, dependency: &str, group: &str) { if let Some(project) = self.project.as_mut() { - if project.optional_dependencies.is_none() { - project.optional_dependencies = Some(IndexMap::new()) - } - if let Some(deps) = project.optional_dependencies.as_mut() { - if let Some(group) = deps.get_mut(group_name) { - group.push(package_str.to_string()); - } else { - deps.insert( - group_name.to_string(), - vec![package_str.to_string()], - ); - } + let deps = + project.optional_dependencies.get_or_insert(IndexMap::new()); + if let Some(g) = deps.get_mut(group) { + g.push(dependency.to_string()); + } else { + deps.insert(group.to_string(), vec![dependency.to_string()]); } } } - /// Remove a dependency. pub fn remove_dependency(&mut self, package_str: &str) { if let Some(project) = self.project.as_mut() { if let Some(dependencies) = project.dependencies.as_mut() { @@ -395,27 +770,24 @@ impl PyProjectToml { }; } - /// Remove an optional dependency. pub fn remove_optional_dependency( &mut self, - package_str: &str, - group_name: &str, + dependency: &str, + group: &str, ) { if let Some(project) = self.project.as_mut() { - if let Some(group) = project.optional_dependencies.as_mut() { - if let Some(dependencies) = group.get_mut(group_name) { - if let Some(i) = dependencies - .iter() - .position(|item| item.contains(package_str)) + if let Some(g) = project.optional_dependencies.as_mut() { + if let Some(deps) = g.get_mut(group) { + if let Some(i) = + deps.iter().position(|item| item.contains(dependency)) { - dependencies.remove(i); + deps.remove(i); }; }; } }; } - /// Get the scripts. pub fn scripts(&self) -> Option<&IndexMap> { if let Some(project) = self.project.as_ref() { return project.scripts.as_ref(); @@ -423,7 +795,6 @@ impl PyProjectToml { None } - /// Add a new script. pub fn add_script( &mut self, name: &str, @@ -442,18 +813,15 @@ impl PyProjectToml { Ok(()) } - /// Write the toml file. pub fn write_file(&self, path: impl AsRef) -> HuakResult<()> { let string = self.to_string_pretty()?; Ok(std::fs::write(path, string)?) } - /// Convert the toml struct to a formatted String. pub fn to_string_pretty(&self) -> HuakResult { Ok(toml_edit::ser::to_string_pretty(&self)?) } - /// Convert the toml to a string as-is. pub fn to_string(&self) -> HuakResult { Ok(toml_edit::ser::to_string(&self)?) } @@ -469,8 +837,16 @@ impl Default for PyProjectToml { } } +fn default_project_manifest_file_name() -> &'static str { + DEFAULT_MANIFEST_FILE_NAME +} + +fn default_project_version_str() -> &'static str { + DEFAULT_PROJECT_VERSION_STR +} + fn default_virtual_environment_name() -> &'static str { - DEFAULT_VIRTUAL_ENVIRONMENT_NAME + DEFAULT_VENV_NAME } fn default_pyproject_toml_contents(project_name: &str) -> String { @@ -521,154 +897,187 @@ if __name__ == "__main__": .to_string() } -/// A PEP-compliant Python environment API. +/// The PythonEnvironment struct. /// -/// Python environments contain the following: -/// executables directory (unix: bin; windows: Scripts) -/// include (windows: Include) -/// lib -/// └── pythonX.Y -/// └── site-packages (windows: Lib/site-packages) -/// ├── some_pkg -/// └── some_pkg-X.X.X.dist-info -/// pyvenv.cfg -#[derive(Default, Debug)] -pub struct VirtualEnvironment { - /// Absolute path to the root of the virtual environment directory. +/// Python environments are used to execute Python-based processes. Python +/// environments contain a Python interpreter, an executables directory, +/// installed Python packages, etc. This struct is an abstraction for that +/// environment, allowing various processes to interact with Python. +struct PythonEnvironment { + /// The absolute path to the Python environment's root. root: PathBuf, - /// The installer the virtual environment uses to install python packages. - installer: Installer, - /// The pyvenv.cfg data. - config: VirtualEnvironmentConfig, -} - -impl VirtualEnvironment { - /// Create a new virtual environment. - pub fn new() -> VirtualEnvironment { - VirtualEnvironment { - root: PathBuf::new(), - installer: Installer::new(), - config: VirtualEnvironmentConfig::new(), + /// The absolute path to the Python environment's Python interpreter. + python_path: PathBuf, + /// The version of the Python environment's Python interpreter. + python_version: Version, + /// The absolute path to the Python environment's executables directory. + executables_dir_path: PathBuf, + /// The absolute path to the Python environment's site-packages directory. + site_packages_path: PathBuf, + /// The Python package installer associated with the Python environment. + installer: Option, + #[allow(dead_code)] + /// The kind of Python environment the environment is. + kind: PythonEnvironmentKind, +} + +impl PythonEnvironment { + /// Initialize a new `PythonEnvironment`. + pub fn new>(path: T) -> HuakResult { + if !path.as_ref().join(VENV_CONFIG_FILE_NAME).exists() { + return Err(Error::UnimplementedError(format!( + "{} is not supported", + path.as_ref().display() + ))); } + PythonEnvironment::venv(path) + } + + // TODO: Could instead construct the config and do PythonEnvironment::new(config) + fn venv>(path: T) -> HuakResult { + let kind = PythonEnvironmentKind::Venv; + let root = std::fs::canonicalize(path)?; + let config = + VenvConfig::from(root.join(VENV_CONFIG_FILE_NAME).as_ref()); + let python_version = config.version; + + // Establishing paths differs between Windows and Unix systems. + #[cfg(unix)] + let executables_dir_path = root.join("bin"); + #[cfg(unix)] + let python_path = executables_dir_path.join("python"); + #[cfg(windows)] + let executables_dir_path = root.join("Scripts"); + #[cfg(windows)] + let python_path = executables_dir_path.join("python.exe"); + + let python_version = if python_version.semver.is_some() { + python_version + } else { + parse_python_interpreter_version(&python_path)?.unwrap_or(Version { + release: python_version.release.clone(), + semver: Some(SemVerVersion { + major: python_version.release[0], + minor: *python_version.release.get(1).unwrap_or(&0), + patch: *python_version.release.get(2).unwrap_or(&0), + }), + }) + }; + + let semver = match python_version.semver.as_ref() { + Some(it) => it, + None => { + return Err(Error::VenvInvalidConfigFileError(format!( + "could not parse version from {VENV_CONFIG_FILE_NAME}" + ))) + } + }; + + // On Unix systems the Venv's site-package directory depends on the Python version. + // The path is root/lib/pythonX.X/site-packages. + #[cfg(unix)] + let site_packages_path = root + .join("lib") + .join(format!("python{}.{}", semver.major, semver.minor)) + .join("site-packages"); + #[cfg(unix)] + let installer = PackageInstaller::Pip(executables_dir_path.join("pip")); + #[cfg(windows)] + let site_packages_path = root.join("Lib").join("site-packages"); + #[cfg(windows)] + let installer = + PackageInstaller::Pip(executables_dir_path.join("pip.exe")); + + let venv = PythonEnvironment { + root, + python_path, + python_version, + executables_dir_path, + site_packages_path, + installer: Some(installer), + kind, + }; + + Ok(venv) } - /// Get a reference to the absolute path to the virtual environment. + /// Get a reference to the absolute path to the python environment. pub fn root(&self) -> &Path { self.root.as_ref() } - /// Get the name of the virtual environment. + /// Get the name of the Python environment. pub fn name(&self) -> HuakResult { - last_path_component(self.root()) - } - - /// Create a virtual environment from its root path. - pub fn from_path(path: impl AsRef) -> HuakResult { - let path = path.as_ref(); - let mut venv = VirtualEnvironment { - root: path.to_path_buf(), - installer: Installer::new(), - config: VirtualEnvironmentConfig::from_path( - path.join(VIRTUAL_ENVIRONMENT_CONFIG_FILE_NAME), - )?, - }; - let mut installer = Installer::new(); - installer.set_config(InstallerConfig { - path: venv.executables_dir_path().join("pip"), - }); - venv.installer = installer; - Ok(venv) + fs::last_path_component(&self.root) } /// The absolute path to the Python environment's python interpreter binary. - pub fn python_path(&self) -> PathBuf { - #[cfg(windows)] - let file_name = "python.exe"; - #[cfg(unix)] - let file_name = "python"; - self.executables_dir_path().join(file_name) - } - - /// The absolute path to the Python interpreter used to create the virtual environment. - pub fn base_python_path(&self) -> Option<&PathBuf> { - self.config.executable.as_ref() + pub fn python_path(&self) -> &PathBuf { + &self.python_path } + #[allow(dead_code)] /// Get the version of the Python environment's Python interpreter. - pub fn python_version(&self) -> Option<&Version> { - self.config.version.as_ref() + pub fn python_version(&self) -> &Version { + &self.python_version } /// The absolute path to the Python environment's executables directory. - pub fn executables_dir_path(&self) -> PathBuf { - #[cfg(windows)] - let dir_name = "Scripts"; - #[cfg(unix)] - let dir_name = "bin"; - self.root.join(dir_name) + pub fn executables_dir_path(&self) -> &PathBuf { + &self.executables_dir_path } /// The absolute path to the Python environment's site-packages directory. - pub fn site_packages_dir_path(&self) -> HuakResult { - let path = match OS { - "windows" => self.root.join("Lib").join("site-packages"), - _ => { - let version = match self.python_version() { - Some(it) => it, - None => { - return Err(Error::VenvInvalidConfigFile( - "missing version".to_string(), - )) - } - }; - self.root - .join("lib") - .join(format!( - "python{}", - version - .release - .iter() - .take(2) - .map(|&x| x.to_string()) - .collect::>() - .join(".") - )) - .join("site-packages") - } - }; - Ok(path) + pub fn site_packages_dir_path(&self) -> &PathBuf { + &self.site_packages_path } /// Install Python packages to the environment. - pub fn install_packages( + pub fn install_packages( &self, - packages: &[Package], - installer_options: Option<&InstallerOptions>, + packages: &[T], + installer_options: Option<&PackageInstallerOptions>, terminal: &mut Terminal, - ) -> HuakResult<()> { - self.installer - .install(packages, installer_options, terminal) + ) -> HuakResult<()> + where + T: Display + AsRef, + { + if let Some(installer) = self.installer.as_ref() { + installer.install(packages, installer_options, terminal)?; + } + Ok(()) } /// Uninstall many Python packages from the environment. - pub fn uninstall_packages( + pub fn uninstall_packages( &self, - packages: &[&str], - installer_options: Option<&InstallerOptions>, + packages: &[T], + installer_options: Option<&PackageInstallerOptions>, terminal: &mut Terminal, - ) -> HuakResult<()> { - self.installer - .uninstall(packages, installer_options, terminal) + ) -> HuakResult<()> + where + T: Display + AsRef, + { + if let Some(installer) = self.installer.as_ref() { + installer.uninstall(packages, installer_options, terminal)?; + } + Ok(()) } /// Update many Python packages in the environment. - pub fn update_packages( + pub fn update_packages( &self, - packages: &[&str], - installer_options: Option<&InstallerOptions>, + packages: &[T], + installer_options: Option<&PackageInstallerOptions>, terminal: &mut Terminal, - ) -> HuakResult<()> { - self.installer.update(packages, installer_options, terminal) + ) -> HuakResult<()> + where + T: Display + AsRef, + { + if let Some(installer) = self.installer.as_ref() { + installer.update(packages, installer_options, terminal)?; + } + Ok(()) } /// Check if the environment is already activated. @@ -699,192 +1108,316 @@ impl VirtualEnvironment { } } + #[allow(dead_code)] /// Check if the environment has a package already installed. - pub fn contains_package(&self, package: &Package) -> HuakResult { - Ok(self - .site_packages_dir_path()? - .join(to_importable_package_name(package.name())?) - .exists()) + pub fn contains_package(&self, package: &Package) -> bool { + self.site_packages_dir_path() + .join( + package + .importable_name() + .unwrap_or(package.canonical_name.to_string()), + ) + .exists() } } -/// Search for a Python virtual environment. -/// 1. If VIRTUAL_ENV exists then a venv is active; use it. -/// 2. Walk from CWD up searching for dir containing pyvenv.cfg. -/// 3. Stop after searching the workspace root. -pub fn find_venv_root(workspace_root: impl AsRef) -> HuakResult { - if let Ok(path) = std::env::var("VIRTUAL_ENV") { - return Ok(PathBuf::from(path)); - } - let cwd = std::env::current_dir()?; - let file_path = match fs::find_root_file_bottom_up( - VIRTUAL_ENVIRONMENT_CONFIG_FILE_NAME, - cwd.as_path(), - workspace_root.as_ref(), - ) { - Ok(it) => it.ok_or(Error::VenvNotFoundError)?, - Err(_) => return Err(Error::VenvNotFoundError), +/// Get a `Version` from a Python interpreter using its path. +/// +/// 1. Attempt to parse the version number from the path. +/// 2. Run `{path} --version` and parse from the output. +fn parse_python_interpreter_version>( + path: T, +) -> HuakResult> { + let version = match path + .as_ref() + .file_name() + .and_then(|raw_file_name| raw_file_name.to_str()) + { + Some(file_name) => { + version_from_python_interpreter_file_name(file_name).ok() + } + None => { + let mut cmd = Command::new(path.as_ref()); + cmd.args(["--version"]); + let output = cmd.output()?; + Version::from_str(&sys::parse_command_output(output)?).ok() + } }; - let root = file_path.parent().ok_or(Error::InternalError( - "failed to establish parent directory".to_string(), - ))?; - Ok(root.to_path_buf()) + Ok(version) +} + +/// Kinds of Python environments. +/// +/// Venv +/// executables directory (unix: bin; windows: Scripts) +/// include (windows: Include) +/// lib +/// └── pythonX.Y +/// └── site-packages (windows: Lib/site-packages) +/// ├── some_pkg +/// └── some_pkg-X.X.X.dist-info +/// pyvenv.cfg +enum PythonEnvironmentKind { + Venv, } -#[derive(Default, Debug)] /// Data about some environment's Python configuration. This abstraction is modeled after /// the pyenv.cfg file used for Python virtual environments. -struct VirtualEnvironmentConfig { +struct VenvConfig { /// The version of the environment's Python interpreter. - version: Option, - /// The path to the Python interpreter used to create the virtual environment. - executable: Option, + version: Version, } -impl VirtualEnvironmentConfig { - pub fn new() -> VirtualEnvironmentConfig { - VirtualEnvironmentConfig { - version: None, - executable: None, - } - } - - pub fn from_path( - path: impl AsRef, - ) -> HuakResult { - let file = File::open(&path)?; +impl From<&Path> for VenvConfig { + fn from(value: &Path) -> Self { + // Read the file and flatten the lines for parsing. + let file = File::open(value) + .unwrap_or_else(|_| panic!("failed to open {}", value.display())); let buff_reader = BufReader::new(file); let lines: Vec = buff_reader.lines().flatten().collect(); - let mut version = None; - let mut executable = None; + + // Search for version = "X.X.X" + let mut version = Version::from_str(""); lines.iter().for_each(|item| { - let mut vals = item.splitn(2, " = "); - let name = vals.next().unwrap_or_default(); - let value = vals.next().unwrap_or_default(); - if name == "version" { - version = Version::from_str(value).ok(); - } - if name == "executable" { - executable = Some(PathBuf::from(value)); + let mut split = item.splitn(2, '='); + let key = split.next().unwrap_or_default().trim(); + let val = split.next().unwrap_or_default().trim(); + if key == "version" { + version = Version::from_str(val); } }); + let version = version.unwrap_or_else(|_| { + panic!("failed to parse version from {}", value.display()) + }); - Ok(VirtualEnvironmentConfig { - version, - executable, - }) + VenvConfig { version } } } -/// A struct for managing installing packages. -#[derive(Default, Debug)] -pub struct Installer { - /// Configuration for package installation - config: InstallerConfig, +/// Kinds of Python package installers. +/// +/// Pip +/// The absolute path to `pip`. +enum PackageInstaller { + /// The `pip` Python package installer. + Pip(PathBuf), } -impl Installer { - pub fn new() -> Installer { - Installer { - config: InstallerConfig::new(), - } - } - - pub fn config(&self) -> &InstallerConfig { - &self.config - } +impl PackageInstaller { + pub fn install( + &self, + packages: &[T], + options: Option<&PackageInstallerOptions>, + terminal: &mut Terminal, + ) -> HuakResult<()> + where + T: Display + AsRef, + { + match self { + PackageInstaller::Pip(path) => { + let mut cmd = Command::new(path); + cmd.arg("install") + .args(packages.iter().map(|item| item.to_string())); + + if let Some(PackageInstallerOptions::Pip { args }) = options { + if let Some(args) = args.as_ref() { + cmd.args(args.iter().map(|item| item.as_str())); + } + } - pub fn set_config(&mut self, config: InstallerConfig) { - self.config = config; + terminal.run_command(&mut cmd) + } + } } - pub fn install( + pub fn uninstall( &self, - packages: &[Package], - options: Option<&InstallerOptions>, + packages: &[T], + options: Option<&PackageInstallerOptions>, terminal: &mut Terminal, - ) -> HuakResult<()> { - let mut cmd = Command::new(self.config.path.clone()); - cmd.arg("install") - .args(packages.iter().map(|item| item.dependency_string())); - if let Some(it) = options { - if let Some(args) = it.args.as_ref() { - cmd.args(args.iter().map(|item| item.as_str())); + ) -> HuakResult<()> + where + T: Display + AsRef, + { + match self { + PackageInstaller::Pip(path) => { + let mut cmd = Command::new(path); + cmd.arg("uninstall") + .args(packages.iter().map(|item| item.to_string())) + .arg("-y"); + + if let Some(PackageInstallerOptions::Pip { args }) = options { + if let Some(args) = args.as_ref() { + cmd.args(args.iter().map(|item| item.as_str())); + } + } + + terminal.run_command(&mut cmd) } } - terminal.run_command(&mut cmd) } - pub fn uninstall( + pub fn update( &self, - packages: &[&str], - options: Option<&InstallerOptions>, + packages: &[T], + options: Option<&PackageInstallerOptions>, terminal: &mut Terminal, - ) -> HuakResult<()> { - let mut cmd = Command::new(self.config.path.clone()); - cmd.arg("uninstall").args(packages).arg("-y"); - if let Some(it) = options { - if let Some(args) = it.args.as_ref() { - cmd.args(args.iter().map(|item| item.as_str())); + ) -> HuakResult<()> + where + T: Display + AsRef, + { + match self { + PackageInstaller::Pip(path) => { + let mut cmd = Command::new(path); + cmd.args(["install", "--upgrade"]) + .args(packages.iter().map(|item| item.to_string())); + + if let Some(PackageInstallerOptions::Pip { args }) = options { + if let Some(args) = args.as_ref() { + cmd.args(args.iter().map(|item| item.as_str())); + } + } + + terminal.run_command(&mut cmd) } } - terminal.run_command(&mut cmd) } +} - pub fn update( - &self, - packages: &[&str], - options: Option<&InstallerOptions>, - terminal: &mut Terminal, - ) -> HuakResult<()> { - let mut cmd = Command::new(self.config.path.clone()); - cmd.args(["install", "--upgrade"]).args(packages); - if let Some(it) = options { - if let Some(args) = it.args.as_ref() { - cmd.args(args.iter().map(|item| item.as_str())); +/// `PacakgeInstaller` options. +/// +/// Use `PackageInstallerOptions` to modify configuration used to install packages. +/// Pip can be given a vector of CLI args. +pub enum PackageInstallerOptions { + Pip { args: Option> }, +} + +/// The Version struct. +/// +/// This is a generic version abstraction. +pub struct Version { + pub release: Vec, + pub semver: Option, +} + +impl Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(semver) = self.semver.as_ref() { + write!(f, "{}", semver) + } else { + write!( + f, + "{}", + self.release + .iter() + .map(|item| item.to_string()) + .collect::>() + .join(".") + ) + } + } +} + +impl FromStr for Version { + type Err = Error; + + fn from_str(s: &str) -> Result { + let re = Regex::new(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?$")?; + let captures = match re.captures(s) { + Some(captures) => captures, + None => return Err(Error::InvalidVersionString(s.to_string())), + }; + + let mut release = vec![0, 0, 0]; + for i in [0, 1, 2].into_iter() { + if let Some(it) = captures.get(i + 1) { + release[i] = it + .as_str() + .parse::() + .map_err(|e| Error::InternalError(e.to_string()))? } } - terminal.run_command(&mut cmd) + + let semver = Some(SemVerVersion { + major: release[0], + minor: release[1], + patch: release[2], + }); + + Ok(Version { release, semver }) } } -#[derive(Default, Clone, Debug)] -pub struct InstallerConfig { - path: PathBuf, +impl PartialEq for Version { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } } -impl InstallerConfig { - pub fn new() -> InstallerConfig { - InstallerConfig { - path: PathBuf::from("pip"), +impl Eq for Version {} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + match compare_release(&self.release, &other.release) { + Ordering::Less => Ordering::Less, + Ordering::Equal => Ordering::Equal, + Ordering::Greater => Ordering::Greater, } } } -pub struct InstallerOptions { - pub args: Option>, +pub fn compare_release(this: &[usize], other: &[usize]) -> Ordering { + let iterator = if this.len() < other.len() { + this.iter() + .chain(std::iter::repeat(&0)) + .zip(other) + .collect::>() + } else { + this.iter() + .zip(other.iter().chain(std::iter::repeat(&0))) + .collect() + }; + + for (a, b) in iterator { + if a != b { + return a.cmp(b); + } + } + + Ordering::Equal +} + +/// A `SemVerVersion` struct for Semantic Version numbers. +/// +/// Example `SemVerVersion { major: 3, minor: 11, patch: 0} +pub struct SemVerVersion { + major: usize, + minor: usize, + patch: usize, +} + +impl Display for SemVerVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } } /// The python package compliant with packaging.python.og. /// See -/// # Examples -/// ``` -/// use huak::Package; -/// use std::str::FromStr; -/// -/// let python_pkg = Package::from_str("request>=2.28.1").unwrap(); -/// println!("I've got 99 {} but huak ain't one", python_pkg.name()); -/// ``` -#[derive(Clone, Default, Debug)] +#[derive(Clone, Debug)] pub struct Package { /// Name designated to the package by the author(s). name: String, /// Normalized name of the Python package. canonical_name: String, /// The PEP 440 version of the package. - version: Option, - /// The PEP 440 version specifier. - version_specifier: Option, + version: Version440, } impl Package { @@ -894,138 +1427,49 @@ impl Package { } /// Get the normalized name of the package. - pub fn cononical_name(&self) -> &str { + pub fn canonical_name(&self) -> &str { self.canonical_name.as_ref() } - /// Get the package's PEP440 version. - pub fn version(&self) -> Option<&Version> { - self.version.as_ref() + /// Get the importable version of the package's name. + pub fn importable_name(&self) -> HuakResult { + importable_package_name(&self.canonical_name) } - /// Get the pacakge's PEP440 version specifier. - pub fn version_specifier(&self) -> Option<&VersionSpecifier> { - self.version_specifier.as_ref() - } - - /// Get the pacakge's PEP440 version operator. - pub fn version_operator(&self) -> Option<&VersionOperator> { - if let Some(it) = &self.version_specifier { - return Some(it.operator()); - } - None - } - - /// Get the pacakge name with its version specifier as a &str. - pub fn dependency_string(&self) -> String { - let specifier = match self.version_specifier() { - Some(it) => it, - None => { - return self.name.clone(); - } - }; - format!( - "{}{}{}", - self.name, - specifier.operator(), - specifier.version() - ) + /// Get the package's PEP440 version. + pub fn version(&self) -> &Version440 { + &self.version } } -/// Instantiate a PythonPackage struct from a String -/// # Arguments -/// -/// * 'pkg_string' - A string slice representing PEP-0440 python package -/// -/// # Examples -/// ``` -/// use huak::Package; -/// use std::str::FromStr; -/// -/// let my_pkg = Package::from_str("requests==2.28.1"); -/// ``` -impl FromStr for Package { - type Err = Error; - - fn from_str(pkg_string: &str) -> HuakResult { - // TODO: Improve the method used to parse the version portion - // Search for the first character that isn't part of the package's name - let found = pkg_string - .chars() - .enumerate() - .find(|x| VERSION_OPERATOR_CHARACTERS.contains(&x.1)); - - let spec_str = match found { - Some(it) => &pkg_string[it.0..], - None => { - return Ok(Package { - name: pkg_string.to_string(), - canonical_name: to_package_cononical_name(pkg_string)?, - ..Default::default() - }); - } - }; - - // TODO: More than one specifier - match parse_version_specifiers(spec_str) { - Ok(vspec) => match vspec.first() { - Some(it) => { - let name = match pkg_string.strip_suffix(&spec_str) { - Some(it) => it, - None => pkg_string, - }; - - Ok(Package { - name: name.to_string(), - canonical_name: to_package_cononical_name(name)?, - version_specifier: Some(it.clone()), - ..Default::default() - }) - } - None => Ok(Package { - name: pkg_string.to_string(), - canonical_name: to_package_cononical_name(pkg_string)?, - version_specifier: None, - ..Default::default() - }), - }, - Err(e) => Err(Error::PackageFromStringError(e.to_string())), - } - } +fn importable_package_name(name: &str) -> HuakResult { + let canonical_name = canonical_package_name(name)?; + Ok(canonical_name.replace('-', "_")) } -fn to_package_cononical_name(name: &str) -> HuakResult { +fn canonical_package_name(name: &str) -> HuakResult { let re = Regex::new("[-_. ]+")?; let res = re.replace_all(name, "-"); Ok(res.into_owned()) } -fn to_importable_package_name(name: &str) -> HuakResult { - let cononical_name = to_package_cononical_name(name)?; - Ok(cononical_name.replace('-', "_")) -} - impl PartialEq for Package { fn eq(&self, other: &Self) -> bool { - self.name == other.name - && self.canonical_name == other.canonical_name - && self.version == other.version - && self.version_specifier == other.version_specifier + self.canonical_name == other.canonical_name } } impl Eq for Package {} -/// Collect and return an iterator over Packages from Strings. -fn package_iter(strings: I) -> impl Iterator +/// Collect and return an iterator over `Dependency`s. +fn dependency_iter(strings: I) -> impl Iterator where I: IntoIterator, I::Item: AsRef, { strings .into_iter() - .filter_map(|item| Package::from_str(item.as_ref()).ok()) + .filter_map(|item| Dependency::from_str(item.as_ref()).ok()) } /// A client used to interact with a package index. @@ -1090,26 +1534,6 @@ pub struct PackageInfo { pub struct WorkspaceOptions { pub uses_git: bool, } -pub struct BuildOptions { - pub args: Option>, -} -pub struct FormatOptions { - pub args: Option>, -} -pub struct LintOptions { - pub args: Option>, - pub include_types: bool, -} -pub struct PublishOptions { - pub args: Option>, -} -pub struct TestOptions { - pub args: Option>, -} -pub struct CleanOptions { - pub include_pycache: bool, - pub include_compiled_bytecode: bool, -} /// Get an iterator over available Python interpreter paths parsed from PATH. /// Inspired by brettcannon/python-launcher @@ -1223,7 +1647,7 @@ mod tests { let path = test_resources_dir_path() .join("mock-project") .join("pyproject.toml"); - let pyproject_toml = PyProjectToml::from_path(&path).unwrap(); + let pyproject_toml = PyProjectToml::new(path).unwrap(); assert_eq!(pyproject_toml.project_name().unwrap(), "mock_project"); assert_eq!(pyproject_toml.project_version().unwrap(), "0.0.1"); @@ -1235,7 +1659,7 @@ mod tests { let path = test_resources_dir_path() .join("mock-project") .join("pyproject.toml"); - let pyproject_toml = PyProjectToml::from_path(&path).unwrap(); + let pyproject_toml = PyProjectToml::new(path).unwrap(); assert_eq!( pyproject_toml.to_string_pretty().unwrap(), @@ -1268,7 +1692,7 @@ dev = [ let path = test_resources_dir_path() .join("mock-project") .join("pyproject.toml"); - let pyproject_toml = PyProjectToml::from_path(path).unwrap(); + let pyproject_toml = PyProjectToml::new(path).unwrap(); assert_eq!( pyproject_toml.dependencies().unwrap().deref(), @@ -1281,7 +1705,7 @@ dev = [ let path = test_resources_dir_path() .join("mock-project") .join("pyproject.toml"); - let pyproject_toml = PyProjectToml::from_path(path).unwrap(); + let pyproject_toml = PyProjectToml::new(path).unwrap(); assert_eq!( pyproject_toml @@ -1297,7 +1721,7 @@ dev = [ let path = test_resources_dir_path() .join("mock-project") .join("pyproject.toml"); - let mut pyproject_toml = PyProjectToml::from_path(path).unwrap(); + let mut pyproject_toml = PyProjectToml::new(path).unwrap(); pyproject_toml.add_dependency("test"); assert_eq!( @@ -1334,7 +1758,7 @@ dev = [ let path = test_resources_dir_path() .join("mock-project") .join("pyproject.toml"); - let mut pyproject_toml = PyProjectToml::from_path(path).unwrap(); + let mut pyproject_toml = PyProjectToml::new(path).unwrap(); pyproject_toml.add_optional_dependency("test1", "dev"); pyproject_toml.add_optional_dependency("test2", "new-group"); @@ -1371,7 +1795,7 @@ new-group = ["test2"] let path = test_resources_dir_path() .join("mock-project") .join("pyproject.toml"); - let mut pyproject_toml = PyProjectToml::from_path(path).unwrap(); + let mut pyproject_toml = PyProjectToml::new(path).unwrap(); pyproject_toml.remove_dependency("click"); assert_eq!( @@ -1405,7 +1829,7 @@ dev = [ let path = test_resources_dir_path() .join("mock-project") .join("pyproject.toml"); - let mut pyproject_toml = PyProjectToml::from_path(path).unwrap(); + let mut pyproject_toml = PyProjectToml::new(path).unwrap(); pyproject_toml.remove_optional_dependency("isort", "dev"); assert_eq!( @@ -1434,9 +1858,8 @@ dev = [ } #[test] - /// NOTE: This test depends on local virtual environment. - fn virtual_environment_executable_dir_name() { - let venv = VirtualEnvironment::from_path( + fn python_environment_executable_dir_name() { + let venv = PythonEnvironment::new( PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".venv"), ) .unwrap(); @@ -1449,21 +1872,16 @@ dev = [ } #[test] - fn package_from_str() { - let package = Package::from_str("package_name==0.0.0").unwrap(); + fn dependency_from_str() { + let dep = Dependency::from_str("package_name==0.0.0").unwrap(); - assert_eq!(package.dependency_string(), "package_name==0.0.0"); - assert_eq!(package.name(), "package_name"); - assert_eq!(package.cononical_name(), "package-name"); - assert_eq!( - *package.version_operator().unwrap(), - pep440_rs::Operator::Equal - ); + assert_eq!(dep.dependency_string(), "package_name==0.0.0"); + assert_eq!(dep.name, "package_name"); + assert_eq!(dep.canonical_name, "package-name"); assert_eq!( - package.version_specifier().unwrap().version().to_string(), - "0.0.0" + *dep.version_specifiers.unwrap(), + vec![pep440_rs::VersionSpecifier::from_str("==0.0.0").unwrap()] ); - assert_eq!(package.version(), None); // TODO } #[test] @@ -1471,9 +1889,6 @@ dev = [ let path = python_paths().next().unwrap().1; assert!(path.exists()); - assert!(valid_python_interpreter_file_name( - &last_path_component(path).unwrap() - )) } #[cfg(unix)] diff --git a/src/huak/ops.rs b/src/huak/ops.rs index fd8a97bb..d29f7f05 100644 --- a/src/huak/ops.rs +++ b/src/huak/ops.rs @@ -1,51 +1,88 @@ ///! This module implements various operations to interact with valid workspaces ///! existing on a system. +/// +use indexmap::IndexMap; +use std::{env::consts::OS, path::Path, process::Command, str::FromStr}; +use termcolor::Color; + use crate::{ default_entrypoint_string, default_init_file_contents, - default_main_file_contents, default_test_file_contents, - default_virtual_environment_name, env_path_values, - error::HuakResult, - find_venv_root, - fs::{self, find_root_file_bottom_up}, + default_main_file_contents, default_project_manifest_file_name, + default_project_version_str, default_test_file_contents, + default_virtual_environment_name, dependency_iter, env_path_values, fs, git::{self, default_python_gitignore}, - package_iter, python_paths, - sys::{shell_name, Terminal, TerminalOptions}, - to_importable_package_name, to_package_cononical_name, BuildOptions, - CleanOptions, Error, FormatOptions, InstallerOptions, LintOptions, Package, - Project, PublishOptions, PyProjectToml, TestOptions, VirtualEnvironment, - WorkspaceOptions, + python_paths, + sys::shell_name, + Config, Dependency, Error, HuakResult, PackageInstallerOptions, Project, + ProjectKind, PyProjectToml, PythonEnvironment, WorkspaceOptions, }; -use std::{env::consts::OS, path::PathBuf, process::Command, str::FromStr}; -use termcolor::Color; -#[derive(Default)] -pub struct OperationConfig { - pub workspace_root: PathBuf, - pub terminal_options: TerminalOptions, - pub workspace_options: Option, - pub build_options: Option, - pub format_options: Option, - pub lint_options: Option, - pub publish_options: Option, - pub test_options: Option, - pub installer_options: Option, - pub clean_options: Option, +pub struct AddOptions { + pub args: Option>, + pub install_options: Option, +} +pub struct BuildOptions { + pub args: Option>, + pub install_options: Option, +} +pub struct FormatOptions { + pub args: Option>, + pub install_options: Option, +} +#[derive(Clone)] +pub struct InstallOptions { + pub args: Option>, +} +pub struct LintOptions { + pub args: Option>, + pub include_types: bool, + pub install_options: Option, +} + +pub struct RemoveOptions { + pub args: Option>, + pub install_options: Option, +} +pub struct PublishOptions { + pub args: Option>, + pub install_options: Option, +} +pub struct TestOptions { + pub args: Option>, + pub install_options: Option, +} +#[derive(Clone)] +pub struct UpdateOptions { + pub args: Option>, + pub install_options: Option, } +pub struct CleanOptions { + pub include_pycache: bool, + pub include_compiled_bytecode: bool, +} + +pub fn activate_python_environment(config: &mut Config) -> HuakResult<()> { + let mut workspace = config.workspace()?; + let python_env = workspace.current_python_environment()?; + + if python_env.is_active() { + return Ok(()); + } -pub fn activate_venv(config: &OperationConfig) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let venv = resolve_venv(config, &mut terminal)?; #[cfg(unix)] let mut cmd = Command::new("bash"); - #[cfg(windows)] - let mut cmd = Command::new("powershell"); #[cfg(unix)] cmd.args([ "--init-file", - &format!("{}", venv.executables_dir_path().join("activate").display()), + &format!( + "{}", + python_env.executables_dir_path().join("activate").display() + ), "-i", ]); #[cfg(windows)] + let mut cmd = Command::new("powershell"); + #[cfg(windows)] cmd.args([ "-executionpolicy", "bypass", @@ -54,100 +91,150 @@ pub fn activate_venv(config: &OperationConfig) -> HuakResult<()> { "-File", &format!( "{}", - venv.executables_dir_path().join("activate.ps1").display() + python_env + .executables_dir_path() + .join("activate.ps1") + .display() ), ]); - terminal.run_command(&mut cmd) + + config.terminal.run_command(&mut cmd) } pub fn add_project_dependencies( dependencies: &[String], - config: &OperationConfig, + config: &mut Config, + options: Option, ) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let manifest_path = manifest_path(config); - let mut project = Project::from_manifest(&manifest_path)?; - let packages = package_iter(dependencies) - .filter(|item| { - !project.contains_dependency(item.name()).unwrap_or_default() - }) - .collect::>(); - if packages.is_empty() { + let mut workspace = config.workspace()?; + let mut project = workspace.current_project()?; + + let deps = dependency_iter(dependencies) + .filter(|dep| !project.contains_dependency(dep).unwrap_or_default()) + .collect::>(); + if deps.is_empty() { return Ok(()); } - let venv = resolve_venv(config, &mut terminal)?; - venv.install_packages( - &packages, - config.installer_options.as_ref(), - &mut terminal, + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => { + workspace.new_python_environment()? + } + Err(e) => return Err(e), + }; + let installer_options = match options.as_ref() { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + python_env.install_packages( + dependencies, + installer_options.as_ref(), + &mut config.terminal, )?; - for package in packages { - project.add_dependency(&package.dependency_string())?; + + for dep in deps { + project.add_dependency(dep)?; } - project.pyproject_toml().write_file(&manifest_path) + project.write_manifest() } pub fn add_project_optional_dependencies( dependencies: &[String], group: &str, - config: &OperationConfig, + config: &mut Config, + options: Option, ) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let manifest_path = manifest_path(config); - let mut project = Project::from_manifest(&manifest_path)?; - let packages = package_iter(dependencies) - .filter(|item| { + let mut workspace = config.workspace()?; + let mut project = workspace.current_project()?; + + let deps = dependency_iter(dependencies) + .filter(|dep| { !project - .contains_optional_dependency(item.name(), group) + .contains_optional_dependency(dep, group) .unwrap_or_default() }) - .collect::>(); - if packages.is_empty() { + .collect::>(); + if deps.is_empty() { return Ok(()); - } - let venv = resolve_venv(config, &mut terminal)?; - venv.install_packages( - &packages, - config.installer_options.as_ref(), - &mut terminal, + }; + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => { + workspace.new_python_environment()? + } + Err(e) => return Err(e), + }; + let installer_options = match options.as_ref() { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + python_env.install_packages( + dependencies, + installer_options.as_ref(), + &mut config.terminal, )?; - for package in packages { - project.add_optional_dependency(&package.dependency_string(), group)?; + + for dep in deps { + project.add_optional_dependency(dep, group)?; } - project.pyproject_toml().write_file(&manifest_path) + project.write_manifest() } -pub fn build_project(config: &OperationConfig) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let manifest_path = manifest_path(config); - let mut project = Project::from_manifest(&manifest_path)?; - let venv = resolve_venv(config, &mut terminal)?; - if !venv.contains_module("build")? { - venv.install_packages( - &[Package::from_str("build")?], - config.installer_options.as_ref(), - &mut terminal, +pub fn build_project( + config: &mut Config, + options: Option, +) -> HuakResult<()> { + let mut workspace = config.workspace()?; + let mut project = workspace.current_project()?; + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => { + workspace.new_python_environment()? + } + Err(e) => return Err(e), + }; + let build_dep = Dependency::from_str("build")?; + if !python_env.contains_module(&build_dep.name)? { + let installer_options = match options.as_ref() { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + python_env.install_packages( + &[&build_dep], + installer_options.as_ref(), + &mut config.terminal, )?; } - if !project.contains_dependency_any("build")? { - project.add_optional_dependency("build", "dev")?; - project.pyproject_toml().write_file(&manifest_path)?; + + if !project.contains_dependency_any(&build_dep)? { + project.add_optional_dependency(build_dep, "dev")?; + project.write_manifest()?; } - let mut cmd = Command::new(venv.python_path()); + + let mut cmd = Command::new(python_env.python_path()); let mut args = vec!["-m", "build"]; - if let Some(options) = config.build_options.as_ref() { + if let Some(options) = options.as_ref() { if let Some(it) = options.args.as_ref() { args.extend(it.iter().map(|item| item.as_str())); } } - make_venv_command(&mut cmd, &venv)?; - cmd.args(args).current_dir(&config.workspace_root); - terminal.run_command(&mut cmd) + make_venv_command(&mut cmd, &python_env)?; + cmd.args(args).current_dir(&workspace.root); + + config.terminal.run_command(&mut cmd) } -pub fn clean_project(config: &OperationConfig) -> HuakResult<()> { - if config.workspace_root.join("dist").exists() { - std::fs::read_dir(config.workspace_root.join("dist"))? +pub fn clean_project( + config: &mut Config, + options: Option, +) -> HuakResult<()> { + let workspace = config.workspace()?; + + if workspace.root.join("dist").exists() { + std::fs::read_dir(workspace.root.join("dist"))? .filter_map(|x| x.ok().map(|item| item.path())) .for_each(|item| { if item.is_dir() { @@ -157,15 +244,11 @@ pub fn clean_project(config: &OperationConfig) -> HuakResult<()> { } }); } - if let Some(options) = config.clean_options.as_ref() { - if options.include_pycache { + if let Some(o) = options.as_ref() { + if o.include_pycache { let pattern = format!( "{}", - config - .workspace_root - .join("**") - .join("__pycache__") - .display() + workspace.root.join("**").join("__pycache__").display() ); glob::glob(&pattern)?.for_each(|item| { if let Ok(it) = item { @@ -173,10 +256,10 @@ pub fn clean_project(config: &OperationConfig) -> HuakResult<()> { } }) } - if options.include_compiled_bytecode { + if o.include_compiled_bytecode { let pattern = format!( "{}", - config.workspace_root.join("**").join("*.pyc").display() + workspace.root.join("**").join("*.pyc").display() ); glob::glob(&pattern)?.for_each(|item| { if let Ok(it) = item { @@ -188,409 +271,569 @@ pub fn clean_project(config: &OperationConfig) -> HuakResult<()> { Ok(()) } -pub fn format_project(config: &OperationConfig) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let manifest_path = manifest_path(config); - let mut project = Project::from_manifest(&manifest_path)?; - let venv = resolve_venv(config, &mut terminal)?; - let packages = ["black", "ruff"] - .iter() - .filter(|item| !venv.contains_module(item).unwrap_or_default()) - .filter_map(|item| Package::from_str(item).ok()) - .collect::>(); - if !packages.is_empty() { - venv.install_packages( - &packages, - config.installer_options.as_ref(), - &mut terminal, +pub fn format_project( + config: &mut Config, + options: Option, +) -> HuakResult<()> { + let mut workspace = config.workspace()?; + let mut project = workspace.current_project()?; + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => { + workspace.new_python_environment()? + } + Err(e) => return Err(e), + }; + let format_deps = [ + Dependency::from_str("black")?, + Dependency::from_str("ruff")?, + ]; + let format_deps = format_deps + .into_iter() + .filter(|item| { + !python_env.contains_module(&item.name).unwrap_or_default() + }) + .collect::>(); + if !format_deps.is_empty() { + let installer_options = match options.as_ref() { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + python_env.install_packages( + &format_deps, + installer_options.as_ref(), + &mut config.terminal, )?; } - let packages = packages + + let format_deps = format_deps .into_iter() .filter(|item| { - !project.contains_dependency(item.name()).unwrap_or_default() - && !project - .contains_dependency_any(item.name()) - .unwrap_or_default() + !project.contains_dependency(item).unwrap_or_default() + && !project.contains_dependency_any(item).unwrap_or_default() }) - .collect::>(); - for package in &packages { - { - project.add_optional_dependency(package.name(), "dev")?; + .collect::>(); + if !format_deps.is_empty() { + for dep in format_deps { + { + project.add_optional_dependency(dep, "dev")?; + } } + project.write_manifest()?; } - if !packages.is_empty() { - project.pyproject_toml().write_file(manifest_path)?; - } - let mut cmd = Command::new(venv.python_path()); - let mut ruff_cmd = Command::new(venv.python_path()); + + let mut cmd = Command::new(python_env.python_path()); + let mut ruff_cmd = Command::new(python_env.python_path()); let mut ruff_args = vec!["-m", "ruff", "check", ".", "--select", "I001", "--fix"]; - make_venv_command(&mut cmd, &venv)?; - make_venv_command(&mut ruff_cmd, &venv)?; + make_venv_command(&mut cmd, &python_env)?; + make_venv_command(&mut ruff_cmd, &python_env)?; let mut args = vec!["-m", "black", "."]; - if let Some(it) = config.format_options.as_ref() { + if let Some(it) = options.as_ref() { if let Some(a) = it.args.as_ref() { args.extend(a.iter().map(|item| item.as_str())); if a.contains(&"--check".to_string()) { - terminal.print_warning( + config.terminal.print_warning( "this check will exit early if imports aren't sorted (see https://github.com/cnpryer/huak/issues/510)", )?; ruff_args.retain(|item| *item != "--fix") } } } - ruff_cmd.args(ruff_args).current_dir(&config.workspace_root); - terminal.run_command(&mut ruff_cmd)?; - cmd.args(args).current_dir(&config.workspace_root); - terminal.run_command(&mut cmd) + ruff_cmd.args(ruff_args).current_dir(&workspace.root); + config.terminal.run_command(&mut ruff_cmd)?; + cmd.args(args).current_dir(&workspace.root); + config.terminal.run_command(&mut cmd) } -pub fn init_app_project(config: &OperationConfig) -> HuakResult<()> { - init_lib_project(config)?; - let mut pyproject_toml = PyProjectToml::from_path(manifest_path(config))?; - let name = pyproject_toml.project_name().ok_or(Error::InternalError( - "failed to read project name from toml".to_string(), - ))?; - pyproject_toml.add_script( - &to_package_cononical_name(name)?, - default_entrypoint_string(&to_importable_package_name(name)?).as_str(), - )?; - pyproject_toml.write_file(manifest_path(config)) +pub fn init_app_project( + config: &mut Config, + options: Option, +) -> HuakResult<()> { + init_lib_project(config, options)?; + + let workspace = config.workspace()?; + let mut project = workspace.current_project()?; + + let manifest = &mut project.manifest; + let as_dep = Dependency::from_str(&manifest.name)?; + let entry_point = default_entrypoint_string(&as_dep.importable_name()?); + if let Some(scripts) = manifest.scripts.as_mut() { + if !scripts.contains_key(&as_dep.canonical_name) { + scripts.insert(as_dep.canonical_name, entry_point); + } + } else { + manifest.scripts = + Some(IndexMap::from_iter([(as_dep.canonical_name, entry_point)])); + } + + project.write_manifest() } -pub fn init_lib_project(config: &OperationConfig) -> HuakResult<()> { - let manifest_path = manifest_path(config); +pub fn init_lib_project( + config: &mut Config, + options: Option, +) -> HuakResult<()> { + let manifest_path = config + .workspace_root + .join(default_project_manifest_file_name()); if manifest_path.exists() { - return Err(Error::ProjectTomlExistsError); + return Err(Error::ProjectManifestExistsError); } - init_git(config)?; - let mut pyproject_toml = PyProjectToml::new(); - let name = fs::last_path_component(config.workspace_root.as_path())?; - pyproject_toml.set_project_name(&name); + + let mut pyproject_toml = PyProjectToml::default(); + + init_git(&config.workspace_root, options)?; + let name = fs::last_path_component(&config.workspace_root)?; + pyproject_toml.set_project_name(name); + pyproject_toml.write_file(manifest_path) } pub fn install_project_dependencies( - config: &OperationConfig, + config: &mut Config, + options: Option, ) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let project = Project::from_manifest(manifest_path(config))?; + let mut workspace = config.workspace()?; + let project = Project::new(&workspace.root)?; + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => { + workspace.new_python_environment()? + } + Err(e) => return Err(e), + }; + let dependencies = match project.dependencies() { Some(it) => it, None => return Ok(()), }; - let packages = package_iter(dependencies).collect::>(); - let venv = resolve_venv(config, &mut terminal)?; - venv.install_packages( - &packages, - config.installer_options.as_ref(), - &mut terminal, + + python_env.install_packages( + dependencies, + parse_installer_options(options.as_ref()).as_ref(), + &mut config.terminal, ) } pub fn install_project_optional_dependencies( groups: &[String], - config: &OperationConfig, + config: &mut Config, + options: Option, ) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let pyproject_toml = PyProjectToml::from_path(manifest_path(config))?; - let mut packages = Vec::new(); - let binding = Vec::new(); + let mut workspace = config.workspace()?; + let project = Project::new(&workspace.root)?; + + let binding = Vec::new(); // TODO + let mut dependencies = Vec::new(); // If the group "all" is passed and isn't a valid optional dependency group // then install everything, disregarding other groups passed. - if pyproject_toml.optional_dependencey_group("all").is_none() + if project.optional_dependencey_group("all").is_none() && groups.contains(&"all".to_string()) { - install_project_dependencies(config)?; - if let Some(deps) = pyproject_toml.optional_dependencies() { + if let Some(deps) = project.optional_dependencies() { for (_, vals) in deps { - packages.extend(vals); + dependencies.extend(vals); } } } else { groups.iter().for_each(|item| { - pyproject_toml + project .optional_dependencey_group(item) .unwrap_or(&binding) .iter() .for_each(|v| { - packages.push(v); + dependencies.push(v); }); }) } - packages.dedup(); - let packages = package_iter(packages.iter()).collect::>(); - let venv = resolve_venv(config, &mut terminal)?; - venv.install_packages( - &packages, - config.installer_options.as_ref(), - &mut terminal, + dependencies.dedup(); + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => { + workspace.new_python_environment()? + } + Err(e) => return Err(e), + }; + python_env.install_packages( + &dependencies, + parse_installer_options(options.as_ref()).as_ref(), + &mut config.terminal, ) } -pub fn lint_project(config: &OperationConfig) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let manifest_path = manifest_path(config); - let mut project = Project::from_manifest(&manifest_path)?; - let venv = resolve_venv(config, &mut terminal)?; - if !venv.contains_module("ruff")? { - venv.install_packages( - &[Package::from_str("ruff")?], - config.installer_options.as_ref(), - &mut terminal, +pub fn lint_project( + config: &mut Config, + options: Option, +) -> HuakResult<()> { + let mut workspace = config.workspace()?; + let mut project = workspace.current_project()?; + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => { + workspace.new_python_environment()? + } + Err(e) => return Err(e), + }; + + let ruff_dep = Dependency::from_str("ruff")?; + if !python_env.contains_module("ruff")? { + let installer_options = match options.as_ref() { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + python_env.install_packages( + &[&ruff_dep], + installer_options.as_ref(), + &mut config.terminal, )?; } - if !project.contains_dependency_any("ruff")? { - project.add_optional_dependency("ruff", "dev")?; - project.pyproject_toml().write_file(&manifest_path)?; + + let mut write_manifest = false; + if !project.contains_dependency_any(&ruff_dep)? { + project.add_optional_dependency(ruff_dep, "dev")?; + write_manifest = true; } - let mut cmd = Command::new(venv.python_path()); + + let mut cmd = Command::new(python_env.python_path()); let mut args = vec!["-m", "ruff", "check", "."]; - if let Some(it) = config.lint_options.as_ref() { + if let Some(it) = options.as_ref() { if let Some(a) = it.args.as_ref() { args.extend(a.iter().map(|item| item.as_str())); } if it.include_types { - if !venv.contains_module("mypy")? { - venv.install_packages( - &[Package::from_str("mypy")?], - config.installer_options.as_ref(), - &mut terminal, + let mypy_dep = Dependency::from_str("mypy")?; + let installer_options = match options.as_ref() { + Some(it) => { + parse_installer_options(it.install_options.as_ref()) + } + None => None, + }; + if !python_env.contains_module("mypy")? { + python_env.install_packages( + &[&mypy_dep], + installer_options.as_ref(), + &mut config.terminal, )?; } - if !project.contains_dependency_any("mypy")? { - project.add_optional_dependency("mypy", "dev")?; - project.pyproject_toml().write_file(&manifest_path)?; + if !project.contains_dependency_any(&mypy_dep)? { + project.add_optional_dependency(mypy_dep, "dev")?; + write_manifest = true; } - let mut mypy_cmd = Command::new(venv.python_path()); - make_venv_command(&mut mypy_cmd, &venv)?; + let mut mypy_cmd = Command::new(python_env.python_path()); + make_venv_command(&mut mypy_cmd, &python_env)?; mypy_cmd .args(vec![ "-m", "mypy", ".", "--exclude", - venv.name()?.as_str(), + python_env.name()?.as_str(), ]) - .current_dir(&config.workspace_root); - terminal.run_command(&mut mypy_cmd)?; + .current_dir(&workspace.root); + config.terminal.run_command(&mut mypy_cmd)?; } } - make_venv_command(&mut cmd, &venv)?; - cmd.args(args).current_dir(&config.workspace_root); - terminal.run_command(&mut cmd) + make_venv_command(&mut cmd, &python_env)?; + cmd.args(args).current_dir(&workspace.root); + config.terminal.run_command(&mut cmd)?; + + if write_manifest { + project.write_manifest()?; + } + Ok(()) } -pub fn list_python(config: &OperationConfig) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); +pub fn list_python(config: &mut Config) -> HuakResult<()> { python_paths().enumerate().for_each(|(i, item)| { - terminal + config + .terminal .print_custom(i + 1, item.1.display(), Color::Blue, false) .ok(); }); Ok(()) } -pub fn new_app_project(config: &OperationConfig) -> HuakResult<()> { - new_lib_project(config)?; - let name = to_importable_package_name( - fs::last_path_component(config.workspace_root.as_path())?.as_str(), - )?; - let mut pyproject_toml = PyProjectToml::from_path(manifest_path(config))?; - let src_path = config.workspace_root.join("src"); +pub fn new_app_project( + config: &mut Config, + options: Option, +) -> HuakResult<()> { + new_lib_project(config, options)?; + + let workspace = config.workspace()?; + let mut project = workspace.current_project()?; + project.kind = ProjectKind::Application; + + let manifest = &mut project.manifest; + manifest.name = fs::last_path_component(workspace.root.as_path())?; + let as_dep = Dependency::from_str(&manifest.name)?; + + let src_path = workspace.root.join("src"); std::fs::write( - src_path.join(&name).join("main.py"), + src_path.join(as_dep.importable_name()?).join("main.py"), default_main_file_contents(), )?; - pyproject_toml.add_script( - &to_package_cononical_name(name.as_str())?, - default_entrypoint_string(&to_importable_package_name(&name)?).as_str(), - )?; - pyproject_toml.write_file(manifest_path(config)) + let entry_point = default_entrypoint_string(&as_dep.importable_name()?); + if let Some(scripts) = manifest.scripts.as_mut() { + if !scripts.contains_key(&as_dep.canonical_name) { + scripts.insert(as_dep.canonical_name, entry_point); + } + } else { + manifest.scripts = + Some(IndexMap::from_iter([(as_dep.canonical_name, entry_point)])); + } + + project.write_manifest() } -pub fn new_lib_project(config: &OperationConfig) -> HuakResult<()> { - create_workspace(config)?; - let last_path_component = - fs::last_path_component(config.workspace_root.as_path())?; - let processed_name = to_importable_package_name(&last_path_component)?; - if manifest_path(config).exists() { - return Err(Error::ProjectTomlExistsError); +pub fn new_lib_project( + config: &mut Config, + options: Option, +) -> HuakResult<()> { + let manifest_path = config + .workspace_root + .join(default_project_manifest_file_name()); + if manifest_path.exists() { + return Err(Error::ProjectManifestExistsError); } - let mut pyproject_toml = PyProjectToml::new(); - pyproject_toml.set_project_name(&last_path_component); - pyproject_toml.write_file(manifest_path(config))?; + + let pyproject_toml = PyProjectToml::default(); + + create_workspace(&config.workspace_root, config, options)?; + + let name = &fs::last_path_component(&config.workspace_root)?; + let as_dep = Dependency::from_str(name)?; + pyproject_toml.write_file(manifest_path)?; + let src_path = config.workspace_root.join("src"); - std::fs::create_dir_all(src_path.join(&processed_name))?; + std::fs::create_dir_all(src_path.join(as_dep.importable_name()?))?; std::fs::create_dir_all(config.workspace_root.join("tests"))?; std::fs::write( - src_path.join(&processed_name).join("__init__.py"), - default_init_file_contents(pyproject_toml.project_version().ok_or( - Error::InternalError("failed to read project version".to_string()), - )?), + src_path.join(as_dep.importable_name()?).join("__init__.py"), + default_init_file_contents(default_project_version_str()), )?; std::fs::write( config.workspace_root.join("tests").join("test_version.py"), - default_test_file_contents(&processed_name), + default_test_file_contents(&as_dep.importable_name()?), ) .map_err(Error::IOError) } -pub fn publish_project(config: &OperationConfig) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let manifest_path = manifest_path(config); - let mut project = Project::from_manifest(&manifest_path)?; - let venv = resolve_venv(config, &mut terminal)?; - if !venv.contains_module("twine")? { - venv.install_packages( - &[Package::from_str("twine")?], - config.installer_options.as_ref(), - &mut terminal, +pub fn publish_project( + config: &mut Config, + options: Option, +) -> HuakResult<()> { + let mut workspace = config.workspace()?; + let mut project = workspace.current_project()?; + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => { + workspace.new_python_environment()? + } + Err(e) => return Err(e), + }; + let pub_dep = Dependency::from_str("twine")?; + let installer_options = match options.as_ref() { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + if !python_env.contains_module(&pub_dep.name)? { + python_env.install_packages( + &[&pub_dep], + installer_options.as_ref(), + &mut config.terminal, )?; } - if !project.contains_dependency_any("twine")? { - project.add_optional_dependency("twine", "dev")?; - project.pyproject_toml().write_file(&manifest_path)?; + + if !project.contains_dependency_any(&pub_dep)? { + project.add_optional_dependency(pub_dep, "dev")?; + project.write_manifest()?; } - let mut cmd = Command::new(venv.python_path()); + + let mut cmd = Command::new(python_env.python_path()); let mut args = vec!["-m", "twine", "upload", "dist/*"]; - if let Some(it) = config.publish_options.as_ref() { + if let Some(it) = options.as_ref() { if let Some(a) = it.args.as_ref() { args.extend(a.iter().map(|item| item.as_str())); } } - make_venv_command(&mut cmd, &venv)?; - cmd.args(args).current_dir(&config.workspace_root); - terminal.run_command(&mut cmd) + make_venv_command(&mut cmd, &python_env)?; + cmd.args(args).current_dir(&workspace.root); + config.terminal.run_command(&mut cmd) } pub fn remove_project_dependencies( dependencies: &[String], - config: &OperationConfig, + config: &mut Config, + options: Option, ) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let manifest_path = manifest_path(config); - let mut project = Project::from_manifest(&manifest_path)?; - let deps: Vec = dependencies - .iter() + let mut workspace = config.workspace()?; + let mut project = workspace.current_project()?; + + let deps = dependency_iter(dependencies) .filter(|item| project.contains_dependency(item).unwrap_or_default()) - .cloned() - .collect(); + .collect::>(); if deps.is_empty() { return Ok(()); } - deps.iter().for_each(|item| { - project.remove_dependency(item); - }); - let venv = - VirtualEnvironment::from_path(find_venv_root(&config.workspace_root)?)?; - venv.uninstall_packages( - &deps.iter().map(|item| item.as_str()).collect::>(), - config.installer_options.as_ref(), - &mut terminal, - )?; - project.pyproject_toml().write_file(&manifest_path) + + for dep in &deps { + project.remove_dependency(dep)?; + } + + project.write_manifest()?; + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => return Ok(()), + Err(e) => return Err(e), + }; + let installer_options = match options.as_ref() { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + python_env.uninstall_packages( + &deps, + installer_options.as_ref(), + &mut config.terminal, + ) } pub fn remove_project_optional_dependencies( dependencies: &[String], group: &str, - config: &OperationConfig, + config: &mut Config, + options: Option, ) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let mut project = Project::from_manifest(manifest_path(config))?; + let mut workspace = config.workspace()?; + let mut project = workspace.current_project()?; + if project.optional_dependencey_group(group).is_none() { return Ok(()); } - let deps: Vec = dependencies - .iter() + + let deps: Vec = dependency_iter(dependencies) .filter(|item| { project .contains_optional_dependency(item, group) .unwrap_or_default() }) - .cloned() .collect(); if deps.is_empty() { return Ok(()); } - deps.iter().for_each(|item| { - project.remove_optional_dependency(item, group); - }); - let venv = - VirtualEnvironment::from_path(find_venv_root(&config.workspace_root)?)?; - venv.uninstall_packages( - &deps.iter().map(|item| item.as_str()).collect::>(), - config.installer_options.as_ref(), - &mut terminal, - )?; - project.pyproject_toml().write_file(manifest_path(config)) + + for dep in &deps { + project.remove_optional_dependency(dep, group)?; + } + + project.write_manifest()?; + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => return Ok(()), + Err(e) => return Err(e), + }; + let installer_options = match options { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + python_env.uninstall_packages( + &deps, + installer_options.as_ref(), + &mut config.terminal, + ) } -pub fn run_command_str( - command: &str, - config: &OperationConfig, -) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); +pub fn run_command_str(command: &str, config: &mut Config) -> HuakResult<()> { + let mut workspace = config.workspace()?; + let python_env = workspace.current_python_environment()?; + let mut cmd = Command::new(shell_name()?); let flag = match OS { "windows" => "/C", _ => "-c", }; - let venv = resolve_venv(config, &mut terminal)?; - make_venv_command(&mut cmd, &venv)?; - cmd.args([flag, command]) - .current_dir(&config.workspace_root); - terminal.run_command(&mut cmd) + make_venv_command(&mut cmd, &python_env)?; + cmd.args([flag, command]).current_dir(&workspace.root); + config.terminal.run_command(&mut cmd) } -pub fn test_project(config: &OperationConfig) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let manifest_path = manifest_path(config); - let mut project = Project::from_manifest(&manifest_path)?; - let venv = resolve_venv(config, &mut terminal)?; - if !venv.contains_module("pytest")? { - venv.install_packages( - &[Package::from_str("pytest")?], - config.installer_options.as_ref(), - &mut terminal, +pub fn test_project( + config: &mut Config, + options: Option, +) -> HuakResult<()> { + let mut workspace = config.workspace()?; + let mut project = workspace.current_project()?; + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => { + workspace.new_python_environment()? + } + Err(e) => return Err(e), + }; + let test_dep = Dependency::from_str("pytest")?; + if !python_env.contains_module(&test_dep.name)? { + let installer_options = match options.as_ref() { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + python_env.install_packages( + &[&test_dep], + installer_options.as_ref(), + &mut config.terminal, )?; } - if !project.contains_dependency_any("pytest")? { - project.add_optional_dependency("pytest", "dev")?; - project.pyproject_toml.write_file(&manifest_path)?; + + if !project.contains_dependency_any(&test_dep)? { + project.add_optional_dependency(test_dep, "dev")?; + project.write_manifest()?; } - let mut cmd = Command::new(venv.python_path()); - make_venv_command(&mut cmd, &venv)?; - let python_path = if config.workspace_root.join("src").exists() { - config.workspace_root.join("src") + + let mut cmd = Command::new(python_env.python_path()); + make_venv_command(&mut cmd, &python_env)?; + let python_path = if workspace.root.join("src").exists() { + workspace.root.join("src") } else { - config.workspace_root.clone() + workspace.root.clone() }; let mut args = vec!["-m", "pytest"]; - if let Some(options) = config.lint_options.as_ref() { - if let Some(it) = options.args.as_ref() { + if let Some(o) = options.as_ref() { + if let Some(it) = o.args.as_ref() { args.extend(it.iter().map(|item| item.as_str())); } } cmd.args(args).env("PYTHONPATH", python_path); - terminal.run_command(&mut cmd) + config.terminal.run_command(&mut cmd) } pub fn update_project_dependencies( dependencies: Option>, - config: &OperationConfig, + config: &mut Config, + options: Option, ) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let project = Project::from_manifest(manifest_path(config))?; - let venv = resolve_venv(config, &mut terminal)?; + let mut workspace = config.workspace()?; + let project = Project::new(&workspace.root)?; + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => { + workspace.new_python_environment()? + } + Err(e) => return Err(e), + }; + if let Some(it) = dependencies.as_ref() { - let deps = it - .iter() + let deps = dependency_iter(it) .filter_map(|item| { - if project.contains_dependency(item).unwrap_or_default() { - Some(item.as_str()) + if project.contains_dependency(&item).unwrap_or_default() { + Some(item) } else { None } @@ -599,18 +842,27 @@ pub fn update_project_dependencies( if deps.is_empty() { return Ok(()); } - venv.update_packages( + let installer_options = match options { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + python_env.update_packages( &deps, - config.installer_options.as_ref(), - &mut terminal, + installer_options.as_ref(), + &mut config.terminal, )?; return Ok(()); } - if let Some(it) = project.dependencies() { - venv.update_packages( - &it.iter().map(|item| item.as_str()).collect::>(), - config.installer_options.as_ref(), - &mut terminal, + + if let Some(deps) = project.dependencies() { + let installer_options = match options { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + python_env.update_packages( + deps, + installer_options.as_ref(), + &mut config.terminal, )?; } Ok(()) @@ -618,21 +870,29 @@ pub fn update_project_dependencies( pub fn update_project_optional_dependencies( dependencies: Option>, - group: &String, - config: &OperationConfig, + group: &str, + config: &mut Config, + options: Option, ) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let project = Project::from_manifest(manifest_path(config))?; - let venv = resolve_venv(config, &mut terminal)?; + let mut workspace = config.workspace()?; + let project = Project::new(&workspace.root)?; + + let python_env = match workspace.current_python_environment() { + Ok(it) => it, + Err(Error::PythonEnvironmentNotFoundError) => { + workspace.new_python_environment()? + } + Err(e) => return Err(e), + }; + if let Some(it) = dependencies.as_ref() { - let deps = it - .iter() + let deps = dependency_iter(it) .filter_map(|item| { if project - .contains_optional_dependency(item, group) + .contains_optional_dependency(&item, group) .unwrap_or_default() { - Some(item.as_str()) + Some(item) } else { None } @@ -641,49 +901,52 @@ pub fn update_project_optional_dependencies( if deps.is_empty() { return Ok(()); } - venv.update_packages( + let installer_options = match options { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + python_env.update_packages( &deps, - config.installer_options.as_ref(), - &mut terminal, + installer_options.as_ref(), + &mut config.terminal, )?; return Ok(()); } - let mut packages = Vec::new(); - let binding = Vec::new(); - // If the group "all" is passed and isn't a valid optional dependency group - // then install everything, disregarding other groups passed. - if project - .pyproject_toml - .optional_dependencey_group("all") - .is_none() - && *group == "all" - { - update_project_dependencies(dependencies, config)?; - if let Some(deps) = project.pyproject_toml.optional_dependencies() { - for (_, vals) in deps { - packages.extend(vals.iter().map(|item| item.as_str())); + + let mut deps = Vec::new(); + let binding = Vec::new(); // TODO + // If the group "all" is passed and isn't a valid optional dependency group + // then install everything, disregarding other groups passed. + if project.optional_dependencey_group("all").is_none() && group == "all" { + if let Some(it) = project.optional_dependencies() { + for (_, vals) in it { + deps.extend(vals); } } } else { project - .pyproject_toml .optional_dependencey_group(group) .unwrap_or(&binding) .iter() - .for_each(|v| { - packages.push(v.as_str()); + .for_each(|item| { + deps.push(item); }); } - packages.dedup(); - venv.update_packages( - &packages, - config.installer_options.as_ref(), - &mut terminal, + + deps.dedup(); + let installer_options = match options { + Some(it) => parse_installer_options(it.install_options.as_ref()), + None => None, + }; + python_env.update_packages( + &deps, + installer_options.as_ref(), + &mut config.terminal, ) } -pub fn use_python(version: String, config: &OperationConfig) -> HuakResult<()> { - if let Some(path) = python_paths() +pub fn use_python(version: &str, config: &mut Config) -> HuakResult<()> { + let path = match python_paths() .filter_map(|item| { if let Some(version) = item.0 { Some((version, item.1)) @@ -694,32 +957,34 @@ pub fn use_python(version: String, config: &OperationConfig) -> HuakResult<()> { .find(|item| item.0.to_string() == version) .map(|item| item.1) { - if let Ok(venv) = VirtualEnvironment::from_path( - config - .workspace_root - .join(default_virtual_environment_name()), - ) { - std::fs::remove_dir_all(venv.root())?; - } - let mut terminal = create_terminal(&config.terminal_options); - let mut cmd = Command::new(path); - cmd.args(["-m", "venv", default_virtual_environment_name()]) - .current_dir(&config.workspace_root); - terminal.run_command(&mut cmd) - } else { - Err(Error::PythonNotFoundError) + Some(it) => it, + None => return Err(Error::PythonNotFoundError), + }; + + if let Ok(workspace) = config.workspace().as_mut() { + match workspace.current_python_environment() { + Ok(it) => std::fs::remove_dir_all(it.root)?, + Err(Error::PythonEnvironmentNotFoundError) => (), + Err(e) => return Err(e), + }; } + + let mut cmd = Command::new(path); + cmd.args(["-m", "venv", default_virtual_environment_name()]) + .current_dir(&config.workspace_root); + config.terminal.run_command(&mut cmd) } -pub fn display_project_version(config: &OperationConfig) -> HuakResult<()> { - let mut terminal = create_terminal(&config.terminal_options); - let project = Project::from_manifest(manifest_path(config))?; - terminal.print_custom( +pub fn display_project_version(config: &mut Config) -> HuakResult<()> { + let workspace = config.workspace()?; + let project = Project::new(workspace.root)?; + + config.terminal.print_custom( "version", project - .pyproject_toml() - .project_version() - .unwrap_or("no version found"), + .manifest + .version + .unwrap_or("no version found".to_string()), Color::Green, false, ) @@ -727,7 +992,7 @@ pub fn display_project_version(config: &OperationConfig) -> HuakResult<()> { fn make_venv_command( cmd: &mut Command, - venv: &VirtualEnvironment, + venv: &PythonEnvironment, ) -> HuakResult<()> { let mut paths = match env_path_values() { Some(it) => it, @@ -737,7 +1002,7 @@ fn make_venv_command( )) } }; - paths.insert(0, venv.executables_dir_path()); + paths.insert(0, venv.executables_dir_path().clone()); cmd.env( "PATH", std::env::join_paths(paths) @@ -747,50 +1012,35 @@ fn make_venv_command( Ok(()) } -fn create_terminal(options: &TerminalOptions) -> Terminal { - let mut terminal = Terminal::new(); - terminal.set_verbosity(options.verbosity); - terminal -} - -pub fn find_workspace() -> HuakResult { - let cwd = std::env::current_dir()?; - let path = match find_root_file_bottom_up( - "pyproject.toml", - cwd, - PathBuf::from("/"), - ) { - Ok(it) => it - .ok_or(Error::ProjectFileNotFound)? - .parent() - .ok_or(Error::InternalError( - "failed to parse parent directory".to_string(), - ))? - .to_path_buf(), - Err(_) => return Err(Error::ProjectFileNotFound), - }; - Ok(path) -} +fn create_workspace>( + path: T, + config: &Config, + options: Option, +) -> HuakResult<()> { + let root = path.as_ref(); -fn create_workspace(config: &OperationConfig) -> HuakResult<()> { - let path = config.workspace_root.as_path(); - let cwd = std::env::current_dir()?; - if (path.exists() && path != cwd) - || (path == cwd && path.read_dir()?.count() > 0) + if (root.exists() && root != config.cwd) + || (root == config.cwd && root.read_dir()?.count() > 0) { - return Err(Error::DirectoryExists(path.to_path_buf())); + return Err(Error::DirectoryExists(root.to_path_buf())); } - std::fs::create_dir(path)?; - init_git(config) + + std::fs::create_dir(root)?; + + init_git(root, options) } -fn init_git(config: &OperationConfig) -> HuakResult<()> { - if let Some(options) = config.workspace_options.as_ref() { - if options.uses_git { - if !config.workspace_root.join(".git").exists() { - git::init(&config.workspace_root)?; +fn init_git>( + path: T, + options: Option, +) -> HuakResult<()> { + let root = path.as_ref(); + if let Some(o) = options.as_ref() { + if o.uses_git { + if !root.join(".git").exists() { + git::init(root)?; } - let gitignore_path = config.workspace_root.join(".gitignore"); + let gitignore_path = root.join(".gitignore"); if !gitignore_path.exists() { std::fs::write(gitignore_path, default_python_gitignore())?; } @@ -799,51 +1049,24 @@ fn init_git(config: &OperationConfig) -> HuakResult<()> { Ok(()) } -fn manifest_path(config: &OperationConfig) -> PathBuf { - config.workspace_root.join("pyproject.toml") -} - -/// Find a virtual enironment or create one at the workspace root. -fn resolve_venv( - config: &OperationConfig, - terminal: &mut Terminal, -) -> HuakResult { - let root = match find_venv_root(&config.workspace_root) { - Ok(it) => it, - Err(Error::VenvNotFoundError) => { - create_virtual_environment(config, terminal)?; - config - .workspace_root - .join(default_virtual_environment_name()) - } - Err(e) => return Err(e), - }; - VirtualEnvironment::from_path(root) -} - -/// Create a new virtual environment at workspace root using the found Python interpreter. -fn create_virtual_environment( - config: &OperationConfig, - terminal: &mut Terminal, -) -> HuakResult<()> { - // Use the first path found. - let python_path = match python_paths().next() { - Some(it) => it.1, - None => return Err(Error::PythonNotFoundError), - }; - let args = ["-m", "venv", default_virtual_environment_name()]; - let mut cmd = Command::new(python_path); - cmd.args(args).current_dir(&config.workspace_root); - terminal.run_command(&mut cmd) +fn parse_installer_options( + options: Option<&InstallOptions>, +) -> Option { + options.map(|it| PackageInstallerOptions::Pip { + args: it.args.clone(), + }) } #[cfg(test)] mod tests { + use std::path::PathBuf; + use super::*; use crate::{ - fs, test_resources_dir_path, PyProjectToml, Verbosity, - VirtualEnvironment, + fs, sys::Terminal, test_resources_dir_path, Package, PyProjectToml, + Verbosity, }; + use pep440_rs::Version as Version440; use tempfile::tempdir; #[test] @@ -855,15 +1078,17 @@ mod tests { ) .unwrap(); let deps = ["ruff".to_string()]; - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - let venv = - VirtualEnvironment::from_path(PathBuf::from(".venv")).unwrap(); + let venv = PythonEnvironment::new( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".venv"), + ) + .unwrap(); let mut terminal = Terminal::new(); terminal.set_verbosity(Verbosity::Quiet); venv.uninstall_packages( @@ -873,25 +1098,17 @@ mod tests { ) .unwrap(); - add_project_dependencies(&deps, &config).unwrap(); + add_project_dependencies(&deps, &mut config, None).unwrap(); - let project = Project::from_manifest(manifest_path(&config)).unwrap(); - let ser_toml = PyProjectToml::from_path( - dir.join("mock-project").join("pyproject.toml"), - ) - .unwrap(); + let project = Project::new(config.workspace_root).unwrap(); + let ser_toml = + PyProjectToml::new(dir.join("mock-project").join("pyproject.toml")) + .unwrap(); + let dep = Dependency::from_str("ruff").unwrap(); assert!(venv.contains_module("ruff").unwrap()); - assert!(deps.iter().all(|item| project - .dependencies() - .unwrap() - .contains(&item.to_string()))); - assert!(deps.iter().map(|item| item).all(|item| ser_toml - .dependencies() - .unwrap() - .contains(&item.to_string()))); - assert!(deps.iter().map(|item| item).all(|item| project - .pyproject_toml() + assert!(project.contains_dependency(&dep).unwrap()); + assert!(deps.iter().all(|item| ser_toml .dependencies() .unwrap() .contains(&item.to_string()))); @@ -907,15 +1124,17 @@ mod tests { .unwrap(); let deps = ["ruff".to_string()]; let group = "dev"; - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - let venv = - VirtualEnvironment::from_path(PathBuf::from(".venv")).unwrap(); + let venv = PythonEnvironment::new( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".venv"), + ) + .unwrap(); let mut terminal = Terminal::new(); terminal.set_verbosity(Verbosity::Quiet); venv.uninstall_packages( @@ -925,25 +1144,18 @@ mod tests { ) .unwrap(); - add_project_optional_dependencies(&deps, group, &config).unwrap(); + add_project_optional_dependencies(&deps, group, &mut config, None) + .unwrap(); - let project = Project::from_manifest(manifest_path(&config)).unwrap(); - let ser_toml = PyProjectToml::from_path( - dir.join("mock-project").join("pyproject.toml"), - ) - .unwrap(); + let project = Project::new(config.workspace_root).unwrap(); + let ser_toml = + PyProjectToml::new(dir.join("mock-project").join("pyproject.toml")) + .unwrap(); + let dep = Dependency::from_str("ruff").unwrap(); assert!(venv.contains_module("ruff").unwrap()); - assert!(deps.iter().all(|item| project - .optional_dependencey_group("dev") - .unwrap() - .contains(&item.to_string()))); - assert!(deps.iter().map(|item| item).all(|item| ser_toml - .optional_dependencey_group("dev") - .unwrap() - .contains(&item.to_string()))); - assert!(deps.iter().map(|item| item).all(|item| project - .pyproject_toml() + assert!(project.contains_optional_dependency(&dep, "dev").unwrap()); + assert!(deps.iter().all(|item| ser_toml .optional_dependencey_group("dev") .unwrap() .contains(&item.to_string()))); @@ -957,15 +1169,15 @@ mod tests { &dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - build_project(&config).unwrap(); + build_project(&mut config, None).unwrap(); } #[test] @@ -976,26 +1188,28 @@ mod tests { dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - clean_options: Some(CleanOptions { - include_pycache: true, - include_compiled_bytecode: true, - }), - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; + let options = Some(CleanOptions { + include_pycache: true, + include_compiled_bytecode: true, + }); - clean_project(&config).unwrap(); + clean_project(&mut config, options).unwrap(); - let dist: Vec = glob::glob(&format!( + let dist = glob::glob(&format!( "{}", config.workspace_root.join("dist").join("*").display() )) .unwrap() - .into_iter() .map(|item| item.unwrap()) - .collect(); - let pycaches: Vec = glob::glob(&format!( + .collect::>(); + let pycaches = glob::glob(&format!( "{}", config .workspace_root @@ -1004,17 +1218,15 @@ mod tests { .display() )) .unwrap() - .into_iter() .map(|item| item.unwrap()) - .collect(); - let bytecode: Vec = glob::glob(&format!( + .collect::>(); + let bytecode = glob::glob(&format!( "{}", config.workspace_root.join("**").join("*.pyc").display() )) .unwrap() - .into_iter() .map(|item| item.unwrap()) - .collect(); + .collect::>(); assert!(dist.is_empty()); assert!(pycaches.is_empty()); @@ -1029,16 +1241,17 @@ mod tests { &dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - let project = Project::from_manifest(manifest_path(&config)).unwrap(); + let project = Project::new(&config.workspace_root).unwrap(); let fmt_filepath = project .root() + .unwrap() .join("src") .join("mock_project") .join("fmt_me.py"); @@ -1047,7 +1260,7 @@ def fn( ): pass"#; std::fs::write(&fmt_filepath, pre_fmt_str).unwrap(); - format_project(&config).unwrap(); + format_project(&mut config, None).unwrap(); let post_fmt_str = std::fs::read_to_string(&fmt_filepath).unwrap(); @@ -1063,20 +1276,23 @@ def fn( ): fn test_init_lib_project() { let dir = tempdir().unwrap().into_path(); std::fs::create_dir(dir.join("mock-project")).unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - init_lib_project(&config).unwrap(); + init_lib_project(&mut config, None).unwrap(); - let toml_path = manifest_path(&config); - let ser_toml = PyProjectToml::from_path(toml_path).unwrap(); - let mut pyproject_toml = PyProjectToml::new(); - pyproject_toml.set_project_name("mock-project"); + let ser_toml = + PyProjectToml::new(config.workspace_root.join("pyproject.toml")) + .unwrap(); + let mut pyproject_toml = + PyProjectToml::new(config.workspace_root.join("pyproject.toml")) + .unwrap(); + pyproject_toml.set_project_name("mock-project".to_string()); assert_eq!( ser_toml.to_string_pretty().unwrap(), @@ -1088,20 +1304,21 @@ def fn( ): fn test_init_app_project() { let dir = tempdir().unwrap().into_path(); std::fs::create_dir(dir.join("mock-project")).unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - init_app_project(&config).unwrap(); + init_app_project(&mut config, None).unwrap(); - let toml_path = manifest_path(&config); - let ser_toml = PyProjectToml::from_path(toml_path).unwrap(); - let mut pyproject_toml = PyProjectToml::new(); - pyproject_toml.set_project_name("mock-project"); + let ser_toml = + PyProjectToml::new(config.workspace_root.join("pyproject.toml")) + .unwrap(); + let mut pyproject_toml = PyProjectToml::default(); + pyproject_toml.set_project_name("mock-project".to_string()); assert_eq!( ser_toml.to_string_pretty().unwrap(), @@ -1129,25 +1346,30 @@ mock-project = "mock_project.main:main" &dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - let venv = VirtualEnvironment::from_path(".venv").unwrap(); - let mut terminal = Terminal::new(); - terminal.set_verbosity(config.terminal_options.verbosity); - venv.uninstall_packages(&["click"], None, &mut terminal) + let venv = PythonEnvironment::new( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".venv"), + ) + .unwrap(); + venv.uninstall_packages(&["click"], None, &mut config.terminal) .unwrap(); - let package = Package::from_str("click").unwrap(); - let had_package = venv.contains_package(&package).unwrap(); + let package = Package { + name: String::from("click"), + canonical_name: String::from("click"), + version: Version440::from_str("0.0.0").unwrap(), + }; + let had_package = venv.contains_package(&package); - install_project_dependencies(&config).unwrap(); + install_project_dependencies(&mut config, None).unwrap(); assert!(!had_package); - assert!(venv.contains_package(&package).unwrap()); + assert!(venv.contains_package(&package)); } #[test] @@ -1158,22 +1380,27 @@ mock-project = "mock_project.main:main" &dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - let venv = VirtualEnvironment::from_path(".venv").unwrap(); - let mut terminal = Terminal::new(); - terminal.set_verbosity(config.terminal_options.verbosity); - venv.uninstall_packages(&["pytest"], None, &mut terminal) + let venv = PythonEnvironment::new( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".venv"), + ) + .unwrap(); + venv.uninstall_packages(&["pytest"], None, &mut config.terminal) .unwrap(); let had_package = venv.contains_module("pytest").unwrap(); - install_project_optional_dependencies(&["dev".to_string()], &config) - .unwrap(); + install_project_optional_dependencies( + &["dev".to_string()], + &mut config, + None, + ) + .unwrap(); assert!(!had_package); assert!(venv.contains_module("pytest").unwrap()); @@ -1187,19 +1414,20 @@ mock-project = "mock_project.main:main" &dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - lint_options: Some(LintOptions { - args: None, - include_types: true, - }), - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; + let options = Some(LintOptions { + args: None, + include_types: true, + install_options: None, + }); - lint_project(&config).unwrap(); + lint_project(&mut config, options).unwrap(); } #[test] @@ -1210,20 +1438,22 @@ mock-project = "mock_project.main:main" &dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - lint_options: Some(LintOptions { - args: Some(vec!["--fix".to_string()]), - include_types: false, - }), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - let project = Project::from_manifest(manifest_path(&config)).unwrap(); + let options = Some(LintOptions { + args: Some(vec!["--fix".to_string()]), + include_types: false, + install_options: None, + }); + let project = Project::new(&config.workspace_root).unwrap(); let lint_fix_filepath = project .root() + .unwrap() .join("src") .join("mock_project") .join("fix_me.py"); @@ -1242,7 +1472,7 @@ def fn(): "#; std::fs::write(&lint_fix_filepath, pre_fix_str).unwrap(); - lint_project(&config).unwrap(); + lint_project(&mut config, options).unwrap(); let post_fix_str = std::fs::read_to_string(&lint_fix_filepath).unwrap(); @@ -1252,20 +1482,23 @@ def fn(): #[test] fn test_new_lib_project() { let dir = tempdir().unwrap().into_path(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - new_lib_project(&config).unwrap(); + new_lib_project(&mut config, None).unwrap(); - let project = Project::from_manifest(manifest_path(&config)).unwrap(); - let test_file_filepath = - project.root().join("tests").join("test_version.py"); - let test_file = std::fs::read_to_string(&test_file_filepath).unwrap(); + let project = Project::new(config.workspace_root).unwrap(); + let test_file_filepath = project + .root() + .unwrap() + .join("tests") + .join("test_version.py"); + let test_file = std::fs::read_to_string(test_file_filepath).unwrap(); let expected_test_file = r#"from mock_project import __version__ @@ -1274,21 +1507,15 @@ def test_version(): "#; let init_file_filepath = project .root() + .unwrap() .join("src") .join("mock_project") .join("__init__.py"); - let init_file = std::fs::read_to_string(&init_file_filepath).unwrap(); + let init_file = std::fs::read_to_string(init_file_filepath).unwrap(); let expected_init_file = "__version__ = \"0.0.1\" "; - assert!(project - .pyproject_toml() - .inner - .project - .as_ref() - .unwrap() - .scripts - .is_none()); + assert!(project.manifest.scripts.is_none()); assert_eq!(test_file, expected_test_file); assert_eq!(init_file, expected_init_file); } @@ -1296,24 +1523,24 @@ def test_version(): #[test] fn test_new_app_project() { let dir = tempdir().unwrap().into_path(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - new_app_project(&config).unwrap(); + new_app_project(&mut config, None).unwrap(); - let project = Project::from_manifest(manifest_path(&config)).unwrap(); - let ser_toml = project.pyproject_toml(); + let project = Project::new(config.workspace_root).unwrap(); let main_file_filepath = project .root() + .unwrap() .join("src") .join("mock_project") .join("main.py"); - let main_file = std::fs::read_to_string(&main_file_filepath).unwrap(); + let main_file = std::fs::read_to_string(main_file_filepath).unwrap(); let expected_main_file = r#"def main(): print("Hello, World!") @@ -1323,19 +1550,10 @@ if __name__ == "__main__": "#; assert_eq!( - ser_toml - .inner - .project - .as_ref() - .unwrap() - .scripts - .as_ref() - .unwrap()["mock-project"], + project.manifest.scripts.as_ref().unwrap()["mock-project"], format!("{}.main:main", "mock_project") ); assert_eq!(main_file, expected_main_file); - - assert!(ser_toml.inner.project.as_ref().unwrap().scripts.is_some()); } #[test] @@ -1346,48 +1564,38 @@ if __name__ == "__main__": &dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - let project = Project::from_manifest(manifest_path(&config)).unwrap(); - let venv = - VirtualEnvironment::from_path(PathBuf::from(".venv")).unwrap(); - let package = Package::from_str("click==8.1.3").unwrap(); - let mut terminal = Terminal::new(); - terminal.set_verbosity(config.terminal_options.verbosity); - let packages = [package.clone()]; - venv.install_packages( - &packages, - config.installer_options.as_ref(), - &mut terminal, + let project = Project::new(&config.workspace_root).unwrap(); + let venv = PythonEnvironment::new( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".venv"), ) .unwrap(); - let venv_had_package = venv.contains_package(&package).unwrap(); - let toml_had_package = project - .pyproject_toml() - .dependencies() - .unwrap() - .contains(&package.dependency_string()); + let package = Package { + name: "click".to_string(), + canonical_name: String::from("click"), + version: Version440::from_str("8.1.3").unwrap(), + }; + let dep = Dependency::from_str("click==8.1.3").unwrap(); + venv.install_packages(&[&dep], None, &mut config.terminal) + .unwrap(); + let venv_had_package = venv.contains_package(&package); + let toml_had_package = project.dependencies().unwrap().contains(&dep); - remove_project_dependencies(&["click".to_string()], &config).unwrap(); + remove_project_dependencies(&["click".to_string()], &mut config, None) + .unwrap(); - let project = Project::from_manifest(manifest_path(&config)).unwrap(); - let venv_contains_package = venv.contains_package(&package).unwrap(); - let toml_contains_package = project - .pyproject_toml() - .dependencies() - .unwrap() - .contains(&package.dependency_string()); - venv.install_packages( - &[package], - config.installer_options.as_ref(), - &mut terminal, - ) - .unwrap(); + let project = Project::new(&config.workspace_root).unwrap(); + let venv_contains_package = venv.contains_package(&package); + let toml_contains_package = + project.dependencies().unwrap().contains(&dep); + venv.install_packages(&[&dep], None, &mut config.terminal) + .unwrap(); assert!(venv_had_package); assert!(toml_had_package); @@ -1403,51 +1611,48 @@ if __name__ == "__main__": &dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - let project = Project::from_manifest(manifest_path(&config)).unwrap(); - let venv = - VirtualEnvironment::from_path(PathBuf::from(".venv")).unwrap(); - let package = Package::from_str("black==22.8.0").unwrap(); - let mut terminal = Terminal::new(); - terminal.set_verbosity(config.terminal_options.verbosity); - let packages = [package.clone()]; - venv.uninstall_packages(&[package.name()], None, &mut terminal) - .unwrap(); - venv.install_packages( - &packages, - config.installer_options.as_ref(), - &mut terminal, + let project = Project::new(&config.workspace_root).unwrap(); + let venv = PythonEnvironment::new( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".venv"), ) .unwrap(); + let package = Package { + name: "black".to_string(), + canonical_name: String::from("black"), + version: Version440::from_str("22.8.0").unwrap(), + }; + let dep = Dependency::from_str("black==22.8.0").unwrap(); + venv.uninstall_packages(&[package.name()], None, &mut config.terminal) + .unwrap(); + venv.install_packages(&[&dep], None, &mut config.terminal) + .unwrap(); let venv_had_package = venv.contains_module(package.name()).unwrap(); let toml_had_package = project - .pyproject_toml() .optional_dependencey_group("dev") .unwrap() - .contains(&package.dependency_string()); + .contains(&dep); remove_project_optional_dependencies( &["black".to_string()], "dev", - &config, + &mut config, + None, ) .unwrap(); - let project = Project::from_manifest(manifest_path(&config)).unwrap(); + let project = Project::new(&config.workspace_root).unwrap(); let venv_contains_package = venv.contains_module(package.name()).unwrap(); - let toml_contains_package = project - .pyproject_toml() - .dependencies() - .unwrap() - .contains(&package.dependency_string()); - venv.uninstall_packages(&[package.name()], None, &mut terminal) + let toml_contains_package = + project.dependencies().unwrap().contains(&dep); + venv.uninstall_packages(&[package.name()], None, &mut config.terminal) .unwrap(); assert!(venv_had_package); @@ -1464,25 +1669,25 @@ if __name__ == "__main__": &dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - let venv = - VirtualEnvironment::from_path(PathBuf::from(".venv")).unwrap(); - let mut terminal = Terminal::new(); - terminal.set_verbosity(config.terminal_options.verbosity); - venv.uninstall_packages(&["black"], None, &mut terminal) + let venv = PythonEnvironment::new( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".venv"), + ) + .unwrap(); + venv.uninstall_packages(&["black"], None, &mut config.terminal) .unwrap(); let venv_had_package = venv.contains_module("black").unwrap(); - run_command_str("pip install black", &config).unwrap(); + run_command_str("pip install black", &mut config).unwrap(); let venv_contains_package = venv.contains_module("black").unwrap(); - venv.uninstall_packages(&["black"], None, &mut terminal) + venv.uninstall_packages(&["black"], None, &mut config.terminal) .unwrap(); assert!(!venv_had_package); @@ -1497,15 +1702,15 @@ if __name__ == "__main__": &dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - update_project_dependencies(None, &config).unwrap(); + update_project_dependencies(None, &mut config, None).unwrap(); } #[test] @@ -1516,15 +1721,15 @@ if __name__ == "__main__": &dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - update_project_optional_dependencies(None, &"dev".to_string(), &config) + update_project_optional_dependencies(None, "dev", &mut config, None) .unwrap(); } @@ -1533,29 +1738,14 @@ if __name__ == "__main__": fn test_use_python() { let dir = tempdir().unwrap().into_path(); let version = python_paths().max().unwrap().0.unwrap(); - let config = OperationConfig { - workspace_root: dir, - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { + workspace_root: dir.to_path_buf(), + cwd: dir, + terminal, }; - - use_python(version.to_string(), &config).unwrap(); - - let venv = VirtualEnvironment::from_path( - config - .workspace_root - .join(default_virtual_environment_name()), - ) - .unwrap(); - let version_string = version.to_string().replace(".", ""); - - assert_eq!( - venv.python_version().unwrap().to_string().replace(".", "") - [..version_string.len()], - version_string - ); + use_python(&version.to_string(), &mut config).unwrap(); } #[test] @@ -1566,14 +1756,14 @@ if __name__ == "__main__": &dir.join("mock-project"), ) .unwrap(); - let config = OperationConfig { + let mut terminal = Terminal::new(); + terminal.set_verbosity(Verbosity::Quiet); + let mut config = Config { workspace_root: dir.join("mock-project"), - terminal_options: TerminalOptions { - verbosity: Verbosity::Quiet, - }, - ..Default::default() + cwd: std::env::current_dir().unwrap(), + terminal, }; - test_project(&config).unwrap(); + test_project(&mut config, None).unwrap(); } } diff --git a/src/huak/sys.rs b/src/huak/sys.rs index 49a6bdc7..c8d87f95 100644 --- a/src/huak/sys.rs +++ b/src/huak/sys.rs @@ -201,6 +201,15 @@ impl Terminal { } } +pub fn parse_command_output( + output: std::process::Output, +) -> HuakResult { + let mut s = String::new(); + s.push_str(std::str::from_utf8(&output.stdout)?); + s.push_str(std::str::from_utf8(&output.stderr)?); + Ok(s) +} + impl Default for Terminal { fn default() -> Self { Self::new() @@ -272,11 +281,6 @@ impl TerminalOut { } } -#[derive(Default)] -pub struct TerminalOptions { - pub verbosity: Verbosity, -} - /// Gets the name of the current shell. pub fn shell_name() -> HuakResult { let shell_path = shell_path()?;