Creating a new project for MetricQ
In this section, we explain some of the conventions that we (the metricq
authors) use when setting up a new project using the library.
Python packaging has many (at times confusing) options; these conventions help
us have a consistent developer experience between projects, reducing
maintenance overhead.
In the following, we assume the project is called metricq-example, and all
filesystem paths are relative to the project root directory (/path/to/metricq-example/).
The Python package built from this project is called metricq-example,
and its source code lives in the subdirectory metricq_example of the project root.
Dependencies, building and installation
Build system
Any new projects should use a PEP 517-compliant build system.
Use setuptools for this.
The directory metricq_example contains the Python source code of the
project, create a new file pyproject.toml in the project root,
and declare the necessary build dependencies:
[build-system]
requires = ["setuptools>=40.6.0", "wheel"]
build-backend = 'setuptools.build_meta'
This enables tools like pip to create packages from the project.
Runtime dependencies
In setup.cfg, declare the project runtime dependencies.
Under section [options], point packages to the project source directory (i.e. metricq_example).
Remember to set a minimum required Python version to prevent issues at runtime.
Under install_requires give a list of all runtime dependencies:
# In setup.cfg:
[options]
packages =
metricq_example
python_requires = >=3.8
install_requires =
metricq ~= 3.0
# Your dependencies here
Note
Use compatible release version specifiers (foo ~= x.y, PEP 440#compatible-release)
to prevent breakage caused by incompatibilities introduced in future releases of dependencies.
For compatibility with older release of pip and to enable editable installs for development,
include a dummy setup.py:
# setup.py:
from setuptools import setup
setup()
Note
Keep setup.cfg the single source of truth for package metadata.
Only add entries to setup.py if they otherwise cannot be determined statically.
For example, metricq has to determine its dependencies at build-time:
it must install a PyPI-provided version of protobuf
that is compatible with the host-installed version of the protobuf-compiler, protoc.
Optional dependencies
If your project has optional features that requires additional dependencies,
include them in section options.extras_require of setup.cfg.
For each feature my_feature, define a new extra that lists all
additional dependencies:
# In setup.cfg:
[options.extras_require]
my_feature =
foo ~= 1.0
bar ~= 2.0
# ... more optional dependencies here
The package can then be installed with the feature enabled like so:
$ # Local installation
$ pip install '/path/to/metricq-example[my_feature]'
$ # Installation from PyPI
$ pip install 'metricq-example[my_feature]'
Package metadata
Also in setup.cfg, include relevant package metadata:
# In setup.cfg:
[metadata]
name = metricq-example
author = TU Dresden
description = A metricq example project
long_description = file: README.rst
long_description_content_type = text/rst
url = https://example.com/metricq-example
license = BSD 3-clause "New" or "Revised License"
license_file = LICENSE
classifiers =
License :: OSI Approved :: BSD License
Programming Language :: Python :: 3
The entry long_description points to a README file;
use either Markdown (README.md) or RST (README.rst) formatting.
The content type is inferred from the file extension, but it does not hurt to set it explicitly.
Choose a license appropriate to your project and enter it; metricq
itself is licensed under the terms of the BSD 3-clause “New” or “Revised License”.
Command line interfaces
setuptools allows declaration of Entry points.
An entry point of type console_script makes a python function the
entry point of a script that is added to the $PATH of your Python environment:
# In setup.cfg:
[options.entry_points]
console_scripts =
metricq-example = metricq_example.cli:main
The above makes the function main() in module metricq_example/cli.py
the entry point for an executable named metricq-example.
For a consistent command line experience, use the click project.
Add click ~= 7.0 (or an up-to-date version) to install_requires in setup.cfg.
Then, decorate the script entry point with the appropriate command line arguments and options.
If you are building a metricq client,
include at least options to configure the MetricQ network URL and a client token:
# In metricq_example/cli.py:
import click
...
@click.command()
@click.option(
"--server",
metavar="URL",
default="amqp://localhost/",
show_default=True,
help="MetricQ server URL.",
)
@click.option(
"--token",
metavar="CLIENT_TOKEN",
default=default,
show_default=True,
help="A token to identify this client on the MetricQ network.",
)
def main(server: str, token: str):
...
Project versioning
In order to be a good network citizen, any MetricQ client should provide a version string when asked.
The single source of truth of a project’s version should be its git tags.
Where possible, use a semver-compatible version scheme.
Use setuptools_scm as a build dependency to create a version string
that will automatically be added to the package metadata and is accessible to
code at runtime:
# in pyproject.toml
[build-system]
requires = [
# ..., other build dependencies here
"setuptools_scm[toml]~=6.0",
]
# ...
[tool.setuptools_scm]
write_to = "metricq_example/version.py"
On installation, this creates a file metricq_example/version.py that
includes variables version (a str) and version_tuple
with the parsed version information.
Exclude this file from being tracked by git:
# in .gitignore
metricq_example/version.py
This file must be included in the final package, so add it to the package manifest:
# in MANIFEST.in
metricq_example/version.py
The metricq library will provide client information on request,
but you will need to supply a client version string.
When building a client, declare an identifier __version__ in the same
module as your client class to have it be picked up automatically by the
metricq library:
# in metricq_example/client.py
import metricq
# import this project's version string
from .version import version as __version__
# This could also be a Sink, HistoryClient, etc.
class MySource(metricq.Source):
# __version__ will be picked up automatically as this client's version
...
If you prefer less magic, explicitly provide the version string to the client’s base class constructor:
# in metricq_example/client.py
import metricq
from .version import version as client_version
class MySource(metricq.Source):
def __init__(self, ...):
...
super().__init__(client_version=client_version, ...)
When creating a new command line tool, also add a --version option:
# In metricq_example/cli.py:
import click
from .version import version
...
@click.command()
@click.version_option(version=version)
...
def main(...):
...
Development setup
To enable an easy development setup, define an extra dev,
that transitively includes all optional dependencies needed for a local development setup:
# In setup.cfg:
[options.extras_require]
test =
... # Dependencies needed for running tests
lint =
... # Dependencies needed to run linters
dev =
%(test)s
%(lint)s
...
The string %(foo) includes all dependencies of extra foo in another extra.
Create a new virtual environment for this project,
and then (with this environment activated) set up a local development environment by executing
$ pip install -e '.[dev]'
in the project directory.
Tests
We use pytest to define project tests.
Create an extra test that pulls pytest,
and pytest-asyncio when testing async code:
# In setup.cfg:
[options.extras_require]
test =
pytest
pytest-asyncio
dev =
%(test)s
...
Tests are usually placed outside of application code,
in files at at tests/test_*.py.
Place tests for module metricq_example.foo (at metricq_example/foo.py) in tests/test_foo.py.
For example, to test the function in module metricq_example.hello…
# In metricq_example/hello.py
def hello(name: str) -> str:
return f"Hello, {name}!"
…create a test like so:
# In tests/test_hello.py
import pytest
from metricq_example.hello import hello
def test_hello():
assert hello("Tester") == "Hello, Tester!"
Note
Use absolute imports when importing from your project, see the notes here.
Linting
We recommend a basic set of linters that (hopefully) help producing better code:
# In setup.cfg:
[options.extras_require]
lint =
black
check-manifest
flake8 ~= 3.8
flake8-bugbear
isort ~= 5.0
pre-commit
dev =
%(lint)s
...
This includes:
- black:
A code formatter. No need to spend time hand-formatting your code.
- check-manifest:
Keeps track of all the files included in built packages. Prevents you from accidentally forgetting files when packaging. Whooops.
check-manifestwill tell you to include/exclude files inMANIFEST.in.- flake8:
Helps you enforce some useful code styles.
flake8has plugin support;flake8-bugbearadds some helpful rules. A sensible default configuration includes the following:# In setup.cfg [flake8] # Tell flake8 which packages are part of your application: application-import-names = metricq_example, tests # This is the black default: max-line-length = 88 extend-exclude = .pytest_cache, # Add additional directories here to exclude from checking ... # Rules to check for select = # Regular flake8 rules C, E, F, W # flake8-bugbear rules B # pep8-naming rules N # Rules to ignore. Add a reason why. ignore = # E203: whitespace before ':' (not PEP8 compliant) E203 # E501: line too long (replaced by B950) E501 # W503: line break before binary operator (not PEP8 compliant) W503
- isort:
Automatically sorts your
importstatements. Keeps merge conflicts in import statements to a minimum.- pre-commit:
Adds
githooks, that automatically run other linters. Configure it in.pre-commit-config.yaml:default_language_version: python: python3.9 repos: - repo: https://gitlab.com/pycqa/flake8 rev: 3.9.2 hooks: - id: flake8 - repo: https://github.com/timothycrosley/isort rev: 5.8.0 hooks: - id: isort args: ["--check", "--diff"] - repo: https://github.com/psf/black rev: 21.5b1 hooks: - id: black args: ["--check", "--diff"] - repo: https://github.com/mgedmin/check-manifest rev: "0.46" hooks: - id: check-manifest