From 8e6e8299a00882cbd3e3b5381535c7f0368d7bba Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Fri, 11 Dec 2020 09:46:36 -0500 Subject: [PATCH 01/17] Freezing module to correct issue --- forge/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/forge/__init__.py b/forge/__init__.py index 93484a7..f2e591c 100755 --- a/forge/__init__.py +++ b/forge/__init__.py @@ -1,21 +1,26 @@ """ Forge """ #! /usr/bin/env python3 -import sys import os +import sys from pathlib import Path from typing import List, Any +from multiprocessing import freeze_support + from pluginbase import PluginBase from tabulate import tabulate from .config.config_handler import ConfigHandler + CONF_HOME = str(Path(str(Path.home()) + '/.forge')) CONFIG_FILE_PATH = str(Path(CONF_HOME + '/conf.ini')) PLUGIN_BASE = PluginBase(package='plugins') WORKING_DIR = os.path.dirname(os.path.abspath(__file__)) INTERNAL_PLUGIN_PATH = str(Path(f'{WORKING_DIR}/_internal_plugins/manage_plugins')) + class Application: """ Application Class """ + def __init__(self, name: str, config_handler: ConfigHandler) -> None: config_handler.init_conf_dir() config_handler.init_conf_file() @@ -25,7 +30,7 @@ def __init__(self, name: str, config_handler: ConfigHandler) -> None: self.plugins = [ INTERNAL_PLUGIN_PATH, config_handler.get_plugin_install_location() - ] + config_handler.get_plugins() + ] + config_handler.get_plugins() self.plugin_source = PLUGIN_BASE.make_plugin_source( searchpath=self.plugins, @@ -53,6 +58,7 @@ def execute(self, command: str, args: Any) -> None: else: self.registry[command][0](args) + def main(args: list) -> None: """ Main Function Definition """ if len(args) > 1: @@ -74,4 +80,5 @@ def main(args: list) -> None: if __name__ == '__main__': + freeze_support() main(sys.argv[1:]) From 2e537455d5f5cf1878f36feca6bd750fed5b2ded Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Sat, 12 Dec 2020 21:56:17 -0500 Subject: [PATCH 02/17] Windows support and better feedback --- bin/forge | 5 +- bin/forge.bat | 2 +- forge/__init__.py | 62 ++-- forge/_internal_plugins/__init__.py | 0 .../manage_plugins/__init__.py | 0 .../manage_plugins/manage_plugins.py | 4 + .../command_line_parser.py | 51 +++ .../manage_plugin_exceptions.py | 9 + .../manage_plugins_logic/manage_plugins.py | 297 ++++++++---------- .../manage_plugins_logic/plugin_puller.py | 45 ++- forge/config/__init__.py | 0 forge/config/config_handler.py | 7 +- setup.py | 13 +- 13 files changed, 279 insertions(+), 216 deletions(-) delete mode 100644 forge/_internal_plugins/__init__.py delete mode 100644 forge/_internal_plugins/manage_plugins/__init__.py create mode 100644 forge/_internal_plugins/manage_plugins/manage_plugins_logic/command_line_parser.py create mode 100644 forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugin_exceptions.py delete mode 100644 forge/config/__init__.py diff --git a/bin/forge b/bin/forge index e4ce407..e20b7e5 100644 --- a/bin/forge +++ b/bin/forge @@ -1,6 +1,9 @@ #! /usr/bin/env python3 import sys + import forge -forge.main(sys.argv[1:]) \ No newline at end of file + +if __name__ == '__main__': + forge.main(sys.argv[1:]) diff --git a/bin/forge.bat b/bin/forge.bat index 0da7e25..8b0b1f0 100644 --- a/bin/forge.bat +++ b/bin/forge.bat @@ -1,2 +1,2 @@ @Echo off -python %~dp0forge +python %~dp0forge %* diff --git a/forge/__init__.py b/forge/__init__.py index f2e591c..388c008 100755 --- a/forge/__init__.py +++ b/forge/__init__.py @@ -2,18 +2,19 @@ #! /usr/bin/env python3 import os import sys +from inspect import getmembers, isfunction from pathlib import Path -from typing import List, Any -from multiprocessing import freeze_support +from pprint import pprint +from typing import Any, List -from pluginbase import PluginBase +import pluginbase from tabulate import tabulate -from .config.config_handler import ConfigHandler +from .config.config_handler import ConfigHandler CONF_HOME = str(Path(str(Path.home()) + '/.forge')) CONFIG_FILE_PATH = str(Path(CONF_HOME + '/conf.ini')) -PLUGIN_BASE = PluginBase(package='plugins') +PLUGIN_BASE = pluginbase.PluginBase(package='forge.plugins') WORKING_DIR = os.path.dirname(os.path.abspath(__file__)) INTERNAL_PLUGIN_PATH = str(Path(f'{WORKING_DIR}/_internal_plugins/manage_plugins')) @@ -28,17 +29,20 @@ def __init__(self, name: str, config_handler: ConfigHandler) -> None: self.registry = {} self.plugins = [ - INTERNAL_PLUGIN_PATH, - config_handler.get_plugin_install_location() + INTERNAL_PLUGIN_PATH ] + config_handler.get_plugins() self.plugin_source = PLUGIN_BASE.make_plugin_source( searchpath=self.plugins, identifier=self.name) + for plugin_name in self.plugin_source.list_plugins(): + if plugin_name.endswith('_logic'): + continue + plugin = self.plugin_source.load_plugin(plugin_name) - if callable(getattr(plugin, "register", None)): - plugin.register(self) + + plugin.register(self) def register_plugin(self, name: str, plugin, helptext: str) -> None: """A function a plugin can use to register itself.""" @@ -46,39 +50,43 @@ def register_plugin(self, name: str, plugin, helptext: str) -> None: def print_help(self) -> None: """ Print Help For All Registered Plugins """ + print('\nTo use forge plugins try running a plugin like this:', end='\n\n') + print('\tforge manage-plugins -h', end='\n\n') + help_entries = [] for name in self.registry: help_entries.append([name, self.registry[name][1]]) - print(tabulate(help_entries, ['function', 'blurb'])) + print(tabulate(help_entries, ['plugin', 'description']), end='\n\n') def execute(self, command: str, args: Any) -> None: """ Execute Plugin """ if command == 'help': self.print_help() else: - self.registry[command][0](args) + if command not in self.registry: + print(f'Unknown command: {command}') + sys.exit(1) + else: + self.registry[command][0](args) def main(args: list) -> None: """ Main Function Definition """ - if len(args) > 1: - Application( - 'forge', - ConfigHandler( - home_dir_path=CONF_HOME, - file_path_dir=CONFIG_FILE_PATH - ) - ).execute(args[0], args[1:]) + name = 'forge' + config_handler = ConfigHandler( + home_dir_path=CONF_HOME, + file_path_dir=CONFIG_FILE_PATH + ) + app = Application( + name=name, + config_handler=config_handler + ) + + if len(args) > 0: + app.execute(args[0], args[1:]) else: - Application( - 'forge', - ConfigHandler( - home_dir_path=CONF_HOME, - file_path_dir=CONFIG_FILE_PATH - ) - ).execute('help', None) + app.execute('help', None) if __name__ == '__main__': - freeze_support() main(sys.argv[1:]) diff --git a/forge/_internal_plugins/__init__.py b/forge/_internal_plugins/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/forge/_internal_plugins/manage_plugins/__init__.py b/forge/_internal_plugins/manage_plugins/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins.py b/forge/_internal_plugins/manage_plugins/manage_plugins.py index 9252b74..3dff785 100644 --- a/forge/_internal_plugins/manage_plugins/manage_plugins.py +++ b/forge/_internal_plugins/manage_plugins/manage_plugins.py @@ -1,11 +1,15 @@ """ Manage Plugins Plugin """ from pathlib import Path + from forge.config.config_handler import ConfigHandler + from .manage_plugins_logic.manage_plugins import ManagePlugins from .manage_plugins_logic.plugin_puller import PluginPuller + CONF_HOME = str(Path(str(Path.home()) + '/.forge')) CONFIG_FILE_PATH = str(Path(CONF_HOME + '/conf.ini')) + def execute(args: list) -> None: """ Plugin Execution Definition """ config_handler = ConfigHandler( diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/command_line_parser.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/command_line_parser.py new file mode 100644 index 0000000..1fe7e1d --- /dev/null +++ b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/command_line_parser.py @@ -0,0 +1,51 @@ +""" Command Line Parser for Manage Plugins """ + +import argparse + + +def init_arg_parser() -> argparse.ArgumentParser: + """ Initialize Argument Parser """ + parser = argparse.ArgumentParser( + prog='forge manage-plugins', + description='Tool to allow users to configure their ' + 'anvil installations with plugins. Provides ' + 'the ability to add plugins via a repo reference ' + 'and the ability to update all plugins currently installed. ' + 'See -h for more information.') + parser.add_argument('-a', '--add', + action='store_const', + dest='action_type', + const='ADD', + required=False, + help='Add a new plugin') + + parser.add_argument('-u', '--update', + action='store_const', + dest='action_type', + const='UPDATE', + required=False, + help='Updates named plugin (via -n) or all plugins if -n not ' + 'provided') + parser.add_argument('-i', '--init', + action='store_const', + dest='action_type', + const='INIT', + required=False, + help='Initializes Forge based on an existing plugin conf.ini.') + parser.add_argument('-r', '--repo', + action='store', + dest='repo_url', + required=False, + help='Url to git repo containing plugin source. ' + 'NOTE it must refer to the clone URL, not the browser URL.') + parser.add_argument('-b', '--branch', + action='store', + dest='branch_name', + required=False, + help='Optionally pass the branch name for the plugin.') + parser.add_argument('-n', '--name', + action='store', + dest='plugin_name', + required=False, + help='The exact name of the plugin.') + return parser diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugin_exceptions.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugin_exceptions.py new file mode 100644 index 0000000..1214af4 --- /dev/null +++ b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugin_exceptions.py @@ -0,0 +1,9 @@ +""" Plugin Mangagement Exceptions """ + + +class PluginManagementFatalException(Exception): + pass + + +class PluginManagementWarnException(Exception): + pass diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py index ad03e0a..9410a9b 100644 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py +++ b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py @@ -1,21 +1,27 @@ """ Manage Plugins Internally """ import argparse -import sys -import itertools +import os import re -from multiprocessing import Process -from colorama import init, deinit, Fore -from git import GitCommandError, GitCommandNotFound, Repo -from .plugin_puller import PluginPuller -from typing import Iterator, Any, List +import subprocess +import sys +from typing import Any + +from colorama import Fore, deinit, init from forge.config.config_handler import ConfigHandler +from git import GitCommandError, GitCommandNotFound, Repo +from halo import Halo +from .command_line_parser import init_arg_parser +from .manage_plugin_exceptions import (PluginManagementFatalException, + PluginManagementWarnException) +from .plugin_puller import PluginPuller class ManagePlugins: """ Manage Plugins """ + def __init__(self, plugin_puller: PluginPuller, config_handler: ConfigHandler) -> None: - self.arg_parser = self.init_arg_parser() + self.arg_parser = init_arg_parser() self.plugin_puller = plugin_puller self.config_handler = config_handler @@ -24,94 +30,31 @@ def execute(self, args: list) -> None: parsed_args = self.arg_parser.parse_args(args=args) self.validate_args(parsed_args=parsed_args) - spinner_process = Process(target=self.show_spinner) - spinner_process.start() - if parsed_args.action_type == 'ADD': - self._do_add(parsed_args, spinner_process) - elif parsed_args.action_type == 'UPDATE': - self._do_update(parsed_args, spinner_process) - elif parsed_args.action_type == 'INIT': - self._do_init(parsed_args, spinner_process) - - @staticmethod - def init_arg_parser() -> argparse.ArgumentParser: - """ Initialize Argument Parser """ - parser = argparse.ArgumentParser( - prog='forge manage-plugins', - description='Tool to allow users to configure their ' - 'anvil installations with plugins. Provides ' - 'the ability to add plugins via a repo reference ' - 'and the ability to update all plugins currently installed. ' - 'See -h for more information.') - parser.add_argument('-a', '--add', - action='store_const', - dest='action_type', - const='ADD', - required=False, - help='Add a new plugin') - - parser.add_argument('-u', '--update', - action='store_const', - dest='action_type', - const='UPDATE', - required=False, - help='Updates named plugin (via -n) or all plugins if -n not ' - 'provided') - parser.add_argument('-i', '--init', - action='store_const', - dest='action_type', - const='INIT', - required=False, - help='Initializes Forge based on an existing plugin conf.ini.') - parser.add_argument('-r', '--repo', - action='store', - dest='repo_url', - required=False, - help='Url to git repo containing plugin source. ' - 'NOTE it must refer to the clone URL, not the browser URL.') - parser.add_argument('-b', '--branch', - action='store', - dest='branch_name', - required=False, - help='Optionally pass the branch name for the plugin.') - parser.add_argument('-n', '--name', - action='store', - dest='plugin_name', - required=False, - help='The exact name of the plugin.') - return parser - - @staticmethod - def handle_error(message: str, spinner: Process) -> None: - """ Class Level Error Handling """ init(autoreset=True) - spinner.terminate() - print(Fore.RED + '\n' + message) - deinit() - sys.exit(1) - def pull_plugin(self, url: str, name: str, branch_name: str) -> Repo: - """ Pull Plugin from Git """ - return self.plugin_puller.clone_plugin(url, name, branch_name) + try: + if parsed_args.action_type == 'ADD': + self._do_add(parsed_args) + elif parsed_args.action_type == 'UPDATE': + self._do_update(parsed_args) + elif parsed_args.action_type == 'INIT': + self._do_init(parsed_args) + + except PluginManagementFatalException: + sys.exit(1) + except PluginManagementWarnException: + sys.exit(0) - @staticmethod - def show_spinner() -> None: - """ Graphical Spinner on CLI """ - spinner = itertools.cycle('-/|\\') - while True: - sys.stdout.write(next(spinner)) - sys.stdout.flush() - sys.stdout.write('\b') + finally: + deinit() @staticmethod def pull_name_from_url(url: str) -> Any: """ Extract Plugin Name from Git URL """ - match = re.search(r'[\s]*\/(forge-[A-Za-z1-9]+)[\s]*', url) - - if match: - return match.group(1) - - return None + match = re.search(r'[\s]*\/(forge(?:-[A-Za-z1-9]*)+)[\s]*', url) + if not match: + handle_error('Repository name should be in the form of forge-[alphanumeric name]') + return match.group(1) def validate_args(self, parsed_args: argparse.Namespace) -> None: """Validates passed in args, the presence of some args makes other args required.""" @@ -119,94 +62,106 @@ def validate_args(self, parsed_args: argparse.Namespace) -> None: if action == 'ADD': self._validate_add_action(args=parsed_args) elif action is None: - print(Fore.RED + '\n' + - 'Please provide an action with -a, -u or -i') - sys.exit(1) + handle_error('Please provide an action with -a, -u or -i') @staticmethod def _validate_add_action(args: argparse.Namespace) -> None: if args.repo_url is None: - print(Fore.RED + '\n' + - 'Cant add plugin without providing url!') - sys.exit(1) + handle_error('Cant add plugin without providing url!') + + def _clone_plugin_step(self, name, args) -> None: + """ Clones plugin source code into forge folder """ + with Halo(text='Cloning source code...', spinner='dots', color='blue') as spinner: + try: + repo = self.plugin_puller.clone_plugin(repo_url=args.repo_url, plugin_name=name, branch_name=args.branch_name) + spinner.succeed("Cloned plugin source code!") - def _do_add(self, args: argparse.Namespace, spinner: Process) -> None: + except PluginManagementFatalException as err: + spinner.fail('Could not clone plugin! ' + str(err)) + raise err from None + + except PluginManagementWarnException as err: + spinner.warn(str(err)) + raise err from None + + def _configure_plugin_step(self, args) -> None: + """ Configures plugin for use by forge """ name = self.pull_name_from_url(url=args.repo_url) - if name is None: - self.handle_error( - message='Repository name should be in the form of forge-[alphanumeric name]', - spinner=spinner - ) - repo = None - print("Pulling plugin source...") - try: - repo = self.pull_plugin(url=args.repo_url, name=name, branch_name=args.branch_name) - if repo.bare: - self.handle_error( - message='Plugin repository contained no source code...', - spinner=spinner - ) - except GitCommandError as err: - self.handle_error( - message=f'Could not pull plugin {err}', - spinner=spinner + with Halo(text='Configuring plugin for use...', spinner='dots', color='blue') as spinner: + self.config_handler.write_plugin_to_conf( + name=name, + url=args.repo_url ) - print(Fore.GREEN + '\n' + "Pulled plugin source, configuring for use...") - self.config_handler.write_plugin_to_conf( - name=name, - url=args.repo_url - ) - spinner.terminate() - print(Fore.GREEN + '\n' + 'Plugin ready for use!') - - def _do_update(self, args: argparse.Namespace, spinner: Process) -> None: - if args.plugin_name: + spinner.succeed(f"Plugin configured!") + + def _pull_plugin_step(self, name: str, args: argparse.Namespace) -> None: + """ Pulls plugin source code into forge folder """ + + with Halo(text=f'Updating plugin: [{name}]...', spinner='dots', color='blue') as spinner: try: - self.plugin_puller.pull_plugin( - plugin_name=args.plugin_name, - branch_name=args.branch_name - ) - print(Fore.GREEN + '\n' + 'Plugin updated!') - except GitCommandError as err: - self.handle_error( - message=f'Could not update plugin {err}', - spinner=spinner - ) - except GitCommandNotFound as err: - self.handle_error( - message=f'Could not update plugin, most' \ - 'likely caused by providing an invalid name.', - spinner=spinner - ) + repo = self.plugin_puller.pull_plugin(plugin_name=name, branch_name=args.branch_name) + spinner.succeed(text=f"Plugin: [{name}] updated!") + + except PluginManagementFatalException as err: + spinner.fail(f'Could not update plugin: [{name}]! {str(err)}') + raise err from None + + except PluginManagementWarnException: + spinner.succeed(f'Plugin: [{name}] already up to date!') + + def _install_dependencies(self) -> None: + """ Installs all dependencies required by all plugins """ + with Halo(text='Installing plugin dependencies...', spinner='dots', color='blue') as spinner: + for plugin_path in self.config_handler.get_plugins(): + req_file = os.path.join(plugin_path, 'requirements.txt') + if os.path.exists(req_file): + with open(os.devnull, 'wb') as devnull: + subprocess.check_call(f'{sys.executable} -m pip install -r {req_file}'.split(), stdout=devnull, stderr=subprocess.STDOUT) + spinner.succeed(f"Plugin dependencies installed!") + + def _do_add(self, args: argparse.Namespace) -> None: + print('Installing plugin...') + + name = self.pull_name_from_url(url=args.repo_url) + + self._clone_plugin_step(name=name, args=args) + self._configure_plugin_step(args=args) + self._install_dependencies() + + with Halo(text='Finishing up...', spinner='dots', color='blue') as spinner: + spinner.succeed('Plugin installed and ready for use!') + + def _do_update(self, args: argparse.Namespace) -> None: + print('Updating plugins...') + if args.plugin_name: + self._pull_plugin_step(name=args.plugin_name, args=args) else: - for name in self.config_handler.get_plugin_entries(): - print(f'Updating {name}...') - try: - self.plugin_puller.pull_plugin( - plugin_name=name, - branch_name=args.branch_name - ) - except GitCommandError as err: - self.handle_error( - message=f'Could not update plugin {name} : {err}', - spinner=spinner - ) - spinner.terminate() - print(Fore.GREEN + '\n' + 'Plugin(s) updated!') - - def _do_init(self, args: argparse.Namespace, spinner: Process): - for(name, url) in self.config_handler.get_plugin_entries(): - print(f'Installing {name}...') - try: - self.plugin_puller.clone_plugin( - repo_url=url, - plugin_name=name, - branch_name=args.branch_name - ) - except GitCommandError as err: - self.handle_error( - message=f'Could not install plugin {name} : {err}', - spinner=spinner - ) - spinner.terminate() - print(Fore.GREEN + '\n' + 'Plugins installed!') + for name, url in self.config_handler.get_plugin_entries(): + args.repo_url = url + self._pull_plugin_step(name=name, args=args) + + with Halo(text='Finishing up...', spinner='dots', color='blue') as spinner: + spinner.succeed('Updated plugins!') + + def _do_init(self, args: argparse.Namespace): + print('Initializing plugins...') + + for name, url in self.config_handler.get_plugin_entries(): + args.repo_url = url + self._clone_plugin_step(name=name, args=args) + + self._install_dependencies() + + with Halo(text='Finishing up...', spinner='dots', color='blue') as spinner: + spinner.succeed('Initialized plugins!') + + +def write_failure_message(message: str) -> None: + """ Writes message in red color for errors """ + print(Fore.RED + '\n' + message) + + +def handle_error(message: str) -> None: + """ Class Level Error Handling """ + write_failure_message(message) + raise PluginManagementFatalException(message) diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py index f007594..2ec0a32 100644 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py +++ b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py @@ -1,22 +1,54 @@ """ Plugin Puller """ -import subprocess +from functools import wraps from pathlib import Path -from git import Repo -from git import Git -from colorama import Fore + +from git import Git, GitCommandError, GitCommandNotFound, Repo + +from .manage_plugin_exceptions import (PluginManagementFatalException, + PluginManagementWarnException) + + +def handle_git_errors(func): + """ decorator to share error handling code between git operations """ + @wraps(func) + def wrapper(*args, **kwargs): + repo = None + + try: + repo = func(*args, **kwargs) + + except GitCommandError as err: + if 'already exists and is not an empty directory' in err.stderr: + raise PluginManagementWarnException('Plugin already installed to forge!') + raise PluginManagementFatalException(f'Failed to pull source code! {err.stderr}') + + except GitCommandNotFound as err: + raise PluginManagementFatalException(f'Failed to pull source code! {err.stderr}') + + if not isinstance(repo, Repo): + raise PluginManagementWarnException('Plugin already up to date!') + elif repo.bare: + raise PluginManagementFatalException('Given repository has no data!') + return repo + + return wrapper + class PluginPuller: - """ Plugin Puller Class Def """ + """ Plugin Puller Class """ + def __init__(self, config_handler): self.config_handler = config_handler + @handle_git_errors def pull_plugin(self, plugin_name, branch_name='dev') -> Repo: """Updates plugin by executing a git pull from its install location""" repo = str(Path(f'{self.config_handler.get_plugin_install_location()}/{plugin_name}')) repo = Git(repo).pull('origin', branch_name) - return repo + return repo + @handle_git_errors def clone_plugin(self, repo_url, plugin_name, branch_name='dev') -> Repo: """ Clone Plugin From Git """ repo = Repo.clone_from( @@ -24,4 +56,5 @@ def clone_plugin(self, repo_url, plugin_name, branch_name='dev') -> Repo: str(Path(f'{self.config_handler.get_plugin_install_location()}/{plugin_name}')), branch=branch_name ) + return repo diff --git a/forge/config/__init__.py b/forge/config/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/forge/config/config_handler.py b/forge/config/config_handler.py index a8b0aaf..e12e8ed 100644 --- a/forge/config/config_handler.py +++ b/forge/config/config_handler.py @@ -7,8 +7,10 @@ # CONF_HOME = str(Path(str(Path.home()) + '/.forge')) # CONFIG_FILE_PATH = str(Path(CONF_HOME + '/conf.ini')) + class ConfigHandler: """ Class that handles operations against Forge conf.ini """ + def __init__(self, home_dir_path: str, file_path_dir: str): self.home_dir_path = home_dir_path self.file_path_dir = file_path_dir @@ -24,23 +26,20 @@ def init_conf_file(self) -> None: if not os.path.exists(self.file_path_dir): config['plugin-definitions'] = {} config['install-conf'] = {} - # this is default plugin install location + # this is default plugin install location config['install-conf']['pluginlocation'] = str(Path(self.home_dir_path + '/plugins')) with open(self.file_path_dir, 'w') as conf_file: config.write(conf_file) - def _get_config_parser(self) -> configparser.ConfigParser: config_parser = configparser.ConfigParser() config_parser.read(self.file_path_dir) return config_parser - def get_plugin_install_location(self) -> str: """ Returns the configured location for plugin installations """ return self._get_config_parser()['install-conf']['pluginlocation'] - def get_plugins(self) -> List[str]: """ Parse Plugin Configuration File """ config = self._get_config_parser() diff --git a/setup.py b/setup.py index 755b51e..7be80ce 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ """ Setup """ from setuptools import setup setup(name='tele-forge', - version='1.0.0', + version='1.0.1', description='convenient cli with extendable collection of useful plugins', url='https://github.com/TeleTrackingTechnologies/forge', author='Brandon Horn, Kenneth Poling, Paul Verardi, Cameron Tucker, Clint Wadley', @@ -14,11 +14,12 @@ 'forge._internal_plugins.manage_plugins.manage_plugins_logic' ], install_requires=[ - 'colorama', - 'GitPython', - 'pluginbase', - 'requests', - 'tabulate' + 'colorama==0.4.4', + 'GitPython==3.1.1', + 'pluginbase==1.0.0', + 'requests==2.25.0', + 'tabulate==0.8.7', + 'halo==0.0.31' ], classifiers=[ 'Programming Language :: Python :: 3', From c3a8143dc8e2ae1662a14f6752742edf22f0e440 Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Sun, 13 Dec 2020 16:17:50 -0500 Subject: [PATCH 03/17] Test coverage to 96% --- .github/workflows/pythonpackage.yml | 5 +- .gitignore | 4 + pylintrc => .pylintrc | 2 +- Makefile | 27 ++- dev-requirements.txt | 7 + forge/__init__.py | 2 +- .../test_stubs => }/__init__.py | 0 .../manage_plugins/__init__.py | 0 .../manage_plugins/manage_plugins.py | 11 +- .../manage_plugin_exceptions.py | 4 +- .../manage_plugins_logic/manage_plugins.py | 69 ++++-- .../manage_plugins_test.py | 87 -------- .../manage_plugins_logic/plugin_puller.py | 14 +- forge/config/__init__.py | 0 forge/config/config_handler.py | 10 +- forge/config/config_handler_test.py | 58 ----- setup.py | 3 +- tests/config_handler_test.py | 73 +++++++ tests/main_init_test.py | 116 ++++++++++ tests/manage_plugins_registration_test.py | 40 ++++ tests/manage_plugins_test.py | 203 ++++++++++++++++++ tests/plugin_puller_test.py | 90 ++++++++ tests/test_stubs/__init__.py | 0 .../test_stubs/stub_config_parser.py | 14 +- .../test_stubs/stub_plugin_puller.py | 7 +- 25 files changed, 643 insertions(+), 203 deletions(-) rename pylintrc => .pylintrc (99%) create mode 100644 dev-requirements.txt rename forge/_internal_plugins/{manage_plugins/manage_plugins_logic/test_stubs => }/__init__.py (100%) create mode 100644 forge/_internal_plugins/manage_plugins/__init__.py delete mode 100644 forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins_test.py create mode 100644 forge/config/__init__.py delete mode 100644 forge/config/config_handler_test.py create mode 100644 tests/config_handler_test.py create mode 100644 tests/main_init_test.py create mode 100644 tests/manage_plugins_registration_test.py create mode 100644 tests/manage_plugins_test.py create mode 100644 tests/plugin_puller_test.py create mode 100644 tests/test_stubs/__init__.py rename {forge/_internal_plugins/manage_plugins/manage_plugins_logic => tests}/test_stubs/stub_config_parser.py (56%) rename {forge/_internal_plugins/manage_plugins/manage_plugins_logic => tests}/test_stubs/stub_plugin_puller.py (88%) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 21b42a5..c892ec1 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -33,7 +33,6 @@ jobs: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest + - name: Lint with pylint and test with pytest run: | - pip install pytest - pytest + make test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 89f4adf..d77f4fc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Development Directories .venv +venv .pytype # Build Info *.egg-info @@ -15,3 +16,6 @@ __pycache__ # OS generated files # .DS_Store + +.coverage +cov_html \ No newline at end of file diff --git a/pylintrc b/.pylintrc similarity index 99% rename from pylintrc rename to .pylintrc index 8e8a654..d456315 100644 --- a/pylintrc +++ b/.pylintrc @@ -18,7 +18,7 @@ ignore-patterns= #init-hook= # Use multiple processes to speed up Pylint. -jobs=1 +jobs=4 # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. diff --git a/Makefile b/Makefile index 8779dd4..9744390 100644 --- a/Makefile +++ b/Makefile @@ -29,28 +29,41 @@ init: pip3 install -r requirements.txt; \ ) -lint: +dev: init ( \ . .venv/bin/activate; \ - pylint -j 4 --rcfile=pylintrc forge; \ + pip3 install -r dev-requirements.txt; \ + ) + + +lint: dev + ( \ + . .venv/bin/activate; \ + $(PYTHON) -m pylint -j 4 -r y forge; \ ) build: ( \ + . .venv/bin/activate; \ $(PYTHON) -m pip install --upgrade setuptools wheel; \ $(PYTHON) setup.py sdist bdist_wheel; \ ) install: build ( \ - $(PYTHON) -m pip install dist/tele_forge-${VERSION}-py3-none-any.whl; \ - ) + . .venv/bin/activate; \ + $(PYTHON) -m pip install dist/tele_forge-${VERSION}-py3-none-any.whl; \ + ) + +test: lint + ( \ + . .venv/bin/activate; \ + $(PYTHON) -m pytest -rf -vvv -x --count 5 --cov=forge --cov-fail-under=80 --cov-report term; \ + ) -test: - $(PYTHON) -m unittest discover -s forge -p '*_test.py' clean: - rm -rf forge.egg-info/ build/ dist/ .venv/ + rm -rf forge.egg-info/ build/ dist/ .venv/ venv/ type-check: pytype *.py forge diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..21a7877 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,7 @@ +pylint==2.6.0 +pytest==6.1.2 +pytest-randomly==3.5.0 +pytest-repeat==0.9.1 +pytest-cov==2.10.1 +mock==4.0.2 +twine==1.13.0 diff --git a/forge/__init__.py b/forge/__init__.py index 388c008..ec01dd4 100755 --- a/forge/__init__.py +++ b/forge/__init__.py @@ -89,4 +89,4 @@ def main(args: list) -> None: if __name__ == '__main__': - main(sys.argv[1:]) + main(args=sys.argv[1:]) diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/__init__.py b/forge/_internal_plugins/__init__.py similarity index 100% rename from forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/__init__.py rename to forge/_internal_plugins/__init__.py diff --git a/forge/_internal_plugins/manage_plugins/__init__.py b/forge/_internal_plugins/manage_plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins.py b/forge/_internal_plugins/manage_plugins/manage_plugins.py index 3dff785..5c92ba0 100644 --- a/forge/_internal_plugins/manage_plugins/manage_plugins.py +++ b/forge/_internal_plugins/manage_plugins/manage_plugins.py @@ -16,15 +16,18 @@ def execute(args: list) -> None: home_dir_path=CONF_HOME, file_path_dir=CONFIG_FILE_PATH ) - manage_plugins_logic = ManagePlugins(PluginPuller(config_handler), config_handler) - manage_plugins_logic.execute(args) + manage_plugins_logic = ManagePlugins( + plugin_puller=PluginPuller(config_handler), + config_handler=config_handler + ) + manage_plugins_logic.execute(args=args) def helptext() -> str: """ Simple Helptext For Plugin """ - return "For managing plugins for use by forge." + return 'For managing plugins for use by forge.' def register(app) -> None: """ Plugin Registration """ - app.register_plugin('manage-plugins', execute, helptext()) + app.register_plugin(name='manage-plugins', plugin=execute, helptext=helptext()) diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugin_exceptions.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugin_exceptions.py index 1214af4..2c53b19 100644 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugin_exceptions.py +++ b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugin_exceptions.py @@ -2,8 +2,8 @@ class PluginManagementFatalException(Exception): - pass + """ Exception class for raising during fatal issues """ class PluginManagementWarnException(Exception): - pass + """ Exception class for raising during warning issues """ diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py index 9410a9b..70bbd97 100644 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py +++ b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py @@ -7,14 +7,13 @@ from typing import Any from colorama import Fore, deinit, init -from forge.config.config_handler import ConfigHandler -from git import GitCommandError, GitCommandNotFound, Repo from halo import Halo +from forge.config.config_handler import ConfigHandler from .command_line_parser import init_arg_parser +from .plugin_puller import PluginPuller from .manage_plugin_exceptions import (PluginManagementFatalException, PluginManagementWarnException) -from .plugin_puller import PluginPuller class ManagePlugins: @@ -28,7 +27,7 @@ def __init__(self, plugin_puller: PluginPuller, config_handler: ConfigHandler) - def execute(self, args: list) -> None: """ Execute """ parsed_args = self.arg_parser.parse_args(args=args) - self.validate_args(parsed_args=parsed_args) + self._validate_args(parsed_args=parsed_args) init(autoreset=True) @@ -49,14 +48,14 @@ def execute(self, args: list) -> None: deinit() @staticmethod - def pull_name_from_url(url: str) -> Any: + def _pull_name_from_url(url: str) -> Any: """ Extract Plugin Name from Git URL """ match = re.search(r'[\s]*\/(forge(?:-[A-Za-z1-9]*)+)[\s]*', url) if not match: handle_error('Repository name should be in the form of forge-[alphanumeric name]') return match.group(1) - def validate_args(self, parsed_args: argparse.Namespace) -> None: + def _validate_args(self, parsed_args: argparse.Namespace) -> None: """Validates passed in args, the presence of some args makes other args required.""" action = parsed_args.action_type if action == 'ADD': @@ -67,17 +66,25 @@ def validate_args(self, parsed_args: argparse.Namespace) -> None: @staticmethod def _validate_add_action(args: argparse.Namespace) -> None: if args.repo_url is None: - handle_error('Cant add plugin without providing url!') + handle_error('Can\'t add plugin without providing url!') def _clone_plugin_step(self, name, args) -> None: """ Clones plugin source code into forge folder """ - with Halo(text='Cloning source code...', spinner='dots', color='blue') as spinner: + with Halo( + text=f'Cloning plugin: [{name}]...', + spinner='dots', + color='blue' + ) as spinner: try: - repo = self.plugin_puller.clone_plugin(repo_url=args.repo_url, plugin_name=name, branch_name=args.branch_name) - spinner.succeed("Cloned plugin source code!") + self.plugin_puller.clone_plugin( + repo_url=args.repo_url, + plugin_name=name, + branch_name=args.branch_name + ) + spinner.succeed(f'Cloned plugin: [{name}]!') except PluginManagementFatalException as err: - spinner.fail('Could not clone plugin! ' + str(err)) + spinner.fail(f'Could not clone plugin: [{name}]! ' + str(err)) raise err from None except PluginManagementWarnException as err: @@ -86,21 +93,28 @@ def _clone_plugin_step(self, name, args) -> None: def _configure_plugin_step(self, args) -> None: """ Configures plugin for use by forge """ - name = self.pull_name_from_url(url=args.repo_url) + name = self._pull_name_from_url(url=args.repo_url) with Halo(text='Configuring plugin for use...', spinner='dots', color='blue') as spinner: self.config_handler.write_plugin_to_conf( name=name, url=args.repo_url ) - spinner.succeed(f"Plugin configured!") + spinner.succeed("Plugin configured!") def _pull_plugin_step(self, name: str, args: argparse.Namespace) -> None: """ Pulls plugin source code into forge folder """ - with Halo(text=f'Updating plugin: [{name}]...', spinner='dots', color='blue') as spinner: + with Halo( + text=f'Updating plugin: [{name}]...', + spinner='dots', + color='blue' + ) as spinner: try: - repo = self.plugin_puller.pull_plugin(plugin_name=name, branch_name=args.branch_name) - spinner.succeed(text=f"Plugin: [{name}] updated!") + self.plugin_puller.pull_plugin( + plugin_name=name, + branch_name=args.branch_name + ) + spinner.succeed(text=f'Plugin: [{name}] updated!') except PluginManagementFatalException as err: spinner.fail(f'Could not update plugin: [{name}]! {str(err)}') @@ -111,18 +125,27 @@ def _pull_plugin_step(self, name: str, args: argparse.Namespace) -> None: def _install_dependencies(self) -> None: """ Installs all dependencies required by all plugins """ - with Halo(text='Installing plugin dependencies...', spinner='dots', color='blue') as spinner: + with Halo( + text='Installing plugin dependencies...', + spinner='dots', + color='blue' + ) as spinner: for plugin_path in self.config_handler.get_plugins(): req_file = os.path.join(plugin_path, 'requirements.txt') if os.path.exists(req_file): with open(os.devnull, 'wb') as devnull: - subprocess.check_call(f'{sys.executable} -m pip install -r {req_file}'.split(), stdout=devnull, stderr=subprocess.STDOUT) - spinner.succeed(f"Plugin dependencies installed!") + command = f'{sys.executable} -m pip install -r {req_file}'.split() + + subprocess.check_call(command, + stdout=devnull, + stderr=subprocess.STDOUT + ) + spinner.succeed('Plugin dependencies installed!') def _do_add(self, args: argparse.Namespace) -> None: print('Installing plugin...') - name = self.pull_name_from_url(url=args.repo_url) + name = self._pull_name_from_url(url=args.repo_url) self._clone_plugin_step(name=name, args=args) self._configure_plugin_step(args=args) @@ -140,6 +163,8 @@ def _do_update(self, args: argparse.Namespace) -> None: args.repo_url = url self._pull_plugin_step(name=name, args=args) + self._install_dependencies() + with Halo(text='Finishing up...', spinner='dots', color='blue') as spinner: spinner.succeed('Updated plugins!') @@ -156,12 +181,12 @@ def _do_init(self, args: argparse.Namespace): spinner.succeed('Initialized plugins!') -def write_failure_message(message: str) -> None: +def _write_failure_message(message: str) -> None: """ Writes message in red color for errors """ print(Fore.RED + '\n' + message) def handle_error(message: str) -> None: """ Class Level Error Handling """ - write_failure_message(message) + _write_failure_message(message) raise PluginManagementFatalException(message) diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins_test.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins_test.py deleted file mode 100644 index a7c748b..0000000 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins_test.py +++ /dev/null @@ -1,87 +0,0 @@ -# pylint: disable=all -import unittest -from .manage_plugins import ManagePlugins -from .test_stubs.stub_plugin_puller import StubPluginPuller -from .test_stubs.stub_config_parser import StubPluginConfigHandler -from .test_stubs.stub_plugin_puller import StubPluginPullerWithError - - -class ManagePluginsLogicTest(unittest.TestCase): - - def test_add_with_no_url_error(self): - with self.assertRaises(SystemExit) as raised_ex: - ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()).execute(['-a']) - self.assertEqual(raised_ex.exception.code, 1) - - def test_add_all_works(self): - try: - unit_under_test = ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()) - args = ['-a', '-r some_url/forge-someplugin'] - unit_under_test.execute(args) - except SystemExit as ex: - self.fail(ex) - - def test_add_fails_name_format(self): - with self.assertRaises(SystemExit) as raised_ex: - unit_under_test = ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()) - args = ['-a', '-r some_url/someplugin'] - unit_under_test.execute(args) - - self.assertEqual(raised_ex.exception.code, 1) - - def test_update_without_name(self): - try: - unit_under_test = ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()) - args = ['-u'] - unit_under_test.execute(args) - except SystemExit as ex: - self.fail(ex) - - def test_update_without_name_fails_due_to_command_error(self): - with self.assertRaises(SystemExit) as raised_ex: - unit_under_test = ManagePlugins(StubPluginPullerWithError(StubPluginConfigHandler()), StubPluginConfigHandler()) - args = ['-u'] - unit_under_test.execute(args) - - self.assertEqual(raised_ex.exception.code, 1) - - def test_update_with_name(self): - try: - unit_under_test = ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()) - args = ['-u', '-n some_name'] - unit_under_test.execute(args) - except SystemExit as ex: - self.fail(ex) - - def test_update_with_name_fails(self): - with self.assertRaises(SystemExit) as raised_ex: - unit_under_test = ManagePlugins(StubPluginPullerWithError(StubPluginConfigHandler()), StubPluginConfigHandler()) - args = ['-u', '-n some_bad_name'] - unit_under_test.execute(args) - - self.assertEqual(raised_ex.exception.code, 1) - - def test_init_arg_parser(self): - unit_under_test = ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()) - arg_parser = unit_under_test.init_arg_parser() - - self.assertIsNotNone(arg_parser) - - def test_arg_parser_parses_branch(self): - unit_under_test = ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()) - arg_parser = unit_under_test.init_arg_parser() - args = ['-bsome_name'] - parsed_args = arg_parser.parse_args(args) - self.assertIsNotNone(parsed_args) - self.assertEquals(parsed_args.branch_name, 'some_name') - - def test_arg_parser_parses_name(self): - unit_under_test = ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()) - arg_parser = unit_under_test.init_arg_parser() - args = ['-nsome_name'] - parsed_args = arg_parser.parse_args(args) - self.assertIsNotNone(parsed_args) - self.assertEquals(parsed_args.plugin_name, 'some_name') - -if __name__ == '__main__': - unittest.main() diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py index 2ec0a32..529f6fd 100644 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py +++ b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py @@ -19,16 +19,18 @@ def wrapper(*args, **kwargs): except GitCommandError as err: if 'already exists and is not an empty directory' in err.stderr: - raise PluginManagementWarnException('Plugin already installed to forge!') - raise PluginManagementFatalException(f'Failed to pull source code! {err.stderr}') + raise PluginManagementWarnException('Plugin already installed to forge!') from None + failed_pull_message = f'Failed to pull source code! {err.stderr}' + raise PluginManagementFatalException(failed_pull_message) from None except GitCommandNotFound as err: - raise PluginManagementFatalException(f'Failed to pull source code! {err.stderr}') + failed_pull_message = f'Failed to pull source code! {err.stderr}' + raise PluginManagementFatalException(failed_pull_message) from None if not isinstance(repo, Repo): - raise PluginManagementWarnException('Plugin already up to date!') - elif repo.bare: - raise PluginManagementFatalException('Given repository has no data!') + raise PluginManagementWarnException('Plugin already up to date!') from None + if repo.bare: + raise PluginManagementFatalException('Given repository has no data!') from None return repo return wrapper diff --git a/forge/config/__init__.py b/forge/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/forge/config/config_handler.py b/forge/config/config_handler.py index e12e8ed..b9a5e14 100644 --- a/forge/config/config_handler.py +++ b/forge/config/config_handler.py @@ -4,9 +4,6 @@ from pathlib import Path from typing import List, Tuple -# CONF_HOME = str(Path(str(Path.home()) + '/.forge')) -# CONFIG_FILE_PATH = str(Path(CONF_HOME + '/conf.ini')) - class ConfigHandler: """ Class that handles operations against Forge conf.ini """ @@ -16,9 +13,8 @@ def __init__(self, home_dir_path: str, file_path_dir: str): self.file_path_dir = file_path_dir def init_conf_dir(self): - """Initializes the conf directory for Forge""" - if not os.path.exists(self.home_dir_path): - os.makedirs(self.home_dir_path) + """ Initializes the conf directory for Forge """ + os.makedirs(self.home_dir_path, exist_ok=True) def init_conf_file(self) -> None: """ Initializes the conifiguration file used by Forge """ @@ -49,7 +45,7 @@ def get_plugins(self) -> List[str]: return plugins def get_plugin_entries(self) -> List[Tuple[str, str]]: - """ Parses all of the plugin entries currently installed.""" + """ Parses all of the plugin entries currently installed """ config = self._get_config_parser() config.sections() return config.items('plugin-definitions') diff --git a/forge/config/config_handler_test.py b/forge/config/config_handler_test.py deleted file mode 100644 index 2140b10..0000000 --- a/forge/config/config_handler_test.py +++ /dev/null @@ -1,58 +0,0 @@ -# pylint: disable=all -import unittest -import configparser -import os -from pathlib import Path -from .config_handler import ConfigHandler - -CONF_HOME = str(Path(str('tmp') + '/.forge')) -CONFIG_FILE_PATH = str(Path('tmp' + '/conf.ini')) - -class ConfigHandlerTest(unittest.TestCase): - - def test_write_plugin_to_conf(self): - name = 'somepluginname' - url = 'url.for.plugin.git' - unit_under_test = ConfigHandler(home_dir_path=CONF_HOME, file_path_dir=CONFIG_FILE_PATH) - unit_under_test.write_plugin_to_conf(name, url) - - parser = configparser.ConfigParser() - parser.sections() - parser.read(CONFIG_FILE_PATH) - plugin_entries = dict(parser.items('plugin-definitions')) - - try: - self.assertIsNotNone(plugin_entries[name]) - self.assertEqual(plugin_entries[name], url) - except KeyError: - self.fail() - - def test_read_plugin_entries(self): - unit_under_test = ConfigHandler(home_dir_path=CONF_HOME, file_path_dir=CONFIG_FILE_PATH) - unit_under_test.write_plugin_to_conf('some_name', 'someurl') - unit_under_test.write_plugin_to_conf('someothername', 'someotherurl') - - entries = unit_under_test.get_plugin_entries() - - self.assertIsNotNone(entries) - entries_as_dict = dict(entries) - - try: - self.assertEquals(entries_as_dict['some_name'], 'someurl') - self.assertEquals(entries_as_dict['someothername'], 'someotherurl') - except KeyError: - self.fail() - - def tearDown(self): - super().tearDown() - os.remove(CONFIG_FILE_PATH) - - def setUp(self): - super().setUp() - conf_handler = ConfigHandler( - home_dir_path=CONF_HOME, - file_path_dir=CONFIG_FILE_PATH - ) - conf_handler.init_conf_dir() - conf_handler.init_conf_file() - \ No newline at end of file diff --git a/setup.py b/setup.py index 7be80ce..1aacc9e 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,8 @@ 'pluginbase==1.0.0', 'requests==2.25.0', 'tabulate==0.8.7', - 'halo==0.0.31' + 'halo==0.0.31', + 'python-slugify==4.0.1' ], classifiers=[ 'Programming Language :: Python :: 3', diff --git a/tests/config_handler_test.py b/tests/config_handler_test.py new file mode 100644 index 0000000..ecfac45 --- /dev/null +++ b/tests/config_handler_test.py @@ -0,0 +1,73 @@ +import configparser +import os +import pytest + +from pathlib import Path +from shutil import rmtree + + +from forge.config.config_handler import ConfigHandler + +CONF_HOME = str(Path(str('tmp') + '/.forge')) +CONFIG_FILE_PATH = str(Path('tmp' + '/conf.ini')) + + +@pytest.fixture(autouse=True) +def setup_and_teardown(): + conf_handler = ConfigHandler( + home_dir_path=CONF_HOME, + file_path_dir=CONFIG_FILE_PATH + ) + conf_handler.init_conf_dir() + conf_handler.init_conf_file() + yield + os.remove(CONFIG_FILE_PATH) + rmtree('tmp', ignore_errors=True) + + +def test_write_plugin_to_conf(): + name = 'somepluginname' + url = 'url.for.plugin.git' + config_handler = ConfigHandler(home_dir_path=CONF_HOME, file_path_dir=CONFIG_FILE_PATH) + config_handler.write_plugin_to_conf(name, url) + + parser = configparser.ConfigParser() + parser.sections() + parser.read(CONFIG_FILE_PATH) + plugin_entries = dict(parser.items('plugin-definitions')) + + assert plugin_entries[name] is not None + assert plugin_entries[name] == url + + +def test_read_plugin_entries(): + config_handler = ConfigHandler(home_dir_path=CONF_HOME, file_path_dir=CONFIG_FILE_PATH) + config_handler.write_plugin_to_conf('some_name', 'someurl') + config_handler.write_plugin_to_conf('someothername', 'someotherurl') + + entries = config_handler.get_plugin_entries() + + assert entries is not None + entries_as_dict = dict(entries) + + assert entries_as_dict['some_name'] == 'someurl' + assert entries_as_dict['someothername'] == 'someotherurl' + + +def test_get_plugins(): + config_handler = ConfigHandler(home_dir_path=CONF_HOME, file_path_dir=CONFIG_FILE_PATH) + config_handler.write_plugin_to_conf('some_name', 'someurl') + config_handler.write_plugin_to_conf('someothername', 'someotherurl') + + plugins = config_handler.get_plugins() + + plugin_1 = os.path.join('tmp', '.forge', 'plugins', 'some_name') + plugin_2 = os.path.join('tmp', '.forge', 'plugins', 'someothername') + + assert plugins == [plugin_1, plugin_2] + + +def test_get_plugin_install_location(): + config_handler = ConfigHandler(home_dir_path=CONF_HOME, file_path_dir=CONFIG_FILE_PATH) + + assert config_handler.get_plugin_install_location() == os.path.join('tmp', '.forge', 'plugins') diff --git a/tests/main_init_test.py b/tests/main_init_test.py new file mode 100644 index 0000000..4068426 --- /dev/null +++ b/tests/main_init_test.py @@ -0,0 +1,116 @@ +import pytest +from mock import MagicMock, call, patch +from pprint import pprint + +import forge + + +@patch('forge.ConfigHandler') +@patch('forge.PLUGIN_BASE') +def test_application_init(mock_plugin_base, mock_config): + mock_plugin_base.make_plugin_source().list_plugins.return_value = ['a', 'a_logic', 'b', 'b_logic'] + + fake_plugin = MagicMock() + + mock_plugin_base.make_plugin_source().load_plugin.return_value = fake_plugin + + app = forge.Application(name='forge', config_handler=mock_config()) + + assert fake_plugin.mock_calls[0] == call.register(app) + assert fake_plugin.mock_calls[1] == call.register(app) + + +@patch('forge.ConfigHandler') +@patch('forge.PLUGIN_BASE') +def test_execute_help(mock_plugin_base, mock_config): + app = forge.Application(name='forge', config_handler=mock_config()) + + app.print_help = MagicMock() + + app.execute(command='help', args=None) + + app.print_help.assert_called_once() + + +@patch('forge.ConfigHandler') +@patch('forge.PLUGIN_BASE') +def test_execute_plugin_not_installed(mock_plugin_base, mock_config): + app = forge.Application(name='forge', config_handler=mock_config()) + + app.registry = { + 'plugin1': 'help1', + 'plugin2': 'help2', + } + + with pytest.raises(SystemExit) as raised_ex: + app.execute(command='plugin3', args=None) + + assert raised_ex.value.code == 1 + + +@patch('forge.ConfigHandler') +@patch('forge.PLUGIN_BASE') +def test_execute_plugin_no_args(mock_plugin_base, mock_config): + app = forge.Application(name='forge', config_handler=mock_config()) + + plugin1 = MagicMock() + app.registry = { + 'plugin1': (plugin1, 'help1') + } + + app.execute(command='plugin1', args=None) + + assert plugin1.mock_calls == [call(None)] + + +@patch('forge.ConfigHandler') +@patch('forge.PLUGIN_BASE') +def test_execute_plugin_with_args(mock_plugin_base, mock_config): + app = forge.Application(name='forge', config_handler=mock_config()) + + plugin1 = MagicMock() + app.registry = { + 'plugin1': (plugin1, 'help1') + } + + app.execute(command='plugin1', args=['-arg1', 'val1', '-arg2', 'val2']) + + assert plugin1.mock_calls == [call(['-arg1', 'val1', '-arg2', 'val2'])] + + +@patch('forge.Application') +@patch('forge.ConfigHandler') +def test_main_no_args(mock_config, mock_app): + command = '' + forge.main(args=command.split()) + + assert mock_config.mock_calls == [ + call( + home_dir_path=forge.CONF_HOME, + file_path_dir=forge.CONFIG_FILE_PATH + ) + ] + + assert mock_app.mock_calls == [ + call(name='forge', config_handler=mock_config()), + call().execute('help', None) + ] + + +@patch('forge.Application') +@patch('forge.ConfigHandler') +def test_main_with_args(mock_config, mock_app): + command = 'plugin1 -arg1 val1' + forge.main(args=command.split()) + + assert mock_config.mock_calls == [ + call( + home_dir_path=forge.CONF_HOME, + file_path_dir=forge.CONFIG_FILE_PATH + ) + ] + + assert mock_app.mock_calls == [ + call(name='forge', config_handler=mock_config()), + call().execute('plugin1', ['-arg1', 'val1']) + ] diff --git a/tests/manage_plugins_registration_test.py b/tests/manage_plugins_registration_test.py new file mode 100644 index 0000000..fc919f7 --- /dev/null +++ b/tests/manage_plugins_registration_test.py @@ -0,0 +1,40 @@ +from mock import MagicMock, call, patch + +from forge._internal_plugins.manage_plugins import manage_plugins + +MODULE_PATH = 'forge._internal_plugins.manage_plugins' + + +def test_help_text(): + assert manage_plugins.helptext() == "For managing plugins for use by forge." + + +def test_register(): + fake_app = MagicMock() + manage_plugins.register(fake_app) + + print(dir(manage_plugins)) + + assert fake_app.mock_calls == [ + call.register_plugin( + name='manage-plugins', + plugin=manage_plugins.execute, + helptext='For managing plugins for use by forge.' + ) + ] + + +@patch(f'{MODULE_PATH}.manage_plugins.ConfigHandler') +@patch(f'{MODULE_PATH}.manage_plugins.ManagePlugins') +@patch(f'{MODULE_PATH}.manage_plugins.PluginPuller') +def test_execute(mock_plugin_puller, mock_manage_plugins, mock_config): + manage_plugins.execute(['']) + + assert mock_config.mock_calls == [ + call(home_dir_path=manage_plugins.CONF_HOME, + file_path_dir=manage_plugins.CONFIG_FILE_PATH) + ] + assert mock_manage_plugins.mock_calls == [ + call(plugin_puller=mock_plugin_puller(), + config_handler=mock_config()), call().execute(args=['']) + ] diff --git a/tests/manage_plugins_test.py b/tests/manage_plugins_test.py new file mode 100644 index 0000000..5bc2bd4 --- /dev/null +++ b/tests/manage_plugins_test.py @@ -0,0 +1,203 @@ +import os + +import pytest +from mock import patch, MagicMock, call + + +from forge._internal_plugins.manage_plugins.manage_plugins_logic.manage_plugins import ManagePlugins +from forge._internal_plugins.manage_plugins.manage_plugins_logic.manage_plugin_exceptions import PluginManagementFatalException, PluginManagementWarnException +from test_stubs.stub_plugin_puller import StubPluginPuller +from test_stubs.stub_config_parser import StubPluginConfigHandler +from test_stubs.stub_plugin_puller import StubPluginPullerWithError + +MODULE_PATH = 'forge._internal_plugins.manage_plugins.manage_plugins_logic.manage_plugins' + + +def test_add_with_no_url_error(): + command = '-a' + with pytest.raises(PluginManagementFatalException) as raised_ex: + ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()).execute(command.split()) + assert str(raised_ex.value) == 'Can\'t add plugin without providing url!' + + +def test_add_with_no_action_error(): + command = '' + with pytest.raises(PluginManagementFatalException) as raised_ex: + ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()).execute(command.split()) + assert str(raised_ex.value) == 'Please provide an action with -a, -u or -i' + + +@patch(f'{MODULE_PATH}._write_failure_message') +def test_add_with_bad_repo_name_error(mocked_write_failure_message): + command = '-a -r git@this_name-doesnt-start-with-forgedash.git' + with pytest.raises(SystemExit) as raised_ex: + ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()).execute(command.split()) + mocked_write_failure_message.assert_called_with('Repository name should be in the form of forge-[alphanumeric name]') + assert raised_ex.value.code == 1 + + +@pytest.mark.parametrize("command,expected_branch", [ + ('-a -r git@bitbucket.org:tele/forge-some-plugin', None), + ('-a -r git@bitbucket.org:tele/forge-some-plugin -b TheBranch', 'TheBranch') +]) +@patch('os.path.exists') +@patch('subprocess.check_call') +@patch('test_stubs.stub_config_parser.StubPluginConfigHandler.write_plugin_to_conf') +@patch('test_stubs.stub_plugin_puller.StubPluginPuller.clone_plugin') +def test_do_add(mock_clone, mock_configure, mock_subprocess, mock_exists, command, expected_branch): + manager = ManagePlugins( + StubPluginPuller(StubPluginConfigHandler()), + StubPluginConfigHandler() + ) + + manager.execute(command.split()) + + mock_clone.assert_called_once_with( + repo_url='git@bitbucket.org:tele/forge-some-plugin', + plugin_name='forge-some-plugin', + branch_name=expected_branch + ) + mock_configure.assert_called_once_with(name='forge-some-plugin', url='git@bitbucket.org:tele/forge-some-plugin') + mock_subprocess.assert_called_once() + + _, *args = mock_subprocess.mock_calls[0].args[0] + + assert ' '.join(args) == fr'-m pip install -r forge-some-plugin{os.path.sep}requirements.txt' + + +@patch('os.path.exists') +@patch('subprocess.check_call') +@patch('test_stubs.stub_plugin_puller.StubPluginPuller.clone_plugin') +def test_do_init(mock_clone, mock_subprocess, mock_exists): + command = '-i' + manager = ManagePlugins( + StubPluginPuller(StubPluginConfigHandler()), + StubPluginConfigHandler() + ) + + manager.execute(command.split()) + + mock_clone.assert_called_once_with( + repo_url='git@bitbucket.org:tele/forge-some-plugin', + plugin_name='forge-some-plugin', + branch_name=None + ) + mock_subprocess.assert_called_once() + + _, *args = mock_subprocess.mock_calls[0].args[0] + + assert ' '.join(args) == fr'-m pip install -r forge-some-plugin{os.path.sep}requirements.txt' + + +@patch('os.path.exists') +@patch('subprocess.check_call') +@patch('test_stubs.stub_plugin_puller.StubPluginPuller.clone_plugin', side_effect=PluginManagementFatalException('Reason')) +@patch(f'{MODULE_PATH}.Halo') +def test_do_init_some_clone_fatal_error(mock_spinner, mock_clone, mock_subprocess, mock_exists): + command = '-i' + manager = ManagePlugins( + StubPluginPuller(StubPluginConfigHandler()), + StubPluginConfigHandler() + ) + + mock_spinner.return_value.__enter__.return_value = MagicMock() + + with pytest.raises(SystemExit) as raised_ex: + manager.execute(command.split()) + + assert raised_ex.value.code == 1 + assert mock_spinner.mock_calls[0] == call(text='Cloning plugin: [forge-some-plugin]...', spinner='dots', color='blue') + assert mock_spinner.mock_calls[2] == call().__enter__().fail('Could not clone plugin: [forge-some-plugin]! Reason') + + mock_clone.assert_called_once_with( + repo_url='git@bitbucket.org:tele/forge-some-plugin', + plugin_name='forge-some-plugin', + branch_name=None + ) + + +@patch('os.path.exists') +@patch('subprocess.check_call') +@patch('test_stubs.stub_plugin_puller.StubPluginPuller.clone_plugin', side_effect=PluginManagementWarnException('Reason')) +@patch(f'{MODULE_PATH}.Halo') +def test_do_init_some_clone_warning_error(mock_spinner, mock_clone, mock_subprocess, mock_exists): + command = '-i' + manager = ManagePlugins( + StubPluginPuller(StubPluginConfigHandler()), + StubPluginConfigHandler() + ) + + mock_spinner.return_value.__enter__.return_value = MagicMock() + + with pytest.raises(SystemExit) as raised_ex: + manager.execute(command.split()) + + assert raised_ex.value.code == 0 + assert mock_spinner.mock_calls[0] == call(text='Cloning plugin: [forge-some-plugin]...', spinner='dots', color='blue') + assert mock_spinner.mock_calls[2] == call().__enter__().warn('Reason') + + mock_clone.assert_called_once_with( + repo_url='git@bitbucket.org:tele/forge-some-plugin', + plugin_name='forge-some-plugin', + branch_name=None + ) + + +@pytest.mark.parametrize("command,expected_name", [ + ('-u', 'forge-some-plugin'), + ('-u -n forge-named-plugin', 'forge-named-plugin') +]) +@patch('os.path.exists') +@patch('subprocess.check_call') +@patch('test_stubs.stub_plugin_puller.StubPluginPuller.pull_plugin') +def test_do_update(mock_pull, mock_subprocess, mock_exists, command, expected_name): + manager = ManagePlugins( + StubPluginPuller(StubPluginConfigHandler()), + StubPluginConfigHandler() + ) + + manager.execute(command.split()) + + mock_pull.assert_called_once_with( + plugin_name=expected_name, branch_name=None + ) + + +@patch('os.path.exists') +@patch('subprocess.check_call') +@patch('test_stubs.stub_plugin_puller.StubPluginPuller.pull_plugin', side_effect=PluginManagementFatalException('Reason')) +@patch(f'{MODULE_PATH}.Halo') +def test_do_update_some_pull_error(mock_spinner, mock_pull, mock_subprocess, mock_exists): + command = '-u' + manager = ManagePlugins( + StubPluginPuller(StubPluginConfigHandler()), + StubPluginConfigHandler() + ) + + mock_spinner.return_value.__enter__.return_value = MagicMock() + + with pytest.raises(SystemExit) as raised_ex: + manager.execute(command.split()) + + assert raised_ex.value.code == 1 + assert mock_spinner.mock_calls[0] == call(text='Updating plugin: [forge-some-plugin]...', spinner='dots', color='blue') + assert mock_spinner.mock_calls[2] == call().__enter__().fail('Could not update plugin: [forge-some-plugin]! Reason') + + +@patch('os.path.exists') +@patch('subprocess.check_call') +@patch('test_stubs.stub_plugin_puller.StubPluginPuller.pull_plugin', side_effect=PluginManagementWarnException()) +@patch(f'{MODULE_PATH}.Halo') +def test_do_update_already_up_to_date(mock_spinner, mock_pull, mock_subprocess, mock_exists): + command = '-u' + manager = ManagePlugins( + StubPluginPuller(StubPluginConfigHandler()), + StubPluginConfigHandler() + ) + + mock_spinner.return_value.__enter__.return_value = MagicMock() + + manager.execute(command.split()) + + assert mock_spinner.mock_calls[0] == call(text='Updating plugin: [forge-some-plugin]...', spinner='dots', color='blue') + assert mock_spinner.mock_calls[2] == call().__enter__().succeed('Plugin: [forge-some-plugin] already up to date!') diff --git a/tests/plugin_puller_test.py b/tests/plugin_puller_test.py new file mode 100644 index 0000000..79ffaef --- /dev/null +++ b/tests/plugin_puller_test.py @@ -0,0 +1,90 @@ +import os + +import pytest +from mock import patch, MagicMock, call + +from git import Repo, GitCommandError, GitCommandNotFound + + +from forge._internal_plugins.manage_plugins.manage_plugins_logic.plugin_puller import PluginPuller +from forge._internal_plugins.manage_plugins.manage_plugins_logic.manage_plugin_exceptions import PluginManagementFatalException, PluginManagementWarnException +from test_stubs.stub_config_parser import StubPluginConfigHandler + +MODULE_PATH = 'forge._internal_plugins.manage_plugins.manage_plugins_logic.plugin_puller' + + +@patch(f'{MODULE_PATH}.Repo.clone_from', return_value=MagicMock(spec=Repo, bare=False)) +def test_clone_plugin(mock_clone): + + puller = PluginPuller(StubPluginConfigHandler()) + + puller.clone_plugin('long_url', 'forge-some-plugin') + + print(mock_clone.mock_calls) + + assert mock_clone.mock_calls[0] == call('long_url', f'path.to.install{os.path.sep}forge-some-plugin', branch='dev') + + +@patch(f'{MODULE_PATH}.Git') +def test_pull_plugin(mock_git): + + puller = PluginPuller(StubPluginConfigHandler()) + + mock_git.return_value.pull.return_value = MagicMock(spec=Repo, bare=False) + + puller.pull_plugin('forge-some-plugin') + + assert mock_git.mock_calls[0] == call(f'path.to.install{os.path.sep}forge-some-plugin') + assert mock_git.mock_calls[1] == call().pull('origin', 'dev') + + +@patch(f'{MODULE_PATH}.Git') +def test_pull_plugin_bare_repo(mock_git): + + puller = PluginPuller(StubPluginConfigHandler()) + + mock_git.return_value.pull.return_value = MagicMock(spec=Repo) + + with pytest.raises(PluginManagementFatalException) as raised_ex: + puller.pull_plugin('forge-some-plugin') + + assert str(raised_ex.value) == 'Given repository has no data!' + + +@patch(f'{MODULE_PATH}.Git') +def test_pull_plugin_up_to_date(mock_git): + + puller = PluginPuller(StubPluginConfigHandler()) + + mock_git.return_value.pull.return_value = MagicMock() + + with pytest.raises(PluginManagementWarnException) as raised_ex: + puller.pull_plugin('forge-some-plugin') + + assert str(raised_ex.value) == 'Plugin already up to date!' + + +@patch(f'{MODULE_PATH}.Git') +def test_pull_plugin_command_not_found_error(mock_git): + + puller = PluginPuller(StubPluginConfigHandler()) + + mock_git.return_value.pull.side_effect = GitCommandNotFound('command', 'cause') + + with pytest.raises(PluginManagementFatalException) as raised_ex: + puller.pull_plugin('forge-some-plugin') + + assert str(raised_ex.value) == 'Failed to pull source code! ' + + +@patch(f'{MODULE_PATH}.Git') +def test_pull_plugin_command_error(mock_git): + + puller = PluginPuller(StubPluginConfigHandler()) + + mock_git.return_value.pull.side_effect = GitCommandError('command', 'cause') + + with pytest.raises(PluginManagementFatalException) as raised_ex: + puller.pull_plugin('forge-some-plugin') + + assert str(raised_ex.value) == 'Failed to pull source code! ' diff --git a/tests/test_stubs/__init__.py b/tests/test_stubs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/stub_config_parser.py b/tests/test_stubs/stub_config_parser.py similarity index 56% rename from forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/stub_config_parser.py rename to tests/test_stubs/stub_config_parser.py index 7f61968..00b81c4 100644 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/stub_config_parser.py +++ b/tests/test_stubs/stub_config_parser.py @@ -1,13 +1,23 @@ # pylint: disable=all from forge.config.config_handler import ConfigHandler + + class StubPluginConfigHandler(ConfigHandler): def __init__(self): super().__init__('test', 'test_file') - + @staticmethod def write_plugin_to_conf(name, url): print('stub writing to file') @staticmethod def get_plugin_entries(): - return [('some_name', 'some_url')] + return [('forge-some-plugin', 'git@bitbucket.org:tele/forge-some-plugin')] + + @staticmethod + def get_plugins(): + return ['forge-some-plugin'] + + @staticmethod + def get_plugin_install_location(): + return 'path.to.install' diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/stub_plugin_puller.py b/tests/test_stubs/stub_plugin_puller.py similarity index 88% rename from forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/stub_plugin_puller.py rename to tests/test_stubs/stub_plugin_puller.py index 454175b..884167f 100644 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/stub_plugin_puller.py +++ b/tests/test_stubs/stub_plugin_puller.py @@ -1,10 +1,12 @@ # pylint: disable=all from git import GitCommandError -from ..plugin_puller import PluginPuller +from forge._internal_plugins.manage_plugins.manage_plugins_logic.plugin_puller import PluginPuller + + class StubPluginPuller(PluginPuller): def __init__(self, config_handler): super().__init__(config_handler) - + @staticmethod def pull_plugin(plugin_name, branch_name='dev'): """ stub for pull_plugin of PluginPuller.""" @@ -23,6 +25,7 @@ class StubRepo: class StubPluginPullerWithError(PluginPuller): def __init__(self, config_handler): super().__init__(config_handler) + @staticmethod def pull_plugin(plugin_name, branch_name='dev'): """stub method to raise error on pull.""" From 253dca696f463e618cbd7483035e13cba820be4f Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Sun, 13 Dec 2020 16:22:28 -0500 Subject: [PATCH 04/17] make python runnable in make version agnostic --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9744390..8afd6aa 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ IMAGE_NAME='Forge' VERSION=1.0.0 -PYTHON=python3.7 +PYTHON=python .DEFAULT: help From 41de1fd2bfd1f8ff94e5a52e614c0407f6069912 Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Sun, 13 Dec 2020 17:10:09 -0500 Subject: [PATCH 05/17] windows from source install script added --- README.md | 41 +++++++++++++++++++++++++++++++++++++---- install.ps1 | 14 ++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 install.ps1 diff --git a/README.md b/README.md index 30a7120..f6fcb65 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ def register(app): ``` ## Pre-Requisites and Virtual Environments + Forge is a python package that utilizes many Python dependencies. As such, it is highly recommended that you install and use forge within a Python virtual environment to avoid any potential issues with version requirements with your existing Python packages. @@ -39,6 +40,7 @@ If you haven't used Python before, you'll need to [install Python 3](https://doc In order to activate and use the included virtual environment, proceed with the following steps: ## Unix + ``` $ make init $ . .venv/bin/activate @@ -48,14 +50,17 @@ You should see a visual representation on your command prompt that will indicate Anything installed while this virtual environment is active will only be available while you are within. Read more about Python virtual environments [here](https://realpython.com/python-virtual-environments-a-primer/). You can leave the virtual environment at any time with the following command: + ``` $ deactivate ``` ## Installation + Once within your virtual environment, there are a number of ways that you can install the forge package. -### From Source +### From Source - Unix + If you are already within the repository and using the included virtual environment, you can easily run another single make command in order to install forge from source: ``` @@ -63,35 +68,63 @@ $ make install ``` To verify your installation was successful: + ``` $ which forge ``` + should return the installed location of forge and: + ``` $ forge ``` + should return the simple help interface. -### Via PyPI +### From Source - Windows + +If you are already within the repository and using the included virtual environment, you can easily run another single make command in order to install forge from source: ``` -$ pip3 install tele-forge +$ powershell .\install.ps1 ``` To verify your installation was successful: + ``` -$ which forge +$ where.exe forge ``` + should return the installed location of forge and: + ``` $ forge ``` + should return the simple help interface. +### Via PyPI + +``` +$ pip3 install tele-forge +``` + +To verify your installation was successful: + +``` +$ which forge +``` +should return the installed location of forge and: +``` +$ forge +``` + +should return the simple help interface. ## Usage + ``` forge [plugin-arguments] ``` diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..bc86cb9 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,14 @@ +if (test-path venv) { + Remove-Item venv -Recurse -Force +} +python -m pip install virtualenv +virtualenv --always-copy venv +. venv/Scripts/activate +pip3 install -r requirements.txt + +$version = (Get-Content setup.py | Select-String "version='(\d+\.\d+\.\d+)'" | Select-Object Matches -First 1).Matches.Groups[1].Value + +python -m pip install --upgrade setuptools wheel +python setup.py sdist bdist_wheel +python -m pip install dist/tele_forge-$version-py3-none-any.whl +forge \ No newline at end of file From bdff31c2732bf07d75d80083b234f29292881e72 Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Sun, 13 Dec 2020 17:12:39 -0500 Subject: [PATCH 06/17] added 3.9 to python testing versions --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index c892ec1..e14084f 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8] + python-version: [3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 From c644694ae59d819862c2b3943a1b08cdc3b02c95 Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Sun, 13 Dec 2020 19:26:10 -0500 Subject: [PATCH 07/17] adding macos and window executors to actions --- .github/workflows/pythonpackage.yml | 24 ++++++++++-------------- test.ps1 | 10 ++++++++++ 2 files changed, 20 insertions(+), 14 deletions(-) create mode 100644 test.ps1 diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index e14084f..ad5b823 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -11,10 +11,18 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: + os: [ubuntu-latest, macos-latest, windows-latest] python-version: [3.7, 3.8, 3.9] + include: + - os: ubuntu-latest + test_script: make test + - os: macos-latest + test_script: make test + - os: windows-latest + test_script: .\test.ps1 steps: - uses: actions/checkout@v2 @@ -22,17 +30,5 @@ jobs: uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Lint with flake8 - run: | - pip install flake8 - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Lint with pylint and test with pytest - run: | - make test \ No newline at end of file + run: ${{ matrix.test_script }} diff --git a/test.ps1 b/test.ps1 new file mode 100644 index 0000000..175d664 --- /dev/null +++ b/test.ps1 @@ -0,0 +1,10 @@ +python -m pip install virtualenv +virtualenv.exe --always-copy venv +. venv/Scripts/activate +pip3 install -r requirements.txt +pip3 install -r dev-requirements.txt + +python -m pylint -j 4 -r y forge +if($LastExitCode) { exit $LastExitCode} +python -m pytest -rf -vvv -x --count 5 --cov=forge --cov-fail-under=80 --cov-report term +if($LastExitCode) { exit $LastExitCode} \ No newline at end of file From 08c699095e8a60be5027b208ae82ff2cf14eb8e5 Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Sun, 13 Dec 2020 19:35:46 -0500 Subject: [PATCH 08/17] adding pip caching to actions --- .github/workflows/pythonpackage.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index ad5b823..ecb11e9 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -19,10 +19,13 @@ jobs: include: - os: ubuntu-latest test_script: make test + cache_path: ~/.cache/pip - os: macos-latest test_script: make test + cache_path: ~/Library/Caches/pip - os: windows-latest test_script: .\test.ps1 + cache_path: ~\AppData\Local\pip\Cache steps: - uses: actions/checkout@v2 @@ -30,5 +33,11 @@ jobs: uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} + - uses: actions/cache@v2 + with: + path: ${{ matrix.cache_path }} + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- - name: Lint with pylint and test with pytest run: ${{ matrix.test_script }} From 2f1454b8ccee947929500d61aeb3325007f8c76d Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Sun, 13 Dec 2020 19:39:12 -0500 Subject: [PATCH 09/17] Verify pip is being cached from prev commit --- install.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/install.ps1 b/install.ps1 index bc86cb9..68ff2e6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,4 +11,5 @@ $version = (Get-Content setup.py | Select-String "version='(\d+\.\d+\.\d+)'" | S python -m pip install --upgrade setuptools wheel python setup.py sdist bdist_wheel python -m pip install dist/tele_forge-$version-py3-none-any.whl + forge \ No newline at end of file From ca177109c6c8ca26eb61984b27e41db75a30c3a3 Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Sun, 13 Dec 2020 22:37:21 -0500 Subject: [PATCH 10/17] again --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d77f4fc..4f832b7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,6 @@ __pycache__ # OS generated files # .DS_Store +coverage.xml .coverage cov_html \ No newline at end of file From 24e45b3e2badb90d09e38be7a691209986448d37 Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Wed, 30 Dec 2020 23:11:22 -0500 Subject: [PATCH 11/17] Forge 2.0 Pipx variant initial work --- Makefile | 9 +- README.md | 131 +++++------ bin/forge | 9 - bin/forge.bat | 2 - forge/__init__.py | 101 ++------- forge/_internal_plugins/__init__.py | 0 .../manage_plugins/__init__.py | 0 .../manage_plugins/manage_plugins.py | 33 --- .../manage_plugins_logic/README.md | 5 - .../manage_plugins_logic/__init__.py | 0 .../command_line_parser.py | 51 ----- .../manage_plugins_logic/manage_plugins.py | 192 ----------------- .../manage_plugins_logic/plugin_puller.py | 62 ------ forge/cli.py | 80 +++++++ forge/config/__init__.py | 0 forge/config/config_handler.py | 60 ------ ...age_plugin_exceptions.py => exceptions.py} | 0 forge/forge.py | 73 +++++++ forge/pipx_wrapper.py | 127 +++++++++++ install.ps1 | 15 -- requirements.txt | 6 +- setup.py | 69 +++--- test.ps1 | 5 +- {bin => tests}/__init__.py | 0 tests/cli_test.py | 140 ++++++++++++ tests/config_handler_test.py | 73 ------- tests/conftest.py | 19 ++ tests/forge_test.py | 143 ++++++++++++ tests/main_init_test.py | 116 ---------- tests/main_test.py | 29 +++ tests/manage_plugins_registration_test.py | 40 ---- tests/manage_plugins_test.py | 203 ------------------ tests/pipx_wrapper_test.py | 140 ++++++++++++ tests/plugin_puller_test.py | 90 -------- tests/test_stubs/__init__.py | 0 tests/test_stubs/stub_config_parser.py | 23 -- tests/test_stubs/stub_plugin_puller.py | 32 --- 37 files changed, 863 insertions(+), 1215 deletions(-) delete mode 100644 bin/forge delete mode 100644 bin/forge.bat delete mode 100644 forge/_internal_plugins/__init__.py delete mode 100644 forge/_internal_plugins/manage_plugins/__init__.py delete mode 100644 forge/_internal_plugins/manage_plugins/manage_plugins.py delete mode 100644 forge/_internal_plugins/manage_plugins/manage_plugins_logic/README.md delete mode 100644 forge/_internal_plugins/manage_plugins/manage_plugins_logic/__init__.py delete mode 100644 forge/_internal_plugins/manage_plugins/manage_plugins_logic/command_line_parser.py delete mode 100644 forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py delete mode 100644 forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py create mode 100644 forge/cli.py delete mode 100644 forge/config/__init__.py delete mode 100644 forge/config/config_handler.py rename forge/{_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugin_exceptions.py => exceptions.py} (100%) create mode 100644 forge/forge.py create mode 100644 forge/pipx_wrapper.py delete mode 100644 install.ps1 rename {bin => tests}/__init__.py (100%) create mode 100644 tests/cli_test.py delete mode 100644 tests/config_handler_test.py create mode 100644 tests/conftest.py create mode 100644 tests/forge_test.py delete mode 100644 tests/main_init_test.py create mode 100644 tests/main_test.py delete mode 100644 tests/manage_plugins_registration_test.py delete mode 100644 tests/manage_plugins_test.py create mode 100644 tests/pipx_wrapper_test.py delete mode 100644 tests/plugin_puller_test.py delete mode 100644 tests/test_stubs/__init__.py delete mode 100644 tests/test_stubs/stub_config_parser.py delete mode 100644 tests/test_stubs/stub_plugin_puller.py diff --git a/Makefile b/Makefile index 8afd6aa..bb0c1fc 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,4 @@ IMAGE_NAME='Forge' -VERSION=1.0.0 PYTHON=python @@ -22,7 +21,7 @@ help: init: rm -rf .venv - $(PYTHON) -m pip install virtualenv + pip3 install virtualenv virtualenv --python=$(PYTHON) --always-copy .venv ( \ . .venv/bin/activate; \ @@ -45,14 +44,14 @@ lint: dev build: ( \ . .venv/bin/activate; \ - $(PYTHON) -m pip install --upgrade setuptools wheel; \ + pip3 install --upgrade setuptools wheel; \ $(PYTHON) setup.py sdist bdist_wheel; \ ) install: build ( \ . .venv/bin/activate; \ - $(PYTHON) -m pip install dist/tele_forge-${VERSION}-py3-none-any.whl; \ + pip3 install dist/tele_forge-${VERSION}-py3-none-any.whl; \ ) test: lint @@ -63,7 +62,7 @@ test: lint clean: - rm -rf forge.egg-info/ build/ dist/ .venv/ venv/ + rm -rf forge.egg-info/ build/ dist/ .venv/ venv/ **/__pycache__/ .pytest_cache/ .coverage type-check: pytype *.py forge diff --git a/README.md b/README.md index f6fcb65..57a89e7 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,99 @@ -# forge +# **forge** There are many adjunct command line utilities that have been created by many [TeleTracking](https://www.teletracking.com) employees to assist with their everyday activities. The goal of this tool is to provide an extensible command line utility that will allow -for anyone to create new plugins for it that will be automatically pulled in by the -base tool when placed in the proper directory. +for anyone to create new plugins accessible -In order for a plugin to be used by the utility, a simple contract must be met and a requirements.txt containing all of the packages required by the plugin must be provided at the root directory of the plugin. -Each primary plugin file must have an execute method that accepts an array of args, -a helptext method that returns a very basic explanation of what the module does, -and a register method that accepts an instance of the app and passes the desired name of -the plugin, the execute method, and the value returned from the helptext method. +--- -Example as follows shows what would represent the interface to a simple echo example. +## Pre-Requisites -``` -def execute(args): - print(args[0]) - - -def helptext(): - return 'echoes the provided string' - - -def register(app): - app.register_plugin('echo', execute, helptext()) -``` - -## Pre-Requisites and Virtual Environments - -Forge is a python package that utilizes many Python dependencies. - -As such, it is highly recommended that you install and use forge within a Python virtual environment to avoid any potential issues with version requirements with your existing Python packages. - -For those not familiar with using a virtual environment, the ability to initialize a simple one is provided within the Makefile of this repository. +- Forge is a python package that utilizes many Python dependencies. -If you haven't used Python before, you'll need to [install Python 3](https://docs.python-guide.org/starting/installation/) first. (If you're on a Mac, avoid using the default Python installation.) +- Forge and its plugins uses pipx to isolate dependencies and make self contained globally available executables. -In order to activate and use the included virtual environment, proceed with the following steps: +- If you haven't used Python before, you'll need to [install Python 3](https://docs.python-guide.org/starting/installation/) first. (If you're on a Mac, avoid using the default Python installation.) -## Unix +- You will also need access to `git`. -``` -$ make init -$ . .venv/bin/activate -``` - -You should see a visual representation on your command prompt that will indicate that you are within the virtual environment. -Anything installed while this virtual environment is active will only be available while you are within. Read more about Python virtual environments [here](https://realpython.com/python-virtual-environments-a-primer/). +### **Unix** -You can leave the virtual environment at any time with the following command: - -``` -$ deactivate +```shell +$ apt-get -y install python3-pip +$ apt-get -y install python3-venv +$ apt-get -y install git +$ python3 -m pip install --user pipx +$ echo 'export PIPX_HOME="$HOME/.forge"' >> ~/.profile +$ echo 'export PIPX_BIN_DIR="$HOME/.forge/bin"' >> ~/.profile +$ python3 -m pipx ensurepath ``` -## Installation +> **IMPORTANT:** Now reboot/logout to gain access to `pipx` -Once within your virtual environment, there are a number of ways that you can install the forge package. +### **Windows** -### From Source - Unix +Go to: https://gitforwindows.org/, then download and install latest git build -If you are already within the repository and using the included virtual environment, you can easily run another single make command in order to install forge from source: +In PowerShell, running as administartor, run the following: -``` -$ make install +```shell +python -m pip install --user pipx +$env:PIPX_HOME="~/.forge" +$env:PIPX_BIN_DIR="~/.forge/bin" +python -m pipx ensurepath --force ``` -To verify your installation was successful: +> **IMPORTANT:** Now reboot/logout to gain access to `pipx` -``` -$ which forge -``` +--- -should return the installed location of forge and: - -``` -$ forge -``` - -should return the simple help interface. - -### From Source - Windows +## Installation -If you are already within the repository and using the included virtual environment, you can easily run another single make command in order to install forge from source: +### **Via PyPI** -``` -$ powershell .\install.ps1 +```shell +$ pip3 install tele-forge ``` -To verify your installation was successful: +### **From VCS** -``` -$ where.exe forge +```shell +$ pipx install git+ssh://git@github.com:TeleTrackingTechnologies/forge.git ``` -should return the installed location of forge and: +### **From Source (after cloning)** -``` -$ forge +```shell +$ pipx install . ``` -should return the simple help interface. +--- -### Via PyPI +## Post Install -``` -$ pip3 install tele-forge -``` +To verify your installation was successful -To verify your installation was successful: - -``` +```shell $ which forge +# OR +where.exe forge ``` -should return the installed location of forge and: +should return the installed location of forge, then: ``` -$ forge +$ forge -h ``` should return the simple help interface. +--- + ## Usage ``` forge [plugin-arguments] ``` + +TODO: add other forge commands here - like update/remove etc - explain that diff --git a/bin/forge b/bin/forge deleted file mode 100644 index e20b7e5..0000000 --- a/bin/forge +++ /dev/null @@ -1,9 +0,0 @@ -#! /usr/bin/env python3 - -import sys - -import forge - - -if __name__ == '__main__': - forge.main(sys.argv[1:]) diff --git a/bin/forge.bat b/bin/forge.bat deleted file mode 100644 index 8b0b1f0..0000000 --- a/bin/forge.bat +++ /dev/null @@ -1,2 +0,0 @@ -@Echo off -python %~dp0forge %* diff --git a/forge/__init__.py b/forge/__init__.py index ec01dd4..a05738e 100755 --- a/forge/__init__.py +++ b/forge/__init__.py @@ -1,92 +1,19 @@ -""" Forge """ -#! /usr/bin/env python3 -import os -import sys -from inspect import getmembers, isfunction -from pathlib import Path -from pprint import pprint -from typing import Any, List - -import pluginbase -from tabulate import tabulate - -from .config.config_handler import ConfigHandler - -CONF_HOME = str(Path(str(Path.home()) + '/.forge')) -CONFIG_FILE_PATH = str(Path(CONF_HOME + '/conf.ini')) -PLUGIN_BASE = pluginbase.PluginBase(package='forge.plugins') -WORKING_DIR = os.path.dirname(os.path.abspath(__file__)) -INTERNAL_PLUGIN_PATH = str(Path(f'{WORKING_DIR}/_internal_plugins/manage_plugins')) - - -class Application: - """ Application Class """ - - def __init__(self, name: str, config_handler: ConfigHandler) -> None: - config_handler.init_conf_dir() - config_handler.init_conf_file() - self.name = name - self.registry = {} - - self.plugins = [ - INTERNAL_PLUGIN_PATH - ] + config_handler.get_plugins() - - self.plugin_source = PLUGIN_BASE.make_plugin_source( - searchpath=self.plugins, - identifier=self.name) +""" Entrypoint for Forge """ - for plugin_name in self.plugin_source.list_plugins(): - if plugin_name.endswith('_logic'): - continue - plugin = self.plugin_source.load_plugin(plugin_name) - - plugin.register(self) - - def register_plugin(self, name: str, plugin, helptext: str) -> None: - """A function a plugin can use to register itself.""" - self.registry[name] = (plugin, helptext) - - def print_help(self) -> None: - """ Print Help For All Registered Plugins """ - print('\nTo use forge plugins try running a plugin like this:', end='\n\n') - print('\tforge manage-plugins -h', end='\n\n') - - help_entries = [] - for name in self.registry: - help_entries.append([name, self.registry[name][1]]) - print(tabulate(help_entries, ['plugin', 'description']), end='\n\n') - - def execute(self, command: str, args: Any) -> None: - """ Execute Plugin """ - if command == 'help': - self.print_help() - else: - if command not in self.registry: - print(f'Unknown command: {command}') - sys.exit(1) - else: - self.registry[command][0](args) - - -def main(args: list) -> None: - """ Main Function Definition """ - name = 'forge' - config_handler = ConfigHandler( - home_dir_path=CONF_HOME, - file_path_dir=CONFIG_FILE_PATH - ) - app = Application( - name=name, - config_handler=config_handler - ) +import sys - if len(args) > 0: - app.execute(args[0], args[1:]) - else: - app.execute('help', None) +from forge.cli import forge_cli +from forge.exceptions import (PluginManagementFatalException, + PluginManagementWarnException) -if __name__ == '__main__': - main(args=sys.argv[1:]) +def main(): + """ Error-handled entry point for cli entry point """ + try: + # Disable required here as context agrument is injected via a Click decorator + forge_cli() # pylint: disable=no-value-for-parameter + except PluginManagementFatalException: + sys.exit(1) + except PluginManagementWarnException: + sys.exit(0) diff --git a/forge/_internal_plugins/__init__.py b/forge/_internal_plugins/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/forge/_internal_plugins/manage_plugins/__init__.py b/forge/_internal_plugins/manage_plugins/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins.py b/forge/_internal_plugins/manage_plugins/manage_plugins.py deleted file mode 100644 index 5c92ba0..0000000 --- a/forge/_internal_plugins/manage_plugins/manage_plugins.py +++ /dev/null @@ -1,33 +0,0 @@ -""" Manage Plugins Plugin """ -from pathlib import Path - -from forge.config.config_handler import ConfigHandler - -from .manage_plugins_logic.manage_plugins import ManagePlugins -from .manage_plugins_logic.plugin_puller import PluginPuller - -CONF_HOME = str(Path(str(Path.home()) + '/.forge')) -CONFIG_FILE_PATH = str(Path(CONF_HOME + '/conf.ini')) - - -def execute(args: list) -> None: - """ Plugin Execution Definition """ - config_handler = ConfigHandler( - home_dir_path=CONF_HOME, - file_path_dir=CONFIG_FILE_PATH - ) - manage_plugins_logic = ManagePlugins( - plugin_puller=PluginPuller(config_handler), - config_handler=config_handler - ) - manage_plugins_logic.execute(args=args) - - -def helptext() -> str: - """ Simple Helptext For Plugin """ - return 'For managing plugins for use by forge.' - - -def register(app) -> None: - """ Plugin Registration """ - app.register_plugin(name='manage-plugins', plugin=execute, helptext=helptext()) diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/README.md b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/README.md deleted file mode 100644 index 18c1c24..0000000 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# manage-plugins -## What is this -This base 'plugin' is needed to add and update plugins for use by Forge. -## Usage -`usage: forge manage-plugins -h` \ No newline at end of file diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/__init__.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/command_line_parser.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/command_line_parser.py deleted file mode 100644 index 1fe7e1d..0000000 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/command_line_parser.py +++ /dev/null @@ -1,51 +0,0 @@ -""" Command Line Parser for Manage Plugins """ - -import argparse - - -def init_arg_parser() -> argparse.ArgumentParser: - """ Initialize Argument Parser """ - parser = argparse.ArgumentParser( - prog='forge manage-plugins', - description='Tool to allow users to configure their ' - 'anvil installations with plugins. Provides ' - 'the ability to add plugins via a repo reference ' - 'and the ability to update all plugins currently installed. ' - 'See -h for more information.') - parser.add_argument('-a', '--add', - action='store_const', - dest='action_type', - const='ADD', - required=False, - help='Add a new plugin') - - parser.add_argument('-u', '--update', - action='store_const', - dest='action_type', - const='UPDATE', - required=False, - help='Updates named plugin (via -n) or all plugins if -n not ' - 'provided') - parser.add_argument('-i', '--init', - action='store_const', - dest='action_type', - const='INIT', - required=False, - help='Initializes Forge based on an existing plugin conf.ini.') - parser.add_argument('-r', '--repo', - action='store', - dest='repo_url', - required=False, - help='Url to git repo containing plugin source. ' - 'NOTE it must refer to the clone URL, not the browser URL.') - parser.add_argument('-b', '--branch', - action='store', - dest='branch_name', - required=False, - help='Optionally pass the branch name for the plugin.') - parser.add_argument('-n', '--name', - action='store', - dest='plugin_name', - required=False, - help='The exact name of the plugin.') - return parser diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py deleted file mode 100644 index 70bbd97..0000000 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py +++ /dev/null @@ -1,192 +0,0 @@ -""" Manage Plugins Internally """ -import argparse -import os -import re -import subprocess -import sys -from typing import Any - -from colorama import Fore, deinit, init -from halo import Halo - -from forge.config.config_handler import ConfigHandler -from .command_line_parser import init_arg_parser -from .plugin_puller import PluginPuller -from .manage_plugin_exceptions import (PluginManagementFatalException, - PluginManagementWarnException) - - -class ManagePlugins: - """ Manage Plugins """ - - def __init__(self, plugin_puller: PluginPuller, config_handler: ConfigHandler) -> None: - self.arg_parser = init_arg_parser() - self.plugin_puller = plugin_puller - self.config_handler = config_handler - - def execute(self, args: list) -> None: - """ Execute """ - parsed_args = self.arg_parser.parse_args(args=args) - self._validate_args(parsed_args=parsed_args) - - init(autoreset=True) - - try: - if parsed_args.action_type == 'ADD': - self._do_add(parsed_args) - elif parsed_args.action_type == 'UPDATE': - self._do_update(parsed_args) - elif parsed_args.action_type == 'INIT': - self._do_init(parsed_args) - - except PluginManagementFatalException: - sys.exit(1) - except PluginManagementWarnException: - sys.exit(0) - - finally: - deinit() - - @staticmethod - def _pull_name_from_url(url: str) -> Any: - """ Extract Plugin Name from Git URL """ - match = re.search(r'[\s]*\/(forge(?:-[A-Za-z1-9]*)+)[\s]*', url) - if not match: - handle_error('Repository name should be in the form of forge-[alphanumeric name]') - return match.group(1) - - def _validate_args(self, parsed_args: argparse.Namespace) -> None: - """Validates passed in args, the presence of some args makes other args required.""" - action = parsed_args.action_type - if action == 'ADD': - self._validate_add_action(args=parsed_args) - elif action is None: - handle_error('Please provide an action with -a, -u or -i') - - @staticmethod - def _validate_add_action(args: argparse.Namespace) -> None: - if args.repo_url is None: - handle_error('Can\'t add plugin without providing url!') - - def _clone_plugin_step(self, name, args) -> None: - """ Clones plugin source code into forge folder """ - with Halo( - text=f'Cloning plugin: [{name}]...', - spinner='dots', - color='blue' - ) as spinner: - try: - self.plugin_puller.clone_plugin( - repo_url=args.repo_url, - plugin_name=name, - branch_name=args.branch_name - ) - spinner.succeed(f'Cloned plugin: [{name}]!') - - except PluginManagementFatalException as err: - spinner.fail(f'Could not clone plugin: [{name}]! ' + str(err)) - raise err from None - - except PluginManagementWarnException as err: - spinner.warn(str(err)) - raise err from None - - def _configure_plugin_step(self, args) -> None: - """ Configures plugin for use by forge """ - name = self._pull_name_from_url(url=args.repo_url) - with Halo(text='Configuring plugin for use...', spinner='dots', color='blue') as spinner: - self.config_handler.write_plugin_to_conf( - name=name, - url=args.repo_url - ) - spinner.succeed("Plugin configured!") - - def _pull_plugin_step(self, name: str, args: argparse.Namespace) -> None: - """ Pulls plugin source code into forge folder """ - - with Halo( - text=f'Updating plugin: [{name}]...', - spinner='dots', - color='blue' - ) as spinner: - try: - self.plugin_puller.pull_plugin( - plugin_name=name, - branch_name=args.branch_name - ) - spinner.succeed(text=f'Plugin: [{name}] updated!') - - except PluginManagementFatalException as err: - spinner.fail(f'Could not update plugin: [{name}]! {str(err)}') - raise err from None - - except PluginManagementWarnException: - spinner.succeed(f'Plugin: [{name}] already up to date!') - - def _install_dependencies(self) -> None: - """ Installs all dependencies required by all plugins """ - with Halo( - text='Installing plugin dependencies...', - spinner='dots', - color='blue' - ) as spinner: - for plugin_path in self.config_handler.get_plugins(): - req_file = os.path.join(plugin_path, 'requirements.txt') - if os.path.exists(req_file): - with open(os.devnull, 'wb') as devnull: - command = f'{sys.executable} -m pip install -r {req_file}'.split() - - subprocess.check_call(command, - stdout=devnull, - stderr=subprocess.STDOUT - ) - spinner.succeed('Plugin dependencies installed!') - - def _do_add(self, args: argparse.Namespace) -> None: - print('Installing plugin...') - - name = self._pull_name_from_url(url=args.repo_url) - - self._clone_plugin_step(name=name, args=args) - self._configure_plugin_step(args=args) - self._install_dependencies() - - with Halo(text='Finishing up...', spinner='dots', color='blue') as spinner: - spinner.succeed('Plugin installed and ready for use!') - - def _do_update(self, args: argparse.Namespace) -> None: - print('Updating plugins...') - if args.plugin_name: - self._pull_plugin_step(name=args.plugin_name, args=args) - else: - for name, url in self.config_handler.get_plugin_entries(): - args.repo_url = url - self._pull_plugin_step(name=name, args=args) - - self._install_dependencies() - - with Halo(text='Finishing up...', spinner='dots', color='blue') as spinner: - spinner.succeed('Updated plugins!') - - def _do_init(self, args: argparse.Namespace): - print('Initializing plugins...') - - for name, url in self.config_handler.get_plugin_entries(): - args.repo_url = url - self._clone_plugin_step(name=name, args=args) - - self._install_dependencies() - - with Halo(text='Finishing up...', spinner='dots', color='blue') as spinner: - spinner.succeed('Initialized plugins!') - - -def _write_failure_message(message: str) -> None: - """ Writes message in red color for errors """ - print(Fore.RED + '\n' + message) - - -def handle_error(message: str) -> None: - """ Class Level Error Handling """ - _write_failure_message(message) - raise PluginManagementFatalException(message) diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py deleted file mode 100644 index 529f6fd..0000000 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py +++ /dev/null @@ -1,62 +0,0 @@ -""" Plugin Puller """ -from functools import wraps -from pathlib import Path - -from git import Git, GitCommandError, GitCommandNotFound, Repo - -from .manage_plugin_exceptions import (PluginManagementFatalException, - PluginManagementWarnException) - - -def handle_git_errors(func): - """ decorator to share error handling code between git operations """ - @wraps(func) - def wrapper(*args, **kwargs): - repo = None - - try: - repo = func(*args, **kwargs) - - except GitCommandError as err: - if 'already exists and is not an empty directory' in err.stderr: - raise PluginManagementWarnException('Plugin already installed to forge!') from None - failed_pull_message = f'Failed to pull source code! {err.stderr}' - raise PluginManagementFatalException(failed_pull_message) from None - - except GitCommandNotFound as err: - failed_pull_message = f'Failed to pull source code! {err.stderr}' - raise PluginManagementFatalException(failed_pull_message) from None - - if not isinstance(repo, Repo): - raise PluginManagementWarnException('Plugin already up to date!') from None - if repo.bare: - raise PluginManagementFatalException('Given repository has no data!') from None - return repo - - return wrapper - - -class PluginPuller: - """ Plugin Puller Class """ - - def __init__(self, config_handler): - self.config_handler = config_handler - - @handle_git_errors - def pull_plugin(self, plugin_name, branch_name='dev') -> Repo: - """Updates plugin by executing a git pull from its install location""" - repo = str(Path(f'{self.config_handler.get_plugin_install_location()}/{plugin_name}')) - repo = Git(repo).pull('origin', branch_name) - - return repo - - @handle_git_errors - def clone_plugin(self, repo_url, plugin_name, branch_name='dev') -> Repo: - """ Clone Plugin From Git """ - repo = Repo.clone_from( - repo_url, - str(Path(f'{self.config_handler.get_plugin_install_location()}/{plugin_name}')), - branch=branch_name - ) - - return repo diff --git a/forge/cli.py b/forge/cli.py new file mode 100644 index 0000000..86d53dd --- /dev/null +++ b/forge/cli.py @@ -0,0 +1,80 @@ +""" Forge CLI """ + + +import click + +from forge import forge + +from .pipx_wrapper import install_to_pipx, uninstall_from_pipx, update_pipx + +CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) + + +@click.group(invoke_without_command=True, context_settings=CONTEXT_SETTINGS) +@click.pass_context +# @click.option('command') +def forge_cli(context: click.Context) -> None: + """ Command Line Interface for Forge """ + if context.invoked_subcommand is None: + forge.list_plugins() + + +@forge_cli.command(name='add', + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True) + ) +@click.argument('pipx_args', nargs=-1, type=click.UNPROCESSED) +@click.option('-s', '--source', + type=str, + help='Source of plugin to install', + metavar='PLUGIN_SOURCE', + required=True) +def add_plugin(source: str, pipx_args) -> None: + """ Add plugin to Forge by providing source to be passed to PIPX """ + install_to_pipx(source=source, extra_args=list(pipx_args)) + + +@forge_cli.command(name='update', + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True) + ) +@click.argument('pipx_args', nargs=-1, type=click.UNPROCESSED) +@click.option('-n', '--name', type=str, help='Name of plugin(s) to update', metavar='PLUGIN_NAME') +def update_plugin(name: str, pipx_args) -> None: + """ Update plugin(s) """ + if name: + if not name.startswith('forge-'): + name = f'forge-{name}' + update_pipx(name=name, extra_args=list(pipx_args)) + else: + for plugin in forge.get_plugins(): + update_pipx(name=plugin['main_package']['package'], extra_args=list(pipx_args)) + + +@forge_cli.command(name='remove', + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True) + ) +@click.argument('pipx_args', nargs=-1, type=click.UNPROCESSED) +@click.option('-n', '--name', type=str, help='Name of plugin(s) to remove', metavar='PLUGIN_NAME') +def remove_plugin(name: str, pipx_args) -> None: + """ Remove plugin(s) """ + if name: + if not name.startswith('forge-'): + name = f'forge-{name}' + uninstall_from_pipx(plugin_name=name, extra_args=list(pipx_args)) + else: + for plugin in forge.get_plugins(): + uninstall_from_pipx( + plugin_name=plugin['main_package']['package'], + extra_args=list(pipx_args) + ) + + +@forge_cli.command(name='list') +def list_forge_plugins() -> None: + """ List installed Forge plugins """ + forge.list_plugins() diff --git a/forge/config/__init__.py b/forge/config/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/forge/config/config_handler.py b/forge/config/config_handler.py deleted file mode 100644 index b9a5e14..0000000 --- a/forge/config/config_handler.py +++ /dev/null @@ -1,60 +0,0 @@ -""" Handles operations on Forge conf.ini """ -import os -import configparser -from pathlib import Path -from typing import List, Tuple - - -class ConfigHandler: - """ Class that handles operations against Forge conf.ini """ - - def __init__(self, home_dir_path: str, file_path_dir: str): - self.home_dir_path = home_dir_path - self.file_path_dir = file_path_dir - - def init_conf_dir(self): - """ Initializes the conf directory for Forge """ - os.makedirs(self.home_dir_path, exist_ok=True) - - def init_conf_file(self) -> None: - """ Initializes the conifiguration file used by Forge """ - config = self._get_config_parser() - if not os.path.exists(self.file_path_dir): - config['plugin-definitions'] = {} - config['install-conf'] = {} - # this is default plugin install location - config['install-conf']['pluginlocation'] = str(Path(self.home_dir_path + '/plugins')) - with open(self.file_path_dir, 'w') as conf_file: - config.write(conf_file) - - def _get_config_parser(self) -> configparser.ConfigParser: - config_parser = configparser.ConfigParser() - config_parser.read(self.file_path_dir) - return config_parser - - def get_plugin_install_location(self) -> str: - """ Returns the configured location for plugin installations """ - return self._get_config_parser()['install-conf']['pluginlocation'] - - def get_plugins(self) -> List[str]: - """ Parse Plugin Configuration File """ - config = self._get_config_parser() - plugins = [] - for plugin_name in config['plugin-definitions']: - plugins.append(str(Path(self.get_plugin_install_location() + '/' + plugin_name))) - return plugins - - def get_plugin_entries(self) -> List[Tuple[str, str]]: - """ Parses all of the plugin entries currently installed """ - config = self._get_config_parser() - config.sections() - return config.items('plugin-definitions') - - def write_plugin_to_conf(self, name: str, url: str) -> None: - """ Write Plugin Info to Config File """ - config = self._get_config_parser() - plugin_section = config['plugin-definitions'] - plugin_section[name] = url - - with open(self.file_path_dir, 'w') as configfile: - config.write(configfile) diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugin_exceptions.py b/forge/exceptions.py similarity index 100% rename from forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugin_exceptions.py rename to forge/exceptions.py diff --git a/forge/forge.py b/forge/forge.py new file mode 100644 index 0000000..0007390 --- /dev/null +++ b/forge/forge.py @@ -0,0 +1,73 @@ +""" Forge """ + +import os +from json import loads +from pathlib import Path +from typing import Dict, List + +from tabulate import tabulate +from halo import Halo + +from .exceptions import PluginManagementFatalException + +FORGE_PATH = os.path.join(Path.home(), '.forge') +PLUGIN_PATH = os.path.join(FORGE_PATH, 'venvs') + + +def get_plugin_paths(): + """ Fet paths of installed plugins """ + paths = [] + for venv in os.listdir(PLUGIN_PATH): + paths.append(os.path.join(PLUGIN_PATH, venv)) + return paths + + +def get_pipx_config(plugin_path: str) -> Dict: + """ Return config data from pipx venv metadata file """ + config_file_path = os.path.join(plugin_path, 'pipx_metadata.json') + try: + with open(config_file_path) as config_file: + raw_data = config_file.read() + if raw_data: + return loads(raw_data) + raise PluginManagementFatalException() + + except: + raise PluginManagementFatalException( + f'Problem reading json file expected at {plugin_path}' + ) from None + + +def filter_forge_plugins(plugin_configs: List[Dict]) -> List[Dict]: + """ Filters out non-forge plugins """ + filtered_plugins = [] + for config in plugin_configs: + if config and config['main_package']['package'].startswith('forge-'): + filtered_plugins.append(config) + return filtered_plugins + + +def get_command_from_config(plugin_config: Dict) -> str: + """ Gets the plugin command from its config """ + return plugin_config['main_package']['apps'][0].replace('.exe', '') + + +def get_plugins(): + """ Get installed forge plugins """ + plugin_configs = [] + for plugin_path in get_plugin_paths(): + plugin_configs.append(get_pipx_config(plugin_path)) + + return filter_forge_plugins(plugin_configs=plugin_configs) + + +def list_plugins(): + """ List installed forge plugins """ + tabulated_data = [ + (get_command_from_config(config), config['main_package']['package_version']) + for config in get_plugins()] + + if len(tabulated_data) == 0: + Halo().warn('No forge plugins installed yet! - Run forge --help for help') + else: + print(tabulate(tabulated_data, ['plugin', 'version']), end='\n\n') diff --git a/forge/pipx_wrapper.py b/forge/pipx_wrapper.py new file mode 100644 index 0000000..6dbe8d5 --- /dev/null +++ b/forge/pipx_wrapper.py @@ -0,0 +1,127 @@ +""" Wrapper around pipx for Forge plugin management """ + + +import re +from subprocess import PIPE, CalledProcessError, Popen +from typing import List, Tuple + +from halo import Halo + +from .exceptions import (PluginManagementFatalException, + PluginManagementWarnException) + +DOTS = { + "interval": 80, + "frames": ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] +} + + +def make_spinner(text: str) -> Halo: + """ Creates uniform stylized Halo spinner """ + return Halo( + text=text, + spinner=DOTS, + color='blue' + ) + + +def run_command(command: List[str]) -> Tuple[str, str]: + """ Wrapper to simplify handling subprocess commands """ + try: + process = Popen(command, stdout=PIPE, stderr=PIPE) + + stdout, stderr = process.communicate() + + if process.returncode: + raise PluginManagementFatalException(stderr.decode()) + + return stdout.decode(), stderr.decode() + + except CalledProcessError as err: + raise PluginManagementFatalException(err) from None + + +def update_pipx(name: str, extra_args: List[str]): + """ Installs a plugin to pipx """ + command = f'pipx upgrade {name} --verbose' + pretty_name = name.replace('forge-', '', 1) + + with make_spinner(text=f'Updating plugin: [{pretty_name}]...') as spinner: + try: + stdout, stderr = run_command(command.split() + extra_args) + + if 'Package is not installed' in stderr: + spinner.warn('Plugin not installed! Cannot update!') + raise PluginManagementWarnException() + + update_message = _extract_update_details(stdout) + + spinner.succeed(update_message) + + except PluginManagementFatalException as err: + spinner.fail(str(err)) + raise PluginManagementFatalException from None + + +def install_to_pipx(source: str, extra_args: List[str]) -> None: + """ Installs a plugin to pipx """ + command = f'pipx install {source} --verbose' + + with make_spinner(text='Installing plugin...') as spinner: + try: + stdout, _ = run_command(command.split() + extra_args) + + if 'already seems to be installed' in stdout: + spinner.warn('Plugin already installed!') + raise PluginManagementWarnException() + + plugin_name, package, python_version = _extract_result_details(stdout) + + spinner.succeed(f'Installed plugin: [{plugin_name}] [{package}] [{python_version}]!') + + except PluginManagementFatalException as err: + spinner.fail(str(err)) + raise PluginManagementFatalException from None + + +def uninstall_from_pipx(plugin_name: str, extra_args: List[str]) -> str: + """ Installs a plugin to pipx """ + command = f'pipx uninstall {plugin_name} --verbose' + + with make_spinner(text=f'Uninstalling plugin: [{plugin_name}]...') as spinner: + try: + stdout, _ = run_command(command.split() + extra_args) + + if f'Nothing to uninstall for {plugin_name}' in stdout: + spinner.warn(f'Plugin {plugin_name} not installed!') + raise PluginManagementWarnException() + + spinner.succeed(f'Uninstalled plugin: [{plugin_name}]!') + + return plugin_name + + except PluginManagementFatalException as err: + spinner.fail(str(err)) + raise PluginManagementFatalException from None + + +def _extract_result_details(pipx_output: str) -> Tuple[str, str, str]: + """ Extracts name and version from pipx's stdout """ + match = re.search(r'installed package(.*),(.*)\n.*\n.*?-(.*)', pipx_output) + if match: + package, python_version, plugin_name = map(str.strip, match.groups()) + + return plugin_name.replace('.exe', ''), package, python_version + + raise PluginManagementFatalException('Failed to find package information install log!') + + +def _extract_update_details(pipx_output: str) -> str: + """ Extracts update message from pipx's stdout """ + + match = re.findall(r'(.*forge-.*)\(', pipx_output) + + if match: + return match[-1].strip().replace('forge-', '', 1) + + raise PluginManagementFatalException('Failed to find update information from update log!') diff --git a/install.ps1 b/install.ps1 deleted file mode 100644 index 68ff2e6..0000000 --- a/install.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -if (test-path venv) { - Remove-Item venv -Recurse -Force -} -python -m pip install virtualenv -virtualenv --always-copy venv -. venv/Scripts/activate -pip3 install -r requirements.txt - -$version = (Get-Content setup.py | Select-String "version='(\d+\.\d+\.\d+)'" | Select-Object Matches -First 1).Matches.Groups[1].Value - -python -m pip install --upgrade setuptools wheel -python setup.py sdist bdist_wheel -python -m pip install dist/tele_forge-$version-py3-none-any.whl - -forge \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ecf975e..4052252 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,5 @@ --e . \ No newline at end of file +requests==2.25.0 +tabulate==0.8.7 +halo==0.0.31 +python-slugify==4.0.1 +click==7.1.2 diff --git a/setup.py b/setup.py index 1aacc9e..6656821 100644 --- a/setup.py +++ b/setup.py @@ -1,35 +1,38 @@ """ Setup """ from setuptools import setup -setup(name='tele-forge', - version='1.0.1', - description='convenient cli with extendable collection of useful plugins', - url='https://github.com/TeleTrackingTechnologies/forge', - author='Brandon Horn, Kenneth Poling, Paul Verardi, Cameron Tucker, Clint Wadley', - author_email='opensource@teletracking.com', - packages=[ - 'forge', - 'forge.config', - 'forge._internal_plugins', - 'forge._internal_plugins.manage_plugins', - 'forge._internal_plugins.manage_plugins.manage_plugins_logic' - ], - install_requires=[ - 'colorama==0.4.4', - 'GitPython==3.1.1', - 'pluginbase==1.0.0', - 'requests==2.25.0', - 'tabulate==0.8.7', - 'halo==0.0.31', - 'python-slugify==4.0.1' - ], - classifiers=[ - 'Programming Language :: Python :: 3', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent' - ], - python_requires='>=3.7', - scripts=[ - 'bin/forge', - 'bin/forge.bat'], - zip_safe=False - ) + + +with open('requirements.txt') as f: + required_dependencies = f.read().splitlines() + + +setup( + name='tele-forge', + version="2.0.0", + description='convenient cli with extendable collection of useful plugins', + url='https://github.com/TeleTrackingTechnologies/forge', + author=('Brandon Horn, Kenneth Poling, Paul Verardi, ' + 'Cameron Tucker, Clint Wadley, Morgan Szafranski'), + author_email='opensource@teletracking.com', + license='MIT', + packages=[ + 'forge' + ], + install_requires=required_dependencies, + classifiers=[ + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent' + ], + python_requires='>=3.7', + zip_safe=True, + include_package_data=True, + entry_points={ + 'console_scripts': [ + 'forge = forge:main' + ] + } +) diff --git a/test.ps1 b/test.ps1 index 175d664..5ee70ef 100644 --- a/test.ps1 +++ b/test.ps1 @@ -5,6 +5,7 @@ pip3 install -r requirements.txt pip3 install -r dev-requirements.txt python -m pylint -j 4 -r y forge -if($LastExitCode) { exit $LastExitCode} +if ($LastExitCode) { deactivate; exit $LastExitCode } python -m pytest -rf -vvv -x --count 5 --cov=forge --cov-fail-under=80 --cov-report term -if($LastExitCode) { exit $LastExitCode} \ No newline at end of file +if ($LastExitCode) { deactivate; exit $LastExitCode } +deactivate \ No newline at end of file diff --git a/bin/__init__.py b/tests/__init__.py similarity index 100% rename from bin/__init__.py rename to tests/__init__.py diff --git a/tests/cli_test.py b/tests/cli_test.py new file mode 100644 index 0000000..292b39b --- /dev/null +++ b/tests/cli_test.py @@ -0,0 +1,140 @@ +import pytest +from forge.cli import forge_cli +from mock import call, patch + + +@patch('forge.forge.list_plugins') +def test_cli_no_command(mock_pipx_list): + mock_args = 'forge'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + mock_pipx_list.assert_called_once_with() + + +@patch('forge.forge.list_plugins') +def test_cli_list(mock_pipx_list): + mock_args = 'forge list'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + mock_pipx_list.assert_called_once_with() + + +@patch('forge.cli.install_to_pipx') +def test_cli_add(mock_pipx_install): + mock_args = 'forge add --source some-source'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + mock_pipx_install.assert_called_once_with(source='some-source', extra_args=[]) + + +@patch('forge.cli.install_to_pipx') +def test_cli_add_with_extra_args(mock_pipx_install): + mock_args = 'forge add -s some-source arg1 val1 arg2 val2'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + mock_pipx_install.assert_called_once_with( + source='some-source', + extra_args=['arg1', 'val1', 'arg2', 'val2'] + ) + + +@patch('forge.cli.update_pipx') +def test_cli_update(mock_pipx_update): + mock_args = 'forge update --name plugin-name'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + mock_pipx_update.assert_called_once_with(name='forge-plugin-name', extra_args=[]) + + +@patch('forge.cli.update_pipx') +def test_cli_update_with_extra_args(mock_pipx_update): + mock_args = 'forge update -n plugin-name arg1 val1 arg2 val2 '.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + mock_pipx_update.assert_called_once_with( + name='forge-plugin-name', + extra_args=['arg1', 'val1', 'arg2', 'val2'] + ) + + +@patch('forge.cli.update_pipx') +@patch('forge.forge.get_plugins') +def test_cli_update_all_since_no_name_given(mock_get_plugins, mock_pipx_update): + mock_get_plugins.return_value = [ + {"main_package": {"package": "forge-plugin1"}}, + {"main_package": {"package": "forge-plugin2"}} + ] + mock_args = 'forge update'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + mock_pipx_update.mock_calls = [ + call(name='forge-plugin1', extra_args=[]), + call(name='forge-plugin2', extra_args=[]) + ] + + +@patch('forge.cli.update_pipx') +@patch('forge.forge.get_plugins') +def test_cli_update_all_since_no_name_given_with_extra_args(mock_get_plugins, mock_pipx_update): + mock_get_plugins.return_value = [ + {"main_package": {"package": "forge-plugin1"}}, + {"main_package": {"package": "forge-plugin2"}} + ] + mock_args = 'forge update arg1 val1'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + mock_pipx_update.mock_calls = [ + call(name='forge-plugin1', extra_args=['arg1', 'val1']), + call(name='forge-plugin2', extra_args=['arg1', 'val1']) + ] + + +@patch('forge.cli.uninstall_from_pipx') +def test_cli_remove(uninstall_from_pipx): + mock_args = 'forge remove -n plugin-name'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + uninstall_from_pipx.assert_called_once_with(plugin_name='forge-plugin-name', extra_args=[]) + + +@patch('forge.cli.uninstall_from_pipx') +def test_cli_remove_with_extra_args(uninstall_from_pipx): + mock_args = 'forge remove -n plugin-name arg1 val1 arg2 val2 '.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + uninstall_from_pipx.assert_called_once_with( + plugin_name='forge-plugin-name', + extra_args=['arg1', 'val1', 'arg2', 'val2'] + ) + + +@patch('forge.cli.uninstall_from_pipx') +@patch('forge.forge.get_plugins') +def test_cli_remove_all_since_no_name_given(mock_get_plugins, uninstall_from_pipx): + mock_get_plugins.return_value = [ + {"main_package": {"package": "forge-plugin1"}}, + {"main_package": {"package": "forge-plugin2"}} + ] + mock_args = 'forge remove'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + uninstall_from_pipx.mock_calls = [ + call(plugin_name='forge-plugin1', extra_args=[]), + call(plugin_name='forge-plugin2', extra_args=[]) + ] + + +@patch('forge.cli.uninstall_from_pipx') +@patch('forge.forge.get_plugins') +def test_cli_remove_all_since_no_name_given_with_extra_args(mock_get_plugins, uninstall_from_pipx): + mock_get_plugins.return_value = [ + {"main_package": {"package": "forge-plugin1"}}, + {"main_package": {"package": "forge-plugin2"}} + ] + mock_args = 'forge remove arg1 val1'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + uninstall_from_pipx.mock_calls = [ + call(plugin_name='forge-plugin1', extra_args=['arg1', 'val1']), + call(plugin_name='forge-plugin2', extra_args=['arg1', 'val1']) + ] diff --git a/tests/config_handler_test.py b/tests/config_handler_test.py deleted file mode 100644 index ecfac45..0000000 --- a/tests/config_handler_test.py +++ /dev/null @@ -1,73 +0,0 @@ -import configparser -import os -import pytest - -from pathlib import Path -from shutil import rmtree - - -from forge.config.config_handler import ConfigHandler - -CONF_HOME = str(Path(str('tmp') + '/.forge')) -CONFIG_FILE_PATH = str(Path('tmp' + '/conf.ini')) - - -@pytest.fixture(autouse=True) -def setup_and_teardown(): - conf_handler = ConfigHandler( - home_dir_path=CONF_HOME, - file_path_dir=CONFIG_FILE_PATH - ) - conf_handler.init_conf_dir() - conf_handler.init_conf_file() - yield - os.remove(CONFIG_FILE_PATH) - rmtree('tmp', ignore_errors=True) - - -def test_write_plugin_to_conf(): - name = 'somepluginname' - url = 'url.for.plugin.git' - config_handler = ConfigHandler(home_dir_path=CONF_HOME, file_path_dir=CONFIG_FILE_PATH) - config_handler.write_plugin_to_conf(name, url) - - parser = configparser.ConfigParser() - parser.sections() - parser.read(CONFIG_FILE_PATH) - plugin_entries = dict(parser.items('plugin-definitions')) - - assert plugin_entries[name] is not None - assert plugin_entries[name] == url - - -def test_read_plugin_entries(): - config_handler = ConfigHandler(home_dir_path=CONF_HOME, file_path_dir=CONFIG_FILE_PATH) - config_handler.write_plugin_to_conf('some_name', 'someurl') - config_handler.write_plugin_to_conf('someothername', 'someotherurl') - - entries = config_handler.get_plugin_entries() - - assert entries is not None - entries_as_dict = dict(entries) - - assert entries_as_dict['some_name'] == 'someurl' - assert entries_as_dict['someothername'] == 'someotherurl' - - -def test_get_plugins(): - config_handler = ConfigHandler(home_dir_path=CONF_HOME, file_path_dir=CONFIG_FILE_PATH) - config_handler.write_plugin_to_conf('some_name', 'someurl') - config_handler.write_plugin_to_conf('someothername', 'someotherurl') - - plugins = config_handler.get_plugins() - - plugin_1 = os.path.join('tmp', '.forge', 'plugins', 'some_name') - plugin_2 = os.path.join('tmp', '.forge', 'plugins', 'someothername') - - assert plugins == [plugin_1, plugin_2] - - -def test_get_plugin_install_location(): - config_handler = ConfigHandler(home_dir_path=CONF_HOME, file_path_dir=CONFIG_FILE_PATH) - - assert config_handler.get_plugin_install_location() == os.path.join('tmp', '.forge', 'plugins') diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..28d25bc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +from mock import mock_open + +# Credit to: https://gist.github.com/adammartinez271828/137ae25d0b817da2509c1a96ba37fc56 + + +def multi_mock_open(*file_contents): + """Create a mock "open" that will mock open multiple files in sequence + Args: + *file_contents ([str]): a list of file contents to be returned by open + Returns: + (MagicMock) a mock opener that will return the contents of the first + file when opened the first time, the second file when opened the + second time, etc. + """ + mock_files = [mock_open(read_data=content).return_value for content in file_contents] + mock_opener = mock_open() + mock_opener.side_effect = mock_files + + return mock_opener diff --git a/tests/forge_test.py b/tests/forge_test.py new file mode 100644 index 0000000..e85017d --- /dev/null +++ b/tests/forge_test.py @@ -0,0 +1,143 @@ +import os + +import pytest +from forge import forge +from forge.exceptions import (PluginManagementFatalException, + PluginManagementWarnException) +from mock import call, mock_open, patch + +from .conftest import multi_mock_open + + +@patch('os.listdir', return_value=['plugin1', 'plugin2']) +def test_get_plugin_paths(mocked_listdir): + + forge.PLUGIN_PATH = os.path.join('home', 'user', '.forge', 'venvs') + + expected_paths = [ + 'home|user|.forge|venvs|plugin1', + 'home|user|.forge|venvs|plugin2' + ] + + plugin_paths = forge.get_plugin_paths() + + normalized_plugin_paths = [path.replace(os.path.sep, '|') for path in plugin_paths] + + assert normalized_plugin_paths == expected_paths + + +def test_get_pipx_config(): + + fake_config_json = '{"a":"something", "b":"another"}' + + with patch("builtins.open", mock_open(read_data=fake_config_json)): + config_data = forge.get_pipx_config('some_path') + + assert config_data == {'a': 'something', 'b': 'another'} + + +def test_get_pipx_config_bad_json_data(): + fake_config_json = '{"a":no quote heres, or here:"another"}' + + with pytest.raises(forge.PluginManagementFatalException) as err: + with patch("builtins.open", mock_open(read_data=fake_config_json)): + forge.get_pipx_config('some_path') + + assert str(err.value) == 'Problem reading json file expected at some_path' + + +def test_get_pipx_config_no_data(): + with pytest.raises(forge.PluginManagementFatalException) as err: + with patch("builtins.open", mock_open(read_data='')): + forge.get_pipx_config('some_path') + + assert str(err.value) == 'Problem reading json file expected at some_path' + + +def test_filer_forge_plugins(): + mock_config_list = [ + {'main_package': { + 'package': 'package1' + }}, + {'main_package': { + 'package': 'forge-package2' + }}, + {}, + {'main_package': { + 'package': 'forge-package4' + }}, + ] + + expected_configs = [mock_config_list[1], mock_config_list[3]] + + assert forge.filter_forge_plugins(plugin_configs=mock_config_list) == expected_configs + + +def test_get_command_from_config(): + + mock_plugin_config = { + 'main_package': { + 'apps': [ + 'plugin-name.exe' + ] + }} + + assert forge.get_command_from_config(mock_plugin_config) == 'plugin-name' + + +@patch('os.listdir', return_value=['forge-plugin1', 'forge-plugin2', 'non_plugin']) +def test_get_plugins(mocked_listdir): + forge.PLUGIN_PATH = os.path.join('home', 'user', '.forge', 'venvs') + + fake_config_jsons = [ + '{"main_package": {"package": "forge-plugin1"}}', + '{"main_package": {"package": "forge-plugin2"}}', + '{"main_package": {"package": "non_plugin"}}' + ] + + with patch("builtins.open", multi_mock_open(*fake_config_jsons)): + filtered_plugin_configs = forge.get_plugins() + + expected_plugin_configs = [ + {'main_package': {'package': 'forge-plugin1'}}, + {'main_package': {'package': 'forge-plugin2'}} + ] + + assert filtered_plugin_configs == expected_plugin_configs + + +@patch('forge.forge.Halo') +@patch('forge.forge.tabulate') +@patch('os.listdir', return_value=['forge-plugin1', 'forge-plugin2', 'non_plugin']) +def test_list_plugins(mocked_listdir, mocked_tabulate, mocked_spinner): + forge.PLUGIN_PATH = os.path.join('home', 'user', '.forge', 'venvs') + + fake_config_jsons = [ + '{"main_package": {"package": "forge-plugin1", "apps": ["plugin1.exe"], "package_version":"1.0.0"}}', + '{"main_package": {"package": "forge-plugin2", "apps": ["plugin2"], "package_version":"0.0.1"}}', + '{"main_package": {"package": "non_plugin", "apps": ["non_plugin.exe"], "package_version":"0.0.2"}}' + ] + + with patch("builtins.open", multi_mock_open(*fake_config_jsons)): + forge.list_plugins() + + mocked_tabulate.assert_called_once_with( + [('plugin1', '1.0.0'), ('plugin2', '0.0.1')], ['plugin', 'version'] + ) + + mocked_spinner.assert_not_called() + + +@patch('forge.forge.Halo') +@patch('forge.forge.tabulate') +@patch('os.listdir') +def test_list_plugins_no_plugins_installed(mocked_listdir, mocked_tabulate, mocked_spinner): + forge.PLUGIN_PATH = os.path.join('home', 'user', '.forge', 'venvs') + + fake_config_jsons = [] + + with patch("builtins.open", multi_mock_open(*fake_config_jsons)): + forge.list_plugins() + + mocked_tabulate.assert_not_called() + assert mocked_spinner.mock_calls[1] == call().warn('No forge plugins installed yet! - Run forge --help for help') diff --git a/tests/main_init_test.py b/tests/main_init_test.py deleted file mode 100644 index 4068426..0000000 --- a/tests/main_init_test.py +++ /dev/null @@ -1,116 +0,0 @@ -import pytest -from mock import MagicMock, call, patch -from pprint import pprint - -import forge - - -@patch('forge.ConfigHandler') -@patch('forge.PLUGIN_BASE') -def test_application_init(mock_plugin_base, mock_config): - mock_plugin_base.make_plugin_source().list_plugins.return_value = ['a', 'a_logic', 'b', 'b_logic'] - - fake_plugin = MagicMock() - - mock_plugin_base.make_plugin_source().load_plugin.return_value = fake_plugin - - app = forge.Application(name='forge', config_handler=mock_config()) - - assert fake_plugin.mock_calls[0] == call.register(app) - assert fake_plugin.mock_calls[1] == call.register(app) - - -@patch('forge.ConfigHandler') -@patch('forge.PLUGIN_BASE') -def test_execute_help(mock_plugin_base, mock_config): - app = forge.Application(name='forge', config_handler=mock_config()) - - app.print_help = MagicMock() - - app.execute(command='help', args=None) - - app.print_help.assert_called_once() - - -@patch('forge.ConfigHandler') -@patch('forge.PLUGIN_BASE') -def test_execute_plugin_not_installed(mock_plugin_base, mock_config): - app = forge.Application(name='forge', config_handler=mock_config()) - - app.registry = { - 'plugin1': 'help1', - 'plugin2': 'help2', - } - - with pytest.raises(SystemExit) as raised_ex: - app.execute(command='plugin3', args=None) - - assert raised_ex.value.code == 1 - - -@patch('forge.ConfigHandler') -@patch('forge.PLUGIN_BASE') -def test_execute_plugin_no_args(mock_plugin_base, mock_config): - app = forge.Application(name='forge', config_handler=mock_config()) - - plugin1 = MagicMock() - app.registry = { - 'plugin1': (plugin1, 'help1') - } - - app.execute(command='plugin1', args=None) - - assert plugin1.mock_calls == [call(None)] - - -@patch('forge.ConfigHandler') -@patch('forge.PLUGIN_BASE') -def test_execute_plugin_with_args(mock_plugin_base, mock_config): - app = forge.Application(name='forge', config_handler=mock_config()) - - plugin1 = MagicMock() - app.registry = { - 'plugin1': (plugin1, 'help1') - } - - app.execute(command='plugin1', args=['-arg1', 'val1', '-arg2', 'val2']) - - assert plugin1.mock_calls == [call(['-arg1', 'val1', '-arg2', 'val2'])] - - -@patch('forge.Application') -@patch('forge.ConfigHandler') -def test_main_no_args(mock_config, mock_app): - command = '' - forge.main(args=command.split()) - - assert mock_config.mock_calls == [ - call( - home_dir_path=forge.CONF_HOME, - file_path_dir=forge.CONFIG_FILE_PATH - ) - ] - - assert mock_app.mock_calls == [ - call(name='forge', config_handler=mock_config()), - call().execute('help', None) - ] - - -@patch('forge.Application') -@patch('forge.ConfigHandler') -def test_main_with_args(mock_config, mock_app): - command = 'plugin1 -arg1 val1' - forge.main(args=command.split()) - - assert mock_config.mock_calls == [ - call( - home_dir_path=forge.CONF_HOME, - file_path_dir=forge.CONFIG_FILE_PATH - ) - ] - - assert mock_app.mock_calls == [ - call(name='forge', config_handler=mock_config()), - call().execute('plugin1', ['-arg1', 'val1']) - ] diff --git a/tests/main_test.py b/tests/main_test.py new file mode 100644 index 0000000..c92e7f3 --- /dev/null +++ b/tests/main_test.py @@ -0,0 +1,29 @@ +import forge +import pytest +from forge.exceptions import (PluginManagementFatalException, + PluginManagementWarnException) +from mock import patch + + +@patch('forge.forge_cli') +def test_main_fatal_exception(mock_cli): + + mock_cli.side_effect = PluginManagementFatalException('some fatal message') + + with pytest.raises(SystemExit) as err: + forge.main() + + mock_cli.assert_called_once() + assert str(err.value) == '1' + + +@patch('forge.forge_cli') +def test_main_warning_exception(mock_cli): + + mock_cli.side_effect = PluginManagementWarnException('some fatal message') + + with pytest.raises(SystemExit) as err: + forge.main() + + mock_cli.assert_called_once() + assert str(err.value) == '0' diff --git a/tests/manage_plugins_registration_test.py b/tests/manage_plugins_registration_test.py deleted file mode 100644 index fc919f7..0000000 --- a/tests/manage_plugins_registration_test.py +++ /dev/null @@ -1,40 +0,0 @@ -from mock import MagicMock, call, patch - -from forge._internal_plugins.manage_plugins import manage_plugins - -MODULE_PATH = 'forge._internal_plugins.manage_plugins' - - -def test_help_text(): - assert manage_plugins.helptext() == "For managing plugins for use by forge." - - -def test_register(): - fake_app = MagicMock() - manage_plugins.register(fake_app) - - print(dir(manage_plugins)) - - assert fake_app.mock_calls == [ - call.register_plugin( - name='manage-plugins', - plugin=manage_plugins.execute, - helptext='For managing plugins for use by forge.' - ) - ] - - -@patch(f'{MODULE_PATH}.manage_plugins.ConfigHandler') -@patch(f'{MODULE_PATH}.manage_plugins.ManagePlugins') -@patch(f'{MODULE_PATH}.manage_plugins.PluginPuller') -def test_execute(mock_plugin_puller, mock_manage_plugins, mock_config): - manage_plugins.execute(['']) - - assert mock_config.mock_calls == [ - call(home_dir_path=manage_plugins.CONF_HOME, - file_path_dir=manage_plugins.CONFIG_FILE_PATH) - ] - assert mock_manage_plugins.mock_calls == [ - call(plugin_puller=mock_plugin_puller(), - config_handler=mock_config()), call().execute(args=['']) - ] diff --git a/tests/manage_plugins_test.py b/tests/manage_plugins_test.py deleted file mode 100644 index 5bc2bd4..0000000 --- a/tests/manage_plugins_test.py +++ /dev/null @@ -1,203 +0,0 @@ -import os - -import pytest -from mock import patch, MagicMock, call - - -from forge._internal_plugins.manage_plugins.manage_plugins_logic.manage_plugins import ManagePlugins -from forge._internal_plugins.manage_plugins.manage_plugins_logic.manage_plugin_exceptions import PluginManagementFatalException, PluginManagementWarnException -from test_stubs.stub_plugin_puller import StubPluginPuller -from test_stubs.stub_config_parser import StubPluginConfigHandler -from test_stubs.stub_plugin_puller import StubPluginPullerWithError - -MODULE_PATH = 'forge._internal_plugins.manage_plugins.manage_plugins_logic.manage_plugins' - - -def test_add_with_no_url_error(): - command = '-a' - with pytest.raises(PluginManagementFatalException) as raised_ex: - ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()).execute(command.split()) - assert str(raised_ex.value) == 'Can\'t add plugin without providing url!' - - -def test_add_with_no_action_error(): - command = '' - with pytest.raises(PluginManagementFatalException) as raised_ex: - ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()).execute(command.split()) - assert str(raised_ex.value) == 'Please provide an action with -a, -u or -i' - - -@patch(f'{MODULE_PATH}._write_failure_message') -def test_add_with_bad_repo_name_error(mocked_write_failure_message): - command = '-a -r git@this_name-doesnt-start-with-forgedash.git' - with pytest.raises(SystemExit) as raised_ex: - ManagePlugins(StubPluginPuller(StubPluginConfigHandler()), StubPluginConfigHandler()).execute(command.split()) - mocked_write_failure_message.assert_called_with('Repository name should be in the form of forge-[alphanumeric name]') - assert raised_ex.value.code == 1 - - -@pytest.mark.parametrize("command,expected_branch", [ - ('-a -r git@bitbucket.org:tele/forge-some-plugin', None), - ('-a -r git@bitbucket.org:tele/forge-some-plugin -b TheBranch', 'TheBranch') -]) -@patch('os.path.exists') -@patch('subprocess.check_call') -@patch('test_stubs.stub_config_parser.StubPluginConfigHandler.write_plugin_to_conf') -@patch('test_stubs.stub_plugin_puller.StubPluginPuller.clone_plugin') -def test_do_add(mock_clone, mock_configure, mock_subprocess, mock_exists, command, expected_branch): - manager = ManagePlugins( - StubPluginPuller(StubPluginConfigHandler()), - StubPluginConfigHandler() - ) - - manager.execute(command.split()) - - mock_clone.assert_called_once_with( - repo_url='git@bitbucket.org:tele/forge-some-plugin', - plugin_name='forge-some-plugin', - branch_name=expected_branch - ) - mock_configure.assert_called_once_with(name='forge-some-plugin', url='git@bitbucket.org:tele/forge-some-plugin') - mock_subprocess.assert_called_once() - - _, *args = mock_subprocess.mock_calls[0].args[0] - - assert ' '.join(args) == fr'-m pip install -r forge-some-plugin{os.path.sep}requirements.txt' - - -@patch('os.path.exists') -@patch('subprocess.check_call') -@patch('test_stubs.stub_plugin_puller.StubPluginPuller.clone_plugin') -def test_do_init(mock_clone, mock_subprocess, mock_exists): - command = '-i' - manager = ManagePlugins( - StubPluginPuller(StubPluginConfigHandler()), - StubPluginConfigHandler() - ) - - manager.execute(command.split()) - - mock_clone.assert_called_once_with( - repo_url='git@bitbucket.org:tele/forge-some-plugin', - plugin_name='forge-some-plugin', - branch_name=None - ) - mock_subprocess.assert_called_once() - - _, *args = mock_subprocess.mock_calls[0].args[0] - - assert ' '.join(args) == fr'-m pip install -r forge-some-plugin{os.path.sep}requirements.txt' - - -@patch('os.path.exists') -@patch('subprocess.check_call') -@patch('test_stubs.stub_plugin_puller.StubPluginPuller.clone_plugin', side_effect=PluginManagementFatalException('Reason')) -@patch(f'{MODULE_PATH}.Halo') -def test_do_init_some_clone_fatal_error(mock_spinner, mock_clone, mock_subprocess, mock_exists): - command = '-i' - manager = ManagePlugins( - StubPluginPuller(StubPluginConfigHandler()), - StubPluginConfigHandler() - ) - - mock_spinner.return_value.__enter__.return_value = MagicMock() - - with pytest.raises(SystemExit) as raised_ex: - manager.execute(command.split()) - - assert raised_ex.value.code == 1 - assert mock_spinner.mock_calls[0] == call(text='Cloning plugin: [forge-some-plugin]...', spinner='dots', color='blue') - assert mock_spinner.mock_calls[2] == call().__enter__().fail('Could not clone plugin: [forge-some-plugin]! Reason') - - mock_clone.assert_called_once_with( - repo_url='git@bitbucket.org:tele/forge-some-plugin', - plugin_name='forge-some-plugin', - branch_name=None - ) - - -@patch('os.path.exists') -@patch('subprocess.check_call') -@patch('test_stubs.stub_plugin_puller.StubPluginPuller.clone_plugin', side_effect=PluginManagementWarnException('Reason')) -@patch(f'{MODULE_PATH}.Halo') -def test_do_init_some_clone_warning_error(mock_spinner, mock_clone, mock_subprocess, mock_exists): - command = '-i' - manager = ManagePlugins( - StubPluginPuller(StubPluginConfigHandler()), - StubPluginConfigHandler() - ) - - mock_spinner.return_value.__enter__.return_value = MagicMock() - - with pytest.raises(SystemExit) as raised_ex: - manager.execute(command.split()) - - assert raised_ex.value.code == 0 - assert mock_spinner.mock_calls[0] == call(text='Cloning plugin: [forge-some-plugin]...', spinner='dots', color='blue') - assert mock_spinner.mock_calls[2] == call().__enter__().warn('Reason') - - mock_clone.assert_called_once_with( - repo_url='git@bitbucket.org:tele/forge-some-plugin', - plugin_name='forge-some-plugin', - branch_name=None - ) - - -@pytest.mark.parametrize("command,expected_name", [ - ('-u', 'forge-some-plugin'), - ('-u -n forge-named-plugin', 'forge-named-plugin') -]) -@patch('os.path.exists') -@patch('subprocess.check_call') -@patch('test_stubs.stub_plugin_puller.StubPluginPuller.pull_plugin') -def test_do_update(mock_pull, mock_subprocess, mock_exists, command, expected_name): - manager = ManagePlugins( - StubPluginPuller(StubPluginConfigHandler()), - StubPluginConfigHandler() - ) - - manager.execute(command.split()) - - mock_pull.assert_called_once_with( - plugin_name=expected_name, branch_name=None - ) - - -@patch('os.path.exists') -@patch('subprocess.check_call') -@patch('test_stubs.stub_plugin_puller.StubPluginPuller.pull_plugin', side_effect=PluginManagementFatalException('Reason')) -@patch(f'{MODULE_PATH}.Halo') -def test_do_update_some_pull_error(mock_spinner, mock_pull, mock_subprocess, mock_exists): - command = '-u' - manager = ManagePlugins( - StubPluginPuller(StubPluginConfigHandler()), - StubPluginConfigHandler() - ) - - mock_spinner.return_value.__enter__.return_value = MagicMock() - - with pytest.raises(SystemExit) as raised_ex: - manager.execute(command.split()) - - assert raised_ex.value.code == 1 - assert mock_spinner.mock_calls[0] == call(text='Updating plugin: [forge-some-plugin]...', spinner='dots', color='blue') - assert mock_spinner.mock_calls[2] == call().__enter__().fail('Could not update plugin: [forge-some-plugin]! Reason') - - -@patch('os.path.exists') -@patch('subprocess.check_call') -@patch('test_stubs.stub_plugin_puller.StubPluginPuller.pull_plugin', side_effect=PluginManagementWarnException()) -@patch(f'{MODULE_PATH}.Halo') -def test_do_update_already_up_to_date(mock_spinner, mock_pull, mock_subprocess, mock_exists): - command = '-u' - manager = ManagePlugins( - StubPluginPuller(StubPluginConfigHandler()), - StubPluginConfigHandler() - ) - - mock_spinner.return_value.__enter__.return_value = MagicMock() - - manager.execute(command.split()) - - assert mock_spinner.mock_calls[0] == call(text='Updating plugin: [forge-some-plugin]...', spinner='dots', color='blue') - assert mock_spinner.mock_calls[2] == call().__enter__().succeed('Plugin: [forge-some-plugin] already up to date!') diff --git a/tests/pipx_wrapper_test.py b/tests/pipx_wrapper_test.py new file mode 100644 index 0000000..0dce530 --- /dev/null +++ b/tests/pipx_wrapper_test.py @@ -0,0 +1,140 @@ +from subprocess import CalledProcessError + +import pytest +from forge import pipx_wrapper +from forge.exceptions import (PluginManagementFatalException, + PluginManagementWarnException) +from mock import call, patch + + +def run_command_fail(*args, **kwargs): + raise CalledProcessError(returncode=1, cmd=' '.join(args[0])) + + +@patch('forge.pipx_wrapper.Popen') +def test_run_command(mock_popen): + mock_process = mock_popen.return_value + mock_process.returncode = 0 + mock_process.communicate.return_value = (b'stdout', b'stderr') + + stdout, stderr = pipx_wrapper.run_command(['ls']) + assert stdout == 'stdout' + assert stderr == 'stderr' + + +@patch('forge.pipx_wrapper.Popen') +def test_run_command_fail_non_zero_exit_code(mock_popen): + mock_process = mock_popen.return_value + mock_process.returncode = 1 + mock_process.communicate.return_value = (b'stdout', b'stderr') + with pytest.raises(PluginManagementFatalException): + pipx_wrapper.run_command(['ls']) + + +@patch('forge.pipx_wrapper.Halo') +@patch('forge.pipx_wrapper.run_command') +def test_update_pipx(mock_run_command, mock_spinner): + mock_run_command.return_value = ('forge-plugin-name updated to some version(', '') + pipx_wrapper.update_pipx('forge-plugin-name', []) + + mock_run_command.assert_called_once_with(['pipx', 'upgrade', 'forge-plugin-name', '--verbose']) + + assert mock_spinner.mock_calls[0] == call(text='Updating plugin: [plugin-name]...', + spinner={'interval': 80, 'frames': [ + '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' + ]}, + color='blue') + assert mock_spinner.mock_calls[2] == call().__enter__().succeed('plugin-name updated to some version') + + +@patch('forge.pipx_wrapper.Halo') +@patch('forge.pipx_wrapper.run_command') +def test_update_pipx_fail_no_update_message_matched(mock_run_command, mock_spinner): + mock_run_command.return_value = ('Some pipx output without update message', '') + with pytest.raises(PluginManagementFatalException): + pipx_wrapper.update_pipx('forge-plugin-name', []) + + assert mock_spinner.mock_calls[2] == call().__enter__().fail('Failed to find update information from update log!') + + +@patch('forge.pipx_wrapper.Halo') +@patch('forge.pipx_wrapper.run_command') +def test_update_pipx_warn_already_up_to_date(mock_run_command, mock_spinner): + mock_run_command.return_value = ('', 'Package is not installed') + with pytest.raises(PluginManagementWarnException): + pipx_wrapper.update_pipx('forge-plugin-name', []) + + assert mock_spinner.mock_calls[2] == call().__enter__().warn('Plugin not installed! Cannot update!') + + +@patch('forge.pipx_wrapper.Halo') +@patch('forge.pipx_wrapper.run_command') +def test_install_to_pipx(mock_run_command, mock_spinner): + + pipx_output = """ + installed package forge-plugin-name-here,python-version + yada + forge-plugin-name-here + """ + + mock_run_command.return_value = (pipx_output, '') + pipx_wrapper.install_to_pipx('some-source', []) + + mock_run_command.assert_called_once_with(['pipx', 'install', 'some-source', '--verbose']) + assert mock_spinner.mock_calls[0].kwargs['text'] == 'Installing plugin...' + assert mock_spinner.mock_calls[2] == call().__enter__().succeed( + 'Installed plugin: [plugin-name-here] [forge-plugin-name-here] [python-version]!' + ) + + +@patch('forge.pipx_wrapper.Halo') +@patch('forge.pipx_wrapper.run_command') +def test_install_to_pipx_warn_plugin_already_installed(mock_run_command, mock_spinner): + mock_run_command.return_value = ('already seems to be installed', '') + with pytest.raises(PluginManagementWarnException): + pipx_wrapper.install_to_pipx('forge-plugin-name', []) + + assert mock_spinner.mock_calls[2] == call().__enter__().warn('Plugin already installed!') + + +@patch('forge.pipx_wrapper.Halo') +@patch('forge.pipx_wrapper.run_command') +def test_install_pipx_fail_no_install_message_matched(mock_run_command, mock_spinner): + mock_run_command.return_value = ('', '') + with pytest.raises(PluginManagementFatalException): + pipx_wrapper.install_to_pipx('forge-plugin-name', []) + + assert mock_spinner.mock_calls[2] == call().__enter__().fail('Failed to find package information install log!') + + +@patch('forge.pipx_wrapper.Halo') +@patch('forge.pipx_wrapper.run_command') +def test_uninstall_from_pipx(mock_run_command, mock_spinner): + mock_run_command.return_value = ('', '') + pipx_wrapper.uninstall_from_pipx('forge-plugin-name', []) + + mock_run_command.assert_called_once_with(['pipx', 'uninstall', 'forge-plugin-name', '--verbose']) + assert mock_spinner.mock_calls[0].kwargs['text'] == 'Uninstalling plugin: [forge-plugin-name]...' + assert mock_spinner.mock_calls[2] == call().__enter__().succeed('Uninstalled plugin: [forge-plugin-name]!') + + +@patch('forge.pipx_wrapper.Halo') +@patch('forge.pipx_wrapper.run_command') +def test_uninstall_from_pipx_warn_plugin_not_installed(mock_run_command, mock_spinner): + mock_run_command.return_value = ('Nothing to uninstall for forge-plugin-name', '') + with pytest.raises(PluginManagementWarnException): + pipx_wrapper.uninstall_from_pipx('forge-plugin-name', []) + + assert mock_spinner.mock_calls[2] == call().__enter__().warn('Plugin forge-plugin-name not installed!') + + +@patch('forge.pipx_wrapper.Halo') +@patch('forge.pipx_wrapper.Popen') +def test_uninstall_from_pipx_fail_some_pipx_exception(mock_popen, mock_spinner): + mock_popen.side_effect = run_command_fail + with pytest.raises(PluginManagementFatalException): + pipx_wrapper.uninstall_from_pipx('forge-plugin-name', []) + + assert mock_spinner.mock_calls[2] == call().__enter__().fail( + "Command 'pipx uninstall forge-plugin-name --verbose' returned non-zero exit status 1." + ) diff --git a/tests/plugin_puller_test.py b/tests/plugin_puller_test.py deleted file mode 100644 index 79ffaef..0000000 --- a/tests/plugin_puller_test.py +++ /dev/null @@ -1,90 +0,0 @@ -import os - -import pytest -from mock import patch, MagicMock, call - -from git import Repo, GitCommandError, GitCommandNotFound - - -from forge._internal_plugins.manage_plugins.manage_plugins_logic.plugin_puller import PluginPuller -from forge._internal_plugins.manage_plugins.manage_plugins_logic.manage_plugin_exceptions import PluginManagementFatalException, PluginManagementWarnException -from test_stubs.stub_config_parser import StubPluginConfigHandler - -MODULE_PATH = 'forge._internal_plugins.manage_plugins.manage_plugins_logic.plugin_puller' - - -@patch(f'{MODULE_PATH}.Repo.clone_from', return_value=MagicMock(spec=Repo, bare=False)) -def test_clone_plugin(mock_clone): - - puller = PluginPuller(StubPluginConfigHandler()) - - puller.clone_plugin('long_url', 'forge-some-plugin') - - print(mock_clone.mock_calls) - - assert mock_clone.mock_calls[0] == call('long_url', f'path.to.install{os.path.sep}forge-some-plugin', branch='dev') - - -@patch(f'{MODULE_PATH}.Git') -def test_pull_plugin(mock_git): - - puller = PluginPuller(StubPluginConfigHandler()) - - mock_git.return_value.pull.return_value = MagicMock(spec=Repo, bare=False) - - puller.pull_plugin('forge-some-plugin') - - assert mock_git.mock_calls[0] == call(f'path.to.install{os.path.sep}forge-some-plugin') - assert mock_git.mock_calls[1] == call().pull('origin', 'dev') - - -@patch(f'{MODULE_PATH}.Git') -def test_pull_plugin_bare_repo(mock_git): - - puller = PluginPuller(StubPluginConfigHandler()) - - mock_git.return_value.pull.return_value = MagicMock(spec=Repo) - - with pytest.raises(PluginManagementFatalException) as raised_ex: - puller.pull_plugin('forge-some-plugin') - - assert str(raised_ex.value) == 'Given repository has no data!' - - -@patch(f'{MODULE_PATH}.Git') -def test_pull_plugin_up_to_date(mock_git): - - puller = PluginPuller(StubPluginConfigHandler()) - - mock_git.return_value.pull.return_value = MagicMock() - - with pytest.raises(PluginManagementWarnException) as raised_ex: - puller.pull_plugin('forge-some-plugin') - - assert str(raised_ex.value) == 'Plugin already up to date!' - - -@patch(f'{MODULE_PATH}.Git') -def test_pull_plugin_command_not_found_error(mock_git): - - puller = PluginPuller(StubPluginConfigHandler()) - - mock_git.return_value.pull.side_effect = GitCommandNotFound('command', 'cause') - - with pytest.raises(PluginManagementFatalException) as raised_ex: - puller.pull_plugin('forge-some-plugin') - - assert str(raised_ex.value) == 'Failed to pull source code! ' - - -@patch(f'{MODULE_PATH}.Git') -def test_pull_plugin_command_error(mock_git): - - puller = PluginPuller(StubPluginConfigHandler()) - - mock_git.return_value.pull.side_effect = GitCommandError('command', 'cause') - - with pytest.raises(PluginManagementFatalException) as raised_ex: - puller.pull_plugin('forge-some-plugin') - - assert str(raised_ex.value) == 'Failed to pull source code! ' diff --git a/tests/test_stubs/__init__.py b/tests/test_stubs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_stubs/stub_config_parser.py b/tests/test_stubs/stub_config_parser.py deleted file mode 100644 index 00b81c4..0000000 --- a/tests/test_stubs/stub_config_parser.py +++ /dev/null @@ -1,23 +0,0 @@ -# pylint: disable=all -from forge.config.config_handler import ConfigHandler - - -class StubPluginConfigHandler(ConfigHandler): - def __init__(self): - super().__init__('test', 'test_file') - - @staticmethod - def write_plugin_to_conf(name, url): - print('stub writing to file') - - @staticmethod - def get_plugin_entries(): - return [('forge-some-plugin', 'git@bitbucket.org:tele/forge-some-plugin')] - - @staticmethod - def get_plugins(): - return ['forge-some-plugin'] - - @staticmethod - def get_plugin_install_location(): - return 'path.to.install' diff --git a/tests/test_stubs/stub_plugin_puller.py b/tests/test_stubs/stub_plugin_puller.py deleted file mode 100644 index 884167f..0000000 --- a/tests/test_stubs/stub_plugin_puller.py +++ /dev/null @@ -1,32 +0,0 @@ -# pylint: disable=all -from git import GitCommandError -from forge._internal_plugins.manage_plugins.manage_plugins_logic.plugin_puller import PluginPuller - - -class StubPluginPuller(PluginPuller): - def __init__(self, config_handler): - super().__init__(config_handler) - - @staticmethod - def pull_plugin(plugin_name, branch_name='dev'): - """ stub for pull_plugin of PluginPuller.""" - return {} - - @staticmethod - def clone_plugin(repo_url, plugin_name, branch_name='dev'): - """ Clone Plugin From Git """ - return StubRepo() - - -class StubRepo: - bare = False - - -class StubPluginPullerWithError(PluginPuller): - def __init__(self, config_handler): - super().__init__(config_handler) - - @staticmethod - def pull_plugin(plugin_name, branch_name='dev'): - """stub method to raise error on pull.""" - raise GitCommandError('test', None) From 13265de3cc54cc9e5cd8c4813abeda8864d43ed3 Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Wed, 30 Dec 2020 23:13:37 -0500 Subject: [PATCH 12/17] Trigger action build --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 57a89e7..39ff384 100644 --- a/README.md +++ b/README.md @@ -95,5 +95,3 @@ should return the simple help interface. ``` forge [plugin-arguments] ``` - -TODO: add other forge commands here - like update/remove etc - explain that From 97ea4ef48e6c30bf0135aa4f15d9d7fea663d790 Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Wed, 30 Dec 2020 23:42:27 -0500 Subject: [PATCH 13/17] added type checking, make only, mypy doesnt work with windows --- Makefile | 20 +++++++++----------- dev-requirements.txt | 2 +- forge/__init__.py | 2 +- forge/cli.py | 8 +++++--- forge/forge.py | 12 ++++++------ forge/pipx_wrapper.py | 4 ++-- setup.cfg | 13 +++++++++++-- 7 files changed, 35 insertions(+), 26 deletions(-) diff --git a/Makefile b/Makefile index bb0c1fc..0fc4b2e 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ dev: init ( \ . .venv/bin/activate; \ pip3 install -r dev-requirements.txt; \ + pip3 install mypy==0.790; \ ) @@ -41,6 +42,12 @@ lint: dev $(PYTHON) -m pylint -j 4 -r y forge; \ ) +type-check: + ( \ + . .venv/bin/activate; \ + $(PYTHON) -m mypy forge; \ + ) + build: ( \ . .venv/bin/activate; \ @@ -48,13 +55,7 @@ build: $(PYTHON) setup.py sdist bdist_wheel; \ ) -install: build - ( \ - . .venv/bin/activate; \ - pip3 install dist/tele_forge-${VERSION}-py3-none-any.whl; \ - ) - -test: lint +test: lint type-check ( \ . .venv/bin/activate; \ $(PYTHON) -m pytest -rf -vvv -x --count 5 --cov=forge --cov-fail-under=80 --cov-report term; \ @@ -62,7 +63,4 @@ test: lint clean: - rm -rf forge.egg-info/ build/ dist/ .venv/ venv/ **/__pycache__/ .pytest_cache/ .coverage - -type-check: - pytype *.py forge + rm -rf forge.egg-info/ build/ dist/ .venv/ venv/ **/__pycache__/ .pytest_cache/ .coverage \ No newline at end of file diff --git a/dev-requirements.txt b/dev-requirements.txt index 21a7877..17f9038 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -4,4 +4,4 @@ pytest-randomly==3.5.0 pytest-repeat==0.9.1 pytest-cov==2.10.1 mock==4.0.2 -twine==1.13.0 +twine==1.13.0 \ No newline at end of file diff --git a/forge/__init__.py b/forge/__init__.py index a05738e..c13245e 100755 --- a/forge/__init__.py +++ b/forge/__init__.py @@ -8,7 +8,7 @@ PluginManagementWarnException) -def main(): +def main() -> None: """ Error-handled entry point for cli entry point """ try: # Disable required here as context agrument is injected via a Click decorator diff --git a/forge/cli.py b/forge/cli.py index 86d53dd..0f5e8d5 100644 --- a/forge/cli.py +++ b/forge/cli.py @@ -1,6 +1,8 @@ """ Forge CLI """ +from typing import List + import click from forge import forge @@ -30,7 +32,7 @@ def forge_cli(context: click.Context) -> None: help='Source of plugin to install', metavar='PLUGIN_SOURCE', required=True) -def add_plugin(source: str, pipx_args) -> None: +def add_plugin(source: str, pipx_args: List[str]) -> None: """ Add plugin to Forge by providing source to be passed to PIPX """ install_to_pipx(source=source, extra_args=list(pipx_args)) @@ -42,7 +44,7 @@ def add_plugin(source: str, pipx_args) -> None: ) @click.argument('pipx_args', nargs=-1, type=click.UNPROCESSED) @click.option('-n', '--name', type=str, help='Name of plugin(s) to update', metavar='PLUGIN_NAME') -def update_plugin(name: str, pipx_args) -> None: +def update_plugin(name: str, pipx_args: List[str]) -> None: """ Update plugin(s) """ if name: if not name.startswith('forge-'): @@ -60,7 +62,7 @@ def update_plugin(name: str, pipx_args) -> None: ) @click.argument('pipx_args', nargs=-1, type=click.UNPROCESSED) @click.option('-n', '--name', type=str, help='Name of plugin(s) to remove', metavar='PLUGIN_NAME') -def remove_plugin(name: str, pipx_args) -> None: +def remove_plugin(name: str, pipx_args: List[str]) -> None: """ Remove plugin(s) """ if name: if not name.startswith('forge-'): diff --git a/forge/forge.py b/forge/forge.py index 0007390..c5cef45 100644 --- a/forge/forge.py +++ b/forge/forge.py @@ -3,7 +3,7 @@ import os from json import loads from pathlib import Path -from typing import Dict, List +from typing import Dict, List, Any from tabulate import tabulate from halo import Halo @@ -14,7 +14,7 @@ PLUGIN_PATH = os.path.join(FORGE_PATH, 'venvs') -def get_plugin_paths(): +def get_plugin_paths() -> List[str]: """ Fet paths of installed plugins """ paths = [] for venv in os.listdir(PLUGIN_PATH): @@ -22,7 +22,7 @@ def get_plugin_paths(): return paths -def get_pipx_config(plugin_path: str) -> Dict: +def get_pipx_config(plugin_path: str) -> Dict[Any, Any]: """ Return config data from pipx venv metadata file """ config_file_path = os.path.join(plugin_path, 'pipx_metadata.json') try: @@ -49,10 +49,10 @@ def filter_forge_plugins(plugin_configs: List[Dict]) -> List[Dict]: def get_command_from_config(plugin_config: Dict) -> str: """ Gets the plugin command from its config """ - return plugin_config['main_package']['apps'][0].replace('.exe', '') + return str(plugin_config['main_package']['apps'][0].replace('.exe', '')) -def get_plugins(): +def get_plugins() -> List[Dict]: """ Get installed forge plugins """ plugin_configs = [] for plugin_path in get_plugin_paths(): @@ -61,7 +61,7 @@ def get_plugins(): return filter_forge_plugins(plugin_configs=plugin_configs) -def list_plugins(): +def list_plugins() -> None: """ List installed forge plugins """ tabulated_data = [ (get_command_from_config(config), config['main_package']['package_version']) diff --git a/forge/pipx_wrapper.py b/forge/pipx_wrapper.py index 6dbe8d5..791dbdd 100644 --- a/forge/pipx_wrapper.py +++ b/forge/pipx_wrapper.py @@ -41,7 +41,7 @@ def run_command(command: List[str]) -> Tuple[str, str]: raise PluginManagementFatalException(err) from None -def update_pipx(name: str, extra_args: List[str]): +def update_pipx(name: str, extra_args: List[str]) -> None: """ Installs a plugin to pipx """ command = f'pipx upgrade {name} --verbose' pretty_name = name.replace('forge-', '', 1) @@ -122,6 +122,6 @@ def _extract_update_details(pipx_output: str) -> str: match = re.findall(r'(.*forge-.*)\(', pipx_output) if match: - return match[-1].strip().replace('forge-', '', 1) + return str(match[-1].strip().replace('forge-', '', 1)) raise PluginManagementFatalException('Failed to find update information from update log!') diff --git a/setup.cfg b/setup.cfg index b579934..7435177 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,11 @@ -[pytype] -inputs = forge \ No newline at end of file +[mypy] +warn_redundant_casts = True +warn_unused_ignores = True +disallow_subclassing_any = False +ignore_missing_imports = True + +disallow_untyped_calls = True +disallow_untyped_defs = True +check_untyped_defs = True +no_implicit_optional = True +strict_optional = True \ No newline at end of file From 58cb560c069eaf246495fc1fe471363328c6e3f8 Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Thu, 31 Dec 2020 16:31:52 -0500 Subject: [PATCH 14/17] added ability to actually run forge plugins --- Makefile | 2 +- forge/__init__.py | 4 +- forge/cli.py | 101 +++++++++++++++++++++++++++++-------- forge/pipx_wrapper.py | 8 +-- tests/cli_test.py | 61 +++++++++++++++++++--- tests/main_test.py | 8 ++- tests/pipx_wrapper_test.py | 6 +-- 7 files changed, 149 insertions(+), 41 deletions(-) diff --git a/Makefile b/Makefile index 0fc4b2e..e268013 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,7 @@ build: test: lint type-check ( \ . .venv/bin/activate; \ - $(PYTHON) -m pytest -rf -vvv -x --count 5 --cov=forge --cov-fail-under=80 --cov-report term; \ + $(PYTHON) -m pytest -rf -vvv -x --count 5 --cov=forge --cov-fail-under=80 --cov-report term-missing; \ ) diff --git a/forge/__init__.py b/forge/__init__.py index c13245e..665c89b 100755 --- a/forge/__init__.py +++ b/forge/__init__.py @@ -3,7 +3,7 @@ import sys -from forge.cli import forge_cli +from forge.cli import forge_cli, inject_forge_plugins from forge.exceptions import (PluginManagementFatalException, PluginManagementWarnException) @@ -11,7 +11,7 @@ def main() -> None: """ Error-handled entry point for cli entry point """ try: - # Disable required here as context agrument is injected via a Click decorator + inject_forge_plugins() forge_cli() # pylint: disable=no-value-for-parameter except PluginManagementFatalException: sys.exit(1) diff --git a/forge/cli.py b/forge/cli.py index 0f5e8d5..9b44c70 100644 --- a/forge/cli.py +++ b/forge/cli.py @@ -1,31 +1,55 @@ """ Forge CLI """ - from typing import List - +from subprocess import PIPE, Popen +import sys import click from forge import forge from .pipx_wrapper import install_to_pipx, uninstall_from_pipx, update_pipx -CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) +CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'], + ignore_unknown_options=True, + allow_extra_args=True) + + +def print_cmd_help(ctx: click.Context, param, value) -> None: # type: ignore # pylint: disable=unused-argument + """ Command help handler for dealing with nested commands """ + group = ctx.command + parser = group.make_parser(ctx) + global_opts, args, _ = parser.parse_args(args=sys.argv[1:]) + if global_opts.get("help") is True: + click.echo(ctx.get_help()) + ctx.exit() + + if args: + name, cmd, args = group.resolve_command(ctx, args) # type: ignore + help_names = cmd.get_help_option_names(ctx) + + if (set(args) & help_names) and name not in ('list', 'update', 'remove'): + if name in get_forge_plugin_command_names(): + run_forge_plugin([name, '-h']) + else: + cmd_ctx = click.Context(cmd, info_name=cmd.name, parent=ctx) + click.echo(cmd_ctx.get_help()) + cmd_ctx.exit() @click.group(invoke_without_command=True, context_settings=CONTEXT_SETTINGS) -@click.pass_context -# @click.option('command') -def forge_cli(context: click.Context) -> None: +@click.option( + "-h", + "--help", + callback=print_cmd_help, + is_flag=True +) +def forge_cli(help: str) -> None: # pylint: disable=redefined-builtin, unused-argument """ Command Line Interface for Forge """ - if context.invoked_subcommand is None: + if click.get_current_context().invoked_subcommand is None: forge.list_plugins() -@forge_cli.command(name='add', - context_settings=dict( - ignore_unknown_options=True, - allow_extra_args=True) - ) +@forge_cli.command(name='add') @click.argument('pipx_args', nargs=-1, type=click.UNPROCESSED) @click.option('-s', '--source', type=str, @@ -37,11 +61,7 @@ def add_plugin(source: str, pipx_args: List[str]) -> None: install_to_pipx(source=source, extra_args=list(pipx_args)) -@forge_cli.command(name='update', - context_settings=dict( - ignore_unknown_options=True, - allow_extra_args=True) - ) +@forge_cli.command(name='update') @click.argument('pipx_args', nargs=-1, type=click.UNPROCESSED) @click.option('-n', '--name', type=str, help='Name of plugin(s) to update', metavar='PLUGIN_NAME') def update_plugin(name: str, pipx_args: List[str]) -> None: @@ -55,11 +75,7 @@ def update_plugin(name: str, pipx_args: List[str]) -> None: update_pipx(name=plugin['main_package']['package'], extra_args=list(pipx_args)) -@forge_cli.command(name='remove', - context_settings=dict( - ignore_unknown_options=True, - allow_extra_args=True) - ) +@forge_cli.command(name='remove') @click.argument('pipx_args', nargs=-1, type=click.UNPROCESSED) @click.option('-n', '--name', type=str, help='Name of plugin(s) to remove', metavar='PLUGIN_NAME') def remove_plugin(name: str, pipx_args: List[str]) -> None: @@ -80,3 +96,44 @@ def remove_plugin(name: str, pipx_args: List[str]) -> None: def list_forge_plugins() -> None: """ List installed Forge plugins """ forge.list_plugins() + + +def run_forge_plugin(command: List[str]) -> None: + """ Forge Plugin """ + process = Popen(command, stdout=PIPE, stderr=PIPE) + + stdout, stderr = process.communicate() + if stdout: + click.echo(stdout.decode()) + + if stderr: + click.echo(stderr.decode()) + + raise SystemExit(process.returncode) + + +def bind_plugin_command(plugin_name: str) -> None: + """ Binds plugin command to click cli """ + @forge_cli.command(name=plugin_name, + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True + )) # pylint: disable=unused-variable + def command() -> None: + """ Plugin Entrypoint""" + args = sys.argv[2:] if len(sys.argv) > 2 else [] + run_forge_plugin([sys.argv[1]] + args) + + +def get_forge_plugin_command_names() -> List[str]: + """ Returns a list of plugin command names""" + return [ + forge.get_command_from_config(plugin_config) + for plugin_config in forge.get_plugins() + ] + + +def inject_forge_plugins() -> None: + """ Inject all plugin command names into the click CLI """ + for command in get_forge_plugin_command_names(): + bind_plugin_command(plugin_name=command) diff --git a/forge/pipx_wrapper.py b/forge/pipx_wrapper.py index 791dbdd..a0e1d00 100644 --- a/forge/pipx_wrapper.py +++ b/forge/pipx_wrapper.py @@ -32,7 +32,7 @@ def run_command(command: List[str]) -> Tuple[str, str]: stdout, stderr = process.communicate() - if process.returncode: + if process.returncode and '(_symlink_package_apps:95): Same path' not in stderr.decode(): raise PluginManagementFatalException(stderr.decode()) return stdout.decode(), stderr.decode() @@ -59,7 +59,7 @@ def update_pipx(name: str, extra_args: List[str]) -> None: spinner.succeed(update_message) except PluginManagementFatalException as err: - spinner.fail(str(err)) + spinner.fail(f'Something went wrong!\n{str(err)}') raise PluginManagementFatalException from None @@ -80,7 +80,7 @@ def install_to_pipx(source: str, extra_args: List[str]) -> None: spinner.succeed(f'Installed plugin: [{plugin_name}] [{package}] [{python_version}]!') except PluginManagementFatalException as err: - spinner.fail(str(err)) + spinner.fail(f'Something went wrong!\n{str(err)}') raise PluginManagementFatalException from None @@ -101,7 +101,7 @@ def uninstall_from_pipx(plugin_name: str, extra_args: List[str]) -> str: return plugin_name except PluginManagementFatalException as err: - spinner.fail(str(err)) + spinner.fail(f'Something went wrong!\n{str(err)}') raise PluginManagementFatalException from None diff --git a/tests/cli_test.py b/tests/cli_test.py index 292b39b..289938e 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -1,5 +1,5 @@ import pytest -from forge.cli import forge_cli +from forge.cli import forge_cli, run_forge_plugin, inject_forge_plugins from mock import call, patch @@ -67,7 +67,7 @@ def test_cli_update_all_since_no_name_given(mock_get_plugins, mock_pipx_update): mock_args = 'forge update'.split() with patch('sys.argv', mock_args), pytest.raises(SystemExit): forge_cli() - mock_pipx_update.mock_calls = [ + assert mock_pipx_update.mock_calls == [ call(name='forge-plugin1', extra_args=[]), call(name='forge-plugin2', extra_args=[]) ] @@ -83,7 +83,7 @@ def test_cli_update_all_since_no_name_given_with_extra_args(mock_get_plugins, mo mock_args = 'forge update arg1 val1'.split() with patch('sys.argv', mock_args), pytest.raises(SystemExit): forge_cli() - mock_pipx_update.mock_calls = [ + assert mock_pipx_update.mock_calls == [ call(name='forge-plugin1', extra_args=['arg1', 'val1']), call(name='forge-plugin2', extra_args=['arg1', 'val1']) ] @@ -110,7 +110,7 @@ def test_cli_remove_with_extra_args(uninstall_from_pipx): @patch('forge.cli.uninstall_from_pipx') @patch('forge.forge.get_plugins') -def test_cli_remove_all_since_no_name_given(mock_get_plugins, uninstall_from_pipx): +def test_cli_remove_all_since_no_name_given(mock_get_plugins, mocked_uninstall_from_pipx): mock_get_plugins.return_value = [ {"main_package": {"package": "forge-plugin1"}}, {"main_package": {"package": "forge-plugin2"}} @@ -118,7 +118,7 @@ def test_cli_remove_all_since_no_name_given(mock_get_plugins, uninstall_from_pip mock_args = 'forge remove'.split() with patch('sys.argv', mock_args), pytest.raises(SystemExit): forge_cli() - uninstall_from_pipx.mock_calls = [ + assert mocked_uninstall_from_pipx.mock_calls == [ call(plugin_name='forge-plugin1', extra_args=[]), call(plugin_name='forge-plugin2', extra_args=[]) ] @@ -126,7 +126,7 @@ def test_cli_remove_all_since_no_name_given(mock_get_plugins, uninstall_from_pip @patch('forge.cli.uninstall_from_pipx') @patch('forge.forge.get_plugins') -def test_cli_remove_all_since_no_name_given_with_extra_args(mock_get_plugins, uninstall_from_pipx): +def test_cli_remove_all_since_no_name_given_with_extra_args(mock_get_plugins, mocked_uninstall_from_pipx): mock_get_plugins.return_value = [ {"main_package": {"package": "forge-plugin1"}}, {"main_package": {"package": "forge-plugin2"}} @@ -134,7 +134,54 @@ def test_cli_remove_all_since_no_name_given_with_extra_args(mock_get_plugins, un mock_args = 'forge remove arg1 val1'.split() with patch('sys.argv', mock_args), pytest.raises(SystemExit): forge_cli() - uninstall_from_pipx.mock_calls = [ + assert mocked_uninstall_from_pipx.mock_calls == [ call(plugin_name='forge-plugin1', extra_args=['arg1', 'val1']), call(plugin_name='forge-plugin2', extra_args=['arg1', 'val1']) ] + + +@patch('click.echo') +def test_cli_help_forge(mock_echo): + + mock_args = 'forge -h'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + + mock_echo.assert_called_once() + assert str(mock_echo.mock_calls[0].args[0]).split('\n')[0] == 'Usage: forge [OPTIONS] COMMAND [ARGS]...' + + +@patch('forge.cli.get_forge_plugin_command_names') +@patch('click.echo') +def test_cli_help_forge_core_command(mock_echo, mock_command_names): + + mock_args = 'forge add -h'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + forge_cli() + + mock_echo.assert_called_once() + assert str(mock_echo.mock_calls[0].args[0]).split('\n')[0] == 'Usage: forge add [OPTIONS] [PIPX_ARGS]...' + + +@patch('forge.cli.Popen') +@patch('click.echo') +def test_run_forge_plugin(mock_echo, mock_popen): + mock_process = mock_popen.return_value + mock_process.returncode = 0 + mock_process.communicate.return_value = (b'stdout', b'stderr') + + with pytest.raises(SystemExit): + run_forge_plugin(['ls']) + assert mock_echo.mock_calls == [call('stdout'), call('stderr')] + + +@patch('forge.cli.get_forge_plugin_command_names', return_value=['plugin1']) +@patch('forge.cli.run_forge_plugin') +def test_cli_help_forge_plugin_command(mock_run_forge_plugin, mock_command_names): + + mock_args = 'forge plugin1 -h'.split() + with patch('sys.argv', mock_args), pytest.raises(SystemExit): + inject_forge_plugins() + forge_cli() + + mock_run_forge_plugin.assert_called_once_with(['plugin1', '-h']) diff --git a/tests/main_test.py b/tests/main_test.py index c92e7f3..167d2b5 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -5,8 +5,9 @@ from mock import patch +@patch('forge.inject_forge_plugins') @patch('forge.forge_cli') -def test_main_fatal_exception(mock_cli): +def test_main_fatal_exception(mock_cli, mock_inject): mock_cli.side_effect = PluginManagementFatalException('some fatal message') @@ -14,11 +15,13 @@ def test_main_fatal_exception(mock_cli): forge.main() mock_cli.assert_called_once() + mock_inject.assert_called_once() assert str(err.value) == '1' +@patch('forge.inject_forge_plugins') @patch('forge.forge_cli') -def test_main_warning_exception(mock_cli): +def test_main_warning_exception(mock_cli, mock_inject): mock_cli.side_effect = PluginManagementWarnException('some fatal message') @@ -26,4 +29,5 @@ def test_main_warning_exception(mock_cli): forge.main() mock_cli.assert_called_once() + mock_inject.assert_called_once() assert str(err.value) == '0' diff --git a/tests/pipx_wrapper_test.py b/tests/pipx_wrapper_test.py index 0dce530..347b470 100644 --- a/tests/pipx_wrapper_test.py +++ b/tests/pipx_wrapper_test.py @@ -54,7 +54,7 @@ def test_update_pipx_fail_no_update_message_matched(mock_run_command, mock_spinn with pytest.raises(PluginManagementFatalException): pipx_wrapper.update_pipx('forge-plugin-name', []) - assert mock_spinner.mock_calls[2] == call().__enter__().fail('Failed to find update information from update log!') + assert mock_spinner.mock_calls[2] == call().__enter__().fail('Something went wrong!\nFailed to find update information from update log!') @patch('forge.pipx_wrapper.Halo') @@ -104,7 +104,7 @@ def test_install_pipx_fail_no_install_message_matched(mock_run_command, mock_spi with pytest.raises(PluginManagementFatalException): pipx_wrapper.install_to_pipx('forge-plugin-name', []) - assert mock_spinner.mock_calls[2] == call().__enter__().fail('Failed to find package information install log!') + assert mock_spinner.mock_calls[2] == call().__enter__().fail('Something went wrong!\nFailed to find package information install log!') @patch('forge.pipx_wrapper.Halo') @@ -136,5 +136,5 @@ def test_uninstall_from_pipx_fail_some_pipx_exception(mock_popen, mock_spinner): pipx_wrapper.uninstall_from_pipx('forge-plugin-name', []) assert mock_spinner.mock_calls[2] == call().__enter__().fail( - "Command 'pipx uninstall forge-plugin-name --verbose' returned non-zero exit status 1." + "Something went wrong!\nCommand 'pipx uninstall forge-plugin-name --verbose' returned non-zero exit status 1." ) From f92801a2f5880a459d3db22e6d3a6723b50eed44 Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Mon, 4 Jan 2021 08:07:10 -0500 Subject: [PATCH 15/17] refactored get_forge_plugin_command_names from cli to forge --- forge/cli.py | 12 ++---------- forge/forge.py | 8 ++++++++ tests/cli_test.py | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/forge/cli.py b/forge/cli.py index 9b44c70..47b0369 100644 --- a/forge/cli.py +++ b/forge/cli.py @@ -28,7 +28,7 @@ def print_cmd_help(ctx: click.Context, param, value) -> None: # type: ignore # help_names = cmd.get_help_option_names(ctx) if (set(args) & help_names) and name not in ('list', 'update', 'remove'): - if name in get_forge_plugin_command_names(): + if name in forge.get_forge_plugin_command_names(): run_forge_plugin([name, '-h']) else: cmd_ctx = click.Context(cmd, info_name=cmd.name, parent=ctx) @@ -125,15 +125,7 @@ def command() -> None: run_forge_plugin([sys.argv[1]] + args) -def get_forge_plugin_command_names() -> List[str]: - """ Returns a list of plugin command names""" - return [ - forge.get_command_from_config(plugin_config) - for plugin_config in forge.get_plugins() - ] - - def inject_forge_plugins() -> None: """ Inject all plugin command names into the click CLI """ - for command in get_forge_plugin_command_names(): + for command in forge.get_forge_plugin_command_names(): bind_plugin_command(plugin_name=command) diff --git a/forge/forge.py b/forge/forge.py index c5cef45..ce7fdbf 100644 --- a/forge/forge.py +++ b/forge/forge.py @@ -22,6 +22,14 @@ def get_plugin_paths() -> List[str]: return paths +def get_forge_plugin_command_names() -> List[str]: + """ Returns a list of plugin command names""" + return [ + get_command_from_config(plugin_config) + for plugin_config in get_plugins() + ] + + def get_pipx_config(plugin_path: str) -> Dict[Any, Any]: """ Return config data from pipx venv metadata file """ config_file_path = os.path.join(plugin_path, 'pipx_metadata.json') diff --git a/tests/cli_test.py b/tests/cli_test.py index 289938e..2d29c91 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -151,7 +151,7 @@ def test_cli_help_forge(mock_echo): assert str(mock_echo.mock_calls[0].args[0]).split('\n')[0] == 'Usage: forge [OPTIONS] COMMAND [ARGS]...' -@patch('forge.cli.get_forge_plugin_command_names') +@patch('forge.forge.get_forge_plugin_command_names') @patch('click.echo') def test_cli_help_forge_core_command(mock_echo, mock_command_names): @@ -175,7 +175,7 @@ def test_run_forge_plugin(mock_echo, mock_popen): assert mock_echo.mock_calls == [call('stdout'), call('stderr')] -@patch('forge.cli.get_forge_plugin_command_names', return_value=['plugin1']) +@patch('forge.forge.get_forge_plugin_command_names', return_value=['plugin1']) @patch('forge.cli.run_forge_plugin') def test_cli_help_forge_plugin_command(mock_run_forge_plugin, mock_command_names): From 142aa700aac456404c69e12c9d40ff3dfe31fabc Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Mon, 4 Jan 2021 09:26:53 -0500 Subject: [PATCH 16/17] added additional testing --- forge/pipx_wrapper.py | 16 +++++++++++++++- tests/pipx_wrapper_test.py | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/forge/pipx_wrapper.py b/forge/pipx_wrapper.py index a0e1d00..124d7ea 100644 --- a/forge/pipx_wrapper.py +++ b/forge/pipx_wrapper.py @@ -25,6 +25,20 @@ def make_spinner(text: str) -> Halo: ) +def determine_is_fatal_error(current_error_message: str) -> bool: + """ Determine if a pipx error message is truly fatal """ + acceptable_error_messages = [ + '(_copy_package_apps:66): Overwriting file', + '(_symlink_package_apps:95): Same path' + ] + + for message in acceptable_error_messages: + if message in current_error_message: + return False + + return True + + def run_command(command: List[str]) -> Tuple[str, str]: """ Wrapper to simplify handling subprocess commands """ try: @@ -32,7 +46,7 @@ def run_command(command: List[str]) -> Tuple[str, str]: stdout, stderr = process.communicate() - if process.returncode and '(_symlink_package_apps:95): Same path' not in stderr.decode(): + if process.returncode and determine_is_fatal_error(current_error_message=stderr.decode()): raise PluginManagementFatalException(stderr.decode()) return stdout.decode(), stderr.decode() diff --git a/tests/pipx_wrapper_test.py b/tests/pipx_wrapper_test.py index 347b470..714881c 100644 --- a/tests/pipx_wrapper_test.py +++ b/tests/pipx_wrapper_test.py @@ -138,3 +138,24 @@ def test_uninstall_from_pipx_fail_some_pipx_exception(mock_popen, mock_spinner): assert mock_spinner.mock_calls[2] == call().__enter__().fail( "Something went wrong!\nCommand 'pipx uninstall forge-plugin-name --verbose' returned non-zero exit status 1." ) + + +@pytest.mark.parametrize("error_message,expected_result", [ + (""" + yada + (_symlink_package_apps:95): Same path + yada + """, False), + (""" + yada + (_copy_package_apps:66): Overwriting file + yada + """, False), + (""" + yada + some none error log message + yada + """, True) +]) +def test_determine_is_fatal_error(error_message, expected_result): + assert pipx_wrapper.determine_is_fatal_error(error_message) == expected_result From ab262a2fd350ab29eeb60d7f13688bacf42e4249 Mon Sep 17 00:00:00 2001 From: Morgan Szafranski Date: Wed, 6 Jan 2021 08:02:10 -0500 Subject: [PATCH 17/17] readme changes --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 39ff384..d68d1bf 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ python -m pipx ensurepath --force ### **Via PyPI** ```shell -$ pip3 install tele-forge +$ pipx install tele-forge ``` ### **From VCS** @@ -66,6 +66,8 @@ $ pipx install git+ssh://git@github.com:TeleTrackingTechnologies/forge.git ```shell $ pipx install . +# OR as editable for developing forge changes +$ pipx install -e . ``` ---