PyGuide

PyGuide

Topics on best practices of Python programming: testing, code quality, project organisation, packaging, etc.

[toc]

Setup Project

pipenv install black --dev
pipenv run black
pipenv install flake8 --dev

[flake8]
ignore = E203, E266, E501, W503
max-line-length = 88
max-complexity = 18
select = B,C,E,F,W,T4

pipenv run flake8

[mypy]
files=best_practices,test # dir
ignore_missing_imports=true

[tool:pytest]
testpaths=test

# .coveragerc
[run]
source = best_practices

[report]
exclude_lines =
    # Have to re-enable the standard pragma
    pragma: no cover

    # Don't complain about missing debug-only code:
    def __repr__
    if self\.debug

    # Don't complain if tests don't hit defensive assertion code:
    raise AssertionError
    raise NotImplementedError

    # Don't complain if non-runnable code isn't run:
    if 0:
    if __name__ == .__main__.:

pipenv run pytest --cov --cov-fail-under=100


# project cookiecutter
cookiecutter gh:...
git init
pipenv install -dev

PyTest

Sinlge-script app: benefit using below is that no need to turn project folder into package and able to specify file name

target = __import__("my_sum.py")
sum = target.sum

How to Structure Simple Test

  1. What to test?
  2. Unit test or integration test?

Then:

  1. Create inputs
  2. Run code capturing output
  3. Compare with expected

Example sum(): many behaviours could be checked:

Differences Testing Flask: Using unittest

Flask requires that the app be imported and then set in test mode. Init test client and use test client to make requests to any routes in app

All of test client init is done in the setup method of test case

# my_app is name of app

import my_app
import unittest

class MyTestCase(unittest.TestCase):
    
    def setUp(self):
        my_app.app.testing = True
        self.app = my_app.app.test_client()
        
	def test_home(self):
        result = self.app.get('/')
        # Make assertions...

# run with `python -m unittest discover`

Advanced Input: instance of class or context

Input is FIXTURE: create and reuse

Isolating Behaviour and State or Side Effects

Integration Test

Most can follow Unit Test Input, Run, Assert pattern with DIFF that this is checking more components AT ONCE and hence having more side effects, requiring more fixtures, like DB, network socket, config file.

Run once before deployment rather than per commit !!

Repo Structure

project/
|
|---my_app/
|	|---__init__.py
|---tests/
	|---unit/
		|---__init__.py
		|---test_sum.py
    |---integration/
    	|
    	|---fixtures/
    		|---test_basic.json
    		|---test_complex.json
    	|
    	|---__init__.py
    	|---test_integration.py

Automation: CI/CD - run tests, compile and publish and deploy

Tips

Test-Driven Dev with PyTest

TODO

MOCKs

Adding outside func call to app:

def initial_transform(data):
    """
    Flatten nested dicts
    """
    for item in list(data):
        if type(data[item]) is dict:
            for key in data[item]:
                data[key] = data[item][key]
			data.pop(item)
	
    outside_module.do_sth()
    return data

CI/CD

Multi-coder coordination management.

CI is practice of frequently building and testing each change done to code automatically and as early as possible.

Martin Fowler: CI is a software dev practice where members of a team integrate their work frequently, usually each person integrates at least daily - leading to multiple integrations per day. Each is verified by an automated build (and test) to detect integration errors asap.

Skipping Git repo, code, Unit Tests,

Unit Test

Done and push to master git add ..., git commit ... git push

Connect to Server CI - CircleCI

Advanced Topics

Other CI Services

OFF: Scripting Tips

# Highlight Tools

f-strings

from subprocess import call
call(f'curl -s -X GET http://{URL}/v2/{IMAGE}/tags/list')

pathlib

Without pathlib

import glob
import os
import shutil

for file_name in glob.glob('*.txt'):
    new_path = os.path.join('archive', file_name)
    shutil.move(file_name, new_path)

Examples

>>> import pathlib
>>> pathlib.Path.cwd()
PosixPath('/home/gahjelle/realpython/')

>>> pathlib.Path(r'C:\Users\gahjelle\realpython\file.txt')
WindowsPath('C:/Users/gahjelle/realpython/file.txt')

>>> pathlib.Path.home() / 'python' / 'scripts' / 'test.py'
PosixPath('/home/gahjelle/python/scripts/test.py')

>>> pathlib.Path.home().joinpath('python', 'scripts', 'test.py')
PosixPath('/home/gahjelle/python/scripts/test.py')

