├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGES.md ├── LICENSE ├── README.md ├── examples ├── AsyncProgressBar │ ├── README.md │ ├── progress_bar.py │ └── requirements.txt ├── aioflaskr │ ├── .flaskenv │ ├── LICENSE │ ├── README.md │ ├── flaskr │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── blog.py │ │ ├── models.py │ │ ├── static │ │ │ └── style.css │ │ └── templates │ │ │ ├── auth │ │ │ ├── login.html │ │ │ └── register.html │ │ │ ├── base.html │ │ │ └── blog │ │ │ ├── create.html │ │ │ ├── index.html │ │ │ └── update.html │ ├── requirements.txt │ └── tests │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_auth.py │ │ ├── test_blog.py │ │ └── test_init.py ├── g │ └── app.py ├── hello_world │ ├── app.py │ └── templates │ │ └── index.html ├── login │ └── app.py ├── quotes-aiohttp │ ├── README.md │ └── quotes.py └── quotes-requests │ ├── README.md │ ├── quotes.py │ └── quotes_app.py ├── pyproject.toml ├── setup.cfg ├── setup.py ├── src └── aioflask │ ├── __init__.py │ ├── app.py │ ├── asgi.py │ ├── cli.py │ ├── ctx.py │ ├── patch.py │ ├── patched │ ├── __init__.py │ └── flask_login │ │ └── __init__.py │ ├── templating.py │ └── testing.py ├── tests ├── __init__.py ├── templates │ └── template.html ├── test_app.py ├── test_cli.py ├── test_ctx.py ├── test_patch.py ├── test_templating.py └── utils.py └── tox.ini /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | lint: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-python@v2 16 | - run: python -m pip install --upgrade pip wheel 17 | - run: pip install tox tox-gh-actions 18 | - run: tox -eflake8 19 | tests: 20 | name: tests 21 | strategy: 22 | matrix: 23 | os: [ubuntu-latest, macos-latest, windows-latest] 24 | python: ['3.7', '3.8', '3.9', '3.10'] 25 | fail-fast: false 26 | runs-on: ${{ matrix.os }} 27 | steps: 28 | - uses: actions/checkout@v2 29 | - uses: actions/setup-python@v2 30 | with: 31 | python-version: ${{ matrix.python }} 32 | - run: python -m pip install --upgrade pip wheel 33 | - run: pip install tox tox-gh-actions 34 | - run: tox 35 | coverage: 36 | name: coverage 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v2 40 | - uses: actions/setup-python@v2 41 | - run: python -m pip install --upgrade pip wheel 42 | - run: pip install tox tox-gh-actions codecov 43 | - run: tox 44 | - run: codecov 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - method: pip 14 | path: . 15 | extra_requirements: 16 | - docs 17 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # aioflask change log 2 | 3 | **Release 0.4.0** - 2021-08-18 4 | 5 | - Support for app factory functions with uvicorn ([commit](https://github.com/miguelgrinberg/aioflask/commit/7f51ca835a5b581b28915b9818428ea09f720081)) 6 | - Make app context async ([commit](https://github.com/miguelgrinberg/aioflask/commit/d07ea5449389ae58b286ceff389386e9481e6715)) 7 | - Make request context async ([commit](https://github.com/miguelgrinberg/aioflask/commit/4232b2819ad7cf3ed386578a679ba2cbc75f91b0)) 8 | - Make test and cli runner clients async ([commit](https://github.com/miguelgrinberg/aioflask/commit/12408cb1d6018fabac8d2749607687164fb1da50)) 9 | - Patcher for 3rd party decorators without async view support ([commit](https://github.com/miguelgrinberg/aioflask/commit/b7d4433acd153c43463bd047ddfa19b8c2087078)) 10 | - Flask-Login support ([commit #1](https://github.com/miguelgrinberg/aioflask/commit/cbe8abcc0d890bc03787b75ba3c7cb78d5333f38)) ([commit #2](https://github.com/miguelgrinberg/aioflask/commit/e0ab0e0fe1a3b51c3dc3b35abc47b21002f034c3)) 11 | - Fix handling of application context ([commit](https://github.com/miguelgrinberg/aioflask/commit/f0b14856b58bd1e85b2b054cd6b3028da0f89091)) ([commit #3](https://github.com/miguelgrinberg/aioflask/commit/a6f5a67a1d9eaa4046d075c1417f5c042dd30c38)) 12 | - More unit tests ([commit](https://github.com/miguelgrinberg/aioflask/commit/cf061caa60c9c32975db7560de6c6e8dbe746e7d)) ([commit](https://github.com/miguelgrinberg/aioflask/commit/28f6bd5d62baa8857310aedd2ba728ab3e7322b6)) ([commit](https://github.com/miguelgrinberg/aioflask/commit/0b5f9e7bb0ba98f3c291dd5aa2c2e55ebce4aa61)) ([commit](https://github.com/miguelgrinberg/aioflask/commit/d4275e15474906c30acb80acac0a41766ef1d5d7)) 13 | - Update example documentation to use `flask aiorun` ([commit](https://github.com/miguelgrinberg/aioflask/commit/634ee10b7cbc934fa70d512a66334e78ddc39b3a)) 14 | 15 | **Release 0.3.0** - 2021-06-07 16 | 17 | - Test client support, and some more unit tests ([commit](https://github.com/miguelgrinberg/aioflask/commit/c765e12f6382d685bbec1861dac062c13d63aea3)) 18 | - Started a change log ([commit](https://github.com/miguelgrinberg/aioflask/commit/e6f4c3a87964fb2e5ea3f4464853c2a1d5ecfc29)) 19 | - Improved example code ([commit](https://github.com/miguelgrinberg/aioflask/commit/496ce73f3f0ecb1bdbdd25bd957ce08e6742c191)) 20 | - One more example ([commit](https://github.com/miguelgrinberg/aioflask/commit/7edab525809f7ba19562f67bb363a033563d6158)) 21 | 22 | **Release 0.2.0** - 2021-05-15 23 | 24 | - Flask 2.x changes ([commit](https://github.com/miguelgrinberg/aioflask/commit/52aef31fb9a7f8fe6a54b156fe257db1300c0ca6)) 25 | - Update README.md ([commit](https://github.com/miguelgrinberg/aioflask/commit/c232561ff3e1c954c49ab362be030da854ceb8ba)) 26 | - codecov.io integration ([commit](https://github.com/miguelgrinberg/aioflask/commit/d558dfde5f0717dc6f9b6ff0cedec142ffe60335)) 27 | - github actions build ([commit](https://github.com/miguelgrinberg/aioflask/commit/c5f43dacae3d8c73ac0c55a7a58b7c9ac985195a)) 28 | 29 | **Release 0.1** - 2020-11-07 30 | 31 | - async render_template and CLI commands ([commit](https://github.com/miguelgrinberg/aioflask/commit/2e6944c111bd581e1c0eb345ffe88cb1ec014140)) 32 | - travis builds ([commit](https://github.com/miguelgrinberg/aioflask/commit/5834c8526fffe424bccfcbe62aa03e33c81b3018)) 33 | - app.run implementation and debug mode fixes ([commit](https://github.com/miguelgrinberg/aioflask/commit/2dc8426b5e5e52309639aa31db1f845c44226259)) 34 | - add note about the experimental nature of this thing ([commit](https://github.com/miguelgrinberg/aioflask/commit/f027e5ba95cc16ed3c513525d88a197c22001784)) 35 | - initial version ([commit](https://github.com/miguelgrinberg/aioflask/commit/4f1d1a343642fa88f76a4cc064f1d7268c9d7dc7)) 36 | - Initial commit ([commit](https://github.com/miguelgrinberg/aioflask/commit/b02c360cae72d2f7dd479c93e0cd7517d4dce259)) 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Miguel Grinberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aioflask 2 | 3 | ![Build status](https://github.com/miguelgrinberg/aioflask/workflows/build/badge.svg) [![codecov](https://codecov.io/gh/miguelgrinberg/aioflask/branch/main/graph/badge.svg?token=CDMKF3L0ID)](https://codecov.io/gh/miguelgrinberg/aioflask) 4 | 5 | Flask 2.x running on asyncio! 6 | 7 | Is there a purpose for this, now that Flask 2.0 is out with support for async 8 | views? Yes! Flask's own support for async handlers is very limited, as the 9 | application still runs inside a WSGI web server, which severely limits 10 | scalability. With aioflask you get a true ASGI application, running in a 100% 11 | async environment. 12 | 13 | WARNING: This is an experiment at this point. Not at all production ready! 14 | 15 | ## Quick start 16 | 17 | To use async view functions and other handlers, use the `aioflask` package 18 | instead of `flask`. 19 | 20 | The `aioflask.Flask` class is a subclass of `flask.Flask` that changes a few 21 | minor things to help the application run properly under the asyncio loop. In 22 | particular, it overrides the following aspects of the application instance: 23 | 24 | - The `route`, `before_request`, `before_first_request`, `after_request`, 25 | `teardown_request`, `teardown_appcontext`, `errorhandler` and `cli.command` 26 | decorators accept coroutines as well as regular functions. The handlers all 27 | run inside an asyncio loop, so when using regular functions, care must be 28 | taken to not block. 29 | - The WSGI callable entry point is replaced with an ASGI equivalent. 30 | - The `run()` method uses uvicorn as web server. 31 | 32 | There are also changes outside of the `Flask` class: 33 | 34 | - The `flask aiorun` command starts an ASGI application using the uvicorn web 35 | server. 36 | - The `render_template()` and `render_template_string()` functions are 37 | asynchronous and must be awaited. 38 | - The context managers for the Flask application and request contexts are 39 | async. 40 | - The test client and test CLI runner use coroutines. 41 | 42 | ## Example 43 | 44 | ```python 45 | import asyncio 46 | from aioflask import Flask, render_template 47 | 48 | app = Flask(__name__) 49 | 50 | @app.route('/') 51 | async def index(): 52 | await asyncio.sleep(1) 53 | return await render_template('index.html') 54 | ``` 55 | -------------------------------------------------------------------------------- /examples/AsyncProgressBar/README.md: -------------------------------------------------------------------------------- 1 | AsyncProgressBar 2 | ================ 3 | 4 | This is the *AsyncProgressBar* from Quart ported to Flask. You need to have a 5 | Redis server running on localhost:6379 for this example to run. 6 | -------------------------------------------------------------------------------- /examples/AsyncProgressBar/progress_bar.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | import aioredis 4 | import redis 5 | from aioflask import Flask, request, url_for, jsonify 6 | 7 | app = Flask(__name__) 8 | 9 | sr = redis.StrictRedis(host='localhost', port=6379) 10 | sr.execute_command('FLUSHDB') 11 | 12 | 13 | async def some_work(): 14 | global aredis 15 | await aredis.set('state', 'running') 16 | work_to_do = range(1, 26) 17 | await aredis.set('length_of_work', len(work_to_do)) 18 | for i in work_to_do: 19 | await aredis.set('processed', i) 20 | await asyncio.sleep(random.random()) 21 | await aredis.set('state', 'ready') 22 | await aredis.set('percent', 100) 23 | 24 | 25 | @app.route('/check_status/') 26 | async def check_status(): 27 | global aredis, sr 28 | status = dict() 29 | try: 30 | if await aredis.get('state') == b'running': 31 | if await aredis.get('processed') != await aredis.get('lastProcessed'): 32 | await aredis.set('percent', round( 33 | int(await aredis.get('processed')) / int(await aredis.get('length_of_work')) * 100, 2)) 34 | await aredis.set('lastProcessed', str(await aredis.get('processed'))) 35 | except: 36 | pass 37 | 38 | try: 39 | status['state'] = sr.get('state').decode() 40 | status['processed'] = sr.get('processed').decode() 41 | status['length_of_work'] = sr.get('length_of_work').decode() 42 | status['percent_complete'] = sr.get('percent').decode() 43 | except: 44 | status['state'] = sr.get('state') 45 | status['processed'] = sr.get('processed') 46 | status['length_of_work'] = sr.get('length_of_work') 47 | status['percent_complete'] = sr.get('percent') 48 | 49 | status['hint'] = 'refresh me.' 50 | 51 | return jsonify(status) 52 | 53 | 54 | @app.route('/progress/') 55 | async def progress(): 56 | return """ 57 | 58 | 59 | 60 | 61 | 62 | Asyncio Progress Bar Demo 63 | 64 | 65 | 66 | 67 | 101 | 102 | 103 |

