├── .coveragerc
├── .gitignore
├── .isort.cfg
├── .pydocstyle.ini
├── .travis.yml
├── CHANGELOG.rst
├── LICENSE.txt
├── MANIFEST.in
├── README.rst
├── doc8.ini
├── docs
├── Makefile
├── _static
│ └── sqreen_logo.svg
├── api.rst
├── changelog.rst
├── conf.py
├── index.rst
├── introduction.rst
└── make.bat
├── pytest.ini
├── requirements.in
├── requirements.txt
├── setup.cfg
├── setup.py
├── src
└── aiocontext
│ ├── __about__.py
│ ├── __init__.py
│ ├── context.py
│ ├── errors.py
│ └── task_factory.py
├── tests
├── conftest.py
├── test_aiocontext.py
└── test_asyncio.py
└── tox.ini
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = True
3 | source = aiocontext
4 |
5 | [paths]
6 | source =
7 | src/aiocontext
8 | .tox/*/lib/python*/site-packages/aiocontext
9 |
10 | [report]
11 | fail_under = 100
12 | show_missing = True
13 | exclude_lines =
14 | if __name__ == .__main__.:
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.egg-info/
2 | /.cache/
3 | /.coverage
4 | /.coverage.*
5 | /.tox/
6 | /build/
7 | /dist/
8 | /docs/_build/
9 | __pycache__/
10 |
--------------------------------------------------------------------------------
/.isort.cfg:
--------------------------------------------------------------------------------
1 | [settings]
2 | default_section = THIRDPARTY
3 | known_first_party = aiocontext
4 | lines_after_imports = 2
5 | skip = .tox
6 |
--------------------------------------------------------------------------------
/.pydocstyle.ini:
--------------------------------------------------------------------------------
1 | [pydocstyle]
2 | add_ignore = D105, D107
3 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | python: "3.6"
3 |
4 | install:
5 | - pip install codecov tox
6 |
7 | script:
8 | - tox
9 |
10 | matrix:
11 | include:
12 | - env: TOXENV=flake8
13 | - env: TOXENV=isort
14 | - env: TOXENV=pydocstyle
15 | - python: "3.5"
16 | env: TOXENV=py35
17 | - python: "3.6"
18 | env: TOXENV=py36-cov
19 | after_success:
20 | - coverage combine
21 | - codecov
22 | - env: TOXENV=manifest
23 | - env: TOXENV=metadata
24 | - env: TOXENV=doc8
25 | - env: TOXENV=sphinx
26 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | Changelog
2 | =========
3 |
4 | All notable changes to this project are documented in this file.
5 |
6 | The format is based on `Keep a Changelog`_ and this project adheres to
7 | `Semantic Versioning`_.
8 |
9 | .. _Keep a Changelog: http://keepachangelog.com/
10 | .. _Semantic Versioning: https://semver.org/
11 |
12 | 0.1.2 - 2019-05-12
13 | ------------------
14 |
15 | Added
16 | ^^^^^
17 |
18 | * Python 3.7 classifier in ``setup.py``.
19 | * Mention ``contextvars`` in documentation.
20 |
21 | Changed
22 | ^^^^^^^
23 |
24 | * Update dependencies.
25 |
26 | 0.1.1 - 2018-03-05
27 | ------------------
28 |
29 | Fixed
30 | ^^^^^
31 |
32 | * Removed incorrect PyPI classifiers in ``setup.py``.
33 |
34 | 0.1.0 - 2018-03-05
35 | ------------------
36 |
37 | Added
38 | ^^^^^
39 |
40 | * Initial release.
41 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Sqreen SAS
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include .*.cfg *.in *.ini .*rc *.rst *.txt *.yml
2 | include docs/conf.py docs/make.bat docs/Makefile docs/_static/sqreen_logo.svg
3 | recursive-include docs *.rst
4 | prune docs/_build
5 | recursive-include tests *.py
6 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | AioContext
2 | ==========
3 |
4 | Context information storage for asyncio.
5 |
6 | If you're interested in knowing more about the rationale behind writing this
7 | library, please read our blog post `Context information storage for asyncio`_.
8 |
9 | .. _Context information storage for asyncio: https://blog.sqreen.io/asyncio/
10 |
11 | Project Information
12 | -------------------
13 |
14 | AioContext is released under the MIT license, its documentation lives at `Read
15 | the Docs`_, the code on `GitHub`_ and the latest release on `PyPI`_.
16 |
17 | .. _Read the Docs: http://aiocontext.readthedocs.io/
18 | .. _GitHub: https://github.com/sqreen/AioContext
19 | .. _PyPI: https://pypi.python.org/pypi/aiocontext
20 |
--------------------------------------------------------------------------------
/doc8.ini:
--------------------------------------------------------------------------------
1 | [doc8]
2 | ignore-path = .tox/, src/*.egg-info/
3 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | SPHINXPROJ = aiocontext
8 | SOURCEDIR = .
9 | BUILDDIR = _build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/docs/_static/sqreen_logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/api.rst:
--------------------------------------------------------------------------------
1 | API reference
2 | =============
3 |
4 | Task factories
5 | --------------
6 |
7 | .. autofunction:: aiocontext.wrap_task_factory
8 |
9 | .. autofunction:: aiocontext.unwrap_task_factory
10 |
11 | Contexts
12 | --------
13 |
14 | .. autoclass:: aiocontext.Context
15 |
16 | .. autofunction:: aiocontext.chainmap_copy
17 |
18 | .. autofunction:: aiocontext.get_loop_contexts
19 |
20 | Exceptions
21 | ----------
22 |
23 | .. autoexception:: aiocontext.AioContextError
24 |
25 | .. autoexception:: aiocontext.EventLoopError
26 |
27 | .. autoexception:: aiocontext.TaskFactoryError
28 |
29 | .. autoexception:: aiocontext.TaskContextError
30 |
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../CHANGELOG.rst
2 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | #
4 | # aiocontext documentation build configuration file, created by
5 | # sphinx-quickstart on Mon Jan 15 11:11:28 2018.
6 | #
7 | # This file is execfile()d with the current directory set to its
8 | # containing dir.
9 | #
10 | # Note that not all possible configuration values are present in this
11 | # autogenerated file.
12 | #
13 | # All configuration values have a default; values that are commented out
14 | # serve to show the default.
15 |
16 | # If extensions (or modules to document with autodoc) are in another directory,
17 | # add these directories to sys.path here. If the directory is relative to the
18 | # documentation root, use os.path.abspath to make it absolute, like shown here.
19 | #
20 | # import os
21 | # import sys
22 | # sys.path.insert(0, os.path.abspath('.'))
23 | import aiocontext
24 |
25 |
26 | # -- General configuration ------------------------------------------------
27 |
28 | # If your documentation needs a minimal Sphinx version, state it here.
29 | #
30 | # needs_sphinx = '1.0'
31 |
32 | # Add any Sphinx extension module names here, as strings. They can be
33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
34 | # ones.
35 | extensions = [
36 | 'sphinx.ext.autodoc',
37 | 'sphinx.ext.intersphinx',
38 | 'sphinx.ext.viewcode',
39 | ]
40 |
41 | # Add any paths that contain templates here, relative to this directory.
42 | templates_path = ['_templates']
43 |
44 | # The suffix(es) of source filenames.
45 | # You can specify multiple suffix as a list of string:
46 | #
47 | # source_suffix = ['.rst', '.md']
48 | source_suffix = '.rst'
49 |
50 | # The master toctree document.
51 | master_doc = 'index'
52 |
53 | # General information about the project.
54 | project = 'AioContext'
55 | copyright = aiocontext.__copyright__
56 | author = aiocontext.__author__
57 |
58 | # The version info for the project you're documenting, acts as replacement for
59 | # |version| and |release|, also used in various other places throughout the
60 | # built documents.
61 | #
62 | # The short X.Y version.
63 | version = '.'.join(aiocontext.__version__.split('.', 2)[:2])
64 | # The full version, including alpha/beta/rc tags.
65 | release = aiocontext.__version__
66 |
67 | # The language for content autogenerated by Sphinx. Refer to documentation
68 | # for a list of supported languages.
69 | #
70 | # This is also used if you do content translation via gettext catalogs.
71 | # Usually you set "language" from the command line for these cases.
72 | language = None
73 |
74 | # List of patterns, relative to source directory, that match files and
75 | # directories to ignore when looking for source files.
76 | # This patterns also effect to html_static_path and html_extra_path
77 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
78 |
79 | # The name of the Pygments (syntax highlighting) style to use.
80 | pygments_style = 'sphinx'
81 |
82 | # If true, `todo` and `todoList` produce output, else they produce nothing.
83 | todo_include_todos = False
84 |
85 |
86 | # -- Options for HTML output ----------------------------------------------
87 |
88 | # The theme to use for HTML and HTML Help pages. See the documentation for
89 | # a list of builtin themes.
90 | #
91 | html_theme = 'alabaster'
92 |
93 | # Theme options are theme-specific and customize the look and feel of a theme
94 | # further. For a list of options available for each theme, see the
95 | # documentation.
96 | #
97 | # html_theme_options = {}
98 |
99 | # Add any paths that contain custom static files (such as style sheets) here,
100 | # relative to this directory. They are copied after the builtin static files,
101 | # so a file named "default.css" will overwrite the builtin "default.css".
102 | html_static_path = ['_static']
103 | html_logo = '_static/sqreen_logo.svg'
104 |
105 | # Custom sidebar templates, must be a dictionary that maps document names
106 | # to template names.
107 | #
108 | # This is required for the alabaster theme
109 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
110 | html_sidebars = {
111 | '**': [
112 | # 'localtoc.html',
113 | 'globaltoc.html',
114 | 'relations.html', # needs 'show_related': True theme option to display
115 | 'searchbox.html',
116 | ]
117 | }
118 |
119 |
120 | # -- Options for HTMLHelp output ------------------------------------------
121 |
122 | # Output file base name for HTML help builder.
123 | htmlhelp_basename = aiocontext.__title__
124 |
125 |
126 | # -- Options for LaTeX output ---------------------------------------------
127 |
128 | latex_elements = {
129 | # The paper size ('letterpaper' or 'a4paper').
130 | #
131 | # 'papersize': 'letterpaper',
132 |
133 | # The font size ('10pt', '11pt' or '12pt').
134 | #
135 | # 'pointsize': '10pt',
136 |
137 | # Additional stuff for the LaTeX preamble.
138 | #
139 | # 'preamble': '',
140 |
141 | # Latex figure (float) alignment
142 | #
143 | # 'figure_align': 'htbp',
144 | }
145 |
146 | # Grouping the document tree into LaTeX files. List of tuples
147 | # (source start file, target name, title,
148 | # author, documentclass [howto, manual, or own class]).
149 | latex_documents = [
150 | (
151 | master_doc,
152 | '{}.tex'.format(aiocontext.__title__),
153 | 'AioContext Documentation',
154 | author,
155 | 'manual',
156 | ),
157 | ]
158 |
159 |
160 | # -- Options for manual page output ---------------------------------------
161 |
162 | # One entry per manual page. List of tuples
163 | # (source start file, name, description, authors, manual section).
164 | man_pages = [
165 | (
166 | master_doc,
167 | aiocontext.__title__,
168 | 'AioContext Documentation',
169 | [author],
170 | 1,
171 | ),
172 | ]
173 |
174 |
175 | # -- Options for Texinfo output -------------------------------------------
176 |
177 | # Grouping the document tree into Texinfo files. List of tuples
178 | # (source start file, target name, title, author,
179 | # dir menu entry, description, category)
180 | texinfo_documents = [
181 | (
182 | master_doc,
183 | aiocontext.__title__,
184 | 'AioContext Documentation',
185 | author,
186 | aiocontext.__title__,
187 | aiocontext.__summary__,
188 | 'Miscellaneous',
189 | ),
190 | ]
191 |
192 |
193 | # Example configuration for intersphinx: refer to the Python standard library.
194 | intersphinx_mapping = {'https://docs.python.org/3/': None}
195 |
196 |
197 | # Configuration for autodoc.
198 | autodoc_member_order = 'bysource'
199 | autodoc_default_flags = [
200 | 'members',
201 | 'undoc-members',
202 | 'show-inheritance',
203 | ]
204 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | Welcome to AioContext
2 | =====================
3 |
4 | AioContext is a Python library to store context information within the
5 | :class:`asyncio.Task` object::
6 |
7 | import asyncio
8 | import aiocontext
9 |
10 | context = aiocontext.Context()
11 |
12 | async def print_request():
13 | print("Request ID:", context.get('request_id', 'unknown'))
14 |
15 | async def handle_request():
16 | context['request_id'] = 42
17 | await print_request()
18 |
19 | if __name__ == '__main__':
20 | loop = asyncio.get_event_loop()
21 | aiocontext.wrap_task_factory(loop)
22 | context.attach(loop)
23 | loop.run_until_complete(handle_request())
24 |
25 | Features:
26 |
27 | * Support both asyncio and uvloop event loops.
28 | * Support custom loop task factories.
29 | * Manage several execution contexts.
30 |
31 | .. toctree::
32 | :maxdepth: 2
33 | :caption: Contents:
34 |
35 | introduction
36 | api
37 | changelog
38 |
39 | If you can’t find the information you’re looking for, have a look at the index
40 | or try to find it using the search function:
41 |
42 | * :ref:`genindex`
43 | * :ref:`search`
44 |
--------------------------------------------------------------------------------
/docs/introduction.rst:
--------------------------------------------------------------------------------
1 | Introduction
2 | ===========
3 |
4 | Prerequistes
5 | ------------
6 |
7 | AioContext works with Python 3.4 and greater. It is compatible with both
8 | asyncio and uvloop event loops and does not depend on external libraries.
9 |
10 | Installation
11 | ------------
12 |
13 | The recommended way to install packages is to use ``pip`` inside a virtual
14 | environment:
15 |
16 | .. code-block:: bash
17 |
18 | $ pip install aiocontext
19 |
20 | To install a development version:
21 |
22 | .. code-block:: bash
23 |
24 | $ git clone https://github.com/sqreen/AioContext.git
25 | $ cd aiocontext
26 | $ pip install -r requirements.txt
27 | $ pip install -e .
28 |
29 | Basic API usage
30 | ---------------
31 |
32 | This section is a brief introduction to AioContext API.
33 |
34 | AioContext allows to store context information inside the asyncio.Task object.
35 | A typical use case for it is to pass information between coroutine calls
36 | without the need to do it explicitly using the called coroutine args.
37 |
38 | To create a new context store, instanciate a :class:`aiocontext.Context`
39 | instance::
40 |
41 | from aiocontext import Context
42 | context = Context()
43 |
44 | A context object is a :class:`dict` so you can store any value you want inside.
45 | For example, in a web application, you can share a ``request_id`` between
46 | asynchronous calls with the following code::
47 |
48 | async def print_request():
49 | print("Request ID:", context.get('request_id', 'unknown'))
50 |
51 | async def handle_request():
52 | context['request_id'] = 42
53 | await print_request()
54 |
55 | To enable context propagation between tasks (i.e. between calls like
56 | :func:`asyncio.ensure_future`, :func:`asyncio.wait_for`,
57 | :func:`asyncio.gather`, etc.), the task factory of the event loop must be
58 | changed to be made context-aware. This is done by calling
59 | :func:`aiocontext.wrap_task_factory`::
60 |
61 | from aiocontext import wrap_task_factory
62 | loop = asyncio.get_event_loop()
63 | wrap_task_factory(loop)
64 |
65 | If a custom task factory is already set, this function will "wrap" it with
66 | context management code, so it must be called after
67 | :meth:`asyncio.Loop.set_task_factory`.
68 |
69 | Finally, the context must be attached to the event loop::
70 |
71 | context.attach(loop)
72 |
73 | The full code looks like::
74 |
75 | import asyncio
76 | import aiocontext
77 |
78 | context = aiocontext.Context()
79 |
80 | async def print_request():
81 | print("Request ID:", context.get('request_id', 'unknown'))
82 |
83 | async def handle_request():
84 | context['request_id'] = 42
85 | await print_request()
86 |
87 | if __name__ == '__main__':
88 | loop = asyncio.get_event_loop()
89 | aiocontext.wrap_task_factory(loop)
90 | context.attach(loop)
91 | loop.run_until_complete(handle_request())
92 |
93 | Comparison with other solutions
94 | -------------------------------
95 |
96 | `aiotask-context`_ was an important source of inspiration and is a more
97 | battle-tested library. It provides a simpler API with a global, unique context.
98 | It does not support overloading custom task factories at the moment.
99 |
100 | `aiolocals`_ is another library to track task-local states. It comes with
101 | `aiohttp`_ integration to track HTTP requests. New tasks must be explicitly
102 | spawned with a ``wrap_async`` function to share contexts, which may be
103 | problematic when using libraries.
104 |
105 | `tasklocals`_ strives to provide an interface similar to
106 | :func:`threading.local`. It provides no mechanism of context sharing when a
107 | child task is spawned. The project looks abandoned.
108 |
109 | `contextvars`_ is the native solution to manage context-local states starting
110 | from Python 3.7.
111 |
112 | .. _aiohttp: https://aiohttp.readthedocs.io/
113 | .. _aiolocals: https://docs.atlassian.com/aiolocals/
114 | .. _aiotask-context: https://github.com/Skyscanner/aiotask-context
115 | .. _tasklocals: https://github.com/vkryachko/tasklocals
116 | .. _contextvars: https://docs.python.org/3/library/contextvars.html
117 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=.
11 | set BUILDDIR=_build
12 | set SPHINXPROJ=aiocontext
13 |
14 | if "%1" == "" goto help
15 |
16 | %SPHINXBUILD% >NUL 2>NUL
17 | if errorlevel 9009 (
18 | echo.
19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
20 | echo.installed, then set the SPHINXBUILD environment variable to point
21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
22 | echo.may add the Sphinx directory to PATH.
23 | echo.
24 | echo.If you don't have Sphinx installed, grab it from
25 | echo.http://sphinx-doc.org/
26 | exit /b 1
27 | )
28 |
29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
30 | goto end
31 |
32 | :help
33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
34 |
35 | :end
36 | popd
37 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | strict = True
3 | addopts = -ra
4 | testpaths = tests
5 |
--------------------------------------------------------------------------------
/requirements.in:
--------------------------------------------------------------------------------
1 | pytest
2 | pytest-asyncio
3 | sphinx
4 | uvloop
5 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | #
2 | # This file is autogenerated by pip-compile
3 | # To update, run:
4 | #
5 | # pip-compile requirements.in
6 | #
7 | alabaster==0.7.12 # via sphinx
8 | atomicwrites==1.3.0 # via pytest
9 | attrs==19.1.0 # via pytest
10 | babel==2.6.0 # via sphinx
11 | certifi==2019.3.9 # via requests
12 | chardet==3.0.4 # via requests
13 | docutils==0.14 # via sphinx
14 | idna==2.8 # via requests
15 | imagesize==1.1.0 # via sphinx
16 | jinja2==2.10.1 # via sphinx
17 | markupsafe==1.1.1 # via jinja2
18 | more-itertools==7.0.0 # via pytest
19 | packaging==19.0 # via sphinx
20 | pluggy==0.11.0 # via pytest
21 | py==1.8.0 # via pytest
22 | pygments==2.4.0 # via sphinx
23 | pyparsing==2.4.0 # via packaging
24 | pytest-asyncio==0.10.0
25 | pytest==4.5.0
26 | pytz==2019.1 # via babel
27 | requests==2.21.0 # via sphinx
28 | six==1.12.0 # via packaging, pytest
29 | snowballstemmer==1.2.1 # via sphinx
30 | sphinx==2.0.1
31 | sphinxcontrib-applehelp==1.0.1 # via sphinx
32 | sphinxcontrib-devhelp==1.0.1 # via sphinx
33 | sphinxcontrib-htmlhelp==1.0.2 # via sphinx
34 | sphinxcontrib-jsmath==1.0.1 # via sphinx
35 | sphinxcontrib-qthelp==1.0.2 # via sphinx
36 | sphinxcontrib-serializinghtml==1.1.3 # via sphinx
37 | urllib3==1.24.3 # via requests
38 | uvloop==0.12.2
39 | wcwidth==0.1.7 # via pytest
40 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [bdist_wheel]
2 | universal = 1
3 |
4 | [metdata]
5 | license_file = LICENSE.txt
6 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from os.path import dirname, join
2 |
3 | from setuptools import find_packages, setup
4 |
5 |
6 | KEYWORDS = []
7 | CLASSIFIERS = [
8 | 'Intended Audience :: Developers',
9 | 'License :: OSI Approved :: MIT License',
10 | 'Programming Language :: Python :: 3',
11 | 'Programming Language :: Python :: 3.4',
12 | 'Programming Language :: Python :: 3.5',
13 | 'Programming Language :: Python :: 3.6',
14 | 'Programming Language :: Python :: 3.7',
15 | 'Programming Language :: Python :: Implementation :: CPython',
16 | 'Programming Language :: Python',
17 | 'Topic :: Software Development',
18 | ]
19 | INSTALL_REQUIRES = []
20 |
21 |
22 | PROJECT_DIR = dirname(__file__)
23 | README_FILE = join(PROJECT_DIR, 'README.rst')
24 | ABOUT_FILE = join(PROJECT_DIR, 'src', 'aiocontext', '__about__.py')
25 |
26 |
27 | def get_readme():
28 | with open(README_FILE) as fileobj:
29 | return fileobj.read()
30 |
31 |
32 | def get_about():
33 | about = {}
34 | with open(ABOUT_FILE) as fileobj:
35 | exec(fileobj.read(), about)
36 | return about
37 |
38 |
39 | ABOUT = get_about()
40 |
41 |
42 | setup(
43 | name=ABOUT['__title__'],
44 | version=ABOUT['__version__'],
45 | description=ABOUT['__summary__'],
46 | long_description=get_readme(),
47 | author=ABOUT['__author__'],
48 | author_email=ABOUT['__email__'],
49 | license=ABOUT['__license__'],
50 | url=ABOUT['__uri__'],
51 | keywords=KEYWORDS,
52 | classifiers=CLASSIFIERS,
53 | package_dir={'': 'src'},
54 | packages=find_packages('src'),
55 | install_requires=INSTALL_REQUIRES,
56 | python_requires='>=3.4, <4',
57 | zip_safe=False,
58 | )
59 |
--------------------------------------------------------------------------------
/src/aiocontext/__about__.py:
--------------------------------------------------------------------------------
1 | """Project metadata."""
2 |
3 | __version__ = '0.1.2'
4 |
5 | __title__ = 'aiocontext'
6 | __summary__ = "Context information storage for asyncio."
7 | __uri__ = 'https://github.com/sqreen/AioContext'
8 |
9 | __author__ = "Sqreen"
10 | __email__ = 'contact@sqreen.io'
11 |
12 | __license__ = 'MIT'
13 | __copyright__ = "2019, Sqreen"
14 |
--------------------------------------------------------------------------------
/src/aiocontext/__init__.py:
--------------------------------------------------------------------------------
1 | """Context information storage for asyncio."""
2 |
3 | from .__about__ import (__author__, __copyright__, __email__, __license__,
4 | __summary__, __title__, __uri__, __version__)
5 | from .context import Context, chainmap_copy, get_loop_contexts
6 | from .errors import (AioContextError, EventLoopError, TaskContextError,
7 | TaskFactoryError)
8 | from .task_factory import unwrap_task_factory, wrap_task_factory
9 |
10 |
11 | __all__ = [
12 | '__author__',
13 | '__copyright__',
14 | '__email__',
15 | '__license__',
16 | '__summary__',
17 | '__title__',
18 | '__uri__',
19 | '__version__',
20 | 'wrap_task_factory',
21 | 'unwrap_task_factory',
22 | 'Context',
23 | 'chainmap_copy',
24 | 'get_loop_contexts',
25 | 'AioContextError',
26 | 'EventLoopError',
27 | 'TaskFactoryError',
28 | 'TaskContextError',
29 | ]
30 |
--------------------------------------------------------------------------------
/src/aiocontext/context.py:
--------------------------------------------------------------------------------
1 | """Context information storage for asyncio."""
2 |
3 | import asyncio
4 | from collections import ChainMap
5 | from collections.abc import MutableMapping
6 | from contextlib import suppress
7 | from uuid import uuid4
8 |
9 | from .__about__ import __title__
10 | from .errors import EventLoopError, TaskContextError, TaskFactoryError
11 | from .task_factory import get_task_factory_attr
12 |
13 |
14 | class Context(MutableMapping):
15 | """Create an empty, asynchronous execution context.
16 |
17 | The context must be attached to an event loop. It behaves like a
18 | dictionnary::
19 |
20 | >>> context = Context()
21 | >>> context.attach(loop)
22 | >>> context['key1'] = 'value'
23 | >>> context['key1']
24 | 'value'
25 | >>> context.get('key2', 'defaut')
26 | 'default'
27 |
28 | Upon initialization, an optional argument *copy_func* can be passed to
29 | specify how context data are copied between tasks. Relevant values are:
30 |
31 | * :class:`dict` or :func:`copy.copy` to create a shallow copy.
32 | * :func:`copy.deepcopy` to create a deep copy. All values should support
33 | the deepcopy protocol.
34 | * :func:`chainmap_copy` to use a :class:`collections.ChainMap`, with child
35 | task data stored in the front map and parent data stored in nested maps.
36 | """
37 |
38 | __slots__ = (
39 | '_copy_func',
40 | '_data_attr',
41 | )
42 |
43 | def __init__(self, copy_func=dict):
44 | self._copy_func = copy_func
45 | self._data_attr = '_{prefix}_{suffix}'.format(
46 | prefix=__title__,
47 | suffix=str(uuid4()).replace('-', ''),
48 | )
49 |
50 | @property
51 | def copy_func(self):
52 | """Copy function, called when a new task is spawned."""
53 | return self._copy_func
54 |
55 | def get_data(self, task=None):
56 | """Return the :class:`dict` of *task* data.
57 |
58 | If *task* is omitted or ``None``, return data of the task being
59 | currently executed. If no task is running, an :exc:`EventLoopError` is
60 | raised.
61 |
62 | This method raises :exc:`TaskContextError` if no context data is stored
63 | in *task*. This usually indicates that the context task factory was not
64 | set in the event loop.
65 |
66 | ::
67 |
68 | >>> context['key'] = 'value'
69 | >>> context.get_data()
70 | {'key': 'value'}
71 | """
72 | if task is None:
73 | task = asyncio.Task.current_task()
74 | if task is None:
75 | raise EventLoopError("No event loop found")
76 | data = getattr(task, self._data_attr, None)
77 | if data is None:
78 | raise TaskContextError("No task context found")
79 | return data
80 |
81 | def __getitem__(self, key):
82 | data = self.get_data()
83 | return data[key]
84 |
85 | def __setitem__(self, key, value):
86 | data = self.get_data()
87 | data[key] = value
88 |
89 | def __delitem__(self, key):
90 | data = self.get_data()
91 | del data[key]
92 |
93 | def __iter__(self):
94 | data = self.get_data()
95 | return iter(data)
96 |
97 | def __len__(self):
98 | data = self.get_data()
99 | return len(data)
100 |
101 | def attach(self, loop):
102 | """Attach the execution context to *loop*.
103 |
104 | When new tasks are spawned by the loop, they will inherit context data
105 | from the parent task. The loop must use a context-aware task factory;
106 | if not, a :exc:`TaskFactoryError` is raised.
107 |
108 | This method has no effect if the context is already attached to *loop*.
109 | """
110 | get_task_factory_attr(loop)[self._data_attr] = self
111 |
112 | def detach(self, loop):
113 | """Detach the execution context from *loop*.
114 |
115 | This method has no effect if the context is not attached to *loop*.
116 | """
117 | with suppress(KeyError, TaskFactoryError):
118 | del get_task_factory_attr(loop)[self._data_attr]
119 |
120 |
121 | def chainmap_copy(data):
122 | """Context copy function based on :class:`collections.ChainMap`.
123 |
124 | ::
125 |
126 | context = Context(copy_func=chainmap_copy)
127 |
128 | On nested copies, :class:`collections.ChainMap` instances are flattened
129 | for efficiency purposes.
130 | """
131 | if isinstance(data, ChainMap):
132 | return data.new_child()
133 | else:
134 | return ChainMap({}, data)
135 |
136 |
137 | def get_loop_contexts(loop):
138 | """Return the list of contexts attached to *loop*.
139 |
140 | ::
141 |
142 | >>> context1 = Context()
143 | >>> context1.attach(loop)
144 | >>> context2 = Context()
145 | >>> context2.attach(loop)
146 | >>> get_loop_contexts(loop)
147 | [, ]
148 | >>> context2.detach(loop)
149 | []
150 |
151 | Raises :exc:`TaskFactoryError` if the loop is not context-aware, i.e. the
152 | task factory was not set.
153 | """
154 | return list(get_task_factory_attr(loop).values())
155 |
--------------------------------------------------------------------------------
/src/aiocontext/errors.py:
--------------------------------------------------------------------------------
1 | """Errors and exceptions."""
2 |
3 |
4 | class AioContextError(Exception):
5 | """Base class for aiocontext errors."""
6 |
7 |
8 | class EventLoopError(AioContextError):
9 | """Raised when the current running task cannot be determined.
10 |
11 | This generally means that the context is manipulated with no event loop
12 | running, e.g. in synchronous code.
13 | """
14 |
15 |
16 | class TaskFactoryError(AioContextError):
17 | """Raised when no context-aware task factory was set for the current loop.
18 |
19 | This generally means the following code was not executed::
20 |
21 | wrap_task_factory(loop)
22 | """
23 |
24 |
25 | class TaskContextError(AioContextError):
26 | """Raised when no context data is stored in the current task.
27 |
28 | This generally means the context is not registered in the current loop,
29 | i.e. the following code was not executed::
30 |
31 | context.attach(loop)
32 | """
33 |
--------------------------------------------------------------------------------
/src/aiocontext/task_factory.py:
--------------------------------------------------------------------------------
1 | """Task factory."""
2 |
3 | import asyncio
4 | from functools import wraps
5 |
6 | from .__about__ import __title__
7 | from .errors import TaskFactoryError
8 |
9 |
10 | _TASK_FACTORY_ATTR = '_{}_contexts'.format(__title__)
11 |
12 |
13 | def get_task_factory_attr(loop):
14 | """Return the dict of contexts registered in *loop*.
15 |
16 | Raises :exc:`TaskFactoryError` if the loop is not context-aware.
17 | """
18 | task_factory = loop.get_task_factory()
19 | if not hasattr(task_factory, _TASK_FACTORY_ATTR):
20 | raise TaskFactoryError("Task factory is not context-aware")
21 | return getattr(task_factory, _TASK_FACTORY_ATTR)
22 |
23 |
24 | def _default_task_factory(loop, coro):
25 | return asyncio.Task(coro, loop=loop)
26 |
27 |
28 | def wrap_task_factory(loop):
29 | """Wrap the *loop* task factory to make it context-aware.
30 |
31 | Internally, this replaces the loop task factory by a wrapper function that
32 | manages context sharing between tasks. When a new task is spawned, the
33 | original task factory is called, then for each attached context, data is
34 | copied from the parent task to the child one. How copy is performed is
35 | specified in :meth:`Context.copy_func`.
36 |
37 | If *loop* uses a custom task factory, this function must be called after
38 | setting it::
39 |
40 | class CustomTask(asyncio.Task):
41 | pass
42 |
43 | def custom_task_factory(loop, coro):
44 | return CustomTask(coro, loop=loop)
45 |
46 | loop.set_task_factory(custom_task_factory)
47 | wrap_task_factory(loop)
48 |
49 | This function has no effect if the task factory is already context-aware.
50 | """
51 | task_factory = loop.get_task_factory()
52 | if hasattr(task_factory, _TASK_FACTORY_ATTR):
53 | return
54 | if task_factory is None:
55 | task_factory = _default_task_factory
56 |
57 | @wraps(task_factory)
58 | def wrapper(loop, coro):
59 | parent_task = asyncio.Task.current_task(loop=loop)
60 | child_task = task_factory(loop, coro)
61 | if child_task._source_traceback:
62 | del child_task._source_traceback[-1]
63 | for context in getattr(wrapper, _TASK_FACTORY_ATTR).values():
64 | parent_data = getattr(parent_task, context._data_attr, None)
65 | if parent_data is None:
66 | child_data = {}
67 | else:
68 | child_data = context.copy_func(parent_data)
69 | setattr(child_task, context._data_attr, child_data)
70 | return child_task
71 |
72 | setattr(wrapper, _TASK_FACTORY_ATTR, {})
73 | loop.set_task_factory(wrapper)
74 |
75 |
76 | def unwrap_task_factory(loop):
77 | """Restore the original task factory of *loop*.
78 |
79 | This function cancels the effect of :func:`wrap_task_factory`. After
80 | calling it, the loop task factory is no longer context-aware. Context
81 | registration is lost.
82 |
83 | This function has no effect if the task factory is not context-aware.
84 | """
85 | task_factory = loop.get_task_factory()
86 | if not hasattr(task_factory, _TASK_FACTORY_ATTR):
87 | return
88 | loop.set_task_factory(task_factory.__wrapped__)
89 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import pytest
4 | import uvloop
5 |
6 | import aiocontext
7 |
8 |
9 | @pytest.fixture()
10 | def asyncio_loop():
11 | loop = asyncio.get_event_loop_policy().new_event_loop()
12 | yield loop
13 | loop.close()
14 |
15 |
16 | @pytest.fixture()
17 | def uvloop_loop():
18 | loop = uvloop.new_event_loop()
19 | yield loop
20 | loop.close()
21 |
22 |
23 | @pytest.fixture(params=['asyncio_loop', 'uvloop_loop'])
24 | def event_loop(request):
25 | return request.getfixturevalue(request.param)
26 |
27 |
28 | @pytest.fixture()
29 | def context():
30 | return aiocontext.Context()
31 |
32 |
33 | @pytest.fixture()
34 | def context_loop(context, event_loop):
35 | aiocontext.wrap_task_factory(event_loop)
36 | context.attach(event_loop)
37 | return event_loop
38 |
--------------------------------------------------------------------------------
/tests/test_aiocontext.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import traceback
3 |
4 | import pytest
5 |
6 | from aiocontext import (Context, EventLoopError, TaskContextError,
7 | TaskFactoryError, chainmap_copy, get_loop_contexts,
8 | unwrap_task_factory, wrap_task_factory)
9 |
10 |
11 | def _wrapped_task_factory(event_loop):
12 | try:
13 | get_loop_contexts(event_loop)
14 | except TaskFactoryError:
15 | return False
16 | else:
17 | return True
18 |
19 |
20 | class CustomTask(asyncio.Task):
21 | pass
22 |
23 |
24 | def custom_task_factory(loop, coro):
25 | return CustomTask(coro, loop=loop)
26 |
27 |
28 | def test_wrap_task_factory(event_loop):
29 | assert not _wrapped_task_factory(event_loop)
30 | wrap_task_factory(event_loop)
31 | assert _wrapped_task_factory(event_loop)
32 |
33 |
34 | def test_wrap_task_factory_many(event_loop):
35 | assert not _wrapped_task_factory(event_loop)
36 | for _ in range(3):
37 | wrap_task_factory(event_loop)
38 | assert _wrapped_task_factory(event_loop)
39 |
40 |
41 | def test_wrap_task_factory_custom(event_loop):
42 | event_loop.set_task_factory(custom_task_factory)
43 | assert not _wrapped_task_factory(event_loop)
44 | wrap_task_factory(event_loop)
45 | assert _wrapped_task_factory(event_loop)
46 | task = event_loop.create_task(asyncio.sleep(1))
47 | task.cancel()
48 | assert isinstance(task, CustomTask)
49 |
50 |
51 | def test_unwrap_task_factory(event_loop):
52 | wrap_task_factory(event_loop)
53 | assert _wrapped_task_factory(event_loop)
54 | unwrap_task_factory(event_loop)
55 | assert not _wrapped_task_factory(event_loop)
56 |
57 |
58 | def test_unwrap_task_factory_many(event_loop):
59 | wrap_task_factory(event_loop)
60 | assert _wrapped_task_factory(event_loop)
61 | for _ in range(3):
62 | unwrap_task_factory(event_loop)
63 | assert not _wrapped_task_factory(event_loop)
64 |
65 |
66 | def test_unwrap_task_factory_custom(event_loop):
67 | event_loop.set_task_factory(custom_task_factory)
68 | wrap_task_factory(event_loop)
69 | assert _wrapped_task_factory(event_loop)
70 | unwrap_task_factory(event_loop)
71 | assert not _wrapped_task_factory(event_loop)
72 | assert event_loop.get_task_factory() is custom_task_factory
73 |
74 |
75 | @pytest.mark.asyncio
76 | @asyncio.coroutine
77 | def test_wrap_task_factory_traceback(context, context_loop):
78 | context_loop.set_debug(True)
79 | task = context_loop.create_task(asyncio.sleep(1))
80 | task.cancel()
81 | assert isinstance(task._source_traceback, traceback.StackSummary)
82 |
83 |
84 | class TestContext:
85 |
86 | def test_copy_func(self):
87 | context = Context()
88 | assert context.copy_func is dict
89 | context = Context(copy_func=chainmap_copy)
90 | assert context.copy_func is chainmap_copy
91 |
92 | @pytest.mark.asyncio
93 | @asyncio.coroutine
94 | def test_get_data(self, context, context_loop):
95 | context['key1'] = 'value1'
96 | assert context.get_data() == {'key1': 'value1'}
97 |
98 | @asyncio.coroutine
99 | def coro():
100 | context['key2'] = 'value2'
101 |
102 | task = context_loop.create_task(coro())
103 | yield from task
104 | assert context.get_data(task) == {'key1': 'value1', 'key2': 'value2'}
105 | assert context.get_data() == {'key1': 'value1'}
106 |
107 | @pytest.mark.asyncio
108 | @asyncio.coroutine
109 | def test_getitem(self, context, context_loop):
110 | context['key1'] = 'value'
111 | assert context['key1'] == 'value'
112 | with pytest.raises(KeyError):
113 | context['key2']
114 |
115 | @pytest.mark.asyncio
116 | @asyncio.coroutine
117 | def test_setitem(self, context, context_loop):
118 | context['key'] = 'value1'
119 | assert context['key'] == 'value1'
120 | context['key'] = 'value2'
121 | assert context['key'] == 'value2'
122 |
123 | @pytest.mark.asyncio
124 | @asyncio.coroutine
125 | def test_delitem(self, context, context_loop):
126 | with pytest.raises(KeyError):
127 | del context['key']
128 | context['key'] = 'value'
129 | del context['key']
130 | assert 'key' not in context
131 |
132 | @pytest.mark.asyncio
133 | @asyncio.coroutine
134 | def test_iter(self, context, context_loop):
135 | context['key1'] = 'value1'
136 | context['key2'] = 'value2'
137 | assert list(sorted(context)) == ['key1', 'key2']
138 |
139 | @pytest.mark.asyncio
140 | @asyncio.coroutine
141 | def test_len(self, context, context_loop):
142 | context['key1'] = 'value1'
143 | context['key2'] = 'value2'
144 | assert len(context) == 2
145 |
146 | def test_missing_event_loop(self, context):
147 | with pytest.raises(EventLoopError):
148 | context.get_data()
149 | with pytest.raises(EventLoopError):
150 | context['key']
151 | with pytest.raises(EventLoopError):
152 | context['key'] = 'value'
153 | with pytest.raises(EventLoopError):
154 | del context['key']
155 | with pytest.raises(EventLoopError):
156 | list(context)
157 | with pytest.raises(EventLoopError):
158 | len(context)
159 |
160 | @pytest.mark.asyncio
161 | @asyncio.coroutine
162 | def test_missing_task_context(self, context, event_loop):
163 | with pytest.raises(TaskContextError):
164 | context.get_data()
165 | with pytest.raises(TaskContextError):
166 | context['key']
167 | with pytest.raises(TaskContextError):
168 | context['key'] = 'value'
169 | with pytest.raises(TaskContextError):
170 | del context['key']
171 | with pytest.raises(TaskContextError):
172 | list(context)
173 | with pytest.raises(TaskContextError):
174 | len(context)
175 |
176 | def test_attach(self, event_loop):
177 | wrap_task_factory(event_loop)
178 | assert len(get_loop_contexts(event_loop)) == 0
179 | context1 = Context()
180 | context1.attach(event_loop)
181 | assert len(get_loop_contexts(event_loop)) == 1
182 | context2 = Context()
183 | context2.attach(event_loop)
184 | assert len(get_loop_contexts(event_loop)) == 2
185 | context2.attach(event_loop)
186 | assert len(get_loop_contexts(event_loop)) == 2
187 |
188 | def test_detach(self, event_loop):
189 | wrap_task_factory(event_loop)
190 | context1 = Context()
191 | context1.attach(event_loop)
192 | context2 = Context()
193 | context2.attach(event_loop)
194 | assert len(get_loop_contexts(event_loop)) == 2
195 | context2.detach(event_loop)
196 | assert len(get_loop_contexts(event_loop)) == 1
197 | context2.detach(event_loop)
198 | assert len(get_loop_contexts(event_loop)) == 1
199 |
200 |
201 | def test_chainmap_copy():
202 | data1 = {'key1': 'value1'}
203 | data2 = chainmap_copy(data1)
204 | data2['key2'] = 'value2'
205 | data3 = chainmap_copy(data2)
206 | data3['key3'] = 'value3'
207 | assert data1 == {'key1': 'value1'}
208 | assert data2 == {'key1': 'value1', 'key2': 'value2'}
209 | assert data3 == {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'}
210 | assert len(data3.maps) == 3
211 |
212 |
213 | def test_get_loop_contexts(context, event_loop):
214 | with pytest.raises(TaskFactoryError):
215 | get_loop_contexts(event_loop)
216 | wrap_task_factory(event_loop)
217 | assert get_loop_contexts(event_loop) == []
218 | context.attach(event_loop)
219 | assert get_loop_contexts(event_loop) == [context]
220 |
--------------------------------------------------------------------------------
/tests/test_asyncio.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import pytest
4 |
5 |
6 | @asyncio.coroutine
7 | def _check_update_context(context):
8 | assert context == {'key1': 'value1'}
9 | context['key1'] = 'value2'
10 | context['key2'] = 'value2'
11 | assert context == {'key1': 'value2', 'key2': 'value2'}
12 |
13 |
14 | @pytest.mark.asyncio
15 | @asyncio.coroutine
16 | def test_ensure_future(context, context_loop):
17 | context['key1'] = 'value1'
18 | yield from asyncio.ensure_future(_check_update_context(context))
19 | assert context == {'key1': 'value1'}
20 |
21 |
22 | @pytest.mark.asyncio
23 | @asyncio.coroutine
24 | def test_wait_for(context, context_loop):
25 | context['key1'] = 'value1'
26 | yield from asyncio.wait_for(_check_update_context(context), 1)
27 | assert context == {'key1': 'value1'}
28 |
29 |
30 | @pytest.mark.asyncio
31 | @asyncio.coroutine
32 | def test_gather(context, context_loop):
33 | context['key1'] = 'value1'
34 | yield from asyncio.gather(_check_update_context(context))
35 | assert context == {'key1': 'value1'}
36 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist =
3 | flake8
4 | isort
5 | pydocstyle
6 | py35
7 | py36
8 | py37
9 | manifest
10 | metadata
11 | doc8
12 | sphinx
13 | coverage
14 |
15 | [testenv:flake8]
16 | deps = flake8
17 | skip_install = True
18 | commands = flake8
19 |
20 | [testenv:isort]
21 | deps = isort
22 | skip_install = True
23 | commands = isort -c -rc -df
24 |
25 | [testenv:pydocstyle]
26 | deps = pydocstyle
27 | skip_install = True
28 | commands = pydocstyle src
29 |
30 | [testenv]
31 | deps =
32 | coverage
33 | pytest
34 | pytest-asyncio
35 | uvloop
36 | commands =
37 | coverage run --parallel -m pytest {posargs}
38 |
39 | [testenv:manifest]
40 | deps = check-manifest
41 | skip_install = True
42 | commands = check-manifest
43 |
44 | [testenv:metadata]
45 | deps =
46 | docutils
47 | readme_renderer
48 | skip_install = True
49 | commands = python setup.py check -mrs
50 |
51 | [testenv:doc8]
52 | deps =
53 | doc8
54 | pygments
55 | skip_install = True
56 | commands = doc8
57 |
58 | [testenv:sphinx]
59 | deps = sphinx
60 | commands =
61 | sphinx-build -W -b html docs docs/_build/html
62 | sphinx-build -W -b linkcheck docs docs/_build/linkcheck
63 |
64 | [testenv:coverage]
65 | deps = coverage
66 | skip_install = True
67 | commands =
68 | coverage combine
69 | coverage report
70 |
--------------------------------------------------------------------------------