Skip to content

Commit

Permalink
add ZStandard compression (#45)
Browse files Browse the repository at this point in the history
* add ZStandard compression

* switch to using python-zstandard

---------

Co-authored-by: Tristan Moreno <tristan.moreno@orange.com>
  • Loading branch information
redorff and Tristan Moreno committed Apr 25, 2024
1 parent 5cf354b commit a493830
Show file tree
Hide file tree
Showing 5 changed files with 54 additions and 10 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

All notable changes to `flask-compress` will be documented in this file.

## 1.15 (2024-04-24)

- Add support of Zstandard compression.

## 1.14 (2023-09-11)

- Add `text/javascript` mimetype. See [#41](https://github.com/colour-science/flask-compress/pull/41)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,11 @@ Within your Flask application's settings you can provide the following settings
| `COMPRESS_BR_MODE` | For Brotli, the compression mode. The options are 0, 1, or 2. These correspond to "generic", "text" (for UTF-8 input), and "font" (for WOFF 2.0). | `0` |
| `COMPRESS_BR_WINDOW` | For Brotli, this specifies the base-2 logarithm of the sliding window size. Ranges from 10 to 24. | `22` |
| `COMPRESS_BR_BLOCK` | For Brotli, this provides the base-2 logarithm of the maximum input block size. If zero is provided, value will be determined based on the quality. Ranges from 16 to 24. | `0` |
| `COMPRESS_ZSTD_LEVEL` | Specifies the ZStandard compression level. Ranges from 1 to 22. Levels >= 20, labeled ultra, should be used with caution, as they require more memory. 0 means use the default level. -131072 to -1, negative levels extend the range of speed vs ratio preferences. The lower the level, the faster the speed, but at the cost of compression ratio. | `3` |
| `COMPRESS_DEFLATE_LEVEL` | Specifies the deflate compression level. | `-1` |
| `COMPRESS_MIN_SIZE` | Specifies the minimum file size threshold for compressing files. | `500` |
| `COMPRESS_CACHE_KEY` | Specifies the cache key method for lookup/storage of response data. | `None` |
| `COMPRESS_CACHE_BACKEND` | Specified the backend for storing the cached response data. | `None` |
| `COMPRESS_REGISTER` | Specifies if compression should be automatically registered. | `True` |
| `COMPRESS_ALGORITHM` | Supported compression algorithms. | `['br', 'gzip', 'deflate']` |
| `COMPRESS_ALGORITHM` | Supported compression algorithms. | `['zstd', 'br', 'gzip', 'deflate']` |
| `COMPRESS_STREAMS` | Compress content streams. | `True` |
11 changes: 8 additions & 3 deletions flask_compress/flask_compress.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
except ImportError:
import brotli

import zstandard

from flask import request, after_this_request, current_app


Expand Down Expand Up @@ -81,13 +83,14 @@ def init_app(self, app):
('COMPRESS_BR_MODE', 0),
('COMPRESS_BR_WINDOW', 22),
('COMPRESS_BR_BLOCK', 0),
('COMPRESS_ZSTD_LEVEL', 3),
('COMPRESS_DEFLATE_LEVEL', -1),
('COMPRESS_MIN_SIZE', 500),
('COMPRESS_CACHE_KEY', None),
('COMPRESS_CACHE_BACKEND', None),
('COMPRESS_REGISTER', True),
('COMPRESS_STREAMS', True),
('COMPRESS_ALGORITHM', ['br', 'gzip', 'deflate']),
('COMPRESS_ALGORITHM', ['zstd', 'br', 'gzip', 'deflate']),
]

for k, v in defaults:
Expand Down Expand Up @@ -115,7 +118,7 @@ def _choose_compress_algorithm(self, accept_encoding_header):
means the client prefers that algorithm more).
:param accept_encoding_header: Content of the `Accept-Encoding` header
:return: name of a compression algorithm (`gzip`, `deflate`, `br`) or `None` if
:return: name of a compression algorithm (`gzip`, `deflate`, `br`, 'zstd') or `None` if
the client and server don't agree on any.
"""
# A flag denoting that client requested using any (`*`) algorithm,
Expand Down Expand Up @@ -188,7 +191,7 @@ def after_request(self, response):
response.mimetype not in app.config["COMPRESS_MIMETYPES"] or
response.status_code < 200 or
response.status_code >= 300 or
(response.is_streamed and app.config["COMPRESS_STREAMS"] is False)or
(response.is_streamed and app.config["COMPRESS_STREAMS"] is False) or
"Content-Encoding" in response.headers or
(response.content_length is not None and
response.content_length < app.config["COMPRESS_MIN_SIZE"])):
Expand Down Expand Up @@ -246,3 +249,5 @@ def compress(self, app, response, algorithm):
quality=app.config['COMPRESS_BR_LEVEL'],
lgwin=app.config['COMPRESS_BR_WINDOW'],
lgblock=app.config['COMPRESS_BR_BLOCK'])
elif algorithm == 'zstd':
return zstandard.ZstdCompressor(app.config['COMPRESS_ZSTD_LEVEL']).compress(response.get_data())
6 changes: 4 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
license='MIT',
author='Thomas Mansencal',
author_email='thomas.mansencal@gmail.com',
description='Compress responses in your Flask app with gzip, deflate or brotli.',
description='Compress responses in your Flask app with gzip, deflate, brotli or zstandard.',
long_description=LONG_DESCRIPTION,
long_description_content_type='text/markdown',
packages=find_packages(exclude=['tests']),
Expand All @@ -20,7 +20,9 @@
install_requires=[
'flask',
"brotli; platform_python_implementation!='PyPy'",
"brotlicffi; platform_python_implementation=='PyPy'"
"brotlicffi; platform_python_implementation=='PyPy'",
"zstandard; platform_python_implementation!='PyPy'",
"zstandard[cffi]; platform_python_implementation=='PyPy'",
],
setup_requires=[
'setuptools_scm',
Expand Down
40 changes: 36 additions & 4 deletions tests/test_flask_compress.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def test_min_size_default(self):

def test_algorithm_default(self):
""" Tests COMPRESS_ALGORITHM default value is correctly set. """
self.assertEqual(self.app.config['COMPRESS_ALGORITHM'], ['br', 'gzip', 'deflate'])
self.assertEqual(self.app.config['COMPRESS_ALGORITHM'], ['zstd', 'br', 'gzip', 'deflate'])

def test_default_deflate_settings(self):
""" Tests COMPRESS_DELATE_LEVEL default value is correctly set. """
Expand All @@ -61,6 +61,10 @@ def test_stream(self):
""" Tests COMPRESS_STREAMS default value is correctly set. """
self.assertEqual(self.app.config['COMPRESS_STREAMS'], True)

def test_quality_level_default_zstd(self):
""" Tests COMPRESS_ZSTD_LEVEL default value is correctly set. """
self.assertEqual(self.app.config['COMPRESS_ZSTD_LEVEL'], 3)

class InitTests(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
Expand Down Expand Up @@ -114,6 +118,16 @@ def test_br_algorithm(self):
response = client.options('/large/', headers=headers)
self.assertEqual(response.status_code, 200)

def test_zstd_algorithm(self):
client = self.app.test_client()
headers = [('Accept-Encoding', 'zstd')]

response = client.options('/small/', headers=headers)
self.assertEqual(response.status_code, 200)

response = client.options('/large/', headers=headers)
self.assertEqual(response.status_code, 200)

def test_compress_min_size(self):
""" Tests COMPRESS_MIN_SIZE correctly affects response data. """
response = self.client_get('/small/')
Expand Down Expand Up @@ -175,6 +189,19 @@ def test_deflate_compression_level(self):

self.assertNotEqual(response_size, response1_size)

def test_zstd_compression_level(self):
""" Tests that COMPRESS_ZSTD_LEVEL correctly affects response data. """
self.app.config['COMPRESS_ZSTD_LEVEL'] = 1
client = self.app.test_client()
response = client.get('/large/', headers=[('Accept-Encoding', 'zstd')])
response1_size = len(response.data)

self.app.config['COMPRESS_ZSTD_LEVEL'] = 11
client = self.app.test_client()
response = client.get('/large/', headers=[('Accept-Encoding', 'zstd')])
response11_size = len(response.data)

self.assertNotEqual(response1_size, response11_size)

class CompressionAlgoTests(unittest.TestCase):
"""
Expand Down Expand Up @@ -306,7 +333,7 @@ def test_chrome_ranged_requests(self):

