├── .github └── workflows │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── aiohttp_utils ├── __init__.py ├── constants.py ├── negotiation.py ├── routing.py └── runner.py ├── dev-requirements.txt ├── docs ├── Makefile ├── changelog.rst ├── conf.py ├── index.rst ├── license.rst ├── make.bat ├── modules │ ├── negotiation.rst │ ├── path_norm.rst │ ├── routing.rst │ └── runner.rst ├── requirements.txt └── versioning.rst ├── examples ├── kitchen_sink.py └── mako_example.py ├── setup.cfg ├── setup.py ├── tasks.py ├── tests ├── __init__.py ├── conftest.py ├── test_examples.py ├── test_negotiation.py ├── test_routing.py ├── test_runner.py └── views.py └── tox.ini /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | python -m pip install tox tox-gh-actions 24 | - name: Test with tox 25 | run: tox 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | 37 | # Complexity 38 | output/*.html 39 | output/*/index.html 40 | 41 | # Sphinx 42 | docs/_build 43 | README.html 44 | 45 | _sandbox 46 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v1.2.3 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: flake8 7 | - id: check-yaml 8 | - repo: https://github.com/codespell-project/codespell 9 | rev: v1.15.0 10 | hooks: 11 | - id: codespell 12 | 13 | # black requires py3.6+ 14 | #- repo: https://github.com/python/black 15 | # rev: 19.3b0 16 | # hooks: 17 | # - id: black 18 | # language_version: python3 19 | #- repo: https://github.com/asottile/blacken-docs 20 | # rev: v1.0.0-1 21 | # hooks: 22 | # - id: blacken-docs 23 | # additional_dependencies: [black==19.3b0] 24 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ********* 2 | Changelog 3 | ********* 4 | 5 | 3.2.1 (2023-02-14) 6 | ================== 7 | 8 | - Actually bump package version... 9 | 10 | 3.2.0 (2023-02-14) 11 | ================== 12 | 13 | - Add support for Python 3.10 and 3.11 14 | - Switch ci to gh actions 15 | - Rename package name as aiohttp-utils 16 | 17 | 18 | 3.1.1 (2019-07-11) 19 | ================== 20 | 21 | - [negotiation]: Fix handling of aiohttp.web.Response when force_rendering is set. 22 | - Add examples to the MANIFEST. 23 | 24 | 3.1.0 (2019-07-09) 25 | ================== 26 | 27 | - Add support for aiohttp>=3. 28 | - Drop support for Python 3.4. 29 | - Test against Python 3.7. 30 | - [negotiation]: Add a new `force_rendering` config option. 31 | 32 | 3.0.0 (2016-03-16) 33 | ================== 34 | 35 | - Test against Python 3.6. 36 | - [runner] *Backwards-incompatible*: The `runner` module is deprecated. Install `aiohttp-devtools` and use the `adev runserver` command instead. 37 | - [path_norm] *Backwards-incompatible*: The `path_norm` module is removed, as it is now available in `aiohttp` in `aiohttp.web_middlewares.normalize_path_middleware`. 38 | 39 | 2.0.1 (2016-04-03) 40 | ================== 41 | 42 | - [runner] Fix compatibility with aiohttp>=0.21.0 (:issue:`2`). Thanks :user:`charlesfleche` for reporting. 43 | 44 | 2.0.0 (2016-03-13) 45 | ================== 46 | 47 | - Fix compatibility with aiohttp>=0.21.0. 48 | - [routing] *Backwards-incompatible*: Renamed ``ResourceRouter.add_resource`` to ``ResourceRouter.add_resource_object`` to prevent clashing with aiohttp's URLDispatcher. 49 | 50 | 1.0.0 (2015-10-27) 51 | ================== 52 | 53 | - [negotiation,path_norm] *Backwards-incompatible*: Changed signatures of ``negotiation.setup`` and ``path_norm.setup`` to be more explicit. Both now take keyword-only arguments which are the same as the module's configuration keys, except lowercased, e.g. ``setup(app, append_slash=True, merge_slashes=True)``. 54 | - [runner] Make ``run`` importable from top-level ``aiohttp_utils`` module. 55 | - [runner] Fix behavior when passing ``reload=False`` when ``app.debug=True`` 56 | - Improved docs. 57 | 58 | 0.1.0 (2015-10-25) 59 | ================== 60 | 61 | - First PyPI release. Includes ``negotiation``, ``path_norm``, ``routing``, and ``runner`` modules. 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015-2018 Steven Loria 2 | Copyright 2019 David Douard 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst LICENSE 2 | recursive-include examples *.py 3 | recursive-include tests * 4 | recursive-include docs * 5 | recursive-exclude docs *.pyc 6 | recursive-exclude docs *.pyo 7 | recursive-exclude tests *.pyc 8 | recursive-exclude tests *.pyo 9 | prune docs/_build 10 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ************* 2 | aiohttp-utils 3 | ************* 4 | 5 | .. image:: https://badgen.net/pypi/v/aiohttp-utils 6 | :target: https://pypi.org/project/aiohttp-utils/ 7 | :alt: Latest version 8 | 9 | .. image:: https://badgen.net/travis/sloria/aiohttp-utils 10 | :target: https://travis-ci.org/sloria/aiohttp-utils 11 | :alt: Travis-CI 12 | 13 | **aiohttp-utils** provides handy utilities for building `aiohttp.web `_ applications. 14 | 15 | 16 | * Method-based handlers ("resources") 17 | * Routing utilities 18 | * Content negotiation with JSON rendering by default 19 | 20 | **Everything is optional**. You can use as much (or as little) of this toolkit as you need. 21 | 22 | .. code-block:: python 23 | 24 | from aiohttp import web 25 | from aiohttp_utils import Response, routing, negotiation 26 | 27 | app = web.Application(router=routing.ResourceRouter()) 28 | 29 | # Method-based handlers 30 | class HelloResource: 31 | 32 | async def get(self, request): 33 | name = request.GET.get('name', 'World') 34 | return Response({ 35 | 'message': 'Hello ' + name 36 | }) 37 | 38 | 39 | app.router.add_resource_object('/', HelloResource()) 40 | 41 | # Content negotiation 42 | negotiation.setup( 43 | app, renderers={ 44 | 'application/json': negotiation.render_json 45 | } 46 | ) 47 | 48 | Install 49 | ======= 50 | :: 51 | 52 | $ pip install aiohttp-utils 53 | 54 | Documentation 55 | ============= 56 | 57 | Full documentation is available at https://aiohttp-utils.readthedocs.io/. 58 | 59 | Project Links 60 | ============= 61 | 62 | - Docs: https://aiohttp-utils.readthedocs.io/ 63 | - Changelog: https://aiohttp-utils.readthedocs.io/en/latest/changelog.html 64 | - PyPI: https://pypi.python.org/pypi/aiohttp-utils 65 | - Issues: https://github.com/sloria/aiohttp-utils/issues 66 | 67 | License 68 | ======= 69 | 70 | MIT licensed. See the bundled `LICENSE `_ file for more details. 71 | -------------------------------------------------------------------------------- /aiohttp_utils/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .negotiation import Response 3 | from .constants import APP_KEY, CONFIG_KEY 4 | from .runner import run 5 | 6 | __version__ = '3.2.1' 7 | __author__ = 'Steven Loria' 8 | __license__ = "MIT" 9 | 10 | __all__ = ( 11 | 'Response', 12 | 'run', 13 | 'APP_KEY', 14 | 'CONFIG_KEY', 15 | ) 16 | -------------------------------------------------------------------------------- /aiohttp_utils/constants.py: -------------------------------------------------------------------------------- 1 | #: Key used to store configuration on a `web.Application` 2 | #: APP_KEY is deprecated; use CONFIG_KEY instead 3 | CONFIG_KEY = APP_KEY = 'AIOHTTP_UTILS' 4 | -------------------------------------------------------------------------------- /aiohttp_utils/negotiation.py: -------------------------------------------------------------------------------- 1 | """Content negotiation is the process of selecting an appropriate 2 | representation (e.g. JSON, HTML, etc.) to return to a client based on the client's and/or server's 3 | preferences. 4 | 5 | If no custom renderers are supplied, this plugin will render responses to JSON. 6 | 7 | .. code-block:: python 8 | 9 | import asyncio 10 | 11 | from aiohttp import web 12 | from aiohttp_utils import negotiation, Response 13 | 14 | async def handler(request): 15 | return Response({'message': "Let's negotiate"}) 16 | 17 | app = web.Application() 18 | app.router.add_route('GET', '/', handler) 19 | 20 | # No configuration: renders to JSON by default 21 | negotiation.setup(app) 22 | 23 | We can consume the app with httpie. 24 | :: 25 | 26 | $ pip install httpie 27 | $ http :5000/ 28 | HTTP/1.1 200 OK 29 | CONNECTION: keep-alive 30 | CONTENT-LENGTH: 30 31 | CONTENT-TYPE: application/json 32 | DATE: Thu, 22 Oct 2015 06:03:39 GMT 33 | SERVER: Python/3.5 aiohttp/0.17.4 34 | 35 | { 36 | "message": "Let's negotiate" 37 | } 38 | 39 | .. note:: 40 | 41 | Handlers **MUST** return an `aiohttp_utils.negotiation.Response` (which can be imported from 42 | the top-level `aiohttp_utils` module) for data 43 | to be properly negotiated. `aiohttp_utils.negotiation.Response` is the 44 | same as `aiohttp.web.Response`, except that its first 45 | argument is `data`, the data to be negotiated. 46 | 47 | Customizing negotiation 48 | ======================= 49 | 50 | Renderers are just callables that receive a `request ` and the 51 | data to render. 52 | 53 | Renderers can return either the rendered representation of the data 54 | or a `Response `. 55 | 56 | Example: 57 | 58 | .. code-block:: python 59 | 60 | # A simple text renderer 61 | def render_text(request, data): 62 | return data.encode(request.charset) 63 | 64 | # OR, if you need to parametrize your renderer, you can use a class 65 | 66 | class TextRenderer: 67 | def __init__(self, charset): 68 | self.charset = charset 69 | 70 | def __call__(self, request, data): 71 | return data.encode(self.charset) 72 | 73 | render_text_utf8 = TextRenderer('utf-8') 74 | render_text_utf16 = TextRenderer('utf-16') 75 | 76 | You can then pass your renderers to `setup ` 77 | with a corresponding media type. 78 | 79 | .. code-block:: python 80 | 81 | from collections import OrderedDict 82 | from aiohttp_utils import negotiation 83 | 84 | negotiation.setup(app, renderers=OrderedDict([ 85 | ('text/plain', render_text), 86 | ('application/json', negotiation.render_json), 87 | ])) 88 | 89 | .. note:: 90 | 91 | We use an `OrderedDict ` of renderers because priority is given to 92 | the first specified renderer when the client passes an unsupported media type. 93 | 94 | By default, rendering the value returned by a handler according to content 95 | negotiation will only occur if this value is considered True. If you want to 96 | enforce the rendering whatever the boolean interpretation of the returned 97 | value you can set the `force_rendering` flag: 98 | 99 | .. code-block:: python 100 | 101 | from collections import OrderedDict 102 | from aiohttp_utils import negotiation 103 | 104 | negotiation.setup(app, force_rendering=True, 105 | renderers=OrderedDict([ 106 | ('application/json', negotiation.render_json), 107 | ])) 108 | 109 | """ 110 | from collections import OrderedDict 111 | import asyncio 112 | import json as pyjson 113 | 114 | from aiohttp import web 115 | import mimeparse 116 | 117 | from .constants import CONFIG_KEY 118 | 119 | 120 | __all__ = ( 121 | 'setup', 122 | 'negotiation_middleware', 123 | 'Response', 124 | 'select_renderer', 125 | 'JSONRenderer', 126 | 'render_json' 127 | ) 128 | 129 | 130 | class Response(web.Response): 131 | """Same as `aiohttp.web.Response`, except that the constructor takes a `data` argument, 132 | which is the data to be negotiated by the 133 | `negotiation_middleware `. 134 | """ 135 | 136 | def __init__(self, data=None, *args, **kwargs): 137 | if data is not None and kwargs.get('body', None): 138 | raise ValueError('data and body are not allowed together.') 139 | if data is not None and kwargs.get('text', None): 140 | raise ValueError('data and text are not allowed together.') 141 | self.data = data 142 | super().__init__(*args, **kwargs) 143 | 144 | 145 | # ##### Negotiation strategies ##### 146 | 147 | def select_renderer(request: web.Request, renderers: OrderedDict, force=True): 148 | """ 149 | Given a request, a list of renderers, and the ``force`` configuration 150 | option, return a two-tuple of: 151 | (media type, render callable). Uses mimeparse to find the best media 152 | type match from the ACCEPT header. 153 | """ 154 | header = request.headers.get('ACCEPT', '*/*') 155 | best_match = mimeparse.best_match(renderers.keys(), header) 156 | if not best_match or best_match not in renderers: 157 | if force: 158 | return tuple(renderers.items())[0] 159 | else: 160 | raise web.HTTPNotAcceptable 161 | return best_match, renderers[best_match] 162 | 163 | 164 | # ###### Renderers ###### 165 | 166 | # Use a class so that json module is easily override-able 167 | class JSONRenderer: 168 | """Callable object which renders to JSON.""" 169 | json_module = pyjson 170 | 171 | def __repr__(self): 172 | return '' 173 | 174 | def __call__(self, request, data): 175 | return self.json_module.dumps(data).encode('utf-8') 176 | 177 | 178 | #: Render data to JSON. Singleton `JSONRenderer`. This can be passed to the 179 | #: ``RENDERERS`` configuration option, e.g. ``('application/json', render_json)``. 180 | render_json = JSONRenderer() 181 | 182 | 183 | # ##### Main API ##### 184 | 185 | #: Default configuration 186 | DEFAULTS = { 187 | 'NEGOTIATOR': select_renderer, 188 | 'RENDERERS': OrderedDict([ 189 | ('application/json', render_json), 190 | ]), 191 | 'FORCE_NEGOTIATION': True, 192 | 'FORCE_RENDERING': False, 193 | } 194 | 195 | 196 | def negotiation_middleware( 197 | renderers=DEFAULTS['RENDERERS'], 198 | negotiator=DEFAULTS['NEGOTIATOR'], 199 | force_negotiation=DEFAULTS['FORCE_NEGOTIATION'], 200 | force_rendering=DEFAULTS['FORCE_RENDERING'] 201 | ): 202 | """Middleware which selects a renderer for a given request then renders 203 | a handler's data to a `aiohttp.web.Response`. 204 | """ 205 | async def factory(app, handler): 206 | async def middleware(request): 207 | content_type, renderer = negotiator( 208 | request, 209 | renderers, 210 | force_negotiation, 211 | ) 212 | request['selected_media_type'] = content_type 213 | response = await handler(request) 214 | 215 | data = getattr(response, 'data', None) 216 | if isinstance(response, Response) and (force_rendering or data): 217 | # Render data with the selected renderer 218 | if asyncio.iscoroutinefunction(renderer): 219 | render_result = await renderer(request, data) 220 | else: 221 | render_result = renderer(request, data) 222 | else: 223 | render_result = response 224 | if isinstance(render_result, web.Response): 225 | return render_result 226 | 227 | if force_rendering or data is not None: 228 | response.body = render_result 229 | response.content_type = content_type 230 | 231 | return response 232 | return middleware 233 | return factory 234 | 235 | 236 | def setup( 237 | app: web.Application, *, negotiator: callable = DEFAULTS['NEGOTIATOR'], 238 | renderers: OrderedDict = DEFAULTS['RENDERERS'], 239 | force_negotiation: bool = DEFAULTS['FORCE_NEGOTIATION'], 240 | force_rendering: bool = DEFAULTS['FORCE_RENDERING']): 241 | """Set up the negotiation middleware. Reads configuration from 242 | ``app['AIOHTTP_UTILS']``. 243 | 244 | :param app: Application to set up. 245 | :param negotiator: Function that selects a renderer given a 246 | request, a dict of renderers, and a ``force`` parameter (whether to return 247 | a renderer even if the client passes an unsupported media type). 248 | :param renderers: Mapping of mediatypes to callable renderers. 249 | :param force_negotiation: Whether to return a renderer even if the 250 | client passes an unsupported media type). 251 | :param force_rendering: Whether to enforce rendering the result even if it 252 | considered False. 253 | """ 254 | config = app.get(CONFIG_KEY, {}) 255 | middleware = negotiation_middleware( 256 | renderers=config.get('RENDERERS', renderers), 257 | negotiator=config.get('NEGOTIATOR', negotiator), 258 | force_negotiation=config.get('FORCE_NEGOTIATION', force_negotiation), 259 | force_rendering=config.get('FORCE_RENDERING', force_rendering) 260 | ) 261 | app.middlewares.append(middleware) 262 | return app 263 | -------------------------------------------------------------------------------- /aiohttp_utils/routing.py: -------------------------------------------------------------------------------- 1 | """Routing utilities.""" 2 | from collections.abc import Mapping 3 | from contextlib import contextmanager 4 | import importlib 5 | 6 | from aiohttp import web 7 | 8 | __all__ = ( 9 | 'ResourceRouter', 10 | 'add_route_context', 11 | 'add_resource_context', 12 | ) 13 | 14 | 15 | class ResourceRouter(web.UrlDispatcher): 16 | """Router with an :meth:`add_resource` method for registering method-based handlers, 17 | a.k.a "resources". Includes all the methods `aiohttp.web.UrlDispatcher` with the addition 18 | of `add_resource`. 19 | 20 | Example: 21 | 22 | .. code-block:: python 23 | 24 | from aiohttp import web 25 | from aiohttp_utils.routing import ResourceRouter 26 | 27 | app = web.Application(router=ResourceRouter()) 28 | 29 | class IndexResource: 30 | 31 | async def get(self, request): 32 | return web.Response(body=b'Got it', content_type='text/plain') 33 | 34 | async def post(self, request): 35 | return web.Response(body=b'Posted it', content_type='text/plain') 36 | 37 | 38 | app.router.add_resource_object('/', IndexResource()) 39 | 40 | # Normal function-based handlers still work 41 | async def handler(request): 42 | return web.Response() 43 | 44 | app.router.add_route('GET', '/simple', handler) 45 | 46 | By default, handler names will be registered with the name ``:``. :: 47 | 48 | app.router['IndexResource:post'].url() == '/' 49 | 50 | You can override the default names by passing a ``names`` dict to `add_resource`. :: 51 | 52 | app.router.add_resource_object('/', IndexResource(), names={'get': 'index_get'}) 53 | app.router['index_get'].url() == '/' 54 | """ 55 | 56 | HTTP_METHOD_NAMES = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] 57 | 58 | def get_default_handler_name(self, resource, method_name: str): 59 | return '{resource.__class__.__name__}:{method_name}'.format(**locals()) 60 | 61 | def add_resource_object(self, path: str, resource, 62 | methods: tuple = tuple(), names: Mapping = None): 63 | """Add routes by an resource instance's methods. 64 | 65 | :param path: route path. Should be started with slash (``'/'``). 66 | :param resource: A "resource" instance. May be an instance of a plain object. 67 | :param methods: Methods (strings) to register. 68 | :param names: Dictionary of ``name`` overrides. 69 | """ 70 | names = names or {} 71 | if methods: 72 | method_names = methods 73 | else: 74 | method_names = self.HTTP_METHOD_NAMES 75 | for method_name in method_names: 76 | handler = getattr(resource, method_name, None) 77 | if handler: 78 | name = names.get(method_name, self.get_default_handler_name(resource, method_name)) 79 | self.add_route(method_name.upper(), path, handler, name=name) 80 | 81 | 82 | def make_path(path, url_prefix=None): 83 | return ('/'.join((url_prefix.rstrip('/'), path.lstrip('/'))) 84 | if url_prefix 85 | else path) 86 | 87 | 88 | @contextmanager 89 | def add_route_context( 90 | app: web.Application, module=None, 91 | url_prefix: str = None, name_prefix: str = None): 92 | """Context manager which yields a function for adding multiple routes from a given module. 93 | 94 | Example: 95 | 96 | .. code-block:: python 97 | 98 | # myapp/articles/views.py 99 | async def list_articles(request): 100 | return web.Response(b'article list...') 101 | 102 | async def create_article(request): 103 | return web.Response(b'created article...') 104 | 105 | .. code-block:: python 106 | 107 | # myapp/app.py 108 | from myapp.articles import views 109 | 110 | with add_route_context(app, url_prefix='/api/', name_prefix='articles') as route: 111 | route('GET', '/articles/', views.list_articles) 112 | route('POST', '/articles/', views.create_article) 113 | 114 | app.router['articles.list_articles'].url() # /api/articles/ 115 | 116 | If you prefer, you can also pass module and handler names as strings. 117 | 118 | .. code-block:: python 119 | 120 | with add_route_context(app, module='myapp.articles.views', 121 | url_prefix='/api/', name_prefix='articles') as route: 122 | route('GET', '/articles/', 'list_articles') 123 | route('POST', '/articles/', 'create_article') 124 | 125 | :param app: Application to add routes to. 126 | :param module: Import path to module (str) or module object which contains the handlers. 127 | :param url_prefix: Prefix to prepend to all route paths. 128 | :param name_prefix: Prefix to prepend to all route names. 129 | """ 130 | if isinstance(module, (str, bytes)): 131 | module = importlib.import_module(module) 132 | 133 | def add_route(method, path, handler, name=None): 134 | """ 135 | :param str method: HTTP method. 136 | :param str path: Path for the route. 137 | :param handler: A handler function or a name of a handler function contained 138 | in `module`. 139 | :param str name: Name for the route. If `None`, defaults to the handler's 140 | function name. 141 | """ 142 | if isinstance(handler, (str, bytes)): 143 | if not module: 144 | raise ValueError( 145 | 'Must pass module to add_route_context if passing handler name strings.' 146 | ) 147 | name = name or handler 148 | handler = getattr(module, handler) 149 | else: 150 | name = name or handler.__name__ 151 | path = make_path(path, url_prefix) 152 | name = '.'.join((name_prefix, name)) if name_prefix else name 153 | return app.router.add_route(method, path, handler, name=name) 154 | yield add_route 155 | 156 | 157 | def get_supported_method_names(resource): 158 | return [ 159 | method_name for method_name in ResourceRouter.HTTP_METHOD_NAMES 160 | if hasattr(resource, method_name) 161 | ] 162 | 163 | 164 | @contextmanager 165 | def add_resource_context( 166 | app: web.Application, module=None, 167 | url_prefix: str = None, name_prefix: str = None, 168 | make_resource=lambda cls: cls()): 169 | """Context manager which yields a function for adding multiple resources from a given module 170 | to an app using `ResourceRouter `. 171 | 172 | Example: 173 | 174 | .. code-block:: python 175 | 176 | # myapp/articles/views.py 177 | class ArticleList: 178 | async def get(self, request): 179 | return web.Response(b'article list...') 180 | 181 | class ArticleDetail: 182 | async def get(self, request): 183 | return web.Response(b'article detail...') 184 | 185 | .. code-block:: python 186 | 187 | # myapp/app.py 188 | from myapp.articles import views 189 | 190 | with add_resource_context(app, url_prefix='/api/') as route: 191 | route('/articles/', views.ArticleList()) 192 | route('/articles/{pk}', views.ArticleDetail()) 193 | 194 | app.router['ArticleList:get'].url() # /api/articles/ 195 | app.router['ArticleDetail:get'].url(pk='42') # /api/articles/42 196 | 197 | If you prefer, you can also pass module and class names as strings. :: 198 | 199 | with add_resource_context(app, module='myapp.articles.views', 200 | url_prefix='/api/') as route: 201 | route('/articles/', 'ArticleList') 202 | route('/articles/{pk}', 'ArticleDetail') 203 | 204 | .. note:: 205 | If passing class names, the resource classes will be instantiated with no 206 | arguments. You can change this behavior by overriding ``make_resource``. 207 | 208 | .. code-block:: python 209 | 210 | # myapp/authors/views.py 211 | class AuthorList: 212 | def __init__(self, db): 213 | self.db = db 214 | 215 | async def get(self, request): 216 | # Fetch authors from self.db... 217 | 218 | .. code-block:: python 219 | 220 | # myapp/app.py 221 | from myapp.database import db 222 | 223 | with add_resource_context(app, module='myapp.authors.views', 224 | url_prefix='/api/', 225 | make_resource=lambda cls: cls(db=db)) as route: 226 | route('/authors/', 'AuthorList') 227 | 228 | :param app: Application to add routes to. 229 | :param resource: Import path to module (str) or module object 230 | which contains the resource classes. 231 | :param url_prefix: Prefix to prepend to all route paths. 232 | :param name_prefix: Prefix to prepend to all route names. 233 | :param make_resource: Function which receives a resource class and returns 234 | a resource instance. 235 | """ 236 | assert isinstance(app.router, ResourceRouter), 'app must be using ResourceRouter' 237 | 238 | if isinstance(module, (str, bytes)): 239 | module = importlib.import_module(module) 240 | 241 | def get_base_name(resource, method_name, names): 242 | return names.get(method_name, 243 | app.router.get_default_handler_name(resource, method_name)) 244 | 245 | default_make_resource = make_resource 246 | 247 | def add_route(path: str, resource, 248 | names: Mapping = None, make_resource=None): 249 | make_resource = make_resource or default_make_resource 250 | names = names or {} 251 | if isinstance(resource, (str, bytes)): 252 | if not module: 253 | raise ValueError( 254 | 'Must pass module to add_route_context if passing resource name strings.' 255 | ) 256 | resource_cls = getattr(module, resource) 257 | resource = make_resource(resource_cls) 258 | path = make_path(path, url_prefix) 259 | if name_prefix: 260 | supported_method_names = get_supported_method_names(resource) 261 | names = { 262 | method_name: '.'.join( 263 | (name_prefix, get_base_name(resource, method_name, names=names)) 264 | ) 265 | for method_name in supported_method_names 266 | } 267 | return app.router.add_resource_object(path, resource, names=names) 268 | yield add_route 269 | -------------------------------------------------------------------------------- /aiohttp_utils/runner.py: -------------------------------------------------------------------------------- 1 | """Run an `aiohttp.web.Application` on a local development server. If 2 | :attr:`debug` is set to `True` on the application, the server will automatically 3 | reload when code changes. 4 | :: 5 | 6 | from aiohttp import web 7 | from aiohttp_utils import run 8 | 9 | app = web.Application() 10 | # ... 11 | run(app, app_uri='path.to.module:app', reload=True, port=5000) 12 | 13 | 14 | .. warning:: 15 | Auto-reloading functionality is currently **experimental**. 16 | """ 17 | import warnings 18 | 19 | from aiohttp import web 20 | from aiohttp.worker import GunicornWebWorker as BaseWorker 21 | from gunicorn.app.wsgiapp import WSGIApplication as BaseApplication 22 | from gunicorn.util import import_app 23 | 24 | __all__ = ( 25 | 'run', 26 | 'Runner', 27 | 'GunicornApp', 28 | 'GunicornWorker', 29 | ) 30 | 31 | 32 | class GunicornWorker(BaseWorker): 33 | # Override to set the app's loop to the worker's loop 34 | def make_handler(self, app, **kwargs): 35 | app._loop = self.loop 36 | return super().make_handler(app, **kwargs) 37 | 38 | 39 | class GunicornApp(BaseApplication): 40 | 41 | def __init__(self, app: web.Application, 42 | app_uri: str = None, *args, **kwargs): 43 | self._app = app 44 | self.app_uri = app_uri 45 | super().__init__(*args, **kwargs) 46 | 47 | # Override BaseApplication so that we don't try to parse command-line args 48 | def load_config(self): 49 | pass 50 | 51 | # Override BaseApplication to return aiohttp app 52 | def load(self): 53 | self.chdir() 54 | if self.app_uri: 55 | return import_app(self.app_uri) 56 | return self._app 57 | 58 | 59 | class Runner: 60 | worker_class = 'aiohttp_utils.runner.GunicornWorker' 61 | 62 | def __init__( 63 | self, 64 | app: web.Application = None, 65 | app_uri: str = None, 66 | host='127.0.0.1', 67 | port=5000, 68 | reload: bool = None, 69 | **options 70 | ): 71 | warnings.warn('aiohttp_utils.runner is deprecated. ' 72 | 'Install aiohttp-devtools and use ' 73 | 'the "adev runserver" command instead.', DeprecationWarning) 74 | self.app = app 75 | self.app_uri = app_uri 76 | self.host = host 77 | self.port = port 78 | self.reload = reload if reload is not None else app.debug 79 | if self.reload and not self.app_uri: 80 | raise RuntimeError('"reload" option requires "app_uri"') 81 | self.options = options 82 | 83 | @property 84 | def bind(self): 85 | return '{self.host}:{self.port}'.format(self=self) 86 | 87 | def make_gunicorn_app(self): 88 | gapp = GunicornApp(self.app, app_uri=self.app_uri) 89 | gapp.cfg.set('bind', self.bind) 90 | gapp.cfg.set('reload', self.reload) 91 | gapp.cfg.settings['worker_class'].default = self.worker_class 92 | gapp.cfg.set('worker_class', self.worker_class) 93 | for key, value in self.options.items(): 94 | gapp.cfg.set(key, value) 95 | return gapp 96 | 97 | def run(self): 98 | gapp = self.make_gunicorn_app() 99 | gapp.run() 100 | 101 | 102 | def run(app: web.Application, **kwargs): 103 | """Run an `aiohttp.web.Application` using gunicorn. 104 | 105 | :param app: The app to run. 106 | :param str app_uri: Import path to `app`. Takes the form 107 | ``$(MODULE_NAME):$(VARIABLE_NAME)``. 108 | The module name can be a full dotted path. 109 | The variable name refers to the `aiohttp.web.Application` instance. 110 | This argument is required if ``reload=True``. 111 | :param str host: Hostname to listen on. 112 | :param int port: Port of the server. 113 | :param bool reload: Whether to reload the server on a code change. 114 | If not set, will take the same value as ``app.debug``. 115 | **EXPERIMENTAL**. 116 | :param kwargs: Extra configuration options to set on the 117 | ``GunicornApp's`` config object. 118 | """ 119 | runner = Runner(app, **kwargs) 120 | runner.run() 121 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | # Build tasks 2 | invoke 3 | 4 | # Testing 5 | pytest 6 | tox>=1.5.0 7 | webtest-aiohttp>=1.1.0 8 | Mako # Used in the integration tests 9 | 10 | # Packaging 11 | wheel 12 | twine 13 | 14 | # Syntax checking 15 | flake8==5.0.4 16 | 17 | -e . 18 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/complexity.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/complexity.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/complexity" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/complexity" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. _changelog: 2 | 3 | .. include:: ../CHANGELOG.rst 4 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import datetime as dt 5 | import os 6 | import sys 7 | 8 | sys.path.insert(0, os.path.abspath('..')) 9 | import aiohttp_utils # noqa 10 | 11 | extensions = [ 12 | 'sphinx.ext.autodoc', 13 | 'sphinx.ext.intersphinx', 14 | 'sphinx.ext.viewcode', 15 | 'sphinx_issues', 16 | 'sphinx_autodoc_annotation', 17 | ] 18 | 19 | primary_domain = 'py' 20 | default_role = 'py:obj' 21 | 22 | intersphinx_mapping = { 23 | 'python': ('https://python.readthedocs.io/en/latest/', None), 24 | 'aiohttp': ('https://aiohttp.readthedocs.io/en/stable/', None), 25 | } 26 | 27 | issues_github_path = 'sloria/aiohttp_utils' 28 | 29 | source_suffix = '.rst' 30 | master_doc = 'index' 31 | project = 'aiohttp_utils' 32 | copyright = 'Steven Loria {0:%Y}'.format( 33 | dt.datetime.utcnow() 34 | ) 35 | 36 | version = release = aiohttp_utils.__version__ 37 | 38 | exclude_patterns = ['_build'] 39 | 40 | # THEME 41 | 42 | # on_rtd is whether we are on readthedocs.org 43 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 44 | 45 | if not on_rtd: # only import and set the theme if we're building docs locally 46 | import sphinx_rtd_theme 47 | html_theme = 'sphinx_rtd_theme' 48 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 49 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | aiohttp_utils 2 | ============= 3 | 4 | Release v\ |version|. (:ref:`Changelog `) 5 | 6 | **aiohttp_utils** provides handy utilities for building `aiohttp.web `_ applications. 7 | 8 | 9 | * Method-based handlers ("resources") 10 | * Routing utilities 11 | * Content negotiation with JSON rendering by default 12 | 13 | **Everything is optional**. You can use as much (or as little) of this toolkit as you need. 14 | 15 | .. code-block:: python 16 | 17 | from aiohttp import web 18 | from aiohttp_utils import Response, routing, negotiation 19 | 20 | app = web.Application(router=routing.ResourceRouter()) 21 | 22 | # Method-based handlers 23 | class HelloResource: 24 | 25 | async def get(self, request): 26 | name = request.GET.get('name', 'World') 27 | return Response({ 28 | 'message': 'Hello ' + name 29 | }) 30 | 31 | 32 | app.router.add_resource_object('/', HelloResource()) 33 | 34 | # Content negotiation 35 | negotiation.setup( 36 | app, renderers={ 37 | 'application/json': negotiation.render_json 38 | } 39 | ) 40 | 41 | Install 42 | ------- 43 | :: 44 | 45 | $ pip install aiohttp_utils 46 | 47 | 48 | **Ready to get started?** Go on to one of the the usage guides below or check out some `examples `_. 49 | 50 | Guides 51 | ------ 52 | 53 | Below are usage guides for each of the modules. 54 | 55 | .. toctree:: 56 | :maxdepth: 1 57 | 58 | modules/negotiation 59 | modules/routing 60 | 61 | Project info 62 | ------------ 63 | 64 | .. toctree:: 65 | :maxdepth: 1 66 | 67 | changelog 68 | versioning 69 | license 70 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | License 3 | ******* 4 | 5 | .. literalinclude:: ../LICENSE 6 | 7 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\complexity.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\complexity.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/modules/negotiation.rst: -------------------------------------------------------------------------------- 1 | ************************************* 2 | ``negotiation`` - Content negotiation 3 | ************************************* 4 | 5 | .. automodule:: aiohttp_utils.negotiation 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/modules/path_norm.rst: -------------------------------------------------------------------------------- 1 | ********************************** 2 | ``path_norm`` - Path normalization 3 | ********************************** 4 | 5 | .. automodule:: aiohttp_utils.path_norm 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/modules/routing.rst: -------------------------------------------------------------------------------- 1 | ******************************* 2 | ``routing`` - Routing utilities 3 | ******************************* 4 | 5 | .. automodule:: aiohttp_utils.routing 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/modules/runner.rst: -------------------------------------------------------------------------------- 1 | *********************************************************** 2 | ``runner`` - Run applications on a local development server 3 | *********************************************************** 4 | 5 | .. automodule:: aiohttp_utils.runner 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../dev-requirements.txt 2 | sphinx 3 | sphinx-rtd-theme 4 | sphinx-autodoc-annotation 5 | sphinx-issues>=0.2.0 6 | -------------------------------------------------------------------------------- /docs/versioning.rst: -------------------------------------------------------------------------------- 1 | ********** 2 | Versioning 3 | ********** 4 | 5 | aiohttp_utils follows a form of `sentimental versioning `_. Given that this package contains indepedendent modules with varying degrees of stability, `semver `_ doesn't quite fit. 6 | 7 | Major breaking changes will be clearly documented in the :ref:`changelog `. 8 | -------------------------------------------------------------------------------- /examples/kitchen_sink.py: -------------------------------------------------------------------------------- 1 | """Example using 2 | 3 | - `routing.ResourceRouter`, 4 | - routing helpers, 5 | - content negotiation, 6 | - path normalization, and 7 | - local development server with reloading. 8 | 9 | Start the app with the `adev runserver` command from aiohttp-devtools 10 | :: 11 | $ pip install aiohttp-devtools 12 | $ adev runserver examples/kitchen_sink.py 13 | 14 | Try it out: 15 | :: 16 | 17 | $ pip install httpie 18 | $ http :8000/ 19 | $ http :8000/api/ 20 | """ 21 | from aiohttp import web 22 | from aiohttp_utils import Response, routing, negotiation 23 | 24 | app = web.Application(router=routing.ResourceRouter()) 25 | 26 | 27 | async def index(request): 28 | return Response('Welcome!') 29 | 30 | 31 | class HelloResource: 32 | 33 | async def get(self, request): 34 | return Response({ 35 | 'message': 'Welcome to the API!' 36 | }) 37 | 38 | 39 | with routing.add_route_context(app) as route: 40 | route('GET', '/', index) 41 | 42 | with routing.add_resource_context(app, url_prefix='/api/') as route: 43 | route('/', HelloResource()) 44 | 45 | negotiation.setup(app) 46 | -------------------------------------------------------------------------------- /examples/mako_example.py: -------------------------------------------------------------------------------- 1 | """Example of using content negotiation to simultaneously support HTML and JSON representations, 2 | using Mako for templating. Also demonstrates app configuration. 3 | 4 | Start the app with the `adev runserver` command from aiohttp-devtools 5 | :: 6 | $ pip install aiohttp-devtools 7 | $ adev runserver examples/mako_example.py 8 | 9 | Try it out: 10 | :: 11 | 12 | $ pip install httpie 13 | $ http :8000/ Accept:application/json 14 | $ http :8000/ Accept:text/html 15 | """ 16 | from collections import OrderedDict 17 | from collections.abc import Mapping 18 | 19 | from aiohttp import web 20 | from aiohttp_utils import Response, negotiation 21 | 22 | from mako.lookup import TemplateLookup 23 | 24 | 25 | # ##### Templates ##### 26 | 27 | lookup = TemplateLookup() 28 | # Note: In a real app, this would be in a separate file. 29 | template = """ 30 | 31 | 32 |

