././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4708102 flask_compress-1.25/0000755000175100017510000000000015252212527014125 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4691164 flask_compress-1.25/.github/0000755000175100017510000000000015252212527015465 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465936.8397255 flask_compress-1.25/.github/dependabot.yaml0000644000175100017510000000027315252212521020452 0ustar00runnerrunnerversion: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" groups: github-actions: patterns: - "*" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4691834 flask_compress-1.25/.github/workflows/0000755000175100017510000000000015252212527017522 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841197 flask_compress-1.25/.github/workflows/ci.yaml0000644000175100017510000000631015252212521020773 0ustar00runnerrunner# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Python package on: pull_request: push: branches: - master tags: - "v*" jobs: test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: python-version: - '3.9' - '3.10' - '3.11' - '3.12' - '3.13' - '3.14' - '3.15' - 'pypy-3.11' os: [ubuntu-latest, windows-latest, macos-latest] name: ${{ matrix.os }} - ${{ matrix.python-version }} steps: - name: Set git to not change line endings run: | git config --global core.autocrlf false git config --global core.eol lf - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get history and tags for SCM versioning to work run: | git fetch --prune --unshallow git fetch --depth=1 origin +refs/tags/*:refs/tags/* - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Install dependencies run: pip install tox - name: Run tox # Run tox using the version of Python in `PATH` run: tox -e py,coverage_report-ci dist: runs-on: ubuntu-latest needs: [test] name: Build Python packages steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get history and tags for SCM versioning to work run: | git fetch --prune --unshallow git fetch --depth=1 origin +refs/tags/*:refs/tags/* - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip pip install --upgrade wheel setuptools build - name: Build package run: python -m build -s -w -o dist/ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist path: dist dist_check: runs-on: ubuntu-latest needs: [dist] name: Twine check steps: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Install dependencies run: pip install twine - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: dist path: dist - run: twine check dist/* dist_upload: runs-on: ubuntu-latest if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') needs: [dist_check] name: PyPI upload environment: pypi permissions: id-token: write steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: dist path: dist - name: Publish package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841197 flask_compress-1.25/.gitignore0000644000175100017510000000051615252212521016111 0ustar00runnerrunner*~ # vim *.sw[klmnop] # python *.py[co~] .python-version # Ignoring build dir /build /dist /*.egg-info/ # Virtual environments venv* .env* # Coverage reports /cover/ /.coverage* /htmlcov # Ignoring profiling reports *.prof *.lprof # Tox and pytest /.tox /.cache /.pytest_cache # Generated by setuptools_scm flask_compress/_version.py ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841197 flask_compress-1.25/.pre-commit-config.yaml0000644000175100017510000000341115252212521020377 0ustar00runnerrunnerci: autoupdate_schedule: "quarterly" default_language_version: python: "python3.12" exclude: "tests/templates/" repos: - repo: "meta" hooks: - id: "check-hooks-apply" - id: "check-useless-excludes" - repo: "https://github.com/pre-commit/pre-commit-hooks" rev: "3e8a8703264a2f4a69428a0aa4dcb512790b2c8c" # frozen: v6.0.0 hooks: - id: "check-added-large-files" - id: "check-merge-conflict" - id: "check-yaml" - id: "end-of-file-fixer" - id: "mixed-line-ending" args: - "--fix=lf" - id: "trailing-whitespace" - repo: "https://github.com/asottile/pyupgrade" rev: "75992aaa40730136014f34227e0135f63fc951b4" # frozen: v3.21.2 hooks: - id: "pyupgrade" name: "Enforce Python 3.9+ idioms" args: - "--py39-plus" - repo: "https://github.com/psf/black-pre-commit-mirror" rev: "4160603246a6b365d4a2af661c6d71b0a0f50478" # frozen: 26.5.1 hooks: - id: "black" - repo: "https://github.com/pycqa/flake8" rev: "c48217e1fc006c2dddd14df54e83b67da15de5cd" # frozen: 7.3.0 hooks: - id: "flake8" additional_dependencies: - "flake8-bugbear==24.8.19" - repo: "https://github.com/pre-commit/mirrors-mypy" rev: "41e691678310dfd3833f7ab4e180ddb014310356" # frozen: v2.3.0 hooks: - id: "mypy" additional_dependencies: - "flask>=3.0" - repo: "https://github.com/python-jsonschema/check-jsonschema" rev: "6b63472e72e1a91ed8a2f6d483790dfb644fa1d3" # frozen: 0.37.4 hooks: - id: "check-dependabot" - id: "check-github-workflows" - repo: "https://github.com/rhysd/actionlint" rev: "914e7df21a07ef503a81201c76d2b11c789d3fca" # frozen: v1.7.12 hooks: - id: "actionlint" ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841197 flask_compress-1.25/CHANGELOG.md0000644000175100017510000001543415252212521015737 0ustar00runnerrunner# Changelog All notable changes to `flask-compress` will be documented in this file. ## 1.25 (2026-09-15) - support, and test against, Python 3.15 - return an empty `204 No Content` response when the Flask `after_request` hook receives no response, [#83](https://github.com/colour-science/flask-compress/pull/83) by @mmdbalkhi ## 1.24 (2026-03-31) - add mypy annotations and mark it as checkable, [#76](https://github.com/colour-science/flask-compress/pull/76) by @orborde - enable trusted publishing, allowing GitHub Actions to publish releases to PyPI without long lived credentials, [#78](https://github.com/colour-science/flask-compress/issues/78) by @alexprengere ## 1.23 (2025-11-06) - add `gzip` to supported algorithms for streaming responses, but not in the `COMPRESS_ALGORITHM_STREAMING` default values, so users have to explicitly enable it - add Windows and MacOS to the list of platforms tested on GitHub Actions ## 1.22 (2025-11-04) - fix possible `RuntimeError` when applying compression to a streaming endpoint - no longer apply `make_conditional` to streaming responses, as it had the effect of buffering the entire response, which defeats the purpose of streaming responses, unless the `request.endpoint` is in a new `COMPRESS_STREAMING_ENDPOINT_CONDITIONAL` config option, which defaults to `["static"]` > As serving static content with Flask is using *streaming* responses, we wanted to preserve the correct handling of conditional requests (i.e. 304 Not Modified responses) for static content, while avoiding buffering the entire response when compressing streaming responses for other endpoints ## 1.21 (2025-11-04, yanked) - compressing *streaming* responses is now supported: - the *previous* behavior was: - if `COMPRESS_STREAMS` was `True` (the default), streaming responses were compressed in *1 sitting* using `response.get_data()`, meaning streaming responses were compressed but *not streamed* - if `COMPRESS_STREAMS` was `False`, streaming responses were not compressed at all - the *new* behavior is: - if `COMPRESS_STREAMS` is `True`, streaming responses are compressed chunk-by-chunk using `response.iter_encoded()`, which means that streaming responses are compressed and streamed - if `COMPRESS_STREAMS` is `False`, streaming responses are not compressed at all, as before. - we have a new `COMPRESS_ALGORITHM_STREAMING` config option to specify the compression algorithm to use for streaming responses, which defaults to `["zstd", "br", "deflate"]`, as `"gzip"` is not suitable for streaming compression. - ⚠� serving static content with Flask is actually using *streaming* responses, so if you enable streaming compression, static content will be compressed chunk-by-chunk as well, which may not be what you want. ## 1.20 (2025-10-20) - the new `COMPRESS_EVALUATE_CONDITIONAL_REQUEST` config option is now `True` by default ## 1.19 (2025-10-20) - Use `backports.zstd` instead of `pyzstd`, [#63](https://github.com/colour-science/flask-compress/pull/63) by @alexprengere - Add new behaviours to improve Etag compatibility, [#67](https://github.com/colour-science/flask-compress/pull/67), by @jlopex - weak etags are no longer modified by flask-compress - add new `COMPRESS_EVALUATE_CONDITIONAL_REQUEST` config option to call `response.make_conditional(request)` after compression, this ensures that 304 Not Modified responses are correctly generated when the client sends conditional requests with `If-None-Match` (defaults to `False`) ## 1.18 (2025-07-11) - Support, and test against, Python 3.14, see [#61](https://github.com/colour-science/flask-compress/pull/61) by @kurtmckee - Migrate from `zstandard` to `pyzstd`, which is now included in the Python 3.14 standard library, also part of [#61](https://github.com/colour-science/flask-compress/pull/61) - Restore `flask_compress.DictCache()` that is needed to configure Flask-Compress with caching, without relying on [Flask-Caching](https://pypi.org/project/Flask-Caching/) ## 1.17 (2024-10-14) - Add support for Python 3.13 in tox and classifiers - Expand mimetype defaults, see [#57](https://github.com/colour-science/flask-compress/pull/57) - Minor performance improvements, cache the algorithm selection logic ## 1.16 (2024-10-13) - Drop support for Python 3.8 and lower, see [#46](https://github.com/colour-science/flask-compress/pull/46) - Fix cache pollution caused by caching compression results for different compression algorithms, see [#53](https://github.com/colour-science/flask-compress/issues/53) ## 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) - Use [brotlicffi](https://github.com/python-hyper/brotlicffi) for PyPy ## 1.13 (2022-09-21) - Re-enable compression for streaming, but hide it behind a new option `COMPRESS_STREAMS` that defaults to `True`. See [#36](https://github.com/colour-science/flask-compress/pull/36) ## 1.12 (2022-04-28) - Disable compression when response is streamed, see [#31](https://github.com/colour-science/flask-compress/pull/31) ## 1.11 (2022-03-01) - When compression is enabled, *accept-encoding* is always added to the `Vary` header, fixes [#28](https://github.com/colour-science/flask-compress/issues/28) ## 1.10.0 (2021-06-15) - Automate the release process with GitHub Actions - Use `setuptools_scm` to manage package versions - The layout is now an actual package rather than a single module - Clean up unused files ## 1.9.0 (2021-02-12) - Add support for the `identity` value in *accept-encoding*, fixes [#19](https://github.com/colour-science/flask-compress/issues/19) ## 1.8.0 (2020-11-03) - Support ETag header as defined in *RFC7232* [#17](https://github.com/colour-science/flask-compress/pull/17) - Implement per-view compression [#14](https://github.com/colour-science/flask-compress/pull/14) ## 1.7.0 (2020-10-09) - The following parameters to control Brotli compression are now available: [#10](https://github.com/colour-science/flask-compress/pull/10) - `COMPRESS_BR_MODE` - `COMPRESS_BR_LEVEL` - `COMPRESS_BR_WINDOW` - `COMPRESS_BR_BLOCK` - Add deflate support, with `COMPRESS_DEFLATE_LEVEL` to control compression level (default is `-1`) [#8](https://github.com/colour-science/flask-compress/pull/8) - The default quality level for Brotli is now `4`, which provides compression comparable to `gzip` at the default setting, while reducing the time required versus the Brotli default of `11` ## 1.6.0 (2020-10-05) - Support for multiple compression algorithms and quality factors [#7](https://github.com/colour-science/flask-compress/pull/7) - Modified default compression settings to use Brotli when available before `gzip` ## 1.5.0 (2020-05-09) - Add Brotli support [#1](https://github.com/colour-science/flask-compress/pull/1) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465943.469606 flask_compress-1.25/Flask_Compress.egg-info/0000755000175100017510000000000015252212527020532 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4453921 flask_compress-1.25/Flask_Compress.egg-info/PKG-INFO0000644000175100017510000002563615252212527021643 0ustar00runnerrunnerMetadata-Version: 2.4 Name: Flask-Compress Version: 1.25 Summary: Compress responses in your Flask app with gzip, deflate, brotli or zstandard. Home-page: https://github.com/colour-science/flask-compress Author: Thomas Mansencal Author-email: thomas.mansencal@gmail.com License: MIT Platform: any Classifier: Environment :: Web Environment Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Classifier: Programming Language :: Python :: 3.15 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content Classifier: Topic :: Software Development :: Libraries :: Python Modules Requires-Python: >=3.9 Description-Content-Type: text/markdown License-File: LICENSE.txt Requires-Dist: flask Requires-Dist: brotli; platform_python_implementation != "PyPy" Requires-Dist: brotlicffi; platform_python_implementation == "PyPy" Requires-Dist: backports.zstd; python_version < "3.14" Dynamic: author Dynamic: author-email Dynamic: classifier Dynamic: description Dynamic: description-content-type Dynamic: home-page Dynamic: license Dynamic: license-file Dynamic: platform Dynamic: requires-dist Dynamic: requires-python Dynamic: summary # Flask-Compress [![Build](https://github.com/colour-science/flask-compress/actions/workflows/ci.yaml/badge.svg)](https://github.com/colour-science/flask-compress/actions/workflows/ci.yaml) [![Version](https://img.shields.io/pypi/v/flask-compress.svg)](https://pypi.python.org/pypi/Flask-Compress) [![Downloads](https://static.pepy.tech/badge/flask-compress)](https://pypi.python.org/pypi/Flask-Compress) Flask-Compress allows you to easily compress your [Flask](http://flask.pocoo.org/) application's responses with gzip, deflate, brotli or zstd. It originally started as a fork of [Flask-gzip](https://github.com/closeio/Flask-gzip). Supported Python versions are 3.9 and newer. The preferred solution is to have a server (like [Nginx](http://wiki.nginx.org/Main)) automatically compress the static files for you. If you don't have that option Flask-Compress will solve the problem for you. ## How it works Flask-Compress both adds the various headers required for a compressed response and compresses the response data. This makes serving compressed static files extremely easy. Internally, every time a request is made the extension will check if it matches one of the compressible MIME types and whether the client and the server use some common compression algorithm, and will automatically attach the appropriate headers. To determine the compression algorithm, the `Accept-Encoding` request header is inspected, respecting the quality factor as described in [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding). If no requested compression algorithm is supported by the server, we don't compress the response. If, on the other hand, multiple suitable algorithms are found and are requested with the same quality factor, we choose the first one defined in the `COMPRESS_ALGORITHM` option (see below). ## Installation If you use pip then installation is simply: ```shell $ pip install flask-compress ``` or, if you want the latest github version: ```shell $ pip install git+git://github.com/colour-science/flask-compress.git ``` ## Using Flask-Compress ### Globally Flask-Compress is incredibly simple to use. In order to start compressing your Flask application's assets, the first thing to do is let Flask-Compress know about your [`flask.Flask`](http://flask.pocoo.org/docs/latest/api/#flask.Flask) application object. ```python from flask import Flask from flask_compress import Compress app = Flask(__name__) Compress(app) ``` In many cases, however, one cannot expect a Flask instance to be ready at import time, and a common pattern is to return a Flask instance from within a function only after other configuration details have been taken care of. In these cases, Flask-Compress provides a simple function, `flask_compress.Compress.init_app`, which takes your application as an argument. ```python from flask import Flask from flask_compress import Compress compress = Compress() def start_app(): app = Flask(__name__) compress.init_app(app) return app ``` In terms of automatically compressing your assets, passing your [`flask.Flask`](http://flask.pocoo.org/docs/latest/api/#flask.Flask) object to the `flask_compress.Compress` object is all that needs to be done. ### Per-view compression Compression is possible per view using the `@compress.compressed()` decorator. Make sure to disable global compression first. ```python from flask import Flask from flask_compress import Compress app = Flask(__name__) app.config["COMPRESS_REGISTER"] = False # disable default compression of all eligible requests compress = Compress() compress.init_app(app) # Compress this view specifically @app.route("/test") @compress.compressed() def view(): pass ``` ### Cache example Flask-Compress can be integrated with caching mechanisms to serve compressed responses directly from the cache. This can significantly reduce server load and response times. Here is an example of how to configure Flask-Compress with caching using [Flask-Caching](https://pypi.org/project/Flask-Caching/). The example demonstrates how to create a simple cache instance with a 1-hour timeout, and use it to cache compressed responses for incoming requests. ```python from flask import Flask from flask_compress import Compress from flask_cache import Cache # Initializing flask app app = Flask(__name__) cache = Cache(config={ 'CACHE_TYPE': 'SimpleCache', 'CACHE_DEFAULT_TIMEOUT': 60*60 # 1 hour cache timeout }) cache.init_app(app) # Define a function to return cache key for incoming requests def get_cache_key(request): return request.url # Initialize Flask-Compress compress = Compress() compress.init_app(app) # Set up cache for compressed responses compress.cache = cache compress.cache_key = get_cache_key ``` If you do not want to pull an external dependency, you can use a simple in-memory cache using `compress.cache = flask_compress.DictCache()`. ## ETag support Flask-Compress supports ETag headers for conditional requests. When a client makes a request with an `If-None-Match` header, Flask-Compress will evaluate the ETag and return a `304 Not Modified` response if the resource has not changed. This helps to reduce bandwidth usage and improve performance for clients that support caching. To disable ETag support, set the `COMPRESS_EVALUATE_CONDITIONAL_REQUEST` configuration option to `False` in your Flask application settings. > For streaming responses, ETag support is disabled by default, as this effectively requires buffering the entire response in memory to compute the ETag. If you want to enable ETag support for streaming responses, you can add the endpoint name to the `COMPRESS_STREAMING_ENDPOINT_CONDITIONAL` configuration option, which defaults to `["static"]` for static files served by Flask. ## Streaming support Flask-Compress supports compressing streaming responses. By default, streaming responses are compressed using the algorithms specified in the `COMPRESS_ALGORITHM_STREAMING` configuration option. To disable streaming compression, set the `COMPRESS_STREAMS` configuration option to `False` in your Flask application settings. > As mentioned above, ETag support is disabled by default for streaming responses. If you want to enable it for specific endpoints, you can add the endpoint name to the `COMPRESS_STREAMING_ENDPOINT_CONDITIONAL` configuration option, but this will require buffering the entire response in memory to compute the ETag. ## Options Within your Flask application's settings you can provide the following settings to control the behavior of Flask-Compress. None of the settings are required. | Option | Description | Default | | ------ | ----------- | ------- | | `COMPRESS_MIMETYPES` | Set the list of mimetypes to compress here. | `[`
`'text/html',`
`'text/css',`
`'text/plain',`
`'text/xml',`
`'text/x-component',`
`'text/javascript',`
`'application/x-javascript',`
`'application/javascript',`
`'application/json',`
`'application/manifest+json',`
`'application/vnd.api+json',`
`'application/xml',`
`'application/xhtml+xml',`
`'application/rss+xml',`
`'application/atom+xml',`
`'application/vnd.ms-fontobject',`
`'application/x-font-ttf',`
`'application/x-font-opentype',`
`'application/x-font-truetype',`
`'image/svg+xml',`
`'image/x-icon',`
`'image/vnd.microsoft.icon',`
`'font/ttf',`
`'font/eot',`
`'font/otf',`
`'font/opentype',`
`]` | | `COMPRESS_LEVEL` | Specifies the gzip compression level. | `6` | | `COMPRESS_BR_LEVEL` | Specifies the Brotli compression level. Ranges from 0 to 11. | `4` | | `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. | `['zstd', 'br', 'gzip', 'deflate']` | | `COMPRESS_ALGORITHM_STREAMING` | Supported compression algorithms for streaming. | `['zstd', 'br', 'deflate']` | | `COMPRESS_STREAMS` | Compress streaming responses. | `True` | | `COMPRESS_EVALUATE_CONDITIONAL_REQUEST` | Compress evaluates conditional requests. | `True` | | `COMPRESS_STREAMING_ENDPOINT_CONDITIONAL` | Streaming endpoints where we evaluate conditional requests. | `["static"]` | ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4649947 flask_compress-1.25/Flask_Compress.egg-info/SOURCES.txt0000644000175100017510000000112515252212527022415 0ustar00runnerrunner.gitignore .pre-commit-config.yaml CHANGELOG.md LICENSE.txt README.md pyproject.toml setup.py tox.ini .github/dependabot.yaml .github/workflows/ci.yaml Flask_Compress.egg-info/PKG-INFO Flask_Compress.egg-info/SOURCES.txt Flask_Compress.egg-info/dependency_links.txt Flask_Compress.egg-info/requires.txt Flask_Compress.egg-info/top_level.txt flask_compress/__init__.py flask_compress/_version.py flask_compress/compat.py flask_compress/flask_compress.py flask_compress/py.typed tests/test_flask_compress.py tests/static/1.png tests/templates/large.html tests/templates/small.html tests/web/test.xml././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465943.445512 flask_compress-1.25/Flask_Compress.egg-info/dependency_links.txt0000644000175100017510000000000115252212527024600 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4458597 flask_compress-1.25/Flask_Compress.egg-info/requires.txt0000644000175100017510000000023515252212527023132 0ustar00runnerrunnerflask [:platform_python_implementation != "PyPy"] brotli [:platform_python_implementation == "PyPy"] brotlicffi [:python_version < "3.14"] backports.zstd ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4459372 flask_compress-1.25/Flask_Compress.egg-info/top_level.txt0000644000175100017510000000001715252212527023262 0ustar00runnerrunnerflask_compress ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841197 flask_compress-1.25/LICENSE.txt0000644000175100017510000000207515252212521015746 0ustar00runnerrunnerThe MIT License (MIT) Copyright (c) 2013-2017 William Fagan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4707832 flask_compress-1.25/PKG-INFO0000644000175100017510000002563615252212527015236 0ustar00runnerrunnerMetadata-Version: 2.4 Name: Flask-Compress Version: 1.25 Summary: Compress responses in your Flask app with gzip, deflate, brotli or zstandard. Home-page: https://github.com/colour-science/flask-compress Author: Thomas Mansencal Author-email: thomas.mansencal@gmail.com License: MIT Platform: any Classifier: Environment :: Web Environment Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Classifier: Programming Language :: Python :: 3.15 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content Classifier: Topic :: Software Development :: Libraries :: Python Modules Requires-Python: >=3.9 Description-Content-Type: text/markdown License-File: LICENSE.txt Requires-Dist: flask Requires-Dist: brotli; platform_python_implementation != "PyPy" Requires-Dist: brotlicffi; platform_python_implementation == "PyPy" Requires-Dist: backports.zstd; python_version < "3.14" Dynamic: author Dynamic: author-email Dynamic: classifier Dynamic: description Dynamic: description-content-type Dynamic: home-page Dynamic: license Dynamic: license-file Dynamic: platform Dynamic: requires-dist Dynamic: requires-python Dynamic: summary # Flask-Compress [![Build](https://github.com/colour-science/flask-compress/actions/workflows/ci.yaml/badge.svg)](https://github.com/colour-science/flask-compress/actions/workflows/ci.yaml) [![Version](https://img.shields.io/pypi/v/flask-compress.svg)](https://pypi.python.org/pypi/Flask-Compress) [![Downloads](https://static.pepy.tech/badge/flask-compress)](https://pypi.python.org/pypi/Flask-Compress) Flask-Compress allows you to easily compress your [Flask](http://flask.pocoo.org/) application's responses with gzip, deflate, brotli or zstd. It originally started as a fork of [Flask-gzip](https://github.com/closeio/Flask-gzip). Supported Python versions are 3.9 and newer. The preferred solution is to have a server (like [Nginx](http://wiki.nginx.org/Main)) automatically compress the static files for you. If you don't have that option Flask-Compress will solve the problem for you. ## How it works Flask-Compress both adds the various headers required for a compressed response and compresses the response data. This makes serving compressed static files extremely easy. Internally, every time a request is made the extension will check if it matches one of the compressible MIME types and whether the client and the server use some common compression algorithm, and will automatically attach the appropriate headers. To determine the compression algorithm, the `Accept-Encoding` request header is inspected, respecting the quality factor as described in [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding). If no requested compression algorithm is supported by the server, we don't compress the response. If, on the other hand, multiple suitable algorithms are found and are requested with the same quality factor, we choose the first one defined in the `COMPRESS_ALGORITHM` option (see below). ## Installation If you use pip then installation is simply: ```shell $ pip install flask-compress ``` or, if you want the latest github version: ```shell $ pip install git+git://github.com/colour-science/flask-compress.git ``` ## Using Flask-Compress ### Globally Flask-Compress is incredibly simple to use. In order to start compressing your Flask application's assets, the first thing to do is let Flask-Compress know about your [`flask.Flask`](http://flask.pocoo.org/docs/latest/api/#flask.Flask) application object. ```python from flask import Flask from flask_compress import Compress app = Flask(__name__) Compress(app) ``` In many cases, however, one cannot expect a Flask instance to be ready at import time, and a common pattern is to return a Flask instance from within a function only after other configuration details have been taken care of. In these cases, Flask-Compress provides a simple function, `flask_compress.Compress.init_app`, which takes your application as an argument. ```python from flask import Flask from flask_compress import Compress compress = Compress() def start_app(): app = Flask(__name__) compress.init_app(app) return app ``` In terms of automatically compressing your assets, passing your [`flask.Flask`](http://flask.pocoo.org/docs/latest/api/#flask.Flask) object to the `flask_compress.Compress` object is all that needs to be done. ### Per-view compression Compression is possible per view using the `@compress.compressed()` decorator. Make sure to disable global compression first. ```python from flask import Flask from flask_compress import Compress app = Flask(__name__) app.config["COMPRESS_REGISTER"] = False # disable default compression of all eligible requests compress = Compress() compress.init_app(app) # Compress this view specifically @app.route("/test") @compress.compressed() def view(): pass ``` ### Cache example Flask-Compress can be integrated with caching mechanisms to serve compressed responses directly from the cache. This can significantly reduce server load and response times. Here is an example of how to configure Flask-Compress with caching using [Flask-Caching](https://pypi.org/project/Flask-Caching/). The example demonstrates how to create a simple cache instance with a 1-hour timeout, and use it to cache compressed responses for incoming requests. ```python from flask import Flask from flask_compress import Compress from flask_cache import Cache # Initializing flask app app = Flask(__name__) cache = Cache(config={ 'CACHE_TYPE': 'SimpleCache', 'CACHE_DEFAULT_TIMEOUT': 60*60 # 1 hour cache timeout }) cache.init_app(app) # Define a function to return cache key for incoming requests def get_cache_key(request): return request.url # Initialize Flask-Compress compress = Compress() compress.init_app(app) # Set up cache for compressed responses compress.cache = cache compress.cache_key = get_cache_key ``` If you do not want to pull an external dependency, you can use a simple in-memory cache using `compress.cache = flask_compress.DictCache()`. ## ETag support Flask-Compress supports ETag headers for conditional requests. When a client makes a request with an `If-None-Match` header, Flask-Compress will evaluate the ETag and return a `304 Not Modified` response if the resource has not changed. This helps to reduce bandwidth usage and improve performance for clients that support caching. To disable ETag support, set the `COMPRESS_EVALUATE_CONDITIONAL_REQUEST` configuration option to `False` in your Flask application settings. > For streaming responses, ETag support is disabled by default, as this effectively requires buffering the entire response in memory to compute the ETag. If you want to enable ETag support for streaming responses, you can add the endpoint name to the `COMPRESS_STREAMING_ENDPOINT_CONDITIONAL` configuration option, which defaults to `["static"]` for static files served by Flask. ## Streaming support Flask-Compress supports compressing streaming responses. By default, streaming responses are compressed using the algorithms specified in the `COMPRESS_ALGORITHM_STREAMING` configuration option. To disable streaming compression, set the `COMPRESS_STREAMS` configuration option to `False` in your Flask application settings. > As mentioned above, ETag support is disabled by default for streaming responses. If you want to enable it for specific endpoints, you can add the endpoint name to the `COMPRESS_STREAMING_ENDPOINT_CONDITIONAL` configuration option, but this will require buffering the entire response in memory to compute the ETag. ## Options Within your Flask application's settings you can provide the following settings to control the behavior of Flask-Compress. None of the settings are required. | Option | Description | Default | | ------ | ----------- | ------- | | `COMPRESS_MIMETYPES` | Set the list of mimetypes to compress here. | `[`
`'text/html',`
`'text/css',`
`'text/plain',`
`'text/xml',`
`'text/x-component',`
`'text/javascript',`
`'application/x-javascript',`
`'application/javascript',`
`'application/json',`
`'application/manifest+json',`
`'application/vnd.api+json',`
`'application/xml',`
`'application/xhtml+xml',`
`'application/rss+xml',`
`'application/atom+xml',`
`'application/vnd.ms-fontobject',`
`'application/x-font-ttf',`
`'application/x-font-opentype',`
`'application/x-font-truetype',`
`'image/svg+xml',`
`'image/x-icon',`
`'image/vnd.microsoft.icon',`
`'font/ttf',`
`'font/eot',`
`'font/otf',`
`'font/opentype',`
`]` | | `COMPRESS_LEVEL` | Specifies the gzip compression level. | `6` | | `COMPRESS_BR_LEVEL` | Specifies the Brotli compression level. Ranges from 0 to 11. | `4` | | `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. | `['zstd', 'br', 'gzip', 'deflate']` | | `COMPRESS_ALGORITHM_STREAMING` | Supported compression algorithms for streaming. | `['zstd', 'br', 'deflate']` | | `COMPRESS_STREAMS` | Compress streaming responses. | `True` | | `COMPRESS_EVALUATE_CONDITIONAL_REQUEST` | Compress evaluates conditional requests. | `True` | | `COMPRESS_STREAMING_ENDPOINT_CONDITIONAL` | Streaming endpoints where we evaluate conditional requests. | `["static"]` | ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841197 flask_compress-1.25/README.md0000644000175100017510000002243515252212521015404 0ustar00runnerrunner# Flask-Compress [![Build](https://github.com/colour-science/flask-compress/actions/workflows/ci.yaml/badge.svg)](https://github.com/colour-science/flask-compress/actions/workflows/ci.yaml) [![Version](https://img.shields.io/pypi/v/flask-compress.svg)](https://pypi.python.org/pypi/Flask-Compress) [![Downloads](https://static.pepy.tech/badge/flask-compress)](https://pypi.python.org/pypi/Flask-Compress) Flask-Compress allows you to easily compress your [Flask](http://flask.pocoo.org/) application's responses with gzip, deflate, brotli or zstd. It originally started as a fork of [Flask-gzip](https://github.com/closeio/Flask-gzip). Supported Python versions are 3.9 and newer. The preferred solution is to have a server (like [Nginx](http://wiki.nginx.org/Main)) automatically compress the static files for you. If you don't have that option Flask-Compress will solve the problem for you. ## How it works Flask-Compress both adds the various headers required for a compressed response and compresses the response data. This makes serving compressed static files extremely easy. Internally, every time a request is made the extension will check if it matches one of the compressible MIME types and whether the client and the server use some common compression algorithm, and will automatically attach the appropriate headers. To determine the compression algorithm, the `Accept-Encoding` request header is inspected, respecting the quality factor as described in [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding). If no requested compression algorithm is supported by the server, we don't compress the response. If, on the other hand, multiple suitable algorithms are found and are requested with the same quality factor, we choose the first one defined in the `COMPRESS_ALGORITHM` option (see below). ## Installation If you use pip then installation is simply: ```shell $ pip install flask-compress ``` or, if you want the latest github version: ```shell $ pip install git+git://github.com/colour-science/flask-compress.git ``` ## Using Flask-Compress ### Globally Flask-Compress is incredibly simple to use. In order to start compressing your Flask application's assets, the first thing to do is let Flask-Compress know about your [`flask.Flask`](http://flask.pocoo.org/docs/latest/api/#flask.Flask) application object. ```python from flask import Flask from flask_compress import Compress app = Flask(__name__) Compress(app) ``` In many cases, however, one cannot expect a Flask instance to be ready at import time, and a common pattern is to return a Flask instance from within a function only after other configuration details have been taken care of. In these cases, Flask-Compress provides a simple function, `flask_compress.Compress.init_app`, which takes your application as an argument. ```python from flask import Flask from flask_compress import Compress compress = Compress() def start_app(): app = Flask(__name__) compress.init_app(app) return app ``` In terms of automatically compressing your assets, passing your [`flask.Flask`](http://flask.pocoo.org/docs/latest/api/#flask.Flask) object to the `flask_compress.Compress` object is all that needs to be done. ### Per-view compression Compression is possible per view using the `@compress.compressed()` decorator. Make sure to disable global compression first. ```python from flask import Flask from flask_compress import Compress app = Flask(__name__) app.config["COMPRESS_REGISTER"] = False # disable default compression of all eligible requests compress = Compress() compress.init_app(app) # Compress this view specifically @app.route("/test") @compress.compressed() def view(): pass ``` ### Cache example Flask-Compress can be integrated with caching mechanisms to serve compressed responses directly from the cache. This can significantly reduce server load and response times. Here is an example of how to configure Flask-Compress with caching using [Flask-Caching](https://pypi.org/project/Flask-Caching/). The example demonstrates how to create a simple cache instance with a 1-hour timeout, and use it to cache compressed responses for incoming requests. ```python from flask import Flask from flask_compress import Compress from flask_cache import Cache # Initializing flask app app = Flask(__name__) cache = Cache(config={ 'CACHE_TYPE': 'SimpleCache', 'CACHE_DEFAULT_TIMEOUT': 60*60 # 1 hour cache timeout }) cache.init_app(app) # Define a function to return cache key for incoming requests def get_cache_key(request): return request.url # Initialize Flask-Compress compress = Compress() compress.init_app(app) # Set up cache for compressed responses compress.cache = cache compress.cache_key = get_cache_key ``` If you do not want to pull an external dependency, you can use a simple in-memory cache using `compress.cache = flask_compress.DictCache()`. ## ETag support Flask-Compress supports ETag headers for conditional requests. When a client makes a request with an `If-None-Match` header, Flask-Compress will evaluate the ETag and return a `304 Not Modified` response if the resource has not changed. This helps to reduce bandwidth usage and improve performance for clients that support caching. To disable ETag support, set the `COMPRESS_EVALUATE_CONDITIONAL_REQUEST` configuration option to `False` in your Flask application settings. > For streaming responses, ETag support is disabled by default, as this effectively requires buffering the entire response in memory to compute the ETag. If you want to enable ETag support for streaming responses, you can add the endpoint name to the `COMPRESS_STREAMING_ENDPOINT_CONDITIONAL` configuration option, which defaults to `["static"]` for static files served by Flask. ## Streaming support Flask-Compress supports compressing streaming responses. By default, streaming responses are compressed using the algorithms specified in the `COMPRESS_ALGORITHM_STREAMING` configuration option. To disable streaming compression, set the `COMPRESS_STREAMS` configuration option to `False` in your Flask application settings. > As mentioned above, ETag support is disabled by default for streaming responses. If you want to enable it for specific endpoints, you can add the endpoint name to the `COMPRESS_STREAMING_ENDPOINT_CONDITIONAL` configuration option, but this will require buffering the entire response in memory to compute the ETag. ## Options Within your Flask application's settings you can provide the following settings to control the behavior of Flask-Compress. None of the settings are required. | Option | Description | Default | | ------ | ----------- | ------- | | `COMPRESS_MIMETYPES` | Set the list of mimetypes to compress here. | `[`
`'text/html',`
`'text/css',`
`'text/plain',`
`'text/xml',`
`'text/x-component',`
`'text/javascript',`
`'application/x-javascript',`
`'application/javascript',`
`'application/json',`
`'application/manifest+json',`
`'application/vnd.api+json',`
`'application/xml',`
`'application/xhtml+xml',`
`'application/rss+xml',`
`'application/atom+xml',`
`'application/vnd.ms-fontobject',`
`'application/x-font-ttf',`
`'application/x-font-opentype',`
`'application/x-font-truetype',`
`'image/svg+xml',`
`'image/x-icon',`
`'image/vnd.microsoft.icon',`
`'font/ttf',`
`'font/eot',`
`'font/otf',`
`'font/opentype',`
`]` | | `COMPRESS_LEVEL` | Specifies the gzip compression level. | `6` | | `COMPRESS_BR_LEVEL` | Specifies the Brotli compression level. Ranges from 0 to 11. | `4` | | `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. | `['zstd', 'br', 'gzip', 'deflate']` | | `COMPRESS_ALGORITHM_STREAMING` | Supported compression algorithms for streaming. | `['zstd', 'br', 'deflate']` | | `COMPRESS_STREAMS` | Compress streaming responses. | `True` | | `COMPRESS_EVALUATE_CONDITIONAL_REQUEST` | Compress evaluates conditional requests. | `True` | | `COMPRESS_STREAMING_ENDPOINT_CONDITIONAL` | Streaming endpoints where we evaluate conditional requests. | `["static"]` | ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4700127 flask_compress-1.25/flask_compress/0000755000175100017510000000000015252212527017140 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841197 flask_compress-1.25/flask_compress/__init__.py0000644000175100017510000000066115252212521021246 0ustar00runnerrunnerfrom .flask_compress import CacheBackend, Compress, DictCache # _version.py is generated by setuptools_scm when building the package. # It is not version-controlled, so if it is missing, this likely means that # the imported code was installed from the git repo without using "editable" mode. try: from ._version import __version__ except ImportError: __version__ = "0" __all__ = ("CacheBackend", "Compress", "DictCache") ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4127991 flask_compress-1.25/flask_compress/_version.py0000644000175100017510000000002515252212527021333 0ustar00runnerrunner__version__ = "1.25" ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841503 flask_compress-1.25/flask_compress/compat.py0000644000175100017510000000133515252212521020771 0ustar00runnerrunner# compression compatibility # ------------------------- # # Use the Python 3.14 `compression` module if possible. # If unavailable, mimic the module structure for backwards compatibility. # # When Python 3.14 is the lowest supported version, # the `import compression.*` statements can move to `flask_compress.py` # and this try/except block can be removed from this file. import sys if sys.version_info >= (3, 14): import compression.gzip import compression.zlib import compression.zstd else: # Python <= 3.13 import gzip import types import zlib from backports import zstd compression = types.SimpleNamespace() compression.gzip = gzip compression.zlib = zlib compression.zstd = zstd ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841503 flask_compress-1.25/flask_compress/flask_compress.py0000644000175100017510000003520615252212521022525 0ustar00runnerrunner# Authors: William Fagan # Copyright (c) 2013-2017 William Fagan # License: The MIT License (MIT) from __future__ import annotations import functools from collections import defaultdict from collections.abc import Callable, Iterator from functools import lru_cache from typing import Any, Protocol try: import brotlicffi as brotli except ImportError: import brotli from flask import ( Flask, after_this_request, current_app, make_response, request, stream_with_context, ) from flask.wrappers import Response from .compat import compression class CacheBackend(Protocol): def get(self, key: str) -> bytes | None: ... def set(self, key: str, value: bytes) -> bool | None: ... class DictCache: def __init__(self) -> None: self.data: dict[str, bytes] = {} def get(self, key: str) -> bytes | None: return self.data.get(key) def set(self, key: str, value: bytes) -> None: self.data[key] = value @lru_cache(maxsize=128) def _choose_algorithm(algorithms: tuple[str, ...], accept_encoding: str) -> str | None: """ Determine which compression algorithm we're going to use based on the client request. The `Accept-Encoding` header may list one or more desired algorithms, together with a "quality factor" for each one (higher quality means the client prefers that algorithm more). :param algorithms: Tuple of supported compression algorithms :param accept_encoding: Content of the `Accept-Encoding` header :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, # in case a specific one is not supported by the server fallback_to_any = False # Map quality factors to requested algorithm names. algos_by_quality: defaultdict[float, set[str | None]] = defaultdict(set) # Set of supported algorithms server_algos_set = set(algorithms) for part in accept_encoding.lower().split(","): part = part.strip() if ";q=" in part: # If the client associated a quality factor with an algorithm, # try to parse it. We could do the matching using a regex, but # the format is so simple that it would be overkill. algo = part.split(";")[0].strip() try: quality = float(part.split("=")[1].strip()) except ValueError: quality = 1.0 else: # Otherwise, use the default quality algo = part quality = 1.0 if algo == "*": if quality > 0: fallback_to_any = True elif algo == "identity": # identity means 'no compression asked' algos_by_quality[quality].add(None) elif algo in server_algos_set: algos_by_quality[quality].add(algo) # Choose the algorithm with the highest quality factor that the server supports. # # If there are multiple equally good options, # choose the first supported algorithm from server configuration. # # If the server doesn't support any algorithm that the client requested but # there's a special wildcard algorithm request (`*`), choose the first supported # algorithm. for _, viable_algos in sorted(algos_by_quality.items(), reverse=True): if len(viable_algos) == 1: return viable_algos.pop() elif len(viable_algos) > 1: for server_algo in algorithms: if server_algo in viable_algos: return server_algo if fallback_to_any: return algorithms[0] return None def _format(algo: str | list[str]) -> tuple[str, ...]: """Format the algorithm configuration into a tuple of strings. >>> _format("gzip, deflate, br") ('gzip', 'deflate', 'br') >>> _format(["gzip", "deflate", "br"]) ('gzip', 'deflate', 'br') """ if isinstance(algo, str): return tuple(i.strip() for i in algo.split(",")) else: return tuple(algo) class Compress: """ The Compress object allows your application to use Flask-Compress. When initialising a Compress object you may optionally provide your :class:`flask.Flask` application object if it is ready. Otherwise, you may provide it later by using the :meth:`init_app` method. :param app: optional :class:`flask.Flask` application object :type app: :class:`flask.Flask` or None """ cache: CacheBackend | None cache_key: Callable[..., str] | None compress_mimetypes_set: set[str] enabled_algorithms: tuple[str, ...] streaming_algorithms: tuple[str, ...] streaming_endpoint_with_conditional: set[str] def __init__(self, app: Flask | None = None) -> None: """ An alternative way to pass your :class:`flask.Flask` application object to Flask-Compress. :meth:`init_app` also takes care of some default `settings`_. :param app: the :class:`flask.Flask` application object. """ self.app = app if app is not None: self.init_app(app) def init_app(self, app: Flask) -> None: defaults = [ ( "COMPRESS_MIMETYPES", [ "text/html", "text/css", "text/plain", "text/xml", "text/x-component", "text/javascript", # Obsolete (RFC 9239) "application/x-javascript", "application/javascript", "application/json", "application/manifest+json", "application/vnd.api+json", "application/xml", "application/xhtml+xml", "application/rss+xml", "application/atom+xml", "application/vnd.ms-fontobject", "application/x-font-ttf", "application/x-font-opentype", "application/x-font-truetype", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon", "font/ttf", "font/eot", "font/otf", "font/opentype", ], ), ("COMPRESS_LEVEL", 6), ("COMPRESS_BR_LEVEL", 4), ("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_EVALUATE_CONDITIONAL_REQUEST", True), ("COMPRESS_STREAMING_ENDPOINT_CONDITIONAL", ["static"]), ("COMPRESS_ALGORITHM", ["zstd", "br", "gzip", "deflate"]), ("COMPRESS_ALGORITHM_STREAMING", ["zstd", "br", "deflate"]), # no gzip ] for k, v in defaults: app.config.setdefault(k, v) backend = app.config["COMPRESS_CACHE_BACKEND"] self.cache = backend() if backend else None self.cache_key = app.config["COMPRESS_CACHE_KEY"] self.compress_mimetypes_set = set(app.config["COMPRESS_MIMETYPES"]) self.enabled_algorithms = _format(app.config["COMPRESS_ALGORITHM"]) self.streaming_algorithms = _format(app.config["COMPRESS_ALGORITHM_STREAMING"]) self.streaming_endpoint_with_conditional = set( app.config["COMPRESS_STREAMING_ENDPOINT_CONDITIONAL"] ) if app.config["COMPRESS_REGISTER"] and app.config["COMPRESS_MIMETYPES"]: app.after_request(self.after_request) def after_request(self, response: Response | None) -> Response: if not response: return make_response("", 204) app = self.app or current_app vary = response.headers.get("Vary") if not vary: response.headers["Vary"] = "Accept-Encoding" elif "accept-encoding" not in vary.lower(): response.headers["Vary"] = f"{vary}, Accept-Encoding" accept_encoding = request.headers.get("Accept-Encoding", "") streaming_compressed = response.is_streamed and app.config["COMPRESS_STREAMS"] streaming_conditional = response.is_streamed and ( request.endpoint in self.streaming_endpoint_with_conditional ) algorithms = ( self.streaming_algorithms if streaming_compressed else self.enabled_algorithms ) chosen_algorithm = _choose_algorithm(algorithms, accept_encoding) if ( chosen_algorithm is None or response.mimetype not in self.compress_mimetypes_set or response.status_code < 200 or response.status_code >= 300 or (response.is_streamed and not app.config["COMPRESS_STREAMS"]) or "Content-Encoding" in response.headers or ( response.content_length is not None and response.content_length < app.config["COMPRESS_MIN_SIZE"] ) ): return response response.direct_passthrough = False response.headers["Content-Encoding"] = chosen_algorithm if streaming_compressed: chunks = response.iter_encoded() _gen_compressed_content = _compress_chunks(app, chunks, chosen_algorithm) response.response = stream_with_context(_gen_compressed_content) response.headers.pop("Content-Length", None) else: if self.cache is not None: assert self.cache_key is not None key = f"{chosen_algorithm};{self.cache_key(request)}" compressed_content = self.cache.get(key) if compressed_content is None: data = response.get_data() compressed_content = _compress_data(app, data, chosen_algorithm) self.cache.set(key, compressed_content) else: data = response.get_data() compressed_content = _compress_data(app, data, chosen_algorithm) response.set_data(compressed_content) response.headers["Content-Length"] = response.content_length # "123456789" => "123456789:gzip" - A strong ETag validator # W/"123456789" => W/"123456789:gzip" - A weak ETag validator etag, is_weak = response.get_etag() if etag and not is_weak: response.set_etag(f"{etag}:{chosen_algorithm}", weak=False) if ( app.config["COMPRESS_EVALUATE_CONDITIONAL_REQUEST"] and request.method in ("GET", "HEAD") and (not response.is_streamed or streaming_conditional) ): response.make_conditional(request) return response def compressed(self) -> Callable[..., Callable[..., Any]]: def decorator(f: Callable[..., Any]) -> Callable[..., Any]: @functools.wraps(f) def decorated_function(*args: Any, **kwargs: Any) -> Any: @after_this_request def compressor(response: Response) -> Response: return self.after_request(response) return f(*args, **kwargs) return decorated_function return decorator def _compress_data(app: Flask, data: bytes, algorithm: str) -> bytes: if algorithm == "zstd": return compression.zstd.compress( # type: ignore[no-any-return] data, app.config["COMPRESS_ZSTD_LEVEL"] ) if algorithm == "gzip": return compression.gzip.compress( # type: ignore[no-any-return] data, app.config["COMPRESS_LEVEL"] ) if algorithm == "deflate": return compression.zlib.compress( # type: ignore[no-any-return] data, app.config["COMPRESS_DEFLATE_LEVEL"] ) if algorithm == "br": return brotli.compress( # type: ignore[no-any-return] data, mode=app.config["COMPRESS_BR_MODE"], quality=app.config["COMPRESS_BR_LEVEL"], lgwin=app.config["COMPRESS_BR_WINDOW"], lgblock=app.config["COMPRESS_BR_BLOCK"], ) raise ValueError(f"Unknown compression algorithm: {algorithm}") def _uncompress_data(data: bytes, algorithm: str) -> bytes: # This is used for tests purposes only. if algorithm == "zstd": return compression.zstd.decompress(data) # type: ignore[no-any-return] if algorithm == "gzip": return compression.gzip.decompress(data) # type: ignore[no-any-return] if algorithm == "deflate": return compression.zlib.decompress(data) # type: ignore[no-any-return] if algorithm == "br": return brotli.decompress(data) # type: ignore[no-any-return] raise ValueError(f"Unknown compression algorithm: {algorithm}") def _compress_chunks( app: Flask, chunks: Iterator[bytes], algorithm: str ) -> Iterator[bytes]: if algorithm == "zstd": level = app.config["COMPRESS_ZSTD_LEVEL"] compressor = compression.zstd.ZstdCompressor(level=level) for data in chunks: out = compressor.compress(data) if out: yield out out = compressor.flush() if out: yield out elif algorithm == "gzip": level = app.config["COMPRESS_LEVEL"] compressor = compression.zlib.compressobj( level, compression.zlib.DEFLATED, compression.zlib.MAX_WBITS + 16, ) for data in chunks: out = compressor.compress(data) if out: yield out out = compressor.flush() if out: yield out elif algorithm == "deflate": level = app.config["COMPRESS_DEFLATE_LEVEL"] compressor = compression.zlib.compressobj(level=level) for data in chunks: out = compressor.compress(data) if out: yield out out = compressor.flush() if out: yield out elif algorithm == "br": compressor = brotli.Compressor( mode=app.config["COMPRESS_BR_MODE"], quality=app.config["COMPRESS_BR_LEVEL"], lgwin=app.config["COMPRESS_BR_WINDOW"], lgblock=app.config["COMPRESS_BR_BLOCK"], ) for data in chunks: out = compressor.process(data) if out: yield out out = compressor.finish() if out: yield out else: raise ValueError(f"Unsupported streaming algorithm: {algorithm}") ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841503 flask_compress-1.25/flask_compress/py.typed0000644000175100017510000000000015252212521020617 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841503 flask_compress-1.25/pyproject.toml0000644000175100017510000000140315252212521017031 0ustar00runnerrunner[build-system] requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]<8"] build-backend = "setuptools.build_meta" [tool.setuptools_scm] write_to = "flask_compress/_version.py" write_to_template = "__version__ = \"{version}\"\n" # coverage # -------- [tool.coverage.run] relative_files = true parallel = true branch = true source = [ "flask_compress", "tests", ] [tool.coverage.paths] source = [ "flask_compress", "*/site-packages", ] [tool.coverage.report] skip_covered = true fail_under = 94 [tool.coverage.html] directory = "htmlcov/" skip_covered = false # type checking # -------------- [tool.mypy] strict = true [[tool.mypy.overrides]] module = ["brotli", "brotlicffi", "backports.*", "flask_caching"] ignore_missing_imports = true ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4710112 flask_compress-1.25/setup.cfg0000644000175100017510000000004615252212527015746 0ustar00runnerrunner[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841503 flask_compress-1.25/setup.py0000644000175100017510000000341615252212521015635 0ustar00runnerrunnerfrom setuptools import find_packages, setup with open("README.md") as fl: LONG_DESCRIPTION = fl.read() setup( name="Flask-Compress", use_scm_version=True, url="https://github.com/colour-science/flask-compress", license="MIT", author="Thomas Mansencal", author_email="thomas.mansencal@gmail.com", 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"]), package_data={"flask_compress": ["py.typed"]}, include_package_data=True, platforms="any", python_requires=">=3.9", install_requires=[ "flask", "brotli; platform_python_implementation!='PyPy'", "brotlicffi; platform_python_implementation=='PyPy'", "backports.zstd; python_version<'3.14'", ], setup_requires=[ "setuptools_scm", ], classifiers=[ "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development :: Libraries :: Python Modules", ], ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4701085 flask_compress-1.25/tests/0000755000175100017510000000000015252212527015267 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4701715 flask_compress-1.25/tests/static/0000755000175100017510000000000015252212527016556 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465936.8417542 flask_compress-1.25/tests/static/1.png0000644000175100017510000000035715252212521017423 0ustar00runnerrunner‰PNG  IHDR7nù$gAMA±� üa cHRMz&€„ú€èu0ê`:˜pœºQ<bKGDÝŠ¤ IDAT×ch‚�ÝCjô%tEXtdate:create2014-07-23T15:42:03+04:00”¡%tEXtdate:modify2014-07-23T15:42:03+04:00jɧIEND®B`‚././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465943.4703562 flask_compress-1.25/tests/templates/0000755000175100017510000000000015252212527017265 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1789465936.8417542 flask_compress-1.25/tests/templates/large.html0000644000175100017510000000171115252212521021237 0ustar00runnerrunner

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

Also, Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841825 flask_compress-1.25/tests/templates/small.html0000644000175100017510000000005515252212521021255 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841825 flask_compress-1.25/tests/test_flask_compress.py0000644000175100017510000010420115252212521021703 0ustar00runnerrunnerimport gzip import os import tempfile import unittest from collections.abc import Iterator from flask import ( Flask, Request, Response, make_response, render_template, request, stream_with_context, ) from flask_caching import Cache from werkzeug.test import TestResponse from flask_compress import Compress, DictCache from flask_compress.flask_compress import _choose_algorithm, _uncompress_data ALGORITHMS = ("gzip", "deflate", "br", "zstd") class DefaultsTest(unittest.TestCase): def setUp(self) -> None: self.app = Flask(__name__) self.app.testing = True Compress(self.app) def test_mimetypes_default(self) -> None: """Tests COMPRESS_MIMETYPES default value is correctly set.""" defaults = [ "text/html", "text/css", "text/plain", "text/xml", "text/x-component", "text/javascript", # Obsolete (RFC 9239) "application/x-javascript", "application/javascript", "application/json", "application/manifest+json", "application/vnd.api+json", "application/xml", "application/xhtml+xml", "application/rss+xml", "application/atom+xml", "application/vnd.ms-fontobject", "application/x-font-ttf", "application/x-font-opentype", "application/x-font-truetype", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon", "font/ttf", "font/eot", "font/otf", "font/opentype", ] self.assertEqual(self.app.config["COMPRESS_MIMETYPES"], defaults) def test_level_default(self) -> None: """Tests COMPRESS_LEVEL default value is correctly set.""" self.assertEqual(self.app.config["COMPRESS_LEVEL"], 6) def test_min_size_default(self) -> None: """Tests COMPRESS_MIN_SIZE default value is correctly set.""" self.assertEqual(self.app.config["COMPRESS_MIN_SIZE"], 500) def test_algorithm_default(self) -> None: """Tests COMPRESS_ALGORITHM default value is correctly set.""" self.assertEqual( self.app.config["COMPRESS_ALGORITHM"], ["zstd", "br", "gzip", "deflate"] ) def test_algorithm_streaming(self) -> None: """Tests COMPRESS_ALGORITHM_STREAMING default value is correctly set.""" self.assertEqual( self.app.config["COMPRESS_ALGORITHM_STREAMING"], ["zstd", "br", "deflate"] ) def test_default_deflate_settings(self) -> None: """Tests COMPRESS_DELATE_LEVEL default value is correctly set.""" self.assertEqual(self.app.config["COMPRESS_DEFLATE_LEVEL"], -1) def test_mode_default(self) -> None: """Tests COMPRESS_BR_MODE default value is correctly set.""" self.assertEqual(self.app.config["COMPRESS_BR_MODE"], 0) def test_quality_level_default(self) -> None: """Tests COMPRESS_BR_LEVEL default value is correctly set.""" self.assertEqual(self.app.config["COMPRESS_BR_LEVEL"], 4) def test_window_size_default(self) -> None: """Tests COMPRESS_BR_WINDOW default value is correctly set.""" self.assertEqual(self.app.config["COMPRESS_BR_WINDOW"], 22) def test_block_size_default(self) -> None: """Tests COMPRESS_BR_BLOCK default value is correctly set.""" self.assertEqual(self.app.config["COMPRESS_BR_BLOCK"], 0) def test_stream(self) -> None: """Tests COMPRESS_STREAMS default value is correctly set.""" self.assertEqual(self.app.config["COMPRESS_STREAMS"], True) def test_quality_level_default_zstd(self) -> None: """Tests COMPRESS_ZSTD_LEVEL default value is correctly set.""" self.assertEqual(self.app.config["COMPRESS_ZSTD_LEVEL"], 3) def test_evaluate_conditional_request(self) -> None: """Tests COMPRESS_EVALUATE_CONDITIONAL_REQUEST default value is correctly set.""" self.assertEqual(self.app.config["COMPRESS_EVALUATE_CONDITIONAL_REQUEST"], True) def test_streaming_endpoint_conditional(self) -> None: """Tests COMPRESS_STREAMING_ENDPOINT_CONDITIONAL default value is correctly set.""" self.assertEqual( self.app.config["COMPRESS_STREAMING_ENDPOINT_CONDITIONAL"], ["static"] ) class InitTests(unittest.TestCase): def setUp(self) -> None: self.app = Flask(__name__) self.app.testing = True def test_constructor_init(self) -> None: Compress(self.app) def test_delayed_init(self) -> None: compress = Compress() compress.init_app(self.app) class UrlTests(unittest.TestCase): def setUp(self) -> None: self.app = Flask(__name__) self.app.testing = True self.small_path = os.path.join(os.getcwd(), "tests", "templates", "small.html") self.large_path = os.path.join(os.getcwd(), "tests", "templates", "large.html") self.small_size = os.path.getsize(self.small_path) - 1 self.large_size = os.path.getsize(self.large_path) - 1 Compress(self.app) @self.app.route("/small/") def small() -> str: return render_template("small.html") @self.app.route("/large/") def large() -> str: return render_template("large.html") def test_compressed_content(self) -> None: client = self.app.test_client() with open(self.large_path, "rb") as f: original_data = f.read().rstrip() # flask strips trailing newline for algorithm in ALGORITHMS: headers = [("Accept-Encoding", algorithm)] response = client.get("/large/", headers=headers) self.assertIn("Content-Encoding", response.headers) self.assertEqual(response.status_code, 200) self.assertEqual(response.is_streamed, True) self.assertGreater(self.large_size, len(response.data)) self.assertEqual(original_data, _uncompress_data(response.data, algorithm)) def client_get(self, ufs: str) -> TestResponse: client = self.app.test_client() response = client.get(ufs, headers=[("Accept-Encoding", "gzip")]) self.assertEqual(response.status_code, 200) return response def test_br_algorithm(self) -> None: client = self.app.test_client() headers = [("Accept-Encoding", "br")] 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_zstd_algorithm(self) -> None: 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) -> None: """Tests COMPRESS_MIN_SIZE correctly affects response data.""" response = self.client_get("/small/") self.assertEqual(self.small_size, len(response.data)) response = self.client_get("/large/") self.assertNotEqual(self.large_size, len(response.data)) def test_mimetype_mismatch(self) -> None: """Tests if mimetype not in COMPRESS_MIMETYPES.""" response = self.client_get("/static/1.png") self.assertEqual(response.mimetype, "image/png") def test_content_length_options(self) -> None: client = self.app.test_client() headers = [("Accept-Encoding", "gzip")] response = client.options("/small/", headers=headers) self.assertEqual(response.status_code, 200) def test_gzip_compression_level(self) -> None: """Tests COMPRESS_LEVEL correctly affects response data.""" self.app.config["COMPRESS_LEVEL"] = 1 client = self.app.test_client() response = client.get("/large/", headers=[("Accept-Encoding", "gzip")]) response1_size = len(response.data) self.app.config["COMPRESS_LEVEL"] = 6 client = self.app.test_client() response = client.get("/large/", headers=[("Accept-Encoding", "gzip")]) response6_size = len(response.data) self.assertNotEqual(response1_size, response6_size) def test_br_compression_level(self) -> None: """Tests that COMPRESS_BR_LEVEL correctly affects response data.""" self.app.config["COMPRESS_BR_LEVEL"] = 4 client = self.app.test_client() response = client.get("/large/", headers=[("Accept-Encoding", "br")]) response4_size = len(response.data) self.app.config["COMPRESS_BR_LEVEL"] = 11 client = self.app.test_client() response = client.get("/large/", headers=[("Accept-Encoding", "br")]) response11_size = len(response.data) self.assertNotEqual(response4_size, response11_size) def test_deflate_compression_level(self) -> None: """Tests COMPRESS_DELATE_LEVEL correctly affects response data.""" self.app.config["COMPRESS_DEFLATE_LEVEL"] = -1 client = self.app.test_client() response = client.get("/large/", headers=[("Accept-Encoding", "deflate")]) response_size = len(response.data) self.app.config["COMPRESS_DEFLATE_LEVEL"] = 1 client = self.app.test_client() response = client.get("/large/", headers=[("Accept-Encoding", "deflate")]) response1_size = len(response.data) self.assertNotEqual(response_size, response1_size) def test_zstd_compression_level(self) -> None: """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) def test_when_response_empty(self) -> None: """Tests that after_request returns 204 when response is None.""" compress = Compress(self.app) with self.app.app_context(): r1 = compress.after_request(None) self.assertEqual(r1.status_code, 204) self.assertEqual(r1.get_data(as_text=True), "") class CompressionAlgoTests(unittest.TestCase): """ Test different scenarios for compression algorithm negotiation between client and server. Please note that algorithm names (even the "supported" ones) in these tests **do not** indicate that all of these are actually supported by this extension. """ def setUp(self) -> None: super().setUp() # Create the app here but don't call `Compress()` on it just yet; # we need to be able to modify the settings in various tests. # Calling `Compress(self.app)` twice would result in # two `@after_request` handlers, which would be bad. self.app = Flask(__name__) self.app.testing = True small_path = os.path.join(os.getcwd(), "tests", "templates", "small.html") self.small_size = os.path.getsize(small_path) - 1 @self.app.route("/small/") def small() -> str: return render_template("small.html") def test_setting_compress_algorithm_simple_string(self) -> None: """Test that a single entry in `COMPRESS_ALGORITHM` still works. This is a backwards-compatibility test.""" self.app.config["COMPRESS_ALGORITHM"] = "gzip" c = Compress(self.app) self.assertTupleEqual(c.enabled_algorithms, ("gzip",)) def test_setting_compress_algorithm_cs_string(self) -> None: """Test that `COMPRESS_ALGORITHM` can be a comma-separated string""" self.app.config["COMPRESS_ALGORITHM"] = "gzip, br, zstd" c = Compress(self.app) self.assertTupleEqual(c.enabled_algorithms, ("gzip", "br", "zstd")) def test_setting_compress_algorithm_list(self) -> None: """Test that `COMPRESS_ALGORITHM` can be a list of strings""" self.app.config["COMPRESS_ALGORITHM"] = ["gzip", "br", "deflate"] c = Compress(self.app) self.assertTupleEqual(c.enabled_algorithms, ("gzip", "br", "deflate")) def test_one_algo_supported(self) -> None: """Tests requesting a single supported compression algorithm""" accept_encoding = "gzip" self.app.config["COMPRESS_ALGORITHM"] = ["br", "gzip"] c = Compress(self.app) chosen_algorithm = _choose_algorithm(c.enabled_algorithms, accept_encoding) self.assertEqual(chosen_algorithm, "gzip") def test_one_algo_unsupported(self) -> None: """Tests requesting single unsupported compression algorithm""" accept_encoding = "some-alien-algorithm" self.app.config["COMPRESS_ALGORITHM"] = ["br", "gzip"] c = Compress(self.app) chosen_algorithm = _choose_algorithm(c.enabled_algorithms, accept_encoding) self.assertIsNone(chosen_algorithm) def test_multiple_algos_supported(self) -> None: """Tests requesting multiple supported compression algorithms""" accept_encoding = "br, gzip, zstd" self.app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip"] c = Compress(self.app) # When the decision is tied, we expect the first server-configured algorithm chosen_algorithm = _choose_algorithm(c.enabled_algorithms, accept_encoding) self.assertEqual(chosen_algorithm, "zstd") def test_multiple_algos_unsupported(self) -> None: """Tests requesting multiple unsupported compression algorithms""" accept_encoding = "future-algo, alien-algo, forbidden-algo" self.app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip"] c = Compress(self.app) chosen_algorithm = _choose_algorithm(c.enabled_algorithms, accept_encoding) self.assertIsNone(chosen_algorithm) def test_multiple_algos_with_wildcard(self) -> None: """Request multiple unsupported compression algorithms and a wildcard""" accept_encoding = "future-algo, alien-algo, forbidden-algo, *" self.app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip"] c = Compress(self.app) # We expect to see the first server-configured algorithm chosen_algorithm = _choose_algorithm(c.enabled_algorithms, accept_encoding) self.assertEqual(chosen_algorithm, "zstd") def test_multiple_algos_with_different_quality(self) -> None: """Request multiple supported compression algorithms with different q-factors""" accept_encoding = "zstd;q=0.8, br;q=0.9, gzip;q=0.5" self.app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip"] c = Compress(self.app) chosen_algorithm = _choose_algorithm(c.enabled_algorithms, accept_encoding) self.assertEqual(chosen_algorithm, "br") def test_multiple_algos_with_equal_quality(self) -> None: """Request multiple supported compression algorithms with equal q-factors""" accept_encoding = "zstd;q=0.5, br;q=0.5, gzip;q=0.5" self.app.config["COMPRESS_ALGORITHM"] = ["gzip", "br", "zstd"] c = Compress(self.app) # We expect to see the first server-configured algorithm chosen_algorithm = _choose_algorithm(c.enabled_algorithms, accept_encoding) self.assertEqual(chosen_algorithm, "gzip") def test_default_quality_is_1(self) -> None: """Tests that when making mixed-quality requests, the default q-factor is 1.0""" accept_encoding = "deflate, br;q=0.999, gzip;q=0.5" self.app.config["COMPRESS_ALGORITHM"] = ["gzip", "br", "deflate"] c = Compress(self.app) chosen_algorithm = _choose_algorithm(c.enabled_algorithms, accept_encoding) self.assertEqual(chosen_algorithm, "deflate") def test_default_wildcard_quality_is_0(self) -> None: """Tests that a wildcard has a default q-factor of 0.0""" accept_encoding = "br;q=0.001, *" self.app.config["COMPRESS_ALGORITHM"] = ["gzip", "br", "deflate"] c = Compress(self.app) chosen_algorithm = _choose_algorithm(c.enabled_algorithms, accept_encoding) self.assertEqual(chosen_algorithm, "br") def test_wildcard_quality(self) -> None: """Tests that a wildcard with q=0 is discarded""" accept_encoding = "*;q=0" self.app.config["COMPRESS_ALGORITHM"] = ["gzip", "br", "deflate"] c = Compress(self.app) chosen_algorithm = _choose_algorithm(c.enabled_algorithms, accept_encoding) self.assertIsNone(chosen_algorithm) def test_identity(self) -> None: """Tests that identity is understood""" accept_encoding = "identity;q=1, br;q=0.5, *;q=0" self.app.config["COMPRESS_ALGORITHM"] = ["gzip", "br", "deflate"] c = Compress(self.app) chosen_algorithm = _choose_algorithm(c.enabled_algorithms, accept_encoding) self.assertIsNone(chosen_algorithm) def test_chrome_ranged_requests(self) -> None: """Tests that Chrome ranged requests behave as expected""" accept_encoding = "identity;q=1, *;q=0" self.app.config["COMPRESS_ALGORITHM"] = ["gzip", "br", "deflate"] c = Compress(self.app) chosen_algorithm = _choose_algorithm(c.enabled_algorithms, accept_encoding) self.assertIsNone(chosen_algorithm) def test_content_encoding_is_correct(self) -> None: """Test that the `Content-Encoding` header matches the compression algorithm""" self.app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"] Compress(self.app) headers_gzip = [("Accept-Encoding", "gzip")] client = self.app.test_client() response_gzip = client.options("/small/", headers=headers_gzip) self.assertIn("Content-Encoding", response_gzip.headers) self.assertEqual(response_gzip.headers.get("Content-Encoding"), "gzip") headers_br = [("Accept-Encoding", "br")] client = self.app.test_client() response_br = client.options("/small/", headers=headers_br) self.assertIn("Content-Encoding", response_br.headers) self.assertEqual(response_br.headers.get("Content-Encoding"), "br") headers_deflate = [("Accept-Encoding", "deflate")] client = self.app.test_client() response_deflate = client.options("/small/", headers=headers_deflate) 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) -> None: self.app = Flask(__name__) self.app.testing = True self.app.config["COMPRESS_REGISTER"] = False compress = Compress() compress.init_app(self.app) @self.app.route("/route1/") def view_1() -> str: return render_template("large.html") @self.app.route("/route2/") @compress.compressed() def view_2() -> str: return render_template("large.html") def test_compression(self) -> None: client = self.app.test_client() headers = [("Accept-Encoding", "deflate")] response = client.get("/route1/", headers=headers) self.assertEqual(response.status_code, 200) self.assertNotIn("Content-Encoding", response.headers) response = client.get("/route2/", headers=headers) self.assertEqual(response.status_code, 200) self.assertIn("Content-Encoding", response.headers) self.assertEqual(response.headers.get("Content-Encoding"), "deflate") class StreamTests(unittest.TestCase): def setUp(self) -> None: self.app = Flask(__name__) self.app.testing = True self.app.config["COMPRESS_ALGORITHM_STREAMING"] = [ "zstd", "br", "gzip", # not by default for streaming "deflate", ] self.file_path = os.path.join(os.getcwd(), "tests", "templates", "large.html") self.file_size = os.path.getsize(self.file_path) @self.app.route("/stream/large") def stream() -> Response: def _stream() -> Iterator[str]: with open(self.file_path) as f: yield from f.readlines() return self.app.response_class(_stream(), mimetype="text/html") def test_no_compression_stream(self) -> None: """Tests compression is skipped when COMPRESS_STREAMS is False""" Compress(self.app) self.app.config["COMPRESS_STREAMS"] = False client = self.app.test_client() for algorithm in (*ALGORITHMS, ""): headers = [("Accept-Encoding", algorithm)] response = client.get("/stream/large", headers=headers) self.assertEqual(response.status_code, 200) self.assertEqual(response.is_streamed, True) self.assertEqual(self.file_size, len(response.data)) def test_compression_stream(self) -> None: Compress(self.app) client = self.app.test_client() with open(self.file_path, "rb") as f: original_data = f.read() for algorithm in ALGORITHMS: headers = [("Accept-Encoding", algorithm)] response = client.get("/stream/large", headers=headers) self.assertIn("Content-Encoding", response.headers) self.assertEqual(response.status_code, 200) self.assertEqual(response.is_streamed, True) self.assertGreater(self.file_size, len(response.data)) self.assertEqual(original_data, _uncompress_data(response.data, algorithm)) headers = [("Accept-Encoding", "")] response = client.get("/stream/large", headers=headers) self.assertNotIn("Content-Encoding", response.headers) self.assertEqual(response.status_code, 200) self.assertEqual(response.is_streamed, True) self.assertEqual(self.file_size, len(response.data)) self.assertEqual(original_data, response.data) class StreamTestsWithETags(unittest.TestCase): def setUp(self) -> None: self.app = Flask(__name__, static_folder="web", static_url_path="/path") self.app.testing = True self.file_path = os.path.join(os.getcwd(), "tests", "templates", "large.html") self.file_size = os.path.getsize(self.file_path) self.app.config["COMPRESS_MIN_SIZE"] = 1 Compress(self.app) @self.app.route("/stream/large") def stream() -> Response: def _stream() -> Iterator[str]: with open(self.file_path) as f: yield from f.readlines() rv = make_response(stream_with_context(_stream())) rv.mimetype = "text/html" rv.set_etag("stream-etag", weak=False) return rv def test_conditionals_are_skipped_on_streaming(self) -> None: client = self.app.test_client() r1 = client.get("/stream/large", headers=[("Accept-Encoding", "br")]) tag, is_weak = r1.get_etag() self.assertEqual(r1.status_code, 200) self.assertEqual(r1.is_streamed, True) self.assertIn("Content-Encoding", r1.headers) self.assertEqual(r1.headers.get("Content-Encoding"), "br") self.assertEqual(tag, "stream-etag:br") self.assertFalse(is_weak) r1.close() r2 = client.get( "/stream/large", headers=[("Accept-Encoding", "br"), ("If-None-Match", r1.headers["ETag"])], ) self.assertEqual(r2.status_code, 200) # Should be 200, not 304 self.assertEqual(r2.is_streamed, True) self.assertIn("Content-Encoding", r1.headers) self.assertEqual(r1.headers.get("Content-Encoding"), "br") r2.close() def test_static_exception_to_conditionals(self) -> None: # Here we test that the static endpoint, which is using streaming responses, # still respects conditional requests even when streaming. # We use a custom static folder and static url path to show that the test # works even if those are changed from the defaults. # In practice there is almost no chance for a user to accidentally write a # streaming 'static' endpoint, as this will raise an exception in Flask, # because of the predefined static file handling. client = self.app.test_client() r1 = client.get("/path/test.xml", headers=[("Accept-Encoding", "br")]) tag, is_weak = r1.get_etag() self.assertEqual(r1.status_code, 200) self.assertEqual(r1.is_streamed, True) self.assertIn("Content-Encoding", r1.headers) self.assertEqual(r1.headers.get("Content-Encoding"), "br") self.assertIsNotNone(tag) assert tag is not None self.assertEqual(tag[-3:], ":br") self.assertFalse(is_weak) r1.close() r2 = client.get( "/path/test.xml", headers=[("Accept-Encoding", "br"), ("If-None-Match", r1.headers["ETag"])], ) self.assertEqual(r2.status_code, 304) # static endpoint has an exception self.assertEqual(r2.is_streamed, True) self.assertIn("Content-Encoding", r1.headers) self.assertEqual(r1.headers.get("Content-Encoding"), "br") r2.close() class CachingCompressionTests(unittest.TestCase): def setUp(self) -> None: # We keep track of the number of times the view is called self.view_calls = 0 self.tmpdir = tempfile.TemporaryDirectory() self.app = Flask(__name__) self.app.testing = True cache = Cache( config={ "CACHE_TYPE": "FileSystemCache", "CACHE_DIR": self.tmpdir.name, "CACHE_DEFAULT_TIMEOUT": 60 * 60, # 1 hour cache timeout } ) cache.init_app(self.app) def get_cache_key(request: Request) -> str: return request.url compress = Compress() compress.init_app(self.app) compress.cache = cache compress.cache_key = get_cache_key @self.app.route("/route/") def view() -> str: self.view_calls += 1 return render_template("large.html") def tearDown(self) -> None: self.tmpdir.cleanup() def test_compression(self) -> None: # Here we are testing cache pollution where the same query is cached # but with different compression algorithms. The cache key should include # the compression algorithm so that the cache is not polluted. client = self.app.test_client() headers = [("Accept-Encoding", "deflate")] response = client.get("/route/", headers=headers) self.assertEqual(response.status_code, 200) self.assertIn("Content-Encoding", response.headers) self.assertEqual(response.headers.get("Content-Encoding"), "deflate") self.assertEqual(self.view_calls, 1) headers = [("Accept-Encoding", "gzip")] response = client.get("/route/", headers=headers) self.assertEqual(response.status_code, 200) self.assertIn("Content-Encoding", response.headers) self.assertEqual(response.headers.get("Content-Encoding"), "gzip") self.assertEqual(self.view_calls, 2) # If cache is polluted, this decompression fails as we get brotli _ = gzip.decompress(response.data) class DictCacheTests(unittest.TestCase): def setUp(self) -> None: # We keep track of the number of times the cache key function is called self.cache_key_calls = 0 self.app = Flask(__name__) self.app.testing = True def get_cache_key(request: Request) -> str: self.cache_key_calls += 1 return request.url compress = Compress() compress.init_app(self.app) compress.cache = DictCache() compress.cache_key = get_cache_key @self.app.route("/route/") def view() -> str: return render_template("large.html") def test_compression(self) -> None: client = self.app.test_client() headers = [("Accept-Encoding", "deflate")] response = client.get("/route/", headers=headers) self.assertEqual(response.status_code, 200) self.assertIn("Content-Encoding", response.headers) self.assertEqual(response.headers.get("Content-Encoding"), "deflate") self.assertEqual(self.cache_key_calls, 1) headers = [("Accept-Encoding", "deflate")] response = client.get("/route/", headers=headers) self.assertEqual(response.status_code, 200) self.assertIn("Content-Encoding", response.headers) self.assertEqual(response.headers.get("Content-Encoding"), "deflate") self.assertEqual(self.cache_key_calls, 2) class ETagTests(unittest.TestCase): def setUp(self) -> None: self.app = Flask(__name__) self.app.testing = True self.app.config["COMPRESS_ALGORITHM"] = ["gzip"] self.app.config["COMPRESS_MIN_SIZE"] = 1 Compress(self.app) @self.app.route("/strong/") def strong() -> Response: rv = make_response(render_template("large.html")) rv.set_etag("abc123", weak=False) rv.make_conditional(request) return rv @self.app.route("/strong-compress-conditional/") def strong_compress_conditional() -> Response: rv = make_response(render_template("large.html")) rv.set_etag("abc123", weak=False) return rv @self.app.route("/weak/") def weak() -> Response: rv = make_response(render_template("large.html")) rv.set_etag("abc123", weak=True) rv.make_conditional(request) return rv @self.app.route("/weak-compress-conditional/") def weak_compress_conditional() -> Response: rv = make_response(render_template("large.html")) rv.set_etag("abc123", weak=True) return rv def test_strong_etag_is_mutated_with_suffix_and_remains_strong(self) -> None: client = self.app.test_client() r = client.get("/strong/", headers=[("Accept-Encoding", "gzip")]) self.assertEqual(r.status_code, 200) self.assertEqual(r.headers.get("Content-Encoding"), "gzip") tag, is_weak = r.get_etag() self.assertFalse(is_weak) self.assertEqual(tag, "abc123:gzip") self.assertEqual(int(r.headers["Content-Length"]), len(r.data)) def test_weak_etag_is_preserved(self) -> None: client = self.app.test_client() r = client.get("/weak/", headers=[("Accept-Encoding", "gzip")]) self.assertEqual(r.status_code, 200) self.assertEqual(r.headers.get("Content-Encoding"), "gzip") tag, is_weak = r.get_etag() self.assertTrue(is_weak) # No :gzip suffix when flag is False self.assertEqual(tag, "abc123") def test_conditional_get_uses_strong_compressed_representation(self) -> None: self.app.config["COMPRESS_EVALUATE_CONDITIONAL_REQUEST"] = False client = self.app.test_client() r1 = client.get("/strong/", headers=[("Accept-Encoding", "gzip")]) r2 = client.get( "/strong/", headers=[ ("Accept-Encoding", "gzip"), ("If-None-Match", r1.headers["ETag"]), ], ) # This is the old behavior that broke make_conditional # strong etags due rewrite at after_request # We would expect a 304 but it does not because of etag mismatch self.assertEqual(r2.status_code, 200) def test_conditional_get_uses_weak_compressed_representation(self) -> None: self.app.config["COMPRESS_EVALUATE_CONDITIONAL_REQUEST"] = False client = self.app.test_client() r1 = client.get("/weak/", headers=[("Accept-Encoding", "gzip")]) etag_header = r1.headers["ETag"] r2 = client.get( "/weak/", headers=[("Accept-Encoding", "gzip"), ("If-None-Match", etag_header)], ) # This is the behaviour we expect by not mutating # the weak etags at after_request self.assertEqual(r2.status_code, 304) self.assertEqual(r2.headers.get("ETag"), etag_header) self.assertNotIn("Content-Encoding", r2.headers) self.assertEqual(len(r2.get_data()), 0) def test_conditional_get_uses_strong_compressed_representation_evaluate_conditional( self, ) -> None: client = self.app.test_client() r1 = client.get( "/strong-compress-conditional/", headers=[("Accept-Encoding", "gzip")] ) etag_header = r1.headers["ETag"] r2 = client.get( "/strong-compress-conditional/", headers=[("Accept-Encoding", "gzip"), ("If-None-Match", etag_header)], ) # This is the behaviour we would expect after evaluating # flask make_conditional at after_request self.assertEqual(r2.status_code, 304) self.assertEqual(r2.headers.get("ETag"), etag_header) self.assertNotIn("Content-Encoding", r2.headers) self.assertEqual(len(r2.get_data()), 0) def test_conditional_get_uses_weak_compressed_representation_evaluate_conditional( self, ) -> None: client = self.app.test_client() r1 = client.get( "/weak-compress-conditional/", headers=[("Accept-Encoding", "gzip")] ) etag_header = r1.headers["ETag"] r2 = client.get( "/weak-compress-conditional/", headers=[("Accept-Encoding", "gzip"), ("If-None-Match", etag_header)], ) # This is the behaviour we would expect after evaluating # flask make_conditional at after_request self.assertEqual(r2.status_code, 304) self.assertEqual(r2.headers.get("ETag"), etag_header) self.assertNotIn("Content-Encoding", r2.headers) self.assertEqual(len(r2.get_data()), 0) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1789465943.47043 flask_compress-1.25/tests/web/0000755000175100017510000000000015252212527016044 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841825 flask_compress-1.25/tests/web/test.xml0000644000175100017510000000002215252212521017531 0ustar00runnerrunnertest ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1789465936.841825 flask_compress-1.25/tox.ini0000644000175100017510000000220415252212521015430 0ustar00runnerrunner[tox] skip_missing_interpreters = true envlist = coverage_erase # When modifying the list of CPython and PyPy versions to test here, # be sure to update the "depends" configurations elsewhere in this file. # This ensures that coverage is reported only after all tests have run. py{39, 310, 311, 312, 313, 314, 315} pypy311 coverage_report [testenv:coverage_erase] description = Erase coverage files deps = coverage[toml] commands = - coverage erase [testenv] description = Run the test suite depends = py{39, 310, 311, 312, 313, 314, 315},pypy{39, 310, 311}: coverage_erase package = wheel wheel_build_env = .pkg deps = coverage[toml] pytest flask-caching commands = coverage run -m pytest {posargs} [testenv:coverage_report{,-ci}] description = Generate HTML and console coverage reports depends = py{39, 310, 311, 312, 313, 314, 315},pypy{39, 310, 311} deps = coverage[toml] commands_pre = - coverage combine # Only generate an HTML coverage report when running locally. !ci: - coverage html commands = coverage report [flake8] max-line-length = 88 extend-ignore = E203