Progress of work is shown below

104 |
105 |
106 | 107 | """ 108 | 109 | 110 | @app.route('/') 111 | async def index(): 112 | return 'This is the index page. Try the following to start some test work with a progress indicator.' 114 | 115 | 116 | @app.route('/start_work/') 117 | async def start_work(): 118 | global aredis 119 | loop = asyncio.get_event_loop() 120 | aredis = await aioredis.create_redis('redis://localhost', loop=loop) 121 | 122 | if await aredis.get('state') == b'running': 123 | return "
Please wait for current work to finish.
" 124 | else: 125 | await aredis.set('state', 'ready') 126 | 127 | if await aredis.get('state') == b'ready': 128 | loop.create_task(some_work()) 129 | body = ''' 130 |
131 | work started! 132 |
133 | ''' 136 | return body 137 | 138 | 139 | if __name__ == "__main__": 140 | app.run('localhost', port=5000, debug=True) 141 | -------------------------------------------------------------------------------- /examples/AsyncProgressBar/requirements.txt: -------------------------------------------------------------------------------- 1 | aioflask 2 | aioredis==1.3.1 3 | async-timeout==3.0.1 4 | click==7.1.2 5 | Flask==1.1.2 6 | greenlet==0.4.16 7 | greenletio 8 | h11==0.9.0 9 | hiredis==1.1.0 10 | httptools==0.1.1 11 | itsdangerous==1.1.0 12 | Jinja2==2.11.2 13 | MarkupSafe==1.1.1 14 | redis==3.5.3 15 | uvicorn==0.11.6 16 | uvloop==0.14.0 17 | websockets==8.1 18 | Werkzeug==1.0.1 19 | -------------------------------------------------------------------------------- /examples/aioflaskr/.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_APP=flaskr:create_app() 2 | -------------------------------------------------------------------------------- /examples/aioflaskr/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2010 Pallets (original version) 2 | Copyright 2021 Miguel Grinberg (this version) 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 22 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 25 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 26 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 27 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 28 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /examples/aioflaskr/README.md: -------------------------------------------------------------------------------- 1 | aioflaskr 2 | ========= 3 | 4 | This is the "Flaskr" application from the tutorial section of the Flask 5 | documentation, adapted to work as an asyncio application with aioflask as the 6 | web framework and Alchemical for its database. The Flask-Login extension is 7 | used to maintain the logged in state of the user. 8 | 9 | Install 10 | ------- 11 | ```bash 12 | # clone the repository 13 | $ git clone https://github.com/miguelgrinberg/aioflask 14 | $ cd aioflask/examples/aioflaskr 15 | ``` 16 | 17 | Create a virtualenv and activate it: 18 | 19 | ```bash 20 | $ python3 -m venv venv 21 | $ . venv/bin/activate 22 | ``` 23 | 24 | Or on Windows cmd: 25 | 26 | ```text 27 | $ py -3 -m venv venv 28 | $ venv\Scripts\activate.bat 29 | ``` 30 | 31 | Install the requirements 32 | 33 | ```bash 34 | $ pip install -r requirements.txt 35 | ``` 36 | 37 | Run 38 | --- 39 | 40 | ```bash 41 | flask init-db 42 | flask aiorun 43 | ``` 44 | 45 | Open http://127.0.0.1:5000 in a browser. 46 | 47 | Test 48 | ---- 49 | 50 | ```bash 51 | $ pip install pytest pytest-asyncio 52 | $ python -m pytest 53 | ``` 54 | 55 | Run with coverage report: 56 | 57 | ```bash 58 | $ pip install pytest-cov 59 | $ python -m pytest --cov=flaskr --cov-branch --cov-report=term-missing 60 | ``` 61 | -------------------------------------------------------------------------------- /examples/aioflaskr/flaskr/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | from aioflask import Flask 5 | from aioflask.cli import with_appcontext 6 | from alchemical.aioflask import Alchemical 7 | from aioflask.patched.flask_login import LoginManager 8 | 9 | db = Alchemical() 10 | login = LoginManager() 11 | login.login_view = 'auth.login' 12 | 13 | 14 | def create_app(test_config=None): 15 | """Create and configure an instance of the Flask application.""" 16 | app = Flask(__name__, instance_relative_config=True) 17 | 18 | # some deploy systems set the database url in the environ 19 | db_url = os.environ.get("DATABASE_URL") 20 | 21 | if db_url is None: 22 | # default to a sqlite database in the instance folder 23 | db_path = os.path.join(app.instance_path, "flaskr.sqlite") 24 | db_url = f"sqlite:///{db_path}" 25 | # ensure the instance folder exists 26 | os.makedirs(app.instance_path, exist_ok=True) 27 | 28 | app.config.from_mapping( 29 | SECRET_KEY=os.environ.get("SECRET_KEY", "dev"), 30 | ALCHEMICAL_DATABASE_URL=db_url, 31 | ) 32 | 33 | if test_config is None: 34 | # load the instance config, if it exists, when not testing 35 | app.config.from_pyfile("config.py", silent=True) 36 | else: 37 | # load the test config if passed in 38 | app.config.update(test_config) 39 | 40 | # initialize Flask-Alchemical and the init-db command 41 | db.init_app(app) 42 | app.cli.add_command(init_db_command) 43 | 44 | # initialize Flask-Login 45 | login.init_app(app) 46 | 47 | # apply the blueprints to the app 48 | from flaskr import auth, blog 49 | 50 | app.register_blueprint(auth.bp) 51 | app.register_blueprint(blog.bp) 52 | 53 | # make "index" point at "/", which is handled by "blog.index" 54 | app.add_url_rule("/", endpoint="index") 55 | 56 | return app 57 | 58 | 59 | async def init_db(): 60 | await db.drop_all() 61 | await db.create_all() 62 | 63 | 64 | @click.command("init-db") 65 | @with_appcontext 66 | async def init_db_command(): 67 | """Clear existing data and create new tables.""" 68 | await init_db() 69 | click.echo("Initialized the database.") 70 | -------------------------------------------------------------------------------- /examples/aioflaskr/flaskr/auth.py: -------------------------------------------------------------------------------- 1 | from aioflask import Blueprint 2 | from aioflask import flash 3 | from aioflask import redirect 4 | from aioflask import render_template 5 | from aioflask import request 6 | from aioflask import url_for 7 | from aioflask.patched.flask_login import login_user 8 | from aioflask.patched.flask_login import logout_user 9 | from sqlalchemy.exc import IntegrityError 10 | 11 | from flaskr import db, login 12 | from flaskr.models import User 13 | 14 | bp = Blueprint("auth", __name__, url_prefix="/auth") 15 | 16 | 17 | @login.user_loader 18 | async def load_user(id): 19 | return await db.session.get(User, int(id)) 20 | 21 | 22 | @bp.route("/register", methods=("GET", "POST")) 23 | async def register(): 24 | """Register a new user. 25 | Validates that the username is not already taken. Hashes the 26 | password for security. 27 | """ 28 | if request.method == "POST": 29 | username = request.form["username"] 30 | password = request.form["password"] 31 | error = None 32 | 33 | if not username: 34 | error = "Username is required." 35 | elif not password: 36 | error = "Password is required." 37 | 38 | if error is None: 39 | try: 40 | db.session.add(User(username=username, password=password)) 41 | await db.session.commit() 42 | except IntegrityError: 43 | # The username was already taken, which caused the 44 | # commit to fail. Show a validation error. 45 | error = f"User {username} is already registered." 46 | else: 47 | # Success, go to the login page. 48 | return redirect(url_for("auth.login")) 49 | 50 | flash(error) 51 | 52 | return await render_template("auth/register.html") 53 | 54 | 55 | @bp.route("/login", methods=("GET", "POST")) 56 | async def login(): 57 | """Log in a registered user by adding the user id to the session.""" 58 | if request.method == "POST": 59 | username = request.form["username"] 60 | password = request.form["password"] 61 | error = None 62 | 63 | query = User.select().filter_by(username=username) 64 | user = await db.session.scalar(query) 65 | 66 | if user is None: 67 | error = "Incorrect username." 68 | elif not user.check_password(password): 69 | error = "Incorrect password." 70 | 71 | if error is None: 72 | # store the user id in a new session and return to the index 73 | login_user(user) 74 | return redirect(url_for("index")) 75 | 76 | flash(error) 77 | 78 | return await render_template("auth/login.html") 79 | 80 | 81 | @bp.route("/logout") 82 | async def logout(): 83 | """Clear the current session, including the stored user id.""" 84 | logout_user() 85 | return redirect(url_for("index")) 86 | -------------------------------------------------------------------------------- /examples/aioflaskr/flaskr/blog.py: -------------------------------------------------------------------------------- 1 | from aioflask import Blueprint 2 | from aioflask import flash 3 | from aioflask import redirect 4 | from aioflask import render_template 5 | from aioflask import request 6 | from aioflask import url_for 7 | from werkzeug.exceptions import abort 8 | from aioflask.patched.flask_login import current_user 9 | from aioflask.patched.flask_login import login_required 10 | 11 | from flaskr import db 12 | from flaskr.models import Post 13 | 14 | bp = Blueprint("blog", __name__) 15 | 16 | 17 | @bp.route("/") 18 | async def index(): 19 | """Show all the posts, most recent first.""" 20 | posts = (await db.session.scalars(Post.select())).all() 21 | return await render_template("blog/index.html", posts=posts) 22 | 23 | 24 | async def get_post(id, check_author=True): 25 | """Get a post and its author by id. 26 | Checks that the id exists and optionally that the current user is 27 | the author. 28 | :param id: id of post to get 29 | :param check_author: require the current user to be the author 30 | :return: the post with author information 31 | :raise 404: if a post with the given id doesn't exist 32 | :raise 403: if the current user isn't the author 33 | """ 34 | post = await db.session.get(Post, id) 35 | 36 | if post is None: 37 | abort(404, f"Post id {id} doesn't exist.") 38 | 39 | if check_author and post.author != current_user: 40 | abort(403) 41 | 42 | return post 43 | 44 | 45 | @bp.route("/create", methods=("GET", "POST")) 46 | @login_required 47 | async def create(): 48 | """Create a new post for the current user.""" 49 | if request.method == "POST": 50 | title = request.form["title"] 51 | body = request.form["body"] 52 | error = None 53 | 54 | if not title: 55 | error = "Title is required." 56 | 57 | if error is not None: 58 | flash(error) 59 | else: 60 | db.session.add(Post(title=title, body=body, author=current_user)) 61 | await db.session.commit() 62 | return redirect(url_for("blog.index")) 63 | 64 | return await render_template("blog/create.html") 65 | 66 | 67 | @bp.route("//update", methods=("GET", "POST")) 68 | @login_required 69 | async def update(id): 70 | """Update a post if the current user is the author.""" 71 | post = await get_post(id) 72 | 73 | if request.method == "POST": 74 | title = request.form["title"] 75 | body = request.form["body"] 76 | error = None 77 | 78 | if not title: 79 | error = "Title is required." 80 | 81 | if error is not None: 82 | flash(error) 83 | else: 84 | post.title = title 85 | post.body = body 86 | await db.session.commit() 87 | return redirect(url_for("blog.index")) 88 | 89 | return await render_template("blog/update.html", post=post) 90 | 91 | 92 | @bp.route("//delete", methods=("POST",)) 93 | @login_required 94 | async def delete(id): 95 | """Delete a post. 96 | Ensures that the post exists and that the logged in user is the 97 | author of the post. 98 | """ 99 | post = await get_post(id) 100 | await db.session.delete(post) 101 | await db.session.commit() 102 | return redirect(url_for("blog.index")) 103 | -------------------------------------------------------------------------------- /examples/aioflaskr/flaskr/models.py: -------------------------------------------------------------------------------- 1 | from werkzeug.security import check_password_hash 2 | from werkzeug.security import generate_password_hash 3 | from aioflask import url_for 4 | from aioflask.patched.flask_login import UserMixin 5 | from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func 6 | from sqlalchemy.orm import relationship 7 | 8 | from flaskr import db 9 | 10 | 11 | class User(UserMixin, db.Model): 12 | id = Column(Integer, primary_key=True) 13 | username = Column(String, unique=True, nullable=False) 14 | password_hash = Column(String, nullable=False) 15 | 16 | @property 17 | def password(self): 18 | raise RuntimeError('Cannot get user passwords!') 19 | 20 | @password.setter 21 | def password(self, value): 22 | """Store the password as a hash for security.""" 23 | self.password_hash = generate_password_hash(value) 24 | 25 | def check_password(self, value): 26 | return check_password_hash(self.password_hash, value) 27 | 28 | 29 | class Post(db.Model): 30 | id = Column(Integer, primary_key=True) 31 | author_id = Column(ForeignKey(User.id), nullable=False) 32 | created = Column( 33 | DateTime, nullable=False, server_default=func.current_timestamp() 34 | ) 35 | title = Column(String, nullable=False) 36 | body = Column(String, nullable=False) 37 | 38 | # User object backed by author_id 39 | # lazy="joined" means the user is returned with the post in one query 40 | author = relationship(User, lazy="joined", backref="posts") 41 | 42 | @property 43 | def update_url(self): 44 | return url_for("blog.update", id=self.id) 45 | 46 | @property 47 | def delete_url(self): 48 | return url_for("blog.delete", id=self.id) 49 | -------------------------------------------------------------------------------- /examples/aioflaskr/flaskr/static/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: sans-serif; 3 | background: #eee; 4 | padding: 1rem; 5 | } 6 | 7 | body { 8 | max-width: 960px; 9 | margin: 0 auto; 10 | background: white; 11 | } 12 | 13 | h1, h2, h3, h4, h5, h6 { 14 | font-family: serif; 15 | color: #377ba8; 16 | margin: 1rem 0; 17 | } 18 | 19 | a { 20 | color: #377ba8; 21 | } 22 | 23 | hr { 24 | border: none; 25 | border-top: 1px solid lightgray; 26 | } 27 | 28 | nav { 29 | background: lightgray; 30 | display: flex; 31 | align-items: center; 32 | padding: 0 0.5rem; 33 | } 34 | 35 | nav h1 { 36 | flex: auto; 37 | margin: 0; 38 | } 39 | 40 | nav h1 a { 41 | text-decoration: none; 42 | padding: 0.25rem 0.5rem; 43 | } 44 | 45 | nav ul { 46 | display: flex; 47 | list-style: none; 48 | margin: 0; 49 | padding: 0; 50 | } 51 | 52 | nav ul li a, nav ul li span, header .action { 53 | display: block; 54 | padding: 0.5rem; 55 | } 56 | 57 | .content { 58 | padding: 0 1rem 1rem; 59 | } 60 | 61 | .content > header { 62 | border-bottom: 1px solid lightgray; 63 | display: flex; 64 | align-items: flex-end; 65 | } 66 | 67 | .content > header h1 { 68 | flex: auto; 69 | margin: 1rem 0 0.25rem 0; 70 | } 71 | 72 | .flash { 73 | margin: 1em 0; 74 | padding: 1em; 75 | background: #cae6f6; 76 | border: 1px solid #377ba8; 77 | } 78 | 79 | .post > header { 80 | display: flex; 81 | align-items: flex-end; 82 | font-size: 0.85em; 83 | } 84 | 85 | .post > header > div:first-of-type { 86 | flex: auto; 87 | } 88 | 89 | .post > header h1 { 90 | font-size: 1.5em; 91 | margin-bottom: 0; 92 | } 93 | 94 | .post .about { 95 | color: slategray; 96 | font-style: italic; 97 | } 98 | 99 | .post .body { 100 | white-space: pre-line; 101 | } 102 | 103 | .content:last-child { 104 | margin-bottom: 0; 105 | } 106 | 107 | .content form { 108 | margin: 1em 0; 109 | display: flex; 110 | flex-direction: column; 111 | } 112 | 113 | .content label { 114 | font-weight: bold; 115 | margin-bottom: 0.5em; 116 | } 117 | 118 | .content input, .content textarea { 119 | margin-bottom: 1em; 120 | } 121 | 122 | .content textarea { 123 | min-height: 12em; 124 | resize: vertical; 125 | } 126 | 127 | input.danger { 128 | color: #cc2f2e; 129 | } 130 | 131 | input[type=submit] { 132 | align-self: start; 133 | min-width: 10em; 134 | } 135 | -------------------------------------------------------------------------------- /examples/aioflaskr/flaskr/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}Log In{% endblock %}

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /examples/aioflaskr/flaskr/templates/auth/register.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}Register{% endblock %}

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /examples/aioflaskr/flaskr/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% block title %}{% endblock %} - Flaskr 3 | 4 | 16 |
17 |
18 | {% block header %}{% endblock %} 19 |
20 | {% for message in get_flashed_messages() %} 21 |
{{ message }}
22 | {% endfor %} 23 | {% block content %}{% endblock %} 24 |
25 | -------------------------------------------------------------------------------- /examples/aioflaskr/flaskr/templates/blog/create.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}New Post{% endblock %}

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /examples/aioflaskr/flaskr/templates/blog/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}Posts{% endblock %}

5 | {% if current_user.is_authenticated %} 6 | New 7 | {% endif %} 8 | {% endblock %} 9 | 10 | {% block content %} 11 | {% for post in posts %} 12 |
13 |
14 |
15 |

{{ post.title }}

16 |
by {{ post.author.username }} on {{ post.created.strftime('%Y-%m-%d') }}
17 |
18 | {% if current_user == post.author %} 19 | Edit 20 | {% endif %} 21 |
22 |

{{ post.body }}

23 |
24 | {% if not loop.last %} 25 |
26 | {% endif %} 27 | {% endfor %} 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /examples/aioflaskr/flaskr/templates/blog/update.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}Edit "{{ post['title'] }}"{% endblock %}

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 | 18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /examples/aioflaskr/requirements.txt: -------------------------------------------------------------------------------- 1 | aioflask 2 | aiosqlite==0.17.0 3 | alchemical 4 | asgiref==3.4.1 5 | click==8.0.1 6 | Flask==2.0.1 7 | Flask-Login==0.5.0 8 | greenlet==1.1.1 9 | greenletio 10 | h11==0.12.0 11 | importlib-metadata==4.6.3 12 | itsdangerous==2.0.1 13 | Jinja2==3.0.1 14 | MarkupSafe==2.0.1 15 | python-dotenv==0.19.0 16 | SQLAlchemy==1.4.25 17 | typing-extensions==3.10.0.0 18 | uvicorn==0.14.0 19 | Werkzeug==2.0.1 20 | zipp==3.5.0 21 | -------------------------------------------------------------------------------- /examples/aioflaskr/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miguelgrinberg/aioflask/7f447e79c81ce7ca46edc5f5852845ff9806ce6a/examples/aioflaskr/tests/__init__.py -------------------------------------------------------------------------------- /examples/aioflaskr/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from flaskr import create_app 6 | from flaskr import db 7 | from flaskr import init_db 8 | from flaskr.models import User 9 | from flaskr.models import Post 10 | 11 | 12 | @pytest.fixture 13 | async def app(): 14 | """Create and configure a new app instance for each test.""" 15 | # create the app with common test config 16 | app = create_app({"TESTING": True, 17 | "ALCHEMICAL_DATABASE_URL": "sqlite:///:memory:"}) 18 | 19 | # create the database and load test data 20 | async with app.app_context(): 21 | await init_db() 22 | user = User(username="test", password="test") 23 | db.session.add_all( 24 | ( 25 | user, 26 | User(username="other", password="other"), 27 | Post( 28 | title="test title", 29 | body="test\nbody", 30 | author=user, 31 | created=datetime(2018, 1, 1), 32 | ), 33 | ) 34 | ) 35 | await db.session.commit() 36 | 37 | yield app 38 | 39 | 40 | @pytest.fixture 41 | def client(app): 42 | """A test client for the app.""" 43 | return app.test_client() 44 | 45 | 46 | @pytest.fixture 47 | def runner(app): 48 | """A test runner for the app's Click commands.""" 49 | return app.test_cli_runner() 50 | 51 | 52 | class AuthActions: 53 | def __init__(self, client): 54 | self._client = client 55 | 56 | async def login(self, username="test", password="test"): 57 | return await self._client.post( 58 | "/auth/login", data={"username": username, "password": password} 59 | ) 60 | 61 | async def logout(self): 62 | return await self._client.get("/auth/logout") 63 | 64 | 65 | @pytest.fixture 66 | def auth(client): 67 | return AuthActions(client) 68 | -------------------------------------------------------------------------------- /examples/aioflaskr/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flaskr import db 4 | from flaskr.models import User 5 | 6 | 7 | @pytest.mark.asyncio 8 | async def test_register(client, app): 9 | # test that viewing the page renders without template errors 10 | assert (await client.get("/auth/register")).status_code == 200 11 | 12 | # test that successful registration redirects to the login page 13 | response = await client.post("/auth/register", 14 | data={"username": "a", "password": "a"}) 15 | assert "/auth/login" == response.headers["Location"] 16 | 17 | # test that the user was inserted into the database 18 | async with app.app_context(): 19 | query = User.select().filter_by(username="a") 20 | assert await db.session.scalar(query) is not None 21 | 22 | 23 | def test_user_password(app): 24 | user = User(username="a", password="a") 25 | assert user.password_hash != "a" 26 | assert user.check_password("a") 27 | 28 | 29 | @pytest.mark.asyncio 30 | @pytest.mark.parametrize( 31 | ("username", "password", "message"), 32 | ( 33 | ("", "", b"Username is required."), 34 | ("a", "", b"Password is required."), 35 | ("test", "test", b"already registered"), 36 | ), 37 | ) 38 | async def test_register_validate_input(client, username, password, message): 39 | response = await client.post( 40 | "/auth/register", data={"username": username, "password": password} 41 | ) 42 | assert message in response.data 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_login(client, auth): 47 | # test that viewing the page renders without template errors 48 | assert (await client.get("/auth/login")).status_code == 200 49 | 50 | # test that successful login redirects to the index page 51 | response = await auth.login() 52 | assert response.headers["Location"] == "/" 53 | 54 | # login request set the user_id in the session 55 | # check that the user is loaded from the session 56 | async with client: 57 | response = await client.get("/") 58 | assert b"test" in response.data 59 | 60 | 61 | @pytest.mark.asyncio 62 | @pytest.mark.parametrize( 63 | ("username", "password", "message"), 64 | (("a", "test", b"Incorrect username."), 65 | ("test", "a", b"Incorrect password.")), 66 | ) 67 | async def test_login_validate_input(auth, username, password, message): 68 | response = await auth.login(username, password) 69 | assert message in response.data 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_logout(client, auth): 74 | await auth.login() 75 | 76 | async with client: 77 | await auth.logout() 78 | response = await client.get("/") 79 | assert b"Log In" in response.data 80 | -------------------------------------------------------------------------------- /examples/aioflaskr/tests/test_blog.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import func 3 | from sqlalchemy import select 4 | 5 | from flaskr import db 6 | from flaskr.models import User 7 | from flaskr.models import Post 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_index(client, auth): 12 | response = await client.get("/") 13 | assert b"Log In" in response.data 14 | assert b"Register" in response.data 15 | 16 | await auth.login() 17 | response = await client.get("/") 18 | assert b"test title" in response.data 19 | assert b"by test on 2018-01-01" in response.data 20 | assert b"test\nbody" in response.data 21 | assert b'href="/1/update"' in response.data 22 | 23 | 24 | @pytest.mark.asyncio 25 | @pytest.mark.parametrize("path", ("/create", "/1/update", "/1/delete")) 26 | async def test_login_required(client, path): 27 | response = await client.post(path) 28 | assert response.headers["Location"].startswith("/auth/login?next=") 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_author_required(app, client, auth): 33 | # change the post author to another user 34 | async with app.app_context(): 35 | (await db.session.get(Post, 1)).author = await db.session.get(User, 2) 36 | await db.session.commit() 37 | 38 | await auth.login() 39 | # current user can't modify other user's post 40 | assert (await client.post("/1/update")).status_code == 403 41 | assert (await client.post("/1/delete")).status_code == 403 42 | # current user doesn't see edit link 43 | assert b'href="/1/update"' not in (await client.get("/")).data 44 | 45 | 46 | @pytest.mark.asyncio 47 | @pytest.mark.parametrize("path", ("/2/update", "/2/delete")) 48 | async def test_exists_required(client, auth, path): 49 | await auth.login() 50 | assert (await client.post(path)).status_code == 404 51 | 52 | 53 | @pytest.mark.asyncio 54 | async def test_create(client, auth, app): 55 | await auth.login() 56 | assert (await client.get("/create")).status_code == 200 57 | await client.post("/create", data={"title": "created", "body": ""}) 58 | 59 | async with app.app_context(): 60 | query = select(func.count()).select_from(Post) 61 | assert await db.session.scalar(query) == 2 62 | 63 | 64 | @pytest.mark.asyncio 65 | async def test_update(client, auth, app): 66 | await auth.login() 67 | assert (await client.get("/1/update")).status_code == 200 68 | await client.post("/1/update", data={"title": "updated", "body": ""}) 69 | 70 | async with app.app_context(): 71 | assert (await db.session.get(Post, 1)).title == "updated" 72 | 73 | 74 | @pytest.mark.asyncio 75 | @pytest.mark.parametrize("path", ("/create", "/1/update")) 76 | async def test_create_update_validate(client, auth, path): 77 | await auth.login() 78 | response = await client.post(path, data={"title": "", "body": ""}) 79 | assert b"Title is required." in response.data 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_delete(client, auth, app): 84 | await auth.login() 85 | response = await client.post("/1/delete") 86 | assert response.headers["Location"] == "/" 87 | 88 | async with app.app_context(): 89 | assert (await db.session.get(Post, 1)) is None 90 | -------------------------------------------------------------------------------- /examples/aioflaskr/tests/test_init.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flaskr import create_app 3 | 4 | 5 | def test_config(): 6 | """Test create_app without passing test config.""" 7 | assert not create_app().testing 8 | assert create_app({"TESTING": True}).testing 9 | 10 | 11 | def test_db_url_environ(monkeypatch): 12 | """Test DATABASE_URL environment variable.""" 13 | monkeypatch.setenv("DATABASE_URL", "sqlite:///environ") 14 | app = create_app() 15 | assert app.config["ALCHEMICAL_DATABASE_URL"] == "sqlite:///environ" 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_init_db_command(runner, monkeypatch): 20 | class Recorder: 21 | called = False 22 | 23 | async def fake_init_db(): 24 | Recorder.called = True 25 | 26 | monkeypatch.setattr("flaskr.init_db", fake_init_db) 27 | result = await runner.invoke(args=["init-db"]) 28 | assert "Initialized" in result.output 29 | assert Recorder.called 30 | -------------------------------------------------------------------------------- /examples/g/app.py: -------------------------------------------------------------------------------- 1 | from aioflask import Flask, g 2 | import aiohttp 3 | 4 | app = Flask(__name__) 5 | 6 | 7 | @app.before_request 8 | async def before_request(): 9 | g.session = aiohttp.ClientSession() 10 | 11 | 12 | @app.teardown_appcontext 13 | async def teardown_appcontext(exc): 14 | await g.session.close() 15 | 16 | 17 | @app.route('/') 18 | async def index(): 19 | response = await g.session.get('https://api.quotable.io/random') 20 | return (await response.json())['content'] 21 | -------------------------------------------------------------------------------- /examples/hello_world/app.py: -------------------------------------------------------------------------------- 1 | from aioflask import Flask, render_template 2 | 3 | app = Flask(__name__) 4 | 5 | 6 | @app.route('/') 7 | async def index(): 8 | return await render_template('index.html') 9 | 10 | 11 | @app.cli.command() 12 | async def hello(): 13 | """Example async CLI handler.""" 14 | print('hello!') 15 | -------------------------------------------------------------------------------- /examples/hello_world/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hello (async) world! 5 | 6 | 7 |