# CREATE FILE

path = pathlib.Path.cwd() / 'test.md'
with open(path, mode='r') as fid:
    headers = [line.strip() for line in fid if line.startswith('#')]
print('\n'.join(headers))

# READ
>>> pathlib.Path('test.md').read_text()
<the contents of the test.md-file>

# COMPONENTS
>>> path
PosixPath('/home/gahjelle/realpython/test.md')
>>> path.name
'test.md'
>>> path.stem
'test'
>>> path.suffix
'.md'
>>> path.parent
PosixPath('/home/gahjelle/realpython')
>>> path.parent.parent
PosixPath('/home/gahjelle')
>>> path.anchor
'/'

>>> path.parent.parent / ('new' + path.suffix)
PosixPath('/home/gahjelle/new.md')

>>> path
PosixPath('/home/gahjelle/realpython/test001.txt')
>>> path.with_suffix('.py')
PosixPath('/home/gahjelle/realpython/test001.py')
>>> path.replace(path.with_suffix('.py'))


>>> import collections
>>> collections.Counter(p.suffix for p in pathlib.Path.cwd().iterdir())
Counter({'.md': 2, '.txt': 4, '.pdf': 2, '.py': 1})



from pathlib import Path
p = Path('.')
p

str(p)
str(p.absolute())

p = p.absolute()
p.as_posix()

p.as_uri()

p.parent

p.relative_to(p.parent)

# example 2
q = p / 'newdir'
q

p.exists()

q.exists()

p.is_dir()

p.is_file()

# subdirectory
[x for x in p.iterdir() if x.is_dir()]

# files
[x for x in p.iterdir() if x.is_file()]

# find recursively
list(p.rglob('*'))

# directory
q.exists()

q.mkdir()
	
q.exists()

# files
fp = n / 'newfile.txt'
fp

with fp.open('wt') as f:
    f.write('The quick brown fox jumped over the lazy dog.')

fp.exists() and fp.is_file()

fp.read_text()

# removal
fp.unlink()

fp.exists()

q.rmdir()

q.exists()

click

import click

@click.command()
@click.option('--count', default=1, help='Number of greetings')
@click.option('--name', prompt='Your name', help='The person to greet')
def hello(count, name):
  """Simple app greeting NAME for a total of COUNT times"""
  for x in range(count):
    click.echo('Hello {name}!')
if __name__ == '__main__':
  hello()
python app.py --help

python examples/greet.py

python examples/greet.py --name 'PyconZA 2017' --count 3

setuptool integration check docs or google numismatic setup github

Example - crawl bitcoin data [asyncio, attrs, streamz, websocks]

streamz

from streamz import Stream source = Stream()
source.emit('hello') source.emit('world')


printer = source.map(print) for event in ['hello', 'world']: source.emit(event) 
L = [] 
collector = source.map(L.append) j for event in ['hello', 'world']: source.emit(event)

attr (better named tuples)

@attr.s(slots=True)
class Heartbeat:
    exchange = attr.ib()
    symbol = attr.ib()
    timestamp = attr.ib(default=attr.Factory(time.time))

used as

from numismatic.events import Heartbeat
Heartbeat('bitfinex', 'BTCUSD')

import attr
attr.asdict(Heartbeat('bitfinex', 'BTCUSD'))

numismatic

# set up stream

from streamz import Stream
source = Stream()
printer = source.map(print)
L = []
collector = source.map(L.append)

# prep connection
from numismatic.exchanges import BitfinexExchange
bfx = BitfinexExchange(source)
subscription = bfx.listen('BTCUSD', 'trades')

# run event loop
import asyncio
loop = asyncio.get_event_loop()
future = asyncio.wait([subscription], timeout=10)
loop.run_until_complete(future)

try: git clone https://github.com/snth/numismatic.git cd numismatic pip install -e .

run sans args for help coin

streamz as test tool before deploying KAFKA, FLINK

Style and Quality

Google Style

Exceptions

Global Variables

Nested/Local/Inner Classes and Functions

Comprehensions & Generator Expressions

# YES
result = [mapping_expr for value in iterable if filter_expr]
result = [{'key': value} for value in iterable
	if a_long_filter_expression(value)]
result = [complicated_transform(x)
	for x in iterable if predicate(x)]
