diff --git a/.bumpversion.cfg b/.bumpversion.cfg index aa253a6f..59ce67d4 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.5.6 +current_version = 2.6.0 [bumpversion:file:.env] diff --git a/.env b/.env index 7c0d9a10..7687a5af 100644 --- a/.env +++ b/.env @@ -11,7 +11,7 @@ fi export PROJECT_NAME=$OPEN_PROJECT_NAME export PROJECT_DIR="$PWD" -export PROJECT_VERSION="2.5.6" +export PROJECT_VERSION="2.6.0" if [ ! -d "venv" ]; then if ! hash pyvenv 2>/dev/null; then @@ -53,7 +53,7 @@ alias project="root; cd $PROJECT_NAME" alias tests="root; cd tests" alias examples="root; cd examples" alias requirements="root; cd requirements" -alias test="_test" +alias run_tests="_test" function open { @@ -64,7 +64,8 @@ function open { function clean { (root - isort hug/*.py setup.py tests/*.py) + isort hug/*.py setup.py tests/*.py + black -l 100 hug) } diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..99100df1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +tidelift: "pypi/hug" diff --git a/.travis.yml b/.travis.yml index ee58dd62..454ae64b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -41,6 +41,10 @@ matrix: sudo: required python: 3.7 env: TOXENV=py37-isort + - os: linux + sudo: required + python: 3.7 + env: TOXENV=py37-safety - os: linux sudo: required python: pypy3.5-6.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 81e55744..32bc327c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ Ideally, within a virtual environment. Changelog ========= +### 2.6.0 - August 29, 2019 +- Improved CLI multiple behaviour with empty defaults +- Improved CLI type output for built-in types +- Improved MultiCLI base documentation + ### 2.5.6 - June 20, 2019 - Fixed issue #815: map_params() causes api documentation to lose param type information - Improved project testing: restoring 100% coverage diff --git a/LICENSE b/LICENSE index f49a5775..a6167d3d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ -The MIT License (MIT) +MIT License -Copyright (c) 2015 Timothy Edmund Crosley +Copyright (c) 2016 Timothy Crosley Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - diff --git a/README.md b/README.md index c0ec61a9..a54fc714 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Windows Build Status](https://ci.appveyor.com/api/projects/status/0h7ynsqrbaxs7hfm/branch/master?svg=true)](https://ci.appveyor.com/project/TimothyCrosley/hug) [![Coverage Status](https://coveralls.io/repos/hugapi/hug/badge.svg?branch=develop&service=github)](https://coveralls.io/github/hugapi/hug?branch=master) [![License](https://img.shields.io/github/license/mashape/apistatus.svg)](https://pypi.python.org/pypi/hug/) -[![Join the chat at https://gitter.im/timothycrosley/hug](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/hugapi/hug?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![Join the chat at https://gitter.im/timothycrosley/hug](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/timothycrosley/hug?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) NOTE: For more in-depth documentation visit [hug's website](http://www.hug.rest) @@ -25,6 +25,17 @@ As a result of these goals, hug is Python 3+ only and built upon [Falcon's](http [![HUG Hello World Example](https://raw.github.com/hugapi/hug/develop/artwork/example.gif)](https://github.com/hugapi/hug/blob/develop/examples/hello_world.py) +Supporting hug development +=================== +[Get professionally supported hug with the Tidelift Subscription](https://tidelift.com/subscription/pkg/pypi-hug?utm_source=pypi-hug&utm_medium=referral&utm_campaign=readme) + +Professional support for hug is available as part of the [Tidelift +Subscription](https://tidelift.com/subscription/pkg/pypi-hug?utm_source=pypi-hug&utm_medium=referral&utm_campaign=readme). +Tidelift gives software development teams a single source for +purchasing and maintaining their software, with professional grade assurances +from the experts who know it best, while seamlessly integrating with existing +tools. + Installing hug =================== @@ -418,6 +429,16 @@ bash-4.3# tree 1 directory, 3 files ``` +Security contact information +=================== + +hug takes security and quality seriously. This focus is why we depend only on thoroughly tested components and utilize static analysis tools (such as bandit and safety) to verify the security of our code base. +If you find or encounter any potential security issues, please let us know right away so we can resolve them. + +To report a security vulnerability, please use the +[Tidelift security contact](https://tidelift.com/security). +Tidelift will coordinate the fix and disclosure. + Why hug? =================== diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..0fc15531 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +hug takes security and quality seriously. This focus is why we depend only on thoroughly tested components and utilize static analysis tools (such as bandit and safety) to verify the security of our code base. +If you find or encounter any potential security issues, please let us know right away so we can resolve them. + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 2.5.6 | :white_check_mark: | + +Currently, only the latest version of hug will receive security fixes. + +## Reporting a Vulnerability + +To report a security vulnerability, please use the +[Tidelift security contact](https://tidelift.com/security). +Tidelift will coordinate the fix and disclosure. diff --git a/examples/cli_multiple.py b/examples/cli_multiple.py new file mode 100644 index 00000000..b4bfcef1 --- /dev/null +++ b/examples/cli_multiple.py @@ -0,0 +1,9 @@ +import hug + +@hug.cli() +def add(numbers: list=None): + return sum([int(number) for number in numbers]) + + +if __name__ == "__main__": + add.interface.cli() diff --git a/examples/cli_object.py b/examples/cli_object.py index 00026539..c2a5297a 100644 --- a/examples/cli_object.py +++ b/examples/cli_object.py @@ -9,10 +9,12 @@ class GIT(object): @hug.object.cli def push(self, branch="master"): + """Push the latest to origin""" return "Pushing {}".format(branch) @hug.object.cli def pull(self, branch="master"): + """Pull in the latest from origin""" return "Pulling {}".format(branch) diff --git a/examples/matplotlib/additional_requirements.txt b/examples/matplotlib/additional_requirements.txt new file mode 100644 index 00000000..a1e35e39 --- /dev/null +++ b/examples/matplotlib/additional_requirements.txt @@ -0,0 +1 @@ +matplotlib==3.1.1 diff --git a/examples/matplotlib/plot.py b/examples/matplotlib/plot.py new file mode 100644 index 00000000..4f1906c3 --- /dev/null +++ b/examples/matplotlib/plot.py @@ -0,0 +1,15 @@ +import io + +import hug +from matplotlib import pyplot + + +@hug.get(output=hug.output_format.png_image) +def plot(): + pyplot.plot([1, 2, 3, 4]) + pyplot.ylabel('some numbers') + + image_output = io.BytesIO() + pyplot.savefig(image_output, format='png') + image_output.seek(0) + return image_output diff --git a/examples/redirects.py b/examples/redirects.py index 17e45fbd..f47df8ba 100644 --- a/examples/redirects.py +++ b/examples/redirects.py @@ -13,7 +13,7 @@ def internal_redirection_automatic(number_1: int, number_2: int): """This will redirect internally to the sum_two_numbers handler passing along all passed in parameters. - Redirect happens within internally within hug, fully transparent to clients. + This kind of redirect happens internally within hug, fully transparent to clients. """ print("Internal Redirection Automatic {}, {}".format(number_1, number_2)) return sum_two_numbers @@ -29,7 +29,7 @@ def internal_redirection_manual(number: int): @hug.post() -def redirect(redirect_type: hug.types.one_of((None, "permanent", "found", "see_other")) = None): +def redirect(redirect_type: hug.types.one_of(("permanent", "found", "see_other")) = None): """Hug also fully supports classical HTTP redirects, providing built in convenience functions for the most common types. """ @@ -38,3 +38,10 @@ def redirect(redirect_type: hug.types.one_of((None, "permanent", "found", "see_o hug.redirect.to("/sum_two_numbers") else: getattr(hug.redirect, redirect_type)("/sum_two_numbers") + + +@hug.post() +def redirect_set_variables(number: int): + """You can also do some manual parameter setting with HTTP based redirects""" + print("HTTP Redirect set variables {}".format(number)) + hug.redirect.to("/sum_two_numbers?number_1={0}&number_2={0}".format(number)) diff --git a/hug/_version.py b/hug/_version.py index 64c8c5c3..a3c76249 100644 --- a/hug/_version.py +++ b/hug/_version.py @@ -21,4 +21,4 @@ """ from __future__ import absolute_import -current = "2.5.6" +current = "2.6.0" diff --git a/hug/api.py b/hug/api.py index a7f574ad..7951d6e0 100644 --- a/hug/api.py +++ b/hug/api.py @@ -469,9 +469,14 @@ def output_format(self, formatter): self._output_format = formatter def __str__(self): - return "{0}\n\nAvailable Commands:{1}\n".format( - self.api.doc or self.api.name, "\n\n\t- " + "\n\t- ".join(self.commands.keys()) - ) + output = "{0}\n\nAvailable Commands:\n\n".format(self.api.doc or self.api.name) + for command_name, command in self.commands.items(): + command_string = " - {}{}".format( + command_name, ": " + str(command).replace("\n", " ") if str(command) else "" + ) + output += command_string[:77] + "..." if len(command_string) > 80 else command_string + output += "\n" + return output class ModuleSingleton(type): diff --git a/hug/interface.py b/hug/interface.py index 0b56c340..3463a062 100644 --- a/hug/interface.py +++ b/hug/interface.py @@ -48,6 +48,12 @@ text, ) +DOC_TYPE_MAP = {str: "String", bool: "Boolean", list: "Multiple", int: "Integer", float: "Float"} + + +def _doc(kind): + return DOC_TYPE_MAP.get(kind, kind.__doc__) + def asyncio_call(function, *args, **kwargs): loop = asyncio.get_event_loop() @@ -227,7 +233,7 @@ def __init__(self, route, function): self.output_doc = self.transform.__doc__ elif self.transform or self.interface.transform: output_doc = self.transform or self.interface.transform - self.output_doc = output_doc if type(output_doc) is str else output_doc.__doc__ + self.output_doc = output_doc if type(output_doc) is str else _doc(output_doc) self.raise_on_invalid = route.get("raise_on_invalid", False) if "on_invalid" in route: @@ -310,7 +316,7 @@ def documentation(self, add_to=None): for requirement in self.requires ] doc["outputs"] = OrderedDict() - doc["outputs"]["format"] = self.outputs.__doc__ + doc["outputs"]["format"] = _doc(self.outputs) doc["outputs"]["content_type"] = self.outputs.content_type parameters = [ param @@ -329,7 +335,7 @@ def documentation(self, add_to=None): continue input_definition = inputs.setdefault(argument, OrderedDict()) - input_definition["type"] = kind if isinstance(kind, str) else kind.__doc__ + input_definition["type"] = kind if isinstance(kind, str) else _doc(kind) default = self.defaults.get(argument, None) if default is not None: input_definition["default"] = default @@ -505,7 +511,7 @@ def exit(self, status=0, message=None): if option in self.interface.input_transformations: transform = self.interface.input_transformations[option] kwargs["type"] = transform - kwargs["help"] = transform.__doc__ + kwargs["help"] = _doc(transform) if transform in (list, tuple) or isinstance(transform, types.Multiple): kwargs["action"] = "append" kwargs["type"] = Text() @@ -526,7 +532,7 @@ def exit(self, status=0, message=None): kwargs["action"] = "store_true" kwargs.pop("type", None) elif kwargs.get("action", None) == "store_true": - kwargs.pop("action", None) == "store_true" + kwargs.pop("action", None) if option == self.additional_options: kwargs["nargs"] = "*" @@ -559,6 +565,9 @@ def output(self, data, context): sys.stdout.buffer.write(b"\n") return data + def __str__(self): + return self.parser.description or "" + def __call__(self): """Calls the wrapped function through the lens of a CLI ran command""" context = self.api.context_factory(api=self.api, argparse=self.parser, interface=self) @@ -588,9 +597,16 @@ def exit_callback(message): for field, type_handler in self.reaffirm_types.items(): if field in pass_to_function: - pass_to_function[field] = self.initialize_handler( - type_handler, pass_to_function[field], context=context - ) + if not pass_to_function[field] and type_handler in ( + list, + tuple, + hug.types.Multiple, + ): + pass_to_function[field] = type_handler(()) + else: + pass_to_function[field] = self.initialize_handler( + type_handler, pass_to_function[field], context=context + ) if getattr(self, "validate_function", False): errors = self.validate_function(pass_to_function) diff --git a/requirements/build_common.txt b/requirements/build_common.txt index 567795e0..a312c6de 100644 --- a/requirements/build_common.txt +++ b/requirements/build_common.txt @@ -1,10 +1,9 @@ -r common.txt -flake8==3.3.0 -pytest-cov==2.4.0 -pytest==4.3.1 -python-coveralls==2.9.0 -wheel==0.29.0 -PyJWT==1.4.2 -pytest-xdist==1.14.0 -numpy==1.15.4 - +flake8==3.5.0 +pytest-cov==2.7.1 +pytest==4.6.3 +python-coveralls==2.9.2 +wheel==0.33.4 +PyJWT==1.7.1 +pytest-xdist==1.29.0 +numpy<1.16 diff --git a/requirements/build_style_tools.txt b/requirements/build_style_tools.txt index 063c2e98..8d80fc0f 100644 --- a/requirements/build_style_tools.txt +++ b/requirements/build_style_tools.txt @@ -4,4 +4,5 @@ isort==4.3.20 pep8-naming==0.8.2 flake8-bugbear==19.3.0 vulture==1.0 -bandit==1.6.0 +bandit==1.6.1 +safety==1.8.5 diff --git a/requirements/build_windows.txt b/requirements/build_windows.txt index 408595fa..a67127df 100644 --- a/requirements/build_windows.txt +++ b/requirements/build_windows.txt @@ -1,8 +1,8 @@ -r common.txt -flake8==3.3.0 -isort==4.2.5 +flake8==3.7.7 +isort==4.3.20 marshmallow==2.18.1 -pytest==4.4.2 -wheel==0.29.0 -pytest-xdist==1.28.0 +pytest==4.6.3 +wheel==0.33.4 +pytest-xdist==1.29.0 numpy==1.15.4 diff --git a/requirements/common.txt b/requirements/common.txt index 4dc67ad9..3acc7891 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -1,2 +1,2 @@ falcon==2.0.0 -requests==2.21.0 +requests==2.22.0 diff --git a/requirements/development.txt b/requirements/development.txt index d967da5a..4142d01c 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -1,16 +1,16 @@ bumpversion==0.5.3 -Cython==0.29.6 +Cython==0.29.10 -r common.txt -flake8==3.5.0 -ipython==6.2.1 -isort==4.3.18 -pytest-cov==2.5.1 -pytest==4.3.1 -python-coveralls==2.9.1 -tox==2.9.1 +flake8==3.7.7 +ipython==7.5.0 +isort==4.3.20 +pytest-cov==2.7.1 +pytest==4.6.3 +python-coveralls==2.9.2 +tox==3.12.1 wheel -pytest-xdist==1.20.1 +pytest-xdist==1.29.0 marshmallow==2.18.1 ujson==1.35 -numpy==1.15.4 +numpy<1.16 diff --git a/setup.py b/setup.py index 811f17c4..034cacf4 100755 --- a/setup.py +++ b/setup.py @@ -78,7 +78,7 @@ def list_modules(dirname): setup( name="hug", - version="2.5.6", + version="2.6.0", description="A Python framework that makes developing APIs " "as simple as possible, but no simpler.", long_description=long_description, @@ -87,7 +87,7 @@ def list_modules(dirname): author="Timothy Crosley", author_email="timothy.crosley@gmail.com", # These appear in the left hand side bar on PyPI - url="https://github.com/timothycrosley/hug", + url="https://github.com/hugapi/hug", project_urls={ "Documentation": "http://www.hug.rest/", "Gitter": "https://gitter.im/timothycrosley/hug", diff --git a/tests/test_decorators.py b/tests/test_decorators.py index f2df856d..8e70f134 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1936,3 +1936,11 @@ def pull_record(record_id: hug.types.number = 1): assert hug.test.get(hug_api, "pull_record").data == 1 assert hug.test.get(hug_api, "pull_record", id=10).data == 10 + + +def test_multiple_cli(hug_api): + @hug.cli(api=hug_api) + def multiple(items: list=None): + return items + + hug_api.cli([None, "multiple", "-i", "one", "-i", "two"]) diff --git a/tests/test_full_request.py b/tests/test_full_request.py index 56866138..e60bd0d0 100644 --- a/tests/test_full_request.py +++ b/tests/test_full_request.py @@ -20,6 +20,7 @@ """ import platform +import sys import time from subprocess import Popen @@ -42,6 +43,7 @@ def post(body, response): @pytest.mark.skipif( platform.python_implementation() == "PyPy", reason="Can't run hug CLI from travis PyPy" ) +@pytest.mark.skipif(sys.platform == "win32", reason="CLI not currently testable on Windows") def test_hug_post(tmp_path): hug_test_file = tmp_path / "hug_postable.py" hug_test_file.write_text(TEST_HUG_API) diff --git a/tox.ini b/tox.ini index 9428483f..6d60eda4 100644 --- a/tox.ini +++ b/tox.ini @@ -51,6 +51,14 @@ deps= whitelist_externals=flake8 commands=isort -c --diff --recursive hug +[testenv:py37-safety] +deps= + -rrequirements/build_style_tools.txt + marshmallow==3.0.0rc6 + +whitelist_externals=flake8 +commands=safety check -i 36810 + [testenv:pywin] deps =-rrequirements/build_windows.txt basepython = {env:PYTHON:}\python.exe