Hello (async) world!

8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/login/app.py: -------------------------------------------------------------------------------- 1 | from aioflask import Flask, request, redirect 2 | from aioflask.patched.flask_login import LoginManager, login_required, UserMixin, login_user, logout_user, current_user 3 | import aiohttp 4 | 5 | app = Flask(__name__) 6 | app.secret_key = 'top-secret!' 7 | login = LoginManager(app) 8 | login.login_view = 'login' 9 | 10 | 11 | class User(UserMixin): 12 | def __init__(self, user_id): 13 | self.id = user_id 14 | 15 | 16 | @login.user_loader 17 | async def load_user(user_id): 18 | return User(user_id) 19 | 20 | 21 | @app.route('/') 22 | @login_required 23 | async def index(): 24 | return f''' 25 | 26 | 27 |

Logged in user: {current_user.id}

28 |
29 | 30 |
31 | 32 | ''' 33 | 34 | 35 | @app.route('/login', methods=['GET', 'POST']) 36 | def login(): 37 | if request.method == 'GET': 38 | return ''' 39 | 40 | 41 |
42 | 43 | 44 |
45 | 46 | ''' 47 | else: 48 | login_user(User(request.form['username'])) 49 | return redirect(request.args.get('next', '/')) 50 | 51 | 52 | @app.route('/logout', methods=['POST']) 53 | def logout(): 54 | logout_user() 55 | return redirect('/') 56 | -------------------------------------------------------------------------------- /examples/quotes-aiohttp/README.md: -------------------------------------------------------------------------------- 1 | Quotes 2 | ====== 3 | 4 | Returns 10 famous quotes each time the page is refreshed. Quotes are obtained 5 | by sending concurrent HTTP requests to a Quotes API with the aiohttp 6 | asynchronous client. 7 | 8 | To run this example, set `FLASK_APP=quotes.py` in your environment and then use 9 | the standard `flask aiorun` command to start the server. 10 | -------------------------------------------------------------------------------- /examples/quotes-aiohttp/quotes.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import aiohttp 3 | from aioflask import Flask, render_template_string 4 | 5 | app = Flask(__name__) 6 | template = ''' 7 | 8 | 9 | Quotes 10 | 11 | 12 |

Quotes

13 | {% for quote in quotes %} 14 |

"{{ quote.content }}" — {{ quote.author }}

15 | {% endfor %} 16 | 17 | ''' 18 | 19 | 20 | async def get_quote(session): 21 | response = await session.get('https://api.quotable.io/random') 22 | return await response.json() 23 | 24 | 25 | @app.route('/') 26 | async def index(): 27 | async with aiohttp.ClientSession() as session: 28 | tasks = [get_quote(session) for _ in range(10)] 29 | quotes = await asyncio.gather(*tasks) 30 | return await render_template_string(template, quotes=quotes) 31 | -------------------------------------------------------------------------------- /examples/quotes-requests/README.md: -------------------------------------------------------------------------------- 1 | Quotes 2 | ====== 3 | 4 | Returns 10 famous quotes each time the page is refreshed. Quotes are obtained 5 | by sending concurrent HTTP requests to a Quotes API with the requests client. 6 | 7 | This example shows how you can incorporate blocking code into your aioflask 8 | application without blocking the asyncio loop. 9 | 10 | To run this example, set `FLASK_APP=quotes.py` in your environment and then use 11 | the standard `flask aiorun` command to start the server. 12 | -------------------------------------------------------------------------------- /examples/quotes-requests/quotes.py: -------------------------------------------------------------------------------- 1 | import greenletio 2 | 3 | # import the application with blocking functions monkey patched 4 | with greenletio.patch_blocking(): 5 | from quotes_app import app 6 | -------------------------------------------------------------------------------- /examples/quotes-requests/quotes_app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from aioflask import Flask, render_template_string 3 | from greenletio import async_ 4 | import requests 5 | 6 | app = Flask(__name__) 7 | template = ''' 8 | 9 | 10 | Quotes 11 | 12 | 13 |