descriptive_name = [
	transform({'key': key, 'value': value}, color='black')
	for key, value in generate_iterable(some_input)
	if complicated_condition_is_met(key, value)
]
result = []
for x in range(10):
	for y in range(5):
		if x * y > 10:
			result.append((x, y))
return {x: complicated_transform(x)
	for x in long_generator_function(parameter)
	if x is not None}
squares_generator = (x**2 for x in range(10))
unique_names = {user.name for user in users if user is not None}
eat(jelly_bean for jelly_bean in jelly_beans
	if jelly_bean.color == 'black')

# NO
result = [complicated_transform(
	x, some_argument=x+1)
	for x in iterable if predicate(x)]
result = [(x, y) for x in range(10) for y in range(5) if x * y > 10]
return ((x, y, z)
	for x in xrange(5)
	for y in xrange(5)
	if x != y
	for z in xrange(5)
        if y != z)

Default Iterators and Operators

# Yes
for key in adict: ...
if key not in adict: ...
for line in afile: ...
for k, v in adict.items(): ...
# No
for key in adict.keys(): ...
if not adict.has_key(key):...
for line in afile.readlines(): ...
for k, v in dict.iteritems(): ....

Properties

# Yes
class Square(object):
    """A square with two properties: a writable area and a read-only perimeter.
    To use:
    >>> sq = Square(3)
    >>> sq.area
    9
    >>> sq.perimeter
    12
    >>> sq.area = 16
    >>> sq.side
    4
    >>> sq.perimeter
    16
    """
    def __init__(self, side):
    	self.side = side
    @property
    def area(self):
    	"""Area of the square."""
    	return self._get_area()
    @area.setter
    def area(self, area):
    	return self._set_area(area)
    def _get_area(self):
    	"""Indirect accessor to calculate the 'area' property."""
        return self.side ** 2
    def _set_area(self, area):
        """Indirect setter to set the 'area' property."""
   		self.side = math.sqrt(area)
    @property
    def perimeter(self):
    	return self.side * 4

Conditional !!!

# Yes
if not users:
    print(' no users')
if foo == 0:
    self.handle_zero()
if i % 10 == 0:
    self.handle_multiple_of_ten()
def f(x=None):
    if x is None:
        x = []

# No
if len(users) == 0:
    ...
if foo is not None and not foo:
    ...
if not i % 0:
    ...
def f(x=None):
    x = x or []

Decorators

class C:
    @my_decorator
    def method(self):
        # method body
        
# equivalent to
class C:
    def method(self):
        # body
	method = my_decorator(method)

Threading

Power Features

Numpy Style Docstrings

# -*- coding: utf-8 -*-
"""Example NumPy style docstrings.

This module demonstrates documentation as specified by the `NumPy
Documentation HOWTO`_. Docstrings may extend over multiple lines. Sections
are created with a section header followed by an underline of equal length.

Example
-------
Examples can be given using either the ``Example`` or ``Examples``
sections. Sections support any reStructuredText formatting, including
literal blocks::

    $ python example_numpy.py


Section breaks are created with two blank lines. Section breaks are also
implicitly created anytime a new section starts. Section bodies *may* be
indented:

Notes
-----
    This is an example of an indented section. It's like any other section,
    but the body is indented to help it stand out from surrounding text.

If a section is indented, then a section break is created by
resuming unindented text.

Attributes
----------
module_level_variable1 : int
    Module level variables may be documented in either the ``Attributes``
    section of the module docstring, or in an inline docstring immediately
    following the variable.

    Either form is acceptable, but the two should not be mixed. Choose
    one convention to document module level variables and be consistent
    with it.


.. _NumPy Documentation HOWTO:
   https://github.com/numpy/numpy/blob/master/doc/HOWTO_DOCUMENT.rst.txt

"""

module_level_variable1 = 12345

module_level_variable2 = 98765
"""int: Module level variable documented inline.

The docstring may span multiple lines. The type may optionally be specified
on the first line, separated by a colon.
"""


def function_with_types_in_docstring(param1, param2):
    """Example function with types documented in the docstring.

    `PEP 484`_ type annotations are supported. If attribute, parameter, and
    return types are annotated according to `PEP 484`_, they do not need to be
    included in the docstring:

    Parameters
    ----------
    param1 : int
        The first parameter.
    param2 : str
        The second parameter.

    Returns
    -------
    bool
        True if successful, False otherwise.

    .. _PEP 484:
        https://www.python.org/dev/peps/pep-0484/

    """


