├── .gitattributes ├── tests ├── static │ ├── test.file │ ├── decode me.txt │ ├── python.png │ └── test.html ├── test_plugin_pickle.py ├── conftest.py ├── test_plugin_log.py ├── test_spf_singleton.py ├── test_plugin_asgi.py ├── test_plugin_urlfor.py ├── test_plugin_websocket.py ├── test_plugin_signal_handlers.py ├── test_plugin_registration.py ├── test_contextualize_plugin.py ├── test_plugin_route.py ├── test_context.py ├── test_plugin_middleware.py ├── test_plugin_exception.py └── test_plugin_static.py ├── examples ├── example_stk.ini ├── my_blueprint.py ├── view_example.py ├── blueprint_example.py ├── example.py ├── websocket_test.py ├── spf_config_example.py ├── contextualize_sqalchemy_example.py ├── contextualize_example.py ├── my_plugin.py └── single_file.py ├── .flake8 ├── .coveragerc ├── MANIFEST.in ├── .gitignore ├── sanic_plugin_toolkit ├── plugins │ ├── __init__.py │ └── contextualize.py ├── __init__.py ├── config.py ├── context.py ├── plugin.py └── realm.py ├── environment.yml ├── .editorconfig ├── LICENSE.txt ├── .travis.yml ├── Makefile ├── pyproject.toml ├── README.rst └── CHANGELOG.rst /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /tests/static/test.file: -------------------------------------------------------------------------------- 1 | I am just a regular static file 2 | -------------------------------------------------------------------------------- /examples/example_stk.ini: -------------------------------------------------------------------------------- 1 | [plugins] 2 | Contextualize 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/static/decode me.txt: -------------------------------------------------------------------------------- 1 | I am just a regular static file that needs to have its uri decoded 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 119 3 | select = C,E,F,W,B,B950 4 | ignore = E501,W503,E203 5 | -------------------------------------------------------------------------------- /tests/static/python.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleysommer/sanic-plugin-toolkit/HEAD/tests/static/python.png -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = sanic_plugin_toolkit 4 | omit = site-packages 5 | 6 | [html] 7 | directory = coverage 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include pyproject.toml 2 | include LICENSE.txt 3 | include CHANGELOG.rst 4 | include README.rst 5 | include MANIFEST.in 6 | include Makefile 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | venv/ 3 | *.pyc 4 | __pycache__ 5 | test1.py 6 | .cache/ 7 | .coverage 8 | .tox/ 9 | *.egg-info/ 10 | .venv/ 11 | dist/ 12 | coverage/ 13 | -------------------------------------------------------------------------------- /sanic_plugin_toolkit/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | from .contextualize import Contextualize, contextualize 4 | 5 | 6 | __all__ = ('Contextualize', 'contextualize') 7 | -------------------------------------------------------------------------------- /examples/my_blueprint.py: -------------------------------------------------------------------------------- 1 | from sanic import Blueprint 2 | 3 | api_v1 = Blueprint(__name__, None) 4 | 5 | 6 | @api_v1.middleware(attach_to="request") 7 | async def bp_mw(request): 8 | print("Hello bp") 9 | 10 | __all__ = ['api_v1'] 11 | -------------------------------------------------------------------------------- /sanic_plugin_toolkit/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: latin-1 -*- 2 | # this is ascii, no unicode in this document 3 | from .plugin import SanicPlugin 4 | from .realm import SanicPluginRealm 5 | 6 | 7 | __version__ = '1.2.1' 8 | __all__ = ["SanicPlugin", "SanicPluginRealm", "__version__"] 9 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: py35 2 | dependencies: 3 | - openssl=1.0.2g=0 4 | - pip=9.0.1=py35_0 5 | - python=3.5.2=0 6 | - readline=6.2=2 7 | - setuptools=20.3=py35_0 8 | - sqlite=3.9.2=0 9 | - tk=8.5.18=0 10 | - wheel=0.29.0=py35_0 11 | - xz=5.0.5=1 12 | - zlib=1.2.8=0 13 | - pip: 14 | - uvloop>=0.5.3 15 | - httptools>=0.0.9 16 | - ujson>=1.35 17 | - aiofiles>=0.3.0 18 | - websockets>=3.2 19 | -------------------------------------------------------------------------------- /examples/view_example.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic.response import text 3 | 4 | from examples import my_plugin 5 | 6 | 7 | app = Sanic(__name__) 8 | 9 | 10 | @app.route('/', methods={'GET', 'OPTIONS'}) 11 | @my_plugin.decorate(app) 12 | def index(request, context): 13 | return text("hello world") 14 | 15 | 16 | if __name__ == "__main__": 17 | app.run("127.0.0.1", port=8098, debug=True, auto_reload=False) 18 | -------------------------------------------------------------------------------- /examples/blueprint_example.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic.response import text 3 | from sanic_plugin_toolkit import SanicPluginRealm 4 | from examples import my_plugin 5 | from examples import my_blueprint 6 | 7 | app = Sanic(__name__) 8 | # mp = MyPlugin(app) //Legacy registration example 9 | realm = SanicPluginRealm(my_blueprint.api_v1) 10 | 11 | realm.register_plugin(my_plugin) 12 | 13 | app.blueprint(my_blueprint.api_v1) 14 | 15 | @app.route('/') 16 | def index(request): 17 | return text("hello world") 18 | 19 | 20 | if __name__ == "__main__": 21 | app.run("127.0.0.1", port=8098, debug=True, auto_reload=False) 22 | -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic.response import text 3 | from sanic_plugin_toolkit import SanicPluginRealm 4 | #from examples.my_plugin import my_plugin 5 | from examples import my_plugin 6 | from examples.my_plugin import MyPlugin 7 | from logging import DEBUG 8 | 9 | app = Sanic(__name__) 10 | # mp = MyPlugin(app) //Legacy registration example 11 | realm = SanicPluginRealm(app) 12 | my_plugin = realm.register_plugin(my_plugin) 13 | 14 | 15 | @app.route('/') 16 | def index(request): 17 | return text("hello world") 18 | 19 | 20 | if __name__ == "__main__": 21 | app.run("127.0.0.1", port=8098, debug=True, auto_reload=False) 22 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: 2 | # http://EditorConfig.org 3 | 4 | # top-most EditorConfig file 5 | root = true 6 | 7 | # Unix-style newlines with a newline ending every file 8 | [*] 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.{md, rst}] 14 | charset = utf-8 15 | trim_trailing_whitespace = false 16 | 17 | # Matches multiple files with brace expansion notation 18 | # Set default charset 19 | [*.{py, toml}] 20 | charset = utf-8 21 | 22 | # 4 space indentation 23 | [*.py] 24 | indent_style = space 25 | indent_size = 4 26 | max_line_length = 119 27 | 28 | # tab indentation 29 | [Makefile] 30 | indent_style = tab 31 | -------------------------------------------------------------------------------- /tests/static/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
 4 |                  ▄▄▄▄▄
 5 |         ▀▀▀██████▄▄▄       _______________
 6 |       ▄▄▄▄▄  █████████▄  /                 \
 7 |      ▀▀▀▀█████▌ ▀▐▄ ▀▐█ |   Gotta go fast!  |
 8 |    ▀▀█████▄▄ ▀██████▄██ | _________________/
 9 |    ▀▄▄▄▄▄  ▀▀█▄▀█════█▀ |/