${message}

33 | 34 | 35 | """ 36 | lookup.put_string('index.html', template) 37 | 38 | 39 | # ##### Handlers ##### 40 | 41 | async def index(request): 42 | return Response({ 43 | 'message': 'Hello ' + request.query.get('name', 'World') 44 | }) 45 | 46 | 47 | # ##### Custom router ##### 48 | 49 | class RouterWithTemplating(web.UrlDispatcher): 50 | """Optionally save a template name on a handler function's __dict__.""" 51 | 52 | def add_route(self, method, path, handler, template: str = None, **kwargs): 53 | if template: 54 | handler.__dict__['template'] = template 55 | super().add_route(method, path, handler, **kwargs) 56 | 57 | 58 | # ##### Renderer ##### 59 | 60 | def render_mako(request, data): 61 | handler = request.match_info.handler 62 | template_name = handler.__dict__.get('template', None) 63 | if not template_name: 64 | raise web.HTTPNotAcceptable(text='text/html not supported.') 65 | if not isinstance(data, Mapping): 66 | raise web.HTTPInternalServerError( 67 | text="context should be mapping, not {}".format(type(data))) 68 | template = request.app['mako_lookup'].get_template(template_name) 69 | text = template.render_unicode(**data) 70 | return web.Response(text=text, content_type=request['selected_media_type']) 71 | 72 | 73 | # ##### Configuration ##### 74 | 75 | CONFIG = { 76 | 'AIOHTTP_UTILS': { 77 | 'RENDERERS': OrderedDict([ 78 | ('application/json', negotiation.render_json), 79 | ('text/html', render_mako), 80 | ]) 81 | } 82 | } 83 | 84 | 85 | # ##### Application ##### 86 | 87 | app = web.Application(router=RouterWithTemplating(), debug=True) 88 | app['mako_lookup'] = lookup 89 | app.update(CONFIG) 90 | negotiation.setup(app) 91 | app.router.add_route('GET', '/', index, template='index.html') 92 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # This flag says that the code is written to work on both Python 2 and Python 3 | # 3. If at all possible, it is good practice to do this. If you cannot, you 4 | # will need to generate wheels for each Python version that you support. 5 | universal=1 6 | 7 | [flake8] 8 | max-line-length = 100 9 | exclude=docs,.git,.tox,aiohttp_utils/compat.py,build,setup.py,env,venv 10 | 11 | 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | from setuptools import setup, find_packages 4 | 5 | REQUIRES = [ 6 | 'aiohttp>=3', 7 | 'python-mimeparse', 8 | 'gunicorn', 9 | ] 10 | 11 | 12 | def find_version(fname): 13 | """Attempts to find the version number in the file names fname. 14 | Raises RuntimeError if not found. 15 | """ 16 | version = '' 17 | with open(fname, 'r') as fp: 18 | reg = re.compile(r'__version__ = [\'"]([^\'"]*)[\'"]') 19 | for line in fp: 20 | m = reg.match(line) 21 | if m: 22 | version = m.group(1) 23 | break 24 | if not version: 25 | raise RuntimeError('Cannot find version information') 26 | return version 27 | 28 | __version__ = find_version('aiohttp_utils/__init__.py') 29 | 30 | 31 | def read(fname): 32 | with open(fname) as fp: 33 | content = fp.read() 34 | return content 35 | 36 | setup( 37 | name='aiohttp-utils', 38 | version=__version__, 39 | description='Handy utilities for aiohttp.web applications.', 40 | long_description=read('README.rst'), 41 | long_description_content_type='text/x-rst', 42 | author='Steven Loria', 43 | author_email='sloria1@gmail.com', 44 | url='https://github.com/sloria/aiohttp-utils', 45 | packages=find_packages(exclude=("test*", )), 46 | package_dir={'aiohttp-utils': 'aiohttp_utils'}, 47 | include_package_data=True, 48 | install_requires=REQUIRES, 49 | license='MIT', 50 | zip_safe=False, 51 | keywords='aiohttp_utils aiohttp utilities aiohttp.web', 52 | classifiers=[ 53 | 'Development Status :: 2 - Pre-Alpha', 54 | 'Intended Audience :: Developers', 55 | 'License :: OSI Approved :: MIT License', 56 | 'Natural Language :: English', 57 | 'Programming Language :: Python :: 3', 58 | 'Programming Language :: Python :: 3.5', 59 | 'Programming Language :: Python :: 3.6', 60 | 'Programming Language :: Python :: 3.7', 61 | ], 62 | test_suite='tests' 63 | ) 64 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | import webbrowser 5 | 6 | from invoke import task 7 | 8 | docs_dir = 'docs' 9 | build_dir = os.path.join(docs_dir, '_build') 10 | 11 | 12 | @task 13 | def test(ctx, watch=False, last_failing=False): 14 | """Run the tests. 15 | 16 | Note: --watch requires pytest-xdist to be installed. 17 | """ 18 | import pytest 19 | flake(ctx) 20 | args = [] 21 | if watch: 22 | args.append('-f') 23 | if last_failing: 24 | args.append('--lf') 25 | retcode = pytest.main(args) 26 | sys.exit(retcode) 27 | 28 | 29 | @task 30 | def flake(ctx): 31 | """Run flake8 on codebase.""" 32 | ctx.run('flake8 .', echo=True) 33 | 34 | 35 | @task 36 | def clean(ctx): 37 | ctx.run("rm -rf build") 38 | ctx.run("rm -rf dist") 39 | ctx.run("rm -rf aiohttp_utils.egg-info") 40 | clean_docs(ctx) 41 | print("Cleaned up.") 42 | 43 | 44 | @task 45 | def clean_docs(ctx): 46 | ctx.run("rm -rf %s" % build_dir) 47 | 48 | 49 | @task 50 | def browse_docs(ctx): 51 | path = os.path.join(build_dir, 'index.html') 52 | webbrowser.open_new_tab(path) 53 | 54 | 55 | def build_docs(ctx, browse): 56 | ctx.run("sphinx-build %s %s" % (docs_dir, build_dir), echo=True) 57 | if browse: 58 | browse_docs(ctx) 59 | 60 | 61 | @task 62 | def docs(ctx, clean=False, browse=False, watch=False): 63 | """Build the docs.""" 64 | if clean: 65 | clean_docs(ctx) 66 | if watch: 67 | watch_docs(ctx, browse=browse) 68 | else: 69 | build_docs(ctx, browse=browse) 70 | 71 | 72 | @task 73 | def watch_docs(ctx, browse=False): 74 | """Run build the docs when a file changes.""" 75 | try: 76 | import sphinx_autobuild # noqa 77 | except ImportError: 78 | print('ERROR: watch task requires the sphinx_autobuild package.') 79 | print('Install it with:') 80 | print(' pip install sphinx-autobuild') 81 | sys.exit(1) 82 | ctx.run('sphinx-autobuild {0} {1} {2} -z aiohttp_utils'.format( 83 | '--open-browser' if browse else '', docs_dir, build_dir), echo=True, pty=True) 84 | 85 | 86 | @task 87 | def readme(ctx, browse=False): 88 | ctx.run('rst2html.py README.rst > README.html') 89 | if browse: 90 | webbrowser.open_new_tab('README.html') 91 | 92 | 93 | @task 94 | def publish(ctx, test=False): 95 | """Publish to the cheeseshop.""" 96 | clean(ctx) 97 | if test: 98 | ctx.run('python setup.py register -r test sdist bdist_wheel', echo=True) 99 | ctx.run('twine upload dist/* -r test', echo=True) 100 | else: 101 | ctx.run('python setup.py register sdist bdist_wheel', echo=True) 102 | ctx.run('twine upload dist/*', echo=True) 103 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import asyncio 4 | 5 | from aiohttp import web 6 | import pytest 7 | 8 | from webtest_aiohttp import TestApp 9 | 10 | 11 | @pytest.fixture(scope='session') 12 | def loop(): 13 | """Create and provide asyncio loop.""" 14 | loop_ = asyncio.get_event_loop() 15 | asyncio.set_event_loop(loop_) 16 | return loop_ 17 | 18 | 19 | @pytest.fixture() 20 | def create_client(): 21 | def maker(app, *args, **kwargs): 22 | # Set to False because we set app['aiohttp_utils'], which 23 | # is invalid in wsgi environs 24 | kwargs.setdefault('lint', False) 25 | return TestApp(app, *args, **kwargs) 26 | return maker 27 | 28 | 29 | def make_dummy_handler(**kwargs): 30 | async def dummy(request): 31 | return web.Response(**kwargs) 32 | return dummy 33 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | """We use the apps in examples/ for integration tests.""" 2 | import pytest 3 | 4 | from examples.kitchen_sink import app as kitchen_sink_app 5 | from examples.mako_example import app as mako_app 6 | 7 | 8 | class TestKitchenSinkApp: 9 | 10 | @pytest.fixture() 11 | def client(self, loop, create_client): 12 | kitchen_sink_app._loop = loop 13 | return create_client(kitchen_sink_app) 14 | 15 | def test_index(self, client): 16 | res = client.get('/') 17 | assert res.status_code == 200 18 | assert res.json == 'Welcome!' 19 | 20 | def test_api_index(self, client): 21 | res = client.get('/api/') 22 | assert res.status_code == 200 23 | assert res.json == {'message': 'Welcome to the API!'} 24 | 25 | 26 | class TestMakoApp: 27 | 28 | @pytest.fixture() 29 | def client(self, loop, create_client): 30 | mako_app._loop = loop 31 | return create_client(mako_app) 32 | 33 | def test_json_request(self, client): 34 | res = client.get('/', headers={'Accept': 'application/json'}) 35 | assert res.content_type == 'application/json' 36 | assert res.json == {'message': 'Hello World'} 37 | 38 | def test_json_request_with_query_params(self, client): 39 | res = client.get('/?name=Ada', headers={'Accept': 'application/json'}) 40 | assert res.content_type == 'application/json' 41 | assert res.json == {'message': 'Hello Ada'} 42 | 43 | def test_html_request(self, client): 44 | res = client.get('/', headers={'Accept': 'text/html'}) 45 | assert res.status_code == 200 46 | assert res.content_type == 'text/html' 47 | assert res.html.find('h1').text == 'Hello World' 48 | -------------------------------------------------------------------------------- /tests/test_negotiation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from collections import OrderedDict 3 | 4 | from aiohttp import web 5 | 6 | from aiohttp_utils import negotiation, CONFIG_KEY 7 | from aiohttp_utils.negotiation import Response 8 | 9 | 10 | @pytest.fixture() 11 | def app(loop): 12 | return web.Application(loop=loop) 13 | 14 | 15 | @pytest.fixture() 16 | def client(create_client, app): 17 | return create_client(app) 18 | 19 | 20 | def add_routes(app): 21 | def handler(request): 22 | return Response({'message': 'Hello world'}) 23 | 24 | def handler_false(request): 25 | return Response(False) 26 | 27 | def handler_none(request): 28 | return Response(None) 29 | 30 | async def coro_handler(request): 31 | return Response({'message': 'Hello coro'}) 32 | 33 | async def post_coro_handler(request): 34 | return Response({'message': 'Post coro'}, status=201) 35 | 36 | def handler_no_nego(request): 37 | return web.Response(body=b'Some raw data') 38 | 39 | app.router.add_route('GET', '/hello', handler) 40 | app.router.add_route('GET', '/hellocoro', coro_handler) 41 | app.router.add_route('POST', '/postcoro', post_coro_handler) 42 | app.router.add_route('GET', '/false', handler_false) 43 | app.router.add_route('GET', '/none', handler_none) 44 | app.router.add_route('GET', '/nonego', handler_no_nego) 45 | 46 | 47 | def configure_app(app, overrides=None, setup=False): 48 | overrides = overrides or {} 49 | add_routes(app) 50 | 51 | if setup: 52 | negotiation.setup(app, **overrides) 53 | else: 54 | middleware = negotiation.negotiation_middleware(**overrides) 55 | app.middlewares.append(middleware) 56 | 57 | 58 | @pytest.mark.parametrize('setup', [True, False]) 59 | def test_renders_to_json_by_default(app, client, setup): 60 | configure_app(app, overrides=None, setup=setup) 61 | res = client.get('/hello') 62 | assert res.content_type == 'application/json' 63 | assert res.json == {'message': 'Hello world'} 64 | 65 | res = client.get('/hellocoro') 66 | assert res.content_type == 'application/json' 67 | assert res.json == {'message': 'Hello coro'} 68 | 69 | res = client.post('/postcoro') 70 | assert res.content_type == 'application/json' 71 | assert res.status_code == 201 72 | assert res.json == {'message': 'Post coro'} 73 | 74 | # without force_rendering=True, false values discard any rendering process 75 | res = client.get('/false') 76 | assert res.content_type == 'application/octet-stream' 77 | assert res.body == b'' 78 | 79 | res = client.get('/none') 80 | assert res.content_type == 'application/octet-stream' 81 | assert res.body == b'' 82 | 83 | 84 | def dummy_renderer(request, data): 85 | return web.Response( 86 | body='

