Merge branch 'release-0.1.0' into main
Some checks failed
CI / tests (push) Has been cancelled

This commit is contained in:
Alexander Hess 2020-08-05 16:10:39 +02:00
commit 52514ca411
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
16 changed files with 2839 additions and 4 deletions

14
.github/workflows/tests.yml vendored Normal file
View file

@ -0,0 +1,14 @@
name: CI
on: push
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v1
with:
python-version: 3.8
architecture: x64
- run: pip install nox==2020.5.24
- run: pip install poetry==1.0.10
- run: nox

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.cache/
*.egg-info/
.python-version
.venv/

38
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,38 @@
default_stages: [commit] # only used if a hook does not specify stages
fail_fast: true
repos:
# Run the local formatting, linting, and testing tool chains.
- repo: local
hooks:
- id: local-pre-commit-checks
name: Run code formatters and linters
entry: poetry run nox -s pre-commit --
language: system
stages: [commit]
types: [python]
- id: local-pre-merge-checks
name: Run the entire test suite
entry: poetry run nox -s pre-merge --
language: system
stages: [merge-commit, push]
types: [python]
# Enable hooks provided by the pre-commit project to
# enforce rules that local tools could not that easily.
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
hooks:
- id: check-added-large-files
args: [--maxkb=100]
- id: check-case-conflict
- id: check-builtin-literals
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
stages: [commit] # overwrite the default
- id: mixed-line-ending
args: [--fix=no]
- id: no-commit-to-branch
args: [--branch, main]
- id: trailing-whitespace
stages: [commit] # overwrite the default

15
docs/conf.py Normal file
View file

@ -0,0 +1,15 @@
"""Configure sphinx."""
import urban_meal_delivery as umd
project = umd.__pkg_name__
author = umd.__author__
copyright = f'2020, {author}' # pylint:disable=redefined-builtin
version = release = umd.__version__
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.napoleon',
'sphinx_autodoc_typehints',
]

95
docs/index.rst Normal file
View file

@ -0,0 +1,95 @@
Urban Meal Delivery
===================
.. toctree::
:hidden:
:maxdepth: 1
license
reference
This is the documentation for the `urban-meal-delivery` package
that contains all the source code
used in a research project
residing in this `repository`_.
Prerequisites
-------------
Only `git`_, `Python`_ 3.8 and `poetry`_ must be available.
All other software is automatically installed (cf., next section).
Installation
------------
The `urban-meal-delivery` package is installed
only after cloning the `repository`_.
It is *not* available on `PyPI`_ via `pip`_.
.. code-block:: console
$ git clone https://github.com/webartifex/urban-meal-delivery.git
Use `poetry`_ to install the package
with its dependencies into a `virtual environment`_,
which we also refer to as the **local** or the **develop** environment here.
.. code-block:: console
$ poetry install
First Steps
-----------
Verify that the installation is in a good state by
running the test suite and the code linters
via the automated task runner `nox`_.
``poetry run`` executes whatever comes after it in the develop environment.
If you have a project-independent `nox`_ installation available,
you may drop the ``poetry run`` prefix.
Otherwise, `nox`_ was installed as a develop dependency in the `virtual environment`_
in the installation step above.
Run the default sessions in `nox`_:
.. code-block:: console
$ [poetry run] nox
`nox`_ provides many options.
You can use them to choose among the different tasks you want to achieve.
.. option:: -l, --list
List all available `nox`_ sessions.
This includes sessions that are *not* run by default
and that may be used as develop tools
to work on the source files.
.. option:: -s [SESSION [SESSION ...]], --session[s] [SESSION [SESSION ...]]
Run only the specified session[s]
.. option:: -r, --reuse-existing-virtualenvs
By default,
`nox`_ creates a new `virtual environment`_ for every session.
To speed things up,
one can re-use existing environments with the -r flag.
This should only be done when developing
and not before committing or on the CI server.
.. _git: https://git-scm.com/
.. _nox: https://nox.thea.codes/en/stable/
.. _pip: https://pip.pypa.io/en/stable/
.. _poetry: https://python-poetry.org/docs/
.. _pypi: https://pypi.org/
.. _python: https://docs.python.org/3/
.. _repository: https://github.com/webartifex/urban-meal-delivery
.. _virtual environment: https://docs.python.org/3/tutorial/venv.html