10 |         ▀▀▀▄  ▀▀███ ▀       ▄▄
11 |      ▄███▀▀██▄████████▄ ▄▀▀▀▀▀▀█▌
12 |    ██▀▄▄▄██▀▄███▀ ▀▀████      ▄██
13 | ▄▀▀▀▄██▄▀▀▌████▒▒▒▒▒▒███     ▌▄▄▀
14 | ▌    ▐▀████▐███▒▒▒▒▒▐██▌
15 | ▀▄▄▄▄▀   ▀▀████▒▒▒▒▄██▀
16 |           ▀▀█████████▀
17 |         ▄▄██▀██████▀█
18 |       ▄██▀     ▀▀▀  █
19 |      ▄█             ▐▌
20 |  ▄▄▄▄█▌              ▀█▄▄▄▄▀▀▄
21 | ▌     ▐                ▀▀▄▄▄▀
22 |  ▀▀▄▄▀
23 | 
24 | 
25 | 26 | 27 | -------------------------------------------------------------------------------- /tests/test_plugin_pickle.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from sanic.response import text 3 | from sanic.exceptions import NotFound 4 | from sanic_plugin_toolkit import SanicPlugin 5 | 6 | 7 | class TestPlugin(SanicPlugin): 8 | pass 9 | 10 | 11 | instance = test_plugin = TestPlugin() 12 | 13 | @test_plugin.route('/t1') 14 | def t1(request): 15 | return text("t1") 16 | 17 | @test_plugin.exception(NotFound) 18 | def not_found(request): 19 | return text("404") 20 | 21 | def test_plugin_pickle_unpickle(realm): 22 | app = realm._app 23 | p1 = pickle.dumps(test_plugin) 24 | p2 = pickle.loads(p1) 25 | realm.register_plugin(p2) 26 | client = app._test_manager.test_client 27 | resp = client.get('/t1') 28 | assert resp[1].text == 't1' 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pytest_asyncio 3 | 4 | from sanic import Blueprint, Sanic 5 | from sanic_testing import TestManager 6 | 7 | from sanic_plugin_toolkit import SanicPluginRealm 8 | 9 | 10 | def app_with_name(name): 11 | return Sanic(name) 12 | 13 | 14 | @pytest.fixture 15 | def app(request): 16 | return app_with_name(request.node.name) 17 | 18 | 19 | @pytest.fixture 20 | def realm(request): 21 | a = app_with_name(request.node.name) 22 | manager = TestManager(a) 23 | return SanicPluginRealm(a) 24 | 25 | 26 | @pytest.fixture 27 | def realm_bp(request): 28 | a = app_with_name(request.node.name) 29 | b = Blueprint("TestBP", "blueprint") 30 | realm = SanicPluginRealm(b) 31 | manager = TestManager(a) 32 | return realm, a 33 | -------------------------------------------------------------------------------- /tests/test_plugin_log.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic.response import text, redirect 3 | from sanic_plugin_toolkit import SanicPlugin, SanicPluginRealm 4 | from functools import partial 5 | import logging 6 | 7 | class TestPlugin(SanicPlugin): 8 | pass 9 | 10 | 11 | instance = test_plugin = TestPlugin() 12 | 13 | @test_plugin.route('/t1', with_context=True) 14 | def t1(request, context): 15 | log = context.log 16 | log(logging.INFO, "hello world") 17 | debug = partial(log, logging.DEBUG) 18 | debug("hello debug") 19 | return text("t1") 20 | 21 | 22 | def test_plugin_log1(realm): 23 | app = realm._app 24 | plugin = realm.register_plugin(test_plugin) 25 | client = app._test_manager.test_client 26 | exceptions = None 27 | try: 28 | resp = client.get('/t1') 29 | except Exception as e: 30 | exceptions = e 31 | assert exceptions is None 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/test_spf_singleton.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic_plugin_toolkit import SanicPlugin, SanicPluginRealm 3 | 4 | 5 | class TestPlugin(SanicPlugin): 6 | pass 7 | 8 | 9 | instance = TestPlugin() 10 | 11 | def test_spf_singleton_1(realm): 12 | """ 13 | Registering the toolkit twice on the same app should return 14 | an indentical instance of the realm 15 | :return: 16 | """ 17 | app1 = realm._app 18 | realm.register_plugin(instance) 19 | realm2 = SanicPluginRealm(app1) 20 | assert realm == realm2 21 | 22 | def test_spf_singleton_2(realm): 23 | """ 24 | Registering the toolkit twice, but with different apps should return 25 | two different spfs 26 | :return: 27 | """ 28 | app1 = realm._app 29 | app2 = Sanic('test_realm_singleton_2_1') 30 | realm.register_plugin(instance) 31 | realm2 = SanicPluginRealm(app2) 32 | assert realm != realm2 33 | -------------------------------------------------------------------------------- /tests/test_plugin_asgi.py: -------------------------------------------------------------------------------- 1 | from distutils.version import LooseVersion 2 | 3 | import pytest 4 | 5 | from sanic import __version__ as sanic_version 6 | from sanic.response import text 7 | 8 | from sanic_plugin_toolkit import SanicPlugin 9 | 10 | 11 | SANIC_VERSION = LooseVersion(sanic_version) 12 | 13 | if LooseVersion("19.6.3") <= SANIC_VERSION: 14 | 15 | class TestPlugin(SanicPlugin): 16 | pass 17 | 18 | @pytest.mark.asyncio 19 | async def test_request_class_regular(realm): 20 | app = realm._app 21 | test_plugin = TestPlugin() 22 | 23 | def regular_request(request): 24 | return text(request.__class__.__name__) 25 | 26 | test_plugin.route("/regular", methods=('GET',))(regular_request) 27 | realm.register_plugin(test_plugin) 28 | 29 | _, response = await app._test_manager.asgi_client.get("/regular") 30 | assert response.body == b"Request" 31 | -------------------------------------------------------------------------------- /examples/websocket_test.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | from sanic import Sanic 4 | from sanic_plugin_toolkit import SanicPlugin, SanicPluginRealm 5 | from sanic.response import text 6 | 7 | from logging import DEBUG 8 | 9 | 10 | class MyPlugin(SanicPlugin): 11 | 12 | def __init__(self, *args, **kwargs): 13 | super(MyPlugin, self).__init__(*args, **kwargs) 14 | 15 | 16 | instance = MyPlugin() 17 | 18 | @instance.middleware(priority=6, with_context=True, attach_to="cleanup") 19 | def mw1(request, context): 20 | context['test1'] = "test" 21 | print("Doing Cleanup!") 22 | 23 | 24 | app = Sanic(__name__) 25 | realm = SanicPluginRealm(app) 26 | assoc_reg = realm.register_plugin(instance) 27 | 28 | @app.route('/') 29 | def index(request): 30 | return text("hello world") 31 | 32 | @app.websocket('/test1') 33 | async def we_test(request, ws): 34 | print("hi") 35 | return 36 | 37 | 38 | if __name__ == "__main__": 39 | app.run("127.0.0.1", port=8098, debug=True, auto_reload=False) 40 | 41 | 42 | -------------------------------------------------------------------------------- /examples/spf_config_example.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic.response import text 3 | from sanic_plugin_toolkit import SanicPluginRealm 4 | from sanic_plugin_toolkit.plugins.contextualize import instance as contextualize 5 | 6 | app = Sanic(__name__) 7 | app.config['STK_LOAD_INI'] = True 8 | app.config['STK_INI_FILE'] = 'example_stk.ini' 9 | realm = SanicPluginRealm(app) 10 | 11 | # We can get the assoc object from SPF, it is already registered 12 | contextualize = realm.get_plugin_assoc('Contextualize') 13 | 14 | 15 | @contextualize.middleware 16 | def middle3(request, context): 17 | shared = context.shared 18 | r = shared.request 19 | r.test = "true" 20 | 21 | 22 | @contextualize.middleware(priority=7) 23 | def middle4(request, context): 24 | shared = context.shared 25 | r = shared.request 26 | _ = r.test 27 | 28 | 29 | @contextualize.route('/1/') 30 | def index2(request, args, context): 31 | shared = context.shared 32 | _ = shared.request 33 | return text("hello world") 34 | 35 | 36 | if __name__ == "__main__": 37 | app.run("127.0.0.1", port=8098, debug=True, auto_reload=False) 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2021 Ashley Sommer 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 | -------------------------------------------------------------------------------- /tests/test_plugin_urlfor.py: -------------------------------------------------------------------------------- 1 | from sanic.response import redirect, text 2 | 3 | from sanic_plugin_toolkit import SanicPlugin 4 | 5 | 6 | class TestPlugin(SanicPlugin): 7 | pass 8 | 9 | 10 | instance = test_plugin = TestPlugin() 11 | 12 | 13 | @test_plugin.route('/t1') 14 | def t1(request): 15 | return text("t1") 16 | 17 | 18 | @test_plugin.route('/t2', with_context=True) 19 | def t2(request, context): 20 | app = context.app 21 | url_for = context.url_for 22 | t1 = url_for('t1') 23 | if isinstance(t1, (list, tuple, set)): 24 | t1 = t1[0] # On a blueprint, redirect to the first app's one 25 | return redirect(t1) 26 | 27 | 28 | def test_plugin_urlfor_1(realm): 29 | app = realm._app 30 | realm.register_plugin(test_plugin) 31 | client = app._test_manager.test_client 32 | resp = client.get('/t2') 33 | assert resp[1].text == 't1' 34 | 35 | 36 | def test_plugin_urlfor_2(realm_bp): 37 | realm, app = realm_bp 38 | bp = realm._app 39 | realm.register_plugin(test_plugin) 40 | app.blueprint(bp) 41 | client = app._test_manager.test_client 42 | resp = client.get("/blueprint/t2") 43 | assert resp[1].text == 't1' 44 | -------------------------------------------------------------------------------- /tests/test_plugin_websocket.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | 3 | import pytest 4 | 5 | from sanic import Sanic 6 | from sanic.response import text 7 | 8 | from sanic_plugin_toolkit import SanicPlugin, SanicPluginRealm 9 | 10 | 11 | class TestPlugin(SanicPlugin): 12 | pass 13 | 14 | 15 | # The following tests are taken directly from Sanic source @ v0.6.0 16 | # and modified to test the SanicPluginsFramework, rather than Sanic 17 | 18 | 19 | @pytest.mark.parametrize( 20 | 'path,query,expected_url', 21 | [ 22 | ('/foo', '', 'http://{}:{}/foo'), 23 | ('/bar/baz', '', 'http://{}:{}/bar/baz'), 24 | ('/moo/boo', 'arg1=val1', 'http://{}:{}/moo/boo?arg1=val1'), 25 | ], 26 | ) 27 | def test_plugin_ws_url_attributes(realm, path, query, expected_url): 28 | """Note, this doesn't _really_ test websocket functionality very well.""" 29 | app = realm._app 30 | test_plugin = TestPlugin() 31 | 32 | async def handler(request): 33 | return text('OK') 34 | 35 | test_plugin.websocket(path)(handler) 36 | realm.register_plugin(test_plugin) 37 | test_client = app._test_manager.test_client 38 | request, response = test_client.get(path + '?{}'.format(query)) 39 | try: 40 | # Sanic 20.3.0 and above 41 | p = test_client.port 42 | h = test_client.host 43 | except AttributeError: 44 | p = 0 45 | h = "127.0.0.1" 46 | assert request.url == expected_url.format(h, str(p)) 47 | parsed = urlparse(request.url) 48 | assert parsed.scheme == request.scheme 49 | assert parsed.path == request.path 50 | assert parsed.query == request.query_string 51 | assert parsed.netloc == request.host 52 | -------------------------------------------------------------------------------- /tests/test_plugin_signal_handlers.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from queue import Queue 3 | from sanic.response import json, text, HTTPResponse 4 | from sanic_plugin_toolkit import SanicPlugin, SanicPluginRealm 5 | from unittest.mock import MagicMock 6 | 7 | class TestPlugin(SanicPlugin): 8 | pass 9 | 10 | async def stop(app, loop): 11 | await asyncio.sleep(0.1) 12 | app.stop() 13 | 14 | 15 | calledq = Queue() 16 | 17 | 18 | def set_loop(app, loop): 19 | loop.add_signal_handler = MagicMock() 20 | 21 | 22 | def after(app, loop): 23 | calledq.put(loop.add_signal_handler.called) 24 | 25 | 26 | def test_register_system_signals(realm): 27 | """Test if sanic register system signals""" 28 | app = realm._app 29 | plugin = TestPlugin() 30 | @plugin.route("/hello") 31 | async def hello_route(request): 32 | return HTTPResponse() 33 | 34 | plugin.listener("after_server_start")(stop) 35 | plugin.listener("before_server_start")(set_loop) 36 | plugin.listener("after_server_stop")(after) 37 | realm.register_plugin(plugin) 38 | app.run("127.0.0.1", 9999) 39 | assert calledq.get() is True 40 | 41 | 42 | def test_dont_register_system_signals(realm): 43 | """Test if sanic don't register system signals""" 44 | app = realm._app 45 | plugin = TestPlugin() 46 | @plugin.route("/hello") 47 | async def hello_route(request): 48 | return HTTPResponse() 49 | 50 | plugin.listener("after_server_start")(stop) 51 | plugin.listener("before_server_start")(set_loop) 52 | plugin.listener("after_server_stop")(after) 53 | realm.register_plugin(plugin) 54 | app.run("127.0.0.1", 9999, register_sys_signals=False) 55 | assert calledq.get() is False 56 | -------------------------------------------------------------------------------- /examples/contextualize_sqalchemy_example.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic.response import text 3 | from sanic_plugin_toolkit import SanicPluginRealm 4 | from sanic_plugin_toolkit.plugins.contextualize import instance as contextualize 5 | app = Sanic(__name__) 6 | realm = SanicPluginRealm(app) 7 | 8 | 9 | @contextualize.listener('after_server_start') 10 | async def setup_db(app, loop, context): 11 | from sqlalchemy import create_engine 12 | shared_context = context.shared 13 | engine = create_engine('sqlite:///orm_in_detail.sqlite') 14 | shared_context['db'] = engine 15 | from sqlalchemy.orm import sessionmaker 16 | session = sessionmaker() 17 | session.configure(bind=engine) 18 | shared_context['dbsession'] = session 19 | 20 | 21 | async def get_user_session(db_session): 22 | # Hypothetical get_user_session function 23 | return {"username": "test_user"} 24 | 25 | 26 | @contextualize.middleware(priority=4) 27 | async def db_request_middleware(request, context): 28 | shared_context = context.shared 29 | request_context = shared_context.request 30 | db_session = shared_context.dbsession 31 | user_session = await get_user_session(db_session) 32 | request_context['user_session'] = user_session 33 | 34 | 35 | @contextualize.route('/') 36 | def index(request, context): 37 | shared_context = context.shared 38 | request_context = shared_context.request 39 | user_session = request_context.user_session 40 | current_username = user_session['username'] 41 | return text("hello {}!".format(current_username)) 42 | 43 | 44 | _ = realm.register_plugin(contextualize) 45 | 46 | if __name__ == "__main__": 47 | app.run("127.0.0.1", port=8098, debug=True, auto_reload=False) 48 | -------------------------------------------------------------------------------- /examples/contextualize_example.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic.response import text 3 | from sanic_plugin_toolkit import SanicPluginRealm 4 | from sanic_plugin_toolkit.plugins.contextualize import instance as contextualize 5 | 6 | app = Sanic(__name__) 7 | realm = SanicPluginRealm(app) 8 | 9 | 10 | # You can create a context middleware _before_ registering the plugin 11 | @contextualize.middleware 12 | def middle1(request, context): 13 | shared = context.shared 14 | r = shared.request 15 | r.hello = "true" 16 | 17 | 18 | # and with args 19 | @contextualize.middleware(priority=6) 20 | def middle2(request, context): 21 | shared = context.shared 22 | r = shared.request 23 | a_ = r.hello 24 | 25 | 26 | # You can create a context route _before_ registering the plugin 27 | @contextualize.route('/1') 28 | def index1(request, context): 29 | shared = context.shared 30 | _ = shared.request 31 | return text("hello world") 32 | 33 | 34 | contextualize = realm.register_plugin(contextualize) 35 | 36 | 37 | # Or you can create a context route _after_ registering the plugin 38 | @contextualize.middleware 39 | def middle3(request, context): 40 | shared = context.shared 41 | r = shared.request 42 | r.test = "true" 43 | 44 | 45 | # and with args 46 | @contextualize.middleware(priority=7) 47 | def middle4(request, context): 48 | shared = context.shared 49 | r = shared.request 50 | _ = r.test 51 | 52 | 53 | # And you can create a context route _after_ registering the plugin 54 | @contextualize.route('/2/') 55 | def index2(request, args, context): 56 | shared = context.shared 57 | _ = shared.request 58 | return text("hello world") 59 | 60 | 61 | if __name__ == "__main__": 62 | app.run("127.0.0.1", port=8098, debug=True, auto_reload=False) 63 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: bionic 3 | language: python 4 | cache: 5 | directories: 6 | - "$HOME/.cache/pip" 7 | matrix: 8 | include: 9 | - env: TOX_ENV=py37 10 | python: 3.7 11 | - env: TOX_ENV=py37-noext 12 | python: 3.7 13 | - env: TOX_ENV=py38 14 | python: 3.8 15 | - env: TOX_ENV=py38-noext 16 | python: 3.8 17 | - env: TOX_ENV=py39 18 | python: 3.9 19 | - env: TOX_ENV=py39-noext 20 | python: 3.9 21 | - env: TOX_ENV=lint 22 | python: 3.7 23 | name: "Python 3.7 Linting" 24 | - env: TOX_ENV=type-checking 25 | python: 3.7 26 | name: "Python 3.7 Type Checking" 27 | - env: TOX_ENV=type-checking 28 | python: 3.8 29 | name: "Python 3.8 Type Checking" 30 | - env: TOX_ENV=type-checking 31 | python: 3.9 32 | name: "Python 3.9 Type Checking" 33 | 34 | install: pip3 install -U pip && pip3 install -U tox 35 | script: tox -e $TOX_ENV 36 | before_deploy: 37 | - poetry build 38 | deploy: 39 | provider: pypi 40 | user: ashleysommer 41 | skip_cleanup: true #Need skip cleanup because we build the artifacts in before_deploy 42 | password: 43 | secure: oU3ofnRYBTpEa0I5sdIpj7VahzGWo6axRsi8/BCA/HfFSg83b3LnDubIAurKpE0qRI3GH6tYz/+Q5NgDsQ+sIWURQ/rb+WU1tHYYWM9JRMQuPRtxuQur71ocNDup2oCPvfPZz5WIMdeNlS/R+pwMtEohqrNKlFsZY6d4pzIDyn+5W4N7cEs74ewQ+7u7jahgIgT4kpXDd/Is2j8MAMQjsj+YrDj8Gf7+6TUEMNqguGnp2MZpDYKTdsfVnXjYErDwKEuKb/WmbfsRJCGtEuW6UtUc2btCjfYygF5lnkcBVbCta0x1ldkc+7ZALalXvTbmK5WGn6KCJce+aFCZVAfA8z8wsMTMgCbwANBaRunW4lYLVfe5h/ww/Fs3fHv+Fwy6z/fQ/l3E9vrBvT2gPvqGtpgLwm50yqKGgNt66tdXM3boT9zvR0HMRFZ+BAXQsxfrbRuyAuQT9xQVBuegHYMjtjO+7R1ROztAemmasrLmgkQaEwK/FAf94ptAM7Lv0b68RStqjneYgBSPqV5uvpBLxh12okEjD5etyCNOs+6x7LGdfNR37bY8fZWUiCOPcbsIKCGvXqiQdLYDMrih7R2NSJ8AOKSJf9QiAxg6uP/AIkHSGptd2zDKTY7MWaLTXv3+/Ne2w3eVR49dkc6Hvr5Sp6YZAaS3x4LStZDza/NLezU= 44 | on: 45 | tags: true 46 | python: 3.6 47 | repo: ashleysommer/sanicpluginsframework 48 | condition: $DEPLOY = true 49 | branch: release 50 | distributions: "sdist bdist_wheel" 51 | skip_existing: true 52 | 53 | -------------------------------------------------------------------------------- /examples/my_plugin.py: -------------------------------------------------------------------------------- 1 | from sanic_plugin_toolkit.plugin import SanicPlugin 2 | from sanic.response import text 3 | from logging import DEBUG 4 | 5 | class MyPlugin(SanicPlugin): 6 | def on_before_registered(self, context, *args, **kwargs): 7 | shared = context.shared 8 | print("Before Registered") 9 | 10 | def on_registered(self, context, reg, *args, **kwargs): 11 | shared = context.shared 12 | print("After Registered") 13 | shared.hello_shared = "test2" 14 | context.hello1 = "test1" 15 | _a = context.hello1 16 | _b = context.hello_shared 17 | 18 | def __init__(self, *args, **kwargs): 19 | super(MyPlugin, self).__init__(*args, **kwargs) 20 | 21 | 22 | my_plugin = instance = MyPlugin() 23 | 24 | @my_plugin.middleware(priority=6, with_context=True) 25 | def mw1(request, context): 26 | context['test1'] = "testa" 27 | print("Hello world") 28 | 29 | 30 | @my_plugin.middleware(priority=7, with_context=True) 31 | def mw2(request, context): 32 | assert 'test1' in context and context['test1'] == "testa" 33 | context['test2'] = "testb" 34 | print("Hello world") 35 | 36 | 37 | @my_plugin.middleware(priority=8, attach_to='response', relative='pre', 38 | with_context=True) 39 | def mw3(request, response, context): 40 | assert 'test1' in context and context['test1'] == "testa" 41 | assert 'test2' in context and context['test2'] == "testb" 42 | print("Hello world") 43 | 44 | 45 | @my_plugin.middleware(priority=2, with_context=True) 46 | def mw4(request, context): 47 | print(context) 48 | log = context.log 49 | # logging example! 50 | log(DEBUG, "Hello Middleware") 51 | 52 | @my_plugin.route('/test_plugin', with_context=True) 53 | def t1(request, context): 54 | print(context) 55 | return text('from plugin!') 56 | 57 | def decorate(app, *args, **kwargs): 58 | return my_plugin.decorate(app, *args, with_context=True, 59 | run_middleware=True, **kwargs) 60 | 61 | 62 | __all__ = ["my_plugin", "decorate"] 63 | 64 | -------------------------------------------------------------------------------- /examples/single_file.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | from sanic import Sanic 4 | from sanic_plugin_toolkit import SanicPlugin, SanicPluginRealm 5 | from sanic.response import text 6 | 7 | from logging import DEBUG 8 | 9 | 10 | class MyPlugin(SanicPlugin): 11 | def on_registered(self, context, reg, *args, **kwargs): 12 | shared = context.shared 13 | shared.hello_shared = "test2" 14 | context.hello1 = "test1" 15 | _a = context.hello1 16 | _b = context.hello_shared 17 | 18 | def __init__(self, *args, **kwargs): 19 | super(MyPlugin, self).__init__(*args, **kwargs) 20 | 21 | 22 | instance = MyPlugin() 23 | 24 | @instance.middleware(priority=6, with_context=True) 25 | def mw1(request, context): 26 | context['test1'] = "test" 27 | print("Hello world") 28 | 29 | 30 | @instance.middleware(priority=7, with_context=True) 31 | def mw2(request, context): 32 | assert 'test1' in context and context['test1'] == "test" 33 | context['test2'] = "testb" 34 | print("Hello world") 35 | 36 | 37 | @instance.middleware(priority=8, attach_to='response', relative='pre', 38 | with_context=True) 39 | def mw3(request, response, context): 40 | assert 'test1' in context and context['test1'] == "test" 41 | assert 'test2' in context and context['test2'] == "testb" 42 | print("Hello world") 43 | 44 | 45 | @instance.middleware(priority=2, with_context=True) 46 | def mw4(request, context): 47 | log = context.log 48 | print(context) 49 | log(DEBUG, "Hello Middleware") 50 | 51 | @instance.route('/test_plugin', with_context=False) 52 | def t1(request): 53 | return text('from plugin!') 54 | 55 | 56 | app = Sanic(__name__) 57 | mp = MyPlugin(app) 58 | realm = SanicPluginRealm(app) 59 | try: 60 | assoc_reg = realm.register_plugin(MyPlugin) # already registered! (line 57) 61 | except ValueError as ve: 62 | assoc_reg = ve.args[1] 63 | 64 | @app.route('/') 65 | def index(request): 66 | return text("hello world") 67 | 68 | if __name__ == "__main__": 69 | app.run("127.0.0.1", port=8098, debug=True, workers=2, auto_reload=False) 70 | 71 | 72 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | help: 3 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 4 | 5 | .PHONY: venvcheck ## Check if venv is active 6 | venvcheck: 7 | ifeq ("$(VIRTUAL_ENV)","") 8 | @echo "Venv is not activated!" 9 | @echo "Activate venv first." 10 | @echo 11 | exit 1 12 | endif 13 | 14 | .PHONY: env 15 | env: venvcheck ## Double check environment variables 16 | env 17 | 18 | .PHONY: install 19 | install: venvcheck ## Install the dependencies, but not dev-dependencies 20 | poetry install --no-dev 21 | 22 | .PHONY: dev 23 | dev: venvcheck ## Install dependencies and dev-dependencies, but not this project itself 24 | poetry install --no-root 25 | 26 | .PHONY: test 27 | test: venvcheck ## Run the TOX tests in a TOX environment 28 | poetry run tox 29 | 30 | .PHONY: dev-test 31 | test: venvcheck ## Run the tests in dev environment 32 | poetry run pytest --cov=pyshacl test/ 33 | 34 | .PHONY: format 35 | format: venvcheck ## Run Black and isort Formatters 36 | ifeq ("$(FilePath)", "") 37 | poetry run black --config=./pyproject.toml --verbose sanic_plugin_toolkit 38 | poetry run isort sanic_plugin_toolkit 39 | else 40 | poetry run black --config=./pyproject.toml --verbose "$(FilePath)" 41 | poetry run isort "$(FilePath)" 42 | endif 43 | 44 | .PHONY: lint 45 | lint: venvcheck ## Validate with Black and isort in check-only mode 46 | ifeq ("$(FilePath)", "") 47 | poetry run flake8 sanic_plugin_toolkit 48 | poetry run black --config=./pyproject.toml --check --verbose sanic_plugin_toolkit 49 | poetry run isort --check-only sanic_plugin_toolkit 50 | else 51 | poetry run flake8 "$(FilePath)" 52 | poetry run black --config=./pyproject.toml --check --verbose "$(FilePath)" 53 | poetry run isort --check-only "$(FilePath)" 54 | endif 55 | 56 | .PHONY: upgrade 57 | upgrade: venvcheck ## Upgrade the dependencies 58 | poetry update 59 | 60 | .PHONY: downgrade 61 | downgrade: venvcheck ## Downgrade the dependencies 62 | git checkout pyproject.toml && git checkout poetry.lock 63 | 64 | .PHONY: publish 65 | publish: venvcheck ## Build and publish to PYPI 66 | poetry build 67 | poetry publish 68 | -------------------------------------------------------------------------------- /tests/test_plugin_registration.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic_plugin_toolkit import SanicPlugin, SanicPluginRealm 3 | from sanic_plugin_toolkit.plugin import PluginRegistration, PluginAssociated 4 | 5 | 6 | class TestPlugin(SanicPlugin): 7 | pass 8 | 9 | 10 | instance = TestPlugin() 11 | 12 | 13 | def test_spf_registration(realm): 14 | reg = realm.register_plugin(instance) 15 | assert isinstance(reg, PluginAssociated) 16 | (plugin, reg) = reg 17 | assert isinstance(reg, PluginRegistration) 18 | assert plugin == instance 19 | 20 | def test_legacy_registration_1(app): 21 | # legacy style import 22 | reg = TestPlugin(app) 23 | assert isinstance(reg, PluginAssociated) 24 | (plugin, reg) = reg 25 | assert isinstance(reg, PluginRegistration) 26 | assert plugin == instance 27 | 28 | def test_legacy_registration_2(app): 29 | # legacy style import, without declaring sanic_plugin_toolkit first 30 | reg = TestPlugin(app) 31 | assert isinstance(reg, PluginAssociated) 32 | (plugin, reg) = reg 33 | assert isinstance(reg, PluginRegistration) 34 | assert plugin == instance 35 | 36 | def test_duplicate_registration_1(realm): 37 | assoc1 = realm.register_plugin(instance) 38 | exc = None 39 | try: 40 | assoc2 = realm.register_plugin(instance) 41 | assert not assoc2 42 | except Exception as e: 43 | exc = e 44 | assert isinstance(exc, ValueError) 45 | assert exc.args and len(exc.args) > 1 and exc.args[1] == assoc1 46 | 47 | def test_duplicate_legacy_registration(): 48 | app1 = Sanic('test_duplicate_legacy_registration_1') 49 | app2 = Sanic('test_duplicate_legacy_registration_2') 50 | # legacy style import 51 | assoc1 = TestPlugin(app1) 52 | (plugin1, reg1) = assoc1 53 | baseline_reg_count = len(plugin1.registrations) 54 | assoc2 = TestPlugin(app2) 55 | (plugin2, reg2) = assoc2 56 | assert len(plugin1.registrations) == baseline_reg_count + 1 57 | assert plugin1 == plugin2 58 | 59 | class TestPlugin2(SanicPlugin): 60 | pass 61 | 62 | test_plugin2 = TestPlugin2() 63 | 64 | def test_plugiun_class_registration(realm): 65 | reg = realm.register_plugin(TestPlugin2) 66 | assert isinstance(reg, PluginAssociated) 67 | (plugin, reg) = reg 68 | assert isinstance(reg, PluginRegistration) 69 | assert plugin == test_plugin2 70 | -------------------------------------------------------------------------------- /tests/test_contextualize_plugin.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | from sanic import Sanic 3 | from sanic.response import text 4 | from sanic_plugin_toolkit import SanicPluginRealm, SanicPlugin 5 | from sanic_plugin_toolkit.plugins import contextualize 6 | import pytest 7 | 8 | from sanic_plugin_toolkit.context import HierDict 9 | 10 | def test_contextualize_plugin_route(realm): 11 | app = realm._app 12 | ctx = realm.register_plugin(contextualize) 13 | 14 | async def handler(request, context): 15 | assert isinstance(context, HierDict) 16 | shared = context.get('shared', None) 17 | assert shared is not None 18 | shared_request = shared.get('request', None) 19 | assert shared_request is not None 20 | shared_request_ctx = shared_request.get(id(request), None) 21 | assert shared_request_ctx is not None 22 | assert isinstance(shared_request_ctx, HierDict) 23 | r2 = shared_request_ctx.get('request', None) 24 | assert r2 is not None 25 | assert r2 == request 26 | 27 | priv_request = context.get('request', None) 28 | assert priv_request is not None 29 | priv_request_ctx = priv_request.get(id(request), None) 30 | assert priv_request_ctx is not None 31 | assert isinstance(priv_request_ctx, HierDict) 32 | r3 = priv_request_ctx.get('request', None) 33 | assert r3 is not None 34 | assert r3 == request 35 | assert priv_request_ctx != shared_request_ctx 36 | return text('OK') 37 | 38 | ctx.route('/')(handler) 39 | 40 | request, response = app._test_manager.test_client.get('/') 41 | assert response.text == "OK" 42 | 43 | def test_contextualize_plugin_middleware(realm): 44 | app = realm._app 45 | ctx = realm.register_plugin(contextualize) 46 | 47 | @ctx.middleware(attach_to='request') 48 | async def mw1(request, context): 49 | assert isinstance(context, HierDict) 50 | shared = context.get('shared', None) 51 | shared_request = shared.get('request', None) 52 | assert shared_request is not None 53 | shared_request_ctx = shared_request.get(id(request), None) 54 | assert shared_request_ctx is not None 55 | shared_request_ctx['middleware_ran'] = True 56 | 57 | @ctx.route('/') 58 | async def handler(request, context): 59 | assert isinstance(context, HierDict) 60 | shared = context.get('shared', None) 61 | shared_request = shared.get('request', None) 62 | shared_request_ctx = shared_request.get(id(request), None) 63 | middleware_ran = shared_request_ctx.get('middleware_ran', False) 64 | assert middleware_ran 65 | return text('OK') 66 | 67 | request, response = app._test_manager.test_client.get('/') 68 | assert response.text == "OK" 69 | -------------------------------------------------------------------------------- /tests/test_plugin_route.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | 3 | import pytest 4 | 5 | from sanic import Sanic 6 | from sanic.response import text 7 | 8 | from sanic_plugin_toolkit import SanicPlugin, SanicPluginRealm 9 | from sanic_plugin_toolkit.context import HierDict, SanicContext 10 | 11 | 12 | class TestPlugin(SanicPlugin): 13 | pass 14 | 15 | 16 | @pytest.mark.parametrize( 17 | 'path,query,expected_url', 18 | [ 19 | ('/foo', '', 'http://{}:{}/foo'), 20 | ('/bar/baz', '', 'http://{}:{}/bar/baz'), 21 | ('/moo/boo', 'arg1=val1', 'http://{}:{}/moo/boo?arg1=val1'), 22 | ], 23 | ) 24 | def test_plugin_url_attributes(realm, path, query, expected_url): 25 | app = realm._app 26 | test_plugin = TestPlugin() 27 | 28 | async def handler(request): 29 | return text('OK') 30 | 31 | test_plugin.route(path)(handler) 32 | 33 | realm.register_plugin(test_plugin) 34 | test_client = app._test_manager.test_client 35 | request, response = test_client.get(path + '?{}'.format(query)) 36 | try: 37 | # Sanic 20.3.0 and above 38 | p = test_client.port 39 | h = test_client.host 40 | except AttributeError: 41 | p = 0 42 | h = "127.0.0.1" 43 | 44 | assert request.url == expected_url.format(h, str(p)) 45 | 46 | parsed = urlparse(request.url) 47 | 48 | assert parsed.scheme == request.scheme 49 | assert parsed.path == request.path 50 | assert parsed.query == request.query_string 51 | assert parsed.netloc == request.host 52 | 53 | 54 | def test_plugin_route_context(realm): 55 | app = realm._app 56 | test_plugin = TestPlugin() 57 | 58 | async def handler(request, context): 59 | assert isinstance(context, HierDict) 60 | shared = context.get('shared', None) 61 | assert shared is not None 62 | shared_request = shared.get('request', None) 63 | assert shared_request is not None 64 | req_id = id(request) 65 | shared_request = shared_request.get(req_id, None) 66 | assert shared_request is not None 67 | assert isinstance(shared_request, HierDict) 68 | r2 = shared_request.get('request', None) 69 | assert r2 is not None 70 | assert r2 == request 71 | 72 | priv_request = context.get('request', None) 73 | assert priv_request is not None 74 | priv_request = priv_request.get(req_id, None) 75 | assert priv_request is not None 76 | assert isinstance(priv_request, HierDict) 77 | r3 = priv_request.get('request', None) 78 | assert r3 is not None 79 | assert r3 == request 80 | priv_request2 = context.for_request(request) 81 | assert priv_request2 is priv_request 82 | assert priv_request != shared_request 83 | return text('OK') 84 | 85 | test_plugin.route('/', with_context=True)(handler) 86 | 87 | realm.register_plugin(test_plugin) 88 | request, response = app._test_manager.test_client.get('/') 89 | assert response.text == "OK" 90 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from sanic_plugin_toolkit.context import SanicContext 3 | 4 | def test_context_set_contains_get(realm): 5 | context = SanicContext(realm, None) 6 | context.set("t1", "hello world") 7 | assert "t1" in context 8 | assert context.get("t1") == "hello world" 9 | exceptions = [] 10 | try: 11 | context.set("__weakref__", set()) 12 | except ValueError as e: 13 | exceptions.append(e) 14 | finally: 15 | assert len(exceptions) > 0 16 | 17 | def test_context_get_private_from_slots(realm): 18 | context = SanicContext(realm, None) 19 | context.set("t1", "hello world") 20 | d = context.__getattr__('_dict') 21 | d2 = getattr(context, '_dict') 22 | assert isinstance(d, dict) 23 | assert "t1" in d 24 | assert d == d2 25 | 26 | def test_context_items_keys_values(realm): 27 | context = SanicContext(realm, None) 28 | context["t1"] = "hello world" 29 | context["t2"] = "hello 2" 30 | items = context.items() 31 | assert len(items) == 2 32 | keys = context.keys() 33 | assert len(keys) == 2 34 | assert "t1" in list(keys) 35 | vals = context.values() 36 | assert len(vals) == 2 37 | assert "hello world" in list(vals) 38 | 39 | def test_context_pickle(realm): 40 | context = SanicContext(realm, None) 41 | child_context = context.create_child_context() 42 | child_context['t1'] = "hello world" 43 | p_bytes = pickle.dumps(child_context) 44 | un_p = pickle.loads(p_bytes) 45 | # the sanic_plugin_toolkit and the parent context are not the same as before, because 46 | # their state got pickled and unpicked too 47 | assert un_p._stk_realm != realm 48 | assert un_p._parent_hd != context 49 | assert un_p['t1'] == "hello world" 50 | 51 | def test_context_replace(realm): 52 | context = SanicContext(realm, None) 53 | child_context = context.create_child_context() 54 | context['t1'] = "hello world" 55 | assert child_context['t1'] == "hello world" 56 | child_context['t1'] = "goodbye world" 57 | assert context['t1'] != "goodbye world" 58 | del(child_context['t1']) 59 | child_context.replace('t1', 'goodbye world') 60 | assert context['t1'] == "goodbye world" 61 | 62 | def test_context_update(realm): 63 | context = SanicContext(realm, None) 64 | child_context = context.create_child_context() 65 | context['t1'] = "hello world" 66 | child_context['t2'] = "hello2" 67 | assert child_context['t1'] == "hello world" 68 | child_context.update({'t1': "test1", 't2': "test2"}) 69 | assert context['t1'] == "test1" 70 | assert child_context['t2'] == "test2" 71 | 72 | def test_context_del(realm): 73 | context = SanicContext(realm, None) 74 | context.set(1, "1") 75 | context.set(2, "2") 76 | context.set(3, "3") 77 | context.set(4, "4") 78 | del context[1] 79 | one = context.get(1, None) 80 | assert one is None 81 | exceptions = [] 82 | try: 83 | #TODO: How do you even delete a slice? 84 | del context[2:4] 85 | except Exception as e: 86 | exceptions.append(e) 87 | finally: 88 | assert len(exceptions) > 0 89 | 90 | def test_context_str(realm): 91 | context = SanicContext(realm, None) 92 | context['t1'] = "hello world" 93 | s1 = str(context) 94 | assert s1 == "SanicContext({'t1': 'hello world'})" 95 | 96 | def test_context_repr(realm): 97 | context = SanicContext(realm, None) 98 | context['t1'] = "hello world" 99 | s1 = repr(context) 100 | assert s1 == "SanicContext({'t1': 'hello world'})" 101 | 102 | def test_recursive_dict(realm): 103 | context = SanicContext(realm, None) 104 | context['t1'] = "hello world" 105 | c2 = context.create_child_context() 106 | c2['t2'] = "hello 2" 107 | c3 = c2.create_child_context() 108 | c3['t3'] = "hello 3" 109 | context._parent_hd = c3 # This is dodgy, why would anyone do this? 110 | exceptions = [] 111 | try: 112 | _ = c2['t4'] 113 | except RuntimeError as e: 114 | assert len(e.args) > 0 115 | assert "recursive" in str(e.args[0]).lower() 116 | exceptions.append(e) 117 | finally: 118 | assert len(exceptions) == 1 119 | -------------------------------------------------------------------------------- /tests/test_plugin_middleware.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic.exceptions import NotFound 3 | from sanic.request import Request 4 | from sanic.response import HTTPResponse, text 5 | 6 | from sanic_plugin_toolkit import SanicPlugin, SanicPluginRealm 7 | 8 | 9 | class TestPlugin(SanicPlugin): 10 | pass 11 | 12 | 13 | # The following tests are taken directly from Sanic source @ v0.6.0 14 | # and modified to test the SanicPlugin, rather than Sanic 15 | 16 | # ------------------------------------------------------------ # 17 | # GET 18 | # ------------------------------------------------------------ # 19 | 20 | 21 | def test_middleware_request(realm): 22 | app = realm._app 23 | plugin = TestPlugin() 24 | 25 | results = [] 26 | 27 | @plugin.middleware 28 | async def handler(request): 29 | results.append(request) 30 | 31 | @plugin.route('/') 32 | async def handler(request): 33 | return text('OK') 34 | 35 | realm.register_plugin(plugin) 36 | request, response = app._test_manager.test_client.get('/') 37 | 38 | assert response.text == 'OK' 39 | assert type(results[0]) is Request 40 | 41 | 42 | def test_middleware_response(realm): 43 | app = realm._app 44 | plugin = TestPlugin() 45 | results = [] 46 | 47 | @plugin.middleware('request') 48 | async def process_response(request): 49 | results.append(request) 50 | 51 | @plugin.middleware('response') 52 | async def process_response(request, response): 53 | results.append(request) 54 | results.append(response) 55 | 56 | @plugin.route('/') 57 | async def handler(request): 58 | return text('OK') 59 | 60 | realm.register_plugin(plugin) 61 | request, response = app._test_manager.test_client.get('/') 62 | 63 | assert response.text == 'OK' 64 | assert type(results[0]) is Request 65 | assert type(results[1]) is Request 66 | assert isinstance(results[2], HTTPResponse) 67 | 68 | 69 | def test_middleware_response_exception(realm): 70 | app = realm._app 71 | plugin = TestPlugin() 72 | result = {'status_code': None} 73 | 74 | @plugin.middleware('response') 75 | async def process_response(reqest, response): 76 | result['status_code'] = response.status 77 | return response 78 | 79 | @plugin.exception(NotFound) 80 | async def error_handler(request, exception): 81 | return text('OK', exception.status_code) 82 | 83 | @plugin.route('/') 84 | async def handler(request): 85 | return text('FAIL') 86 | 87 | realm.register_plugin(plugin) 88 | request, response = app._test_manager.test_client.get('/page_not_found') 89 | assert response.text == 'OK' 90 | assert result['status_code'] == 404 91 | 92 | 93 | def test_middleware_override_request(realm): 94 | app = realm._app 95 | plugin = TestPlugin() 96 | 97 | @plugin.middleware 98 | async def halt_request(request): 99 | return text('OK') 100 | 101 | @plugin.route('/') 102 | async def handler(request): 103 | return text('FAIL') 104 | 105 | realm.register_plugin(plugin) 106 | _, response = app._test_manager.test_client.get('/', gather_request=False) 107 | 108 | assert response.status == 200 109 | assert response.text == 'OK' 110 | 111 | 112 | def test_middleware_override_response(realm): 113 | app = realm._app 114 | plugin = TestPlugin() 115 | 116 | @plugin.middleware('response') 117 | async def process_response(request, response): 118 | return text('OK') 119 | 120 | @plugin.route('/') 121 | async def handler(request): 122 | return text('FAIL') 123 | 124 | realm.register_plugin(plugin) 125 | request, response = app._test_manager.test_client.get('/') 126 | 127 | assert response.status == 200 128 | assert response.text == 'OK' 129 | 130 | 131 | def test_middleware_order(realm): 132 | app = realm._app 133 | plugin = TestPlugin() 134 | order = [] 135 | 136 | @plugin.middleware('request') 137 | async def request1(request): 138 | order.append(1) 139 | 140 | @plugin.middleware('request') 141 | async def request2(request): 142 | order.append(2) 143 | 144 | @plugin.middleware('request') 145 | async def request3(request): 146 | order.append(3) 147 | 148 | @plugin.middleware('response') 149 | async def response1(request, response): 150 | order.append(6) 151 | 152 | @plugin.middleware('response') 153 | async def response2(request, response): 154 | order.append(5) 155 | 156 | @plugin.middleware('response') 157 | async def response3(request, response): 158 | order.append(4) 159 | 160 | @plugin.route('/') 161 | async def handler(request): 162 | return text('OK') 163 | 164 | realm.register_plugin(plugin) 165 | request, response = app._test_manager.test_client.get('/') 166 | 167 | assert response.status == 200 168 | assert order == [1, 2, 3, 4, 5, 6] 169 | -------------------------------------------------------------------------------- /sanic_plugin_toolkit/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Allows SPTK to parse a config file and automatically load defined plugins 4 | """ 5 | 6 | import configparser 7 | import importlib 8 | import os 9 | 10 | import pkg_resources 11 | 12 | 13 | def _find_config_file(filename): 14 | abs = os.path.abspath(filename) 15 | if os.path.isfile(abs): 16 | return abs 17 | raise FileNotFoundError(filename) 18 | 19 | 20 | def _get_config_defaults(): 21 | return {} 22 | 23 | 24 | def _find_advertised_plugins(realm): 25 | plugins = {} 26 | for entrypoint in pkg_resources.iter_entry_points('sanic_plugins'): 27 | if entrypoint.attrs: 28 | attr = entrypoint.attrs[0] 29 | else: 30 | attr = None 31 | name = entrypoint.name 32 | try: 33 | module = importlib.import_module(entrypoint.module_name) 34 | except ImportError: 35 | realm.error("Cannot import {}".format(entrypoint.module_name)) 36 | continue 37 | p_dict = {'name': name, 'module': module} 38 | if attr: 39 | try: 40 | inst = getattr(module, attr) 41 | except AttributeError: 42 | realm.error("Cannot import {} from {}".format(attr, entrypoint.module_name)) 43 | continue 44 | p_dict['instance'] = inst 45 | plugins[name] = p_dict 46 | plugins[str(name).casefold()] = p_dict 47 | return plugins 48 | 49 | 50 | def _transform_option_dict(options): 51 | parts = str(options).split(',') 52 | args = [] 53 | kwargs = {} 54 | for part in parts: 55 | if "=" in part: 56 | kwparts = part.split('=', 1) 57 | kwkey = kwparts[0] 58 | val = kwparts[1] 59 | else: 60 | val = part 61 | kwkey = None 62 | 63 | if val == "True": 64 | val = True 65 | elif val == "False": 66 | val = False 67 | elif val == "None": 68 | val = None 69 | elif '.' in val: 70 | try: 71 | f = float(val) 72 | val = f 73 | except ValueError: 74 | pass 75 | else: 76 | try: 77 | i = int(val) 78 | val = i 79 | except ValueError: 80 | pass 81 | if kwkey: 82 | kwargs[kwkey] = val 83 | else: 84 | args.append(val) 85 | args = tuple(args) 86 | return args, kwargs 87 | 88 | 89 | def _register_advertised_plugin(realm, app, plugin_def, *args, **kwargs): 90 | name = plugin_def['name'] 91 | realm.info("Found advertised plugin {}.".format(name)) 92 | inst = plugin_def.get('instance', None) 93 | if inst: 94 | p = inst 95 | else: 96 | p = plugin_def['module'] 97 | return realm.register_plugin(p, *args, **kwargs) 98 | 99 | 100 | def _try_register_other_plugin(realm, app, plugin_name, *args, **kwargs): 101 | try: 102 | module = importlib.import_module(plugin_name) 103 | except ImportError: 104 | raise RuntimeError("Do not know how to register plugin: {}".format(plugin_name)) 105 | return realm.register_plugin(module, *args, **kwargs) 106 | 107 | 108 | def _register_plugins(realm, app, config_plugins): 109 | advertised_plugins = _find_advertised_plugins(realm) 110 | registered_plugins = {} 111 | for plugin, options in config_plugins: 112 | realm.info("Loading plugin: {}...".format(plugin)) 113 | if options: 114 | args, kwargs = _transform_option_dict(options) 115 | else: 116 | args = tuple() 117 | kwargs = {} 118 | p_fold = str(plugin).casefold() 119 | if p_fold in advertised_plugins: 120 | assoc = _register_advertised_plugin(realm, app, advertised_plugins[p_fold], *args, **kwargs) 121 | else: 122 | assoc = _try_register_other_plugin(realm, app, plugin, *args, **kwargs) 123 | _p, reg = assoc 124 | registered_plugins[reg.plugin_name] = assoc 125 | return registered_plugins 126 | 127 | 128 | def load_config_file(realm, app, filename): 129 | """ 130 | 131 | :param realm: 132 | :type realm: sanic_plugin_toolkit.SanicPluginRealm 133 | :param app: 134 | :type app: sanic.Sanic 135 | :param filename: 136 | :type filename: str 137 | :return: 138 | """ 139 | location = _find_config_file(filename) 140 | realm.info("Loading sanic_plugin_toolkit config file {}.".format(location)) 141 | 142 | defaults = _get_config_defaults() 143 | parser = configparser.ConfigParser(defaults=defaults, allow_no_value=True, strict=False) 144 | parser.read(location) 145 | try: 146 | config_plugins = parser.items('plugins') 147 | except Exception as e: 148 | raise e 149 | # noinspection PyUnusedLocal 150 | _ = _register_plugins(realm, app, config_plugins) # noqa: F841 151 | return 152 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=1.0.7"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "sanic-plugin-toolkit" 7 | version = "1.2.1" 8 | # Don't forget to change the version number in __init__.py along with this one 9 | description = "The all-in-one toolkit for creating powerful Sanic Plugins" 10 | license = "MIT" 11 | authors = [ 12 | "Ashley Sommer " 13 | ] 14 | readme = "README.rst" 15 | repository = "https://github.com/ashleysommer/sanicplugintoolkit" 16 | homepage = "https://github.com/ashleysommer/sanicplugintoolkit" 17 | keywords = ["sanic", "plugin", "toolkit"] 18 | classifiers = [ 19 | 'Environment :: Web Environment', 20 | 'Intended Audience :: Developers', 21 | 'License :: OSI Approved :: MIT License', 22 | 'Operating System :: OS Independent', 23 | 'Programming Language :: Python', 24 | 'Programming Language :: Python :: 3', 25 | 'Programming Language :: Python :: 3.7', 26 | 'Programming Language :: Python :: 3.8', 27 | 'Programming Language :: Python :: 3.9', 28 | 'Programming Language :: Python :: 3.10', 29 | 'Programming Language :: Python :: Implementation :: CPython', 30 | 'Programming Language :: Python :: Implementation :: PyPy', 31 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 32 | 'Topic :: Software Development :: Libraries :: Python Modules' 33 | ] 34 | packages = [ 35 | { include = "sanic_plugin_toolkit" }, 36 | { include = "sanic_plugin_toolkit/plugins" }, 37 | { include = "examples", format = "sdist" }, 38 | { include = "tests", format = "sdist" } 39 | ] 40 | 41 | include = [ 42 | { path = "pyproject.toml", format = "sdist" }, 43 | { path = "poetry.lock", format = "sdist" }, 44 | { path = "Makefile", format = "sdist" }, 45 | { path = "MANIFEST.in", format = "sdist" }, 46 | { path = "*.md" }, 47 | { path = "*.txt" }, 48 | { path = "*.rst" }, 49 | ] 50 | 51 | [tool.poetry.plugins."sanic_plugins"] 52 | Contextualize = "sanic_plugin_toolkit.plugins.contextualize:instance" 53 | 54 | 55 | [tool.poetry.dependencies] 56 | python = "^3.7" # Compatible python versions must be declared here 57 | setuptools = ">=40.8" 58 | sanic = { version = ">= 21.3.1, < 21.12.0" } 59 | 60 | [tool.poetry.dev-dependencies] 61 | attrs = "<19.2.0" 62 | coverage = "^4.5" 63 | pytest = ">=5.3.0,<6.0.0" 64 | pytest-cov = ">2.8.1,<3.0" 65 | pytest-asyncio = "*" 66 | flake8 = "^3.7" 67 | sanic-testing = ">=0.3.0" 68 | isort = {version="^5.7.0", python=">=3.6"} 69 | black = {version="21.8b0", python=">=3.6"} 70 | mypy = {version="^0.800.0", python=">=3.6"} 71 | 72 | [tool.dephell.main] 73 | from = {format = "poetry", path = "pyproject.toml"} 74 | to = {format = "setuppy", path = "setup.py"} 75 | 76 | [tool.black] 77 | line-length = "119" 78 | skip-string-normalization = true 79 | required-version = "21.8b0" 80 | target-version = ['py37'] 81 | include = '\.pyi?$' 82 | exclude = ''' 83 | ( 84 | /( 85 | \.eggs # exclude a few common directories in the 86 | | \.git # root of the project 87 | | \.hg 88 | | \.mypy_cache 89 | | \.pytest_cache 90 | | \.tox 91 | | \.venv 92 | | _build 93 | | htmlcov 94 | | benchmarks 95 | | examples 96 | | sanic_plugin_toolkit.egg-info 97 | | buck-out 98 | | build 99 | | dist 100 | | venv 101 | )/ 102 | ) 103 | ''' 104 | 105 | [tool.isort] 106 | atomic = true 107 | default_section = "THIRDPARTY" 108 | include_trailing_comma = true 109 | force_grid_wrap = 0 110 | use_parentheses = true 111 | ensure_newline_before_comments = true 112 | known_first_party = "sanic_plugin_toolkit" 113 | known_third_party = ["pytest"] 114 | line_length = 119 115 | lines_after_imports = 2 116 | lines_between_types = 1 117 | multi_line_output = 3 118 | 119 | [tool.tox] 120 | legacy_tox_ini = """ 121 | [tox] 122 | isolated_build = true 123 | skipsdist = true 124 | envlist = py37, py38, py39, {py37,py38,py39}-noext, lint, type-checking 125 | 126 | [testenv] 127 | deps = 128 | poetry>=1.1.0 129 | py37: coveralls 130 | passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH 131 | setenv = 132 | {py37,py38,py39}-noext: SANIC_NO_UJSON=1 133 | {py37,py38,py39}-noext: SANIC_NO_UVLOOP=1 134 | skip_install = true 135 | commands_pre = poetry install -vvv 136 | commands = 137 | - poetry show 138 | poetry run pytest tests --cov=sanic_plugin_toolkit --cov-report= {posargs} 139 | - poetry run coverage combine --append 140 | poetry run coverage report -m 141 | poetry run coverage html -i 142 | py37: - coveralls 143 | 144 | [testenv:lint] 145 | commands = 146 | - poetry show 147 | poetry run flake8 sanic_plugin_toolkit 148 | poetry run flake8 sanic_plugin_toolkit/plugins 149 | poetry run isort --check-only sanic_plugin_toolkit 150 | poetry run black --check --verbose --config ./pyproject.toml sanic_plugin_toolkit 151 | 152 | [testenv:type-checking] 153 | commands = 154 | - poetry show 155 | poetry run mypy --ignore-missing-imports sanic_plugin_toolkit 156 | 157 | """ 158 | 159 | -------------------------------------------------------------------------------- /tests/test_plugin_exception.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic.exceptions import InvalidUsage, NotFound, ServerError 3 | from sanic.handlers import ErrorHandler 4 | from sanic.response import text 5 | from sanic_testing import TestManager 6 | 7 | from sanic_plugin_toolkit import SanicPlugin, SanicPluginRealm 8 | 9 | 10 | class TestPlugin(SanicPlugin): 11 | pass 12 | 13 | 14 | # The following tests are taken directly from Sanic source @ v0.6.0 15 | # and modified to test the SanicPlugin, rather than Sanic 16 | 17 | exception_handler_app = Sanic('test_exception_handler') 18 | test_manager = TestManager(exception_handler_app) 19 | realm = SanicPluginRealm(exception_handler_app) 20 | test_plugin = TestPlugin() 21 | 22 | 23 | @test_plugin.route('/1') 24 | def handler_1(request): 25 | raise InvalidUsage("OK") 26 | 27 | 28 | @test_plugin.route('/2') 29 | def handler_2(request): 30 | raise ServerError("OK") 31 | 32 | 33 | @test_plugin.route('/3') 34 | def handler_3(request): 35 | raise NotFound("OK") 36 | 37 | 38 | @test_plugin.route('/4') 39 | def handler_4(request): 40 | # noinspection PyUnresolvedReferences 41 | foo = bar # noqa -- F821 undefined name 'bar' is done to throw exception 42 | return text(foo) 43 | 44 | 45 | @test_plugin.route('/5') 46 | def handler_5(request): 47 | class CustomServerError(ServerError): 48 | pass 49 | 50 | raise CustomServerError('Custom server error') 51 | 52 | 53 | @test_plugin.route('/6/') 54 | def handler_6(request, arg): 55 | try: 56 | foo = 1 / arg 57 | except Exception as e: 58 | raise e from ValueError("{}".format(arg)) 59 | return text(foo) 60 | 61 | 62 | @test_plugin.exception(NotFound, ServerError) 63 | def handler_exception(request, exception): 64 | return text("OK") 65 | 66 | 67 | realm.register_plugin(test_plugin) 68 | 69 | 70 | def test_invalid_usage_exception_handler(): 71 | request, response = test_manager.test_client.get('/1') 72 | assert response.status == 400 73 | 74 | 75 | def test_server_error_exception_handler(): 76 | request, response = test_manager.test_client.get('/2') 77 | assert response.status == 200 78 | assert response.text == 'OK' 79 | 80 | 81 | def test_not_found_exception_handler(): 82 | request, response = test_manager.test_client.get('/3') 83 | assert response.status == 200 84 | 85 | 86 | def test_text_exception__handler(): 87 | request, response = test_manager.test_client.get('/random') 88 | assert response.status == 200 89 | assert response.text == 'OK' 90 | 91 | 92 | def test_html_traceback_output_in_debug_mode(): 93 | request, response = test_manager.test_client.get('/4', debug=True) 94 | assert response.status == 500 95 | html = str(response.body) 96 | 97 | assert ('response = handler(request, *args, **kwargs)' in html) or ( 98 | 'response = handler(request, **kwargs)' in html 99 | ) 100 | assert 'handler_4' in html 101 | assert 'foo = bar' in html 102 | 103 | try: 104 | summary_start = html.index("
") 105 | summary_start += 21 106 | summary_end = html.index("
", summary_start) 107 | except ValueError: 108 | # Sanic 20.3 and later uses a cut down HTML5 spec, 109 | # see here: https://stackoverflow.com/a/25749523 110 | summary_start = html.index("
") 111 | summary_start += 19 112 | summary_end = html.index("", summary_start) 113 | summary_text = html[summary_start:summary_end] 114 | summary_text = ( 115 | summary_text.replace(" ", " ") 116 | .replace("\n", "") 117 | .replace('\\n', "") 118 | .replace('\t', "") 119 | .replace('\\t', "") 120 | .replace('\\\'', '\'') 121 | .replace("", "") 122 | .replace("", "") 123 | .replace("