{}

'.format(data['message']).encode('utf-8'), 87 | content_type='text/html' 88 | ) 89 | 90 | 91 | def test_renderer_override(app, client): 92 | configure_app(app, overrides={ 93 | 'renderers': OrderedDict([ 94 | ('text/html', dummy_renderer), 95 | ]) 96 | }, setup=True) 97 | 98 | res = client.get('/hello') 99 | assert res.content_type == 'text/html' 100 | assert res.body == b'

Hello world

' 101 | 102 | 103 | def test_renderer_override_multiple_classes(app, client): 104 | configure_app(app, overrides={ 105 | 'renderers': OrderedDict([ 106 | ('text/html', dummy_renderer), 107 | ('application/json', negotiation.render_json), 108 | ('application/vnd.api+json', negotiation.render_json), 109 | ]) 110 | }, setup=True) 111 | 112 | res = client.get('/hello', headers={'Accept': 'application/json'}) 113 | assert res.content_type == 'application/json' 114 | assert res.json == {'message': 'Hello world'} 115 | 116 | res = client.get('/hello', headers={'Accept': 'application/vnd.api+json'}) 117 | assert res.content_type == 'application/vnd.api+json' 118 | assert res.json == {'message': 'Hello world'} 119 | 120 | res = client.get('/hello', headers={'Accept': 'text/html'}) 121 | assert res.content_type == 'text/html' 122 | assert res.body == b'

