- 1 Background
- 2 Python Language Rules
- 3 Python Style Rules
- 4 Libraries
- 5 Tools
- 6 How to Extend This Guide
Python is a dynamic language used at Cookpad. This style guide is a list of dos and donβts for Python programs, inspired by the Google Python Style Guide. Rather than being a complete guide, this document aims to be a reference where decisions from previous Python style discussions are captured and to resolve ambiguities when they occur.
On occasion, you may encounter a stylistic convention that is covered in a few references. These references may also disagree in their convention. When answering a question of style, please follow the following order of precedence:
- Project specific conventions (where justified and absolutely necessary, and documented in a project-specific style guide)
- Cookpad Python Style Guide (this document)
- PEP8
- Google Python Style Guide
Most ambiguities are resolved by using a code formatter and linting rules, detailed in subsequent sections. Avoid (1) if possible to allow consistent coding experience throughout the company. Prefer to update this document.
Language rules are code choices affecting the code behaviour. Some are enforced by pylint
.
Code choices that don't have any effect on the code execution are documented in the section 3 Python Style Rules.
The highest supported version is the latest stable 3.10 Python version.
Type of codebase | Version support |
---|---|
New project | highest, unless prevented by required dependencies |
Existing project | the closest possible to highest |
New/existing library | the closest possible to highest |
- Use absolute imports.
- In case of a name conflict, import the module instead. If the package name conflicts as well, you may rename the import.
- Well known library abbreviations are allowed, if they are documented here.
- Do not use relative names in imports. Even if the module is in the same package, use the full package name. This helps prevent unintentionally importing a package twice.G224
- Do not use star imports. They pollute the module name space and is very rare to want import of everything.
-
Use absolute imports:
import module.sub_module from module.sub_module import SomeClass
-
Import module on name conflict:
from module import sub_module_a, sub_module_b print(sub_module_a.SomeClass) print(sub_module_b.SomeClass)
-
Rename import on name and module conflict:
from module.sub_a.module import SomeClass as SomeClassA from module.sub_b.module import SomeClass as SomeClassB
- Use well known library abbreviation:
import dask.dataframe as dd import matplotlib as mpl import matplotlib.pyplot as plt import pandas as pd import seaborn as sns import tensorflow as tf import torch.nn.functional as F
-
Don't use relative imports:
from .module import sub_module from ..module import sub_sub_module
-
Don't use star imports:
from module.sub_module import *
-
Don't use unnecessary renames:
from module.sub_module import SomeClass as SomeOtherClass
Conditional expressions (sometimes called a βternary operatorβ) are mechanisms that provide a shorter syntax for if statements.
Okay to use for simple cases. Each portion must fit on one line: true-expression, if-expression, else-expression. Use a complete if statement when things get more complicated.
Shorter and more convenient than an if statement.
May be harder to read than an if statement. The condition may be difficult to locate if the expression is long.
one_line = 'yes' if predicate(value) else 'no'
slightly_split = (
'yes' if some_long_module.some_long_predicate_function(value)
else 'no, nein, nyet'
)
the_longest_ternary_style_that_can_be_done = (
'yes, true, affirmative, confirmed, correct'
if some_long_module.some_long_predicate_function(value)
else 'no, false, negative, nay'
)
portion_too_long = (
'yes' if some_long_module.some_long_predicate_function(
really_long_variable_name
)
else 'no, false, negative, nay'
)
You should annotate code with type hints according to PEP-484, and type-check the code with mypy. Because there is no need to support older Python versions that don't understand type annotations, all type annotations must be in source code, not in comments, docstrings or stub *.pyi
files.G221
Make sure you are familiar with types provided by the typing package and you use those, instead of built-in functions.
- Type annotations improve the readability and maintainability of your code.
- The type checker will convert many runtime errors to build-time errors.
- Guides towards less complex code (no one wants to write complex annotations).
You will have to keep the type declarations up to date. You might see type errors that you think are valid code.
from typing import List, Dict
def func(a: int) -> List[int]:
dictionary: Dict[str, bool] = {}
-
Don't use docstring type annotations:
def func(a): """ Args: a (int): Input value. Returns: List[int]: 10 instance of ``a``. """ ...
-
Don't use type annotations in comments:
dictionary = {} # type: Dict[str, bool]
-
Don't use built-in function instead of a type:
def func(a: int) -> list[int]: dictionary: dict[str, bool] = {}
- When annotating a generator that only
yield
s but doesn'treturn
orsend()
, useIterator[...]
, notGenerator[...]
:from typing import Iterator def func() -> Iterator[int]: for i in range(10): yield i
If a function's argument defaults to None
, it should include the | None
type annotation. This is now the preferred approach in Python 3.10 and later.
def create_new_index(self, overide_index_timestamp: datetime | None = None) -> str:
...
def create_new_index(self, overide_index_timestamp: datetime = None) -> str:
...
def create_new_index(self, overide_index_timestamp: Optional[datetime] = None) -> str:
...
(Unless using Python <= 3.9)
Use async code only when you really need it.
- Async code is more prone to errors - forgetting
await
will often not raise any error in static code analysis because it is valid. It will return aFuture
instead of the expected result. - Async code is more difficult to test.
If you use async, make sure you run tasks concurrently when possible. Using asyncio.gather()
will cover most cases. There are other functions available if you need finer grained control.
-
Use
asyncio.gather()
to call multiple async functions concurrently:async def load_all(language_code: str, country_code: str): await asyncio.gather( load_definitions(language_code, country_code), load_stopwords(language_code), load_negators(language_code), )
-
Use
asyncio.gather(*(...))
to call a single async function in a loop concurrently:async def load_all(language_codes: List[str]): await asyncio.gather( *(load_definitions(language_code) for language_code in language_codes) )
-
Don't use several awaits, unless you want them to be serialized.
async def load_all(language_code: str, country_code: str): await load_definitions(language_code, country_code) # `load_stopwords` will start only when `load_definitions` has finished await load_stopwords(language_code) await load_negators(language_code)
You may need the calls to be serialized if:
- Calls are dependent (one uses results of another)
- You want to limit the resource utilisation (such as not running 300 calls to a single server concurrently)
-
Don't call await in a loop, unless you want the calls to be serialized:
async def load_all(language_codes: List[str]): for language_code in language_codes: await load_definitions(language_code) # Next `language_code` will be loaded only when the previous one has finished
-
Don't use
asyncio.wait()
, unless you want a finer grained control over results/exceptions:async def load_all(language_code: str, country_code: str): # Any exceptions raised in the `load_*` functions below will be ignored await asyncio.wait( [ load_definitions(language_code, country_code), load_stopwords(language_code), load_negators(language_code), ] ) # The following code will re-raise exceptions, similar to `asyncio.gather` async def load_all_with_exceptions(language_code: str, country_code: str): done, pending = await asyncio.wait( [ load_definitions(language_code, country_code), load_stopwords(language_code), load_negators(language_code), ] ) assert not pending for task in done: task.result()
Use the appropriate caching technique as using a wrong one may lead to memory leaks.
Q: Should I use a permanent cache or a TTL cache?
In a live service (as opposed to a job), we should avoid cases where we cache something at service startup and then leave it cached indefinitely, except in cases where the underlying value(s) will certainly not change throughout the lifetime of the service.
βοΈ Example where it's OK to permanently cache at startup:
- There are some static values held in a library that we load from disk. Those values only change when a new library version is released, so they'll be the same until the service is redeployed. We want to cache them to avoid repeatedly reloading them from disk during each request.
β Example where it's not OK to permanently cache at startup:
- Service pulls list of ingredients from search dictionary and loads them into memory. The reason for this is:
- If the underlying data are changing, in most cases you probably want your feature to stay fresh (for some definition of "fresh")
- You don't want big surprises when the service restarts. E.g., imagine the service is running for 5 days without restart, during which time the database has gone from 5 entries to 100000 entries.
Additional resources
- Careful when using lru_cache by Niko Wenselowski
- Python functools lru_cache with instance methods: release object, Stackoverflow
- functools.lru_cache keeps objects alive forever, Python 3.5 bug report
A summary of available alternatives at the time of writing:
library | function | method | dataclass method | async function | async method | TTL function | TTL method | TTL async function | TTL async method |
---|---|---|---|---|---|---|---|---|---|
functools |
βοΈ | β1 | β1 | β | β | β | β | β | β |
boltons.cacheutils |
βοΈ | βοΈ | β 2 | β | β | β | β | β | β |
cachetools |
βοΈ | βοΈ | βοΈ | β 3 | β 3 | βοΈ | βοΈ | β 3 | β 3 |
async-cache |
β | β | β | βοΈ | β | β | β | βοΈ | β |
- Use
cache
for zero-parameter, synchronous functions, static or class methods (typically used for postponed evaluation or singletons):from functools import cache @cache def get_settings(): ... class Singleton: @cache @staticmethod def get() -> "Singleton": return Singleton()
- Use
lru_cache
withmaxsize
set on synchronous functions, static or class methods with parameters:from functools import lru_cache @lru_cache(maxsize=128) def multiply(a: int, b: int) -> int: return a * b @lru_cache(maxsize=1) def expensive_object_creation(): ...
- Use
cached_property
on synchronous properties:from functools import cached_property class Query: def __init__(self, value: str): self._value = value @cached_property def normalized(self) -> str: return self._value.strip().lower()
- Use
cachetools
andasyncache.cachedmethod
on sync/async instance methods:This works on dataclasses too:from operator import attrgetter from cachetools import LRUCache from asyncache import cachedmethod class Number: def __init__(self, value: int): self._value = value self._cache = LRUCache(max_size=128) @cachedmethod(attrgetter("_cache")) def multiply(self, other: int) -> int: return self._value * other @cachedmethod(attrgetter("_cache")) async def add(self, other: int) -> int: return self._value + other
from typing import Awaitable from dataclasses import dataclass from operator import attrgetter from cachetools import cachedmethod, LRUCache @dataclass(frozen=True) class Number: value: int _cache: LRUCache = LRUCache(max_size=128) @cachedmethod(attrgetter("_cache")) def multiply(self, other: int) -> int: return self._value * other @cachedmethod(attrgetter("_cache")) async def add(self, other: int) -> Awaitable[int]: return self._value + other
- Use
cachetools
andasyncache.cached
for asynchronous functions or methods:from typing import Awaitable from asyncache import cached from cachetools import LRUCache @cached(LRUCache(128)) def multiply(a: int, b: int) -> int: return a * b @cached(LRUCache(128)) async def add(a: int, b: int) -> Awaitable[int]: return a + b
- Use
cachetools
if you need more advance caching, such as TTL:from cachetools import LRUCache, cached @cached(TTLCache(128, 3600)) def multiply(a: int, b: int) -> int: return a * b from typing import Awaitable import asyncache @asyncache.cached(TTLCache(128, 3600)) async def async_multiply(a: int, b: int) -> Awaitable[int]: return a * b
- Don't use
lru_cache
on instance methods.self
is kept in the cache as a key, preventing the object to be garbage collected:from functools import lru_cache class Query: def __init__(self, value: str): self._value = value @lru_cache(maxsize=1) def normalized(self) -> str: return self._value.strip().lower() @lru_cache(maxsize=1) def join(self, other: str) -> str: return self._value + other
- Don't use
lru_cache
on asynchronous function or methods. This decorator is not async compatible and the code will fail on subsequent executions:from functools import lru_cache @lru_cache(maxsize=1) async def async_multiply(a: int, b: int) -> Awaitable[int]: return a * b
- Don't use unbound cache for any function/method that takes 1 or more parameters:
from functools import lru_cache, cache @lru_cache(maxsize=None) def multiply(a: int, b: int) -> int: return a * b @cache def multiply(a: int, b: int) -> int: return a * b
Style rules are (non-)code choices having no effect on code behaviour. They are enforced by pylint
, pydocstyle
, pycodestyle
and black
.
Code choices that do affect code execution are documented in the section 2 Python Language Rules.
Maximum line length is 120 characters. Compliance with this line length requirement is taken care of by black
.
-
Function names, variable names, and file names should be descriptive; eschew abbreviation. In particular, do not use abbreviations that are ambiguous or unfamiliar to readers outside your project, and do not abbreviate by deleting letters within a word.G316
-
Python file names must have a .py extension and must not contain dashes (
-
). This allows them to be imported and unit tested. -
"Internal" means internal to a module, or protected or private within a class.
-
Prepending a single underscore (
_
) has some support for protecting module variables and functions (not included withfrom module import *
). While prepending a double underscore (__
aka "dunder") to an instance variable or method effectively makes the variable or method private to its class (using name mangling) we discourage its use as it impacts readability and testability and isnβt really private. Lint warnings take care of invalid access to protected members. -
Place related classes and top-level functions together in a module. Unlike Java, there is no need to limit yourself to one class per module. Unless you reach module size limit mandated by the linter.
-
Use
class MyClass
for class names,my_class.py
for module names. -
Use
test_my_class.py
for module related unit tests,class TestMyClass
/class TestMethodOfMyClass
for grouping related tests anddef should_do_something
for individual tests.
Type | Public | Internal |
---|---|---|
Packages | lower_with_under |
|
Modules | lower_with_under.py |
lower_with_under.py |
Classes | CapWords |
_CapWords |
Exceptions | CapWords |
|
Functions | lower_with_under() |
_lower_with_under |
Global/Class Constants | CAPS_WITH_UNDER |
_CAPS_WITH_UNDER |
Global/Class Variables | lower_with_under |
_lower_with_under |
Instance Variables | lower_with_under |
_lower_with_under |
Method Names | lower_with_under() |
_lower_with_under() |
Function/Method Parameters | lower_with_under |
|
Local Variables | lower_with_under |
- 1-2 character names except for counters or iterators. You may use
ex
as an exception identifier in try/except statements andfd
for file descriptor in with/open statements. - dashes (
-
) in any package/module name __double_leading_and_trailing_underscore__
names (reserved by Python)- Package names identical to standard libraries. Example:
test
βtests
- Package names identical to used external libraries. Example:
elasticsearch
βelastic_search
For source code, use American spelling, not British spelling.
class Serializer:
color = 0x00ffffff
class Serialiser:
colour = 0x00ffffff
Unused argument can be marked as such by deleting the variables at the beginning of the function/method. Always include a comment explaining why you are deleting it. "Unused" is sufficient.
def viking_cafe_order(spam, beans, eggs, haggis):
del beans, eggs, haggis # Unused by vikings.
return spam + spam + spam
- Don't use a prefix:
# Breaks callers that pass arguments by name def viking_cafe_order(spam, _, unused_eggs, _haggis): return spam + spam + spam
- Don't assign to
_
:# Does not enforce that the arguments are actually unused def viking_cafe_order(spam, beans, eggs, haggis): _ = beans, eggs, haggis return spam + spam + spam
Clearly communicate what is the interface of a class or module by appropriately marking entities as public or internal (see 3.2 Naming). As a rule of thumb, start with internal and make it public only if you need to.
If certain instance variables are read/write only, use properties or setters.
In case of data classes, consider making them immutable/frozen.
- The intended use is clearer with less documentation needed.
- Less of unexpected side effects.
_MODULE_CONSTANT = None
class _InternalHelperClass:
_internal_class_variable = True
public_class_variable = False
class PublicClass:
def __init__(self):
self._internal_instance_variable = _MODULE_CONSTANT
@property
def read_only_public_instance_variable(self):
return self._internal_instance_variable
MODULE_CONSTANT = None # Can be imported
class InternalHelperClass: # Can be imported
# internal_class_variable can be changed, not clear it should not
internal_class_variable = True
public_class_variable = False
class PublicClass:
def __init__(self):
# internal_instance_variable not read-only, not clear it should be
self.internal_instance_variable = MODULE_CONSTANT
@property
def read_only_public_instance_variable(self):
return self.internal_instance_variable
Docstrings are formatted using the Google style (as opposed to reST or numpy), as this is the most readable format. See an example. This format is partially enforced by pydocstyle
.
For docstring and comments format, please refer to the Google Python Style Guide, section 3.10. For documenting types, see 2.4 Type Annotations.
- Use double backticks for code in docstrings:
def method(value): """Modifies parameter ``value`` in place.""" ...
- Use single backticks for code in comments:
def method(value): value += " modified" # Modifies parameter `value` in place
Exceptions should be full and grammatically correct sentences.
raise ValueError(
f"Unknown object type {obj.__class__.__name__} for value {obj}. "
f"Consider adding support for it if acceptable."
)
raise NotImplementedError("Expected to be defined in sub-classed.")
raise ValueError(
f"Unknown object type {obj.__class__.__name__} for value {obj}. "
f"Consider adding support for it if acceptable" # missing dot
)
raise NotImplementedError("expected to be defined in sub-classed")
If a class inherits from no other base classes, do not inherit from object. This also applies to nested classes.
Explanation
There is no need to inherit from object
in Python 3. This was required in Python 2 to generate new-style classes. Python 3 doesn't have old-style classes anymore, thus there is no difference between classes inheriting from object
and those which don't.
class SampleClass:
pass
class OuterClass:
class InnerClass:
pass
class SampleClass(object):
pass
class OuterClass(object):
class InnerClass(object):
pass
Access class of an object obj
via obj.__class__
, not type(obj)
.
Explanation
There is no benefit in using type
in new-style classes.
- More readable than
type(obj)
.
- Slightly longer representation.
def dynamic_function(value: str):
if not isinstance(value, str):
raise TypeError(f"Unexpected type '{value.__class__.__name__}' used for 'value'")
def dynamic_function(value: str):
if not isinstance(value, str):
raise TypeError(f"Unexpected type '{type(value).__name__}' used for 'value'")
Prefer f-strings over str.format
. Don't use %
formatting.
There are a few legitimate uses of str.format
:
- formatting dictionaries
- string templating
f"The value is {value}." # "The value is 80."
f"{date} was on a {date:%A}" # "1991-10-12 was on a Saturday"
f"{{{4*10}}}" # "{ 40 }"
f" [line {lineno:2d}]"
f"{extra},waiters:{len(self._waiters)}"
-
Formatting a dictionary:
@dataclass class Point: x: int y: int def __str__(self): return "X: {x}, Y: {y}".format(self.__dict__)
-
String templating, template is dynamic:
@dataclass class Point: x: int y: int def format(self, output_template: str): return output_template.format(self.__dict__) Point(1, 2).format("X: {x}, Y: {y}")
-
String templating, template is used multiple times:
query_template = "/recipes?query={query}&country_code=GB&provider_id=1" client.get( query_template.format(query=Faker().sentence()), name=query_template.format(query="<RANDOM SENTENCE>"), )
-
Don't use
%
formatting:'error: %s' % msg
-
Don't use unnecessary
str.format
:"The value is {value}.".format(value=123)
In multi-line strings, use only regular strings or only f-strings. Combining them often leads to unpopulated placeholders.
-
Use regular strings if there is no string formatting needed:
value = ( "This is an extra long string " "wrapped to multiple lines." )
-
Use f-strings for string formatting:
value = ( f"This is an extra long string with a {placeholder} " f"wrapped to {count} lines." )
-
Use f-strings consistently, even when only some lines need to be formatted:
value = ( f"This is an extra long string with a {placeholder}. " f"This line has no placeholder but should consitently use an f-string." )
- Don't mix regular and f-strings:
value = ( f"This is an extra long string with a {placeholder} wrapped to " "{count} lines. I added the placeholder later and forgot to add the 'f'." # ^^^ Oops, this will render "{count} lines" )
The following section describes recommended and commonly used libraries, including conventions for their use.
Following section describe recommended and commonly used tools, including conventions for their use.
pylint is a tool for finding bugs and style problems in Python source code. It finds problems that are typically caught by a compiler for less dynamic languages like C and C++. Because of the dynamic nature of Python, some warnings may be incorrect; however, spurious warnings should be infrequent.
Catches easy-to-miss errors like typos, using-vars-before-assignment, etc., therefore faster PRs with fewer comments.
pylint
is not perfect. To take advantage of it, we will need sometimes to:
- write around it,
- suppress its warnings,
- or improve it.
Before you start thinking about suppressing warnings, make sure you fully understand the error message and the issue can't be solved in code.
Suppress warnings if they are inappropriate so that other issues are not hidden. To suppress warnings, you use # pylint: disable=<RULE NAME>
, in this order of preference:
- line-level: use for a single disable
- block-level: use if the same error appears multiple times in a single block or can't be easily disable on the same line
- file-level: suggests a larger issue where either the rules need to change or the solution needs a different direction.
Additionally:
- Use symbolic names to identify
pylint
warnings, not numeric codes. - Always add an explanation for your disable above or below the suppression.
See also section 3.4 Unused Function/Method Parameters.
- Suppressing in this way has the advantage that we can easily search for suppressions and revisit them.
- You may realise you don't need the suppression at all while trying to explain it in the comment (rubber ducking).
# My explanation for this disable
dict = 'something awful' # pylint: disable=redefined-builtin
-
Don't use numeric code instead of symbolic:
# My explanation for this disable dict = 'something awful' # pylint: disable=W0622
Not clear what is disabled.
-
Don't leave out explaining comment:
dict = 'something awful' # pylint: disable=redefined-builtin
Is this disable still needed? What was the motivation?
Black is an opinionated Python code formatter.
- Speed, determinism, and freedom from pycodestyle nagging about formatting.
- Saves time and mental energy for more important matters.
- Makes code reviews faster by producing the smallest diffs possible.
- Blackened code looks the same regardless of the project youβre reading.
- Formatting becomes transparent after a while and you can focus on the content instead.
- Not everyone will agree with the formatting.
- Manual intervention needed for long strings (black will not wrap them).
- It doesn't and will not order imports. Consider using
isort
for that purpose.
-
Run black with
pre-commit
before every push. See the Tasks Automation for guidance on combining black with pre-commit. -
Check code into CI if it is formatted.
-
Map black to a short cut in your IDE.
- Don't use the magic trailing comma unless you want to communicate adding more items soon:
# The parameters are not going to change any time soon ingredients=[ { "name": "Turkey", "quantity": "1", "position": 0 # Do not use a comma here }, ]
- Don't use the editor built-in formatter. While it may do a good job at formatting, it also may not be opinionated. Everyone could have different settings, resulting in different formatting. This difference will result in a lot of unnecessary changes in pull requests by hopping from one style to another.
- Don't use
yapf
. It is just less popular alternative.
Pipenv automatically creates and manages a virtualenv for projects, as well as adds/removes packages from a Pipfile
as you install/uninstall packages. It also generates a Pipfile.lock
, which is used to produce deterministic builds.
Q: Why not poetry?
pipenv was chosen when both pipenv and poetry were equals. Poetry has evolved since then to have better support. But there wasn't any reason good enough to migrate all project yet. See how they compare in star rating. A future migration could be made easier with dephell.
Q: How to fix versions of the "types-*" modular typeshed packages?
Mypy 0.900 brings modular typeshed packages. This can result in a lot of types-*
packages in the Pipfile
. While they all use semantic versioning, vast majority seem to be in the "initial development" (version 0.x
). In semantic versioning, this means that any subsequent version can be breaking (for example 0.1.1
-> 0.1.2
). In case of type packages, breaking means it will start failing type checks where it previously hasn't. This is solved by locking these dependencies to an exact version so that upgrade requires a manual edit of the Pipfile
. However, doing this for a lot of packages without them actually breaking creates a maintenance overhead.
As a reasonable compromise for maintainability of these packages, set all types-*
packages to *
(the latest version), fix to major or exact version only if type check fails.
- deterministic builds (as opposed to requirements.txt)
- slow (as opposed to poetry, requirements.txt)
- maintenance is almost non-existent
- Lock semantically versioned stable dependencies (version >= 1.0) to minor version with the PEP-440 compatible release operator:
pytest = "~=5.4" # version 6 will have breaking changes, upgrade should be explicit # version 5.5 is not breaking, upgrade can be automatic
- Lock dependencies that are not semantically versioned or in initial development (version 0.x) to a specific version with the PEP-440 version matching operator:
mypy = "==0.790" # any new version can be breaking, upgrade mustn't be automatic respx = "==0.15.0" # semantically versioned but any version can be breaking at this stage
- Leave development dependencies unlocked if breaking changes don't affect anybody:
ipython = "*" # you often use ipython in the project but want the latest
- Leave typeshed packages unlocked, unless it keeps breaking type checks often. If it does, lock to the next least strict version that will prevent the breaking change:
types-requests = "*" # version 6.x breaks type checks and we are not ready to move the main # package to version 6.x types-PyYAML = "~=5.4"
- Install packages in a reproducible way:
pipenv install --deploy # Aborts if the Pipfile.lock is out-of-date, or Python version is wrong.
- Don't leave code or testing dependencies unlocked:
pytest = "*" # could unexpectedly start failing in CI pipeline requests = "*" # could have API change and break the code
As an organisation, we embrace the use of third party open source packages in our software. Third party software comes with some risks and can be a vector for malicious software attacks. To mitigate risks we recommended following industry best practices.
This section is used to record recommendations for dealing with third party packages, although it is not yet complete. You should follow your own discretion (and industry best practices) when dealing with third party packages. All code authors and code reviewers should take particular care to evaluate new third party packages they are introducing, and also to vet version updates when bumping existing dependencies.
-
When updating existing third party dependencies, verify the provenance of each updated version. Use
pipenv update --dry-run
for a list of packages that can be updated. This is to avoid dependency hijack attacks. -
When referring to any package that is not hosted on the official PyPI (Python Package Index), you should explicitly refer to the source index from which the dependency will be imported, using
{..., index="my-index-name"}
. The following example is a Pipfile that is correctly adhering to this rule:[[source]] name = "pypi" url = "https://pypi.org/simple" verify_ssl = true [[source]] url = "http://pypi.home.kennethreitz.org/simple" verify_ssl = false name = "kennethreitz" # ... [packages] "your-favorite-lib" = {version="~=1.0", index="kennethreitz"} # <-- refers to `kennethreitzth` from the source list
This will ensure that the package comes from the intended source, rather than a package from PyPI (or any other index on your list of sources) that happens to have the same name. This applies when referring to any non-PyPI index, including internal Cookpad packages hosted on our internal index. A project that fails to follow this rule is at risk of a dependency confusion attack.
The pytest framework makes it easy to write small tests yet scales to support complex functional testing for applications and libraries.
All tests are expected in a directory called tests
. This is further subdivided:
-
By type of tests:
unit
for unit testsintegration
for integration tests
-
By type of code shared among tests of any type:
fixtures
for pytest fixtures shared among different testsutils
for any extra helper code used exclusively by testsfakers
for code generating fake instances of objects, usually withfaker
There should be no nested directories of the same names.
Additionally, all packages must contain an __init__.py
file to prevent name clashes.
- Avoids clash with
test
, which is a standard package. - Predictable locations.
- Allows running tests easily by type or all at once.
- Discourages code duplication
Directory structure under tests/unit
replicates directory structure of the source package. Test file is the same as the file it tests, with a test_
prefix.
If the test file grows too large, it may be an indicator or either missing parametrization or the source module doing too much. Try to introduce parametrization or split the source code first.
- Allows easier finding of relevant tests.
For the following source package:
src/
package/
module.py
- Test file matching source file
tests/ unit/ package/ test_module.py
For the same source package as in Do's:
-
Don't use mismatching path:
tests/ unit/ test_module.py
-
Don't leave out split by type:
tests/ module/ test_module.py
-
Don't use unpredictable file names:
tests/ unit/ package/ test_multiple_modules_in_one_file.py
Unlike unit tests, integration tests don't relate to a specific file . Therefore, the directory structure under tests/integration
should centre around features. All files must use the test_
prefix.
Always put a test in a class, even though pytest does not mandate it. Name the class after the thing you test. It can be a class, a feature, etc. The class must start with Test
.
Start test cases with should_
rather than test_
. The test case should require no docstring. All the information should fit in the method name.
The class name, method name and test parameters (if used) should for a sentence.
- Readable test output - it makes sense without additional context.
- Guides towards small, focused tests because of thinking "I'm testing something. It should do something."
- Focuses on features rather than line coverage.
- Tests often need to be static methods because
self
is not used.
Test of function datetime_to_iso_zulu
from utils.py
in test_utils.py
.
class TestDatetimeToIsoZulu:
@staticmethod
def should_convert_datetime_to_utc_string():
assert ...
@staticmethod
@pytest.mark.parametrize(
"instance", [
pytest.param(datetime.now(), id="datetime without timezone"),
pytest.param(None, id="None"),
]
)
def should_refuse():
assert ...
This will generate tests:
test_utils.TestDatetimeToIsoZulu.should_convert_datetime_to_utc_string
test_utils.TestDatetimeToIsoZulu.should_refuse.datetime without timezone
test_utils.TestDatetimeToIsoZulu.should_refuse.None
-
Don't leave out a class:
def test_convert_datetime_to_utc_string(): assert ...
Test of what?
-
Don't name test cases poorly:
def test_1(): assert ...
Can you tell what was supposed to happen if it fails?
Prefer to parametrize test cases with pytest.mark.parametrize
instead of copy and pasting them. Additionally, make sure each example has a readable ID by using pytest.param(..., id="β¦")
. Default ones are rarely readable. See also section 5.6.4 Test File Organisation.
Tips
If you're not sure what are the test parameters:
- Start with a copy of the test case
- Change the copy
- See where they differ
- Refactor into a single parametrized test case
- Decreased maintenance cost due to reduced duplication
- Readable test output
class TestDatetimeToIsoZulu:
@staticmethod
@pytest.mark.parametrize(
"instance", [
pytest.param(datetime.now(), id="datetime without timezone"),
pytest.param(None, id="None"),
]
)
def should_refuse(instance: Any):
assert ...
-
Don't use the same assertions in multiple tests, unless the function bodies are too different:
class TestDatetimeToIsoZulu: @staticmethod def should_refuse_datetime_without_timezone(): assert ... @staticmethod def should_refuse_none(): assert ...
-
Don't use default example IDs:
class TestDatetimeToIsoZulu: @staticmethod @pytest.mark.parametrize("instance", [[datetime.now()], [None]]) def should_refuse(instance: Any): assert ...
-
Don't parametrize values that don't change:
class TestDatetimeToIsoZulu: @staticmethod @pytest.mark.parametrize( "instance,key", [ pytest.param(datetime.now(), "key_name", id="datetime without timezone"), pytest.param(None, "key_name", id="None"), ] ) def should_refuse(instance: Any, key: str): # `key` is identical in all examples assert getattr(obj, key)
-
Don't parametrize if there is just one example, unless you expect more in the next bulk of work:
class TestDatetimeToIsoZulu: @staticmethod @pytest.mark.parametrize( "instance", [ pytest.param(datetime.now(), "key_name", id="datetime without timezone"), ] ) def should_refuse(instance: Any): assert ...
Tests and especially integration tests can be complex. It may not be evident from reading the code what is the setup, run and the actual test. If the test is more than a couple of lines, consider adding signpost comments borrowed from the Gherkin syntax: GIVEN
, WHEN
, THEN
Don't use the full Gherkin syntax though. If you have a need to explain a complicated test, prefer doing so using descriptive variables and/or functions. Only if it doesn't help, use regular (block) comments.
- Better structured tests because of thinking in term of given-when-then.
def should_give_lower_rating_to_recipes(
self, es_client: Elasticsearch, recipe_index: str, attribute: str, value: Any
):
# GIVEN
worse_recipe = _matching_recipe()
setattr(worse_recipe, attribute, value)
recipe_ids_in_relevance_order = _index_recipes(es_client, recipe_index, [_matching_recipe(), worse_recipe])
# WHEN
response = CLIENT.get("/recipes", params=self.REQUIRED_QUERY_PARAMS)
# THEN
assert response.status_code == HTTP_200_OK, response.text
recipe_response = RecipeResponse(**response.json())
assert [
result.id for result in recipe_response.result
] == recipe_ids_in_relevance_order, "Exactly 2 IDs were expected in this order."
-
Don't leave long tests without signposting:
def should_give_lower_rating_to_recipes( self, es_client: Elasticsearch, recipe_index: str, attribute: str, value: Any ): worse_recipe = _matching_recipe() setattr(worse_recipe, attribute, value) recipe_ids_in_relevance_order = _index_recipes(es_client, recipe_index, [_matching_recipe(), worse_recipe]) response = CLIENT.get("/recipes", params=self.REQUIRED_QUERY_PARAMS) assert response.status_code == HTTP_200_OK, response.text recipe_response = RecipeResponse(**response.json()) assert [ result.id for result in recipe_response.result ] == recipe_ids_in_relevance_order, "Exactly 2 IDs were expected in this order."
-
Don't use signposting when each section is one statement or evident:
def should_accept_page_number(self): # GIVEN page = 1 # WHEN response = CLIENT.get("/recipes", params={"page": page}) # THEN assert response.status_code == HTTP_200_OK, response.text
-
Don't use the full Gherkin syntax:
def should_give_lower_rating_to_recipes( self, es_client: Elasticsearch, recipe_index: str, attribute: str, value: Any ): # GIVEN I have a recipe with default values # AND I create a recipe with one stat worse worse_recipe = _matching_recipe() setattr(worse_recipe, attribute, value) # AND I index both recipes in the relevance order I expect to get them back recipe_ids_in_relevance_order = _index_recipes(es_client, recipe_index, [_matching_recipe(), worse_recipe]) # WHEN I make a request response = CLIENT.get("/recipes", params=self.REQUIRED_QUERY_PARAMS) # THEN the request is successful assert response.status_code == HTTP_200_OK, response.text # AND IDs order matches the expected relevance order recipe_response = RecipeResponse(**response.json()) assert [ result.id for result in recipe_response.result ] == recipe_ids_in_relevance_order, "Exactly 2 IDs were expected in this order."
Prefer to use invoke framework for tasks automation. Invoke is a Python task execution tool & library, drawing inspiration from various sources to arrive at a powerful & clean feature set.
Q: Can I use Invoke tasks in pre-commit?
Yes you can. See the following .pre-commit-config.yaml
file example for running a format
task that executes the black
formatter through pipenv
:
repos:
- repo: local
hooks:
- id: formatting
name: formatting
stages: [commit]
language: system
pass_filenames: false
require_serial: true
entry: pipenv run inv format
types: [python]
- Deduplication of tasks (similar to Makefile)
- Removes a lot of complexity from running processes from Python - input/output handling, command line options parsing
- No new language knowledge needed
- Easy to learn
- Harder to debug than plain Python script
-
Use the same name for tasks with the same purpose across multiple repositories. It reduces cognitive overhead when switching between repositories.
cd apps/sparks-jobs invoke verify-all cd ../recipe-search-svc invoke verify-all
Standard task names:
build
,format
,lint
,lint-docstyle
,lint-pycodestyle
,lint-pylint
,test-all
,test-integration
,test-unit
,typecheck
,verify-all
. Task names should be in kebab case.
-
Don't rename tasks if not needed. Simply name the function as the task if possible:
@task(name="lint-docstyle") # can be just `@task` def lint_docstyle(ctx, environment): ...
-
Don't use other task automation technologies if possible:
Script
section ofPipfile
- no benefit over calling invoke directly.- Ad-hoc Python scripts - there is no common interface.
- Makefiles or Bash scripts - not everyone knows them on sufficient level to be able to fully understand them or write them well.
- Fabric - Invoke is used already in many repositories. Using a single framework for tasks automation has lower cognitive overhead.
This is a live document and everyone is welcome to improve it. Feel free to open a PR in this repository to suggest changes or gather feedback.
- Prefer adding content to end of sections to prevent broken links and tedious relabeling of all subsequent sections.
- Do include section numbering (e.g.
2.4.3.1
) in the section title for do and don't items. These headings generate anchors that can be used in PRs to link to the particular example directly. Without a section numbering, it won't have a unique anchor. - There is a section template at the end of this document, hidden in a block comment. It has a lot of TODO comments to help you capture all information in a consistent style.