4
docs/license.rst Normal file
View file

@ -0,0 +1,4 @@
License
=======
.. include:: ../LICENSE.txt

6
docs/reference.rst Normal file
View file

@ -0,0 +1,6 @@
Reference
=========
.. contents::
:local:
:backlinks: none

439
noxfile.py Normal file
View file

@ -0,0 +1,439 @@
"""Configure nox as the task runner, including CI and pre-commit hooks.
Generic maintainance tasks:
- "init-project": set up the pre-commit hooks
- "clean-pwd": ~ `git clean -X` with minor exceptions
For local development, use the "format", "lint", and "test" sessions
as unified tasks to assure the quality of the source code:
- "format" (autoflake, black, isort):
+ check all source files [default]
+ accept extra arguments, e.g., `poetry run nox -s format -- noxfile.py`,
that are then interpreted as the paths the formatters and linters work
on recursively
- "lint" (flake8, mypy, pylint): same as "format"
- "test" (pytest, xdoctest):
+ run the entire test suite [default]
+ accepts extra arguments, e.g., `poetry run nox -s test -- --no-cov`,
that are passed on to `pytest` and `xdoctest` with no changes
=> may be paths or options
GitHub Actions implements a CI workflow:
- "format", "lint", and "test" as above
- "safety": check if dependencies contain known security vulnerabilites
- "docs": build the documentation with sphinx
The pre-commit framework invokes the "pre-commit" and "pre-merge" sessions:
- "pre-commit" before all commits:
+ triggers "format" and "lint" on staged source files
+ => test coverage may be < 100%
- "pre-merge" before all merges and pushes:
+ same as "pre-commit"
+ plus: triggers "test", "safety", and "docs" (that ignore extra arguments)
+ => test coverage is enforced to be 100%
"""
import contextlib
import glob
import os
import tempfile
import nox
from nox.sessions import Session
PACKAGE_IMPORT_NAME = 'urban_meal_delivery'
# Docs/sphinx locations.
DOCS_SRC = 'docs/'
DOCS_BUILD = '.cache/docs/'
# Path to the *.py files to be packaged.
PACKAGE_SOURCE_LOCATION = 'src/'
# Path to the test suite.
PYTEST_LOCATION = 'tests/'
# Paths with all *.py files.
SRC_LOCATIONS = (
f'{DOCS_SRC}/conf.py',
'noxfile.py',
PACKAGE_SOURCE_LOCATION,
PYTEST_LOCATION,
)
PYTHON = '3.8'
# Use a unified .cache/ folder for all tools.
nox.options.envdir = '.cache/nox'
# All tools except git and poetry are project dependencies.
# Avoid accidental successes if the environment is not set up properly.
nox.options.error_on_external_run = True
# Run only CI related checks by default.
nox.options.sessions = (
'format',
'lint',
'test',
'safety',
'docs',
)
@nox.session(name='format', python=PYTHON)
def format_(session):
"""Format source files with autoflake, black, and isort.
If no extra arguments are provided, all source files are formatted.
Otherwise, they are interpreted as paths the formatters work on recursively.
"""
_begin(session)
# The formatting tools do not require the developed
# package be installed in the virtual environment.
_install_packages(session, 'autoflake', 'black', 'isort')
# Interpret extra arguments as locations of source files.
locations = session.posargs or SRC_LOCATIONS
session.run('autoflake', '--version')
session.run(
'autoflake',
'--in-place',
'--recursive',
'--expand-star-imports',
'--remove-all-unused-imports',
'--ignore-init-module-imports', # modifies --remove-all-unused-imports
'--remove-duplicate-keys',
'--remove-unused-variables',
*locations,
)
session.run('black', '--version')
session.run('black', *locations)
with _isort_fix(session): # TODO (isort): Remove after upgrading
session.run('isort', '--version')
session.run('isort', *locations)
@nox.session(python=PYTHON)
def lint(session):
"""Lint source files with flake8, mypy, and pylint.
If no extra arguments are provided, all source files are linted.
Otherwise, they are interpreted as paths the linters work on recursively.
"""
_begin(session)
# The linting tools do not require the developed
# package be installed in the virtual environment.
_install_packages(
session,
'flake8',
'flake8-annotations',
'flake8-black',
'flake8-expression-complexity',
'flake8-pytest-style',
'mypy',
'pylint',
'wemake-python-styleguide',
)
# Interpret extra arguments as locations of source files.
locations = session.posargs or SRC_LOCATIONS
session.run('flake8', '--version')
session.run('flake8', '--ignore=I0', *locations) # TODO (isort): Remove flag
with _isort_fix(session): # TODO (isort): Remove after upgrading
session.run('isort', '--version')
session.run('isort', '--check-only', *locations)
# For mypy, only lint *.py files to be packaged.
mypy_locations = [
path for path in locations if path.startswith(PACKAGE_SOURCE_LOCATION)
]
if mypy_locations:
session.run('mypy', '--version')
session.run('mypy', *mypy_locations)
else:
session.log('No paths to be checked with mypy')
# Ignore errors where pylint cannot import a third-party package due its
# 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', '--disable=wrong-import-order', *locations,
)
@nox.session(name='pre-commit', python=PYTHON, venv_backend='none')
def pre_commit(session):
"""Run the format and lint sessions.
Source files must be well-formed before they enter git.
Intended to be run as a pre-commit hook.
Passed in extra arguments are forwarded. So, if it is run as a pre-commit
hook, only the currently staged source files are formatted and linted.
"""
# "format" and "lint" are run in sessions on their own as
# session.notify() creates new Session objects.
session.notify('format')
session.notify('lint')
@nox.session(python=PYTHON)
def test(session):
"""Test the code base.
Runs the unit and integration tests with pytest and
validate that all code snippets in docstrings work with xdoctest.
If no extra arguments are provided, the entire test suite
is exexcuted and succeeds only with 100% coverage.
If extra arguments are provided, they are
forwarded to pytest and xdoctest without any changes.
xdoctest ignores arguments it does not understand.
"""
# Re-using an old environment is not so easy here as
# `poetry install --no-dev` removes previously installed packages.
# We keep things simple and forbid such usage.
if session.virtualenv.reuse_existing:
raise RuntimeError('The "test" session must be run without the "-r" option')
_begin(session)
# The testing tools require the developed package and its
# non-develop dependencies be installed in the virtual environment.
session.run('poetry', 'install', '--no-dev', external=True)
_install_packages(
session, 'packaging', 'pytest', 'pytest-cov', 'xdoctest[optional]',
)
# Interpret extra arguments as options for pytest.
# They are "dropped" by the hack in the pre_merge() function
# if this function is run within the "pre-merge" session.
posargs = () if session.env.get('_drop_posargs') else session.posargs
args = posargs or (
'--cov',
'--no-cov-on-fail',
'--cov-branch',
'--cov-fail-under=100',
'--cov-report=term-missing:skip-covered',
PYTEST_LOCATION,
)
session.run('pytest', '--version')
session.run('pytest', *args)
# For xdoctest, the default arguments are different from pytest.
args = posargs or [PACKAGE_IMPORT_NAME]
session.run('xdoctest', '--version')
session.run('xdoctest', '--quiet', *args) # --quiet => less verbose output
@nox.session(python=PYTHON)
def safety(session):
"""Check the dependencies for known security vulnerabilities."""
_begin(session)
# We do not pin the version of `safety` to always check with
# the latest version. The risk this breaks the CI is rather low.
session.install('safety')
with tempfile.NamedTemporaryFile() as requirements_txt:
session.run(
'poetry',
'export',
'--dev',
'--format=requirements.txt',
f'--output={requirements_txt.name}',
external=True,
)
session.run(
'safety', 'check', f'--file={requirements_txt.name}', '--full-report',
)
@nox.session(python=PYTHON)
def docs(session):
"""Build the documentation with sphinx."""
# The latest version of the package needs to be installed
# so that sphinx's autodoc can include the latest docstrings.
if session.virtualenv.reuse_existing:
raise RuntimeError('The "docs" session must be run without the "-r" option')
_begin(session)
# The documentation tools require the developed package and its
# non-develop dependencies be installed in the virtual environment.
# Otherwise, sphinx's autodoc could not include the docstrings.
session.run('poetry', 'install', '--no-dev', external=True)
_install_packages(session, 'sphinx', 'sphinx-autodoc-typehints')
session.run('sphinx-build', DOCS_SRC, DOCS_BUILD)
# Verify all external links return 200 OK.
session.run('sphinx-build', '-b', 'linkcheck', DOCS_SRC, DOCS_BUILD)
print(f'Docs are available at {os.getcwd()}/{DOCS_BUILD}index.html') # noqa:WPS421
@nox.session(name='pre-merge', python=PYTHON)
def pre_merge(session):
"""Run the format, lint, test, safety, and docs sessions.
Intended to be run either as a pre-merge or pre-push hook.
Ignores the paths passed in by the pre-commit framework
for the test, safety, and docs sessions so that the
entire test suite is executed.
"""
# Re-using an old environment is not so easy here as the "test" session
# runs `poetry install --no-dev`, which removes previously installed packages.
if session.virtualenv.reuse_existing:
raise RuntimeError(
'The "pre-merge" session must be run without the "-r" option',
)
session.notify('format')
session.notify('lint')
session.notify('safety')
session.notify('docs')
# Little hack to not work with the extra arguments provided
# by the pre-commit framework. Create a flag in the
# env(ironment) that must contain only `str`-like objects.
session.env['_drop_posargs'] = 'true'
# Cannot use session.notify() to trigger the "test" session
# as that would create a new Session object without the flag
# in the env(ironment). Instead, run the test() function within
# the "pre-merge" session.
test(session)
@nox.session(name='init-project', python=PYTHON, venv_backend='none')
def init_project(session):
"""Install the pre-commit hooks."""
for type_ in ('pre-commit', 'pre-merge-commit', 'pre-push'):
session.run('poetry', 'run', 'pre-commit', 'install', f'--hook-type={type_}')
@nox.session(name='clean-pwd', python=PYTHON, venv_backend='none')
def clean_pwd(session):
"""Remove (almost) all glob patterns listed in .gitignore.
The difference compared to `git clean -X` is that this task
does not remove pyenv's .python-version file and poetry's
virtual environment.
"""
exclude = frozenset(('.python-version', '.venv', 'venv'))
with open('.gitignore') as file_handle:
paths = file_handle.readlines()
for path in paths:
path = path.strip()
if path.startswith('#') or path in exclude:
continue
for expanded in glob.glob(path):
session.run(f'rm -rf {expanded}')
def _begin(session):
"""Show generic info about a session."""
if session.posargs:
# Part of the hack in pre_merge() to "drop" the extra arguments.
# Indicate to stdout that the passed in extra arguments are ignored.
if session.env.get('_drop_posargs') is None:
print('Provided extra argument(s):', *session.posargs) # noqa:WPS421
else:
print('The provided extra arguments are ignored') # noqa:WPS421
session.run('python', '--version')
# Fake GNU's pwd.
session.log('pwd')
print(os.getcwd()) # noqa:WPS421
def _install_packages(session: Session, *packages_or_pip_args: str, **kwargs) -> None:
"""Install packages respecting the poetry.lock file.
This function wraps nox.sessions.Session.install() such that it installs
packages respecting the pinnned versions specified in poetry's lock file.
This makes nox sessions even more deterministic.
IMPORTANT: This function skips installation if the current nox session
is run with the "-r" flag to re-use an existing virtual environment.
That turns nox into a fast task runner provided that a virtual
environment actually existed and does not need to be changed (e.g.,
new dependencies added in the meantime). Do not use the "-r" flag on CI
or as part of pre-commit hooks!
Args:
session: the Session object
*packages_or_pip_args: the packages to be installed or pip options
**kwargs: passed on to nox.sessions.Session.install()
""" # noqa:RST210,RST213
if session.virtualenv.reuse_existing:
session.log(
'No dependencies are installed as an existing environment is re-used',
)
return
session.log('Dependencies are installed respecting the poetry.lock file')
with tempfile.NamedTemporaryFile() as requirements_txt:
session.run(
'poetry',
'export',
'--dev',
'--format=requirements.txt',
f'--output={requirements_txt.name}',
external=True,
)
session.install(
f'--constraint={requirements_txt.name}', *packages_or_pip_args, **kwargs,
)
# TODO (isort): Remove this fix after
# upgrading to isort ^5.3.0 in pyproject.toml.
@contextlib.contextmanager
def _isort_fix(session):
"""Temporarily upgrade to isort 5.3.0."""
session.install('isort==5.3.0')
try:
yield
finally:
session.install('isort==4.3.21')