Quotes

14 | {% for quote in quotes %} 15 |

"{{ quote.content }}" — {{ quote.author }}

16 | {% endfor %} 17 | 18 | ''' 19 | 20 | 21 | # this is a blocking function that is converted to asynchronous with 22 | # greenletio's @async_ decorator. For this to work, all the low-level I/O 23 | # functions started from this function must be asynchronous, which can be 24 | # achieved with greenletio's monkey patching feature. 25 | @async_ 26 | def get_quote(): 27 | response = requests.get('https://api.quotable.io/random') 28 | return response.json() 29 | 30 | 31 | @app.route('/') 32 | async def index(): 33 | tasks = [get_quote() for _ in range(10)] 34 | quotes = await asyncio.gather(*tasks) 35 | return await render_template_string(template, quotes=quotes) 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = aioflask 3 | version = 0.4.1.dev0 4 | author = Miguel Grinberg 5 | author_email = miguel.grinberg@gmail.com 6 | description = Flask running on asyncio. 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/miguelgrinberg/aioflask 10 | project_urls = 11 | Bug Tracker = https://github.com/miguelgrinberg/aioflask/issues 12 | classifiers = 13 | Intended Audience :: Developers 14 | Programming Language :: Python :: 3 15 | License :: OSI Approved :: MIT License 16 | Operating System :: OS Independent 17 | 18 | [options] 19 | zip_safe = False 20 | include_package_data = True 21 | package_dir = 22 | = src 23 | packages = find: 24 | python_requires = >=3.6 25 | install_requires = 26 | greenletio 27 | flask >= 2 28 | uvicorn 29 | 30 | [options.packages.find] 31 | where = src 32 | 33 | [options.entry_points] 34 | flask.commands = 35 | aiorun = aioflask.cli:run_command 36 | 37 | [options.extras_require] 38 | docs = 39 | sphinx 40 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup() 4 | -------------------------------------------------------------------------------- /src/aioflask/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import * 2 | from .app import Flask 3 | from .templating import render_template, render_template_string 4 | from .testing import FlaskClient 5 | -------------------------------------------------------------------------------- /src/aioflask/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from functools import wraps 3 | from inspect import iscoroutinefunction 4 | import os 5 | from flask.app import * 6 | from flask.app import Flask as OriginalFlask 7 | from flask import cli 8 | from flask.globals import _app_ctx_stack, _request_ctx_stack 9 | from flask.helpers import get_debug_flag, get_env, get_load_dotenv 10 | from greenletio import await_ 11 | import uvicorn 12 | from .asgi import WsgiToAsgiInstance 13 | from .cli import show_server_banner, AppGroup 14 | from .ctx import AppContext, RequestContext 15 | from .testing import FlaskClient, FlaskCliRunner 16 | 17 | 18 | class Flask(OriginalFlask): 19 | def __init__(self, *args, **kwargs): 20 | super().__init__(*args, **kwargs) 21 | self.cli = AppGroup() 22 | self.jinja_options['enable_async'] = True 23 | self.test_client_class = FlaskClient 24 | self.test_cli_runner_class = FlaskCliRunner 25 | self.async_fixed = False 26 | 27 | def ensure_sync(self, func): 28 | if not iscoroutinefunction(func): 29 | return func 30 | 31 | def wrapped(*args, **kwargs): 32 | appctx = _app_ctx_stack.top 33 | reqctx = _request_ctx_stack.top 34 | 35 | async def _coro(): 36 | # app context is push internally to avoid changing reference 37 | # counts and emitting duplicate signals 38 | _app_ctx_stack.push(appctx) 39 | if reqctx: 40 | _request_ctx_stack.push(reqctx) 41 | ret = await func(*args, **kwargs) 42 | if reqctx: 43 | _request_ctx_stack.pop() 44 | _app_ctx_stack.pop() 45 | return ret 46 | 47 | return await_(_coro()) 48 | 49 | return wrapped 50 | 51 | def app_context(self): 52 | return AppContext(self) 53 | 54 | def request_context(self, environ): 55 | return RequestContext(self, environ) 56 | 57 | def _fix_async(self): # pragma: no cover 58 | self.async_fixed = True 59 | 60 | if os.environ.get('AIOFLASK_USE_DEBUGGER') == 'true': 61 | os.environ['WERKZEUG_RUN_MAIN'] = 'true' 62 | from werkzeug.debug import DebuggedApplication 63 | self.wsgi_app = DebuggedApplication(self.wsgi_app, evalex=True) 64 | 65 | async def asgi_app(self, scope, receive, send): # pragma: no cover 66 | if not self.async_fixed: 67 | self._fix_async() 68 | return await WsgiToAsgiInstance(self.wsgi_app)(scope, receive, send) 69 | 70 | async def __call__(self, scope, receive, send=None): # pragma: no cover 71 | if send is None: 72 | # we were called with two arguments, so this is likely a WSGI app 73 | raise RuntimeError('The WSGI interface is not supported by ' 74 | 'aioflask, use an ASGI web server instead.') 75 | return await self.asgi_app(scope, receive, send) 76 | 77 | def run(self, host=None, port=None, debug=None, load_dotenv=True, 78 | **options): 79 | 80 | if get_load_dotenv(load_dotenv): 81 | cli.load_dotenv() 82 | 83 | # if set, let env vars override previous values 84 | if "FLASK_ENV" in os.environ: 85 | self.env = get_env() 86 | self.debug = get_debug_flag() 87 | elif "FLASK_DEBUG" in os.environ: 88 | self.debug = get_debug_flag() 89 | 90 | # debug passed to method overrides all other sources 91 | if debug is not None: 92 | self.debug = bool(debug) 93 | 94 | server_name = self.config.get("SERVER_NAME") 95 | sn_host = sn_port = None 96 | 97 | if server_name: 98 | sn_host, _, sn_port = server_name.partition(":") 99 | 100 | if not host: 101 | if sn_host: 102 | host = sn_host 103 | else: 104 | host = "127.0.0.1" 105 | 106 | if port or port == 0: 107 | port = int(port) 108 | elif sn_port: 109 | port = int(sn_port) 110 | else: 111 | port = 5000 112 | 113 | options.setdefault("use_reloader", self.debug) 114 | options.setdefault("use_debugger", self.debug) 115 | options.setdefault("threaded", True) 116 | options.setdefault("workers", 1) 117 | 118 | certfile = None 119 | keyfile = None 120 | cert = options.get('ssl_context') 121 | if cert is not None and len(cert) == 2: 122 | certfile = cert[0] 123 | keyfile = cert[1] 124 | elif cert == 'adhoc': 125 | raise RuntimeError( 126 | 'Aad-hoc certificates are not supported by aioflask.') 127 | 128 | if debug: 129 | os.environ['FLASK_DEBUG'] = 'true' 130 | 131 | if options['use_debugger']: 132 | os.environ['AIOFLASK_USE_DEBUGGER'] = 'true' 133 | 134 | show_server_banner(self.env, self.debug, self.name, False) 135 | 136 | uvicorn.run( 137 | self.import_name + ':app', 138 | host=host, 139 | port=port, 140 | reload=options['use_reloader'], 141 | workers=options['workers'], 142 | log_level='debug' if self.debug else 'info', 143 | ssl_certfile=certfile, 144 | ssl_keyfile=keyfile, 145 | ) 146 | -------------------------------------------------------------------------------- /src/aioflask/asgi.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from tempfile import SpooledTemporaryFile 3 | from greenletio import async_, await_ 4 | 5 | 6 | class wsgi_to_asgi: # pragma: no cover 7 | """Wraps a WSGI application to make it into an ASGI application.""" 8 | 9 | def __init__(self, wsgi_application): 10 | self.wsgi_application = wsgi_application 11 | 12 | async def __call__(self, scope, receive, send): 13 | """ASGI application instantiation point. 14 | 15 | We return a new WsgiToAsgiInstance here with the WSGI app 16 | and the scope, ready to respond when it is __call__ed. 17 | """ 18 | await WsgiToAsgiInstance(self.wsgi_application)(scope, receive, send) 19 | 20 | 21 | class WsgiToAsgiInstance: # pragma: no cover 22 | """Per-socket instance of a wrapped WSGI application""" 23 | 24 | def __init__(self, wsgi_application): 25 | self.wsgi_application = wsgi_application 26 | self.response_started = False 27 | 28 | async def __call__(self, scope, receive, send): 29 | if scope["type"] != "http": 30 | raise ValueError("WSGI wrapper received a non-HTTP scope") 31 | self.scope = scope 32 | with SpooledTemporaryFile(max_size=65536) as body: 33 | # Alright, wait for the http.request messages 34 | while True: 35 | message = await receive() 36 | if message["type"] != "http.request": 37 | raise ValueError( 38 | "WSGI wrapper received a non-HTTP-request message") 39 | body.write(message.get("body", b"")) 40 | if not message.get("more_body"): 41 | break 42 | body.seek(0) 43 | 44 | # Wrap send so it can be called from the subthread 45 | self.sync_send = await_(send) 46 | # Call the WSGI app 47 | await self.run_wsgi_app(body) 48 | 49 | def build_environ(self, scope, body): 50 | """Builds a scope and request body into a WSGI environ object.""" 51 | environ = { 52 | "REQUEST_METHOD": scope["method"], 53 | "SCRIPT_NAME": scope.get("root_path", ""), 54 | "PATH_INFO": scope["path"], 55 | "QUERY_STRING": scope["query_string"].decode("ascii"), 56 | "SERVER_PROTOCOL": "HTTP/%s" % scope["http_version"], 57 | "wsgi.version": (1, 0), 58 | "wsgi.url_scheme": scope.get("scheme", "http"), 59 | "wsgi.input": body, 60 | "wsgi.errors": sys.stderr, 61 | "wsgi.multithread": True, 62 | "wsgi.multiprocess": True, 63 | "wsgi.run_once": False, 64 | } 65 | # Get server name and port - required in WSGI, not in ASGI 66 | if "server" in scope: 67 | environ["SERVER_NAME"] = scope["server"][0] 68 | environ["SERVER_PORT"] = str(scope["server"][1]) 69 | else: 70 | environ["SERVER_NAME"] = "localhost" 71 | environ["SERVER_PORT"] = "80" 72 | 73 | if "client" in scope: 74 | environ["REMOTE_ADDR"] = scope["client"][0] 75 | 76 | # Go through headers and make them into environ entries 77 | for name, value in self.scope.get("headers", []): 78 | name = name.decode("latin1") 79 | if name == "content-length": 80 | corrected_name = "CONTENT_LENGTH" 81 | elif name == "content-type": 82 | corrected_name = "CONTENT_TYPE" 83 | else: 84 | corrected_name = "HTTP_%s" % name.upper().replace("-", "_") 85 | # HTTPbis say only ASCII chars are allowed in headers, but we 86 | # latin1 just in case 87 | value = value.decode("latin1") 88 | if corrected_name in environ: 89 | value = environ[corrected_name] + "," + value 90 | environ[corrected_name] = value 91 | return environ 92 | 93 | def start_response(self, status, response_headers, exc_info=None): 94 | """WSGI start_response callable.""" 95 | 96 | # Don't allow re-calling once response has begun 97 | if self.response_started: 98 | raise exc_info[1].with_traceback(exc_info[2]) 99 | # Don't allow re-calling without exc_info 100 | if hasattr(self, "response_start") and exc_info is None: 101 | raise ValueError( 102 | "You cannot call start_response a second time without exc_info" 103 | ) 104 | # Extract status code 105 | status_code, _ = status.split(" ", 1) 106 | status_code = int(status_code) 107 | # Extract headers 108 | headers = [ 109 | (name.lower().encode("ascii"), value.encode("ascii")) 110 | for name, value in response_headers 111 | ] 112 | # Build and send response start message. 113 | self.response_start = { 114 | "type": "http.response.start", 115 | "status": status_code, 116 | "headers": headers, 117 | } 118 | 119 | @async_ 120 | def run_wsgi_app(self, body): 121 | """WSGI app greenlet.""" 122 | # Translate the scope and incoming request body into a WSGI environ 123 | environ = self.build_environ(self.scope, body) 124 | # Run the WSGI app 125 | for output in self.wsgi_application(environ, self.start_response): 126 | # If this is the first response, include the response headers 127 | if not self.response_started: 128 | self.response_started = True 129 | self.sync_send(self.response_start) 130 | self.sync_send({"type": "http.response.body", "body": output, 131 | "more_body": True}) 132 | # Close connection 133 | if not self.response_started: 134 | self.response_started = True 135 | self.sync_send(self.response_start) 136 | self.sync_send({"type": "http.response.body"}) 137 | -------------------------------------------------------------------------------- /src/aioflask/cli.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from inspect import iscoroutinefunction 3 | import os 4 | import sys 5 | 6 | from flask.cli import * 7 | from flask.cli import AppGroup, ScriptInfo, update_wrapper, \ 8 | SeparatedPathType, pass_script_info, get_debug_flag, NoAppException, \ 9 | prepare_import 10 | from flask.cli import _validate_key 11 | from flask.globals import _app_ctx_stack 12 | from flask.helpers import get_env 13 | from greenletio import await_ 14 | from werkzeug.utils import import_string 15 | import click 16 | import uvicorn 17 | 18 | try: 19 | import ssl 20 | except ImportError: # pragma: no cover 21 | ssl = None 22 | 23 | OriginalAppGroup = AppGroup 24 | 25 | 26 | def _ensure_sync(func, with_appcontext=False): 27 | if not iscoroutinefunction(func): 28 | return func 29 | 30 | def decorated(*args, **kwargs): 31 | if with_appcontext: 32 | appctx = _app_ctx_stack.top 33 | 34 | @await_ 35 | async def _coro(): 36 | with appctx: 37 | return await func(*args, **kwargs) 38 | else: 39 | @await_ 40 | async def _coro(): 41 | return await func(*args, **kwargs) 42 | 43 | return _coro() 44 | 45 | return decorated 46 | 47 | 48 | def with_appcontext(f): 49 | """Wraps a callback so that it's guaranteed to be executed with the 50 | script's application context. If callbacks are registered directly 51 | to the ``app.cli`` object then they are wrapped with this function 52 | by default unless it's disabled. 53 | """ 54 | 55 | @click.pass_context 56 | def decorator(__ctx, *args, **kwargs): 57 | with __ctx.ensure_object(ScriptInfo).load_app().app_context(): 58 | return __ctx.invoke(_ensure_sync(f, True), *args, **kwargs) 59 | 60 | return update_wrapper(decorator, f) 61 | 62 | 63 | class AppGroup(OriginalAppGroup): 64 | """This works similar to a regular click :class:`~click.Group` but it 65 | changes the behavior of the :meth:`command` decorator so that it 66 | automatically wraps the functions in :func:`with_appcontext`. 67 | Not to be confused with :class:`FlaskGroup`. 68 | """ 69 | 70 | def command(self, *args, **kwargs): 71 | """This works exactly like the method of the same name on a regular 72 | :class:`click.Group` but it wraps callbacks in :func:`with_appcontext` 73 | unless it's disabled by passing ``with_appcontext=False``. 74 | """ 75 | wrap_for_ctx = kwargs.pop("with_appcontext", True) 76 | 77 | def decorator(f): 78 | if wrap_for_ctx: 79 | f = with_appcontext(f) 80 | return click.Group.command(self, *args, **kwargs)(_ensure_sync(f)) 81 | 82 | return decorator 83 | 84 | 85 | def show_server_banner(env, debug, app_import_path, eager_loading): 86 | """Show extra startup messages the first time the server is run, 87 | ignoring the reloader. 88 | """ 89 | if app_import_path is not None: 90 | message = f" * Serving Flask app {app_import_path!r}" 91 | 92 | click.echo(message) 93 | 94 | click.echo(f" * Environment: {env}") 95 | 96 | if debug is not None: 97 | click.echo(f" * Debug mode: {'on' if debug else 'off'}") 98 | 99 | 100 | class CertParamType(click.ParamType): 101 | """Click option type for the ``--cert`` option. Allows either an 102 | existing file, the string ``'adhoc'``, or an import for a 103 | :class:`~ssl.SSLContext` object. 104 | """ 105 | 106 | name = "path" 107 | 108 | def __init__(self): 109 | self.path_type = click.Path(exists=True, dir_okay=False, 110 | resolve_path=True) 111 | 112 | def convert(self, value, param, ctx): 113 | if ssl is None: 114 | raise click.BadParameter('Using "--cert" requires Python to be ' 115 | 'compiled with SSL support.', 116 | ctx, param) 117 | 118 | try: 119 | return self.path_type(value, param, ctx) 120 | except click.BadParameter: 121 | value = click.STRING(value, param, ctx).lower() 122 | 123 | if value == "adhoc": 124 | raise click.BadParameter("Aad-hoc certificates are currently " 125 | "not supported by aioflask.", 126 | ctx, param) 127 | 128 | return value 129 | 130 | obj = import_string(value, silent=True) 131 | 132 | if isinstance(obj, ssl.SSLContext): 133 | return obj 134 | 135 | raise 136 | 137 | 138 | @click.command("run", short_help="Run a development server.") 139 | @click.option("--host", "-h", default="127.0.0.1", 140 | help="The interface to bind to.") 141 | @click.option("--port", "-p", default=5000, help="The port to bind to.") 142 | @click.option( 143 | "--cert", type=CertParamType(), 144 | help="Specify a certificate file to use HTTPS." 145 | ) 146 | @click.option( 147 | "--key", 148 | type=click.Path(exists=True, dir_okay=False, resolve_path=True), 149 | callback=_validate_key, 150 | expose_value=False, 151 | help="The key file to use when specifying a certificate.", 152 | ) 153 | @click.option( 154 | "--reload/--no-reload", 155 | default=None, 156 | help="Enable or disable the reloader. By default the reloader " 157 | "is active if debug is enabled.", 158 | ) 159 | @click.option( 160 | "--debugger/--no-debugger", 161 | default=None, 162 | help="Enable or disable the debugger. By default the debugger " 163 | "is active if debug is enabled.", 164 | ) 165 | @click.option( 166 | "--eager-loading/--lazy-loading", 167 | default=None, 168 | help="Enable or disable eager loading. By default eager " 169 | "loading is enabled if the reloader is disabled.", 170 | ) 171 | @click.option( 172 | "--with-threads/--without-threads", 173 | default=True, 174 | help="Enable or disable multithreading.", 175 | ) 176 | @click.option( 177 | "--extra-files", 178 | default=None, 179 | type=SeparatedPathType(), 180 | help=( 181 | "Extra files that trigger a reload on change. Multiple paths" 182 | f" are separated by {os.path.pathsep!r}." 183 | ), 184 | ) 185 | @pass_script_info 186 | def run_command(info, host, port, reload, debugger, eager_loading, 187 | with_threads, cert, extra_files): 188 | """Run a local development server. 189 | This server is for development purposes only. It does not provide 190 | the stability, security, or performance of production WSGI servers. 191 | The reloader and debugger are enabled by default if 192 | FLASK_ENV=development or FLASK_DEBUG=1. 193 | """ 194 | debug = get_debug_flag() 195 | 196 | if reload is None: 197 | reload = debug 198 | 199 | if debugger is None: 200 | debugger = debug 201 | if debugger: 202 | os.environ['AIOFLASK_USE_DEBUGGER'] = 'true' 203 | 204 | certfile = None 205 | keyfile = None 206 | if cert is not None and len(cert) == 2: 207 | certfile = cert[0] 208 | keyfile = cert[1] 209 | 210 | show_server_banner(get_env(), debug, info.app_import_path, eager_loading) 211 | 212 | app_import_path = info.app_import_path 213 | if app_import_path is None: 214 | for path in ('wsgi', 'app'): 215 | if os.path.exists(path) or os.path.exists(path + '.py'): 216 | app_import_path = path 217 | break 218 | if app_import_path is None: 219 | raise NoAppException( 220 | "Could not locate a Flask application. You did not provide " 221 | 'the "FLASK_APP" environment variable, and a "wsgi.py" or ' 222 | '"app.py" module was not found in the current directory.' 223 | ) 224 | if app_import_path.endswith('.py'): 225 | app_import_path = app_import_path[:-3] 226 | 227 | factory = False 228 | if app_import_path.endswith('()'): 229 | # TODO: this needs to be expanded to accept arguments for the factory 230 | # function 231 | app_import_path = app_import_path[:-2] 232 | factory = True 233 | 234 | if ':' not in app_import_path: 235 | app_import_path += ':app' 236 | 237 | import_name, app_name = app_import_path.split(':') 238 | import_name = prepare_import(import_name) 239 | 240 | uvicorn.run( 241 | import_name + ':' + app_name, 242 | factory=factory, 243 | host=host, 244 | port=port, 245 | reload=reload, 246 | workers=1, 247 | log_level='debug' if debug else 'info', 248 | ssl_certfile=certfile, 249 | ssl_keyfile=keyfile, 250 | ) 251 | 252 | # currently not supported: 253 | # - eager_loading 254 | # - with_threads 255 | # - adhoc certs 256 | # - extra_files 257 | -------------------------------------------------------------------------------- /src/aioflask/ctx.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from greenletio import async_ 3 | from flask.ctx import * 4 | from flask.ctx import AppContext as OriginalAppContext, \ 5 | RequestContext as OriginalRequestContext, _sentinel, _app_ctx_stack, \ 6 | _request_ctx_stack, appcontext_popped 7 | 8 | 9 | class AppContext(OriginalAppContext): 10 | async def apush(self): 11 | """Binds the app context to the current context.""" 12 | self.push() 13 | 14 | async def apop(self, exc=_sentinel): 15 | """Pops the app context.""" 16 | try: 17 | self._refcnt -= 1 18 | if self._refcnt <= 0: 19 | if exc is _sentinel: # pragma: no cover 20 | exc = sys.exc_info()[1] 21 | 22 | @async_ 23 | def do_teardown_async(): 24 | _app_ctx_stack.push(self) 25 | self.app.do_teardown_appcontext(exc) 26 | _app_ctx_stack.pop() 27 | 28 | await do_teardown_async() 29 | finally: 30 | rv = _app_ctx_stack.pop() 31 | assert rv is self, \ 32 | f"Popped wrong app context. ({rv!r} instead of {self!r})" 33 | appcontext_popped.send(self.app) 34 | 35 | async def __aenter__(self): 36 | await self.apush() 37 | return self 38 | 39 | async def __aexit__(self, exc_type, exc_value, tb): 40 | await self.apop(exc_value) 41 | 42 | 43 | class RequestContext(OriginalRequestContext): 44 | async def apush(self): 45 | self.push() 46 | 47 | async def apop(self, exc=_sentinel): 48 | app_ctx = self._implicit_app_ctx_stack.pop() 49 | clear_request = False 50 | 51 | try: 52 | if not self._implicit_app_ctx_stack: 53 | if hasattr(self, 'preserved'): # Flask < 2.2 54 | self.preserved = False 55 | self._preserved_exc = None 56 | if exc is _sentinel: # pragma: no cover 57 | exc = sys.exc_info()[1] 58 | 59 | @async_ 60 | def do_teardown(): 61 | _request_ctx_stack.push(self) 62 | self.app.do_teardown_request(exc) 63 | _request_ctx_stack.pop() 64 | 65 | await do_teardown() 66 | 67 | request_close = getattr(self.request, "close", None) 68 | if request_close is not None: # pragma: no branch 69 | request_close() 70 | clear_request = True 71 | finally: 72 | rv = _request_ctx_stack.pop() 73 | 74 | # get rid of circular dependencies at the end of the request 75 | # so that we don't require the GC to be active. 76 | if clear_request: 77 | rv.request.environ["werkzeug.request"] = None 78 | 79 | # Get rid of the app as well if necessary. 80 | if app_ctx is not None: 81 | await app_ctx.apop(exc) 82 | 83 | assert ( 84 | rv is self 85 | ), f"Popped wrong request context. ({rv!r} instead of {self!r})" 86 | 87 | async def aauto_pop(self, exc): 88 | if hasattr(self, 'preserved'): # Flask < 2.2 89 | if self.request.environ.get("flask._preserve_context") or ( 90 | exc is not None and self.app.preserve_context_on_exception 91 | ): # pragma: no cover 92 | self.preserved = True 93 | self._preserved_exc = exc 94 | else: 95 | await self.apop(exc) 96 | else: 97 | await self.apop(exc) 98 | 99 | async def __aenter__(self): 100 | await self.apush() 101 | return self 102 | 103 | async def __aexit__(self, exc_type, exc_value, tb): 104 | await self.aauto_pop(exc_value) 105 | -------------------------------------------------------------------------------- /src/aioflask/patch.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from aioflask import current_app 3 | 4 | 5 | def patch_decorator(decorator): 6 | def patched_decorator(f): 7 | @wraps(f) 8 | def ensure_sync(*a, **kw): 9 | return current_app.ensure_sync(f)(*a, **kw) 10 | 11 | return decorator(ensure_sync) 12 | return patched_decorator 13 | 14 | 15 | def patch_decorator_with_args(decorator): 16 | def patched_decorator(*args, **kwargs): 17 | def inner_patched_decorator(f): 18 | @wraps(f) 19 | def ensure_sync(*a, **kw): 20 | return current_app.ensure_sync(f)(*a, **kw) 21 | 22 | return decorator(*args, **kwargs)(ensure_sync) 23 | return inner_patched_decorator 24 | return patched_decorator 25 | 26 | 27 | def patch_decorator_method(class_, method_name): 28 | original_decorator = getattr(class_, method_name) 29 | 30 | def patched_decorator_method(self, f): 31 | @wraps(f) 32 | def ensure_sync(*a, **kw): 33 | return current_app.ensure_sync(f)(*a, **kw) 34 | 35 | return original_decorator(self, ensure_sync) 36 | return patched_decorator_method 37 | 38 | 39 | def patch_decorator_method_with_args(class_, method_name): 40 | original_decorator = getattr(class_, method_name) 41 | 42 | def patched_decorator_method(self, *args, **kwargs): 43 | def inner_patched_decorator_method(f): 44 | @wraps(f) 45 | def ensure_sync(*a, **kw): 46 | return current_app.ensure_sync(f)(*a, **kw) 47 | 48 | return original_decorator(self, *args, **kwargs)(ensure_sync) 49 | return inner_patched_decorator_method 50 | return patched_decorator_method 51 | -------------------------------------------------------------------------------- /src/aioflask/patched/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miguelgrinberg/aioflask/7f447e79c81ce7ca46edc5f5852845ff9806ce6a/src/aioflask/patched/__init__.py -------------------------------------------------------------------------------- /src/aioflask/patched/flask_login/__init__.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | import sys 3 | from werkzeug.local import LocalProxy 4 | from aioflask import current_app, g 5 | from flask import _request_ctx_stack 6 | from aioflask.patch import patch_decorator, patch_decorator_method 7 | import flask_login 8 | from flask_login import login_required, fresh_login_required, \ 9 | LoginManager as OriginalLoginManager 10 | 11 | for symbol in flask_login.__all__: 12 | try: 13 | globals()[symbol] = getattr(flask_login, symbol) 14 | except AttributeError: 15 | pass 16 | 17 | 18 | def _user_context_processor(): 19 | return {'current_user': _get_user()} 20 | 21 | 22 | def _load_user(): 23 | # Obtain the current user and preserve it in the g object. Flask-Login 24 | # saves the user in a custom attribute of the request context, but that 25 | # doesn't work with aioflask because when a copy of the request context is 26 | # made, custom attributes are not carried over to the copy. 27 | current_app.login_manager._load_user() 28 | g.flask_login_current_user = _request_ctx_stack.top.user 29 | 30 | 31 | def _get_user(): 32 | # Return the current user. This function is linked to the current_user 33 | # context local, but unlike the original in Flask-Login, it does not 34 | # attempt to load the user, it just returns the user that was pre-loaded. 35 | # This avoids the somewhat tricky complication of triggering database 36 | # operations that need to be awaited, which would require using something 37 | # like (await current_user) 38 | if hasattr(g, 'flask_login_current_user'): 39 | return g.flask_login_current_user 40 | return current_app.login_manager.anonymous_user() 41 | 42 | 43 | class LoginManager(OriginalLoginManager): 44 | def init_app(self, app, add_context_processor=True): 45 | super().init_app(app, add_context_processor=False) 46 | if add_context_processor: 47 | app.context_processor(_user_context_processor) 48 | 49 | # To prevent the current_user context local from triggering I/O at a 50 | # random time when it is first referenced (which is a big complication 51 | # if the I/O is async and needs to be awaited), we force the user to be 52 | # loaded before each request. This isn't a perfect solution, because 53 | # a before request handler registered before this one will not see the 54 | # current user. 55 | app.before_request(_load_user) 56 | 57 | # the decorators that register callbacks need to be patched to support 58 | # async views 59 | user_loader = patch_decorator_method(OriginalLoginManager, 'user_loader') 60 | header_loader = patch_decorator_method( 61 | OriginalLoginManager, 'header_loader') 62 | request_loader = patch_decorator_method( 63 | OriginalLoginManager, 'request_loader') 64 | unauthorized_handler = patch_decorator_method( 65 | OriginalLoginManager, 'unauthorized_handler') 66 | needs_refresh_handler = patch_decorator_method( 67 | OriginalLoginManager, 'needs_refresh_handler') 68 | 69 | 70 | # patch the two login_required decorators so that they accept async views 71 | login_required = patch_decorator(login_required) 72 | fresh_login_required = patch_decorator(fresh_login_required) 73 | 74 | 75 | # redefine the current_user context local 76 | current_user = LocalProxy(_get_user) 77 | 78 | # patch the _get_user() function in the flask_login.utils module so that any 79 | # calls to get current_user in Flask-Login functions are redirected here 80 | setattr(sys.modules['flask_login.utils'], '_get_user', _get_user) 81 | -------------------------------------------------------------------------------- /src/aioflask/templating.py: -------------------------------------------------------------------------------- 1 | from flask.templating import * 2 | from flask.templating import _app_ctx_stack, before_render_template, \ 3 | template_rendered 4 | 5 | 6 | async def _render(template, context, app): 7 | """Renders the template and fires the signal""" 8 | 9 | before_render_template.send(app, template=template, context=context) 10 | rv = await template.render_async(context) 11 | template_rendered.send(app, template=template, context=context) 12 | return rv 13 | 14 | 15 | async def render_template(template_name_or_list, **context): 16 | """Renders a template from the template folder with the given 17 | context. 18 | :param template_name_or_list: the name of the template to be 19 | rendered, or an iterable with template names 20 | the first one existing will be rendered 21 | :param context: the variables that should be available in the 22 | context of the template. 23 | """ 24 | ctx = _app_ctx_stack.top 25 | ctx.app.update_template_context(context) 26 | return await _render( 27 | ctx.app.jinja_env.get_or_select_template(template_name_or_list), 28 | context, 29 | ctx.app, 30 | ) 31 | 32 | 33 | async def render_template_string(source, **context): 34 | """Renders a template from the given template source string 35 | with the given context. Template variables will be autoescaped. 36 | :param source: the source code of the template to be 37 | rendered 38 | :param context: the variables that should be available in the 39 | context of the template. 40 | """ 41 | ctx = _app_ctx_stack.top 42 | ctx.app.update_template_context(context) 43 | return await _render(ctx.app.jinja_env.from_string(source), context, 44 | ctx.app) 45 | -------------------------------------------------------------------------------- /src/aioflask/testing.py: -------------------------------------------------------------------------------- 1 | from flask.testing import * 2 | from flask.testing import FlaskClient as OriginalFlaskClient, \ 3 | FlaskCliRunner as OriginalFlaskCliRunner 4 | from flask import _request_ctx_stack 5 | from werkzeug.test import run_wsgi_app 6 | from greenletio import async_ 7 | 8 | 9 | class FlaskClient(OriginalFlaskClient): 10 | def run_wsgi_app(self, environ, buffered=False): 11 | """Runs the wrapped WSGI app with the given environment. 12 | :meta private: 13 | """ 14 | if self.cookie_jar is not None: 15 | self.cookie_jar.inject_wsgi(environ) 16 | 17 | rv = run_wsgi_app(self.application.wsgi_app, environ, 18 | buffered=buffered) 19 | 20 | if self.cookie_jar is not None: 21 | self.cookie_jar.extract_wsgi(environ, rv[2]) 22 | 23 | return rv 24 | 25 | async def get(self, *args, **kwargs): 26 | return await async_(super().get)(*args, **kwargs) 27 | 28 | async def post(self, *args, **kwargs): 29 | return await async_(super().post)(*args, **kwargs) 30 | 31 | async def put(self, *args, **kwargs): 32 | return await async_(super().put)(*args, **kwargs) 33 | 34 | async def patch(self, *args, **kwargs): 35 | return await async_(super().patch)(*args, **kwargs) 36 | 37 | async def delete(self, *args, **kwargs): 38 | return await async_(super().delete)(*args, **kwargs) 39 | 40 | async def head(self, *args, **kwargs): 41 | return await async_(super().head)(*args, **kwargs) 42 | 43 | async def options(self, *args, **kwargs): 44 | return await async_(super().options)(*args, **kwargs) 45 | 46 | async def trace(self, *args, **kwargs): 47 | return await async_(super().trace)(*args, **kwargs) 48 | 49 | async def __aenter__(self): 50 | if self.preserve_context: 51 | raise RuntimeError("Cannot nest client invocations") 52 | self.preserve_context = True 53 | return self 54 | 55 | async def __aexit__(self, exc_type, exc_value, tb): 56 | self.preserve_context = False 57 | 58 | # Normally the request context is preserved until the next 59 | # request in the same thread comes. When the client exits we 60 | # want to clean up earlier. Pop request contexts until the stack 61 | # is empty or a non-preserved one is found. 62 | while True: 63 | top = _request_ctx_stack.top 64 | 65 | if top is not None and top.preserved: 66 | await top.apop() 67 | else: 68 | break 69 | 70 | 71 | class FlaskCliRunner(OriginalFlaskCliRunner): 72 | async def invoke(self, *args, **kwargs): 73 | return await async_(super().invoke)(*args, **kwargs) 74 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miguelgrinberg/aioflask/7f447e79c81ce7ca46edc5f5852845ff9806ce6a/tests/__init__.py -------------------------------------------------------------------------------- /tests/templates/template.html: -------------------------------------------------------------------------------- 1 | {{ g.x }}{{ session.y }} 2 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import unittest 4 | from unittest import mock 5 | import aioflask 6 | from .utils import async_test 7 | 8 | 9 | class TestApp(unittest.TestCase): 10 | @async_test 11 | async def test_app(self): 12 | app = aioflask.Flask(__name__) 13 | 14 | @app.route('/async') 15 | async def async_route(): 16 | await asyncio.sleep(0) 17 | assert aioflask.current_app._get_current_object() == app 18 | return 'async' 19 | 20 | @app.route('/sync') 21 | def sync_route(): 22 | assert aioflask.current_app._get_current_object() == app 23 | return 'sync' 24 | 25 | client = app.test_client() 26 | response = await client.get('/async') 27 | assert response.data == b'async' 28 | response = await client.get('/sync') 29 | assert response.data == b'sync' 30 | 31 | @async_test 32 | async def test_g(self): 33 | app = aioflask.Flask(__name__) 34 | app.secret_key = 'secret' 35 | 36 | @app.before_request 37 | async def async_before_request(): 38 | aioflask.g.asyncvar = 'async' 39 | 40 | @app.before_request 41 | def sync_before_request(): 42 | aioflask.g.syncvar = 'sync' 43 | 44 | @app.route('/async') 45 | async def async_route(): 46 | aioflask.session['a'] = 'async' 47 | return f'{aioflask.g.asyncvar}-{aioflask.g.syncvar}' 48 | 49 | @app.route('/sync') 50 | async def sync_route(): 51 | aioflask.session['s'] = 'sync' 52 | return f'{aioflask.g.asyncvar}-{aioflask.g.syncvar}' 53 | 54 | @app.route('/session') 55 | async def session(): 56 | return f'{aioflask.session.get("a")}-{aioflask.session.get("s")}' 57 | 58 | @app.after_request 59 | async def after_request(rv): 60 | rv.data += f'/{aioflask.g.asyncvar}-{aioflask.g.syncvar}'.encode() 61 | return rv 62 | 63 | client = app.test_client() 64 | response = await client.get('/session') 65 | assert response.data == b'None-None/async-sync' 66 | response = await client.get('/async') 67 | assert response.data == b'async-sync/async-sync' 68 | response = await client.get('/session') 69 | assert response.data == b'async-None/async-sync' 70 | response = await client.get('/sync') 71 | assert response.data == b'async-sync/async-sync' 72 | response = await client.get('/session') 73 | assert response.data == b'async-sync/async-sync' 74 | 75 | @mock.patch('aioflask.app.uvicorn') 76 | def test_app_run(self, uvicorn): 77 | app = aioflask.Flask(__name__) 78 | 79 | app.run() 80 | uvicorn.run.assert_called_with('tests.test_app:app', 81 | host='127.0.0.1', port=5000, 82 | reload=False, workers=1, 83 | log_level='info', ssl_certfile=None, 84 | ssl_keyfile=None) 85 | app.run(host='1.2.3.4', port=3000) 86 | uvicorn.run.assert_called_with('tests.test_app:app', 87 | host='1.2.3.4', port=3000, 88 | reload=False, workers=1, 89 | log_level='info', ssl_certfile=None, 90 | ssl_keyfile=None) 91 | app.run(debug=True) 92 | uvicorn.run.assert_called_with('tests.test_app:app', 93 | host='127.0.0.1', port=5000, 94 | reload=True, workers=1, 95 | log_level='debug', ssl_certfile=None, 96 | ssl_keyfile=None) 97 | app.run(debug=True, use_reloader=False) 98 | uvicorn.run.assert_called_with('tests.test_app:app', 99 | host='127.0.0.1', port=5000, 100 | reload=False, workers=1, 101 | log_level='debug', ssl_certfile=None, 102 | ssl_keyfile=None) 103 | if 'FLASK_DEBUG' in os.environ: 104 | del os.environ['FLASK_DEBUG'] 105 | if 'AIOFLASK_USE_DEBUGGER' in os.environ: 106 | del os.environ['AIOFLASK_USE_DEBUGGER'] 107 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from unittest import mock 4 | import click 5 | from click.testing import CliRunner 6 | import aioflask 7 | import aioflask.cli 8 | from .utils import async_test 9 | 10 | 11 | class TestCli(unittest.TestCase): 12 | @async_test 13 | async def test_command_with_appcontext(self): 14 | app = aioflask.Flask('testapp') 15 | 16 | @app.cli.command(with_appcontext=True) 17 | async def testcmd(): 18 | click.echo(aioflask.current_app.name) 19 | 20 | result = await app.test_cli_runner().invoke(testcmd) 21 | assert result.exit_code == 0 22 | assert result.output == "testapp\n" 23 | 24 | @async_test 25 | async def test_command_without_appcontext(self): 26 | app = aioflask.Flask('testapp') 27 | 28 | @app.cli.command(with_appcontext=False) 29 | async def testcmd(): 30 | click.echo(aioflask.current_app.name) 31 | 32 | result = await app.test_cli_runner().invoke(testcmd) 33 | assert result.exit_code == 1 34 | assert type(result.exception) == RuntimeError 35 | 36 | @async_test 37 | async def test_with_appcontext(self): 38 | @click.command() 39 | @aioflask.cli.with_appcontext 40 | async def testcmd(): 41 | click.echo(aioflask.current_app.name) 42 | 43 | app = aioflask.Flask('testapp') 44 | 45 | result = await app.test_cli_runner().invoke(testcmd) 46 | assert result.exit_code == 0 47 | assert result.output == "testapp\n" 48 | 49 | @mock.patch('aioflask.cli.uvicorn') 50 | def test_aiorun(self, uvicorn): 51 | app = aioflask.Flask('testapp') 52 | obj = aioflask.cli.ScriptInfo(app_import_path='app.py', 53 | create_app=lambda: app) 54 | 55 | result = CliRunner().invoke(aioflask.cli.run_command, obj=obj) 56 | assert result.exit_code == 0 57 | uvicorn.run.assert_called_with('app:app', factory=False, 58 | host='127.0.0.1', port=5000, 59 | reload=False, workers=1, 60 | log_level='info', ssl_certfile=None, 61 | ssl_keyfile=None) 62 | result = CliRunner().invoke(aioflask.cli.run_command, 63 | '--host 1.2.3.4 --port 3000', obj=obj) 64 | assert result.exit_code == 0 65 | uvicorn.run.assert_called_with('app:app', factory=False, 66 | host='1.2.3.4', port=3000, 67 | reload=False, workers=1, 68 | log_level='info', ssl_certfile=None, 69 | ssl_keyfile=None) 70 | os.environ['FLASK_DEBUG'] = 'true' 71 | result = CliRunner().invoke(aioflask.cli.run_command, obj=obj) 72 | assert result.exit_code == 0 73 | uvicorn.run.assert_called_with('app:app', factory=False, 74 | host='127.0.0.1', port=5000, 75 | reload=True, workers=1, 76 | log_level='debug', ssl_certfile=None, 77 | ssl_keyfile=None) 78 | os.environ['FLASK_DEBUG'] = 'true' 79 | result = CliRunner().invoke(aioflask.cli.run_command, '--no-reload', 80 | obj=obj) 81 | assert result.exit_code == 0 82 | uvicorn.run.assert_called_with('app:app', factory=False, 83 | host='127.0.0.1', port=5000, 84 | reload=False, workers=1, 85 | log_level='debug', ssl_certfile=None, 86 | ssl_keyfile=None) 87 | 88 | if 'FLASK_DEBUG' in os.environ: 89 | del os.environ['FLASK_DEBUG'] 90 | if 'AIOFLASK_USE_DEBUGGER' in os.environ: 91 | del os.environ['AIOFLASK_USE_DEBUGGER'] 92 | 93 | @mock.patch('aioflask.cli.uvicorn') 94 | def test_aiorun_with_factory(self, uvicorn): 95 | app = aioflask.Flask('testapp') 96 | obj = aioflask.cli.ScriptInfo(app_import_path='app:create_app()', 97 | create_app=lambda: app) 98 | 99 | result = CliRunner().invoke(aioflask.cli.run_command, obj=obj) 100 | assert result.exit_code == 0 101 | uvicorn.run.assert_called_with('app:create_app', factory=True, 102 | host='127.0.0.1', port=5000, 103 | reload=False, workers=1, 104 | log_level='info', ssl_certfile=None, 105 | ssl_keyfile=None) 106 | -------------------------------------------------------------------------------- /tests/test_ctx.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import pytest 3 | import aioflask 4 | from .utils import async_test 5 | 6 | 7 | class TestApp(unittest.TestCase): 8 | @async_test 9 | async def test_app_context(self): 10 | app = aioflask.Flask(__name__) 11 | called_t1 = False 12 | called_t2 = False 13 | 14 | @app.teardown_appcontext 15 | async def t1(exc): 16 | nonlocal called_t1 17 | called_t1 = True 18 | 19 | @app.teardown_appcontext 20 | def t2(exc): 21 | nonlocal called_t2 22 | called_t2 = True 23 | 24 | async with app.app_context(): 25 | assert aioflask.current_app == app 26 | async with app.app_context(): 27 | assert aioflask.current_app == app 28 | assert aioflask.current_app == app 29 | 30 | assert called_t1 31 | assert called_t2 32 | with pytest.raises(RuntimeError): 33 | print(aioflask.current_app) 34 | 35 | @async_test 36 | async def test_req_context(self): 37 | app = aioflask.Flask(__name__) 38 | called_t1 = False 39 | called_t2 = False 40 | 41 | @app.teardown_appcontext 42 | async def t1(exc): 43 | nonlocal called_t1 44 | called_t1 = True 45 | 46 | @app.teardown_appcontext 47 | def t2(exc): 48 | nonlocal called_t2 49 | called_t2 = True 50 | 51 | async with app.test_request_context('/foo'): 52 | assert aioflask.current_app == app 53 | assert aioflask.request.path == '/foo' 54 | 55 | assert called_t1 56 | assert called_t2 57 | 58 | async with app.app_context(): 59 | async with app.test_request_context('/bar') as reqctx: 60 | assert aioflask.current_app == app 61 | assert aioflask.request.path == '/bar' 62 | async with reqctx: 63 | assert aioflask.current_app == app 64 | assert aioflask.request.path == '/bar' 65 | 66 | with pytest.raises(RuntimeError): 67 | print(aioflask.current_app) 68 | -------------------------------------------------------------------------------- /tests/test_patch.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import aioflask 3 | import aioflask.patch 4 | from .utils import async_test 5 | 6 | 7 | class TestPatch(unittest.TestCase): 8 | @async_test 9 | async def test_decorator(self): 10 | def foo(f): 11 | def decorator(*args, **kwargs): 12 | return f(*args, **kwargs) + '-decorated' 13 | 14 | return decorator 15 | 16 | foo = aioflask.patch.patch_decorator(foo) 17 | 18 | app = aioflask.Flask(__name__) 19 | 20 | @app.route('/abc/') 21 | @foo 22 | async def abc(id): 23 | return str(id) 24 | 25 | client = app.test_client() 26 | response = await client.get('/abc/123') 27 | assert response.data == b'123-decorated' 28 | 29 | @async_test 30 | async def test_decorator_with_args(self): 31 | def foo(value): 32 | def inner_foo(f): 33 | def decorator(*args, **kwargs): 34 | return f(*args, **kwargs) + str(value) 35 | 36 | return decorator 37 | return inner_foo 38 | 39 | foo = aioflask.patch.patch_decorator_with_args(foo) 40 | 41 | app = aioflask.Flask(__name__) 42 | 43 | @app.route('/abc/') 44 | @foo(456) 45 | async def abc(id): 46 | return str(id) 47 | 48 | client = app.test_client() 49 | response = await client.get('/abc/123') 50 | assert response.data == b'123456' 51 | 52 | @async_test 53 | async def test_decorator_method(self): 54 | class Foo: 55 | def __init__(self, value): 56 | self.value = value 57 | 58 | def deco(self, f): 59 | def decorator(*args, **kwargs): 60 | return f(*args, **kwargs) + str(self.value) 61 | 62 | return decorator 63 | 64 | Foo.deco = aioflask.patch.patch_decorator_method(Foo, 'deco') 65 | 66 | app = aioflask.Flask(__name__) 67 | foo = Foo(456) 68 | 69 | @app.route('/abc/') 70 | @foo.deco 71 | async def abc(id): 72 | return str(id) 73 | 74 | client = app.test_client() 75 | response = await client.get('/abc/123') 76 | assert response.data == b'123456' 77 | 78 | @async_test 79 | async def test_decorator_method_with_args(self): 80 | class Foo: 81 | def __init__(self, value): 82 | self.value = value 83 | 84 | def deco(self, value2): 85 | def decorator(f): 86 | def inner_decorator(*args, **kwargs): 87 | return f(*args, **kwargs) + str(self.value) + \ 88 | str(value2) 89 | 90 | return inner_decorator 91 | return decorator 92 | 93 | Foo.deco = aioflask.patch.patch_decorator_method_with_args(Foo, 'deco') 94 | 95 | app = aioflask.Flask(__name__) 96 | foo = Foo(456) 97 | 98 | @app.route('/abc/') 99 | @foo.deco(789) 100 | async def abc(id): 101 | return str(id) 102 | 103 | client = app.test_client() 104 | response = await client.get('/abc/123') 105 | assert response.data == b'123456789' 106 | -------------------------------------------------------------------------------- /tests/test_templating.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import unittest 4 | from unittest import mock 5 | import aioflask 6 | from .utils import async_test 7 | 8 | 9 | class TestTemplating(unittest.TestCase): 10 | @async_test 11 | async def test_template_strng(self): 12 | app = aioflask.Flask(__name__) 13 | app.secret_key = 'secret' 14 | 15 | @app.before_request 16 | def before_request(): 17 | aioflask.g.x = 'foo' 18 | aioflask.session['y'] = 'bar' 19 | 20 | @app.route('/') 21 | async def async_route(): 22 | return await aioflask.render_template_string( 23 | '{{ g.x }}{{ session.y }}') 24 | 25 | client = app.test_client() 26 | response = await client.get('/') 27 | assert response.data == b'foobar' 28 | 29 | @async_test 30 | async def test_template(self): 31 | app = aioflask.Flask(__name__) 32 | app.secret_key = 'secret' 33 | 34 | @app.before_request 35 | def before_request(): 36 | aioflask.g.x = 'foo' 37 | aioflask.session['y'] = 'bar' 38 | 39 | @app.route('/') 40 | async def async_route(): 41 | return await aioflask.render_template('template.html') 42 | 43 | client = app.test_client() 44 | response = await client.get('/') 45 | assert response.data == b'foobar' 46 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from greenletio.core import bridge 3 | 4 | 5 | def async_test(f): 6 | def wrapper(*args, **kwargs): 7 | asyncio.get_event_loop().run_until_complete(f(*args, **kwargs)) 8 | 9 | return wrapper 10 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=flake8,,py37,py38,py39,py310,pypy3,docs 3 | skip_missing_interpreters=True 4 | 5 | [gh-actions] 6 | python = 7 | 3.7: py37 8 | 3.8: py38 9 | 3.9: py39 10 | 3.10: py310 11 | pypy3: pypy-3 12 | 13 | [testenv] 14 | commands= 15 | pip install -e . 16 | pytest -p no:logging --cov=src/aioflask --cov-branch examples/aioflaskr/tests 17 | pytest -p no:logging --cov=src/aioflask --cov-branch --cov-report=term-missing --cov-report=xml --cov-append tests 18 | deps= 19 | aiosqlite 20 | greenletio 21 | alchemical 22 | flask-login 23 | pytest 24 | pytest-asyncio 25 | pytest-cov 26 | 27 | [testenv:flake8] 28 | deps= 29 | flake8 30 | commands= 31 | flake8 --ignore=F401,F403 --exclude=".*" src/aioflask tests 32 | 33 | [testenv:docs] 34 | changedir=docs 35 | deps= 36 | sphinx 37 | whitelist_externals= 38 | make 39 | commands= 40 | make html 41 | --------------------------------------------------------------------------------