├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.rst ├── COPYING ├── MANIFEST.in ├── Makefile ├── README.rst ├── example.py ├── flask_injector ├── __init__.py ├── py.typed └── tests.py ├── pyproject.toml ├── requirements-dev.txt ├── setup.cfg └── setup.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - auto 7 | - try 8 | - try-perf 9 | - master 10 | pull_request: 11 | branches: 12 | - "**" 13 | 14 | jobs: 15 | build: 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest] 20 | python-version: [3.7, 3.8, 3.9, "3.10", pypy3.7, pypy3.8, pypy3.9] 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | pip install -r requirements-dev.txt 30 | pip install . 31 | - name: Run tests 32 | run: if which mypy; then make ci; else make test; fi 33 | - name: Report coverage to Codecov 34 | uses: codecov/codecov-action@v1 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | dist/ 3 | build/ 4 | *.egg-info/ 5 | .mypy_cache/ 6 | cover/ 7 | coverage.xml 8 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Flask-Injector Changelog 2 | ======================== 3 | 4 | Version 0.15.0 5 | -------------- 6 | 7 | * Fixed Flask 2.2.0+ compatibility, thanks to Valentin Baert 8 | 9 | Backwards incompatible: 10 | 11 | * Dropped support for Flask older than 2.2.0 12 | 13 | Version 0.14.0 14 | -------------- 15 | 16 | * Infrastructure contributions thanks to ZHANG Cheng 17 | * Fixed all known Flask/Werkzeug compatibility issues 18 | 19 | Backwards incompatible: 20 | 21 | * Dropped Python 3.6 support 22 | * Flask >= 2.1.2 is now required 23 | * Injector >= 0.20.0 is now required 24 | 25 | Version 0.13.0 26 | -------------- 27 | 28 | * Improved Werkzeug/Flask 2.0 compatibility (fixed the "RuntimeError: Working outside of request context" error) 29 | 30 | Backwards incompatible: 31 | 32 | * Dropped Python 3.5 support 33 | * Dropped flask_restplus support (the library hasn't been usable with Werkzeug 1.0+) 34 | 35 | Version 0.12.3 36 | -------------- 37 | 38 | * Fixed injecting into blueprint-associated teardown_request handlers 39 | 40 | Version 0.12.2 41 | -------------- 42 | 43 | * Added Flask-RESTX integration (#48, thanks to Michael Bukachi) 44 | 45 | Version 0.12.1 46 | -------------- 47 | 48 | * Stopped unnecessarily installing typing on recent Python versions (thanks to Louis Trezzini) 49 | * Fixed injecting request-scoped dependencies into teradown_request handlers (fix suggested 50 | by Nick Krichevsky) 51 | * Added PEP 561 py.typed marker so that tools know to use type hints in the package's source 52 | 53 | Version 0.12.0 54 | -------------- 55 | 56 | * Added support for adding instance methods as handlers (#35, thanks to Rene Hollander) 57 | 58 | Backwards incompatible: 59 | 60 | * Dropped Python 3.4 support 61 | * Dropped Flask < 1.0 support 62 | * Dropped Injector < 0.13.2 support 63 | 64 | Version 0.11.0 65 | -------------- 66 | 67 | * flask_restful is no longer required to be installed when flask_restplus is 68 | used (#24) 69 | * Added support for injecting into before_first_request functions (#26) 70 | 71 | Backwards incompatible: 72 | 73 | * Dropped Python 3.3 support 74 | 75 | Version 0.10.1 76 | -------------- 77 | 78 | * Got rid of a deprecation warning when Injector 0.13.2 is used 79 | 80 | Version 0.10.0 81 | -------------- 82 | 83 | * Dropped support for Injector < 0.12 and Flask < 0.12 84 | * Dropped use_annotations constructor parameter (this also fixed compatibility 85 | with Injector 0.13) 86 | * At least for the time being class-based views' constructors need to be marked 87 | with @inject in order for dependencies to be injected 88 | 89 | Version 0.9.0 90 | ------------- 91 | 92 | * Fixed a bug that would cause a crash when an nonintrospectable callable 93 | was registered as, for example, before_request hook 94 | * Fixed support for forward references in type hints 95 | * Added type hints to the codebase 96 | 97 | Backwards incompatible: 98 | 99 | * Dropped support for Injector 0.10 100 | 101 | Version 0.8.0 102 | ------------- 103 | 104 | * Dropped support for Flask < 0.11, Injector < 0.10, Python 2, PyPy and PyPy 3 105 | (PyPy 3 will be supported in the future) 106 | * Fixed compatibility with Injector 0.11 107 | 108 | Version 0.7.1 109 | ------------- 110 | 111 | * Fixed Flask 0.11 compatibility, thanks to Philip Jones for the initial patch 112 | 113 | Version 0.7.0 114 | ------------- 115 | 116 | * Added support for injecting into Flask-RestPlus Resource constructors 117 | * Added support for dependencies declared using Python 3-style annotations 118 | 119 | Version 0.6.2 120 | ------------- 121 | 122 | * Fixed a regression introduced in 0.6.1 (requesting an interface bound in 123 | RequestScope outside request context got broken and would raise 124 | "AttributeError: scope" exception. Even though it's not a documented 125 | behaviour it's restored now so that backwards compatibility is preserved. 126 | 127 | Version 0.6.1 128 | ------------- 129 | 130 | * Python 2.6 support dropped 131 | * Fixed a memory leak bug (a reference to thread-identity object would be kept 132 | forever after a request would be served by particular thread; without greenlet 133 | package installed thread ids (numeric values) are used so the internal 134 | dictionary of thread local storage grows forever; when greenlet package is 135 | installed greenlet objects are used as thread identities by Werkzeug so on top 136 | of the internal storage growing infinitely all objects referenced by those 137 | greenlet objects are kept alive; keywords: Eventlet, Gevent, GreenThread). See 138 | GH issue #9 and pull request #11, thanks to Zi Li for the fix 139 | 140 | Version 0.6.0 141 | ------------- 142 | 143 | * Added support for injecting into Flask-RESTFul Resource constructors 144 | 145 | Version 0.5.0 146 | ------------- 147 | 148 | * Removed ``init_app`` and ``post_init_app`` functions 149 | * Fixed a bug with Flask-Injector modifying possibly shared view generated by View.as_view 150 | (see GH issue #6, test case provided by Nicholas Hollett) 151 | * Work only with Injector >= 0.9.0 now 152 | 153 | Version 0.4.0 154 | ------------- 155 | 156 | * Deprecated ``init_app`` and ``post_init_app`` in favour of ``FlaskInjector`` 157 | * Made Flask error handlers support injection 158 | 159 | Version 0.3.4 160 | ------------- 161 | 162 | * Made it possible to inject into Jinja template globals 163 | 164 | Version 0.3.3 165 | ------------- 166 | 167 | * Accomodated to Injector >= 0.9.0 168 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Alec Thomas 2 | Copyright (c) 2015 Smarkets Limited 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | - Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | - Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | - Neither the name of SwapOff.org nor the names of its contributors may 14 | be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include COPYING 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCES := flask_injector 2 | 3 | .PHONY: ci 4 | ci: test lint 5 | 6 | .PHONY: test 7 | test: 8 | coverage run --source=$(SOURCES) -m pytest -v flask_injector/tests.py && coverage report -m 9 | PYTHONPATH=.:$(PYTHONPATH) python example.py 10 | 11 | .PHONY: lint 12 | lint: flake8 mypy black-check 13 | 14 | .PHONY: flake8 15 | flake8: 16 | flake8 --max-line-length=110 $(SOURCES) 17 | 18 | .PHONY: mypy 19 | mypy: 20 | python -m mypy \ 21 | --ignore-missing-imports --follow-imports=skip \ 22 | --disallow-untyped-defs \ 23 | --warn-no-return \ 24 | --warn-redundant-casts \ 25 | --strict-optional \ 26 | flask_injector/__init__.py 27 | 28 | .PHONY: black-check 29 | black-check: 30 | black --check . 31 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Flask-Injector 2 | ============== 3 | 4 | .. image:: https://codecov.io/gh/alecthomas/flask_injector/branch/master/graph/badge.svg 5 | :target: https://codecov.io/gh/alecthomas/flask_injector 6 | 7 | .. image:: https://github.com/alecthomas/flask_injector/workflows/CI/badge.svg 8 | :target: https://github.com/alecthomas/flask_injector?query=workflow%3ACI+branch%3Amaster 9 | 10 | 11 | Adds `Injector `_ support to Flask, 12 | this way there's no need to use global Flask objects, which makes testing simpler. 13 | 14 | Injector is a dependency-injection framework for Python, inspired by Guice. You 15 | can `find Injector on PyPI `_ and `Injector 16 | documentation on Read the Docs `_. 17 | 18 | `Flask-Injector` is compatible with CPython 3.7+. 19 | As of version 0.15.0 it requires Injector version 0.20.0 or greater and Flask 20 | 2.2.0 or greater. 21 | 22 | GitHub project page: https://github.com/alecthomas/flask_injector 23 | 24 | PyPI package page: https://pypi.org/project/Flask-Injector/ 25 | 26 | Changelog: https://github.com/alecthomas/flask_injector/blob/master/CHANGELOG.rst 27 | 28 | Features 29 | -------- 30 | 31 | Flask-Injector lets you inject dependencies into: 32 | 33 | * views (functions and class-based) 34 | * `before_request` handlers 35 | * `after_request` handlers 36 | * `teardown_request` handlers 37 | * template context processors 38 | * error handlers 39 | * Jinja environment globals (functions in `app.jinja_env.globals`) 40 | * Flask-RESTFul Resource constructors 41 | * Flask-RestPlus Resource constructors 42 | * Flask-RESTX Resource constructors 43 | 44 | Flask-Injector supports defining types using function annotations (Python 3), 45 | see below. 46 | 47 | Documentation 48 | ------------- 49 | 50 | As Flask-Injector uses Injector under the hood you should find the 51 | `Injector documentation `_, 52 | including the `Injector API reference `_, 53 | helpful. The `Injector README `_ 54 | provides a tutorial-level introduction to using Injector. 55 | 56 | The Flask-Injector public API consists of the following: 57 | 58 | * `FlaskInjector` class with the constructor taking the following parameters: 59 | 60 | * `app`, an instance of`flask.Flask` [mandatory] – the Flask application to be used 61 | * `modules`, an iterable of 62 | `Injector modules `_ [optional] 63 | – the Injector modules to be used. 64 | * `injector`, an instance of 65 | `injector.Injector `_ [optional] 66 | – an instance of Injector to be used if, for some reason, it's not desirable 67 | for `FlaskInjector` to create a new one. You're likely to not need to use this. 68 | * `request_scope_class`, an `injector.Scope `_ 69 | subclass [optional] – the scope to be used instead of `RequestScope`. You're likely to need to use this 70 | except for testing. 71 | * `RequestScope` class – an `injector.Scope `_ 72 | subclass to be used for storing and reusing request-scoped dependencies 73 | * `request` object – to be used as a class decorator or in explicit 74 | `bind() `_ calls in 75 | Injector modules. 76 | 77 | Creating an instance of `FlaskInjector` performs side-effectful configuration of the Flask 78 | application passed to it. The following bindings are applied (if you want to modify them you 79 | need to do it in one of the modules passed to the `FlaskInjector` constructor): 80 | 81 | * `flask.Flask` is bound to the Flask application in the (scope: singleton) 82 | * `flask.Config` is bound to the configuration of the Flask application 83 | * `flask.Request` is bound to the current Flask request object, equivalent to the thread-local 84 | `flask.request` object (scope: request) 85 | 86 | Example application using Flask-Injector 87 | ---------------------------------------- 88 | 89 | .. code:: python 90 | 91 | import sqlite3 92 | from flask import Flask, Config 93 | from flask.views import View 94 | from flask_injector import FlaskInjector 95 | from injector import inject 96 | 97 | app = Flask(__name__) 98 | 99 | # Configure your application by attaching views, handlers, context processors etc.: 100 | 101 | @app.route("/bar") 102 | def bar(): 103 | return render("bar.html") 104 | 105 | 106 | # Route with injection 107 | @app.route("/foo") 108 | def foo(db: sqlite3.Connection): 109 | users = db.execute('SELECT * FROM users').all() 110 | return render("foo.html") 111 | 112 | 113 | # Class-based view with injected constructor 114 | class Waz(View): 115 | @inject 116 | def __init__(self, db: sqlite3.Connection): 117 | self.db = db 118 | 119 | def dispatch_request(self, key): 120 | users = self.db.execute('SELECT * FROM users WHERE name=?', (key,)).all() 121 | return 'waz' 122 | 123 | app.add_url_rule('/waz/', view_func=Waz.as_view('waz')) 124 | 125 | 126 | # In the Injector world, all dependency configuration and initialization is 127 | # performed in modules (https://injector.readthedocs.io/en/latest/terminology.html#module). 128 | # The same is true with Flask-Injector. You can see some examples of configuring 129 | # Flask extensions through modules below. 130 | 131 | # Accordingly, the next step is to create modules for any objects we want made 132 | # available to the application. Note that in this example we also use the 133 | # Injector to gain access to the `flask.Config`: 134 | 135 | def configure(binder): 136 | binder.bind( 137 | sqlite3.Connection, 138 | to=sqlite3.Connection(':memory:'), 139 | scope=request, 140 | ) 141 | 142 | # Initialize Flask-Injector. This needs to be run *after* you attached all 143 | # views, handlers, context processors and template globals. 144 | 145 | FlaskInjector(app=app, modules=[configure]) 146 | 147 | # All that remains is to run the application 148 | 149 | app.run() 150 | 151 | See `example.py` for a more complete example, including `Flask-SQLAlchemy` and 152 | `Flask-Cache` integration. 153 | 154 | Supporting Flask Extensions 155 | --------------------------- 156 | 157 | Typically, Flask extensions are initialized at the global scope using a 158 | pattern similar to the following. 159 | 160 | .. code:: python 161 | 162 | app = Flask(__name__) 163 | ext = ExtClass(app) 164 | 165 | @app.route(...) 166 | def view(): 167 | # Use ext object here... 168 | 169 | As we don't have these globals with Flask-Injector we have to configure the 170 | extension the Injector way - through modules. Modules can either be subclasses 171 | of `injector.Module` or a callable taking an `injector.Binder` instance. 172 | 173 | .. code:: python 174 | 175 | from injector import Module 176 | 177 | class MyModule(Module): 178 | @provider 179 | @singleton 180 | def provide_ext(self, app: Flask) -> ExtClass: 181 | return ExtClass(app) 182 | 183 | def main(): 184 | app = Flask(__name__) 185 | app.config.update( 186 | EXT_CONFIG_VAR='some_value', 187 | ) 188 | 189 | # attach your views etc. here 190 | 191 | FlaskInjector(app=app, modules=[MyModule]) 192 | 193 | app.run() 194 | 195 | *Make sure to bind extension objects as singletons.* 196 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | 4 | from injector import Module, Injector, inject, singleton 5 | from flask import Flask, Request, jsonify 6 | from flask_injector import FlaskInjector 7 | from flask_sqlalchemy import SQLAlchemy 8 | from sqlalchemy.ext.declarative import declarative_base 9 | from sqlalchemy.orm.exc import NoResultFound 10 | from sqlalchemy import Column, String 11 | 12 | il = logging.getLogger('injector') 13 | il.addHandler(logging.StreamHandler()) 14 | il.level = logging.DEBUG 15 | 16 | """ 17 | This is an example of using Injector (https://github.com/alecthomas/injector) and Flask. 18 | 19 | Flask provides a lot of very nice features, but also requires a lot of globals 20 | and tightly bound code. Flask-Injector seeks to remedy this. 21 | """ 22 | 23 | 24 | # We use standard SQLAlchemy models rather than the Flask-SQLAlchemy magic, as 25 | # it requires a global Flask app object and SQLAlchemy db object. 26 | Base = declarative_base() 27 | 28 | 29 | class KeyValue(Base): 30 | __tablename__ = 'data' 31 | 32 | key = Column(String, primary_key=True) 33 | value = Column(String) 34 | 35 | def __init__(self, key, value): 36 | self.key = key 37 | self.value = value 38 | 39 | def serializable(self): 40 | return 41 | 42 | 43 | def configure_views(app): 44 | @app.route('/') 45 | def get(key, db: SQLAlchemy): 46 | try: 47 | kv = db.session.query(KeyValue).filter(KeyValue.key == key).one() 48 | except NoResultFound: 49 | response = jsonify(status='No such key', context=key) 50 | response.status = '404 Not Found' 51 | return response 52 | return jsonify(key=kv.key, value=kv.value) 53 | 54 | @app.route('/') 55 | def list(db: SQLAlchemy): 56 | data = [i.key for i in db.session.query(KeyValue).order_by(KeyValue.key)] 57 | return jsonify(keys=data) 58 | 59 | @app.route('/', methods=['POST']) 60 | def create(request: Request, db: SQLAlchemy): 61 | kv = KeyValue(request.form['key'], request.form['value']) 62 | db.session.add(kv) 63 | db.session.commit() 64 | response = jsonify(status='OK') 65 | response.status = '201 CREATED' 66 | return response 67 | 68 | @app.route('/', methods=['DELETE']) 69 | def delete(db: SQLAlchemy, key): 70 | db.session.query(KeyValue).filter(KeyValue.key == key).delete() 71 | db.session.commit() 72 | response = jsonify(status='OK') 73 | response.status = '200 OK' 74 | return response 75 | 76 | 77 | class AppModule(Module): 78 | def __init__(self, app): 79 | self.app = app 80 | 81 | """Configure the application.""" 82 | 83 | def configure(self, binder): 84 | # We configure the DB here, explicitly, as Flask-SQLAlchemy requires 85 | # the DB to be configured before request handlers are called. 86 | db = self.configure_db(self.app) 87 | binder.bind(SQLAlchemy, to=db, scope=singleton) 88 | 89 | def configure_db(self, app): 90 | db = SQLAlchemy(app) 91 | Base.metadata.create_all(db.engine) 92 | db.session.add_all([KeyValue('hello', 'world'), KeyValue('goodbye', 'cruel world')]) 93 | db.session.commit() 94 | return db 95 | 96 | 97 | def main(): 98 | app = Flask(__name__) 99 | app.config.update(DB_CONNECTION_STRING=':memory:', SQLALCHEMY_DATABASE_URI='sqlite://') 100 | app.debug = True 101 | 102 | # An explicit context because AppModule calls some flask-sqlalchemy's code that needs 103 | # a context. 104 | # 105 | # https://github.com/pallets-eco/flask-sqlalchemy/issues/1129#issuecomment-1283219340 106 | with app.app_context(): 107 | injector = Injector([AppModule(app)]) 108 | configure_views(app=app) 109 | 110 | FlaskInjector(app=app, injector=injector) 111 | 112 | client = app.test_client() 113 | 114 | response = client.get('/') 115 | print('%s\n%s%s' % (response.status, response.headers, response.data)) 116 | response = client.post('/', data={'key': 'foo', 'value': 'bar'}) 117 | print('%s\n%s%s' % (response.status, response.headers, response.data)) 118 | response = client.get('/') 119 | print('%s\n%s%s' % (response.status, response.headers, response.data)) 120 | response = client.get('/hello') 121 | print('%s\n%s%s' % (response.status, response.headers, response.data)) 122 | response = client.delete('/hello') 123 | print('%s\n%s%s' % (response.status, response.headers, response.data)) 124 | response = client.get('/') 125 | print('%s\n%s%s' % (response.status, response.headers, response.data)) 126 | response = client.get('/hello') 127 | print('%s\n%s%s' % (response.status, response.headers, response.data)) 128 | response = client.delete('/hello') 129 | print('%s\n%s%s' % (response.status, response.headers, response.data)) 130 | 131 | 132 | if __name__ == '__main__': 133 | main() 134 | -------------------------------------------------------------------------------- /flask_injector/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (C) 2012 Alec Thomas 4 | # Copyright (C) 2015 Smarkets Limited 5 | # All rights reserved. 6 | # 7 | # This software is licensed as described in the file COPYING, which 8 | # you should have received as part of this distribution. 9 | # 10 | # Author: Alec Thomas 11 | import functools 12 | from inspect import ismethod 13 | from typing import Any, Callable, cast, Dict, get_type_hints, Iterable, List, TypeVar, Union 14 | 15 | import flask 16 | 17 | try: 18 | from flask_restful import Api as FlaskRestfulApi 19 | from flask_restful.utils import unpack as flask_response_unpack 20 | except ImportError: 21 | FlaskRestfulApi = None 22 | try: 23 | import flask_restx 24 | from flask_restx.utils import unpack as flask_response_unpack # noqa 25 | except ImportError: 26 | flask_restx = None 27 | 28 | from injector import Binder, Injector, inject 29 | from flask import Config, Request 30 | from werkzeug.local import Local, LocalManager, LocalProxy 31 | from werkzeug.wrappers import Response 32 | from injector import Module, Provider, Scope, ScopeDecorator, singleton 33 | 34 | 35 | __author__ = 'Alec Thomas ' 36 | __version__ = '0.15.0' 37 | __all__ = ['request', 'RequestScope', 'Config', 'Request', 'FlaskInjector'] 38 | 39 | T = TypeVar('T', LocalProxy, Callable) 40 | 41 | 42 | def instance_method_wrapper(im: T) -> T: 43 | @functools.wraps(im) 44 | def wrapper(*args: Any, **kwargs: Any) -> Any: 45 | return im(*args, **kwargs) 46 | 47 | return wrapper # type: ignore 48 | 49 | 50 | def wrap_fun(fun: T, injector: Injector) -> T: 51 | if isinstance(fun, LocalProxy): 52 | return fun # type: ignore 53 | 54 | if ismethod(fun): 55 | fun = instance_method_wrapper(fun) 56 | 57 | # Important: this block needs to stay here so it's executed *before* the 58 | # hasattr(fun, '__call__') block below - otherwise things may crash. 59 | if hasattr(fun, '__bindings__'): 60 | return wrap_function(fun, injector) 61 | 62 | if hasattr(fun, 'view_class'): 63 | return wrap_class_based_view(fun, injector) 64 | 65 | if hasattr(fun, '__call__') and not isinstance(fun, type): 66 | try: 67 | type_hints = get_type_hints(fun) 68 | except (AttributeError, TypeError): 69 | # Some callables aren't introspectable with get_type_hints, 70 | # let's assume they don't have anything to inject. The exception 71 | # types handled here are what I encountered so far. 72 | # It used to be AttributeError, then https://github.com/python/typing/pull/314 73 | # changed it to TypeError. 74 | wrap_it = False 75 | except NameError: 76 | wrap_it = True 77 | else: 78 | type_hints.pop('return', None) 79 | wrap_it = type_hints != {} 80 | if wrap_it: 81 | return wrap_fun(inject(fun), injector) 82 | 83 | return fun 84 | 85 | 86 | def wrap_function(fun: Callable, injector: Injector) -> Callable: 87 | @functools.wraps(fun) 88 | def wrapper(*args: Any, **kwargs: Any) -> Any: 89 | return injector.call_with_injection(callable=fun, args=args, kwargs=kwargs) 90 | 91 | return wrapper 92 | 93 | 94 | def wrap_class_based_view(fun: Callable, injector: Injector) -> Callable: 95 | cls = cast(Any, fun).view_class 96 | name = fun.__name__ 97 | 98 | closure_contents = (c.cell_contents for c in cast(Any, fun).__closure__) 99 | fun_closure = dict(zip(fun.__code__.co_freevars, closure_contents)) 100 | try: 101 | class_kwargs = fun_closure['class_kwargs'] 102 | except KeyError: 103 | # Most likely flask_restful resource, we'll see in a second 104 | flask_restful_api = fun_closure['self'] 105 | # flask_restful wraps ResourceClass.as_view() result in its own wrapper 106 | # the as_view() result is available under 'resource' name in this closure 107 | fun = fun_closure['resource'] 108 | fun_closure = {} 109 | class_kwargs = {} 110 | # if the lines above succeeded we're quite sure it's flask_restful resource 111 | else: 112 | flask_restful_api = None 113 | class_args = fun_closure.get('class_args') 114 | assert not class_args, 'Class args are not supported, use kwargs instead' 115 | 116 | if flask_restful_api and flask_restx and isinstance(flask_restful_api, flask_restx.Api): 117 | # This is flask_restplus' (before it forked into flask_restx) add_resource 118 | # implementation: 119 | # 120 | # def add_resource(self, resource, *urls, **kwargs): 121 | # (...) 122 | # args = kwargs.pop('resource_class_args', []) 123 | # if isinstance(args, tuple): 124 | # args = list(args) 125 | # args.insert(0, self) 126 | # kwargs['resource_class_args'] = args 127 | # 128 | # super(Api, self).add_resource(resource, *urls, **kwargs) 129 | # 130 | # Correspondingly, flask_restx.Resource's constructor expects 131 | # flask_restx.Api instance in its first argument; since we detected 132 | # that we're very likely dealing with flask_restx we'll provide the Api 133 | # instance as keyword argument instead (it'll work just as well unless 134 | # flask_restx changes the name of the parameter; also 135 | # Injector.create_object doesn't support extra positional arguments anyway). 136 | if 'api' in class_kwargs: 137 | raise AssertionError('api keyword argument is reserved') 138 | 139 | class_kwargs['api'] = flask_restful_api 140 | 141 | # This section is flask.views.View.as_view code modified to make the injection 142 | # possible without relying on modifying view function in place 143 | # Copyright (c) 2014 by Armin Ronacher and Flask contributors, see Flask 144 | # license for details 145 | 146 | def view(*args: Any, **kwargs: Any) -> Any: 147 | self = injector.create_object(cls, additional_kwargs=class_kwargs) 148 | return self.dispatch_request(*args, **kwargs) 149 | 150 | if cls.decorators: 151 | view.__name__ = name 152 | view.__module__ = cls.__module__ 153 | for decorator in cls.decorators: 154 | view = decorator(view) 155 | 156 | # We attach the view class to the view function for two reasons: 157 | # first of all it allows us to easily figure out what class-based 158 | # view this thing came from, secondly it's also used for instantiating 159 | # the view class so you can actually replace it with something else 160 | # for testing purposes and debugging. 161 | cast(Any, view).view_class = cls 162 | view.__name__ = name 163 | view.__doc__ = cls.__doc__ 164 | view.__module__ = cls.__module__ 165 | cast(Any, view).methods = cls.methods 166 | 167 | fun = view 168 | 169 | if flask_restful_api: 170 | return wrap_flask_restful_resource(fun, flask_restful_api, injector) 171 | 172 | return fun 173 | 174 | 175 | def wrap_flask_restful_resource( 176 | fun: Callable, flask_restful_api: FlaskRestfulApi, injector: Injector 177 | ) -> Callable: 178 | """ 179 | This is needed because of how flask_restful views are registered originally. 180 | 181 | :type flask_restful_api: :class:`flask_restful.Api` 182 | """ 183 | # The following fragment of code is copied from flask_restful project 184 | 185 | """ 186 | Copyright (c) 2013, Twilio, Inc. 187 | All rights reserved. 188 | 189 | Redistribution and use in source and binary forms, with or without 190 | modification, are permitted provided that the following conditions are met: 191 | 192 | - Redistributions of source code must retain the above copyright notice, this 193 | list of conditions and the following disclaimer. 194 | - Redistributions in binary form must reproduce the above copyright notice, 195 | this list of conditions and the following disclaimer in the documentation 196 | and/or other materials provided with the distribution. 197 | - Neither the name of the Twilio, Inc. nor the names of its contributors may be 198 | used to endorse or promote products derived from this software without 199 | specific prior written permission. 200 | 201 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 202 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 203 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 204 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 205 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 206 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 207 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 208 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 209 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 210 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 211 | """ 212 | 213 | @functools.wraps(fun) 214 | def wrapper(*args: Any, **kwargs: Any) -> Any: 215 | resp = fun(*args, **kwargs) 216 | if isinstance(resp, Response): # There may be a better way to test 217 | return resp 218 | data, code, headers = flask_response_unpack(resp) 219 | return flask_restful_api.make_response(data, code, headers=headers) 220 | 221 | # end of flask_restful code 222 | 223 | return wrapper 224 | 225 | 226 | class CachedProviderWrapper(Provider): 227 | def __init__(self, old_provider: Provider) -> None: 228 | self._old_provider = old_provider 229 | self._cache = {} # type: Dict[int, Any] 230 | 231 | def get(self, injector: Injector) -> Any: 232 | key = id(injector) 233 | try: 234 | return self._cache[key] 235 | except KeyError: 236 | instance = self._cache[key] = self._old_provider.get(injector) 237 | return instance 238 | 239 | 240 | class RequestScope(Scope): 241 | """A scope whose object lifetime is tied to a request. 242 | 243 | @request 244 | class Session: 245 | pass 246 | """ 247 | 248 | # We don't want to assign here, just provide type hints 249 | if False: 250 | _local_manager = None # type: LocalManager 251 | _locals = None # type: Any 252 | 253 | def cleanup(self) -> None: 254 | self._local_manager.cleanup() 255 | 256 | def prepare(self) -> None: 257 | self._locals.scope = {} 258 | 259 | def configure(self) -> None: 260 | self._locals = Local() 261 | self._local_manager = LocalManager([self._locals]) 262 | self.prepare() 263 | 264 | def get(self, key: Any, provider: Provider) -> Any: 265 | try: 266 | return self._locals.scope[key] 267 | except KeyError: 268 | new_provider = self._locals.scope[key] = CachedProviderWrapper(provider) 269 | return new_provider 270 | 271 | 272 | request = ScopeDecorator(RequestScope) 273 | 274 | 275 | _ModuleT = Union[Callable[[Binder], Any], Module] 276 | 277 | 278 | class FlaskInjector: 279 | def __init__( 280 | self, 281 | app: flask.Flask, 282 | modules: Iterable[_ModuleT] = [], 283 | injector: Injector = None, 284 | request_scope_class: type = RequestScope, 285 | ) -> None: 286 | """Initializes Injector for the application. 287 | 288 | .. note:: 289 | 290 | Needs to be called *after* all views, signal handlers, template globals 291 | and context processors are registered. 292 | 293 | :param app: Application to configure 294 | :param modules: Configuration for newly created :class:`injector.Injector` 295 | :param injector: Injector to initialize app with, if not provided 296 | a new instance will be created. 297 | :type app: :class:`flask.Flask` 298 | :type modules: Iterable of configuration modules 299 | :rtype: :class:`injector.Injector` 300 | """ 301 | if not injector: 302 | injector = Injector() 303 | 304 | modules = list(modules) 305 | modules.insert(0, FlaskModule(app=app, request_scope_class=request_scope_class)) 306 | for module in modules: 307 | injector.binder.install(module) 308 | 309 | for container in ( 310 | app.view_functions, 311 | app.before_request_funcs, 312 | app.after_request_funcs, 313 | app.teardown_request_funcs, 314 | app.template_context_processors, 315 | app.jinja_env.globals, 316 | app.error_handler_spec, 317 | ): 318 | process_dict(container, injector) 319 | 320 | # This is to make sure that mypy sees a non-nullable variable 321 | # in the closures below, otherwise it'd complain that injector 322 | # union may not have get attribute 323 | injector_not_null = injector 324 | 325 | def reset_request_scope_before(*args: Any, **kwargs: Any) -> None: 326 | injector_not_null.get(request_scope_class).prepare() 327 | 328 | def global_reset_request_scope_after(*args: Any, **kwargs: Any) -> None: 329 | blueprint = flask.request.blueprint 330 | # If current blueprint has teardown_request_funcs associated with it we know there may be 331 | # a some teardown request handlers we need to inject into, so we can't reset the scope just yet. 332 | # We'll leave it to blueprint_reset_request_scope_after to do the job which we know will run 333 | # later and we know it'll run after any teardown_request handlers we may want to inject into. 334 | if blueprint is None or blueprint not in app.teardown_request_funcs: 335 | injector_not_null.get(request_scope_class).cleanup() 336 | 337 | def blueprint_reset_request_scope_after(*args: Any, **kwargs: Any) -> None: 338 | # If we got here we truly know this is the last teardown handler, which means we can reset the 339 | # scope unconditionally. 340 | injector_not_null.get(request_scope_class).cleanup() 341 | 342 | app.before_request_funcs.setdefault(None, []).insert(0, reset_request_scope_before) 343 | # We're accessing Flask internals here as the app.teardown_request decorator appends to a list of 344 | # handlers but Flask itself reverses the list when it executes them. To allow injecting request-scoped 345 | # dependencies into teardown_request handlers we need to run our teardown_request handler after them. 346 | # Also see https://github.com/alecthomas/flask_injector/issues/42 where it was reported. 347 | # Secondly, we need to handle blueprints. Flask first executes non-blueprint teardown handlers in 348 | # reverse order and only then executes blueprint-associated teardown handlers in reverse order, 349 | # which means we can't just set on non-blueprint teardown handler, but we need to set both. 350 | # In non-blueprint teardown handler we check if a blueprint handler will run – if so, we do nothing 351 | # there and leave it to the blueprint teardown handler. 352 | # 353 | # We need the None key to be present in the dictionary so that the dictionary iteration always yields 354 | # None as well. We *always* have to set the global teardown request. 355 | app.teardown_request_funcs.setdefault(None, []).insert(0, global_reset_request_scope_after) 356 | for bp, functions in app.teardown_request_funcs.items(): 357 | if bp is not None: 358 | functions.insert(0, blueprint_reset_request_scope_after) 359 | 360 | self.injector = injector_not_null 361 | self.app = app 362 | 363 | 364 | def process_dict(d: Dict, injector: Injector) -> None: 365 | for key, value in d.items(): 366 | if isinstance(value, LocalProxy): 367 | # We need this no-op here, because if we have a LocalProxy and try to use isinstance() on it 368 | # we'll get a RuntimeError 369 | pass 370 | elif isinstance(value, list): 371 | process_list(value, injector) 372 | elif hasattr(value, '__call__'): 373 | d[key] = wrap_fun(value, injector) 374 | elif isinstance(value, dict): 375 | process_dict(value, injector) 376 | 377 | 378 | def process_list(l: List, injector: Injector) -> None: 379 | # This function mutates the l parameter 380 | l[:] = [wrap_fun(fun, injector) for fun in l] 381 | 382 | 383 | class FlaskModule(Module): 384 | def __init__(self, app: flask.Flask, request_scope_class: type = RequestScope) -> None: 385 | self.app = app 386 | self.request_scope_class = request_scope_class 387 | 388 | def configure(self, binder: Binder) -> None: 389 | binder.bind(flask.Flask, to=self.app, scope=singleton) 390 | binder.bind(Config, to=self.app.config, scope=singleton) 391 | binder.bind(Request, to=lambda: flask.request) 392 | -------------------------------------------------------------------------------- /flask_injector/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-injector/flask_injector/004b9b52727e9699d23168a600c490ee5de7a395/flask_injector/py.typed -------------------------------------------------------------------------------- /flask_injector/tests.py: -------------------------------------------------------------------------------- 1 | import gc 2 | import json 3 | import warnings 4 | from functools import partial 5 | from typing import NewType 6 | 7 | import flask_restful 8 | import flask_restx 9 | from eventlet import greenthread 10 | from injector import __version__ as injector_version, CallableProvider, inject, Scope 11 | from flask import Blueprint, Flask 12 | from flask.templating import render_template_string 13 | from flask.views import View 14 | 15 | from flask_injector import request, FlaskInjector 16 | 17 | 18 | def test_injections(): 19 | l = [1, 2, 3] 20 | counter = [0] 21 | 22 | def inc(): 23 | counter[0] += 1 24 | 25 | def conf(binder): 26 | binder.bind(str, to="something") 27 | binder.bind(list, to=l) 28 | 29 | app = Flask(__name__) 30 | 31 | @app.route('/view1') 32 | def view1(content: str): 33 | inc() 34 | return render_template_string(content) 35 | 36 | class View2(View): 37 | @inject 38 | def __init__(self, *args, content: list, **kwargs): 39 | self.content = content 40 | super().__init__(*args, **kwargs) 41 | 42 | def dispatch_request(self): 43 | inc() 44 | return render_template_string('%s' % self.content) 45 | 46 | @app.before_request 47 | def br(c: list): 48 | inc() 49 | assert c == l 50 | 51 | @app.after_request 52 | def ar(response_class, c: list): 53 | inc() 54 | assert c == l 55 | return response_class 56 | 57 | @app.context_processor 58 | def cp(c: list): 59 | inc() 60 | assert c == l 61 | return {} 62 | 63 | @app.teardown_request 64 | def tr(sender, exc=None, c: list = None): 65 | inc() 66 | assert c == l 67 | 68 | app.add_url_rule('/view2', view_func=View2.as_view('view2')) 69 | 70 | FlaskInjector(app=app, modules=[conf]) 71 | 72 | client = app.test_client() 73 | response = client.get('/view1') 74 | assert response.get_data(as_text=True) == "something" 75 | 76 | response = client.get('/view2') 77 | assert response.get_data(as_text=True) == '%s' % (l,) 78 | 79 | assert counter[0] == 10 80 | 81 | 82 | def test_resets(): 83 | app = Flask(__name__) 84 | 85 | counter = [0] 86 | 87 | class OurScope(Scope): 88 | def __init__(self, injector): 89 | pass 90 | 91 | def prepare(self): 92 | pass 93 | 94 | def cleanup(self): 95 | counter[0] += 1 96 | 97 | @app.route('/') 98 | def index(): 99 | return 'asd' 100 | 101 | FlaskInjector(app, request_scope_class=OurScope) 102 | 103 | assert counter[0] == 0 104 | 105 | client = app.test_client() 106 | client.get('/') 107 | 108 | assert counter[0] == 1 109 | 110 | 111 | def test_memory_leak(): 112 | # The RequestScope holds references to GreenThread objects which would 113 | # cause memory leak 114 | 115 | # More explanation below 116 | # 117 | # In Werkzeug locals are indexed using values returned by ``get_ident`` function: 118 | # 119 | # try: 120 | # from greenlet import getcurrent as get_ident 121 | # except ImportError: 122 | # try: 123 | # from thread import get_ident 124 | # except ImportError: 125 | # from _thread import get_ident 126 | # 127 | # This is what LocalManager.cleanup runs indirectly (__ident_func__ 128 | # points to get_ident unless it's overridden): 129 | # 130 | # self.__storage__.pop(self.__ident_func__(), None) 131 | 132 | # If something's assigned in local storage *after* the cleanup is done an entry 133 | # in internal storage under "the return value of get_ident()" key is recreated 134 | # and a reference to the key will be kept forever. 135 | # 136 | # This is not strictly related to Eventlet/GreenThreads but that's how 137 | # the issue manifested itself so the test reflects that. 138 | app = Flask(__name__) 139 | 140 | FlaskInjector(app) 141 | 142 | @app.route('/') 143 | def index(): 144 | return 'test' 145 | 146 | def get_request(): 147 | with app.test_client() as c: 148 | c.get('/') 149 | 150 | green_thread = greenthread.spawn(get_request) 151 | green_thread.wait() 152 | # Delete green_thread so the GreenThread object is dereferenced 153 | del green_thread 154 | # Force run garbage collect to make sure GreenThread object is collected if 155 | # there is no memory leak 156 | gc.collect() 157 | greenthread_count = len([obj for obj in gc.get_objects() if type(obj) is greenthread.GreenThread]) 158 | 159 | assert greenthread_count == 0 160 | 161 | 162 | def test_doesnt_raise_deprecation_warning(): 163 | app = Flask(__name__) 164 | 165 | def provide_str(): 166 | return 'this is string' 167 | 168 | def configure(binder): 169 | binder.bind(str, to=CallableProvider(provide_str), scope=request) 170 | 171 | @app.route('/') 172 | def index(s: str): 173 | return s 174 | 175 | FlaskInjector(app=app, modules=[configure]) 176 | 177 | with warnings.catch_warnings(record=True) as w: 178 | warnings.simplefilter("always") 179 | with app.test_client() as c: 180 | c.get('/') 181 | assert len(w) == 0, map(str, w) 182 | 183 | 184 | def test_jinja_env_globals_support_injection(): 185 | app = Flask(__name__) 186 | 187 | def configure(binder): 188 | binder.bind(str, to='xyz') 189 | 190 | def do_something_helper(s: str): 191 | return s 192 | 193 | app.jinja_env.globals['do_something'] = do_something_helper 194 | 195 | @app.route('/') 196 | def index(): 197 | return render_template_string('{{ do_something() }}') 198 | 199 | FlaskInjector(app=app, modules=[configure]) 200 | 201 | with app.test_client() as c: 202 | assert c.get('/').get_data(as_text=True) == 'xyz' 203 | 204 | 205 | def test_error_handlers_support_injection(): 206 | app = Flask(__name__) 207 | 208 | class CustomException(Exception): 209 | pass 210 | 211 | @app.route('/custom-exception') 212 | def custom_exception(): 213 | raise CustomException() 214 | 215 | @app.errorhandler(404) 216 | def handle_404(error, s: str): 217 | return s, 404 218 | 219 | @app.errorhandler(CustomException) 220 | def handle_custom_exception(error, s: str): 221 | return s, 500 222 | 223 | def configure(binder): 224 | binder.bind(str, to='injected content') 225 | 226 | FlaskInjector(app=app, modules=[configure]) 227 | 228 | with app.test_client() as c: 229 | response = c.get('/this-page-does-not-exist') 230 | assert (response.status_code, response.get_data(as_text=True)) == (404, 'injected content') 231 | 232 | response = c.get('/custom-exception') 233 | assert (response.status_code, response.get_data(as_text=True)) == (500, 'injected content') 234 | 235 | 236 | def test_view_functions_arent_modified_globally(): 237 | # Connected to GH #6 "Doing multiple requests on a flask test client on an injected route 238 | # fails for all but the first request" 239 | # The code would modify view functions generated by View.as_view(), it wasn't an issue with 240 | # views added directly to an application but if function was added to a blueprint and 241 | # that blueprint was used in multiple applications it'd raise an error 242 | 243 | class MyView(View): 244 | pass 245 | 246 | blueprint = Blueprint('test', __name__) 247 | blueprint.add_url_rule('/', view_func=MyView.as_view('view')) 248 | 249 | app = Flask(__name__) 250 | app.register_blueprint(blueprint) 251 | FlaskInjector(app=app) 252 | 253 | app2 = Flask(__name__) 254 | app2.register_blueprint(blueprint) 255 | 256 | # it'd fail here 257 | FlaskInjector(app=app2) 258 | 259 | 260 | def test_view_args_and_class_args_are_passed_to_class_based_views(): 261 | class MyView(View): 262 | def __init__(self, class_arg): 263 | self.class_arg = class_arg 264 | 265 | def dispatch_request(self, dispatch_arg): 266 | return '%s %s' % (self.class_arg, dispatch_arg) 267 | 268 | app = Flask(__name__) 269 | app.add_url_rule('/', view_func=MyView.as_view('view', class_arg='aaa')) 270 | 271 | FlaskInjector(app=app) 272 | 273 | client = app.test_client() 274 | response = client.get('/bbb') 275 | print(response.data) 276 | assert response.data == b'aaa bbb' 277 | 278 | 279 | def test_flask_restful_integration_works(): 280 | class HelloWorld(flask_restful.Resource): 281 | @inject 282 | def __init__(self, *args, int: int, **kwargs): 283 | self._int = int 284 | super().__init__(*args, **kwargs) 285 | 286 | def get(self): 287 | return {'int': self._int} 288 | 289 | app = Flask(__name__) 290 | api = flask_restful.Api(app) 291 | 292 | api.add_resource(HelloWorld, '/') 293 | 294 | FlaskInjector(app=app) 295 | 296 | client = app.test_client() 297 | response = client.get('/') 298 | data = json.loads(response.data.decode('utf-8')) 299 | assert data == {'int': 0} 300 | 301 | 302 | def test_flask_restx_integration_works(): 303 | app = Flask(__name__) 304 | api = flask_restx.Api(app) 305 | 306 | class HelloWorld(flask_restx.Resource): 307 | @inject 308 | def __init__(self, *args, int: int, **kwargs): 309 | self._int = int 310 | super().__init__(*args, **kwargs) 311 | 312 | @api.doc() 313 | def get(self): 314 | return {'int': self._int} 315 | 316 | api.add_resource(HelloWorld, '/hello') 317 | 318 | FlaskInjector(app=app) 319 | 320 | client = app.test_client() 321 | response = client.get('/hello') 322 | data = json.loads(response.data.decode('utf-8')) 323 | assert data == {'int': 0} 324 | 325 | 326 | def test_request_scope_not_started_before_any_request_made_regression(): 327 | # Version 0.6.1 (patch cacaef6 specifially) broke backwards compatibility in 328 | # a relatively subtle way. The code used to support RequestScope even in 329 | # the thread that originally created the Injector object. After cacaef6 an 330 | # "AttributeError: scope" exception would be raised. 331 | # 332 | # For compatibility reason I'll restore the old behaviour, we can 333 | # deprecate it later if needed 334 | 335 | def configure(binder): 336 | binder.bind(str, to='this is string', scope=request) 337 | 338 | app = Flask(__name__) 339 | flask_injector = FlaskInjector(app=app, modules=[configure]) 340 | assert flask_injector.injector.get(str) == 'this is string' 341 | 342 | 343 | def test_noninstrospectable_hooks_dont_crash_everything(): 344 | app = Flask(__name__) 345 | 346 | def do_nothing(): 347 | pass 348 | 349 | app.before_request(partial(do_nothing)) 350 | 351 | # It'd crash here 352 | FlaskInjector(app=app) 353 | 354 | 355 | def test_instance_methods(): 356 | class HelloWorldService: 357 | def get_value(self): 358 | return "test message 1" 359 | 360 | class HelloWorld: 361 | def from_injected_service(self, service: HelloWorldService): 362 | return service.get_value() 363 | 364 | def static_value(self): 365 | return "test message 2" 366 | 367 | app = Flask(__name__) 368 | hello_world = HelloWorld() 369 | app.add_url_rule('/from_injected_service', 'from_injected_service', hello_world.from_injected_service) 370 | app.add_url_rule('/static_value', 'static_value', hello_world.static_value) 371 | 372 | FlaskInjector(app=app) 373 | 374 | client = app.test_client() 375 | response = client.get('/from_injected_service') 376 | assert response.data.decode('utf-8') == "test message 1" 377 | 378 | response = client.get('/static_value') 379 | assert response.data.decode('utf-8') == "test message 2" 380 | 381 | 382 | if injector_version >= '0.12': 383 | 384 | def test_forward_references_work(): 385 | app = Flask(__name__) 386 | 387 | @app.route('/') 388 | def index(x: 'X'): 389 | return x.message 390 | 391 | FlaskInjector(app=app) 392 | 393 | # The class needs to be module-global in order for the string -> object 394 | # resolution mechanism to work. I could make it work with locals but it 395 | # doesn't seem worth it. 396 | global X 397 | 398 | class X: 399 | def __init__(self) -> None: 400 | self.message = 'Hello World' 401 | 402 | try: 403 | client = app.test_client() 404 | response = client.get('/') 405 | assert response.data.decode() == 'Hello World' 406 | finally: 407 | del X 408 | 409 | 410 | def test_request_scope_covers_teardown_request_handlers(): 411 | app = Flask(__name__) 412 | UserID = NewType('UserID', int) 413 | 414 | @app.route('/') 415 | def index(): 416 | return 'hello' 417 | 418 | @app.teardown_request 419 | def on_teardown(exc, user_id: UserID): 420 | assert user_id == 321 421 | 422 | def configure(binder): 423 | binder.bind(UserID, to=321, scope=request) 424 | 425 | FlaskInjector(app=app, modules=[configure]) 426 | client = app.test_client() 427 | response = client.get('/') 428 | assert response.data.decode() == 'hello' 429 | 430 | 431 | def test_request_scope_covers_blueprint_teardown_request_handlers(): 432 | app = Flask(__name__) 433 | UserID = NewType('UserID', int) 434 | blueprint = Blueprint('blueprint', __name__) 435 | 436 | @blueprint.route('/') 437 | def index(): 438 | return 'hello' 439 | 440 | @blueprint.teardown_request 441 | def on_teardown(exc, user_id: UserID): 442 | assert user_id == 321 443 | 444 | def configure(binder): 445 | binder.bind(UserID, to=321, scope=request) 446 | 447 | app.register_blueprint(blueprint) 448 | FlaskInjector(app=app, modules=[configure]) 449 | client = app.test_client() 450 | response = client.get('/') 451 | assert response.data.decode() == 'hello' 452 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 110 3 | target_version = ['py36', 'py37'] 4 | skip_string_normalization = true 5 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black;implementation_name=="cpython" 2 | coverage 3 | eventlet 4 | flake8 5 | flask 6 | flask_restful 7 | flask_restx 8 | flask_sqlalchemy 9 | injector 10 | mypy;implementation_name=="cpython" 11 | pytest 12 | werkzeug 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [egg_info] 2 | tag_build = 3 | tag_date = 0 4 | tag_svn_revision = 0 5 | 6 | [wheel] 7 | universal = 1 8 | 9 | [flake8] 10 | ignore = E741,W503 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | long_description = open('README.rst').read() 5 | 6 | # very convoluted way of reading version from the module without importing it 7 | version = ( 8 | [l for l in open('flask_injector/__init__.py') if '__version__ = ' in l][0] 9 | .split('=')[-1] 10 | .strip() 11 | .strip('\'') 12 | ) 13 | 14 | if __name__ == '__main__': 15 | setup( 16 | name='Flask-Injector', 17 | version=version, 18 | url='https://github.com/alecthomas/flask_injector', 19 | license='BSD', 20 | author='Alec Thomas', 21 | author_email='alec@swapoff.org', 22 | description='Adds Injector, a Dependency Injection framework, support to Flask.', 23 | long_description=long_description, 24 | packages=['flask_injector'], 25 | package_data={'flask_injector': ['py.typed']}, 26 | zip_safe=True, 27 | platforms='any', 28 | install_requires=['Flask>=2.2.0', 'injector>=0.20.0', 'typing; python_version < "3.5"'], 29 | keywords=['Dependency Injection', 'Flask'], 30 | classifiers=[ 31 | 'Development Status :: 5 - Production/Stable', 32 | 'Intended Audience :: Developers', 33 | 'License :: OSI Approved :: MIT License', 34 | 'Operating System :: OS Independent', 35 | 'Topic :: Software Development :: Testing', 36 | 'Topic :: Software Development :: Libraries', 37 | 'Topic :: Utilities', 38 | 'Framework :: Flask', 39 | 'Programming Language :: Python', 40 | 'Programming Language :: Python :: 3', 41 | 'Programming Language :: Python :: 3.7', 42 | 'Programming Language :: Python :: 3.8', 43 | 'Programming Language :: Python :: 3.9', 44 | 'Programming Language :: Python :: 3.10', 45 | 'Programming Language :: Python :: Implementation :: CPython', 46 | ], 47 | ) 48 | --------------------------------------------------------------------------------