1674
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,9 +2,14 @@
build-backend = "poetry.masonry.api"
requires = ["poetry>=0.12"]
[tool.black]
line-length = 88
skip-string-normalization = true # wemake-python-styleguide enforces single quotes
target-version = ["py38"]
[tool.poetry]
name = "urban-meal-delivery"
version = "0.1.0.dev0"
version = "0.1.0"
authors = ["Alexander Hess <alexander@webartifex.biz>"]
description = "Optimizing an urban meal delivery platform"
@ -22,4 +27,37 @@ 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"
pre-commit = "^2.6.0"
# Code Formatters
autoflake = "^1.3.1"
black = "^19.10b0"
isort = "^4.3.21" # TODO (isort): not ^5.2.2 due to pylint and wemake-python-styleguide
# (Static) Code Analyzers
flake8 = "^3.8.3"
flake8-annotations = "^2.3.0"
flake8-black = "^0.2.1"
flake8-expression-complexity = "^0.0.8"
flake8-pytest-style = "^1.2.2"
mypy = "^0.782"
pylint = "^2.5.3"
wemake-python-styleguide = "^0.14.1" # flake8 plug-in
# Test Suite
packaging = "^20.4" # used to test the packaged version
pytest = "^6.0.1"
pytest-cov = "^2.10.0"
xdoctest = { version="^0.13.0", extras=["optional"] }
# Documentation
sphinx = "^3.1.2"
sphinx-autodoc-typehints = "^1.11.0"
[tool.poetry.scripts]
umd = "urban_meal_delivery.console:main"

