From 8fec2128cc0c5b94dbfc3d3771a22e12a90b9f94 Mon Sep 17 00:00:00 2001 From: Ryan Rowe Date: Tue, 9 Jun 2020 14:53:19 -0700 Subject: [PATCH] Initial commit --- .github/workflows/publish.yaml | 27 ++++++ .github/workflows/push.yml | 27 ++++++ .gitignore | 133 +++++++++++++++++++++++++++ LICENSE | 13 +++ Pipfile | 10 ++ README.md | 88 ++++++++++++++++++ argexec/__init__.py | 8 ++ argexec/_decorator.py | 162 +++++++++++++++++++++++++++++++++ argexec/_docstring.py | 52 +++++++++++ argexec/types/__init__.py | 5 + argexec/types/_default.py | 19 ++++ argexec/types/_log_level.py | 49 ++++++++++ setup.cfg | 41 +++++++++ setup.py | 5 + tests/__init__.py | 0 15 files changed, 639 insertions(+) create mode 100644 .github/workflows/publish.yaml create mode 100644 .github/workflows/push.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Pipfile create mode 100644 README.md create mode 100644 argexec/__init__.py create mode 100644 argexec/_decorator.py create mode 100644 argexec/_docstring.py create mode 100644 argexec/types/__init__.py create mode 100644 argexec/types/_default.py create mode 100644 argexec/types/_log_level.py create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/__init__.py diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..5706035 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,27 @@ +name: Publish Python Package + +on: + release: + types: [published] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.8' + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools pipenv + pipenv install --dev + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + pipenv run python setup.py sdist + pipenv run twine upload dist/* diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..5a7c0bb --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,27 @@ +name: Push CI + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.7, 3.8] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install -U pip pipenv + pipenv install --dev + - name: Lint with flake8 + uses: grantmcconnaughey/lintly-flake8-github-action@v1.0 + if: github.event_name == 'pull_request' + - name: Unit tests + run: | + pipenv run python -m unittest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f436c8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,133 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IntelliJ +.DS_Store +.idea diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07e8059 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2020 Xevo Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..ef474b3 --- /dev/null +++ b/Pipfile @@ -0,0 +1,10 @@ +[[source]] +name = 'pypi' +url = 'https://pypi.org/simple' +verify_ssl = true + +[packages] +argexec = {editable = true, path = '.'} + +[dev-packages] +argexec = {editable = true, path = '.', extras = ['dev']} diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcb199e --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Argexec + +![Build status](https://img.shields.io/github/workflow/status/XevoInc/argexec/Push%20CI/master) +[![PyPI](https://img.shields.io/pypi/v/argexec)](https://pypi.org/project/argexec/) +![PyPI - License](https://img.shields.io/pypi/l/argexec) + +An unobtrusive, elegant mechanism to provide seamless command line interfaces through argparse for Python functions. +All you have to do is decorate your function of choice with `@argexec` and away you go! + +## Features +* Description parsing from docstring +* Argument help parsing from reStructuredText-like docstrings +* Argument type enforcement via [typeguard](https://github.com/agronholm/typeguard) from + [type hints](https://www.python.org/dev/peps/pep-0484/) +* Argument default values from function signature +* Support for the following argument types: + * All builtin primitives (`bool`, `int`, `float`, `str`, `bytes`) + * Fixed length tuples of a supported type + * Variable length tuples of a supported type + * Lists of a supported type +* Extensible, complex custom type parsing via [`dynamic_dispatch`](https://github.com/XevoInc/dynamic_dispatch) + +## Install + +You may install this via the [`argexec`](https://pypi.org/project/argexec/) package on [PyPi](https://pypi.org): + +```bash +pip3 install argexec +``` + +## Usage + +The decorator may be applied to any Python function that meets the following requirements: +* Is not a member function +* Has [PEP 484](https://www.python.org/dev/peps/pep-0484/) type hints for all parameters +* Does not use `*args` or `**kwargs` + +Example (`foo.py`): +```python +#!/usr/bin/python3 + +from typing import Tuple + +from argexec import argexec +from argexec.types import LogLevel + +@argexec +def foo(w: int, x: Tuple[str, ...], y: LogLevel, z: bool = True): + """ + Hello, world! + + :param w: foo. + :param x: bar. + :param y: baz. + :param z: qux. + """ + pass +``` + +``` +$ ./foo.py --help +usage: scratch_1.py [-h] [-y] [--no-z] w [x [x ...]] + +Hello, world! + +positional arguments: + w [int] foo + x [Tuple[str, ...]] bar + +optional arguments: + -h, --help show this help message and exit + -y, --y [LogLevel=CRITICAL] baz (more flags for lower level) + --no-z [bool=True] qux +``` + + + +## Development + +When developing, it is recommended to use Pipenv. To create your development environment: + +```bash +pipenv install --dev +``` + +### Testing + +TODO \ No newline at end of file diff --git a/argexec/__init__.py b/argexec/__init__.py new file mode 100644 index 0000000..bf323ca --- /dev/null +++ b/argexec/__init__.py @@ -0,0 +1,8 @@ +""" +An unobtrusive, elegant mechanism to provide seamless command line interfaces +through argparse for Python functions. All you have to do is decorate your +function of choice with `@argexec` and away you go! +""" + +# Please sort alphabetically. +from ._decorator import argexec diff --git a/argexec/_decorator.py b/argexec/_decorator.py new file mode 100644 index 0000000..20e3eb2 --- /dev/null +++ b/argexec/_decorator.py @@ -0,0 +1,162 @@ +""" Decorator for automatic argument parsing and execution of python functions. """ + +import argparse as _argparse +import inspect as _inspect +from typing import Callable, Sequence, TypeVar + +from typeguard import check_type, typechecked + +try: + from typing import get_args, get_origin +except ImportError: + # Python <3.8. + from typing_extensions import get_args, get_origin + +from ._docstring import get_type_name, parse_docstring +from .types import argexec_param_options + + +@typechecked +def argexec(func: Callable): + """ + Builds automatic argument parser for the given function and calls it. + + Custom type parsing and argument configuration may be added using types from the argexec.types module. + + :param func: function to execute. + """ + description, params = parse_docstring(func) + parser = _argparse.ArgumentParser(description=description, formatter_class=_argparse.RawTextHelpFormatter) + + sequences = {} + + sig = _inspect.signature(func) + for name, arg in sig.parameters.items(): + type_ = arg.annotation + if type_ == _inspect.Parameter.empty: + raise TypeError('argexec requires all parameters be type annotated') + parameters = {} + + # Parameters are required if they have no default value. + required = arg.default == _inspect.Parameter.empty + + # Infer argument type and nargs for generic typed parameters. + origin = get_origin(type_) + if origin is None: + type_name = get_type_name(type_) + else: + type_name = repr(type_).replace('typing.', '') + if issubclass(origin, Sequence) and not origin == str: + if origin == tuple: + kwargs = get_args(type_) + + if len(kwargs) == 0: + # Empty tuple type. + parameters['nargs'] = 0 + if not required: + parameters['default'] = () + elif len(kwargs) >= 1: + parameters['type'] = kwargs[0] + + if len(kwargs) == 1: + # One-element tuple. + parameters['nargs'] = 1 + elif len(kwargs) == 2 and kwargs[1] == Ellipsis: + # Variable-length tuple of one type. + parameters['nargs'] = '*' + if not required: + parameters['default'] = () + elif kwargs[-1] == Ellipsis: + raise TypeError('variable length tuples with ellipses are not supported') + elif not all(kwarg != kwargs[0] for kwarg in kwargs[1:]): + # Fixed-length tuple with more than one type. + raise TypeError('multi-type tuples are not supported') + else: + # Fixed length tuple with one type. + parameters['nargs'] = len(kwargs) + else: + parameters['nargs'] = '*' + parameters['default'] = origin + + kwargs = get_args(type_) + if len(kwargs) > 1: + raise TypeError(f'multi-type generics for {type_} are not supported') + if not isinstance(kwargs[0], TypeVar): + parameters['type'] = kwargs[0] + sequences[name] = origin + else: + raise TypeError(f'unsupported generic type {origin}') + + # Get type-specific information. + type_config = argexec_param_options(type_) + + # Merge type-specific config into inferred config. + parameters.update(type_config) + required = parameters.get('required', required) + + # Enforce default value. + if not required and arg.default != _inspect.Parameter.empty: + parameters['default'] = arg.default + try: + default = parameters['default'] + if default is not None: + check_type('default', default, type_) + except KeyError: + pass + + # Populate help field with inferred description. + help_ = params.get(arg.name, "") + if help_.endswith('.'): + # Periods at the end are unsightly. + help_ = help_[:-1] + + # Get the default value to put next to argument type. + try: + default = f'={parameters["default"]!r}' + except KeyError: + default = '' + parameters['help'] = f'[{type_name}{default}] {help_}{type_config.pop("help", "")}' + + # Set type name to nice string. + if 'type' not in parameters: + parameters['type'] = type_ + elif not hasattr(parameters['type'], '__name__'): + parameters['type'].__name__ = type_name + + # Determine flag names and set requirement if necessary. + if arg.kind == _inspect.Parameter.KEYWORD_ONLY or \ + (arg.kind == _inspect.Parameter.POSITIONAL_OR_KEYWORD and not required): + if type_ == bool and not required: + # Boolean flag argument. + del parameters['type'] + if arg.default: + parameters['action'] = 'store_false' + names = (f'--no-{name}',) + else: + parameters['action'] = 'store_true' + names = (f'--{name}',) + else: + # Normal flag argument. + names = (f'-{name[0]}', f'--{name}') + parameters['required'] = required + else: + names = (name,) + + parser.add_argument(*names, **parameters) + + parsed = parser.parse_args() + + kwargs = vars(parsed) + for name, arg in kwargs.items(): + if name in sequences: + kwargs[name] = sequences[name](arg) + + args = [] + for name, arg in sig.parameters.items(): + if arg.kind == _inspect.Parameter.POSITIONAL_ONLY: + if name in kwargs: + args.append(kwargs.pop(name)) + + func(*args, **kwargs) + + return func diff --git a/argexec/_docstring.py b/argexec/_docstring.py new file mode 100644 index 0000000..fd070dc --- /dev/null +++ b/argexec/_docstring.py @@ -0,0 +1,52 @@ +""" Docstring parsing utilities. """ + +import inspect as _inspect +import re as _re +from typing import Callable, Dict, Optional, Tuple, Type + +try: + from typing import Final +except ImportError: + from typing_extensions import Final + + +def get_type_name(type_: Type) -> str: + return getattr(type_, '__name__', None) or getattr(type_, '_name', None) or repr(type_) + + +_PARAM_NAME_REGEX = _re.compile(r':param\s+(\w+)') + + +def parse_docstring(func: Callable) -> Tuple[Optional[str], Dict[str, str]]: + """ + Parses docstrings into a function description and parameter descriptions. + + :param func: function to parse docstring of. + :return: function description and + """ + doc = _inspect.getdoc(func) + params = {} + + if doc is None: + return None, params + + desc = '' + name = None + for line in doc.splitlines(): + match = _re.match(_PARAM_NAME_REGEX, line) + + if match is not None: + name = match.group(match.lastindex) + params[name] = line[match.span(match.lastindex)[1] + 1:].strip() + else: + if line.startswith(':return') or line.startswith(':raises'): + return desc, params + + if len(line) == 0: + desc += '\n\n' + if name is None: + desc += line + else: + params[name] += line + + return desc, params diff --git a/argexec/types/__init__.py b/argexec/types/__init__.py new file mode 100644 index 0000000..1216df0 --- /dev/null +++ b/argexec/types/__init__.py @@ -0,0 +1,5 @@ +""" Custom argexec parameter types """ + +# Please sort alphabetically. +from ._default import argexec_param_options +from ._log_level import LogLevel diff --git a/argexec/types/_default.py b/argexec/types/_default.py new file mode 100644 index 0000000..dd9cea5 --- /dev/null +++ b/argexec/types/_default.py @@ -0,0 +1,19 @@ +""" Default argexec parameter type options. """ + +from typing import Dict, Type, TypeVar + +from dynamic_dispatch import dynamic_dispatch + +T = TypeVar('T') + + +@dynamic_dispatch(default=True) +def argexec_param_options(typ: Type[T]) -> Dict: + """ + Gets configuration parameters for argparse's add_argument given a type. + + :param typ: type to get parsing configuration for. + :return: dictionary containing configuration. + """ + # By default, all types have no configuration. + return {} diff --git a/argexec/types/_log_level.py b/argexec/types/_log_level.py new file mode 100644 index 0000000..b8ee56c --- /dev/null +++ b/argexec/types/_log_level.py @@ -0,0 +1,49 @@ +""" +Argexec parameter type for logging level. + +This module serves as an example of how argexec's type configuration +system can be extended to support custom types. +""" + +from argparse import Action, ArgumentParser, Namespace +from logging import getLevelName +from typing import Any, Dict, Optional, Sequence, Text, Union + +from ._default import argexec_param_options + + +class LogLevel(int): + """ + Type alias for int which can be used as a level for the logging package. + + Adds a command line flag that can be specified zero or more times, with each successive + flag lowering the log level by 10. The default level is CRITICAL (50). + """ + def __repr__(self): + return getLevelName(self) + + # Note: all other maths involving LogLevels will return ints, not LogLevels. This is overridden + # as we use left subtraction on log levels below. Other maths will require similar overrides. + def __sub__(self, x: int) -> int: + return self.__class__(super().__sub__(x)) + + +class LogLevelAction(Action): + """ Sets the level dest to the appropriate integer level based on the number of calls. """ + + def __call__(self, parser: ArgumentParser, namespace: Namespace, values: Union[Text, Sequence[Any], None], + option_string: Optional[Text] = ...) -> None: + level = getattr(namespace, self.dest) + setattr(namespace, self.dest, level - 10) + + +@argexec_param_options.dispatch(on=LogLevel) +def log_level_param_options() -> Dict: + """ :returns: argparse options for LogLevel parameters. """ + return { + 'nargs': 0, + 'action': LogLevelAction, + 'required': False, + 'default': LogLevel(50), + 'help': ' (more flags for lower level)' + } diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..5d23dc4 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,41 @@ +[metadata] +name=argexec +description=Expose your Python functions to the command line with one easy step! +version=1.0.0 +url=https://github.com/XevoInc/argexec +long_description=file: README.md, +long_description_content_type=text/markdown +author=Ryan Rowe +author_email=rrowe@xevo.com +license=Apache License, Version 2.0 +license_file=LICENSE +python_requires=>=3.7 +classifiers= + Development Status :: 4 - Beta + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Programming Language :: Python + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Topic :: Utilities + Typing :: Typed + +[options] +setup_requires = + # Minimal version with most `setup.cfg` bug fixes. + setuptools >= 38.3.0 +packages = argexec +test_suite = tests +install_requires = + dynamic_dispatch + typeguard >= 2.9.1 + typing_extensions; python_version < '3.8' + +[options.extras_require] +dev = + flake8 + twine + +[flake8] +ignore = F401, F403 +max-line-length = 120 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7f317b8 --- /dev/null +++ b/setup.py @@ -0,0 +1,5 @@ +#!/usr/bin/python3 + +from setuptools import setup + +setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29