├── .gitignore ├── .travis.yml ├── FLASK_COMPRESS_LICENSE ├── LICENSE ├── README.md ├── app.py ├── dev_requirements.txt ├── sanic_compress └── __init__.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py └── test_compress.py /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *sublime* 3 | *.egg* 4 | .cache 5 | __pycache__ 6 | build 7 | dist 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.5" 4 | - "3.6" 5 | install: 6 | - "python setup.py install" 7 | - "pip install -r dev_requirements.txt" 8 | script: pytest -------------------------------------------------------------------------------- /FLASK_COMPRESS_LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 by Armin Ronacher and contributors. See AUTHORS 2 | for more details. 3 | 4 | Some rights reserved. 5 | 6 | Redistribution and use in source and binary forms of the software as well 7 | as documentation, with or without modification, are permitted provided 8 | that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above 14 | copyright notice, this list of conditions and the following 15 | disclaimer in the documentation and/or other materials provided 16 | with the distribution. 17 | 18 | * The names of the contributors may not be used to endorse or 19 | promote products derived from this software without specific 20 | prior written permission. 21 | 22 | THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND 23 | CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT 24 | NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 25 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER 26 | OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 27 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 28 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 29 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 30 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 31 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 32 | SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH 33 | DAMAGE. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Suby Raman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sanic_compress 2 | 3 | sanic_compress is an extension which allows you to easily gzip your Sanic responses. It is a port of the [Flask-Compress](https://github.com/libwilliam/flask-compress) extension. 4 | 5 | 6 | ## Installation 7 | 8 | Install with `pip`: 9 | 10 | `pip install sanic_compress` 11 | 12 | ## Usage 13 | 14 | Usage is simple. Simply pass in the Sanic app object to the `Compress` class, and responses will be gzipped. 15 | 16 | ```python 17 | from sanic import Sanic 18 | from sanic_compress import Compress 19 | 20 | app = Sanic(__name__) 21 | Compress(app) 22 | ``` 23 | 24 | ## Options 25 | 26 | Within the Sanic application config you can provide the following settings to control the behavior of sanic_compress. None of the settings are required. 27 | 28 | 29 | `COMPRESS_MIMETYPES`: Set the list of mimetypes to compress here. 30 | - Default: `{'text/html','text/css','text/xml','application/json','application/javascript'}` 31 | 32 | `COMPRESS_LEVEL`: Specifies the gzip compression level (1-9). 33 | - Default: `6` 34 | 35 | `COMPRESS_MIN_SIZE`: Specifies the minimum size (in bytes) threshold for compressing responses. 36 | - Default: `500` 37 | 38 | A higher `COMPRESS_LEVEL` will result in a gzipped response that is smaller, but the compression will take longer. 39 | 40 | Example of using custom configuration: 41 | 42 | ```python 43 | from sanic import Sanic 44 | from sanic_compress import Compress 45 | 46 | app = Sanic(__name__) 47 | app.config['COMPRESS_MIMETYPES'] = {'text/html', 'application/json'} 48 | app.config['COMPRESS_LEVEL'] = 4 49 | app.config['COMPRESS_MIN_SIZE'] = 300 50 | Compress(app) 51 | ``` 52 | 53 | ### Note about gzipping static files: 54 | 55 | Sanic is not at heart a file server. You should consider serving static files with nginx or on a separate file server. 56 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic 2 | from sanic.response import json, html, HTTPResponse 3 | from sanic_compress import Compress 4 | 5 | app = Sanic('compressed') 6 | Compress(app) 7 | 8 | 9 | @app.route('/json/') 10 | def j(request, length): 11 | data = {'a': "".join(['b'] * (int(length) - 8))} 12 | return json(data) 13 | 14 | 15 | @app.route('/') 16 | def h(request): 17 | res = "".join(['h' for i in range(int(501))]) 18 | return html(res) 19 | 20 | 21 | @app.route('/html/status/') 22 | def h_with_status(request, status): 23 | res = "".join(['h' for i in range(501)]) 24 | return html(res, status=int(status)) 25 | 26 | 27 | @app.route('/html/vary/') 28 | def h_with_vary(request, vary): 29 | res = "".join(['h' for i in range(501)]) 30 | return html(res, headers={'Vary': vary}) 31 | 32 | 33 | @app.route('/other/') 34 | def other(request, length): 35 | content_type = request.args.get('content_type') 36 | body = "".join(['h' for i in range(int(length))]) 37 | return HTTPResponse( 38 | body, content_type=content_type) 39 | 40 | app.run() -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | pytest -------------------------------------------------------------------------------- /sanic_compress/__init__.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | 3 | DEFAULT_MIME_TYPES = frozenset([ 4 | 'text/html', 'text/css', 'text/xml', 5 | 'application/json', 6 | 'application/javascript']) 7 | 8 | 9 | class Compress(object): 10 | def __init__(self, app=None): 11 | self.app = app 12 | if app is not None: 13 | self.init_app(app) 14 | 15 | def init_app(self, app): 16 | defaults = [ 17 | ('COMPRESS_MIMETYPES', DEFAULT_MIME_TYPES), 18 | ('COMPRESS_LEVEL', 6), 19 | ('COMPRESS_MIN_SIZE', 500), 20 | ] 21 | 22 | for k, v in defaults: 23 | app.config.setdefault(k, v) 24 | 25 | @app.middleware('response') 26 | async def compress_response(request, response): 27 | return (await self._compress_response(request, response)) 28 | 29 | async def _compress_response(self, request, response): 30 | accept_encoding = request.headers.get('Accept-Encoding', '') 31 | content_length = len(response.body) 32 | content_type = response.content_type 33 | 34 | if ';' in response.content_type: 35 | content_type = content_type.split(';')[0] 36 | 37 | if (content_type not in self.app.config['COMPRESS_MIMETYPES'] or 38 | 'gzip' not in accept_encoding.lower() or 39 | not 200 <= response.status < 300 or 40 | (content_length is not None and 41 | content_length < self.app.config['COMPRESS_MIN_SIZE']) or 42 | 'Content-Encoding' in response.headers): 43 | return response 44 | 45 | gzip_content = self.compress(response) 46 | response.body = gzip_content 47 | 48 | response.headers['Content-Encoding'] = 'gzip' 49 | response.headers['Content-Length'] = len(response.body) 50 | 51 | vary = response.headers.get('Vary') 52 | if vary: 53 | if 'accept-encoding' not in vary.lower(): 54 | response.headers['Vary'] = '{}, Accept-Encoding'.format(vary) 55 | else: 56 | response.headers['Vary'] = 'Accept-Encoding' 57 | 58 | return response 59 | 60 | def compress(self, response): 61 | out = gzip.compress( 62 | response.body, 63 | compresslevel=self.app.config['COMPRESS_LEVEL']) 64 | 65 | return out 66 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | try: 4 | import pypandoc 5 | long_description = pypandoc.convert('README.md', 'rst') 6 | long_description = long_description.replace("\r", "") 7 | except: 8 | long_description = '' 9 | 10 | setup( 11 | name='sanic_compress', 12 | version='0.1.1', 13 | description='An extension which allows you to easily gzip your Sanic responses.', 14 | long_description=long_description, 15 | url='http://github.com/subyraman/sanic_session', 16 | author='Suby Raman', 17 | license='MIT', 18 | packages=['sanic_compress'], 19 | install_requires=('sanic'), 20 | zip_safe=False, 21 | keywords=['sanic', 'gzip'], 22 | classifiers=[ 23 | 'Programming Language :: Python :: 3.5', 24 | 'Programming Language :: Python :: 3.6', 25 | 'Programming Language :: Python :: 3 :: Only', 26 | 'Topic :: Internet :: WWW/HTTP :: Session', 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subyraman/sanic_compress/a6ebe6d109be84bb31d186a239a944d250441ffc/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_compress.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sanic import Sanic 3 | from sanic.response import html, json, HTTPResponse 4 | from sanic_compress import Compress 5 | 6 | OTHER_COMPRESSIBLE_TYPES = set([ 7 | 'text/css', 8 | 'application/javascript']) 9 | 10 | BUNCH_OF_TYPES = OTHER_COMPRESSIBLE_TYPES.union([ 11 | 'image/png', 'application/pdf', 'image/jpeg', 12 | ]) 13 | 14 | CONTENT_LENGTHS = [100, 499, 500] 15 | HEADERS = [ 16 | {'Accept-Encoding': 'gzip'}, 17 | {'Accept-Encoding': ''} 18 | ] 19 | 20 | STATUSES = [200, 201, 400, 401, 500] 21 | 22 | VARY_HEADERS = ['Accept-Encoding', 'Referer', 'Cookie'] 23 | 24 | 25 | @pytest.fixture 26 | def compressed_app(): 27 | app = Sanic('compressed') 28 | Compress(app) 29 | 30 | @app.route('/json/') 31 | def j(request, length): 32 | data = {'a': "".join(['b'] * (int(length) - 8))} 33 | return json(data) 34 | 35 | @app.route('/html/') 36 | def h(request, length): 37 | res = "".join(['h' for i in range(int(length))]) 38 | return html(res) 39 | 40 | @app.route('/html/status/') 41 | def h_with_status(request, status): 42 | res = "".join(['h' for i in range(501)]) 43 | return html(res, status=int(status)) 44 | 45 | @app.route('/html/vary/') 46 | def h_with_vary(request, vary): 47 | res = "".join(['h' for i in range(501)]) 48 | return html(res, headers={'Vary': vary}) 49 | 50 | @app.route('/other/') 51 | def other(request, length): 52 | content_type = request.args.get('content_type') 53 | body = "".join(['h' for i in range(int(length))]) 54 | return HTTPResponse( 55 | body, content_type=content_type) 56 | 57 | return app 58 | 59 | 60 | @pytest.mark.parametrize('headers', HEADERS) 61 | @pytest.mark.parametrize('content_length', CONTENT_LENGTHS) 62 | def test_sets_gzip_for_html(compressed_app, headers, content_length): 63 | request, response = compressed_app.test_client.get( 64 | '/html/{}'.format(content_length), headers=headers) 65 | 66 | if ('gzip' in headers.get('Accept-Encoding') and 67 | content_length >= compressed_app.config['COMPRESS_MIN_SIZE']): 68 | assert response.headers['Content-Encoding'] == 'gzip' 69 | assert response.headers['Content-Length'] < str(content_length) 70 | else: 71 | assert response.headers['Content-Length'] == str(content_length) 72 | assert 'Content-Encoding' not in response.headers 73 | 74 | 75 | @pytest.mark.parametrize('headers', HEADERS) 76 | @pytest.mark.parametrize('content_length', CONTENT_LENGTHS) 77 | def test_gzip_for_json(compressed_app, headers, content_length): 78 | request, response = compressed_app.test_client.get( 79 | '/json/{}'.format(content_length), headers=headers) 80 | 81 | if ('gzip' in headers.get('Accept-Encoding') and 82 | content_length >= compressed_app.config['COMPRESS_MIN_SIZE']): 83 | assert response.headers['Content-Encoding'] == 'gzip' 84 | assert response.headers['Content-Length'] < str(content_length) 85 | else: 86 | assert response.headers['Content-Length'] == str(content_length) 87 | assert 'Content-Encoding' not in response.headers 88 | 89 | 90 | @pytest.mark.parametrize('headers', HEADERS) 91 | @pytest.mark.parametrize('content_length', CONTENT_LENGTHS) 92 | @pytest.mark.parametrize('content_type', BUNCH_OF_TYPES) 93 | def test_gzip_for_others( 94 | compressed_app, content_type, headers, content_length): 95 | request, response = compressed_app.test_client.get( 96 | '/other/{}'.format(content_length), headers=headers, 97 | params={'content_type': content_type}) 98 | 99 | if ('gzip' in headers.get('Accept-Encoding') and 100 | content_length >= compressed_app.config['COMPRESS_MIN_SIZE'] 101 | and content_type in OTHER_COMPRESSIBLE_TYPES): 102 | assert response.headers['Content-Encoding'] == 'gzip' 103 | assert response.headers['Content-Length'] < str(content_length) 104 | else: 105 | assert response.headers['Content-Length'] == str(content_length) 106 | assert 'Content-Encoding' not in response.headers 107 | 108 | 109 | @pytest.mark.parametrize('status', STATUSES) 110 | def test_no_gzip_for_invalid_status(compressed_app, status): 111 | request, response = compressed_app.test_client.get( 112 | '/html/status/{}'.format(status), 113 | headers={'Accept-Encoding': 'gzip'}) 114 | 115 | if status < 200 or status >= 300: 116 | assert 'Content-Encoding' not in response.headers 117 | else: 118 | assert response.headers['Content-Encoding'] == 'gzip' 119 | 120 | 121 | def test_gzip_levels_work(compressed_app): 122 | prev = None 123 | for i in range(1, 10): 124 | compressed_app.config['COMPRESS_LEVEL'] = i 125 | 126 | request, response = compressed_app.test_client.get( 127 | '/html/501', 128 | headers={'Accept-Encoding': 'gzip'}) 129 | 130 | if prev: 131 | print(response.headers['Content-Length']) 132 | assert response.headers['Content-Length'] < prev,\ 133 | 'compression level {} should be smaller than {}'.format( 134 | i, i-1 135 | ) 136 | 137 | 138 | @pytest.mark.parametrize('vary', VARY_HEADERS) 139 | def test_vary_header_modified(compressed_app, vary): 140 | request, response = compressed_app.test_client.get( 141 | '/html/vary/{}'.format(vary), 142 | headers={ 143 | 'Accept-Encoding': 'gzip', 144 | }) 145 | 146 | if vary: 147 | if 'accept-encoding' not in vary.lower(): 148 | assert response.headers['Vary'] == '{}, Accept-Encoding'.format( 149 | vary) 150 | else: 151 | assert response.headers['Vary'] == 'Accept-Encoding' 152 | --------------------------------------------------------------------------------