231
setup.cfg Normal file
View file

@ -0,0 +1,231 @@
[black]
# black's settings are in pyproject.toml => [tool.black]
[coverage:paths]
source =
src/
*/site-packages/
[coverage:report]
show_missing = true
skip_covered = true
skip_empty = true
[coverage:run]
branch = true
data_file = .cache/coverage/data
source =
urban_meal_delivery
[flake8]
# Include error classes only explicitly
# to avoid forward compatibility issues.
select =
# =============
# flake8's base
# =============
# mccabe => cyclomatic complexity
C901,
# pycodestyle => PEP8 compliance
E, W,
# pyflakes => basic errors
F4, F5, F6, F7, F8, F9
# ========================
# wemake-python-styleguide
# Source: https://wemake-python-stylegui.de/en/latest/pages/usage/violations/index.html
# ========================
WPS1, WPS2, WPS3, WPS4, WPS5, WPS6,
# darglint => docstring matches implementation
DAR0, DAR1, DAR2, DAR3, DAR4, DAR5,
# flake8-bandit => common security issues
S1, S2, S3, S4, S5, S6, S7,
# flake8-broken-line => no \ to end a line
N400,
# flake8-bugbear => opinionated bugs and design flaws
B0, B3, B9,
# flake8-commas => better comma placements
C8,
# flake8-comprehensions => better comprehensions
C4,
# flake8-debugger => no debugger usage
T100,
# flake8-docstrings => PEP257 compliance
D1, D2, D3, D4,
# flake8-eradicate => no commented out code
E800,
# flake8-isort => isort would make changes
I0,
# flake8-rst-docstrings => valid rst in docstrings
RST2, RST3, RST4,
# flake8-string-format => unify usage of str.format()
P1, P2, P3,
# flake8-quotes => use double quotes everywhere (complying with black)
Q0,
# pep8-naming
N8,
# =====
# other
# =====
# flake8-annotations => enforce type checking for functions
ANN0, ANN2, ANN3,
# flake8-black => complain if black would make changes
BLK1, BLK9,
# flake8-expression-complexity => not too many expressions at once
ECE001,
# flake8-pytest-style => enforce a consistent style with pytest
PT0,
# By default, flake8 ignores some errors.
# Instead, do not ignore anything.
ignore =
# If --ignore is passed on the command
# line, still ignore the following:
extend-ignore =
# Comply with black's style.
# Source: https://github.com/psf/black/blob/master/docs/compatible_configs.md#flake8
E203, W503,
# f-strings are ok.
WPS305,
# Classes should not have to specify a base class.
WPS306,
# Putting logic into __init__.py files may be justified.
WPS412,
# Allow multiple assignment, e.g., x = y = 123
WPS429,
per-file-ignores =
docs/conf.py:
# Allow shadowing built-ins and reading __*__ variables.
WPS125,WPS609,
noxfile.py:
# Type annotations are not strictly enforced.
ANN0, ANN2,
# TODO (isort): Check if still too many module members.
WPS202,
# TODO (isort): Remove after simplifying the nox session "lint".
WPS213,
# No overuse of string constants (e.g., '--version').
WPS226,
tests/*.py:
# Type annotations are not strictly enforced.
ANN0, ANN2,
# `assert` statements are ok in the test suite.
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.
# Source: https://en.wikipedia.org/wiki/Cyclomatic_complexity#Limiting_complexity_during_development
max-complexity = 10
# Comply with black's style.
# Source: https://github.com/psf/black/blob/master/docs/the_black_code_style.md#line-length
max-line-length = 88
# Preview the code lines that cause errors.
show-source = true
# ===================================
# wemake-python-styleguide's settings
# ===================================
allowed-domain-names =
param,
result,
value,
min-name-length = 3
max-name-length = 40
# darglint
strictness = long
# flake8-docstrings
docstring-convention = google
# flake8-eradicate
eradicate-aggressive = true
# ==============================
# flake8-pytest-style's settings
# ==============================
# Prefer @pytest.fixture over @pytest.fixture().
pytest-fixture-no-parentheses = true
# Prefer @pytest.mark.parametrize(['param1', 'param2'], [(1, 2), (3, 4)])
# over @pytest.mark.parametrize(('param1', 'param2'), ([1, 2], [3, 4]))
pytest-parametrize-names-type = list
pytest-parametrize-values-row-type = tuple
pytest-parametrize-values-type = list
[isort]
atomic = true
case_sensitive = true
combine_star = true
force_alphabetical_sort_within_sections = true
lines_after_imports = 2
remove_redundant_aliases = true
# Comply with black's style.
# Source: https://github.com/psf/black/blob/master/docs/compatible_configs.md#isort
ensure_newline_before_comments = true
force_grid_wrap = 0
include_trailing_comma = true
line_length = 88
multi_line_output = 3
use_parentheses = true
# Comply with Google's Python Style Guide.
# All imports go on a single line except the ones from the typing module.
# Source: https://google.github.io/styleguide/pyguide.html#313-imports-formatting
force_single_line = true
single_line_exclusions = typing
[mypy]
cache_dir = .cache/mypy
[mypy-nox.*,packaging,pytest]
ignore_missing_imports = true
[pylint.FORMAT]
# Comply with black's style.
max-line-length = 88
[pylint.MESSAGES CONTROL]
disable =
# We use TODO's to indicate locations in the source base
# that must be worked on in the near future.
fixme,
# Comply with black's style.
bad-continuation, bad-whitespace,
# =====================
# flake8 de-duplication
# Source: https://pylint.pycqa.org/en/latest/faq.html#i-am-using-another-popular-linter-alongside-pylint-which-messages-should-i-disable-to-avoid-duplicates
# =====================
# mccabe
too-many-branches,
# pep8-naming
bad-classmethod-argument, bad-mcs-classmethod-argument,
invalid-name, no-self-argument,
# pycodestyle
bad-indentation, bare-except, line-too-long, missing-final-newline,
multiple-statements, trailing-whitespace, unnecessary-semicolon, unneeded-not,
# pydocstyle
missing-class-docstring, missing-function-docstring, missing-module-docstring,
# pyflakes
undefined-variable, unused-import, unused-variable,
# wemake-python-styleguide
redefined-outer-name,
[pylint.REPORTS]
score = no
[tool:pytest]
addopts =
--strict-markers
cache_dir = .cache/pytest
console_output_style = count

View file

@ -1 +1,23 @@
"""Source code for the urban-meal-delivery research project."""
"""Source code for the urban-meal-delivery research project.
Example:
>>> import urban_meal_delivery as umd
>>> umd.__version__ != '0.0.0'
True
"""
from importlib import metadata as _metadata
try:
_pkg_info = _metadata.metadata(__name__)
except _metadata.PackageNotFoundError: # pragma: no cover
__author__ = 'unknown'
__pkg_name__ = 'unknown'
__version__ = 'unknown'
else:
__author__ = _pkg_info['author']
__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."""

1
tests/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Test the urban-meal-delivery package."""

110
tests/test_console.py Normal file
View file

@ -0,0 +1,110 @@
"""Test the package's `umd` command-line client."""
import click
import pytest
from click import testing as click_testing
from urban_meal_delivery import console
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
@pytest.fixture
def ctx(self) -> click.Context:
"""Context around the console.main Command."""
return click.Context(console.main)
def test_no_version(self, capsys, ctx):
"""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, ctx, monkeypatch):
"""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, ctx, monkeypatch):
"""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)')
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.fixture
def cli(self) -> click_testing.CliRunner:
"""Initialize Click's CLI Test Runner."""
return click_testing.CliRunner()
@pytest.mark.no_cover
def test_no_options(self, cli):
"""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, monkeypatch, option):
"""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, monkeypatch, option):
"""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)')

114
tests/test_version.py Normal file
View file

@ -0,0 +1,114 @@
"""Test the package's version identifier.
This packaged version identifier must adhere to PEP440
and a strict subset of semantic versioning:
- version identifiers must follow the x.y.z format
where x.y.z are non-negative integers
- non-final versions are only allowed with a .devN suffix
where N is also a non-negative integer
"""
import re
import pytest
from packaging import version as pkg_version
import urban_meal_delivery
class TestPEP404Compliance:
"""Packaged version identifier is PEP440 compliant."""
# pylint:disable=no-self-use
@pytest.fixture
def parsed_version(self) -> str:
"""The packaged version."""
return pkg_version.Version(urban_meal_delivery.__version__) # noqa:WPS609
def test_parsed_version_has_no_epoch(self, parsed_version):
"""PEP440 compliant subset of semantic versioning: no epoch."""
assert parsed_version.epoch == 0
def test_parsed_version_is_non_local(self, parsed_version):
"""PEP440 compliant subset of semantic versioning: no local version."""
assert parsed_version.local is None
def test_parsed_version_is_no_post_release(self, parsed_version):
"""PEP440 compliant subset of semantic versioning: no post releases."""
assert parsed_version.is_postrelease is False
def test_parsed_version_is_all_public(self, parsed_version):
"""PEP440 compliant subset of semantic versioning: all public parts."""
assert parsed_version.public == urban_meal_delivery.__version__ # noqa:WPS609
class TestSemanticVersioning:
"""Packaged version follows a strict subset of semantic versioning."""
# pylint:disable=no-self-use
version_pattern = re.compile(
r'^(0|([1-9]\d*))\.(0|([1-9]\d*))\.(0|([1-9]\d*))(\.dev(0|([1-9]\d*)))?$',
)
def test_version_is_semantic(self):
"""Packaged version follows semantic versioning."""
result = self.version_pattern.fullmatch(
urban_meal_delivery.__version__, # noqa:WPS609
)
assert result is not None
# The next two test cases are sanity checks to validate the version_pattern.
@pytest.mark.parametrize(
'version',
[
'0.1.0',
'1.0.0',
'1.2.3',
'1.23.456',
'123.4.56',
'10.11.12',
'1.2.3.dev0',
'1.2.3.dev1',
'1.2.3.dev10',
],
)
def test_valid_semantic_versioning(self, version):
"""Versions follow the x.y.z or x.y.z.devN format."""
result = self.version_pattern.fullmatch(version)
assert result is not None
@pytest.mark.parametrize(
'version',
[
'1',
'1.2',
'-1.2.3',
'01.2.3',
'1.02.3',
'1.2.03',
'1.2.3.4',
'1.2.3.abc',
'1.2.3-dev0',
'1.2.3+dev0',
'1.2.3.d0',
'1.2.3.develop0',
'1.2.3.dev-1',
'1.2.3.dev01',
'1.2.3..dev0',
'1-2-3',
'1,2,3',
'1..2.3',
'1.2..3',
],
)
def test_invalid_semantic_versioning(self, version):
"""Versions follow the x.y.z or x.y.z.devN format."""
result = self.version_pattern.fullmatch(version)
assert result is None