├── .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 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------