├── .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 |
--------------------------------------------------------------------------------