def function_with_pep484_type_annotations(param1: int, param2: str) -> bool:
    """Example function with PEP 484 type annotations.

    The return type must be duplicated in the docstring to comply
    with the NumPy docstring style.

    Parameters
    ----------
    param1
        The first parameter.
    param2
        The second parameter.

    Returns
    -------
    bool
        True if successful, False otherwise.

    """


def module_level_function(param1, param2=None, *args, **kwargs):
    """This is an example of a module level function.

    Function parameters should be documented in the ``Parameters`` section.
    The name of each parameter is required. The type and description of each
    parameter is optional, but should be included if not obvious.

    If \*args or \*\*kwargs are accepted,
    they should be listed as ``*args`` and ``**kwargs``.

    The format for a parameter is::

        name : type
            description

            The description may span multiple lines. Following lines
            should be indented to match the first line of the description.
            The ": type" is optional.

            Multiple paragraphs are supported in parameter
            descriptions.

    Parameters
    ----------
    param1 : int
        The first parameter.
    param2 : :obj:`str`, optional
        The second parameter.
    *args
        Variable length argument list.
    **kwargs
        Arbitrary keyword arguments.

    Returns
    -------
    bool
        True if successful, False otherwise.

        The return type is not optional. The ``Returns`` section may span
        multiple lines and paragraphs. Following lines should be indented to
        match the first line of the description.

        The ``Returns`` section supports any reStructuredText formatting,
        including literal blocks::

            {
                'param1': param1,
                'param2': param2
            }

    Raises
    ------
    AttributeError
        The ``Raises`` section is a list of all exceptions
        that are relevant to the interface.
    ValueError
        If `param2` is equal to `param1`.

    """
    if param1 == param2:
        raise ValueError('param1 may not be equal to param2')
    return True


def example_generator(n):
    """Generators have a ``Yields`` section instead of a ``Returns`` section.

    Parameters
    ----------
    n : int
        The upper limit of the range to generate, from 0 to `n` - 1.

    Yields
    ------
    int
        The next number in the range of 0 to `n` - 1.

    Examples
    --------
    Examples should be written in doctest format, and should illustrate how
    to use the function.

    >>> print([i for i in example_generator(4)])
    [0, 1, 2, 3]

    """
    for i in range(n):
        yield i


class ExampleError(Exception):
    """Exceptions are documented in the same way as classes.

    The __init__ method may be documented in either the class level
    docstring, or as a docstring on the __init__ method itself.

    Either form is acceptable, but the two should not be mixed. Choose one
    convention to document the __init__ method and be consistent with it.

    Note
    ----
    Do not include the `self` parameter in the ``Parameters`` section.

    Parameters
    ----------
    msg : str
        Human readable string describing the exception.
    code : :obj:`int`, optional
        Numeric error code.

    Attributes
    ----------
    msg : str
        Human readable string describing the exception.
    code : int
        Numeric error code.

    """

    def __init__(self, msg, code):
        self.msg = msg
        self.code = code