def test_content_encoding_is_correct(self):
""" Test that the `Content-Encoding` header matches the compression algorithm """
self.app.config['COMPRESS_ALGORITHM'] = ['br', 'gzip', 'deflate']
self.app.config['COMPRESS_ALGORITHM'] = ['zstd', 'br', 'gzip', 'deflate']
Compress(self.app)

headers_gzip = [('Accept-Encoding', 'gzip')]
Expand All @@ -327,6 +354,11 @@ def test_content_encoding_is_correct(self):
self.assertIn('Content-Encoding', response_deflate.headers)
self.assertEqual(response_deflate.headers.get('Content-Encoding'), 'deflate')

headers_zstd = [('Accept-Encoding', 'zstd')]
client = self.app.test_client()
response_zstd = client.options('/small/', headers=headers_zstd)
self.assertIn('Content-Encoding', response_zstd.headers)
self.assertEqual(response_zstd.headers.get('Content-Encoding'), 'zstd')

class CompressionPerViewTests(unittest.TestCase):
def setUp(self):
Expand Down Expand Up @@ -381,7 +413,7 @@ def test_no_compression_stream(self):
""" Tests compression is skipped when response is streamed"""
Compress(self.app)
client = self.app.test_client()
for algorithm in ('gzip', 'deflate', 'br', ''):
for algorithm in ('gzip', 'deflate', 'br', 'zstd', ''):
headers = [('Accept-Encoding', algorithm)]
response = client.get('/stream/large', headers=headers)
self.assertEqual(response.status_code, 200)
Expand All @@ -393,7 +425,7 @@ def test_disabled_stream(self):
Compress(self.app)
self.app.config["COMPRESS_STREAMS"] = True
client = self.app.test_client()
for algorithm in ('gzip', 'deflate', 'br'):
for algorithm in ('gzip', 'deflate', 'br', 'zstd'):
headers = [('Accept-Encoding', algorithm)]
response = client.get('/stream/large', headers=headers)
self.assertIn('Content-Encoding', response.headers)
Expand Down

0 comments on commit a493830

Please sign in to comment.