Hello world

' 123 | 124 | 125 | def test_renderer_override_force(app, client): 126 | configure_app(app, overrides={ 127 | 'force_negotiation': False, 128 | }, setup=True) 129 | 130 | res = client.get('/hello', headers={'Accept': 'text/notsupported'}, expect_errors=True) 131 | assert res.status_code == 406 132 | 133 | 134 | def test_renderer_override_force_rendering(app, client): 135 | configure_app(app, overrides={ 136 | 'force_rendering': True, 137 | }, setup=True) 138 | 139 | # with force_rendering=True, false values are rendered 140 | res = client.get('/false') 141 | assert res.content_type == 'application/json' 142 | assert res.json is False 143 | 144 | res = client.get('/none') 145 | assert res.content_type == 'application/json' 146 | assert res.json is None 147 | 148 | 149 | def test_nonordered_dict_of_renderers(app, client): 150 | configure_app(app, overrides={ 151 | 'renderers': { 152 | 'application/json': negotiation.render_json 153 | } 154 | }, setup=True) 155 | 156 | res = client.get('/hello', headers={'Accept': 'text/notsupported'}) 157 | assert res.content_type == 'application/json' 158 | 159 | 160 | def test_configuration_through_app_key(app, client): 161 | add_routes(app) 162 | app[CONFIG_KEY] = { 163 | 'RENDERERS': OrderedDict([ 164 | ('text/html', dummy_renderer), 165 | ]) 166 | } 167 | negotiation.setup(app) 168 | res = client.get('/hello') 169 | assert res.content_type == 'text/html' 170 | assert res.body == b'

