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
This commit is contained in:
Alexander Hess 2020-08-04 21:14:40 +02:00
parent da233e2e35
commit 97d714d9ee
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
7 changed files with 196 additions and 15 deletions

View file

@ -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])

10
poetry.lock generated
View file

@ -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"},

View file

@ -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"

View file

@ -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

View file

@ -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']

View file

@ -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."""

126
tests/test_console.py Normal file
View file

@ -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)')