diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 21b42a5..ecb11e9 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -11,10 +11,21 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.7, 3.8] + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: [3.7, 3.8, 3.9] + 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 @@ -22,18 +33,11 @@ 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: Test with pytest - run: | - pip install pytest - pytest + - 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 }} diff --git a/.gitignore b/.gitignore index 89f4adf..4f832b7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Development Directories .venv +venv .pytype # Build Info *.egg-info @@ -15,3 +16,7 @@ __pycache__ # OS generated files # .DS_Store + +coverage.xml +.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..e268013 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,5 @@ IMAGE_NAME='Forge' -VERSION=1.0.0 -PYTHON=python3.7 +PYTHON=python .DEFAULT: help @@ -22,35 +21,46 @@ help: init: rm -rf .venv - $(PYTHON) -m pip install virtualenv + pip3 install virtualenv virtualenv --python=$(PYTHON) --always-copy .venv ( \ . .venv/bin/activate; \ pip3 install -r requirements.txt; \ ) -lint: +dev: init ( \ . .venv/bin/activate; \ - pylint -j 4 --rcfile=pylintrc forge; \ + pip3 install -r dev-requirements.txt; \ + pip3 install mypy==0.790; \ + ) + + +lint: dev + ( \ + . .venv/bin/activate; \ + $(PYTHON) -m pylint -j 4 -r y forge; \ + ) + +type-check: + ( \ + . .venv/bin/activate; \ + $(PYTHON) -m mypy forge; \ ) build: ( \ - $(PYTHON) -m pip install --upgrade setuptools wheel; \ + . .venv/bin/activate; \ + pip3 install --upgrade setuptools wheel; \ $(PYTHON) setup.py sdist bdist_wheel; \ ) -install: build +test: lint type-check ( \ - $(PYTHON) -m pip install dist/tele_forge-${VERSION}-py3-none-any.whl; \ - ) + . .venv/bin/activate; \ + $(PYTHON) -m pytest -rf -vvv -x --count 5 --cov=forge --cov-fail-under=80 --cov-report term-missing; \ + ) -test: - $(PYTHON) -m unittest discover -s forge -p '*_test.py' clean: - rm -rf forge.egg-info/ build/ dist/ .venv/ - -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/README.md b/README.md index 30a7120..d68d1bf 100644 --- a/README.md +++ b/README.md @@ -1,97 +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]) +- Forge is a python package that utilizes many Python dependencies. +- Forge and its plugins uses pipx to isolate dependencies and make self contained globally available executables. -def helptext(): - return 'echoes the provided string' +- 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.) +- You will also need access to `git`. -def register(app): - app.register_plugin('echo', execute, helptext()) -``` +### **Unix** -## Pre-Requisites and Virtual Environments -Forge is a python package that utilizes many Python dependencies. +```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 +``` -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. +> **IMPORTANT:** Now reboot/logout to gain access to `pipx` -For those not familiar with using a virtual environment, the ability to initialize a simple one is provided within the Makefile of this repository. +### **Windows** -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.) +Go to: https://gitforwindows.org/, then download and install latest git build -In order to activate and use the included virtual environment, proceed with the following steps: +In PowerShell, running as administartor, run the following: -## Unix -``` -$ make init -$ . .venv/bin/activate +```shell +python -m pip install --user pipx +$env:PIPX_HOME="~/.forge" +$env:PIPX_BIN_DIR="~/.forge/bin" +python -m pipx ensurepath --force ``` -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/). +> **IMPORTANT:** Now reboot/logout to gain access to `pipx` -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 -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** -``` -$ make install +```shell +$ pipx install tele-forge ``` -To verify your installation was successful: -``` -$ which forge -``` -should return the installed location of forge and: -``` -$ forge +### **From VCS** + +```shell +$ pipx install git+ssh://git@github.com:TeleTrackingTechnologies/forge.git ``` -should return the simple help interface. -### Via PyPI +### **From Source (after cloning)** -``` -$ pip3 install tele-forge +```shell +$ pipx install . +# OR as editable for developing forge changes +$ pipx install -e . ``` -To verify your installation was successful: -``` +--- + +## Post Install + +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. - +should return the simple help interface. +--- ## Usage + ``` forge [plugin-arguments] ``` diff --git a/bin/forge b/bin/forge deleted file mode 100644 index e4ce407..0000000 --- a/bin/forge +++ /dev/null @@ -1,6 +0,0 @@ -#! /usr/bin/env python3 - -import sys -import forge - -forge.main(sys.argv[1:]) \ No newline at end of file diff --git a/bin/forge.bat b/bin/forge.bat deleted file mode 100644 index 0da7e25..0000000 --- a/bin/forge.bat +++ /dev/null @@ -1,2 +0,0 @@ -@Echo off -python %~dp0forge diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..17f9038 --- /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 \ No newline at end of file diff --git a/forge/__init__.py b/forge/__init__.py index 93484a7..665c89b 100755 --- a/forge/__init__.py +++ b/forge/__init__.py @@ -1,77 +1,19 @@ -""" Forge """ -#! /usr/bin/env python3 -import sys -import os -from pathlib import Path -from typing import List, Any -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() - self.name = name - self.registry = {} - - self.plugins = [ - INTERNAL_PLUGIN_PATH, - config_handler.get_plugin_install_location() - ] + config_handler.get_plugins() +""" Entrypoint for Forge """ - self.plugin_source = PLUGIN_BASE.make_plugin_source( - searchpath=self.plugins, - identifier=self.name) - for plugin_name in self.plugin_source.list_plugins(): - plugin = self.plugin_source.load_plugin(plugin_name) - if callable(getattr(plugin, "register", None)): - 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 """ - help_entries = [] - for name in self.registry: - help_entries.append([name, self.registry[name][1]]) - print(tabulate(help_entries, ['function', 'blurb'])) - - def execute(self, command: str, args: Any) -> None: - """ Execute Plugin """ - if command == 'help': - self.print_help() - else: - self.registry[command][0](args) +import sys -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:]) - else: - Application( - 'forge', - ConfigHandler( - home_dir_path=CONF_HOME, - file_path_dir=CONFIG_FILE_PATH - ) - ).execute('help', None) +from forge.cli import forge_cli, inject_forge_plugins +from forge.exceptions import (PluginManagementFatalException, + PluginManagementWarnException) -if __name__ == '__main__': - main(sys.argv[1:]) +def main() -> None: + """ Error-handled entry point for cli entry point """ + try: + inject_forge_plugins() + 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 9252b74..0000000 --- a/forge/_internal_plugins/manage_plugins/manage_plugins.py +++ /dev/null @@ -1,26 +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(PluginPuller(config_handler), config_handler) - manage_plugins_logic.execute(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('manage-plugins', execute, 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/manage_plugins.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py deleted file mode 100644 index ad03e0a..0000000 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/manage_plugins.py +++ /dev/null @@ -1,212 +0,0 @@ -""" Manage Plugins Internally """ -import argparse -import sys -import itertools -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 -from forge.config.config_handler import ConfigHandler - - - -class ManagePlugins: - """ Manage Plugins """ - def __init__(self, plugin_puller: PluginPuller, config_handler: ConfigHandler) -> None: - self.arg_parser = self.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) - - 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) - - @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') - - @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 - - 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: - print(Fore.RED + '\n' + - 'Please provide an action with -a, -u or -i') - sys.exit(1) - - @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) - - def _do_add(self, args: argparse.Namespace, spinner: Process) -> None: - 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 - ) - 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: - 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 - ) - 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!') 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 deleted file mode 100644 index f007594..0000000 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/plugin_puller.py +++ /dev/null @@ -1,27 +0,0 @@ -""" Plugin Puller """ -import subprocess -from pathlib import Path -from git import Repo -from git import Git -from colorama import Fore - -class PluginPuller: - """ Plugin Puller Class Def """ - def __init__(self, config_handler): - self.config_handler = config_handler - - 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 - - - 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/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/__init__.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/stub_config_parser.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/stub_config_parser.py deleted file mode 100644 index 7f61968..0000000 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/stub_config_parser.py +++ /dev/null @@ -1,13 +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 [('some_name', 'some_url')] diff --git a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/stub_plugin_puller.py b/forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/stub_plugin_puller.py deleted file mode 100644 index 454175b..0000000 --- a/forge/_internal_plugins/manage_plugins/manage_plugins_logic/test_stubs/stub_plugin_puller.py +++ /dev/null @@ -1,29 +0,0 @@ -# pylint: disable=all -from git import GitCommandError -from ..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) diff --git a/forge/cli.py b/forge/cli.py new file mode 100644 index 0000000..47b0369 --- /dev/null +++ b/forge/cli.py @@ -0,0 +1,131 @@ +""" 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'], + 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 forge.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.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 click.get_current_context().invoked_subcommand is None: + forge.list_plugins() + + +@forge_cli.command(name='add') +@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: 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)) + + +@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: + """ 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') +@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: + """ 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() + + +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 inject_forge_plugins() -> None: + """ Inject all plugin command names into the click CLI """ + for command in forge.get_forge_plugin_command_names(): + bind_plugin_command(plugin_name=command) 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 a8b0aaf..0000000 --- a/forge/config/config_handler.py +++ /dev/null @@ -1,65 +0,0 @@ -""" Handles operations on Forge conf.ini """ -import os -import configparser -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 """ - 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""" - if not os.path.exists(self.home_dir_path): - os.makedirs(self.home_dir_path) - - 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/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/forge/exceptions.py b/forge/exceptions.py new file mode 100644 index 0000000..2c53b19 --- /dev/null +++ b/forge/exceptions.py @@ -0,0 +1,9 @@ +""" Plugin Mangagement Exceptions """ + + +class PluginManagementFatalException(Exception): + """ Exception class for raising during fatal issues """ + + +class PluginManagementWarnException(Exception): + """ Exception class for raising during warning issues """ diff --git a/forge/forge.py b/forge/forge.py new file mode 100644 index 0000000..ce7fdbf --- /dev/null +++ b/forge/forge.py @@ -0,0 +1,81 @@ +""" Forge """ + +import os +from json import loads +from pathlib import Path +from typing import Dict, List, Any + +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() -> List[str]: + """ 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_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') + 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 str(plugin_config['main_package']['apps'][0].replace('.exe', '')) + + +def get_plugins() -> List[Dict]: + """ 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() -> None: + """ 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..124d7ea --- /dev/null +++ b/forge/pipx_wrapper.py @@ -0,0 +1,141 @@ +""" 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 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: + process = Popen(command, stdout=PIPE, stderr=PIPE) + + stdout, stderr = process.communicate() + + if process.returncode and determine_is_fatal_error(current_error_message=stderr.decode()): + 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]) -> None: + """ 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(f'Something went wrong!\n{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(f'Something went wrong!\n{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(f'Something went wrong!\n{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 str(match[-1].strip().replace('forge-', '', 1)) + + raise PluginManagementFatalException('Failed to find update information from update log!') 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.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 diff --git a/setup.py b/setup.py index 755b51e..6656821 100644 --- a/setup.py +++ b/setup.py @@ -1,33 +1,38 @@ """ Setup """ from setuptools import setup -setup(name='tele-forge', - version='1.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', - 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', - 'GitPython', - 'pluginbase', - 'requests', - 'tabulate' - ], - 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 new file mode 100644 index 0000000..5ee70ef --- /dev/null +++ b/test.ps1 @@ -0,0 +1,11 @@ +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) { deactivate; exit $LastExitCode } +python -m pytest -rf -vvv -x --count 5 --cov=forge --cov-fail-under=80 --cov-report term +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..2d29c91 --- /dev/null +++ b/tests/cli_test.py @@ -0,0 +1,187 @@ +import pytest +from forge.cli import forge_cli, run_forge_plugin, inject_forge_plugins +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() + assert 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() + assert 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, mocked_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() + assert mocked_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, mocked_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() + 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.forge.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.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): + + 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/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_test.py b/tests/main_test.py new file mode 100644 index 0000000..167d2b5 --- /dev/null +++ b/tests/main_test.py @@ -0,0 +1,33 @@ +import forge +import pytest +from forge.exceptions import (PluginManagementFatalException, + PluginManagementWarnException) +from mock import patch + + +@patch('forge.inject_forge_plugins') +@patch('forge.forge_cli') +def test_main_fatal_exception(mock_cli, mock_inject): + + mock_cli.side_effect = PluginManagementFatalException('some fatal message') + + with pytest.raises(SystemExit) as err: + 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, mock_inject): + + mock_cli.side_effect = PluginManagementWarnException('some fatal message') + + with pytest.raises(SystemExit) as err: + 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 new file mode 100644 index 0000000..714881c --- /dev/null +++ b/tests/pipx_wrapper_test.py @@ -0,0 +1,161 @@ +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('Something went wrong!\nFailed 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('Something went wrong!\nFailed 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( + "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