class ExampleClass(object):
    """The summary line for a class docstring should fit on one line.

    If the class has public attributes, they may be documented here
    in an ``Attributes`` section and follow the same formatting as a
    function's ``Args`` section. Alternatively, attributes may be documented
    inline with the attribute's declaration (see __init__ method below).

    Properties created with the ``@property`` decorator should be documented
    in the property's getter method.

    Attributes
    ----------
    attr1 : str
        Description of `attr1`.
    attr2 : :obj:`int`, optional
        Description of `attr2`.

    """

    def __init__(self, param1, param2, param3):
        """Example of docstring on the __init__ method.

        The __init__ method may be documented in either the class level
        docstring, or as a docstring on the __init__ method itself.

        Either form is acceptable, but the two should not be mixed. Choose one
        convention to document the __init__ method and be consistent with it.

        Note
        ----
        Do not include the `self` parameter in the ``Parameters`` section.

        Parameters
        ----------
        param1 : str
            Description of `param1`.
        param2 : :obj:`list` of :obj:`str`
            Description of `param2`. Multiple
            lines are supported.
        param3 : :obj:`int`, optional
            Description of `param3`.

        """
        self.attr1 = param1
        self.attr2 = param2
        self.attr3 = param3  #: Doc comment *inline* with attribute

        #: list of str: Doc comment *before* attribute, with type specified
        self.attr4 = ["attr4"]

        self.attr5 = None
        """str: Docstring *after* attribute, with type specified."""

    @property
    def readonly_property(self):
        """str: Properties should be documented in their getter method."""
        return "readonly_property"

    @property
    def readwrite_property(self):
        """:obj:`list` of :obj:`str`: Properties with both a getter and setter
        should only be documented in their getter method.

        If the setter method contains notable behavior, it should be
        mentioned here.
        """
        return ["readwrite_property"]

    @readwrite_property.setter
    def readwrite_property(self, value):
        value

    def example_method(self, param1, param2):
        """Class methods are similar to regular functions.

        Note
        ----
        Do not include the `self` parameter in the ``Parameters`` section.

        Parameters
        ----------
        param1
            The first parameter.
        param2
            The second parameter.

        Returns
        -------
        bool
            True if successful, False otherwise.

        """
        return True

    def __special__(self):
        """By default special members with docstrings are not included.

        Special members are any methods or attributes that start with and
        end with a double underscore. Any special member with a docstring
        will be included in the output, if
        ``napoleon_include_special_with_doc`` is set to True.

        This behavior can be enabled by changing the following setting in
        Sphinx's conf.py::

            napoleon_include_special_with_doc = True

        """
        pass

    def __special_without_docstring__(self):
        pass

    def _private(self):
        """By default private members are not included.

        Private members are any methods or attributes that start with an
        underscore and are *not* special. By default they are not included
        in the output.

        This behavior can be changed such that private members *are* included
        by changing the following setting in Sphinx's conf.py::

            napoleon_include_private_with_doc = True

        """
        pass

    def _private_without_docstring(self):
        pass

Type Hinting (mypy)

# inline types
>>> name: str = "Guido"
>>> pi: float = 3.142
>>> centered: bool = False
>>> names: list = ["Guido", "Jukka", "Ivan"]
>>> version: tuple = (3, 7, 1)
>>> options: dict = {"centered": False, "capitalize": True}

# should use special types - composite types
from typing import Dict, List, Tuple
names: List[str] = ["..."]
version: Tuple[int, int, int] = (3, 7, 1)
options: Dict[str, bool] = ...

# more composite types: Counter, Deque, FrozenSet, NamedTuple, Set
def create_deck(shuffle: bool = False) -> List[Tuple[str, str]]:
    ...
   
# if care not the content of sequence, use typing.Sequence
def square(elems: Sequence[float]) -> List[float]:
    return [x**2 for x in elems]

# aliasing (save repeating certain types)
Card = Tuple[str, str]
Deck = List[Card]
def deal_hands(deck: Deck) -> Tuple[Deck, Deck, Deck, Deck]:
     return (deck[0::4], ...)
    
# TypeVar - special var taking on any type, conditioned
Choosable = TypeVar("Chooseable")
def choose(items: Sequence[Choosable]) -> Choosable: 
    return random.choice(items)
reveal_type(choose(["Python"], 3, 7)) # does its best to accommodate
# now with constrain
Choosable = TypeVar("Choosable", str, float)

# protocol specifies one ore more methods that must be implemented
# e.g. all cls defining .__len__() fulfull the typing.Size protocol
# therefore annotate len() as :
from typing import Sized
def len(obj: Sized) -> int:
    return obj.__len__()
# other protocols defined include Container, Iterable, Awaitable, ContextManager
# custom protocol
from typing_extensions import Protocol
class Sized(Protocol):
    def __len__(self) -> int: ...
def len(obj: Sized) -> int:
    return obj.__len__()

# Optional for None/other argument case
def player_order(
	names: Sequence[str], start: Optional[str] = None
) -> Sequence[str]:
    ...
# hence a var either has the type specified or is None 
# equivalent: Union[None, str]
# NOTE: when using Optional or Union, must take care of correct type!
# by testing var is type !! else causes static type errors and runtime errors!!
def player_order(
	... = None
) -> Sequence[str]:
    start_idx = names.index(start)
    return names[start_idx:] + names[:start_idx]
# mypy code.py >>> Arg 1 to "index" of "list" has incompatible type
# "Optional[str]"; expected "str"
# start: str = None is ok and often default hanlded! 

# Class types has special cases such as internal returns
class Deck:
    @classmethod
    def create(cls, shuffle: bool = False) -> "Deck":
        cards = ...
        ...
        return cls(cards)
# with __future__ import annotations, can use Deck instead of "Deck" 
# EVEN BEFORE Deck is defined !!! `def create(...) -> Deck:`