", "") 124 | .replace("

", "") 125 | .replace("", "") 126 | .replace("", "") 127 | .replace(" ", " ") 128 | ) 129 | assert "NameError: name \'bar\' is not defined" in summary_text 130 | assert "while handling path /4" in summary_text 131 | 132 | 133 | def test_inherited_exception_handler(): 134 | request, response = test_manager.test_client.get('/5') 135 | assert response.status == 200 136 | 137 | 138 | def test_chained_exception_handler(): 139 | request, response = test_manager.test_client.get('/6/0', debug=True) 140 | assert response.status == 500 141 | 142 | html = str(response.body) 143 | 144 | assert ('response = handler(request, *args, **kwargs)' in html) or ( 145 | 'response = handler(request, **kwargs)' in html 146 | ) 147 | assert 'handler_6' in html 148 | assert 'foo = 1 / arg' in html 149 | assert 'ValueError' in html 150 | assert 'The above exception was the direct cause' in html 151 | 152 | try: 153 | summary_start = html.index("
") 154 | summary_start += 21 155 | summary_end = html.index("
", summary_start) 156 | except ValueError: 157 | # Sanic 20.3 and later uses a cut down HTML5 spec, 158 | # see here: https://stackoverflow.com/a/25749523 159 | summary_start = html.index("
") 160 | summary_start += 19 161 | summary_end = html.index("", summary_start) 162 | summary_text = html[summary_start:summary_end] 163 | summary_text = ( 164 | summary_text.replace(" ", " ") 165 | .replace("\n", "") 166 | .replace('\\n', "") 167 | .replace('\t', "") 168 | .replace('\\t', "") 169 | .replace('\\\'', '\'') 170 | .replace("", "") 171 | .replace("", "") 172 | .replace("

", "") 173 | .replace("