Hello world

' 171 | 172 | 173 | def test_renderer_no_nego(app, client): 174 | add_routes(app) 175 | negotiation.setup(app) 176 | 177 | res = client.get('/nonego') 178 | assert res.content_type == 'application/octet-stream' 179 | assert res.body == b'Some raw data' 180 | 181 | res = client.get('/nonego', headers={'Accept': 'application/json'}) 182 | assert res.content_type == 'application/octet-stream' 183 | assert res.body == b'Some raw data' 184 | -------------------------------------------------------------------------------- /tests/test_routing.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | from aiohttp import web 5 | 6 | from aiohttp_utils.routing import ResourceRouter, add_route_context, add_resource_context 7 | from tests import views 8 | 9 | 10 | @pytest.fixture() 11 | def app(loop): 12 | return web.Application(loop=loop, router=ResourceRouter()) 13 | 14 | 15 | @pytest.fixture() 16 | def client(create_client, app): 17 | return create_client(app) 18 | 19 | 20 | def configure_app(app): 21 | 22 | class MyResource: 23 | 24 | def get(self, request): 25 | return web.Response(body=b'Got it', content_type='text/plain') 26 | 27 | async def post(self, request): 28 | return web.Response(body=b'Posted it', content_type='text/plain') 29 | 30 | class MyResource2: 31 | 32 | def get(self, request): 33 | return web.Response() 34 | 35 | def post(self, request): 36 | return web.Response() 37 | 38 | class ChildResource(MyResource): 39 | pass 40 | 41 | app.router.add_resource_object('/my', MyResource()) 42 | app.router.add_resource_object('/my2', MyResource2(), names={'get': 'my_resource2_get'}) 43 | app.router.add_resource_object('/child', ChildResource(), methods=('get', )) 44 | 45 | 46 | class TestResourceRouter: 47 | 48 | def test_registers_handlers(self, app, client): 49 | configure_app(app) 50 | 51 | res = client.get('/my') 52 | assert res.status_code == 200 53 | assert res.text == 'Got it' 54 | 55 | res = client.post('/my') 56 | assert res.status_code == 200 57 | assert res.text == 'Posted it' 58 | 59 | def test_default_names(self, app): 60 | configure_app(app) 61 | 62 | assert str(app.router['MyResource:get'].url_for()) == '/my' 63 | assert str(app.router['MyResource:post'].url_for()) == '/my' 64 | 65 | def test_name_override(self, app): 66 | configure_app(app) 67 | assert str(app.router['my_resource2_get'].url_for()) == '/my2' 68 | assert str(app.router['MyResource2:post'].url_for()) == '/my2' 69 | 70 | def test_methods_param(self, app, client): 71 | configure_app(app) 72 | 73 | with pytest.raises(KeyError): 74 | app.router['ChildResource:post'] 75 | 76 | res = client.post('/child', expect_errors=True) 77 | assert res.status_code == 405 78 | 79 | 80 | class TestAddRouteContext: 81 | 82 | def test_add_route_context_basic(self, app): 83 | with add_route_context(app, views) as route: 84 | route('GET', '/', 'index') 85 | route('GET', '/projects/', 'list_projects') 86 | route('POST', '/projects', 'create_projects') 87 | 88 | assert str(app.router['index'].url_for()) == '/' 89 | assert str(app.router['list_projects'].url_for()) == '/projects/' 90 | assert str(app.router['create_projects'].url_for()) == '/projects' 91 | 92 | def test_add_route_context_passing_handler_functions(self, app): 93 | with add_route_context(app) as route: 94 | route('GET', '/', views.index) 95 | route('GET', '/projects/', views.list_projects) 96 | route('POST', '/projects', views.create_projects) 97 | 98 | assert str(app.router['index'].url_for()) == '/' 99 | assert str(app.router['list_projects'].url_for()) == '/projects/' 100 | assert str(app.router['create_projects'].url_for()) == '/projects' 101 | 102 | def test_passing_module_and_handlers_as_strings(self, app): 103 | with add_route_context(app, module='tests.views') as route: 104 | route('GET', '/', 'index') 105 | route('GET', '/projects/', 'list_projects') 106 | route('POST', '/projects', 'create_projects') 107 | 108 | assert str(app.router['index'].url_for()) == '/' 109 | assert str(app.router['list_projects'].url_for()) == '/projects/' 110 | assert str(app.router['create_projects'].url_for()) == '/projects' 111 | 112 | def test_route_name_override(self, app): 113 | with add_route_context(app) as route: 114 | route('GET', '/', views.index, name='home') 115 | 116 | assert str(app.router['home'].url_for()) == '/' 117 | 118 | def test_add_route_raises_error_if_handler_not_found(self, app): 119 | with add_route_context(app, views) as route: 120 | with pytest.raises(AttributeError): 121 | route('GET', '/', 'notfound') 122 | 123 | def test_add_route_context_with_url_prefix(self, app): 124 | with add_route_context(app, views, url_prefix='/api/') as route: 125 | route('GET', '/', 'index') 126 | route('GET', '/projects/', 'list_projects') 127 | 128 | assert str(app.router['index'].url_for()) == '/api/' 129 | assert str(app.router['list_projects'].url_for()) == '/api/projects/' 130 | 131 | def test_add_route_context_with_name_prefix(self, app): 132 | with add_route_context(app, views, name_prefix='api') as route: 133 | route('GET', '/', 'index') 134 | route('GET', '/projects/', 'list_projects') 135 | 136 | assert str(app.router['api.index'].url_for()) == '/' 137 | assert str(app.router['api.list_projects'].url_for()) == '/projects/' 138 | 139 | 140 | class TestAddResourceContext: 141 | 142 | def test_add_resource_context_basic(self, app): 143 | with add_resource_context(app, views) as route: 144 | route('/articles/', 'ArticleResource') 145 | route('/articles/{pk}', 'ArticleList') 146 | 147 | assert str(app.router['ArticleResource:get'].url_for()) == '/articles/' 148 | assert str(app.router['ArticleResource:post'].url_for()) == '/articles/' 149 | assert str(app.router['ArticleList:post'].url_for( 150 | pk='42')) == '/articles/42' 151 | 152 | def test_add_resource_context_passing_classes(self, app): 153 | with add_resource_context(app) as route: 154 | route('/articles/', views.ArticleResource()) 155 | route('/articles/{pk}', views.ArticleList()) 156 | 157 | assert str(app.router['ArticleResource:get'].url_for()) == '/articles/' 158 | assert str(app.router['ArticleResource:post'].url_for()) == '/articles/' 159 | 160 | def test_passing_module_and_resources_as_strings(self, app): 161 | with add_resource_context(app, module='tests.views') as route: 162 | route('/articles/', 'ArticleResource') 163 | route('/articles/{pk}', 'ArticleList') 164 | 165 | assert str(app.router['ArticleResource:get'].url_for()) == '/articles/' 166 | assert str(app.router['ArticleResource:post'].url_for()) == '/articles/' 167 | assert str(app.router['ArticleList:post'].url_for( 168 | pk='42')) == '/articles/42' 169 | 170 | def test_make_resource_override(self, app): 171 | db = {} 172 | with add_resource_context(app, module='tests.views') as route: 173 | route('/authors/', 'AuthorList', make_resource=lambda cls: cls(db=db)) 174 | 175 | def test_make_resource_override_on_context_manager(self, app): 176 | db = {} 177 | with add_resource_context(app, module='tests.views', 178 | make_resource=lambda cls: cls(db=db)) as route: 179 | route('/authors/', 'AuthorList') 180 | 181 | def test_add_resource_context_passing_classes_with_prefix(self, app): 182 | with add_resource_context(app, name_prefix='articles') as route: 183 | route('/articles/', views.ArticleResource()) 184 | route('/articles/{pk}', views.ArticleList()) 185 | 186 | assert str(app.router['articles.ArticleResource:get'].url_for()) == '/articles/' 187 | assert str(app.router['articles.ArticleResource:post'].url_for()) == '/articles/' 188 | assert str(app.router['articles.ArticleList:post'].url_for( 189 | pk='42')) == '/articles/42' # noqa 190 | 191 | def test_add_resource_context_with_url_prefix(self, app): 192 | with add_resource_context(app, views, url_prefix='/api/') as route: 193 | route('/articles/', 'ArticleResource') 194 | 195 | assert str(app.router['ArticleResource:get'].url_for()) == '/api/articles/' 196 | assert str(app.router['ArticleResource:post'].url_for()) == '/api/articles/' 197 | 198 | def test_add_resource_context_with_name_prefix(self, app): 199 | with add_resource_context(app, views, name_prefix='api') as route: 200 | route('/articles/', 'ArticleResource') 201 | 202 | assert str(app.router['api.ArticleResource:get'].url_for()) == '/articles/' 203 | assert str(app.router['api.ArticleResource:post'].url_for()) == '/articles/' 204 | 205 | def test_add_resource_context_with_name_prefix_and_override(self, app): 206 | with add_resource_context(app, views, name_prefix='api') as route: 207 | route('/articles/', 'ArticleResource', names={'get': 'list_articles'}) 208 | 209 | assert str(app.router['api.list_articles'].url_for()) == '/articles/' 210 | assert str(app.router['api.ArticleResource:post'].url_for()) == '/articles/' 211 | -------------------------------------------------------------------------------- /tests/test_runner.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aiohttp import web 3 | from aiohttp_utils.runner import Runner 4 | 5 | 6 | class TestRunner: 7 | 8 | def test_bind(self): 9 | app = web.Application() 10 | runner = Runner(app, host='1.2.3.4', port=5678, 11 | app_uri='http://1.2.3.4:5678') 12 | assert runner.bind == '1.2.3.4:5678' 13 | 14 | def test_app_uri_is_required_for_reload(self): 15 | app = web.Application() 16 | with pytest.raises(RuntimeError): 17 | Runner(app, reload=True) # no api_uri 18 | 19 | def test_reload_takes_value_of_app_debug_by_default(self): 20 | app = web.Application(debug=True) 21 | assert Runner(app, app_uri='foo').reload is True 22 | app = web.Application(debug=False) 23 | assert Runner(app, app_uri='foo').reload is False 24 | 25 | def test_reload_override(self): 26 | app = web.Application(debug=True) 27 | assert Runner(app, reload=False).reload is False 28 | 29 | app = web.Application(debug=False) 30 | assert Runner(app, reload=True, app_uri='foo').reload is True 31 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | """Handlers for testing routing""" 2 | from aiohttp import web 3 | 4 | 5 | async def index(request): 6 | return web.Response() 7 | 8 | 9 | async def list_projects(request): 10 | return web.Response() 11 | 12 | 13 | async def create_projects(request): 14 | return web.Response() 15 | 16 | 17 | class ArticleResource: 18 | 19 | async def get(self, request): 20 | return web.Response() 21 | 22 | async def post(self, request): 23 | return web.Response() 24 | 25 | 26 | class ArticleList: 27 | 28 | async def get(self, request): 29 | return web.Response() 30 | 31 | async def post(self, request): 32 | return web.Response() 33 | 34 | 35 | class AuthorList: 36 | 37 | def __init__(self, db): 38 | self.db = db 39 | 40 | async def get(self, request): 41 | return web.Response() 42 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py35,py36,py37,py38,py39 3 | 4 | [gh-actions] 5 | python = 6 | 3.7: py37 7 | 3.8: py38 8 | 3.9: py39 9 | 3.10: py310 10 | 3.11: py311 11 | 12 | [testenv] 13 | deps= 14 | -rdev-requirements.txt 15 | commands= 16 | invoke test 17 | 18 | [testenv:docs] 19 | deps = 20 | -rdocs/requirements.txt 21 | commands = 22 | invoke docs 23 | 24 | 25 | --------------------------------------------------------------------------------