# Returning self or cls
# typically not annotate self/cls
# ONE case might be needed! Superclass inheritance returning cls/self
from datetime import date
class Animal:
    def __init__(self, name: str, birthday: date) -> None:
        self.name = name
        self.birthday = birthday
        
	@classmethod
    def newborn(cls, name: str) -> "Animal":
        return cls(name, date.today())
    def twin(self, name: str) -> "Animal":
        cls = self.__class__
        return cls(name, self.birthday)
    
class Dog(Animal):
    def bark(self) -> None:
        print(f"{self.name} says woof!")
fido = Dog.newborn("Fido")
pluto = fido.twin("Pluto")
fido.bark()
pluto.bark()
# mypy dogs.py >>> error: "Animal" has no attribute "bark" x2
# ISSUE is that even though inherited Dog.newborn() and Dog.twin() methods will
# return a Dog the annotation says taht they return an Animal
# Hence be careful to ensure annotation is correct!!!
# return type should match type of self/cls by tracking
from typing import Type, TypeVar
TAnimal = TypeVar("TAnimal", bound="Animal")
class Animal:
    def __init__(...) -> None:
        ...
    @classmethod
    def newborn(cls: Type[TAnimal], name: str) -> TAnimal:
        ...
	def twin(self: TAnimal, name: str) -> TAnimal:
        ...
# TAnimal (TypeVar) used to denote return values might be inheritances of subcls of Animal
# Animal is an upper bound for TAnimal, meaning only be Animal or one of its subcls
# typing.Type[] is the typing equivalent of type() - noting that cls method expects a class
# and returns an instance of that cls

# Funcs and Methods
from typing import Callable
def do_twice(func: Callable[[str], str], arg: str) -> str:
    ...
    
    
# EXAMPLE
from typing import Tuple, Iterable, Dict, List, DefaultDict
from collections import defaultdict

def create_tree(tuples: Iterable[Tuple[int, int]]) -> DefaultDict[int, List[int]]:
    """
    Return a tree given tuples of (child, father)

    The tree structure is as follows:

        tree = {node_1: [node_2, node_3], 
                node_2: [node_4, node_5, node_6],
                node_6: [node_7, node_8]}
    """
    tree = defaultdict(list) 
    for child, father in tuples:
        if father:
            tree[father].append(child)
    return tree

print(create_tree([(2.0,1.0), (3.0,1.0), (4.0,3.0), (1.0,6.0)]))
# will print
# defaultdict( 'list'="">, {1.0: [2.0, 3.0], 3.0: [4.0], 6.0: [1.0]}

# error: tree = defaltdict(list)
# Mypy cannot infer tree is indeed of type intended
tree: DefaultDict[int, List[int]] = defaultdict(list) # inline new var definition

Package

- README.rst
- LICENSE
- setup.py	# pkg and dist management
- requirements.txt 	# optional inside setup.py
- sample	# main app
	- __init__.py
	- core.py
	- helpers.py
- docs
	- conf.py
	- index.rst
- tests
	- test_basic.py
	- test_advanced.py

Makefile

init:
pip install -r requirements.txt

test:
py.test tests

PIPENV - Python new packaging tool

Dependency MGT with requirements.txt

QUESTION: HOW TO ALLOW FOR DETEMINSTIC BUILDS FOR PYTHON PROJECT WITHOUT GAINING THE RESPONSIBILITY OF UPDATING VERSIONS OF SUB-DEPENS?

Environment Control by virutalenv or venv in P3

Pipenv Intro

Package Distribution with Pipenv

Flask Project Repo

flaskr/
│
├── flaskr/
│   ├── ___init__.py
│   ├── db.py
│   ├── schema.sql
│   ├── auth.py
│   ├── blog.py
│   ├── templates/
│   │   ├── base.html
│   │   ├── auth/
│   │   │   ├── login.html
│   │   │   └── register.html
│   │   │
│   │   └── blog/
│   │       ├── create.html
│   │       ├── index.html
│   │       └── update.html
│   │ 
│   └── static/
│       └── style.css
│
├── tests/
│   ├── conftest.py
│   ├── data.sql
│   ├── test_factory.py
│   ├── test_db.py
│   ├── test_auth.py
│   └── test_blog.py
│
├── venv/
│
├── .gitignore
├── setup.py
└── MANIFEST.in

code · notebook · prose · gallery · qui et quoi? · main