", "") 174 | .replace("", "") 175 | .replace("", "") 176 | .replace(" ", " ") 177 | ) 178 | assert "ZeroDivisionError: division by zero " in summary_text 179 | assert "while handling path /6/0" in summary_text 180 | 181 | 182 | def test_exception_handler_lookup(): 183 | class CustomError(Exception): 184 | pass 185 | 186 | class CustomServerError(ServerError): 187 | pass 188 | 189 | def custom_error_handler(): 190 | pass 191 | 192 | def server_error_handler(): 193 | pass 194 | 195 | def import_error_handler(): 196 | pass 197 | 198 | try: 199 | ModuleNotFoundError 200 | except: 201 | 202 | class ModuleNotFoundError(ImportError): 203 | pass 204 | 205 | try: 206 | handler = ErrorHandler() 207 | except TypeError: 208 | handler = ErrorHandler("auto") 209 | handler.add(ImportError, import_error_handler) 210 | handler.add(CustomError, custom_error_handler) 211 | handler.add(ServerError, server_error_handler) 212 | 213 | assert handler.lookup(ImportError()) == import_error_handler 214 | assert handler.lookup(ModuleNotFoundError()) == import_error_handler 215 | assert handler.lookup(CustomError()) == custom_error_handler 216 | assert handler.lookup(ServerError('Error')) == server_error_handler 217 | assert handler.lookup(CustomServerError('Error')) == server_error_handler 218 | 219 | # once again to ensure there is no caching bug 220 | assert handler.lookup(ImportError()) == import_error_handler 221 | assert handler.lookup(ModuleNotFoundError()) == import_error_handler 222 | assert handler.lookup(CustomError()) == custom_error_handler 223 | assert handler.lookup(ServerError('Error')) == server_error_handler 224 | assert handler.lookup(CustomServerError('Error')) == server_error_handler 225 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Sanic Plugin Toolkit 2 | ==================== 3 | 4 | |Build Status| |Latest Version| |Supported Python versions| |License| 5 | 6 | Welcome to the Sanic Plugin Toolkit. 7 | 8 | The Sanic Plugin Toolkit (SPTK) is a lightweight python library aimed at making it as simple as possible to build 9 | plugins for the Sanic Async HTTP Server. 10 | 11 | The SPTK provides a `SanicPlugin` python base object that your plugin can build upon. It is set up with all of the basic 12 | functionality that the majority of Sanic Plugins will need. 13 | 14 | A SPTK Sanic Plugin is implemented in a similar way to Sanic Blueprints. You can use convenience decorators to set up all 15 | of the routes, middleware, exception handlers, and listeners your plugin uses in the same way you would a blueprint, 16 | and any Application developer can import your plugin and register it into their application. 17 | 18 | The Sanic Plugin Toolkit is more than just a Blueprints-like system for Plugins. It provides an enhanced middleware 19 | system, and manages Context objects. 20 | 21 | **Notice:** Please update to SPTK v0.90.1 if you need compatibility with Sanic v21.03+. 22 | 23 | The Enhanced Middleware System 24 | ------------------------------ 25 | 26 | The Middleware system in the Sanic Plugin Toolkit both builds upon and extends the native Sanic middleware system. 27 | Rather than simply having two middleware queues ('request', and 'response'), the middleware system in SPF uses five 28 | additional queues. 29 | 30 | - Request-Pre: These middleware run *before* the application's own request middleware. 31 | - Request-Post: These middleware run *after* the application's own request middleware. 32 | - Response-Pre: These middleware run *before* the application's own response middleware. 33 | - Response-Post: These middleware run *after* the application's own response middleware. 34 | - Cleanup: These middleware run *after* all of the above middleware, and are run after a response is sent, and are run even if response is None. 35 | 36 | So as a plugin developer you can choose whether you need your middleware to be executed before or after the 37 | application's own middleware. 38 | 39 | You can also assign a priority to each of your plugin's middleware so you can more precisely control the order in which 40 | your middleware is executed, especially when the application is using multiple plugins. 41 | 42 | The Context Object Manager 43 | -------------------------- 44 | 45 | One feature that many find missing from Sanic is a context object. SPF provides multiple context objects that can be 46 | used for different purposes. 47 | 48 | - A shared context: All plugins registered in the SPF have access to a shared, persistent context object, which anyone can read and write to. 49 | - A per-request context: All plugins get access to a shared temporary context object anyone can read and write to that is created at the start of a request, and deleted when a request is completed. 50 | - A per-plugin context: All plugins get their own private persistent context object that only that plugin can read and write to. 51 | - A per-plugin per-request context: All plugins get a temporary private context object that is created at the start of a request, and deleted when a request is completed. 52 | 53 | 54 | Installation 55 | ------------ 56 | 57 | Install the extension with using pip, or easy\_install. 58 | 59 | .. code:: bash 60 | 61 | $ pip install -U sanic-plugin-toolkit 62 | 63 | Usage 64 | ----- 65 | 66 | A simple plugin written using the Sanic Plugin Toolkit will look like this: 67 | 68 | .. code:: python 69 | 70 | # Source: my_plugin.py 71 | from sanic_plugin_toolkit import SanicPlugin 72 | from sanic.response import text 73 | 74 | class MyPlugin(SanicPlugin): 75 | def __init__(self, *args, **kwargs): 76 | super(MyPlugin, self).__init__(*args, **kwargs) 77 | # do pre-registration plugin init here. 78 | # Note, context objects are not accessible here. 79 | 80 | def on_registered(self, context, reg, *args, **kwargs): 81 | # do post-registration plugin init here 82 | # We have access to our context and the shared context now. 83 | context.my_private_var = "Private variable" 84 | shared = context.shared 85 | shared.my_shared_var = "Shared variable" 86 | 87 | my_plugin = MyPlugin() 88 | 89 | # You don't need to add any parameters to @middleware, for default behaviour 90 | # This is completely compatible with native Sanic middleware behaviour 91 | @my_plugin.middleware 92 | def my_middleware(request) 93 | h = request.headers 94 | #Do request middleware things here 95 | 96 | #You can tune the middleware priority, and add a context param like this 97 | #Priority must be between 0 and 9 (inclusive). 0 is highest priority, 9 the lowest. 98 | #If you don't specify an 'attach_to' parameter, it is a 'request' middleware 99 | @my_plugin.middleware(priority=6, with_context=True) 100 | def my_middleware2(request, context): 101 | context['test1'] = "test" 102 | print("Hello world") 103 | 104 | #Add attach_to='response' to make this a response middleware 105 | @my_plugin.middleware(attach_to='response', with_context=True) 106 | def my_middleware3(request, response, context): 107 | # Do response middleware here 108 | return response 109 | 110 | #Add relative='pre' to make this a response middleware run _before_ the 111 | #application's own response middleware 112 | @my_plugin.middleware(attach_to='response', relative='pre', with_context=True) 113 | def my_middleware4(request, response, context): 114 | # Do response middleware here 115 | return response 116 | 117 | #Add attach_to='cleanup' to make this run even when the Response is None. 118 | #This queue is fired _after_ response is already sent to the client. 119 | @my_plugin.middleware(attach_to='cleanup', with_context=True) 120 | def my_middleware5(request, context): 121 | # Do per-request cleanup here. 122 | return None 123 | 124 | #Add your plugin routes here. You can even choose to have your context passed in to the route. 125 | @my_plugin.route('/test_plugin', with_context=True) 126 | def t1(request, context): 127 | words = context['test1'] 128 | return text('from plugin! {}'.format(words)) 129 | 130 | 131 | The Application developer can use your plugin in their code like this: 132 | 133 | .. code:: python 134 | 135 | # Source: app.py 136 | from sanic import Sanic 137 | from sanic_plugin_toolkit import SanicPluginRealm 138 | from sanic.response import text 139 | import my_plugin 140 | 141 | app = Sanic(__name__) 142 | realm = SanicPluginRealm(app) 143 | assoc = realm.register_plugin(my_plugin) 144 | 145 | # ... rest of user app here 146 | 147 | 148 | There is support for using a config file to define the list of plugins to load when SPF is added to an App. 149 | 150 | .. code:: ini 151 | 152 | # Source: sptk.ini 153 | [plugins] 154 | MyPlugin 155 | AnotherPlugin=ExampleArg,False,KWArg1=True,KWArg2=33.3 156 | 157 | .. code:: python 158 | 159 | # Source: app.py 160 | app = Sanic(__name__) 161 | app.config['SPTK_LOAD_INI'] = True 162 | app.config['SPTK_INI_FILE'] = 'sptk.ini' 163 | realm = SanicPluginRealm(app) 164 | 165 | # We can get the assoc object from SPF, it is already registered 166 | assoc = spf.get_plugin_assoc('MyPlugin') 167 | 168 | Or if the developer prefers to do it the old way, (like the Flask way), they can still do it like this: 169 | 170 | .. code:: python 171 | 172 | # Source: app.py 173 | from sanic import Sanic 174 | from sanic.response import text 175 | from my_plugin import MyPlugin 176 | 177 | app = Sanic(__name__) 178 | # this magically returns your previously initialized instance 179 | # from your plugin module, if it is named `my_plugin` or `instance`. 180 | assoc = MyPlugin(app) 181 | 182 | # ... rest of user app here 183 | 184 | Contributing 185 | ------------ 186 | 187 | Questions, comments or improvements? Please create an issue on 188 | `Github `__ 189 | 190 | Credits 191 | ------- 192 | 193 | Ashley Sommer `ashleysommer@gmail.com `__ 194 | 195 | 196 | .. |Build Status| image:: https://api.travis-ci.org/ashleysommer/sanic-plugin-toolkit.svg?branch=master 197 | :target: https://travis-ci.org/ashleysommer/sanic-plugin-toolkit 198 | 199 | .. |Latest Version| image:: https://img.shields.io/pypi/v/sanic-plugin-toolkit.svg 200 | :target: https://pypi.python.org/pypi/sanic-plugin-toolkit/ 201 | 202 | .. |Supported Python versions| image:: https://img.shields.io/pypi/pyversions/sanic-plugin-toolkit.svg 203 | :target: https://img.shields.io/pypi/pyversions/sanic-plugin-toolkit.svg 204 | 205 | .. |License| image:: http://img.shields.io/:license-mit-blue.svg 206 | :target: https://pypi.python.org/pypi/sanic-plugin-toolkit/ 207 | -------------------------------------------------------------------------------- /sanic_plugin_toolkit/context.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | This is the specialised dictionary that is used by Sanic Plugin Toolkit 4 | to manage Context objects. It can be hierarchical, and it searches its 5 | parents if it cannot find an item in its own dictionary. It can create its 6 | own children. 7 | """ 8 | 9 | 10 | class HierDict(object): 11 | """ 12 | This is the specialised dictionary that is used by the Sanic Plugin Toolkit 13 | to manage Context objects. It can be hierarchical, and it searches its 14 | parents if it cannot find an item in its own dictionary. It can create its 15 | own children. 16 | """ 17 | 18 | __slots__ = ('_parent_hd', '_dict', '__weakref__') 19 | 20 | @classmethod 21 | def _iter_slots(cls): 22 | use_cls = cls 23 | bases = cls.__bases__ 24 | base_count = 0 25 | while True: 26 | if use_cls.__slots__: 27 | for _s in use_cls.__slots__: 28 | yield _s 29 | if base_count >= len(bases): 30 | break 31 | use_cls = bases[base_count] 32 | base_count += 1 33 | return 34 | 35 | def _inner(self): 36 | """ 37 | :return: the internal dictionary 38 | :rtype: dict 39 | """ 40 | return object.__getattribute__(self, '_dict') 41 | 42 | def __repr__(self): 43 | _dict_repr = repr(self._inner()) 44 | return "HierDict({:s})".format(_dict_repr) 45 | 46 | def __str__(self): 47 | _dict_str = str(self._inner()) 48 | return "HierDict({:s})".format(_dict_str) 49 | 50 | def __len__(self): 51 | return len(self._inner()) 52 | 53 | def __setitem__(self, key, value): 54 | # TODO: If key is in __slots__, ignore it and return 55 | return self._inner().__setitem__(key, value) 56 | 57 | def __getitem__(self, item): 58 | try: 59 | return self._inner().__getitem__(item) 60 | except KeyError as e1: 61 | parents_searched = [self] 62 | parent = self._parent_hd 63 | while parent: 64 | try: 65 | return parent._inner().__getitem__(item) 66 | except KeyError: 67 | parents_searched.append(parent) 68 | # noinspection PyProtectedMember 69 | next_parent = parent._parent_hd 70 | if next_parent in parents_searched: 71 | raise RuntimeError("Recursive HierDict found!") 72 | parent = next_parent 73 | raise e1 74 | 75 | def __delitem__(self, key): 76 | self._inner().__delitem__(key) 77 | 78 | def __getattr__(self, item): 79 | if item in self._iter_slots(): 80 | return object.__getattribute__(self, item) 81 | try: 82 | return self.__getitem__(item) 83 | except KeyError as e: 84 | raise AttributeError(*e.args) 85 | 86 | def __setattr__(self, key, value): 87 | if key in self._iter_slots(): 88 | if key == '__weakref__': 89 | if value is None: 90 | return 91 | else: 92 | raise ValueError("Cannot set weakrefs on Context") 93 | return object.__setattr__(self, key, value) 94 | try: 95 | return self.__setitem__(key, value) 96 | except Exception as e: # pragma: no cover 97 | # what exceptions can occur on setting an item? 98 | raise e 99 | 100 | def __contains__(self, item): 101 | return self._inner().__contains__(item) 102 | 103 | def get(self, key, default=None): 104 | try: 105 | return self.__getattr__(key) 106 | except (AttributeError, KeyError): 107 | return default 108 | 109 | def set(self, key, value): 110 | try: 111 | return self.__setattr__(key, value) 112 | except Exception as e: # pragma: no cover 113 | raise e 114 | 115 | def items(self): 116 | """ 117 | A set-like read-only view HierDict's (K,V) tuples 118 | :return: 119 | :rtype: frozenset 120 | """ 121 | return self._inner().items() 122 | 123 | def keys(self): 124 | """ 125 | An object containing a view on the HierDict's keys 126 | :return: 127 | :rtype: tuple # using tuple to represent an immutable list 128 | """ 129 | return self._inner().keys() 130 | 131 | def values(self): 132 | """ 133 | An object containing a view on the HierDict's values 134 | :return: 135 | :rtype: tuple # using tuple to represent an immutable list 136 | """ 137 | return self._inner().values() 138 | 139 | def replace(self, key, value): 140 | """ 141 | If this HierDict doesn't already have this key, it sets 142 | the value on a parent HierDict if that parent has the key, 143 | otherwise sets the value on this HierDict. 144 | :param key: 145 | :param value: 146 | :return: Nothing 147 | :rtype: None 148 | """ 149 | if key in self._inner().keys(): 150 | return self.__setitem__(key, value) 151 | parents_searched = [self] 152 | parent = self._parent_hd 153 | while parent: 154 | try: 155 | if key in parent.keys(): 156 | return parent.__setitem__(key, value) 157 | except (KeyError, AttributeError): 158 | pass 159 | parents_searched.append(parent) 160 | # noinspection PyProtectedMember 161 | next_parent = parent._parent_context 162 | if next_parent in parents_searched: 163 | raise RuntimeError("Recursive HierDict found!") 164 | parent = next_parent 165 | return self.__setitem__(key, value) 166 | 167 | # noinspection PyPep8Naming 168 | def update(self, E=None, **F): 169 | """ 170 | Update HierDict from dict/iterable E and F 171 | :return: Nothing 172 | :rtype: None 173 | """ 174 | if E is not None: 175 | if hasattr(E, 'keys'): 176 | for K in E: 177 | self.replace(K, E[K]) 178 | elif hasattr(E, 'items'): 179 | for K, V in E.items(): 180 | self.replace(K, V) 181 | else: 182 | for K, V in E: 183 | self.replace(K, V) 184 | for K in F: 185 | self.replace(K, F[K]) 186 | 187 | def __new__(cls, parent, *args, **kwargs): 188 | self = super(HierDict, cls).__new__(cls) 189 | self._dict = dict(*args, **kwargs) 190 | if parent is not None: 191 | assert isinstance(parent, HierDict), "Parent context must be a valid initialised HierDict" 192 | self._parent_hd = parent 193 | else: 194 | self._parent_hd = None 195 | return self 196 | 197 | def __init__(self, *args, **kwargs): 198 | args = list(args) 199 | args.pop(0) # remove parent 200 | super(HierDict, self).__init__() 201 | 202 | def __getstate__(self): 203 | state_dict = {} 204 | for s in HierDict.__slots__: 205 | if s == "__weakref__": 206 | continue 207 | state_dict[s] = object.__getattribute__(self, s) 208 | return state_dict 209 | 210 | def __setstate__(self, state): 211 | for s, v in state.items(): 212 | setattr(self, s, v) 213 | 214 | def __reduce__(self): 215 | state_dict = self.__getstate__() 216 | _ = state_dict.pop('_stk_realm', None) 217 | parent_context = state_dict.pop('_parent_hd') 218 | return (HierDict.__new__, (self.__class__, parent_context), state_dict) 219 | 220 | 221 | class SanicContext(HierDict): 222 | __slots__ = ('_stk_realm',) 223 | 224 | def __repr__(self): 225 | _dict_repr = repr(self._inner()) 226 | return "SanicContext({:s})".format(_dict_repr) 227 | 228 | def __str__(self): 229 | _dict_str = str(self._inner()) 230 | return "SanicContext({:s})".format(_dict_str) 231 | 232 | def create_child_context(self, *args, **kwargs): 233 | return SanicContext(self._stk_realm, self, *args, **kwargs) 234 | 235 | def __new__(cls, stk_realm, parent, *args, **kwargs): 236 | if parent is not None: 237 | assert isinstance(parent, SanicContext), "Parent context must be a valid initialised SanicContext" 238 | self = super(SanicContext, cls).__new__(cls, parent, *args, **kwargs) 239 | self._stk_realm = stk_realm 240 | return self 241 | 242 | def __init__(self, *args, **kwargs): 243 | args = list(args) 244 | # remove realm 245 | _stk_realm = args.pop(0) # noqa: F841 246 | super(SanicContext, self).__init__(*args) 247 | 248 | def __getstate__(self): 249 | state_dict = super(SanicContext, self).__getstate__() 250 | for s in SanicContext.__slots__: 251 | state_dict[s] = object.__getattribute__(self, s) 252 | return state_dict 253 | 254 | def __reduce__(self): 255 | state_dict = self.__getstate__() 256 | realm = state_dict.pop('_stk_realm') 257 | parent_context = state_dict.pop('_parent_hd') 258 | return (SanicContext.__new__, (self.__class__, realm, parent_context), state_dict) 259 | 260 | def for_request(self, req): 261 | # shortcut for context.request[id(req)] 262 | requests_ctx = self.request 263 | return requests_ctx[id(req)] if req else None 264 | -------------------------------------------------------------------------------- /sanic_plugin_toolkit/plugins/contextualize.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import namedtuple 3 | 4 | from sanic_plugin_toolkit import SanicPlugin 5 | from sanic_plugin_toolkit.plugin import SANIC_21_6_0, SANIC_21_9_0, SANIC_VERSION, FutureMiddleware, FutureRoute 6 | 7 | 8 | ContextualizeAssociatedTuple = namedtuple('ContextualizeAssociatedTuple', ['plugin', 'reg']) 9 | 10 | 11 | class ContextualizeAssociated(ContextualizeAssociatedTuple): 12 | __slots__ = () 13 | 14 | # Decorator 15 | def middleware(self, *args, **kwargs): 16 | """Decorate and register middleware 17 | :param args: captures all of the positional arguments passed in 18 | :type args: tuple(Any) 19 | :param kwargs: captures the keyword arguments passed in 20 | :type kwargs: dict(Any) 21 | :return: The middleware function to use as the decorator 22 | :rtype: fn 23 | """ 24 | kwargs.setdefault('priority', 5) 25 | kwargs.setdefault('relative', None) 26 | kwargs.setdefault('attach_to', None) 27 | kwargs['with_context'] = True # This is the whole point of this plugin 28 | plugin = self.plugin 29 | reg = self.reg 30 | 31 | if len(args) == 1 and callable(args[0]): 32 | middle_f = args[0] 33 | return plugin._add_new_middleware(reg, middle_f, **kwargs) 34 | 35 | def wrapper(middle_f): 36 | nonlocal plugin, reg 37 | nonlocal args, kwargs 38 | return plugin._add_new_middleware(reg, middle_f, *args, **kwargs) 39 | 40 | return wrapper 41 | 42 | def route(self, uri, *args, **kwargs): 43 | """Create a plugin route from a decorated function. 44 | :param uri: endpoint at which the route will be accessible. 45 | :type uri: str 46 | :param args: captures all of the positional arguments passed in 47 | :type args: tuple(Any) 48 | :param kwargs: captures the keyword arguments passed in 49 | :type kwargs: dict(Any) 50 | :return: The exception function to use as the decorator 51 | :rtype: fn 52 | """ 53 | if len(args) == 0 and callable(uri): 54 | raise RuntimeError("Cannot use the @route decorator without " "arguments.") 55 | kwargs.setdefault('methods', frozenset({'GET'})) 56 | kwargs.setdefault('host', None) 57 | kwargs.setdefault('strict_slashes', False) 58 | kwargs.setdefault('stream', False) 59 | kwargs.setdefault('name', None) 60 | kwargs.setdefault('version', None) 61 | kwargs.setdefault('ignore_body', False) 62 | kwargs.setdefault('websocket', False) 63 | kwargs.setdefault('subprotocols', None) 64 | kwargs.setdefault('unquote', False) 65 | kwargs.setdefault('static', False) 66 | if SANIC_21_6_0 <= SANIC_VERSION: 67 | kwargs.setdefault('version_prefix', '/v') 68 | if SANIC_21_9_0 <= SANIC_VERSION: 69 | kwargs.setdefault('error_format', None) 70 | kwargs['with_context'] = True # This is the whole point of this plugin 71 | plugin = self.plugin 72 | reg = self.reg 73 | 74 | def wrapper(handler_f): 75 | nonlocal plugin, reg 76 | nonlocal uri, args, kwargs 77 | return plugin._add_new_route(reg, uri, handler_f, *args, **kwargs) 78 | 79 | return wrapper 80 | 81 | def listener(self, event, *args, **kwargs): 82 | """Create a listener from a decorated function. 83 | :param event: Event to listen to. 84 | :type event: str 85 | :param args: captures all of the positional arguments passed in 86 | :type args: tuple(Any) 87 | :param kwargs: captures the keyword arguments passed in 88 | :type kwargs: dict(Any) 89 | :return: The function to use as the listener 90 | :rtype: fn 91 | """ 92 | if len(args) == 1 and callable(args[0]): 93 | raise RuntimeError("Cannot use the @listener decorator without " "arguments") 94 | kwargs['with_context'] = True # This is the whole point of this plugin 95 | plugin = self.plugin 96 | reg = self.reg 97 | 98 | def wrapper(listener_f): 99 | nonlocal plugin, reg 100 | nonlocal event, args, kwargs 101 | return plugin._add_new_listener(reg, event, listener_f, *args, **kwargs) 102 | 103 | return wrapper 104 | 105 | def websocket(self, uri, *args, **kwargs): 106 | """Create a websocket route from a decorated function 107 | # Deprecated. Use @contextualize.route("/path", websocket=True) 108 | """ 109 | 110 | kwargs["websocket"] = True 111 | kwargs["with_context"] = True # This is the whole point of this plugin 112 | 113 | return self.route(uri, *args, **kwargs) 114 | 115 | 116 | class Contextualize(SanicPlugin): 117 | __slots__ = () 118 | 119 | AssociatedTuple = ContextualizeAssociated 120 | 121 | def _add_new_middleware(self, reg, middle_f, *args, **kwargs): 122 | # A user should never call this directly. 123 | # it should be called only by the AssociatedTuple 124 | assert reg in self.registrations 125 | (realm, p_name, url_prefix) = reg 126 | context = self.get_context_from_realm(reg) 127 | # This is how we add a new middleware _after_ the plugin is registered 128 | m = FutureMiddleware(middle_f, args, kwargs) 129 | realm._register_middleware_helper(m, realm, self, context) 130 | return middle_f 131 | 132 | def _add_new_route(self, reg, uri, handler_f, *args, **kwargs): 133 | # A user should never call this directly. 134 | # it should be called only by the AssociatedTuple 135 | assert reg in self.registrations 136 | (realm, p_name, url_prefix) = reg 137 | context = self.get_context_from_realm(reg) 138 | # This is how we add a new route _after_ the plugin is registered 139 | r = FutureRoute(handler_f, uri, args, kwargs) 140 | realm._register_route_helper(r, realm, self, context, p_name, url_prefix) 141 | return handler_f 142 | 143 | def _add_new_listener(self, reg, event, listener_f, *args, **kwargs): 144 | # A user should never call this directly. 145 | # it should be called only by the AssociatedTuple 146 | assert reg in self.registrations 147 | (realm, p_name, url_prefix) = reg 148 | context = self.get_context_from_realm(reg) 149 | # This is how we add a new listener _after_ the plugin is registered 150 | realm._plugin_register_listener(event, listener_f, self, context, *args, **kwargs) 151 | return listener_f 152 | 153 | # Decorator 154 | def middleware(self, *args, **kwargs): 155 | """Decorate and register middleware 156 | :param args: captures all of the positional arguments passed in 157 | :type args: tuple(Any) 158 | :param kwargs: captures the keyword arguments passed in 159 | :type kwargs: dict(Any) 160 | :return: The middleware function to use as the decorator 161 | :rtype: fn 162 | """ 163 | kwargs.setdefault('priority', 5) 164 | kwargs.setdefault('relative', None) 165 | kwargs.setdefault('attach_to', None) 166 | kwargs['with_context'] = True # This is the whole point of this plugin 167 | if len(args) == 1 and callable(args[0]): 168 | middle_f = args[0] 169 | return super(Contextualize, self).middleware(middle_f, **kwargs) 170 | 171 | def wrapper(middle_f): 172 | nonlocal self, args, kwargs 173 | return super(Contextualize, self).middleware(*args, **kwargs)(middle_f) 174 | 175 | return wrapper 176 | 177 | # Decorator 178 | def route(self, uri, *args, **kwargs): 179 | """Create a plugin route from a decorated function. 180 | :param uri: endpoint at which the route will be accessible. 181 | :type uri: str 182 | :param args: captures all of the positional arguments passed in 183 | :type args: tuple(Any) 184 | :param kwargs: captures the keyword arguments passed in 185 | :type kwargs: dict(Any) 186 | :return: The exception function to use as the decorator 187 | :rtype: fn 188 | """ 189 | if len(args) == 0 and callable(uri): 190 | raise RuntimeError("Cannot use the @route decorator without arguments.") 191 | kwargs.setdefault('methods', frozenset({'GET'})) 192 | kwargs.setdefault('host', None) 193 | kwargs.setdefault('strict_slashes', False) 194 | kwargs.setdefault('stream', False) 195 | kwargs.setdefault('name', None) 196 | kwargs.setdefault('version', None) 197 | kwargs.setdefault('ignore_body', False) 198 | kwargs.setdefault('websocket', False) 199 | kwargs.setdefault('subprotocols', None) 200 | kwargs.setdefault('unquote', False) 201 | kwargs.setdefault('static', False) 202 | kwargs['with_context'] = True # This is the whole point of this plugin 203 | 204 | def wrapper(handler_f): 205 | nonlocal self, uri, args, kwargs 206 | return super(Contextualize, self).route(uri, *args, **kwargs)(handler_f) 207 | 208 | return wrapper 209 | 210 | # Decorator 211 | def listener(self, event, *args, **kwargs): 212 | """Create a listener from a decorated function. 213 | :param event: Event to listen to. 214 | :type event: str 215 | :param args: captures all of the positional arguments passed in 216 | :type args: tuple(Any) 217 | :param kwargs: captures the keyword arguments passed in 218 | :type kwargs: dict(Any) 219 | :return: The exception function to use as the listener 220 | :rtype: fn 221 | """ 222 | if len(args) == 1 and callable(args[0]): 223 | raise RuntimeError("Cannot use the @listener decorator without arguments") 224 | kwargs['with_context'] = True # This is the whole point of this plugin 225 | 226 | def wrapper(listener_f): 227 | nonlocal self, event, args, kwargs 228 | return super(Contextualize, self).listener(event, *args, **kwargs)(listener_f) 229 | 230 | return wrapper 231 | 232 | def websocket(self, uri, *args, **kwargs): 233 | """Create a websocket route from a decorated function 234 | # Deprecated. Use @contextualize.route("/path",websocket=True) 235 | """ 236 | 237 | kwargs["websocket"] = True 238 | kwargs["with_context"] = True # This is the whole point of this plugin 239 | 240 | return self.route(uri, *args, **kwargs) 241 | 242 | def __init__(self, *args, **kwargs): 243 | super(Contextualize, self).__init__(*args, **kwargs) 244 | 245 | 246 | instance = contextualize = Contextualize() 247 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Sanic Plugin Toolkit 2 | ==================== 3 | 4 | 1.2.1 5 | ------ 6 | - Misc bugfixes for Sanic v21.3, and v21.9 7 | - Fix plugins with static dirs, on sanic v21.9 8 | - Explicit non-compatibility with Sanic v21.12+ 9 | 10 | 1.2.0 11 | ------ 12 | - Fix compatibility with Sanic v21.9 13 | - Monkey-patch _startup() to inject SPTK into the app _after_ Touchup is run 14 | - Fix tests on sanic v21.9 15 | 16 | 1.1.0 17 | ------ 18 | - Fix bugs when using SPTK on Sanic v21.6+ 19 | - Fixed project name in pyproject.toml 20 | - Updated to latest poetry, poetry-core, and black 21 | - Fixed some bad typos in the Makefile 22 | 23 | 1.0.1 24 | ------ 25 | - Suppress deprecation warnings when injecting SPTK into Sanic App or Sanic Blueprint 26 | - Utilize the app.ctx (and bp.ctx) Namespace in Sanic 21.3+ instead of using the sanic config dict 27 | 28 | 1.0.0 29 | ------ 30 | - Fix compatibility with Sanic 21.3 31 | - Minor fixes 32 | - Cleanup for v1.0.0 release 33 | - Release for Sanic 21.3.1+ Only! 34 | 35 | 0.99.1 36 | ------ 37 | - Project Renamed to Sanic Plugin Toolkit 38 | - Module is renamed from spf to sanic_plugin_toolkit (fixes #16) 39 | - Changed to PEP517/518 project, with pyproject.toml and Poetry 40 | - removed setup.py, setup.cfg, environment.yml, tox.ini 41 | 42 | 0.9.5 43 | ----------- 44 | - Fixed some small bugs that were found during the larger big rewrite 45 | - Pinned this series of SPF to maximum Sanic v20.12.x, this series will not work on Sanic 21.x 46 | 47 | - A new version of SanicPluginsFramework named SanicPluginToolkit in development that will work on Sanic 21.x 48 | - It will have the module renamed to sanic_plugin_toolkit to avoid the conflict with the other `spf` library on Pip. 49 | - It will be a PEP 517/518 project, with pyproject.toml and Poetry orchestration. 50 | - New features in Sanic 21.x have necessitated some big changes in SanicPluginToolkit (this is a good thing!) 51 | 52 | 0.9.4.post2 53 | ----------- 54 | - Pinned this series of SPF to maximum Sanic v20.12.x, this series will not work on Sanic 21.x 55 | 56 | - A new version of SanicPluginsFramework is in development that will work on Sanic 21.x 57 | - It will have a new module name to avoid the conflict with the other `spf` library on Pip. 58 | - It will be a PEP 517/518 project, with pyproject.toml and Poetry orchestration. 59 | - New features in Sanic 21.x will necessitate some big changes in SanicPluginsFramework (this is a good thing!) 60 | 61 | 62 | 0.9.4.post1 63 | ----------- 64 | - Add ``setuptools`` as a specific requirement to this project. 65 | 66 | - It is needed for the entrypoints-based plugin auto-discovery feature 67 | - ``setuptools`` is not always included in a python distribution, so we cannot assume it will be there 68 | - Pinned to ``>40.0`` for now, but will likely change when we migrate to a Poetry/PEP517-based project 69 | 70 | 71 | 0.9.4 72 | ----------- 73 | - If the Sanic server emits a "before_server_start" event, use this to initialize SPF, instead of the 74 | "after_server_start" event. 75 | 76 | - This solves a potential race-condition introduced in SPF v0.8.2, when this was reversed. 77 | - Changed the RuntimeError thrown in that circumstance to a Sanic ``ServerError`` 78 | 79 | - This may make the error easier to catch and filter. Also may change what the end-user sees when this occurs. 80 | 81 | 82 | 0.9.3 83 | ----------- 84 | - Fixed calling routes on a SPF-enabled Sanic app using asgi_client before the app is started. 85 | - Clarified error message generated when a SPF-enabled route is called before the Sanic server is booted. 86 | - Fixed test breakages for Sanic 20.3 and 20.6 releases 87 | - Updated testing packages in requirements-dev 88 | - Updated Travis and TOX to include python 3.8 tests 89 | 90 | 91 | 0.9.2 92 | ----------- 93 | - Added a convenience feature on SanicContext class to get the request-context for a given request 94 | - Added correct licence file to LICENSE.txt 95 | 96 | - Existing one was accidentally a copy of the old Sanic-CORS licence file 97 | - Renamed from LICENSE to LICENSE.txt 98 | 99 | 100 | 0.9.1 101 | ----------- 102 | - Fixed a problem with error reporting when a plugin is not yet registered on the SPF 103 | 104 | 105 | 0.9.0 106 | ----------- 107 | - Released 0.9.0 with Sanic 19.12LTS compatibility 108 | - Minimum supported sanic version is 18.12LTS 109 | 110 | 111 | 0.9.0.b1 112 | ----------- 113 | - New minimum supported sanic version is 18.12LTS 114 | - Fixed bugs with Sanic 19.12LTS 115 | - Fixed registering plugin routes on blueprints 116 | - Tested more on blueprints 117 | - Added python3.7 tests to tox, and travis 118 | - Max supported sanic version for this release series is unknown for now. 119 | 120 | 121 | 0.8.2.post1 122 | ----------- 123 | - Explicitly set max Sanic version supported to 19.6.3 124 | - This is the last SPF version to support Sanic v0.8.3 125 | 126 | - (please update to 18.12 or greater if you are still on 0.8.3) 127 | 128 | 129 | 0.8.2 130 | ----- 131 | - Change all usages of "before_server_start" to "after_server_start" 132 | 133 | - The logic is basically the same, and this ensures compatibility with external servers, like ASGI mode, and using gunicorn runner, etc. 134 | 135 | 136 | 0.8.1 137 | ----- 138 | - Plugin names in the config file are now case insensitive 139 | - Plugin names exported using entrypoints are now case insensitive 140 | 141 | 0.8.0 142 | ----- 143 | - Added support for a spf config file 144 | 145 | - This is in the python configparser format, it is like an INI file. 146 | - See the config file example in /examples/ for how to use it. 147 | 148 | - Added ability to get a plugin assoc object from SPF, simply by asking for the plugin name. 149 | 150 | - This is to facilitate pulling the assoc object from when a plugin was registered via the config file 151 | 152 | - A new way of advertising sanic plugins using setup.py entrypoints is defined. 153 | 154 | - We use it in this project to advertise the 'Contextualize' plugin. 155 | 156 | - Fixed some example files. 157 | 158 | 0.7.0 159 | ----- 160 | - Added a new type of middleware called "cleanup" middleware 161 | 162 | - It Runs after response middleware, whether response is generated or not, and even if there was errors. 163 | - Moved the request-context removal process to run in the "cleanup" middleware step, because sometimes Response middleware is not run, eg. if Response is None (like in the case of a Websocket route), then Response Middleware will never fire. 164 | - Cleanup middleware can be used to do per-request cleanup to prevent memory leaks. 165 | 166 | 0.6.7 167 | ----- 168 | - A critical fix for plugin-private-request contexts. They were always overwriting the shared request context when they were created. 169 | - Added new 'id' field inside the private request context container and the shared request context container, to tell them apart when they are used. 170 | - Added a new test for this exact issue. 171 | 172 | 0.6.6 173 | ----- 174 | - No 1.0 yet, there are more features planed before we call SPF ready for 1.0. 175 | - Add more tests, and start filling in some missing test coverage 176 | - Fix a couple of bugs already uncovered by filling in coverage. 177 | 178 | - Notably, fix an issue that was preventing the plugin static file helper from working. 179 | 180 | 181 | 0.6.5 182 | ----- 183 | - Changed the versioning scheme to not include ".devN" suffixes. This was preventing SPF from being installed using ``pipenv`` 184 | 185 | - This is in preparation for a 1.0.0 release, to coincide with the Sanic 2018.12 release. 186 | 187 | 188 | 0.6.4.dev20181101 189 | ----------------- 190 | - Made changes in order for SPF, and Sanic Plugins to be pickled 191 | - This fixes the ability for SPF-enabled Sanic Apps to use ``workers=`` on Windows, to allow multiprocessing. 192 | 193 | - Added ``__setstate__``, ``__getstate__``, and ``__reduce__`` methods to all SPF classes 194 | - Change usages of PriorityQueue to collections.deque (PriorityQueue cannot be pickled because it is a synchronous class) 195 | - Changed the "name" part of all namedtuples to be the same name as the attribute key on the module they are declared in. This is necessary in order to be able to de-pickle a namedtuple object. 196 | 197 | - This *may* be a breaking change? 198 | 199 | - No longer store our own logger, because they cannot be picked. Just use the global logger provided by ``sanic.log.logger`` 200 | 201 | 202 | 0.6.3.dev20180717 203 | ----------------- 204 | - Added listener functions to contextualize plugin, 205 | - added a new example for using sqlalchemy with contextualize plugin 206 | - Misc fixes 207 | 208 | 209 | 0.6.2.dev20180617 210 | ----------------- 211 | - SanicPluginsFramework now comes with its own built-in plugin (one of possibly more to come) 212 | - The Contextualize plugin offers the shared context and enhanced middleware functions of SanicPluginsFramework, to regular Sanic users. 213 | - You no longer need to be writing a plugin in order to access features provided by SPF. 214 | - Bump version 215 | 216 | 217 | 0.6.1.dev20180616 218 | ----------------- 219 | - Fix flake problem inhibiting tox tests on travis from passing. 220 | 221 | 222 | 0.6.0.dev20180616 223 | ----------------- 224 | - Added long-awaited feature: 225 | 226 | - add Plugin Websocket routes 227 | - and add Plugin Static routes 228 | 229 | - This more-or-less completes the feature line-up for SanicPluginsFramework. 230 | - Testing is not in place for these features yet. 231 | - Bump version to 0.6.0.dev20180616 232 | 233 | 234 | 0.5.2.dev20180201 235 | ----------------- 236 | - Changed tox runner os env from ``precise`` to ``trusty``. 237 | - Pin pytest to 3.3.2 due to a major release bug in 3.4.0. 238 | 239 | 240 | 0.5.1.dev20180201 241 | ----------------- 242 | - Removed uvloop and ujson from requirements. These break on Windows. 243 | - Sanic requires these, but deals with the incompatibility on windows itself. 244 | - Also ensure requirements.txt is included in the wheel package. 245 | - Added python 3.7 to supported python versions. 246 | 247 | 248 | 0.5.0.dev20171225 249 | ----------------- 250 | - Merry Christmas! 251 | - Sanic version 0.7.0 has been out for a couple of weeks now. It is now our minimum required version. 252 | - Fixed a bug related to deleting shared context when app is a Blueprint. Thanks @huangxinping! 253 | 254 | 255 | 0.4.5.dev20171113 256 | ----------------- 257 | - Fixed error in plugin.log helper. It now calls the correct context .log function. 258 | 259 | 260 | 0.4.4.dev20171107 261 | ----------------- 262 | - Bump to version 0.4.4 because 0.4.3 broke, and PyPI wouldn't let me re-upload it with the same version. 263 | 264 | 265 | 0.4.3.dev20171107 266 | ----------------- 267 | - Fixed ContextDict to no longer be derived from ``dict``, while at the same time act more like a dictionary. 268 | - Added ability for the request context to hold more than one request at once. Use ``id(request)`` to get the correct request context from the request-specific context dict. 269 | 270 | 271 | 0.4.2.dev20171106 272 | ----------------- 273 | - Added a new namedtuple that represents a plugin registration association. 274 | - It is simply a tuple of the plugin instance, and a matching PluginRegistration. 275 | 276 | - This is needed in the Sanic-Restplus port. 277 | 278 | - Allow plugins to choose their own PluginAssociated class. 279 | 280 | 281 | 0.4.1.dev20171103 282 | ----------------- 283 | - Ensure each SPF registers only one 'before_server_start' listener, no matter how many time the SPF is used, and how many plugins are registered on the SPF. 284 | - Added a test to ensure logging works, when got the function from the context object. 285 | 286 | 287 | 0.4.0.dev20171103 288 | ----------------- 289 | Some big architecture changes. 290 | 291 | Split plugin and framework into separate files. 292 | 293 | We no longer assume the plugin is going to be registered onto only one app/blueprint. 294 | 295 | The plugin can be registered many times, onto many different SPF instances, on different apps. 296 | 297 | This means we can no longer easily get a known context object directly from the plugin instance, now the context object 298 | must be provided by the SPF that is registered on the given app. We also need to pass around the context object a bit 299 | more than we did before. While this change makes the whole framework more complicated, it now actually feels cleaner. 300 | 301 | This _should_ be enough to get Sanic-Cors ported over to SPF. 302 | 303 | Added some tests. 304 | 305 | Fixed some tests. 306 | 307 | 308 | 0.3.3.dev20171102 309 | ----------------- 310 | Fixed bug in getting the plugin context object, when using the view/route decorator feature. 311 | 312 | Got decorator-level middleware working. It runs the middleware on a per-view basis if the Plugin is not registered 313 | on the app or blueprint, when decorating a view with a plugin. 314 | 315 | 316 | 0.3.2.dev20171102 317 | ----------------- 318 | First pass cut at implementing a view-specific plugin, using a view decorator. 319 | 320 | This is very handy for when you don't want to register a plugin on the whole application (or blueprint), 321 | rather you just want the plugin to run on specific select views/routes. The main driver for this function is for 322 | porting Sanic-CORS plugin to use sanic-plugins-framework, but it will be useful for may other plugins too. 323 | 324 | 325 | 0.3.1.dev20171102 326 | ----------------- 327 | Fixed a bug when getting the spf singleton from a Blueprint 328 | 329 | This fixed Legacy-style plugin registration when using blueprints. 330 | 331 | 332 | 0.3.0.dev20171102 333 | ----------------- 334 | Plugins can now be applied to Blueprints! This is a game changer! 335 | 336 | A new url_for function for the plugin! This is a handy thing when you need it. 337 | 338 | Added a new section in the examples in the readme. 339 | 340 | Bug fixes. 341 | 342 | 343 | 0.2.0.dev20171102 344 | ----------------- 345 | Added a on_before_register hook for plugins, this is called when the plugin gets registered, but _before_ all of 346 | the Plugin's routes, middleware, tasks, and exception handlers are evaluated. This allows the Plugin Author to 347 | dynamically build routes and middleware at runtime based on the passed in configuration. 348 | 349 | Added changelog. 350 | 351 | 352 | 0.1.0.dev20171101 353 | ----------------- 354 | More features! 355 | 356 | SPF can only be instantiated once per App now. If you try to create a new SPF for a given app, it will give you back the existing one. 357 | 358 | Plugins can now be registered into SPF by using the plugin's module, and also by passing in the Class name of the plugin. Its very smart. 359 | 360 | Plugins can use the legacy method to register themselves on an app. Like ``sample_plugin = SamplePlugin(app)`` it will work correctly. 361 | 362 | More tests! 363 | 364 | FLAKE8 now runs on build, and _passes_! 365 | 366 | Misc Bug fixes. 367 | 368 | 369 | 0.1.0.20171018-1 (.post1) 370 | ------------------------- 371 | Fix readme, add shields to readme 372 | 373 | 374 | 0.1.0.20171018 375 | -------------- 376 | Bump version to trigger travis tests, and initial pypi build 377 | 378 | 379 | 0.1.0.dev1 380 | ---------- 381 | Initial release, pre-alpha. 382 | Got TOX build working with Python 3.5 and Python 3.6, with pytest tests and flake8 383 | -------------------------------------------------------------------------------- /tests/test_plugin_static.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | 4 | from time import gmtime, strftime 5 | 6 | import pytest 7 | 8 | from sanic_plugin_toolkit import SanicPlugin 9 | 10 | 11 | class TestPlugin(SanicPlugin): 12 | pass 13 | 14 | 15 | # The following tests are taken directly from Sanic source @ v0.8.2 16 | # and modified to test the SanicPlugin, rather than Sanic 17 | 18 | # ------------------------------------------------------------ # 19 | # GET 20 | # ------------------------------------------------------------ # 21 | 22 | 23 | @pytest.fixture(scope="module") 24 | def static_file_directory(): 25 | """The static directory to serve""" 26 | current_file = inspect.getfile(inspect.currentframe()) 27 | current_directory = os.path.dirname(os.path.abspath(current_file)) 28 | static_directory = os.path.join(current_directory, "static") 29 | return static_directory 30 | 31 | 32 | def get_file_path(static_file_directory, file_name): 33 | return os.path.join(static_file_directory, file_name) 34 | 35 | 36 | def get_file_content(static_file_directory, file_name): 37 | """The content of the static file to check""" 38 | with open(get_file_path(static_file_directory, file_name), "rb") as file: 39 | return file.read() 40 | 41 | 42 | @pytest.fixture(scope="module") 43 | def large_file(static_file_directory): 44 | large_file_path = os.path.join(static_file_directory, "large.file") 45 | 46 | size = 2 * 1024 * 1024 47 | with open(large_file_path, "w") as f: 48 | f.write("a" * size) 49 | 50 | yield large_file_path 51 | 52 | os.remove(large_file_path) 53 | 54 | 55 | @pytest.fixture(autouse=True, scope="module") 56 | def symlink(static_file_directory): 57 | src = os.path.abspath(os.path.join(os.path.dirname(static_file_directory), "conftest.py")) 58 | symlink = "symlink" 59 | dist = os.path.join(static_file_directory, symlink) 60 | try: 61 | os.remove(dist) 62 | except FileNotFoundError: 63 | pass 64 | os.symlink(src, dist) 65 | yield symlink 66 | os.remove(dist) 67 | 68 | 69 | @pytest.fixture(autouse=True, scope="module") 70 | def hard_link(static_file_directory): 71 | src = os.path.abspath(os.path.join(os.path.dirname(static_file_directory), "conftest.py")) 72 | hard_link = "hard_link" 73 | dist = os.path.join(static_file_directory, hard_link) 74 | try: 75 | os.remove(dist) 76 | except FileNotFoundError: 77 | pass 78 | os.link(src, dist) 79 | yield hard_link 80 | os.remove(dist) 81 | 82 | 83 | @pytest.mark.parametrize( 84 | "file_name", 85 | ["test.file", "decode me.txt", "python.png", "symlink", "hard_link"], 86 | ) 87 | def test_static_file(realm, static_file_directory, file_name): 88 | app = realm._app 89 | plugin = TestPlugin() 90 | plugin.static("/testing.file", get_file_path(static_file_directory, file_name)) 91 | realm.register_plugin(plugin) 92 | request, response = app._test_manager.test_client.get("/testing.file") 93 | assert response.status == 200 94 | assert response.body == get_file_content(static_file_directory, file_name) 95 | 96 | 97 | @pytest.mark.parametrize("file_name", ["test.html"]) 98 | def test_static_file_content_type(realm, static_file_directory, file_name): 99 | app = realm._app 100 | plugin = TestPlugin() 101 | plugin.static( 102 | "/testing.file", 103 | get_file_path(static_file_directory, file_name), 104 | content_type="text/html; charset=utf-8", 105 | ) 106 | realm.register_plugin(plugin) 107 | request, response = app._test_manager.test_client.get("/testing.file") 108 | assert response.status == 200 109 | assert response.body == get_file_content(static_file_directory, file_name) 110 | assert response.headers["Content-Type"] == "text/html; charset=utf-8" 111 | 112 | 113 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt", "symlink", "hard_link"]) 114 | @pytest.mark.parametrize("base_uri", ["/static", "", "/dir"]) 115 | def test_static_directory(realm, file_name, base_uri, static_file_directory): 116 | app = realm._app 117 | plugin = TestPlugin() 118 | plugin.static(base_uri, static_file_directory) 119 | realm.register_plugin(plugin) 120 | request, response = app._test_manager.test_client.get(uri="{}/{}".format(base_uri, file_name)) 121 | assert response.status == 200 122 | assert response.body == get_file_content(static_file_directory, file_name) 123 | 124 | 125 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) 126 | def test_static_head_request(realm, file_name, static_file_directory): 127 | app = realm._app 128 | plugin = TestPlugin() 129 | plugin.static( 130 | "/testing.file", 131 | get_file_path(static_file_directory, file_name), 132 | use_content_range=True, 133 | ) 134 | realm.register_plugin(plugin) 135 | request, response = app._test_manager.test_client.head("/testing.file") 136 | assert response.status == 200 137 | assert "Accept-Ranges" in response.headers 138 | assert "Content-Length" in response.headers 139 | assert int(response.headers["Content-Length"]) == len(get_file_content(static_file_directory, file_name)) 140 | 141 | 142 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) 143 | def test_static_content_range_correct(realm, file_name, static_file_directory): 144 | app = realm._app 145 | plugin = TestPlugin() 146 | plugin.static( 147 | "/testing.file", 148 | get_file_path(static_file_directory, file_name), 149 | use_content_range=True, 150 | ) 151 | realm.register_plugin(plugin) 152 | headers = {"Range": "bytes=12-19"} 153 | request, response = app._test_manager.test_client.get("/testing.file", headers=headers) 154 | assert response.status == 206 155 | assert "Content-Length" in response.headers 156 | assert "Content-Range" in response.headers 157 | static_content = bytes(get_file_content(static_file_directory, file_name))[12:20] 158 | assert int(response.headers["Content-Length"]) == len(static_content) 159 | assert response.body == static_content 160 | 161 | 162 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) 163 | def test_static_content_range_front(realm, file_name, static_file_directory): 164 | app = realm._app 165 | plugin = TestPlugin() 166 | plugin.static( 167 | "/testing.file", 168 | get_file_path(static_file_directory, file_name), 169 | use_content_range=True, 170 | ) 171 | realm.register_plugin(plugin) 172 | headers = {"Range": "bytes=12-"} 173 | request, response = app._test_manager.test_client.get("/testing.file", headers=headers) 174 | assert response.status == 206 175 | assert "Content-Length" in response.headers 176 | assert "Content-Range" in response.headers 177 | static_content = bytes(get_file_content(static_file_directory, file_name))[12:] 178 | assert int(response.headers["Content-Length"]) == len(static_content) 179 | assert response.body == static_content 180 | 181 | 182 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) 183 | def test_static_content_range_back(realm, file_name, static_file_directory): 184 | app = realm._app 185 | plugin = TestPlugin() 186 | plugin.static( 187 | "/testing.file", 188 | get_file_path(static_file_directory, file_name), 189 | use_content_range=True, 190 | ) 191 | realm.register_plugin(plugin) 192 | headers = {"Range": "bytes=-12"} 193 | request, response = app._test_manager.test_client.get("/testing.file", headers=headers) 194 | assert response.status == 206 195 | assert "Content-Length" in response.headers 196 | assert "Content-Range" in response.headers 197 | static_content = bytes(get_file_content(static_file_directory, file_name))[-12:] 198 | assert int(response.headers["Content-Length"]) == len(static_content) 199 | assert response.body == static_content 200 | 201 | 202 | @pytest.mark.parametrize("use_modified_since", [True, False]) 203 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) 204 | def test_static_content_range_empty(realm, file_name, static_file_directory, use_modified_since): 205 | app = realm._app 206 | plugin = TestPlugin() 207 | plugin.static( 208 | "/testing.file", 209 | get_file_path(static_file_directory, file_name), 210 | use_content_range=True, 211 | use_modified_since=use_modified_since, 212 | ) 213 | realm.register_plugin(plugin) 214 | request, response = app._test_manager.test_client.get("/testing.file") 215 | assert response.status == 200 216 | assert "Content-Length" in response.headers 217 | assert "Content-Range" not in response.headers 218 | assert int(response.headers["Content-Length"]) == len(get_file_content(static_file_directory, file_name)) 219 | assert response.body == bytes(get_file_content(static_file_directory, file_name)) 220 | 221 | 222 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) 223 | def test_static_content_range_error(realm, file_name, static_file_directory): 224 | app = realm._app 225 | plugin = TestPlugin() 226 | plugin.static( 227 | "/testing.file", 228 | get_file_path(static_file_directory, file_name), 229 | use_content_range=True, 230 | ) 231 | realm.register_plugin(plugin) 232 | headers = {"Range": "bytes=1-0"} 233 | request, response = app._test_manager.test_client.get("/testing.file", headers=headers) 234 | assert response.status == 416 235 | assert "Content-Length" in response.headers 236 | assert "Content-Range" in response.headers 237 | assert response.headers["Content-Range"] == "bytes */%s" % ( 238 | len(get_file_content(static_file_directory, file_name)), 239 | ) 240 | 241 | 242 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) 243 | def test_static_content_range_invalid_unit(realm, file_name, static_file_directory): 244 | app = realm._app 245 | plugin = TestPlugin() 246 | plugin.static( 247 | "/testing.file", 248 | get_file_path(static_file_directory, file_name), 249 | use_content_range=True, 250 | ) 251 | realm.register_plugin(plugin) 252 | unit = "bit" 253 | headers = {"Range": "{}=1-0".format(unit)} 254 | request, response = app._test_manager.test_client.get("/testing.file", headers=headers) 255 | 256 | assert response.status == 416 257 | assert "{} is not a valid Range Type".format(unit) in response.text 258 | 259 | 260 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) 261 | def test_static_content_range_invalid_start(realm, file_name, static_file_directory): 262 | app = realm._app 263 | plugin = TestPlugin() 264 | plugin.static( 265 | "/testing.file", 266 | get_file_path(static_file_directory, file_name), 267 | use_content_range=True, 268 | ) 269 | realm.register_plugin(plugin) 270 | start = "start" 271 | headers = {"Range": "bytes={}-0".format(start)} 272 | request, response = app._test_manager.test_client.get("/testing.file", headers=headers) 273 | 274 | assert response.status == 416 275 | assert "'{}' is invalid for Content Range".format(start) in response.text 276 | 277 | 278 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) 279 | def test_static_content_range_invalid_end(realm, file_name, static_file_directory): 280 | app = realm._app 281 | plugin = TestPlugin() 282 | plugin.static( 283 | "/testing.file", 284 | get_file_path(static_file_directory, file_name), 285 | use_content_range=True, 286 | ) 287 | realm.register_plugin(plugin) 288 | end = "end" 289 | headers = {"Range": "bytes=1-{}".format(end)} 290 | request, response = app._test_manager.test_client.get("/testing.file", headers=headers) 291 | 292 | assert response.status == 416 293 | assert "'{}' is invalid for Content Range".format(end) in response.text 294 | 295 | 296 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt"]) 297 | def test_static_content_range_invalid_parameters(realm, file_name, static_file_directory): 298 | app = realm._app 299 | plugin = TestPlugin() 300 | plugin.static( 301 | "/testing.file", 302 | get_file_path(static_file_directory, file_name), 303 | use_content_range=True, 304 | ) 305 | realm.register_plugin(plugin) 306 | headers = {"Range": "bytes=-"} 307 | request, response = app._test_manager.test_client.get("/testing.file", headers=headers) 308 | 309 | assert response.status == 416 310 | assert "Invalid for Content Range parameters" in response.text 311 | 312 | 313 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt", "python.png"]) 314 | def test_static_file_specified_host(realm, static_file_directory, file_name): 315 | app = realm._app 316 | plugin = TestPlugin() 317 | plugin.static( 318 | "/testing.file", 319 | get_file_path(static_file_directory, file_name), 320 | host="www.example.com", 321 | ) 322 | realm.register_plugin(plugin) 323 | headers = {"Host": "www.example.com"} 324 | request, response = app._test_manager.test_client.get("/testing.file", headers=headers) 325 | assert response.status == 200 326 | assert response.body == get_file_content(static_file_directory, file_name) 327 | request, response = app._test_manager.test_client.get("/testing.file") 328 | assert response.status == 404 329 | 330 | 331 | @pytest.mark.parametrize("use_modified_since", [True, False]) 332 | @pytest.mark.parametrize("stream_large_files", [True, 1024]) 333 | @pytest.mark.parametrize("file_name", ["test.file", "large.file"]) 334 | def test_static_stream_large_file( 335 | realm, 336 | static_file_directory, 337 | file_name, 338 | use_modified_since, 339 | stream_large_files, 340 | large_file, 341 | ): 342 | app = realm._app 343 | plugin = TestPlugin() 344 | plugin.static( 345 | "/testing.file", 346 | get_file_path(static_file_directory, file_name), 347 | use_modified_since=use_modified_since, 348 | stream_large_files=stream_large_files, 349 | ) 350 | realm.register_plugin(plugin) 351 | request, response = app._test_manager.test_client.get("/testing.file") 352 | 353 | assert response.status == 200 354 | assert response.body == get_file_content(static_file_directory, file_name) 355 | 356 | 357 | @pytest.mark.parametrize("file_name", ["test.file", "decode me.txt", "python.png"]) 358 | def test_use_modified_since(realm, static_file_directory, file_name): 359 | 360 | file_stat = os.stat(get_file_path(static_file_directory, file_name)) 361 | modified_since = strftime("%a, %d %b %Y %H:%M:%S GMT", gmtime(file_stat.st_mtime)) 362 | app = realm._app 363 | plugin = TestPlugin() 364 | plugin.static( 365 | "/testing.file", 366 | get_file_path(static_file_directory, file_name), 367 | use_modified_since=True, 368 | ) 369 | realm.register_plugin(plugin) 370 | request, response = app._test_manager.test_client.get( 371 | "/testing.file", headers={"If-Modified-Since": modified_since} 372 | ) 373 | 374 | assert response.status == 304 375 | 376 | 377 | def test_file_not_found(realm, static_file_directory): 378 | app = realm._app 379 | plugin = TestPlugin() 380 | plugin.static("/static", static_file_directory) 381 | realm.register_plugin(plugin) 382 | request, response = app._test_manager.test_client.get("/static/not_found") 383 | 384 | assert response.status == 404 385 | assert "File not found" in response.text 386 | 387 | 388 | @pytest.mark.parametrize("static_name", ["_static_name", "static"]) 389 | @pytest.mark.parametrize("file_name", ["test.html"]) 390 | def test_static_name(realm, static_file_directory, static_name, file_name): 391 | app = realm._app 392 | plugin = TestPlugin() 393 | plugin.static("/static", static_file_directory, name=static_name) 394 | realm.register_plugin(plugin) 395 | request, response = app._test_manager.test_client.get("/static/{}".format(file_name)) 396 | 397 | assert response.status == 200 398 | -------------------------------------------------------------------------------- /sanic_plugin_toolkit/plugin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import importlib 3 | 4 | from collections import defaultdict, deque, namedtuple 5 | from distutils.version import LooseVersion 6 | from functools import update_wrapper 7 | from inspect import isawaitable 8 | from typing import Type 9 | 10 | from sanic import Blueprint, Sanic 11 | from sanic import __version__ as sanic_version 12 | 13 | 14 | SANIC_VERSION = LooseVersion(sanic_version) 15 | SANIC_21_6_0 = LooseVersion("21.6.0") 16 | SANIC_21_9_0 = LooseVersion("21.9.0") 17 | 18 | CRITICAL = 50 19 | ERROR = 40 20 | WARNING = 30 21 | INFO = 20 22 | DEBUG = 10 23 | 24 | FutureMiddleware = namedtuple('FutureMiddleware', ['middleware', 'args', 'kwargs']) 25 | FutureRoute = namedtuple('FutureRoute', ['handler', 'uri', 'args', 'kwargs']) 26 | FutureWebsocket = namedtuple('FutureWebsocket', ['handler', 'uri', 'args', 'kwargs']) 27 | FutureStatic = namedtuple('FutureStatic', ['uri', 'file_or_dir', 'args', 'kwargs']) 28 | FutureException = namedtuple('FutureException', ['handler', 'exceptions', 'kwargs']) 29 | PluginRegistration = namedtuple('PluginRegistration', ['realm', 'plugin_name', 'url_prefix']) 30 | PluginAssociated = namedtuple('PluginAssociated', ['plugin', 'reg']) 31 | 32 | 33 | class SanicPlugin(object): 34 | __slots__ = ( 35 | 'registrations', 36 | '_routes', 37 | '_ws', 38 | '_static', 39 | '_middlewares', 40 | '_exceptions', 41 | '_listeners', 42 | '_initialized', 43 | '__weakref__', 44 | ) 45 | 46 | AssociatedTuple: Type[object] = PluginAssociated 47 | 48 | # Decorator 49 | def middleware(self, *args, **kwargs): 50 | """Decorate and register middleware 51 | :param args: captures all of the positional arguments passed in 52 | :type args: tuple(Any) 53 | :param kwargs: captures the keyword arguments passed in 54 | :type kwargs: dict(Any) 55 | :return: The middleware function to use as the decorator 56 | :rtype: fn 57 | """ 58 | kwargs.setdefault('priority', 5) 59 | kwargs.setdefault('relative', None) 60 | kwargs.setdefault('attach_to', None) 61 | kwargs.setdefault('with_context', False) 62 | if len(args) == 1 and callable(args[0]): 63 | middle_f = args[0] 64 | self._middlewares.append(FutureMiddleware(middle_f, args=tuple(), kwargs=kwargs)) 65 | return middle_f 66 | 67 | def wrapper(middleware_f): 68 | self._middlewares.append(FutureMiddleware(middleware_f, args=args, kwargs=kwargs)) 69 | return middleware_f 70 | 71 | return wrapper 72 | 73 | def exception(self, *args, **kwargs): 74 | """Decorate and register an exception handler 75 | :param args: captures all of the positional arguments passed in 76 | :type args: tuple(Any) 77 | :param kwargs: captures the keyword arguments passed in 78 | :type kwargs: dict(Any) 79 | :return: The exception function to use as the decorator 80 | :rtype: fn 81 | """ 82 | if len(args) == 1 and callable(args[0]): 83 | if isinstance(args[0], type) and issubclass(args[0], Exception): 84 | pass 85 | else: # pragma: no cover 86 | raise RuntimeError("Cannot use the @exception decorator without arguments") 87 | 88 | def wrapper(handler_f): 89 | self._exceptions.append(FutureException(handler_f, exceptions=args, kwargs=kwargs)) 90 | return handler_f 91 | 92 | return wrapper 93 | 94 | def listener(self, event, *args, **kwargs): 95 | """Create a listener from a decorated function. 96 | :param event: Event to listen to. 97 | :type event: str 98 | :param args: captures all of the positional arguments passed in 99 | :type args: tuple(Any) 100 | :param kwargs: captures the keyword arguments passed in 101 | :type kwargs: dict(Any) 102 | :return: The function to use as the listener 103 | :rtype: fn 104 | """ 105 | if len(args) == 1 and callable(args[0]): # pragma: no cover 106 | raise RuntimeError("Cannot use the @listener decorator without arguments") 107 | 108 | def wrapper(listener_f): 109 | if len(kwargs) > 0: 110 | listener_f = (listener_f, kwargs) 111 | self._listeners[event].append(listener_f) 112 | return listener_f 113 | 114 | return wrapper 115 | 116 | def route(self, uri, *args, **kwargs): 117 | """Create a plugin route from a decorated function. 118 | :param uri: endpoint at which the route will be accessible. 119 | :type uri: str 120 | :param args: captures all of the positional arguments passed in 121 | :type args: tuple(Any) 122 | :param kwargs: captures the keyword arguments passed in 123 | :type kwargs: dict(Any) 124 | :return: The function to use as the decorator 125 | :rtype: fn 126 | """ 127 | if len(args) == 0 and callable(uri): # pragma: no cover 128 | raise RuntimeError("Cannot use the @route decorator without arguments.") 129 | kwargs.setdefault('methods', frozenset({'GET'})) 130 | kwargs.setdefault('host', None) 131 | kwargs.setdefault('strict_slashes', False) 132 | kwargs.setdefault('stream', False) 133 | kwargs.setdefault('name', None) 134 | kwargs.setdefault('version', None) 135 | kwargs.setdefault('ignore_body', False) 136 | kwargs.setdefault('websocket', False) 137 | kwargs.setdefault('subprotocols', None) 138 | kwargs.setdefault('unquote', False) 139 | kwargs.setdefault('static', False) 140 | if SANIC_21_6_0 <= SANIC_VERSION: 141 | kwargs.setdefault('version_prefix', '/v') 142 | if SANIC_21_9_0 <= SANIC_VERSION: 143 | kwargs.setdefault('error_format', None) 144 | 145 | def wrapper(handler_f): 146 | self._routes.append(FutureRoute(handler_f, uri, args, kwargs)) 147 | return handler_f 148 | 149 | return wrapper 150 | 151 | def websocket(self, uri, *args, **kwargs): 152 | """Create a websocket route from a decorated function 153 | deprecated. now use @route("/path",websocket=True) 154 | """ 155 | kwargs["websocket"] = True 156 | return self.route(uri, *args, **kwargs) 157 | 158 | def static(self, uri, file_or_directory, *args, **kwargs): 159 | """Create a websocket route from a decorated function 160 | :param uri: endpoint at which the socket endpoint will be accessible. 161 | :type uri: str 162 | :param args: captures all of the positional arguments passed in 163 | :type args: tuple(Any) 164 | :param kwargs: captures the keyword arguments passed in 165 | :type kwargs: dict(Any) 166 | :return: The function to use as the decorator 167 | :rtype: fn 168 | """ 169 | 170 | kwargs.setdefault('pattern', r'/?.+') 171 | kwargs.setdefault('use_modified_since', True) 172 | kwargs.setdefault('use_content_range', False) 173 | kwargs.setdefault('stream_large_files', False) 174 | kwargs.setdefault('name', 'static') 175 | kwargs.setdefault('host', None) 176 | kwargs.setdefault('strict_slashes', None) 177 | kwargs.setdefault('content_type', None) 178 | if SANIC_21_9_0 <= SANIC_VERSION: 179 | kwargs.setdefault('resource_type', None) 180 | 181 | self._static.append(FutureStatic(uri, file_or_directory, args, kwargs)) 182 | 183 | def on_before_registered(self, context, *args, **kwargs): 184 | pass 185 | 186 | def on_registered(self, context, reg, *args, **kwargs): 187 | pass 188 | 189 | def find_plugin_registration(self, realm): 190 | if isinstance(realm, PluginRegistration): 191 | return realm 192 | for reg in self.registrations: 193 | (r, n, u) = reg 194 | if r is not None and r == realm: 195 | return reg 196 | raise KeyError("Plugin registration not found") 197 | 198 | def first_plugin_context(self): 199 | """Returns the context is associated with the first app this plugin was 200 | registered on""" 201 | # Note, because registrations are stored in a set, its not _really_ 202 | # the first one, but whichever one it sees first in the set. 203 | first_realm_reg = next(iter(self.registrations)) 204 | return self.get_context_from_realm(first_realm_reg) 205 | 206 | def get_context_from_realm(self, realm): 207 | rt = RuntimeError("Cannot use the plugin's Context before it is registered.") 208 | if isinstance(realm, PluginRegistration): 209 | reg = realm 210 | else: 211 | try: 212 | reg = self.find_plugin_registration(realm) 213 | except LookupError: 214 | raise rt 215 | (r, n, u) = reg 216 | try: 217 | return r.get_context(n) 218 | except KeyError as k: 219 | raise k 220 | except AttributeError: 221 | raise rt 222 | 223 | def get_app_from_realm_context(self, realm): 224 | rt = RuntimeError("Cannot get the app from Realm before this plugin is registered on the Realm.") 225 | if isinstance(realm, PluginRegistration): 226 | reg = realm 227 | else: 228 | try: 229 | reg = self.find_plugin_registration(realm) 230 | except LookupError: 231 | raise rt 232 | context = self.get_context_from_realm(reg) 233 | try: 234 | app = context.app 235 | except (LookupError, AttributeError): 236 | raise rt 237 | return app 238 | 239 | def resolve_url_for(self, realm, view_name, *args, **kwargs): 240 | try: 241 | reg = self.find_plugin_registration(realm) 242 | except LookupError: 243 | raise RuntimeError("Cannot resolve URL because this plugin is not registered on the PluginRealm.") 244 | (realm, name, url_prefix) = reg 245 | app = self.get_app_from_realm_context(reg) 246 | if app is None: 247 | return None 248 | if isinstance(app, Blueprint): 249 | self.warning("Cannot use url_for when plugin is registered on a Blueprint. Use `app.url_for` instead.") 250 | return None 251 | constructed_name = "{}.{}".format(name, view_name) 252 | return app.url_for(constructed_name, *args, **kwargs) 253 | 254 | def log(self, realm, level, message, *args, **kwargs): 255 | try: 256 | reg = self.find_plugin_registration(realm) 257 | except LookupError: 258 | raise RuntimeError("Cannot log using this plugin, because this plugin is not registered on the Realm.") 259 | context = self.get_context_from_realm(reg) 260 | return context.log(level, message, *args, reg=self, **kwargs) 261 | 262 | def debug(self, message, *args, **kwargs): 263 | return self.log(DEBUG, message, *args, **kwargs) 264 | 265 | def info(self, message, *args, **kwargs): 266 | return self.log(INFO, message, *args, **kwargs) 267 | 268 | def warning(self, message, *args, **kwargs): 269 | return self.log(WARNING, message, *args, **kwargs) 270 | 271 | def error(self, message, *args, **kwargs): 272 | return self.log(ERROR, message, *args, **kwargs) 273 | 274 | def critical(self, message, *args, **kwargs): 275 | return self.log(CRITICAL, message, *args, **kwargs) 276 | 277 | @classmethod 278 | def decorate(cls, app, *args, run_middleware=False, with_context=False, **kwargs): 279 | """ 280 | This is a decorator that can be used to apply this plugin to a specific 281 | route/view on your app, rather than the whole app. 282 | :param app: 283 | :type app: Sanic | Blueprint 284 | :param args: 285 | :type args: tuple(Any) 286 | :param run_middleware: 287 | :type run_middleware: bool 288 | :param with_context: 289 | :type with_context: bool 290 | :param kwargs: 291 | :param kwargs: dict(Any) 292 | :return: the decorated route/view 293 | :rtype: fn 294 | """ 295 | from sanic_plugin_toolkit.realm import SanicPluginRealm 296 | 297 | realm = SanicPluginRealm(app) # get the singleton from the app 298 | try: 299 | assoc = realm.register_plugin(cls, skip_reg=True) 300 | except ValueError as e: 301 | # this is normal, if this plugin has been registered previously 302 | assert e.args and len(e.args) > 1 303 | assoc = e.args[1] 304 | (plugin, reg) = assoc 305 | # plugin may not actually be registered 306 | inst = realm.get_plugin_inst(plugin) 307 | # registered might be True, False or None at this point 308 | regd = True if inst else None 309 | if regd is True: 310 | # middleware will be run on this route anyway, because the plugin 311 | # is registered on the app. Turn it off on the route-level. 312 | run_middleware = False 313 | req_middleware = deque() 314 | resp_middleware = deque() 315 | if run_middleware: 316 | for i, m in enumerate(plugin._middlewares): 317 | attach_to = m.kwargs.pop('attach_to', 'request') 318 | priority = m.kwargs.pop('priority', 5) 319 | with_context = m.kwargs.pop('with_context', False) 320 | mw_handle_fn = m.middleware 321 | if attach_to == 'response': 322 | relative = m.kwargs.pop('relative', 'post') 323 | if relative == "pre": 324 | mw = (0, 0 - priority, 0 - i, mw_handle_fn, with_context, m.args, m.kwargs) 325 | else: # relative = "post" 326 | mw = (1, 0 - priority, 0 - i, mw_handle_fn, with_context, m.args, m.kwargs) 327 | resp_middleware.append(mw) 328 | else: # attach_to = "request" 329 | relative = m.kwargs.pop('relative', 'pre') 330 | if relative == "post": 331 | mw = (1, priority, i, mw_handle_fn, with_context, m.args, m.kwargs) 332 | else: # relative = "pre" 333 | mw = (0, priority, i, mw_handle_fn, with_context, m.args, m.kwargs) 334 | req_middleware.append(mw) 335 | 336 | req_middleware = tuple(sorted(req_middleware)) 337 | resp_middleware = tuple(sorted(resp_middleware)) 338 | 339 | def _decorator(f): 340 | nonlocal realm, plugin, regd, run_middleware, with_context 341 | nonlocal req_middleware, resp_middleware, args, kwargs 342 | 343 | async def wrapper(request, *a, **kw): 344 | nonlocal realm, plugin, regd, run_middleware, with_context 345 | nonlocal req_middleware, resp_middleware, f, args, kwargs 346 | # the plugin was not registered on the app, it might be now 347 | if regd is None: 348 | _inst = realm.get_plugin_inst(plugin) 349 | regd = _inst is not None 350 | 351 | context = plugin.get_context_from_realm(realm) 352 | if run_middleware and not regd and len(req_middleware) > 0: 353 | for (_a, _p, _i, handler, with_context, args, kwargs) in req_middleware: 354 | if with_context: 355 | resp = handler(request, *args, context=context, **kwargs) 356 | else: 357 | resp = handler(request, *args, **kwargs) 358 | if isawaitable(resp): 359 | resp = await resp 360 | if resp: 361 | return 362 | 363 | response = await plugin.route_wrapper( 364 | f, request, context, a, kw, *args, with_context=with_context, **kwargs 365 | ) 366 | if isawaitable(response): 367 | response = await response 368 | if run_middleware and not regd and len(resp_middleware) > 0: 369 | for (_a, _p, _i, handler, with_context, args, kwargs) in resp_middleware: 370 | if with_context: 371 | _resp = handler(request, response, *args, context=context, **kwargs) 372 | else: 373 | _resp = handler(request, response, *args, **kwargs) 374 | if isawaitable(_resp): 375 | _resp = await _resp 376 | if _resp: 377 | response = _resp 378 | break 379 | return response 380 | 381 | return update_wrapper(wrapper, f) 382 | 383 | return _decorator 384 | 385 | async def route_wrapper( 386 | self, route, request, context, request_args, request_kw, *decorator_args, with_context=None, **decorator_kw 387 | ): 388 | """This is the function that is called when a route is decorated with 389 | your plugin decorator. Context will normally be None, but the user 390 | can pass use_context=True so the route will get the plugin 391 | context 392 | """ 393 | # by default, do nothing, just run the wrapped function 394 | if with_context: 395 | resp = route(request, context, *request_args, **request_kw) 396 | else: 397 | resp = route(request, *request_args, **request_kw) 398 | if isawaitable(resp): 399 | resp = await resp 400 | return resp 401 | 402 | def __new__(cls, *args, **kwargs): 403 | # making a bold assumption here. 404 | # Assuming that if a sanic plugin is initialized using 405 | # `MyPlugin(app)`, then the user is attempting to do a legacy plugin 406 | # instantiation, aka Flask-Style plugin instantiation. 407 | if args and len(args) > 0 and (isinstance(args[0], Sanic) or isinstance(args[0], Blueprint)): 408 | app = args[0] 409 | try: 410 | mod_name = cls.__module__ 411 | mod = importlib.import_module(mod_name) 412 | assert mod 413 | except (ImportError, AssertionError): 414 | raise RuntimeError( 415 | "Failed attempting a legacy plugin instantiation. " 416 | "Cannot find the module this plugin belongs to." 417 | ) 418 | # Get the sanic_plugin_toolkit singleton from this app 419 | from sanic_plugin_toolkit.realm import SanicPluginRealm 420 | 421 | realm = SanicPluginRealm(app) 422 | # catch cases like when the module is "__main__" or 423 | # "__call__" or "__init__" 424 | if mod_name.startswith("__"): 425 | # In this case, we cannot use the module to register the 426 | # plugin. Try to use the class method. 427 | assoc = realm.register_plugin(cls, *args, **kwargs) 428 | else: 429 | assoc = realm.register_plugin(mod, *args, **kwargs) 430 | return assoc 431 | self = super(SanicPlugin, cls).__new__(cls) 432 | try: 433 | self._initialized # initialized may be True or Unknown 434 | except AttributeError: 435 | self._initialized = False 436 | return self 437 | 438 | def is_registered_in_realm(self, check_realm): 439 | for reg in self.registrations: 440 | (realm, name, url) = reg 441 | if realm is not None and realm == check_realm: 442 | return True 443 | return False 444 | 445 | def __init__(self, *args, **kwargs): 446 | # Sometimes __init__ can be called twice. 447 | # Ignore it on subsequent times 448 | if self._initialized: 449 | return 450 | super(SanicPlugin, self).__init__(*args, **kwargs) 451 | self._routes = [] 452 | self._ws = [] 453 | self._static = [] 454 | self._middlewares = [] 455 | self._exceptions = [] 456 | self._listeners = defaultdict(list) 457 | self.registrations = set() 458 | self._initialized = True 459 | 460 | def __getstate__(self): 461 | state_dict = {} 462 | for s in SanicPlugin.__slots__: 463 | state_dict[s] = getattr(self, s) 464 | return state_dict 465 | 466 | def __setstate__(self, state): 467 | for s, v in state.items(): 468 | if s == "__weakref__": 469 | if v is None: 470 | continue 471 | else: 472 | raise NotImplementedError("Setting weakrefs on Plugin") 473 | setattr(self, s, v) 474 | 475 | def __reduce__(self): 476 | state_dict = self.__getstate__() 477 | return SanicPlugin.__new__, (self.__class__,), state_dict 478 | -------------------------------------------------------------------------------- /sanic_plugin_toolkit/realm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import importlib 3 | import re 4 | import sys 5 | 6 | from asyncio import CancelledError 7 | from collections import deque 8 | from distutils.version import LooseVersion 9 | from functools import partial, update_wrapper 10 | from inspect import isawaitable, ismodule 11 | from typing import Any, Dict 12 | from uuid import uuid1 13 | 14 | from sanic import Blueprint, Sanic 15 | from sanic import __version__ as sanic_version 16 | from sanic.exceptions import ServerError 17 | from sanic.log import logger 18 | from sanic.models.futures import FutureException as SanicFutureException 19 | from sanic.models.futures import FutureListener as SanicFutureListener 20 | from sanic.models.futures import FutureMiddleware as SanicFutureMiddleware 21 | from sanic.models.futures import FutureRoute as SanicFutureRoute 22 | from sanic.models.futures import FutureStatic as SanicFutureStatic 23 | 24 | 25 | try: 26 | from sanic.response import BaseHTTPResponse 27 | except ImportError: 28 | from sanic.response import HTTPResponse as BaseHTTPResponse 29 | 30 | from sanic_plugin_toolkit.config import load_config_file 31 | from sanic_plugin_toolkit.context import SanicContext 32 | from sanic_plugin_toolkit.plugin import PluginRegistration, SanicPlugin 33 | 34 | 35 | module = sys.modules[__name__] 36 | CONSTS: Dict[str, Any] = dict() 37 | CONSTS["APP_CONFIG_INSTANCE_KEY"] = APP_CONFIG_INSTANCE_KEY = "__SPTK_INSTANCE" 38 | CONSTS["SPTK_LOAD_INI_KEY"] = SPTK_LOAD_INI_KEY = "SPTK_LOAD_INI" 39 | CONSTS["SPTK_INI_FILE_KEY"] = SPTK_INI_FILE_KEY = "SPTK_INI_FILE" 40 | CONSTS["SANIC_19_12_0"] = SANIC_19_12_0 = LooseVersion("19.12.0") 41 | CONSTS["SANIC_20_12_1"] = SANIC_20_12_1 = LooseVersion("20.12.1") 42 | CONSTS["SANIC_21_3_0"] = SANIC_21_3_0 = LooseVersion("21.3.0") 43 | CONSTS["SANIC_21_12_0"] = SANIC_21_12_0 = LooseVersion("21.12.0") 44 | 45 | # Currently installed sanic version in this environment 46 | SANIC_VERSION = LooseVersion(sanic_version) 47 | 48 | if SANIC_21_12_0 <= SANIC_VERSION: 49 | raise RuntimeError("Sanic-Plugin-Toolkit v1.2 does not work with Sanic v21.12.0") 50 | 51 | CRITICAL = 50 52 | ERROR = 40 53 | WARNING = 30 54 | INFO = 20 55 | DEBUG = 10 56 | 57 | to_snake_case_first_cap_re = re.compile('(.)([A-Z][a-z]+)') 58 | to_snake_case_all_cap_re = re.compile('([a-z0-9])([A-Z])') 59 | 60 | 61 | def to_snake_case(name): 62 | """ 63 | Simple helper function. 64 | Changes PascalCase, camelCase, and CAPS_CASE to snake_case. 65 | :param name: variable name to convert 66 | :type name: str 67 | :return: the name of the variable, converted to snake_case 68 | :rtype: str 69 | """ 70 | s1 = to_snake_case_first_cap_re.sub(r'\1_\2', name) 71 | return to_snake_case_all_cap_re.sub(r'\1_\2', s1).lower() 72 | 73 | 74 | class SanicPluginRealm(object): 75 | __slots__ = ( 76 | '_running', 77 | '_app', 78 | '_plugin_names', 79 | '_contexts', 80 | '_pre_request_middleware', 81 | '_post_request_middleware', 82 | '_pre_response_middleware', 83 | '_post_response_middleware', 84 | '_cleanup_middleware', 85 | '_loop', 86 | '__weakref__', 87 | ) 88 | 89 | def log(self, level, message, reg=None, *args, **kwargs): 90 | if reg is not None: 91 | (_, n, _) = reg 92 | message = "{:s}: {:s}".format(str(n), str(message)) 93 | return logger.log(level, message, *args, **kwargs) 94 | 95 | def debug(self, message, reg=None, *args, **kwargs): 96 | return self.log(DEBUG, message=message, reg=reg, *args, **kwargs) 97 | 98 | def info(self, message, reg=None, *args, **kwargs): 99 | return self.log(INFO, message=message, reg=reg, *args, **kwargs) 100 | 101 | def warning(self, message, reg=None, *args, **kwargs): 102 | return self.log(WARNING, message=message, reg=reg, *args, **kwargs) 103 | 104 | def error(self, message, reg=None, *args, **kwargs): 105 | return self.log(ERROR, message=message, reg=reg, *args, **kwargs) 106 | 107 | def critical(self, message, reg=None, *args, **kwargs): 108 | return self.log(CRITICAL, message=message, reg=reg, *args, **kwargs) 109 | 110 | def url_for(self, view_name, *args, reg=None, **kwargs): 111 | if reg is not None: 112 | (_, name, url_prefix) = reg 113 | view_name = "{}.{}".format(name, view_name) 114 | app = self._app 115 | if app is None: 116 | return None 117 | if isinstance(app, Blueprint): 118 | bp = app 119 | view_name = "{}.{}".format(app.name, view_name) 120 | return [a.url_for(view_name, *args, **kwargs) for a in bp.apps] 121 | return app.url_for(view_name, *args, **kwargs) 122 | 123 | def _get_realm_plugin(self, plugin): 124 | if isinstance(plugin, str): 125 | if plugin not in self._plugin_names: 126 | self.warning("Cannot lookup that plugin by its name.") 127 | return None 128 | name = plugin 129 | else: 130 | reg = plugin.find_plugin_registration(self) 131 | (_, name, _) = reg 132 | _p_context = self._plugins_context 133 | try: 134 | _plugin_reg = _p_context[name] 135 | except KeyError as k: 136 | self.warning("Plugin not found!") 137 | raise k 138 | return _plugin_reg 139 | 140 | def get_plugin_inst(self, plugin): 141 | _plugin_reg = self._get_realm_plugin(plugin) 142 | try: 143 | inst = _plugin_reg['instance'] 144 | except KeyError: 145 | self.warning("Plugin is not registered properly") 146 | inst = None 147 | return inst 148 | 149 | def get_plugin_assoc(self, plugin): 150 | _plugin_reg = self._get_realm_plugin(plugin) 151 | p = _plugin_reg['instance'] 152 | reg = _plugin_reg['reg'] 153 | associated_tuple = p.AssociatedTuple 154 | return associated_tuple(p, reg) 155 | 156 | def register_plugin(self, plugin, *args, name=None, skip_reg=False, **kwargs): 157 | assert not self._running, "Cannot add, remove, or change plugins " "after the App has started serving." 158 | assert plugin, "Plugin must be a valid type! Do not pass in `None` " "or `False`" 159 | 160 | if isinstance(plugin, type): 161 | # We got passed in a Class. That's ok, we can handle this! 162 | module_name = getattr(plugin, '__module__') 163 | class_name = getattr(plugin, '__name__') 164 | lower_class = to_snake_case(class_name) 165 | try: 166 | mod = importlib.import_module(module_name) 167 | try: 168 | plugin = getattr(mod, lower_class) 169 | except AttributeError: 170 | plugin = mod # try the module-based resolution next 171 | except ImportError: 172 | raise 173 | 174 | if ismodule(plugin): 175 | # We got passed in a module. That's ok, we can handle this! 176 | try: # look for '.instance' on the module 177 | plugin = getattr(plugin, 'instance') 178 | assert plugin is not None 179 | except (AttributeError, AssertionError): 180 | # now look for the same name, 181 | # like my_module.my_module on the module. 182 | try: 183 | plugin_module_name = getattr(plugin, '__name__') 184 | assert plugin_module_name and len(plugin_module_name) > 0 185 | plugin_module_name = plugin_module_name.split('.')[-1] 186 | plugin = getattr(plugin, plugin_module_name) 187 | assert plugin is not None 188 | except (AttributeError, AssertionError): 189 | raise RuntimeError("Cannot import this module as a Sanic Plugin.") 190 | 191 | assert isinstance(plugin, SanicPlugin), "Plugin must be derived from SanicPlugin" 192 | if name is None: 193 | try: 194 | name = str(plugin.__class__.__name__) 195 | assert name is not None 196 | except (AttributeError, AssertionError, ValueError, KeyError): 197 | logger.warning("Cannot determine a name for {}, using UUID.".format(repr(plugin))) 198 | name = str(uuid1(None, None)) 199 | assert isinstance(name, str), "Plugin name must be a python unicode string!" 200 | 201 | associated_tuple = plugin.AssociatedTuple 202 | 203 | if name in self._plugin_names: # we're already registered on this Realm 204 | reg = plugin.find_plugin_registration(self) 205 | assoc = associated_tuple(plugin, reg) 206 | raise ValueError("Plugin {:s} is already registered!".format(name), assoc) 207 | if plugin.is_registered_in_realm(self): 208 | raise RuntimeError( 209 | "Plugin already shows it is registered to this " "sanic_plugin_toolkit, maybe under a different name?" 210 | ) 211 | self._plugin_names.add(name) 212 | shared_context = self.shared_context 213 | self._contexts[name] = context = SanicContext(self, shared_context, {'shared': shared_context}) 214 | _p_context = self._plugins_context 215 | _plugin_reg = _p_context.get(name, None) 216 | if _plugin_reg is None: 217 | _p_context[name] = _plugin_reg = _p_context.create_child_context() 218 | _plugin_reg['name'] = name 219 | _plugin_reg['context'] = context 220 | if skip_reg: 221 | dummy_reg = PluginRegistration(realm=self, plugin_name=name, url_prefix=None) 222 | context['log'] = partial(self.log, reg=dummy_reg) 223 | context['url_for'] = partial(self.url_for, reg=dummy_reg) 224 | plugin.registrations.add(dummy_reg) 225 | # This indicates the plugin is not registered on the app 226 | _plugin_reg['instance'] = None 227 | _plugin_reg['reg'] = None 228 | return associated_tuple(plugin, dummy_reg) 229 | if _plugin_reg.get('instance', False): 230 | raise RuntimeError("The plugin we are trying to register already " "has a known instance!") 231 | reg = self._register_helper(plugin, context, *args, _realm=self, _plugin_name=name, **kwargs) 232 | _plugin_reg['instance'] = plugin 233 | _plugin_reg['reg'] = reg 234 | return associated_tuple(plugin, reg) 235 | 236 | @staticmethod 237 | def _register_exception_helper(e, _realm, plugin, context): 238 | return ( 239 | _realm._plugin_register_bp_exception(e.handler, plugin, context, *e.exceptions, **e.kwargs) 240 | if isinstance(_realm._app, Blueprint) 241 | else _realm._plugin_register_app_exception(e.handler, plugin, context, *e.exceptions, **e.kwargs) 242 | ) 243 | 244 | @staticmethod 245 | def _register_listener_helper(event, listener, _realm, plugin, context, **kwargs): 246 | return ( 247 | _realm._plugin_register_bp_listener(event, listener, plugin, context, **kwargs) 248 | if isinstance(_realm._app, Blueprint) 249 | else _realm._plugin_register_app_listener(event, listener, plugin, context, **kwargs) 250 | ) 251 | 252 | @staticmethod 253 | def _register_middleware_helper(m, _realm, plugin, context): 254 | return _realm._plugin_register_middleware(m.middleware, plugin, context, *m.args, **m.kwargs) 255 | 256 | @staticmethod 257 | def _register_route_helper(r, _realm, plugin, context, _p_name, _url_prefix): 258 | # Prepend the plugin URI prefix if available 259 | uri = _url_prefix + r.uri if _url_prefix else r.uri 260 | uri = uri[1:] if uri.startswith('//') else uri 261 | # attach the plugin name to the handler so that it can be 262 | # prefixed properly in the router 263 | _app = _realm._app 264 | handler_name = str(r.handler.__name__) 265 | plugin_prefix = _p_name + '.' 266 | kwargs = r.kwargs 267 | if isinstance(_app, Blueprint): 268 | # blueprint always handles adding __blueprintname__ 269 | # So we identify ourselves here a different way. 270 | # r.handler.__name__ = "{}.{}".format(_p_name, handler_name) 271 | if "name" not in kwargs or kwargs["name"] is None: 272 | kwargs["name"] = plugin_prefix + handler_name 273 | elif not kwargs["name"].startswith(plugin_prefix): 274 | kwargs["name"] = plugin_prefix + kwargs["name"] 275 | _realm._plugin_register_bp_route(r.handler, plugin, context, uri, *r.args, **kwargs) 276 | else: 277 | if "name" not in kwargs or kwargs["name"] is None: 278 | kwargs["name"] = plugin_prefix + handler_name 279 | elif not kwargs["name"].startswith(plugin_prefix): 280 | kwargs["name"] = plugin_prefix + kwargs["name"] 281 | _realm._plugin_register_app_route(r.handler, plugin, context, uri, *r.args, **kwargs) 282 | 283 | @staticmethod 284 | def _register_static_helper(s, _realm, plugin, context, _p_name, _url_prefix): 285 | # attach the plugin name to the static route so that it can be 286 | # prefixed properly in the router 287 | kwargs = s.kwargs 288 | name = kwargs.pop('name', 'static') 289 | plugin_prefix = _p_name + '.' 290 | _app = _realm._app 291 | if not name.startswith(plugin_prefix): 292 | name = plugin_prefix + name 293 | # Prepend the plugin URI prefix if available 294 | uri = _url_prefix + s.uri if _url_prefix else s.uri 295 | uri = uri[1:] if uri.startswith('//') else uri 296 | kwargs["name"] = name 297 | return ( 298 | _realm._plugin_register_bp_static(uri, s.file_or_dir, plugin, context, *s.args, **kwargs) 299 | if isinstance(_app, Blueprint) 300 | else _realm._plugin_register_app_static(uri, s.file_or_dir, plugin, context, *s.args, **kwargs) 301 | ) 302 | 303 | @staticmethod 304 | def _register_helper(plugin, context, *args, _realm=None, _plugin_name=None, _url_prefix=None, **kwargs): 305 | error_str = "Plugin must be initialised using the " "Sanic Plugin Toolkit PluginRealm." 306 | assert _realm is not None, error_str 307 | assert _plugin_name is not None, error_str 308 | _app = _realm._app 309 | assert _app is not None, error_str 310 | 311 | reg = PluginRegistration(realm=_realm, plugin_name=_plugin_name, url_prefix=_url_prefix) 312 | context['log'] = partial(_realm.log, reg=reg) 313 | context['url_for'] = partial(_realm.url_for, reg=reg) 314 | continue_flag = plugin.on_before_registered(context, *args, **kwargs) 315 | if continue_flag is False: 316 | return plugin 317 | 318 | # Routes 319 | [_realm._register_route_helper(r, _realm, plugin, context, _plugin_name, _url_prefix) for r in plugin._routes] 320 | 321 | # Websocket routes 322 | # These are deprecated and should be handled in the _routes_ list above. 323 | [_realm._register_route_helper(w, _realm, plugin, context, _plugin_name, _url_prefix) for w in plugin._ws] 324 | 325 | # Static routes 326 | [_realm._register_static_helper(s, _realm, plugin, context, _plugin_name, _url_prefix) for s in plugin._static] 327 | 328 | # Middleware 329 | [_realm._register_middleware_helper(m, _realm, plugin, context) for m in plugin._middlewares] 330 | 331 | # Exceptions 332 | [_realm._register_exception_helper(e, _realm, plugin, context) for e in plugin._exceptions] 333 | 334 | # Listeners 335 | for event, listeners in plugin._listeners.items(): 336 | for listener in listeners: 337 | if isinstance(listener, tuple): 338 | listener, lkw = listener 339 | else: 340 | lkw = {} 341 | _realm._register_listener_helper(event, listener, _realm, plugin, context, **lkw) 342 | 343 | # # this should only ever run once! 344 | plugin.registrations.add(reg) 345 | plugin.on_registered(context, reg, *args, **kwargs) 346 | 347 | return reg 348 | 349 | def _plugin_register_app_route( 350 | self, r_handler, plugin, context, uri, *args, name=None, with_context=False, **kwargs 351 | ): 352 | if with_context: 353 | r_handler = update_wrapper(partial(r_handler, context=context), r_handler) 354 | fr = SanicFutureRoute(r_handler, uri, name=name, **kwargs) 355 | routes = self._app._apply_route(fr) 356 | return routes 357 | 358 | def _plugin_register_bp_route( 359 | self, r_handler, plugin, context, uri, *args, name=None, with_context=False, **kwargs 360 | ): 361 | bp = self._app 362 | if with_context: 363 | r_handler = update_wrapper(partial(r_handler, context=context), r_handler) 364 | # __blueprintname__ gets added in the register() routine 365 | # When app is a blueprint, it doesn't register right away, it happens 366 | # in the blueprint.register() routine. 367 | r_handler = bp.route(uri, *args, name=name, **kwargs)(r_handler) 368 | return r_handler 369 | 370 | def _plugin_register_app_static(self, uri, file_or_dir, plugin, context, *args, **kwargs): 371 | fs = SanicFutureStatic(uri, file_or_dir, **kwargs) 372 | return self._app._apply_static(fs) 373 | 374 | def _plugin_register_bp_static(self, uri, file_or_dir, plugin, context, *args, **kwargs): 375 | bp = self._app 376 | return bp.static(uri, file_or_dir, *args, **kwargs) 377 | 378 | def _plugin_register_app_exception(self, handler, plugin, context, *exceptions, with_context=False, **kwargs): 379 | if with_context: 380 | handler = update_wrapper(partial(handler, context=context), handler) 381 | fe = SanicFutureException(handler, list(exceptions)) 382 | return self._app._apply_exception_handler(fe) 383 | 384 | def _plugin_register_bp_exception(self, handler, plugin, context, *exceptions, with_context=False, **kwargs): 385 | if with_context: 386 | handler = update_wrapper(partial(handler, context=context), handler) 387 | return self._app.exception(*exceptions)(handler) 388 | 389 | def _plugin_register_app_listener(self, event, listener, plugin, context, *args, with_context=False, **kwargs): 390 | if with_context: 391 | listener = update_wrapper(partial(listener, context=context), listener) 392 | fl = SanicFutureListener(listener, event) 393 | return self._app._apply_listener(fl) 394 | 395 | def _plugin_register_bp_listener(self, event, listener, plugin, context, *args, with_context=False, **kwargs): 396 | if with_context: 397 | listener = update_wrapper(partial(listener, context=context), listener) 398 | bp = self._app 399 | return bp.listener(event)(listener) 400 | 401 | def _plugin_register_middleware( 402 | self, 403 | middleware, 404 | plugin, 405 | context, 406 | *args, 407 | priority=5, 408 | relative=None, 409 | attach_to=None, 410 | with_context=False, 411 | **kwargs, 412 | ): 413 | assert isinstance(priority, int), "Priority must be an integer!" 414 | assert 0 <= priority <= 9, ( 415 | "Priority must be between 0 and 9 (inclusive), " "0 is highest priority, 9 is lowest." 416 | ) 417 | assert isinstance(plugin, SanicPlugin), "Plugin middleware only works with a plugin from SPTK." 418 | if len(args) > 0 and isinstance(args[0], str) and attach_to is None: 419 | # for backwards/sideways compatibility with Sanic, 420 | # the first arg is interpreted as 'attach_to' 421 | attach_to = args[0] 422 | if with_context: 423 | middleware = update_wrapper(partial(middleware, context=context), middleware) 424 | if attach_to is None or attach_to == "request": 425 | insert_order = len(self._pre_request_middleware) + len(self._post_request_middleware) 426 | priority_middleware = (priority, insert_order, middleware) 427 | if relative is None or relative == 'pre': 428 | # plugin request middleware default to pre-app middleware 429 | self._pre_request_middleware.append(priority_middleware) 430 | else: # post 431 | assert relative == "post", "A request middleware must have relative = pre or post" 432 | self._post_request_middleware.append(priority_middleware) 433 | elif attach_to == "cleanup": 434 | insert_order = len(self._cleanup_middleware) 435 | priority_middleware = (priority, insert_order, middleware) 436 | assert relative is None, "A cleanup middleware cannot have relative pre or post" 437 | self._cleanup_middleware.append(priority_middleware) 438 | else: # response 439 | assert attach_to == "response", "A middleware kind must be either request or response." 440 | insert_order = len(self._post_response_middleware) + len(self._pre_response_middleware) 441 | # so they are sorted backwards 442 | priority_middleware = (0 - priority, 0.0 - insert_order, middleware) 443 | if relative is None or relative == 'post': 444 | # plugin response middleware default to post-app middleware 445 | self._post_response_middleware.append(priority_middleware) 446 | else: # pre 447 | assert relative == "pre", "A response middleware must have relative = pre or post" 448 | self._pre_response_middleware.append(priority_middleware) 449 | return middleware 450 | 451 | @property 452 | def _plugins_context(self): 453 | try: 454 | return self._contexts['_plugins'] 455 | except (AttributeError, KeyError): 456 | raise RuntimeError("PluginRealm does not have a valid plugins context!") 457 | 458 | @property 459 | def shared_context(self): 460 | try: 461 | return self._contexts['shared'] 462 | except (AttributeError, KeyError): 463 | raise RuntimeError("PluginRealm does not have a valid shared context!") 464 | 465 | def get_context(self, context=None): 466 | context = context or 'shared' 467 | try: 468 | _context = self._contexts[context] 469 | except KeyError: 470 | logger.error("Context {:s} does not exist!") 471 | return None 472 | return _context 473 | 474 | def get_from_context(self, item, context=None): 475 | context = context or 'shared' 476 | try: 477 | _context = self._contexts[context] 478 | except KeyError: 479 | logger.warning("Context {:s} does not exist! Falling back to shared context".format(context)) 480 | _context = self._contexts['shared'] 481 | return _context.__getitem__(item) 482 | 483 | def create_temporary_request_context(self, request): 484 | request_hash = id(request) 485 | shared_context = self.shared_context 486 | shared_requests_dict = shared_context.get('request', False) 487 | if not shared_requests_dict: 488 | new_ctx = SanicContext(self, None, {'id': 'shared request contexts'}) 489 | shared_context['request'] = shared_requests_dict = new_ctx 490 | shared_request_ctx = shared_requests_dict.get(request_hash, None) 491 | if shared_request_ctx: 492 | # Somehow, we've already created a temporary context for this request. 493 | return shared_request_ctx 494 | shared_requests_dict[request_hash] = shared_request_ctx = SanicContext( 495 | self, None, {'request': request, 'id': "shared request context for request {}".format(id(request))} 496 | ) 497 | for name, _p in self._plugins_context.items(): 498 | if not (isinstance(_p, SanicContext) and 'instance' in _p and isinstance(_p['instance'], SanicPlugin)): 499 | continue 500 | if not ('context' in _p and isinstance(_p['context'], SanicContext)): 501 | continue 502 | _p_context = _p['context'] 503 | if 'request' not in _p_context: 504 | _p_context['request'] = p_request = SanicContext(self, None, {'id': 'private request contexts'}) 505 | else: 506 | p_request = _p_context.request 507 | p_request[request_hash] = SanicContext( 508 | self, 509 | None, 510 | {'request': request, 'id': "private request context for {} on request {}".format(name, id(request))}, 511 | ) 512 | return shared_request_ctx 513 | 514 | def delete_temporary_request_context(self, request): 515 | request_hash = id(request) 516 | shared_context = self.shared_context 517 | try: 518 | _shared_requests_dict = shared_context['request'] 519 | del _shared_requests_dict[request_hash] 520 | except KeyError: 521 | pass 522 | for name, _p in self._plugins_context.items(): 523 | if not (isinstance(_p, SanicContext) and 'instance' in _p and isinstance(_p['instance'], SanicPlugin)): 524 | continue 525 | if not ('context' in _p and isinstance(_p['context'], SanicContext)): 526 | continue 527 | _p_context = _p['context'] 528 | try: 529 | _p_requests_dict = _p_context['request'] 530 | del _p_requests_dict[request_hash] 531 | except KeyError: 532 | pass 533 | 534 | async def _handle_request(self, real_handle, request, write_callback, stream_callback): 535 | cancelled = False 536 | try: 537 | _ = await real_handle(request, write_callback, stream_callback) 538 | except CancelledError as ce: 539 | # We still want to run cleanup middleware, even if cancelled 540 | cancelled = ce 541 | except BaseException as be: 542 | logger.error("SPTK caught an error that should have been caught by Sanic response handler.") 543 | logger.error(str(be)) 544 | raise 545 | finally: 546 | # noinspection PyUnusedLocal 547 | _ = await self._run_cleanup_middleware(request) # noqa: F841 548 | if cancelled: 549 | raise cancelled 550 | 551 | async def _handle_request_21_03(self, real_handle, request): 552 | cancelled = False 553 | try: 554 | _ = await real_handle(request) 555 | except CancelledError as ce: 556 | # We still want to run cleanup middleware, even if cancelled 557 | cancelled = ce 558 | except BaseException as be: 559 | logger.error("SPTK caught an error that should have been caught by Sanic response handler.") 560 | logger.error(str(be)) 561 | raise 562 | finally: 563 | # noinspection PyUnusedLocal 564 | _ = await self._run_cleanup_middleware(request) # noqa: F841 565 | if cancelled: 566 | raise cancelled 567 | 568 | def wrap_handle_request(self, app, new_handler=None): 569 | if new_handler is None: 570 | new_handler = self._handle_request 571 | orig_handle_request = app.handle_request 572 | return update_wrapper(partial(new_handler, orig_handle_request), new_handler) 573 | 574 | async def _run_request_middleware_18_12(self, request): 575 | if not self._running: 576 | raise ServerError("Toolkit processing a request before App server is started.") 577 | self.create_temporary_request_context(request) 578 | if self._pre_request_middleware: 579 | for (_pri, _ins, middleware) in self._pre_request_middleware: 580 | response = middleware(request) 581 | if isawaitable(response): 582 | response = await response 583 | if response: 584 | return response 585 | if self._app.request_middleware: 586 | for middleware in self._app.request_middleware: 587 | response = middleware(request) 588 | if isawaitable(response): 589 | response = await response 590 | if response: 591 | return response 592 | if self._post_request_middleware: 593 | for (_pri, _ins, middleware) in self._post_request_middleware: 594 | response = middleware(request) 595 | if isawaitable(response): 596 | response = await response 597 | if response: 598 | return response 599 | return None 600 | 601 | async def _run_request_middleware_19_12(self, request, request_name=None): 602 | if not self._running: 603 | # Test_mode is only present on Sanic 20.9+ 604 | test_mode = getattr(self._app, "test_mode", False) 605 | if self._app.asgi: 606 | if test_mode: 607 | # We're deliberately in Test Mode, we don't expect 608 | # Server events to have been kicked off yet. 609 | pass 610 | else: 611 | # An ASGI app can receive requests from HTTPX even if 612 | # the app is not booted yet. 613 | self.warning("Unexpected ASGI request. Forcing Toolkit " "into running mode without a server.") 614 | self._on_server_start(request.app, request.transport.loop) 615 | elif test_mode: 616 | self.warning("Unexpected test-mode request. Forcing Toolkit " "into running mode without a server.") 617 | self._on_server_start(request.app, request.transport.loop) 618 | else: 619 | raise RuntimeError("Sanic Plugin Toolkit received a request before Sanic server is started.") 620 | self.create_temporary_request_context(request) 621 | if self._pre_request_middleware: 622 | for (_pri, _ins, middleware) in self._pre_request_middleware: 623 | response = middleware(request) 624 | if isawaitable(response): 625 | response = await response 626 | if response: 627 | return response 628 | app = self._app 629 | named_middleware = app.named_request_middleware.get(request_name, deque()) 630 | applicable_middleware = app.request_middleware + named_middleware 631 | if applicable_middleware: 632 | for middleware in applicable_middleware: 633 | response = middleware(request) 634 | if isawaitable(response): 635 | response = await response 636 | if response: 637 | return response 638 | if self._post_request_middleware: 639 | for (_pri, _ins, middleware) in self._post_request_middleware: 640 | response = middleware(request) 641 | if isawaitable(response): 642 | response = await response 643 | if response: 644 | return response 645 | return None 646 | 647 | async def _run_request_middleware_21_03(self, request, request_name=None): 648 | if not self._running: 649 | test_mode = self._app.test_mode 650 | if self._app.asgi: 651 | if test_mode: 652 | # We're deliberately in Test Mode, we don't expect 653 | # Server events to have been kicked off yet. 654 | pass 655 | else: 656 | # An ASGI app can receive requests from HTTPX even if 657 | # the app is not booted yet. 658 | self.warning("Unexpected ASGI request. Forcing Toolkit " "into running mode without a server.") 659 | self._on_server_start(request.app, request.transport.loop) 660 | elif test_mode: 661 | self.warning("Unexpected test-mode request. Forcing Toolkit " "into running mode without a server.") 662 | self._on_server_start(request.app, request.transport.loop) 663 | else: 664 | raise RuntimeError("Sanic Plugin Toolkit received a request before Sanic server is started.") 665 | 666 | shared_req_context = self.create_temporary_request_context(request) 667 | realm_request_middleware_started = shared_req_context.get('realm_request_middleware_started', False) 668 | if realm_request_middleware_started: 669 | return None 670 | shared_req_context['realm_request_middleware_started'] = True 671 | if self._pre_request_middleware: 672 | for (_pri, _ins, middleware) in self._pre_request_middleware: 673 | response = middleware(request) 674 | if isawaitable(response): 675 | response = await response 676 | if response: 677 | return response 678 | app = self._app 679 | named_middleware = app.named_request_middleware.get(request_name, deque()) 680 | applicable_middleware = app.request_middleware + named_middleware 681 | # request.request_middleware_started is meant as a stop-gap solution 682 | # until RFC 1630 is adopted 683 | if applicable_middleware and not request.request_middleware_started: 684 | request.request_middleware_started = True 685 | for middleware in applicable_middleware: 686 | response = middleware(request) 687 | if isawaitable(response): 688 | response = await response 689 | if response: 690 | return response 691 | if self._post_request_middleware: 692 | for (_pri, _ins, middleware) in self._post_request_middleware: 693 | response = middleware(request) 694 | if isawaitable(response): 695 | response = await response 696 | if response: 697 | return response 698 | return None 699 | 700 | async def _run_response_middleware_18_12(self, request, response): 701 | if self._pre_response_middleware: 702 | for (_pri, _ins, middleware) in self._pre_response_middleware: 703 | _response = middleware(request, response) 704 | if isawaitable(_response): 705 | _response = await _response 706 | if _response: 707 | response = _response 708 | break 709 | if self._app.response_middleware: 710 | for middleware in self._app.response_middleware: 711 | _response = middleware(request, response) 712 | if isawaitable(_response): 713 | _response = await _response 714 | if _response: 715 | response = _response 716 | break 717 | if self._post_response_middleware: 718 | for (_pri, _ins, middleware) in self._post_response_middleware: 719 | _response = middleware(request, response) 720 | if isawaitable(_response): 721 | _response = await _response 722 | if _response: 723 | response = _response 724 | break 725 | return response 726 | 727 | async def _run_response_middleware_19_12(self, request, response, request_name=None): 728 | if self._pre_response_middleware: 729 | for (_pri, _ins, middleware) in self._pre_response_middleware: 730 | _response = middleware(request, response) 731 | if isawaitable(_response): 732 | _response = await _response 733 | if _response: 734 | response = _response 735 | break 736 | app = self._app 737 | named_middleware = app.named_response_middleware.get(request_name, deque()) 738 | applicable_middleware = app.response_middleware + named_middleware 739 | if applicable_middleware: 740 | for middleware in applicable_middleware: 741 | _response = middleware(request, response) 742 | if isawaitable(_response): 743 | _response = await _response 744 | if _response: 745 | response = _response 746 | break 747 | if self._post_response_middleware: 748 | for (_pri, _ins, middleware) in self._post_response_middleware: 749 | _response = middleware(request, response) 750 | if isawaitable(_response): 751 | _response = await _response 752 | if _response: 753 | response = _response 754 | break 755 | return response 756 | 757 | async def _run_response_middleware_21_03(self, request, response, request_name=None): 758 | if self._pre_response_middleware: 759 | for (_pri, _ins, middleware) in self._pre_response_middleware: 760 | _response = middleware(request, response) 761 | if isawaitable(_response): 762 | _response = await _response 763 | if _response: 764 | response = _response 765 | if isinstance(response, BaseHTTPResponse): 766 | response = request.stream.respond(response) 767 | break 768 | app = self._app 769 | named_middleware = app.named_response_middleware.get(request_name, deque()) 770 | applicable_middleware = app.response_middleware + named_middleware 771 | if applicable_middleware: 772 | for middleware in applicable_middleware: 773 | _response = middleware(request, response) 774 | if isawaitable(_response): 775 | _response = await _response 776 | if _response: 777 | response = _response 778 | if isinstance(response, BaseHTTPResponse): 779 | response = request.stream.respond(response) 780 | break 781 | if self._post_response_middleware: 782 | for (_pri, _ins, middleware) in self._post_response_middleware: 783 | _response = middleware(request, response) 784 | if isawaitable(_response): 785 | _response = await _response 786 | if _response: 787 | response = _response 788 | if isinstance(response, BaseHTTPResponse): 789 | response = request.stream.respond(response) 790 | break 791 | return response 792 | 793 | async def _run_cleanup_middleware(self, request): 794 | return_this = None 795 | if self._cleanup_middleware: 796 | for (_pri, _ins, middleware) in self._cleanup_middleware: 797 | response = middleware(request) 798 | if isawaitable(response): 799 | response = await response 800 | if response: 801 | return_this = response 802 | break 803 | self.delete_temporary_request_context(request) 804 | return return_this 805 | 806 | def _on_server_start(self, app, loop): 807 | if not isinstance(self._app, Blueprint): 808 | assert self._app == app, "Sanic Plugins Framework is not assigned to the correct " "Sanic App!" 809 | if self._running: 810 | # during testing, this will be called _many_ times. 811 | return # Ignore if this is already called. 812 | self._loop = loop 813 | 814 | # sort and freeze these 815 | self._pre_request_middleware = tuple(sorted(self._pre_request_middleware)) 816 | self._post_request_middleware = tuple(sorted(self._post_request_middleware)) 817 | self._pre_response_middleware = tuple(sorted(self._pre_response_middleware)) 818 | self._post_response_middleware = tuple(sorted(self._post_response_middleware)) 819 | self._cleanup_middleware = tuple(sorted(self._cleanup_middleware)) 820 | self._running = True 821 | 822 | def _on_after_server_start(self, app, loop): 823 | if not self._running: 824 | # Missed before_server_start event 825 | # Run startup now! 826 | self._on_server_start(app, loop) 827 | 828 | async def _startup(self, app, real_startup): 829 | _ = await real_startup() 830 | # Patch app _after_ Touchup is done. 831 | self._patch_app(app) 832 | 833 | def _patch_app(self, app): 834 | # monkey patch the app! 835 | 836 | if SANIC_21_3_0 <= SANIC_VERSION: 837 | app.handle_request = self.wrap_handle_request(app, self._handle_request_21_03) 838 | app._run_request_middleware = self._run_request_middleware_21_03 839 | app._run_response_middleware = self._run_response_middleware_21_03 840 | else: 841 | if SANIC_19_12_0 <= SANIC_VERSION: 842 | app.handle_request = self.wrap_handle_request(app) 843 | app._run_request_middleware = self._run_request_middleware_19_12 844 | app._run_response_middleware = self._run_response_middleware_19_12 845 | else: 846 | app.handle_request = self.wrap_handle_request(app) 847 | app._run_request_middleware = self._run_request_middleware_18_12 848 | app._run_response_middleware = self._run_response_middleware_18_12 849 | 850 | def _patch_blueprint(self, bp): 851 | # monkey patch the blueprint! 852 | # Caveat! We cannot take over the sanic middleware runner when 853 | # app is a blueprint. We will do this a different way. 854 | _spf = self 855 | 856 | async def run_bp_pre_request_mw(request): 857 | nonlocal _spf 858 | _spf.create_temporary_request_context(request) 859 | if _spf._pre_request_middleware: 860 | for (_pri, _ins, middleware) in _spf._pre_request_middleware: 861 | response = middleware(request) 862 | if isawaitable(response): 863 | response = await response 864 | if response: 865 | return response 866 | 867 | async def run_bp_post_request_mw(request): 868 | nonlocal _spf 869 | if _spf._post_request_middleware: 870 | for (_pri, _ins, middleware) in _spf._post_request_middleware: 871 | response = middleware(request) 872 | if isawaitable(response): 873 | response = await response 874 | if response: 875 | return response 876 | 877 | async def run_bp_pre_response_mw(request, response): 878 | nonlocal _spf 879 | altered = False 880 | if _spf._pre_response_middleware: 881 | for (_pri, _ins, middleware) in _spf._pre_response_middleware: 882 | _response = middleware(request, response) 883 | if isawaitable(_response): 884 | _response = await _response 885 | if _response: 886 | response = _response 887 | altered = True 888 | break 889 | if altered: 890 | return response 891 | 892 | async def run_bp_post_response_mw(request, response): 893 | nonlocal _spf 894 | altered = False 895 | if _spf._post_response_middleware: 896 | for (_pri, _ins, middleware) in _spf._post_response_middleware: 897 | _response = middleware(request, response) 898 | if isawaitable(_response): 899 | _response = await _response 900 | if _response: 901 | response = _response 902 | altered = True 903 | break 904 | if self._cleanup_middleware: 905 | for (_pri, _ins, middleware) in self._cleanup_middleware: 906 | response2 = middleware(request) 907 | if isawaitable(response2): 908 | response2 = await response2 909 | if response2: 910 | break 911 | _spf.delete_temporary_request_context(request) 912 | if altered: 913 | return response 914 | 915 | def bp_register(bp_self, orig_register, app, options): 916 | # from sanic.blueprints import FutureMiddleware as BPFutureMW 917 | pre_request = SanicFutureMiddleware(run_bp_pre_request_mw, 'request') 918 | post_request = SanicFutureMiddleware(run_bp_post_request_mw, 'request') 919 | pre_response = SanicFutureMiddleware(run_bp_pre_response_mw, 'response') 920 | post_response = SanicFutureMiddleware(run_bp_post_response_mw, 'response') 921 | # this order is very important. Don't change it. It is correct. 922 | bp_self._future_middleware.insert(0, post_response) 923 | bp_self._future_middleware.insert(0, pre_request) 924 | bp_self._future_middleware.append(post_request) 925 | bp_self._future_middleware.append(pre_response) 926 | 927 | orig_register(app, options) 928 | 929 | if SANIC_21_3_0 <= SANIC_VERSION: 930 | _slots = list(Blueprint.__fake_slots__) 931 | _slots.extend(["register"]) 932 | Blueprint.__fake_slots__ = tuple(_slots) 933 | bp.register = update_wrapper(partial(bp_register, bp, bp.register), bp.register) 934 | setattr(bp.ctx, APP_CONFIG_INSTANCE_KEY, self) 935 | else: 936 | bp.register = update_wrapper(partial(bp_register, bp, bp.register), bp.register) 937 | setattr(bp, APP_CONFIG_INSTANCE_KEY, self) 938 | 939 | @classmethod 940 | def _recreate(cls, app): 941 | self = super(SanicPluginRealm, cls).__new__(cls) 942 | self._running = False 943 | self._app = app 944 | self._loop = None 945 | self._plugin_names = set() 946 | # these deques get replaced with frozen tuples at runtime 947 | self._pre_request_middleware = deque() 948 | self._post_request_middleware = deque() 949 | self._pre_response_middleware = deque() 950 | self._post_response_middleware = deque() 951 | self._cleanup_middleware = deque() 952 | self._contexts = SanicContext(self, None) 953 | self._contexts['shared'] = SanicContext(self, None, {'app': app}) 954 | self._contexts['_plugins'] = SanicContext(self, None, {'sanic_plugin_toolkit': self}) 955 | return self 956 | 957 | def __new__(cls, app, *args, **kwargs): 958 | if not app: 959 | raise RuntimeError("Plugin Realm must be given a valid Sanic App to work with.") 960 | if not (isinstance(app, Sanic) or isinstance(app, Blueprint)): 961 | raise RuntimeError( 962 | "PluginRealm only works with Sanic Apps or Blueprints. Please pass in an app instance to the Realm constructor." 963 | ) 964 | # An app _must_ only have one sanic_plugin_toolkit instance associated with it. 965 | # If there is already one registered on the app, return that one. 966 | try: 967 | instance = getattr(app.ctx, APP_CONFIG_INSTANCE_KEY) 968 | assert isinstance( 969 | instance, cls 970 | ), "This app is already registered to a different type of Sanic Plugin Realm!" 971 | return instance 972 | except (AttributeError, LookupError): 973 | # App doesn't have .ctx or key is not present 974 | try: 975 | instance = app.config[APP_CONFIG_INSTANCE_KEY] 976 | assert isinstance( 977 | instance, cls 978 | ), "This app is already registered to a different type of Sanic Plugin Realm!" 979 | return instance 980 | except AttributeError: # app must then be a blueprint 981 | try: 982 | instance = getattr(app, APP_CONFIG_INSTANCE_KEY) 983 | assert isinstance( 984 | instance, cls 985 | ), "This Blueprint is already registered to a different type of Sanic Plugin Realm!" 986 | return instance 987 | except AttributeError: 988 | pass 989 | except LookupError: 990 | pass 991 | self = cls._recreate(app) 992 | if isinstance(app, Blueprint): 993 | bp = app 994 | self._patch_blueprint(bp) 995 | bp.listener('before_server_start')(self._on_server_start) 996 | bp.listener('after_server_start')(self._on_after_server_start) 997 | else: 998 | if hasattr(Sanic, "__fake_slots__"): 999 | _slots = list(Sanic.__fake_slots__) 1000 | _slots.extend(["_startup", "handle_request", "_run_request_middleware", "_run_response_middleware"]) 1001 | Sanic.__fake_slots__ = tuple(_slots) 1002 | if hasattr(app, "_startup"): 1003 | # We can wrap startup, to patch _after_ Touchup is done 1004 | app._startup = update_wrapper(partial(self._startup, app, app._startup), app._startup) 1005 | else: 1006 | self._patch_app(app) 1007 | if SANIC_21_3_0 <= SANIC_VERSION: 1008 | setattr(app.ctx, APP_CONFIG_INSTANCE_KEY, self) 1009 | else: 1010 | app.config[APP_CONFIG_INSTANCE_KEY] = self 1011 | app.listener('before_server_start')(self._on_server_start) 1012 | app.listener('after_server_start')(self._on_after_server_start) 1013 | config = getattr(app, 'config', None) 1014 | if config: 1015 | load_ini = config.get(SPTK_LOAD_INI_KEY, True) 1016 | if load_ini: 1017 | ini_file = config.get(SPTK_INI_FILE_KEY, 'sptk.ini') 1018 | try: 1019 | load_config_file(self, app, ini_file) 1020 | except FileNotFoundError: 1021 | pass 1022 | return self 1023 | 1024 | def __init__(self, *args, **kwargs): 1025 | args = list(args) # tuple is not mutable. Change it to a list. 1026 | if len(args) > 0: 1027 | args.pop(0) # remove 'app' arg 1028 | assert self._app and self._contexts, "Sanic Plugin Realm was not initialized correctly." 1029 | assert len(args) < 1, "Unexpected arguments passed to the Sanic Plugin Realm." 1030 | assert len(kwargs) < 1, "Unexpected keyword arguments passed to the SanicPluginRealm." 1031 | super(SanicPluginRealm, self).__init__(*args, **kwargs) 1032 | 1033 | def __getstate__(self): 1034 | if self._running: 1035 | raise RuntimeError("Cannot call __getstate__ on an SPTK app that is already running.") 1036 | state_dict = {} 1037 | for s in SanicPluginRealm.__slots__: 1038 | if s in ('_running', '_loop'): 1039 | continue 1040 | state_dict[s] = getattr(self, s) 1041 | return state_dict 1042 | 1043 | def __setstate__(self, state): 1044 | running = getattr(self, '_running', False) 1045 | if running: 1046 | raise RuntimeError("Cannot call __setstate__ on an SPTK app that is already running.") 1047 | for s, v in state.items(): 1048 | if s in ('_running', '_loop'): 1049 | continue 1050 | if s == "__weakref__": 1051 | if v is None: 1052 | continue 1053 | else: 1054 | raise NotImplementedError("Setting weakrefs on SPTK PluginRealm") 1055 | setattr(self, s, v) 1056 | 1057 | def __reduce__(self): 1058 | if self._running: 1059 | raise RuntimeError("Cannot pickle a SPTK PluginRealm App after it has started running!") 1060 | state_dict = self.__getstate__() 1061 | app = state_dict.pop("_app") 1062 | return SanicPluginRealm._recreate, (app,), state_dict 1063 | --------------------------------------------------------------------------------