From 97d714d9eed461fea4b6a612b2ebb1116cd2bbcf Mon Sep 17 00:00:00 2001 From: Alexander Hess Date: Tue, 4 Aug 2020 21:14:40 +0200 Subject: [PATCH] Add CLI entry point `umd` - add the following file: + src/urban_meal_delivery/console.py => click-based CLI tools + tests/test_console.py => tests for the module above - add a CLI entry point `umd`: + implement the --version / -V option to show the installed package's version + rework to package's top-level: * add a __pkg_name__ variable to parameterize the package name + add unit and integration tests - fix that pylint cannot know the proper order of imports in the isolated nox session --- noxfile.py | 15 ++-- poetry.lock | 10 +-- pyproject.toml | 5 ++ setup.cfg | 6 +- src/urban_meal_delivery/__init__.py | 12 ++- src/urban_meal_delivery/console.py | 37 ++++++++ tests/test_console.py | 126 ++++++++++++++++++++++++++++ 7 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 src/urban_meal_delivery/console.py create mode 100644 tests/test_console.py diff --git a/noxfile.py b/noxfile.py index a1a09d6..7204f1a 100644 --- a/noxfile.py +++ b/noxfile.py @@ -96,13 +96,16 @@ def lint(session: Session) -> None: session.run('mypy', '--version') session.run('mypy', *locations) # Ignore errors where pylint cannot import a third-party package due its - # being run in an isolated environment. One way to fix this is to install - # all develop dependencies in this nox session, which we do not do. The - # whole point of static linting tools is to not rely on any package be - # importable at runtime. Instead, these imports are validated implicitly - # when the test suite is run. + # being run in an isolated environment. For the same reason, pylint is + # also not able to determine the correct order of imports. + # One way to fix this is to install all develop dependencies in this nox + # session, which we do not do. The whole point of static linting tools is + # to not rely on any package be importable at runtime. Instead, these + # imports are validated implicitly when the test suite is run. session.run('pylint', '--version') - session.run('pylint', '--disable=import-error', *locations) + session.run( + 'pylint', '--disable=import-error', '--disable=wrong-import-order', *locations, + ) @nox.session(python=[MAIN_PYTHON, NEXT_PYTHON]) diff --git a/poetry.lock b/poetry.lock index 6e55d75..d4b0b02 100644 --- a/poetry.lock +++ b/poetry.lock @@ -127,7 +127,7 @@ python-versions = ">=3.6.1" version = "3.2.0" [[package]] -category = "dev" +category = "main" description = "Composable command line interface toolkit" name = "click" optional = false @@ -836,7 +836,7 @@ description = "Virtual Python Environment builder" name = "virtualenv" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -version = "20.0.29" +version = "20.0.30" [package.dependencies] appdirs = ">=1.4.3,<2" @@ -886,7 +886,7 @@ python-versions = "*" version = "1.12.1" [metadata] -content-hash = "7a843263817a908ca01d198402d3e9310c33307ac85085f028c8fbdd7587f48f" +content-hash = "899f376a54c187e41392fb83831a59926668b662bfb32004f4a0964c1202698c" lock-version = "1.0" python-versions = "^3.8" @@ -1301,8 +1301,8 @@ typing-extensions = [ {file = "typing_extensions-3.7.4.2.tar.gz", hash = "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae"}, ] virtualenv = [ - {file = "virtualenv-20.0.29-py2.py3-none-any.whl", hash = "sha256:8aa9c37b082664dbce2236fa420759c02d64109d8e6013593ad13914718a30fd"}, - {file = "virtualenv-20.0.29.tar.gz", hash = "sha256:f14a0a98ea4397f0d926cff950361766b6a73cd5975ae7eb259d12919f819a25"}, + {file = "virtualenv-20.0.30-py2.py3-none-any.whl", hash = "sha256:8cd7b2a4850b003a11be2fc213e206419efab41115cc14bca20e69654f2ac08e"}, + {file = "virtualenv-20.0.30.tar.gz", hash = "sha256:7b54fd606a1b85f83de49ad8d80dbec08e983a2d2f96685045b262ebc7481ee5"}, ] wemake-python-styleguide = [ {file = "wemake-python-styleguide-0.14.1.tar.gz", hash = "sha256:e13dc580fa56b7b548de8da170bccb8ddff2d4ab026ca987db8a9893bf8a7b5b"}, diff --git a/pyproject.toml b/pyproject.toml index ebc9618..5ee058e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,8 @@ repository = "https://github.com/webartifex/urban-meal-delivery" [tool.poetry.dependencies] python = "^3.8" +click = "^7.1.2" + [tool.poetry.dev-dependencies] # Task Runners nox = "^2020.5.24" @@ -51,3 +53,6 @@ wemake-python-styleguide = "^0.14.1" # flake8 plug-in packaging = "^20.4" # used to test the packaged version pytest = "^6.0.1" pytest-cov = "^2.10.0" + +[tool.poetry.scripts] +umd = "urban_meal_delivery.console:main" diff --git a/setup.cfg b/setup.cfg index 59af783..9099cbc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -107,6 +107,8 @@ per-file-ignores = S101, # Shadowing outer scopes occurs naturally with mocks. WPS442, + # No overuse of string constants (e.g., '__version__'). + WPS226, # Explicitly set mccabe's maximum complexity to 10 as recommended by # Thomas McCabe, the inventor of the McCabe complexity, and the NIST. @@ -124,7 +126,9 @@ show-source = true # wemake-python-styleguide's settings # =================================== allowed-domain-names = + param, result, + value, min-name-length = 3 max-name-length = 40 # darglint @@ -173,7 +177,7 @@ single_line_exclusions = typing [mypy] cache_dir = .cache/mypy -[mypy-nox.*,packaging.*,pytest] +[mypy-nox.*,packaging.*,pytest,_pytest.*] ignore_missing_imports = true diff --git a/src/urban_meal_delivery/__init__.py b/src/urban_meal_delivery/__init__.py index d17278b..5a68f43 100644 --- a/src/urban_meal_delivery/__init__.py +++ b/src/urban_meal_delivery/__init__.py @@ -1,9 +1,15 @@ """Source code for the urban-meal-delivery research project.""" -from importlib import metadata +from importlib import metadata as _metadata try: - __version__ = metadata.version(__name__) -except metadata.PackageNotFoundError: # pragma: no cover + _pkg_info = _metadata.metadata(__name__) + +except _metadata.PackageNotFoundError: # pragma: no cover + __pkg_name__ = 'unknown' __version__ = 'unknown' + +else: + __pkg_name__ = _pkg_info['name'] + __version__ = _pkg_info['version'] diff --git a/src/urban_meal_delivery/console.py b/src/urban_meal_delivery/console.py new file mode 100644 index 0000000..0141370 --- /dev/null +++ b/src/urban_meal_delivery/console.py @@ -0,0 +1,37 @@ +"""Provide CLI scripts for the project.""" + +from typing import Any + +import click +from click.core import Context + +import urban_meal_delivery + + +def show_version(ctx: Context, _param: Any, value: bool) -> None: + """Show the package's version.""" + # If --version / -V is NOT passed in, + # continue with the command. + if not value or ctx.resilient_parsing: + return + + # Mimic the colors of `poetry version`. + pkg_name = click.style(urban_meal_delivery.__pkg_name__, fg='green') # noqa:WPS609 + version = click.style(urban_meal_delivery.__version__, fg='blue') # noqa:WPS609 + # Include a warning for development versions. + warning = click.style(' (development)', fg='red') if '.dev' in version else '' + click.echo(f'{pkg_name}, version {version}{warning}') + ctx.exit() + + +@click.command() +@click.option( + '--version', + '-V', + is_flag=True, + callback=show_version, + is_eager=True, + expose_value=False, +) +def main() -> None: + """The urban-meal-delivery research project.""" diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..0b3cba8 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,126 @@ +"""Test the package's `umd` command-line client.""" + +import click +import pytest +from _pytest.capture import CaptureFixture # noqa:WPS436 +from _pytest.monkeypatch import MonkeyPatch # noqa:WPS436 +from click import testing as click_testing + +from urban_meal_delivery import console + + +@pytest.fixture +def ctx() -> click.Context: + """Context around the console.main Command.""" + return click.Context(console.main) + + +class TestShowVersion: + """Test console.show_version(). + + The function is used as a callback to a click command option. + + show_version() prints the name and version of the installed package to + stdout. The output looks like this: "{pkg_name}, version {version}". + + Development (= non-final) versions are indicated by appending a + " (development)" to the output. + """ + + # pylint:disable=no-self-use + + def test_no_version(self, capsys: CaptureFixture, ctx: click.Context) -> None: + """The the early exit branch without any output.""" + console.show_version(ctx, _param='discarded', value=False) + + captured = capsys.readouterr() + + assert captured.out == '' + + def test_final_version( + self, capsys: CaptureFixture, ctx: click.Context, monkeypatch: MonkeyPatch, + ) -> None: + """For final versions, NO "development" warning is emitted.""" + version = '1.2.3' + monkeypatch.setattr(console.urban_meal_delivery, '__version__', version) + + with pytest.raises(click.exceptions.Exit): + console.show_version(ctx, _param='discarded', value=True) + + captured = capsys.readouterr() + + assert captured.out.endswith(f', version {version}\n') + + def test_develop_version( + self, capsys: CaptureFixture, ctx: click.Context, monkeypatch: MonkeyPatch, + ) -> None: + """For develop versions, a warning thereof is emitted.""" + version = '1.2.3.dev0' + monkeypatch.setattr(console.urban_meal_delivery, '__version__', version) + + with pytest.raises(click.exceptions.Exit): + console.show_version(ctx, _param='discarded', value=True) + + captured = capsys.readouterr() + + assert captured.out.strip().endswith(f', version {version} (development)') + + +@pytest.fixture +def cli() -> click_testing.CliRunner: + """Initialize Click's CLI Test Runner.""" + return click_testing.CliRunner() + + +class TestCLI: + """Test the `umd` CLI utility. + + The test cases are integration tests. + Therefore, they are not considered for coverage reporting. + """ + + # pylint:disable=no-self-use + + @pytest.mark.no_cover + def test_no_options(self, cli: click_testing.CliRunner) -> None: + """Exit with 0 status code and no output if run without options.""" + result = cli.invoke(console.main) + + assert result.exit_code == 0 + assert result.output == '' + + # The following test cases validate the --version / -V option. + + version_options = ('--version', '-V') + + @pytest.mark.no_cover + @pytest.mark.parametrize( + 'option', version_options, + ) + def test_final_version( + self, cli: click_testing.CliRunner, monkeypatch: MonkeyPatch, option: str, + ) -> None: + """For final versions, NO "development" warning is emitted.""" + version = '1.2.3' + monkeypatch.setattr(console.urban_meal_delivery, '__version__', version) + + result = cli.invoke(console.main, option) + + assert result.exit_code == 0 + assert result.output.strip().endswith(f', version {version}') + + @pytest.mark.no_cover + @pytest.mark.parametrize( + 'option', version_options, + ) + def test_develop_version( + self, cli: click_testing.CliRunner, monkeypatch: MonkeyPatch, option: str, + ) -> None: + """For develop versions, a warning thereof is emitted.""" + version = '1.2.3.dev0' + monkeypatch.setattr(console.urban_meal_delivery, '__version__', version) + + result = cli.invoke(console.main, option) + + assert result.exit_code == 0 + assert result.output.strip().endswith(f', version {version} (development)')