diff --git a/.env b/.env index 4a0a411..5e80cea 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ -PYTHONPATH=$PYTHONPATH:pype:example_pypes +PYTHONPATH=$PYTHONPATH:pype PYPE_CONFIG_FOLDER=.venv/.pype-cli diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d0c3ea8..be210a5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,12 +9,26 @@ on: jobs: build: runs-on: ubuntu-latest - + strategy: + matrix: + # https://www.python.org/downloads/ + python-version: [ + "3.9", # EOL: 2025-10 + "3.10", # EOL: 2026-10 + "3.11", # EOL: 2027-10 + "3.12", # EOL: 2028-10 + "3.13", # EOL: 2029-10 + ] + name: ${{ matrix.python-version }} steps: - uses: actions/checkout@v2 - - uses: actions/setup-python@v1 + + - name: Setup python + uses: actions/setup-python@v3 + with: + python-version: "${{ matrix.python-version }}" - name: CI checks run: | - python3 -m pip install pipenv + python -m pip install pipenv make diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64598d7..a93031a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: - name: Release run: | - python3 -m pip install pipenv + python -m pip install pipenv TWINE_USERNAME="${{ secrets.TWINE_USERNAME }}" \ TWINE_PASSWORD="${{ secrets.TWINE_PASSWORD }}" \ make publish diff --git a/.vscode/settings.json b/.vscode/settings.json index d2e2a17..a88dd2b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,6 @@ "python.pythonPath": ".venv/bin/python", "python.analysis.extraPaths": [ "pypes", - "example_pypes", ".venv/bin/python" ], // PEP8 settings (https://www.python.org/dev/peps/pep-0008/) @@ -29,5 +28,6 @@ "python.testing.unittestEnabled": false, "python.testing.nosetestsEnabled": false, "python.testing.pytestEnabled": true, - "python.testing.pytestArgs": [] + "python.testing.pytestArgs": [], + "makefile.configureOnOpen": false } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a0bd1a..71edebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.8.0 + +- Removed assumptions about `python` versus `python3` installation. This simplifies the code and gives full control into the user's hand to determine the correct `python` through the path or tools such as `pyenv` +- Upgraded to Python 3.9..3.12 +- Using the minimal template for new pypes, e.g., `pype myplugin --create-pype my-pype --minimal` now creates a pype that can be executed independent from `pype-cli`, i.e., no built-in libraries are used +- `pype pype.config shell-install` produces more robust completion scripts +- Removal of all example pypes as they were outdated and misleading + ## 0.7.0 - Rename PypeException to PypeError diff --git a/Makefile b/Makefile index e311bff..62334e8 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,11 @@ # Required executables -ifeq (, $(shell which python3)) - $(error "No python3 on PATH.") +ifeq (, $(shell which python)) + $(error "No python on PATH.") endif -ifeq (, $(shell which pipenv)) - $(error "No pipenv on PATH.") +PIPENV_CMD := python -m pipenv +PIP_CMD := python -m pip +ifeq (, $(shell $(PIPENV_CMD) --version)) + $(error "No $(PIPENV_CMD) on PATH.") endif # Suppress warning if pipenv is started inside .venv @@ -20,23 +22,23 @@ export PYPE_CONFIG_FOLDER = $(shell pwd)/.venv/.pype-cli # Process variables LAST_VERSION := $(shell git tag | sort --version-sort -r | head -n1) VERSION_HASH := $(shell git show-ref -s $(LAST_VERSION)) -PY_FILES := setup.py pype tests example_pypes +PY_FILES := setup.py pype tests all: prepare build prepare: clean @echo Preparing virtual environment - pipenv install --dev + $(PIPENV_CMD) install --dev mkdir -p $(PYPE_CONFIG_FOLDER) echo "export PYPE_CONFIG_FOLDER=$(PYPE_CONFIG_FOLDER)" >> .venv/bin/activate build: test mypy isort lint @echo Run setup.py-based build process to package application - pipenv run python setup.py bdist_wheel + $(PIPENV_CMD) run python setup.py bdist_wheel shell: @echo Initialize virtualenv and open a new shell using it - pipenv shell + $(PIPENV_CMD) shell clean: @echo Clean project base @@ -59,36 +61,42 @@ clean: test: @echo Run all tests in default virtualenv - pipenv run py.test --verbose tests + $(PIPENV_CMD) run py.test --verbose tests isort: @echo Check for incorrectly sorted imports - pipenv run isort --check-only $(PY_FILES) + $(PIPENV_CMD) run isort --check-only $(PY_FILES) isort-apply: @echo Check for incorrectly sorted imports - pipenv run isort $(PY_FILES) + $(PIPENV_CMD) run isort $(PY_FILES) lint: @echo Run code formatting checks against source code base - pipenv run flake8 $(PY_FILES) + $(PIPENV_CMD) run flake8 $(PY_FILES) mypy: @echo Run static code checks against source code base - pipenv run mypy pype example_pypes tests + $(PIPENV_CMD) run mypy pype tests sys-info: @echo Print pype configuration within venv - pipenv run pype pype.config system-info + $(PIPENV_CMD) run pype pype.config system-info install-wheel: all @echo Install from wheel - pip3 install --force-reinstall dist/*.whl + $(PIP_CMD) install --force-reinstall dist/*.whl + +install-wheel-no-test: + @echo Install from wheel including rebuild but skipping tests + rm -rf dist/*.whl + $(PIPENV_CMD) run python setup.py bdist_wheel + $(PIP_CMD) install --force-reinstall dist/*.whl publish: all @echo Publish pype to pypi.org TWINE_USERNAME=$(TWINE_USERNAME) TWINE_PASSWORD=$(TWINE_PASSWORD) \ - pipenv run twine upload dist/* + $(PIPENV_CMD) run twine upload dist/* release: @echo Commit release - requires NEXT_VERSION to be set diff --git a/README.md b/README.md index 47d44f2..b9f34dd 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,19 @@ # pype-cli -> A command-line tool for command-line tools -pype-cli Logo +> A command-line tool for command-line tools ![pype-cli Logo](res/icon.png) -![GitHub Workflow Status](https://img.shields.io/github/workflow/status/BastiTee/pype-cli/CI) -![PyPU - Version](https://img.shields.io/pypi/v/pype-cli.svg) -![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pype-cli.svg) +![GitHub Workflow Status](https://img.shields.io/github/workflow/status/BastiTee/pype-cli/CI) ![PyPU - Version](https://img.shields.io/pypi/v/pype-cli.svg) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pype-cli.svg) ## In a nutshell -__pype-cli__ is a command-line tool to manage sets of other command-line tools. It simplifies the creation, orchestration and access of Python scripts that you require for your development work, process automation, etc. +**pype-cli** is a command-line tool to manage sets of other command-line tools. It simplifies the creation, orchestration and access of Python scripts that you require for your development work, process automation, etc. -pype-cli GIF +![pype-cli GIF](res/pype-cli.gif) ## Quickstart -- Install **pype-cli** via `pip3 install --user pype-cli`. This will install the command `pype` for the current user -- To use an alternative name you need to install from source via `PYPE_CUSTOM_SHELL_COMMAND=my_cmd_name python3 setup.py install --user` +- Install **pype-cli** via `python -m pip install --user pype-cli`. This will install the command `pype` for the current user +- To use an alternative name you need to install from source via `PYPE_CUSTOM_SHELL_COMMAND=my_cmd_name python setup.py install --user` - Run `pype pype.config shell-install` and open a new shell to activate shell completion - Create a new **plugin** in your home folder: `pype pype.config plugin-register --create --name my-plugin --path ~/` - Create a sample **pype** for your plugin: `pype my-plugin --create-pype my-pype` @@ -86,7 +83,23 @@ If you have selected a **pype** from a **plugin** you can set **aliases** for it ### Global logging configuration -**pype-cli** contains a built-in logger setup. To configure it use the **pype** `pype pype.config logger`. In your **pypes** you can use it right away [like in the provided example](example_pypes/basics/logger.py). +**pype-cli** contains a built-in file logger setup. To configure it use the **pype** `pype pype.config logger`. In your **pypes** you can use it right away like this: + +```python +import logging +import click + +@click.command(name='my-pype', help=__doc__) +def main() -> None: + # Name your logger. Note that this can be omitted but you will end up + # with the default 'root' logger. + logger = logging.getLogger(__name__) + + # Log something to the global log file. Note that the output to the file + # depends on the logging configuration mentioned above. + logger.debug('Debug message') + logger.info('Info message') +``` - Enable/disable global logging: `pype pype.config logger enable/disable` - Print current configuration: `pype pype.config logger print-config` @@ -98,21 +111,9 @@ If you have selected a **pype** from a **plugin** you can set **aliases** for it If your **plugin** contains shared code over all **pypes** you can simply put it into a subpackage of your **plugin** or into a file prefixed with `__`, e.g., `__commons__.py`. **pype-cli** will only scan / consider top-level Python scripts without underscores as **pypes**. -### Example recipes - -You can register a sample **plugin** called [**basics**](example_pypes/basics) that contains some useful recipes to get you started with your own pipes. - -- Register the [**basics**](example_pypes/basics) **plugin**: `pype pype.config plugin-register --name basics --path /example_pypes` -- Navigate to `pype basics ` to see its content -- Open a recipe in your edior, for example: `pype basics --open-pype hello-world-opt` - -For some basic information you can also refer to the built-in [template.py](pype/template.py) and [template_minimal.py](pype/template_minimal.py) that are used on creation of new **pypes**. - -Note that as long as you don't import some of the [convenience utilities](pype/__init__.py) of **pype-cli** directly, your **pype** will remain [an independent Python script](example_pypes/basics/non_pype_script.py) that can be used regardless of **pype_cli**. - ### Best practises -**pype-cli** has been built around the [Click-project ("Command Line Interface Creation Kit")](https://click.palletsprojects.com/) which is a Python package for creating beautiful command line interfaces. To fully utilize the capabilities of **pype-cli** it is highly recommended to get familiar with the project and use it in your **pypes** as well. Again you can refer to the [**basics**](example_pypes/basics) plugin for guidance. +**pype-cli** has been built around the [Click-project ("Command Line Interface Creation Kit")](https://click.palletsprojects.com/) which is a Python package for creating beautiful command line interfaces. To fully utilize the capabilities of **pype-cli** it is highly recommended to get familiar with the project and use it in your **pypes** as well. ## pype-cli development diff --git a/example_pypes/basics/__init__.py b/example_pypes/basics/__init__.py deleted file mode 100644 index df4b507..0000000 --- a/example_pypes/basics/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- -"""Example pypes.""" diff --git a/example_pypes/basics/hello_world.py b/example_pypes/basics/hello_world.py deleted file mode 100644 index bfb1f08..0000000 --- a/example_pypes/basics/hello_world.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -"""A classic hello world console feedback.""" - -# Import the "Command Line Interface Creation Kit" -# -import click - -import pype - -# Create a click command https://click.palletsprojects.com/en/7.x/commands/ - - -@click.command(name=pype.fname_to_name(__file__), help=__doc__) -def main() -> None: - """Script's main entry point.""" - print('Hello World!') diff --git a/example_pypes/basics/hello_world_opt.py b/example_pypes/basics/hello_world_opt.py deleted file mode 100644 index 64dc6c9..0000000 --- a/example_pypes/basics/hello_world_opt.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -"""A classic hello world console feedback with click cli-options.""" - -# Import the "Command Line Interface Creation Kit" -# -import click - -import pype - -# Create a click command https://click.palletsprojects.com/en/7.x/commands/ - - -@click.command(name=pype.fname_to_name(__file__), help=__doc__) -# Add an option https://click.palletsprojects.com/en/7.x/options/ -@click.option('--message', '-m', default='Hello World!', - metavar='MESSAGE', help='Alternative message') -def main(message: str) -> None: - """Script's main entry point.""" - print(message) diff --git a/example_pypes/basics/logger.py b/example_pypes/basics/logger.py deleted file mode 100644 index 75f572e..0000000 --- a/example_pypes/basics/logger.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -"""How to use the global logging subsystem.""" - -import logging - -import click - -import pype - - -@click.command(name=pype.fname_to_name(__file__), help=__doc__) -def main() -> None: - """Script's main entry point.""" - # For this to work it is required that you've set up global logging - # via 'pype pype.config logger' before. See its help pages to get yourself - # familiar with the options. - - # Name your logger. Note that this can be omitted but you will end up - # with the default 'root' logger. - logger = logging.getLogger(__name__) - - # Log something to the global log file. Note that the output to the file - # depends on the logging configuration mentioned above. - logger.debug('Debug message') - logger.info('Info message') diff --git a/example_pypes/basics/multi_command.py b/example_pypes/basics/multi_command.py deleted file mode 100644 index 09e8c6e..0000000 --- a/example_pypes/basics/multi_command.py +++ /dev/null @@ -1,79 +0,0 @@ -# -*- coding: utf-8 -*- -"""A dynamic multi-command creator.""" - -import json -from os import path -from typing import Any, List - -# Import the "Command Line Interface Creation Kit" -# -import click - -import pype - -# Store your dynamic commands along other pype configuration files -commands_registry = path.join( - pype.Config().get_dir_path(), 'example-multicommands') - - -def _load_command_registry() -> List: - """Load the available commands from a json file.""" - try: - commands = json.load(open(commands_registry, 'r')) - except (json.decoder.JSONDecodeError, FileNotFoundError): - commands = [] # Fallback on first initialization - return commands - - -def _generate_multi_command() -> List[Any]: - """Create the subcommand registry.""" - commands = _load_command_registry() - return [{ - 'name': command, - 'help': 'Execute ' + command - } for command in commands] - - -def _command_callback(command: str, context: click.Context) -> None: - """Process the passed subcommand.""" - print('Executing ' + command) - print('Command context: ' + str(context)) - - -# Create a new click command with dynamic sub commands -@click.group( - name=pype.fname_to_name(__file__), help=__doc__, - # Initialize dynamic subcommands using the convenience function - cls=pype.generate_dynamic_multicommand( - # Add a function to create multi commands, in this case - # based on the existing command registry. - _generate_multi_command(), - # Provide a callback to receive the command and the - # click context object. - _command_callback - ), - # This will allow to call the pype without a sub command - invoke_without_command=True -) -# Add an option to extend the command registry -@click.option('--add', '-a', metavar='COMMAND_NAME', - help='Add a new subcommand') -# Pass the context to be able to print the help text -@click.pass_context -def main(ctx: click.Context, add: str) -> None: - """Script's main entry point.""" - # Load the current registry from a local json-file - command_registry = _load_command_registry() - # If add option was selected... - if add: - if add in command_registry: - print(add + ' already registered.') - exit(0) - click.confirm( - 'Add ' + add + ' to commands?', default=False, abort=True) - # Register and save the new command - command_registry.append(add) - json.dump(command_registry, open(commands_registry, 'w+')) - # ... else just print the help text. - elif ctx.invoked_subcommand is None: - print(ctx.get_help()) diff --git a/example_pypes/basics/non_pype_script.py b/example_pypes/basics/non_pype_script.py deleted file mode 100644 index 0e0bbec..0000000 --- a/example_pypes/basics/non_pype_script.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -"""Script interoperable with pype-cli omitting any pype-cli libraries.""" - -# Import the "Command Line Interface Creation Kit" -# -import click - -# Create a click command https://click.palletsprojects.com/en/7.x/commands/ -# Notice that instead of using pype.fname_to_name(__file__) we just -# hard-wired the command name. - - -@click.command(name='non-pype-script', help=__doc__) -def main() -> None: - """Script's main entry point.""" - print('I am a pype-cli independent script!') - - -if __name__ == '__main__': - main() diff --git a/pype/__main__.py b/pype/__main__.py index a4b6b03..65bbfe6 100644 --- a/pype/__main__.py +++ b/pype/__main__.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python # -*- coding: utf-8 -*- """PYPE - A command-line tool for command-line tools.""" diff --git a/pype/config_model.py b/pype/config_model.py index dc941a4..7717098 100644 --- a/pype/config_model.py +++ b/pype/config_model.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from enum import Enum from json import dumps -from typing import List, Optional +from typing import Any, List, Optional class ConfigResolverSource(str, Enum): @@ -20,9 +20,10 @@ class ConfigResolverSource(str, Enum): class BaseDataClass: """Base data class with asdict capability.""" - def asdict(self) -> dict: + def asdict(self) -> dict[str, Any]: """Convert to dictionary.""" - return dc_asdict(self) + return dc_asdict(self) # type: ignore + # https://github.com/python/mypy/issues/17550 def asjson(self) -> str: """Convert to JSON.""" diff --git a/pype/core.py b/pype/core.py index 0d36f14..9c74859 100644 --- a/pype/core.py +++ b/pype/core.py @@ -6,7 +6,6 @@ from importlib import import_module from os import environ, path, remove from re import sub -from shutil import copyfile from typing import Any, List, Optional from click import Context @@ -220,7 +219,7 @@ def alias_unregister(self, alias: str) -> None: def __write_init_file(self, init_file: str, aliases: List) -> None: shell_command = path.basename(sys.argv[0]) cfg_dir = self.__config.get_dir_path() - source_cmd = 'zsh_source' if init_file == 'zsh' else 'source' + source_cmd = 'zsh_source' if init_file == 'zsh' else 'bash_source' target_file = resolve_path( path.join(cfg_dir, self.SHELL_INIT_PREFIX + init_file) ) @@ -239,7 +238,7 @@ def __write_init_file(self, init_file: str, aliases: List) -> None: export PATH=$PATH:{console_script} if [ ! -z "$( command -v {shell_command} )" ] # Only if installed then - if [ ! -f {complete_file} ] + if [ ! -s {complete_file} ] then _{shell_upper}_COMPLETE={source_cmd} {shell_command} > {complete_file} fi @@ -269,8 +268,15 @@ def create_pype_or_exit( # Depending on user input create a documented or simple template template_name = ('template_minimal.py' if minimal else 'template.py') - source_name = path.join(path.dirname(__file__), template_name) - copyfile(source_name, target_file) + source_file = path.join(path.dirname(__file__), template_name) + source_handle = open(source_file, 'r', encoding='utf-8') + target_handle = open(target_file, 'w+', encoding='utf-8') + for line in source_handle.readlines(): + if r'%%PYPE_NAME%%' in line: + line = sub(r'%%PYPE_NAME%%', pype_name, line) + target_handle.write(line) + source_handle.close() + target_handle.close() print_success('Created new pype ' + target_file) return target_file diff --git a/pype/template.py b/pype/template.py index 93838ff..f3d13fa 100644 --- a/pype/template.py +++ b/pype/template.py @@ -7,6 +7,7 @@ # For colored output pype includes the colorama library from colorama import Fore, Style +# Import pype directly for some utilities import pype diff --git a/pype/template_minimal.py b/pype/template_minimal.py index fbccb54..58180e5 100644 --- a/pype/template_minimal.py +++ b/pype/template_minimal.py @@ -3,9 +3,7 @@ import click -import pype - -@click.command(name=pype.fname_to_name(__file__), help=__doc__) +@click.command(name=r'%%PYPE_NAME%%', help=__doc__) def main() -> None: # noqa: D103 pass diff --git a/setup.py b/setup.py index 297887c..c98647f 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,7 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python # -*- coding: utf-8 -*- """Package installer script.""" -from io import open from os import environ, path from setuptools import find_packages, setup @@ -19,10 +18,10 @@ setup( # Basic project information name='pype-cli', - version='0.7.1', + version='0.8.0', # Authorship and online reference author='Basti Tee', - author_email='basti.tee@posteo.de', + author_email='basti.tee@icloud.com', url='https://github.com/BastiTee/pype', # Detailed description description='A command-line tool for command-line tools', @@ -35,14 +34,16 @@ 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', 'Programming Language :: Python', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9' + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', ], # Package configuration packages=find_packages(exclude=('tests',)), include_package_data=True, - python_requires='>=3.7', + python_requires='>=3.9', install_requires=[ 'click', 'jsonschema', diff --git a/tests/__init__.py b/tests/__init__.py index 77623e8..c40b298 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -52,7 +52,7 @@ class ConfigTypeForTest(Enum): NONE = 2 -@ contextlib.contextmanager +@contextlib.contextmanager def create_test_env( configuration: ConfigTypeForTest = ConfigTypeForTest.EMPTY ) -> Generator[ @@ -108,11 +108,9 @@ def create_test_env( def invoke_runner( component_under_test: Union[str, BaseCommand], - arguments: list = None + arguments: list = [] ) -> RunnerEnvironment: """Create and invoke a test runner.""" - if arguments is None: - arguments = [] with create_test_env() as test_env: return create_runner(test_env, component_under_test, arguments) @@ -120,7 +118,7 @@ def invoke_runner( def create_runner( test_env: TestEnvironment, component_under_test: Union[str, BaseCommand], - arguments: list = None + arguments: list = [] ) -> RunnerEnvironment: """Create a test runner with a provided test environment. @@ -128,8 +126,6 @@ def create_runner( variables correctly. That is why tests.invoke_runner gets called with a string instead of a module. """ - if arguments is None: - arguments = [] environ[ENV_CONFIG_FOLDER] = test_env.config_dir runner = CliRunner(env=environ) importlib.reload(__main__) diff --git a/tests/integration/test_pype.py b/tests/test_pype.py similarity index 100% rename from tests/integration/test_pype.py rename to tests/test_pype.py diff --git a/tests/integration/test_pype_aliases.py b/tests/test_pype_aliases.py similarity index 100% rename from tests/integration/test_pype_aliases.py rename to tests/test_pype_aliases.py diff --git a/tests/integration/test_pype_pypes.py b/tests/test_pype_pypes.py similarity index 100% rename from tests/integration/test_pype_pypes.py rename to tests/test_pype_pypes.py diff --git a/tests/integration/test_pypeconfig_pluginregister.py b/tests/test_pypeconfig_pluginregister.py similarity index 100% rename from tests/integration/test_pypeconfig_pluginregister.py rename to tests/test_pypeconfig_pluginregister.py