├── tests └── example_app │ ├── config │ ├── __init__.py │ └── settings.py │ ├── example │ ├── __init__.py │ ├── about │ │ ├── __init__.py │ │ ├── team │ │ │ ├── __init__.py │ │ │ ├── static │ │ │ │ └── app.css │ │ │ ├── templates │ │ │ │ └── team.html │ │ │ └── bp_team.py │ │ ├── story │ │ │ ├── __init__.py │ │ │ ├── static_folder │ │ │ │ └── app.css │ │ │ ├── templates │ │ │ │ └── story.html │ │ │ └── bp_story.py │ │ ├── static │ │ │ └── app.css │ │ ├── templates │ │ │ └── about.html │ │ └── bp_about.py │ ├── static │ │ ├── js │ │ │ ├── app.js │ │ │ └── modules │ │ │ │ └── hello.js │ │ ├── robots.txt │ │ └── css │ │ │ └── app.css │ ├── contact │ │ ├── __init__.py │ │ ├── static │ │ │ └── app.css │ │ ├── templates │ │ │ └── index.html │ │ └── bp_contact.py │ ├── templates │ │ ├── app.html │ │ └── layout.html │ └── app.py │ ├── requirements.txt │ └── README.md ├── .flake ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── pyproject.toml ├── LICENSE ├── setup.py ├── .gitignore ├── flask_static_digest ├── cli.py ├── __init__.py └── digester.py ├── CHANGELOG.md └── README.md /tests/example_app/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/example_app/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/example_app/example/about/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/example_app/example/static/js/app.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/example_app/example/static/robots.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/example_app/example/about/team/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/example_app/example/contact/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/example_app/example/static/css/app.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore = E203, W503 3 | -------------------------------------------------------------------------------- /tests/example_app/example/about/story/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/example_app/example/static/js/modules/hello.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/example_app/example/about/static/app.css: -------------------------------------------------------------------------------- 1 | p { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /tests/example_app/example/contact/static/app.css: -------------------------------------------------------------------------------- 1 | p { 2 | color: green; 3 | } 4 | -------------------------------------------------------------------------------- /tests/example_app/example/about/team/static/app.css: -------------------------------------------------------------------------------- 1 | p { 2 | color: purple; 3 | } 4 | -------------------------------------------------------------------------------- /tests/example_app/example/about/story/static_folder/app.css: -------------------------------------------------------------------------------- 1 | p { 2 | color: blue; 3 | } 4 | -------------------------------------------------------------------------------- /tests/example_app/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.0.3 2 | Flask-Static-Digest==0.4.1 3 | Brotli==1.0.9 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | github: "nickjj" 4 | custom: ["https://www.paypal.me/nickjanetakis"] 5 | -------------------------------------------------------------------------------- /tests/example_app/example/templates/app.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block body %} 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 79 3 | 4 | [tool.isort] 5 | profile = "black" 6 | line_length = 79 7 | force_single_line = true 8 | -------------------------------------------------------------------------------- /tests/example_app/config/settings.py: -------------------------------------------------------------------------------- 1 | FLASK_STATIC_DIGEST_BLACKLIST_FILTER = [".txt"] 2 | FLASK_STATIC_DIGEST_HOST_URL = "https://cdn.example.com" 3 | FLASK_STATIC_DIGEST_COMPRESSION = ["gzip", "brotli"] 4 | -------------------------------------------------------------------------------- /tests/example_app/example/about/templates/about.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block body %} 4 |

About

5 | 6 |

This is an example about page.

7 | 8 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /tests/example_app/example/contact/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block body %} 4 |

Contact

5 | 6 |

This is an example contact page.

7 | 8 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /tests/example_app/example/about/team/templates/team.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block body %} 4 |

Team

5 | 6 |

This is an example team page.

7 | 8 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /tests/example_app/example/about/story/templates/story.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block body %} 4 |

Story

5 | 6 |

This is an example story page.

7 | 8 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /tests/example_app/example/about/story/bp_story.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template 2 | 3 | bp_story = Blueprint( 4 | "story", 5 | __name__, 6 | template_folder="./templates", 7 | static_folder="static_folder", 8 | ) 9 | 10 | 11 | @bp_story.route("/story") 12 | def index(): 13 | return render_template("story.html") 14 | -------------------------------------------------------------------------------- /tests/example_app/example/about/team/bp_team.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template 2 | 3 | bp_team = Blueprint( 4 | "team", 5 | __name__, 6 | template_folder="./templates", 7 | static_folder="static", 8 | static_url_path="/static/team", 9 | ) 10 | 11 | 12 | @bp_team.route("/team") 13 | def index(): 14 | return render_template("team.html") 15 | -------------------------------------------------------------------------------- /tests/example_app/example/contact/bp_contact.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template 2 | 3 | bp_contact = Blueprint( 4 | "contact", 5 | __name__, 6 | template_folder="./templates", 7 | static_url_path="/contact/static", 8 | static_folder="static", 9 | ) 10 | 11 | 12 | @bp_contact.route("/contact") 13 | def index(): 14 | return render_template("index.html") 15 | -------------------------------------------------------------------------------- /tests/example_app/example/about/bp_about.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template 2 | 3 | from example.about.story.bp_story import bp_story 4 | from example.about.team.bp_team import bp_team 5 | 6 | bp_about = Blueprint( 7 | "about", 8 | __name__, 9 | url_prefix="/about", 10 | template_folder="./templates", 11 | static_folder="static", 12 | ) 13 | 14 | bp_about.register_blueprint(bp_story) 15 | bp_about.register_blueprint(bp_team) 16 | 17 | 18 | @bp_about.route("/") 19 | def index(): 20 | return render_template("about.html") 21 | -------------------------------------------------------------------------------- /tests/example_app/README.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | ```sh 4 | # Ensure the FLASK_APP is set. 5 | export FLASK_APP=example.app 6 | 7 | # Install the dependencies. 8 | pip install -r requirements.txt 9 | 10 | # Optionally compile the static files if you want to see how it works. 11 | flask digest compile 12 | 13 | # Start the Flask server. 14 | flask run 15 | ``` 16 | 17 | Visit in your browser. If you did compile the static 18 | files then checkout the source code of the page. You should see the md5 hash as 19 | part of the file names for both the CSS and JS files. 20 | -------------------------------------------------------------------------------- /tests/example_app/example/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template 2 | from flask_static_digest import FlaskStaticDigest 3 | 4 | from example.about.bp_about import bp_about 5 | from example.contact.bp_contact import bp_contact 6 | 7 | flask_static_digest = FlaskStaticDigest() 8 | 9 | 10 | def create_app(): 11 | app = Flask(__name__) 12 | 13 | app.config.from_object("config.settings") 14 | 15 | app.register_blueprint(bp_about) 16 | app.register_blueprint(bp_contact) 17 | 18 | flask_static_digest.init_app(app) 19 | 20 | @app.route("/") 21 | def index(): 22 | return render_template("index.html") 23 | 24 | return app 25 | -------------------------------------------------------------------------------- /tests/example_app/example/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | 11 | 12 |

This is an example application

13 | 14 | Index 15 | About 16 | About: Story 17 | About: Team 18 | Contact 19 | 20 | {% block body %}{% endblock %} 21 | 22 | 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Nick Janetakis 4 | Copyright (c) 2023 Matthew Swabey 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining 7 | a copy of this software and associated documentation files (the 8 | 'Software'), to deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, merge, publish, 10 | distribute, sublicense, and/or sell copies of the Software, and to 11 | permit persons to whom the Software is furnished to do so, subject to 12 | the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 20 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 21 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 22 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 23 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name="Flask-Static-Digest", 6 | version="0.4.1", 7 | author="Nick Janetakis", 8 | author_email="nick.janetakis@gmail.com", 9 | url="https://github.com/nickjj/flask-static-digest", 10 | description=( 11 | "Flask extension for md5 tagging and compressing " 12 | "(gzip / brotli) static files." 13 | ), 14 | license="MIT", 15 | package_data={"Flask-Static-Digest": ["VERSION"]}, 16 | packages=["flask_static_digest"], 17 | platforms="any", 18 | python_requires=">=3.6", 19 | zip_safe=False, 20 | install_requires=["Flask>=1.0"], 21 | extras_require={"brotli": ["Brotli>=1.0.9"]}, 22 | entry_points={ 23 | "flask.commands": ["digest=flask_static_digest.cli:digest"], 24 | }, 25 | classifiers=[ 26 | "Environment :: Web Environment", 27 | "Framework :: Flask", 28 | "Intended Audience :: Developers", 29 | "License :: OSI Approved :: MIT License", 30 | "Operating System :: OS Independent", 31 | "Programming Language :: Python", 32 | "Programming Language :: Python :: 3", 33 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 34 | "Topic :: Software Development :: Libraries :: Python Modules", 35 | "Topic :: System :: Archiving :: Compression", 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/python,osx 2 | 3 | ### OSX ### 4 | *.DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | ### Python ### 31 | # Byte-compiled / optimized / DLL files 32 | __pycache__/ 33 | *.py[cod] 34 | *$py.class 35 | 36 | # C extensions 37 | *.so 38 | 39 | # Distribution / packaging 40 | .Python 41 | build/ 42 | develop-eggs/ 43 | dist/ 44 | downloads/ 45 | eggs/ 46 | .eggs/ 47 | lib/ 48 | lib64/ 49 | parts/ 50 | sdist/ 51 | var/ 52 | wheels/ 53 | *.egg-info/ 54 | .installed.cfg 55 | *.egg 56 | 57 | # PyInstaller 58 | # Usually these files are written by a python script from a template 59 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 60 | *.manifest 61 | *.spec 62 | 63 | # Installer logs 64 | pip-log.txt 65 | pip-delete-this-directory.txt 66 | 67 | # Unit test / coverage reports 68 | htmlcov/ 69 | .tox/ 70 | .coverage 71 | .coverage.* 72 | .cache 73 | .pytest_cache/ 74 | nosetests.xml 75 | coverage.xml 76 | *.cover 77 | .hypothesis/ 78 | 79 | # Translations 80 | *.mo 81 | *.pot 82 | 83 | # End of https://www.gitignore.io/api/python,osx 84 | 85 | # Exclude local .venv 86 | .venv/ 87 | -------------------------------------------------------------------------------- /flask_static_digest/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | from flask import current_app 5 | from flask.cli import with_appcontext 6 | 7 | from flask_static_digest.digester import clean as _clean 8 | from flask_static_digest.digester import compile as _compile 9 | 10 | 11 | @click.group() 12 | @click.pass_context 13 | @with_appcontext 14 | def digest(ctx): 15 | """md5 tag and compress static files.""" 16 | 17 | ctx.ensure_object(dict) 18 | 19 | ctx.obj["gzip"] = False 20 | ctx.obj["brotli"] = False 21 | ctx.obj["blacklist_filter"] = current_app.config.get( 22 | "FLASK_STATIC_DIGEST_BLACKLIST_FILTER" 23 | ) 24 | 25 | compression = current_app.config.get("FLASK_STATIC_DIGEST_COMPRESSION") 26 | 27 | for algo in compression: 28 | if algo == "gzip": 29 | ctx.obj["gzip"] = True 30 | elif algo == "brotli": 31 | try: 32 | import brotli # noqa: F401 33 | except ModuleNotFoundError: 34 | click.echo("Error: Python package 'brotli' not installed.") 35 | sys.exit(78) # sysexits.h 78 EX_CONFIG configuration error 36 | else: 37 | ctx.obj["brotli"] = True 38 | else: 39 | click.echo( 40 | f"{algo} is not a supported compression value, it must be" 41 | " 'gzip' or 'brotli'" 42 | ) 43 | sys.exit(78) 44 | 45 | 46 | @digest.command() 47 | @click.pass_context 48 | @with_appcontext 49 | def compile(ctx): 50 | """Generate optimized static files and a cache manifest.""" 51 | for blueprint in [current_app, *current_app.blueprints.values()]: 52 | if not blueprint.static_folder: 53 | continue 54 | 55 | _compile( 56 | blueprint.static_folder, 57 | blueprint.static_folder, 58 | ctx.obj["blacklist_filter"], 59 | ctx.obj["gzip"], 60 | ctx.obj["brotli"], 61 | ) 62 | 63 | 64 | @digest.command() 65 | @click.pass_context 66 | @with_appcontext 67 | def clean(ctx): 68 | """Remove generated static files and cache manifest.""" 69 | for blueprint in [current_app, *current_app.blueprints.values()]: 70 | if not blueprint.static_folder: 71 | continue 72 | 73 | _clean( 74 | blueprint.static_folder, 75 | ctx.obj["blacklist_filter"], 76 | ctx.obj["gzip"], 77 | ctx.obj["brotli"], 78 | ) 79 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a 6 | Changelog](https://keepachangelog.com/en/1.0.0/). 7 | 8 | ## [Unreleased] 9 | 10 | - Nothing yet! 11 | 12 | ## [0.4.1] - 2024-05-17 13 | 14 | - Fix `FLASK_STATIC_DIGEST_HOST_URL` to support URL paths that have multiple `/` segments 15 | 16 | ## [0.4.0] - 2023-05-14 17 | 18 | - Brotli support added by [Dr. Matthew Swabey](https://github.com/mattaw) 19 | - Remove `FLASK_STATIC_DIGEST_GZIP_FILES` gzip config option 20 | - Add `FLASK_STATIC_DIGEST_COMPRESSION` (defaults to `["gzip"]` and replaces the old gzip config option) 21 | 22 | ## [0.3.0] - 2022-03-16 23 | 24 | - Add support for digesting static files in blueprints and nested blueprints 25 | - Spruce up the code base by using Black for code formatting 26 | 27 | ## [0.2.1] - 2020-12-23 28 | 29 | ### Fixed 30 | 31 | - Ensure Flask's `static_url_path` is used if you have a host URL set 32 | 33 | ## [0.2.0] - 2020-12-23 34 | 35 | ### Added 36 | 37 | - Support to prepend your static paths with a host / CDN URL via `FLASK_STATIC_DIGEST_HOST_URL` 38 | 39 | ## [0.1.4] - 2020-11-24 40 | 41 | ### Fixed 42 | 43 | - `static_url_for` will now throw a 404 instead of a 500 if you have an invalid `filename` 44 | 45 | ## [0.1.3] - 2020-01-23 46 | 47 | ### Added 48 | 49 | - Windows support 50 | 51 | ## [0.1.2] - 2019-10-30 52 | 53 | ### Fixed 54 | 55 | - Really fix the version being read dynamically in `setup.py` 56 | 57 | ## [0.1.1] - 2019-10-30 58 | 59 | ### Fixed 60 | 61 | - Attempt to fix the version being read dynamically in `setup.py` 62 | 63 | ## [0.1.0] - 2019-10-30 64 | 65 | ### Added 66 | 67 | - Everything! 68 | 69 | [Unreleased]: https://github.com/nickjj/flask-static-digest/compare/v0.4.1...HEAD 70 | [0.4.1]: https://github.com/nickjj/flask-static-digest/compare/v0.4.0...v0.4.1 71 | [0.4.0]: https://github.com/nickjj/flask-static-digest/compare/v0.3.0...v0.4.0 72 | [0.3.0]: https://github.com/nickjj/flask-static-digest/compare/v0.2.1...v0.3.0 73 | [0.2.1]: https://github.com/nickjj/flask-static-digest/compare/v0.2.0...v0.2.1 74 | [0.2.0]: https://github.com/nickjj/flask-static-digest/compare/v0.1.4...v0.2.0 75 | [0.1.4]: https://github.com/nickjj/flask-static-digest/compare/v0.1.3...v0.1.4 76 | [0.1.3]: https://github.com/nickjj/flask-static-digest/compare/v0.1.2...v0.1.3 77 | [0.1.2]: https://github.com/nickjj/flask-static-digest/compare/v0.1.1...v0.1.2 78 | [0.1.1]: https://github.com/nickjj/flask-static-digest/compare/v0.1.0...v0.1.1 79 | [0.1.0]: https://github.com/nickjj/flask-static-digest/releases/tag/v0.1.0 80 | -------------------------------------------------------------------------------- /flask_static_digest/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | 5 | from flask import request 6 | from flask import url_for as flask_url_for 7 | 8 | 9 | class FlaskStaticDigest(object): 10 | def __init__(self, app=None): 11 | self.app = app 12 | 13 | if app is not None: 14 | self.init_app(app) 15 | 16 | def init_app(self, app): 17 | """ 18 | Mutate the application passed in as explained here: 19 | https://flask.palletsprojects.com/en/1.1.x/extensiondev/ 20 | :param app: Flask application 21 | :return: None 22 | """ 23 | app.config.setdefault("FLASK_STATIC_DIGEST_BLACKLIST_FILTER", []) 24 | app.config.setdefault("FLASK_STATIC_DIGEST_HOST_URL", None) 25 | app.config.setdefault("FLASK_STATIC_DIGEST_COMPRESSION", ["gzip"]) 26 | 27 | compression = set(app.config["FLASK_STATIC_DIGEST_COMPRESSION"]) 28 | app.config["FLASK_STATIC_DIGEST_COMPRESSION"] = list(compression) 29 | 30 | self.host_url = app.config.get("FLASK_STATIC_DIGEST_HOST_URL") 31 | 32 | self.manifests = {} 33 | 34 | self._load_manifest("static", app) 35 | for endpoint, blueprint in app.blueprints.items(): 36 | self._load_manifest(f"{endpoint}.static", blueprint) 37 | 38 | app.add_template_global(self.static_url_for) 39 | 40 | def _load_manifest(self, endpoint, scaffold): 41 | if not scaffold.has_static_folder: 42 | return 43 | 44 | manifest_path = os.path.join( 45 | scaffold._static_folder, "cache_manifest.json" 46 | ) 47 | try: 48 | with scaffold.open_resource(manifest_path, "r") as f: 49 | self.manifests[endpoint] = json.load(f) 50 | except json.JSONDecodeError: 51 | logging.warning(f"Couldn't decode file: {manifest_path}") 52 | except PermissionError: 53 | logging.warning(f"Couldn't access file: {manifest_path}") 54 | except (FileNotFoundError, Exception): 55 | pass 56 | 57 | def _custom_url_join(self, url_path): 58 | """ 59 | Join the host URL with another URL path. 60 | :param url_path: The URL returned by url_for 61 | :type url_path: str 62 | :return: Joined URL 63 | """ 64 | if self.host_url is None: 65 | return url_path 66 | 67 | return "/".join([self.host_url.rstrip("/"), url_path.lstrip("/")]) 68 | 69 | def static_url_for(self, endpoint, **values): 70 | """ 71 | This function uses Flask's url_for under the hood and accepts the 72 | same arguments. The only differences are it will prefix a host URL if 73 | one exists and if a manifest is available it will look up the filename 74 | from the manifest. 75 | :param endpoint: The endpoint of the URL 76 | :type endpoint: str 77 | :param values: Arguments of the URL rule 78 | :return: Static file path. 79 | """ 80 | if request is not None: 81 | blueprint_name = request.blueprint 82 | if endpoint[:1] == ".": 83 | if blueprint_name is not None: 84 | endpoint = f"{blueprint_name}{endpoint}" 85 | else: 86 | endpoint = endpoint[1:] 87 | 88 | manifest = self.manifests.get(endpoint, {}) 89 | filename = values.get("filename", None) 90 | values["filename"] = manifest.get(filename, filename) 91 | 92 | return self._custom_url_join(flask_url_for(endpoint, **values)) 93 | -------------------------------------------------------------------------------- /flask_static_digest/digester.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import glob 3 | import gzip 4 | import hashlib 5 | import json 6 | import os.path 7 | import re 8 | import shutil 9 | 10 | DIGESTED_FILE_REGEX = r"-[a-f\d]{32}" 11 | CHUNK_SIZE = 1024 * 1024 12 | 13 | 14 | def compile( 15 | input_path, output_path, digest_blacklist_filter, gzip_files, brotli_files 16 | ): 17 | """ 18 | Generate md5 tagged static files compressed with gzip and brotli. 19 | 20 | :param input_path: The source path of your static files 21 | :type input_path: str 22 | :param output_path: The destination path of your static files 23 | :type output_path: str 24 | :param digest_blacklist_filter: Ignore compiling these file types 25 | :type digest_blacklist_filter: list 26 | :param gzip_files: Whether or not gzipped files will be generated 27 | :type gzip_files: bool 28 | :param brotli_files: Whether or not brotli files will be generated 29 | :type brotli_files: bool 30 | :return: None 31 | """ 32 | if not os.path.exists(input_path): 33 | print(f"The input path '{input_path}' does not exist") 34 | return None 35 | 36 | if not os.path.exists(output_path): 37 | print(f"The output path '{output_path}' does not exist") 38 | return None 39 | 40 | files = _filter_files(input_path, digest_blacklist_filter) 41 | manifest = _generate_manifest(files, gzip_files, brotli_files, output_path) 42 | _save_manifest(manifest, output_path) 43 | 44 | print(f"Check your digested files at '{output_path}'") 45 | return None 46 | 47 | 48 | def clean(output_path, digest_blacklist_filter, gzip_files, brotli_files): 49 | """ 50 | Delete the generated md5 tagged and gzipped static files. 51 | 52 | :param input_path: The source path of your static files 53 | :type input_path: str 54 | :param output_path: The destination path of your static files 55 | :type output_path: str 56 | :param digest_blacklist_filter: Ignore cleaning these file types 57 | :type digest_blacklist_filter: list 58 | :param gzip_files: Whether or not gzipped files will be cleaned 59 | :type gzip_files: bool 60 | :param brotli_files: Whether or not brotli files will be cleaned 61 | :type brotli_files: bool 62 | :return: None 63 | """ 64 | for item in glob.iglob(output_path + "**/**", recursive=True): 65 | if os.path.isfile(item): 66 | _, file_extension = os.path.splitext(item) 67 | basename = os.path.basename(item) 68 | 69 | if ( 70 | re.search(DIGESTED_FILE_REGEX, basename) 71 | and file_extension not in digest_blacklist_filter 72 | ): 73 | if os.path.exists(item): 74 | os.remove(item) 75 | 76 | if gzip_files and file_extension == ".gz": 77 | if os.path.exists(item): 78 | os.remove(item) 79 | 80 | if brotli_files and file_extension == ".br": 81 | if os.path.exists(item): 82 | os.remove(item) 83 | 84 | manifest_path = os.path.join(output_path, "cache_manifest.json") 85 | 86 | if os.path.exists(manifest_path): 87 | os.remove(manifest_path) 88 | 89 | print(f"Check your cleaned files at '{output_path}'") 90 | return None 91 | 92 | 93 | def _filter_files(input_path, digest_blacklist_filter): 94 | filtered_files = [] 95 | 96 | for item in glob.iglob(input_path + "**/**", recursive=True): 97 | if os.path.isfile(item): 98 | if not _is_compiled_file(item, digest_blacklist_filter): 99 | filtered_files.append(item) 100 | 101 | return filtered_files 102 | 103 | 104 | def _is_compiled_file(file_path, digest_blacklist_filter): 105 | file_name, file_extension = os.path.splitext(file_path) 106 | basename = os.path.basename(file_path) 107 | 108 | return ( 109 | re.search(DIGESTED_FILE_REGEX, basename) 110 | or file_extension in digest_blacklist_filter 111 | or file_extension == ".gz" 112 | or file_extension == ".br" 113 | or basename == "cache_manifest.json" 114 | ) 115 | 116 | 117 | def _generate_manifest(files, gzip_files, brotli_files, output_path): 118 | manifest = {} 119 | 120 | for file in files: 121 | rel_file_path = os.path.relpath(file, output_path).replace("\\", "/") 122 | 123 | file_name, file_extension = os.path.splitext(rel_file_path) 124 | 125 | digest = _generate_digest(file) 126 | digested_file_path = f"{file_name}-{digest}{file_extension}" 127 | 128 | manifest[rel_file_path] = digested_file_path 129 | 130 | _write_to_disk( 131 | file, digested_file_path, gzip_files, brotli_files, output_path 132 | ) 133 | 134 | return manifest 135 | 136 | 137 | def _generate_digest(file): 138 | digest = None 139 | 140 | with open(file, "rb") as f: 141 | digest = hashlib.md5(f.read()).hexdigest() 142 | 143 | return digest 144 | 145 | 146 | def _save_manifest(manifest, output_path): 147 | manifest_content = json.dumps(manifest) 148 | manifest_path = os.path.join(output_path, "cache_manifest.json") 149 | 150 | with open(manifest_path, "w") as f: 151 | f.write(manifest_content) 152 | 153 | return None 154 | 155 | 156 | def _write_to_disk( 157 | file, digested_file_path, gzip_files, brotli_files, input_path 158 | ): 159 | full_digested_file_path = os.path.join(input_path, digested_file_path) 160 | 161 | # Copy file while preserving permissions and meta data if supported. 162 | shutil.copy2(file, full_digested_file_path) 163 | 164 | if gzip_files: 165 | with open(file, "rb") as f_in: 166 | with gzip.open(f"{file}.gz", "wb") as f_out: 167 | shutil.copyfileobj(f_in, f_out) 168 | 169 | shutil.copy2(f"{file}.gz", f"{full_digested_file_path}.gz") 170 | 171 | if brotli_files: 172 | import brotli 173 | 174 | compressor = brotli.Compressor(quality=11) 175 | 176 | with open(file, "rb") as f_in: 177 | with open(f"{file}.br", "wb") as f_out: 178 | read_chunk = functools.partial(f_in.read, CHUNK_SIZE) 179 | 180 | for data in iter(read_chunk, b""): 181 | f_out.write(compressor.process(data)) 182 | 183 | f_out.write(compressor.finish()) 184 | 185 | shutil.copy2(f"{file}.br", f"{full_digested_file_path}.br") 186 | 187 | return None 188 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "*" 7 | push: 8 | branches: 9 | - "main" 10 | - "master" 11 | 12 | jobs: 13 | test: 14 | runs-on: "ubuntu-22.04" 15 | 16 | steps: 17 | - uses: "actions/checkout@v2" 18 | 19 | - name: "Set up Python 3.10" 20 | uses: "actions/setup-python@v1" 21 | with: 22 | python-version: "3.10" 23 | 24 | - name: "Install dependencies" 25 | run: | 26 | pip3 install flake8 black Flask==3.0.3 27 | pip3 install -e .[brotli] 28 | - name: "Lint code base" 29 | run: | 30 | flake8 . 31 | black --check . 32 | - name: "Run test suite for gzip and brotli" 33 | run: | 34 | cd tests/example_app 35 | export FLASK_APP=example.app 36 | ls -laR example/static/ | wc -l \ 37 | | grep -q "26" \ 38 | && (echo "File count before compiling test (index): pass" && exit 0) \ 39 | || (echo "File count before compiling test (index): fail" && exit 1) 40 | ls -laR example/about/static/ | wc -l \ 41 | | grep -q "5" \ 42 | && (echo "File count before compiling test (about): pass" && exit 0) \ 43 | || (echo "File count before compiling test (about): fail" && exit 1) 44 | ls -laR example/contact/static/ | wc -l \ 45 | | grep -q "5" \ 46 | && (echo "File count before compiling test (contact): pass" && exit 0) \ 47 | || (echo "File count before compiling test (contact): fail" && exit 1) 48 | ls -laR example/about/team/static/ | wc -l \ 49 | | grep -q "5" \ 50 | && (echo "File count before compiling test (team): pass" && exit 0) \ 51 | || (echo "File count before compiling test (team): fail" && exit 1) 52 | flask digest compile \ 53 | && ls -laR example/static/ | wc -l \ 54 | | grep -q "42" \ 55 | && (echo "File count after compiling test (index): pass" && exit 0) \ 56 | || (echo "File count after compiling test (index): fail" && exit 1) 57 | ls -laR example/about/static/ | wc -l \ 58 | | grep -q "11" \ 59 | && (echo "File count after compiling test (about): pass" && exit 0) \ 60 | || (echo "File count after compiling test (about): fail" && exit 1) 61 | ls -laR example/contact/static/ | wc -l \ 62 | | grep -q "11" \ 63 | && (echo "File count after compiling test (contact): pass" && exit 0) \ 64 | || (echo "File count after compiling test (contact): fail" && exit 1) 65 | ls -laR example/about/team/static/ | wc -l \ 66 | | grep -q "11" \ 67 | && (echo "File count after compiling test (team): pass" && exit 0) \ 68 | || (echo "File count after compiling test (team): fail" && exit 1) 69 | find . -type f | grep ".gz$" | wc -l \ 70 | | grep -q "14" \ 71 | && (echo "Check for gzip asset creation: pass" && exit 0) \ 72 | || (echo "Check for gzip asset creation: pass: fail" && exit 1) 73 | find . -type f | grep ".br$" | wc -l \ 74 | | grep -q "14" \ 75 | && (echo "Check for brotli asset creation: pass" && exit 0) \ 76 | || (echo "Check for brotli asset creation: pass: fail" && exit 1) 77 | grep -q "js/modules/hello.js" example/static/cache_manifest.json \ 78 | && (echo "Cache manifest has nested file test: pass" && exit 0) \ 79 | || (echo "Cache manifest has nested file test: fail" && exit 1) 80 | grep -q "js/modules/hello-d41d8cd98f00b204e9800998ecf8427e.js" example/static/cache_manifest.json \ 81 | && (echo "Cache manifest has digested file test: pass" && exit 0) \ 82 | || (echo "Cache manifest has digested file test: fail" && exit 1) 83 | grep -q "js/modules/hello.js" example/static/cache_manifest.json \ 84 | && (echo "Cache manifest has nested file test: pass" && exit 0) \ 85 | || (echo "Cache manifest has nested file test: fail" && exit 1) 86 | flask run & 87 | sleep 5 \ 88 | && curl http://localhost:5000 \ 89 | | grep -q 'https://cdn.example.com/static/css/app-d41d8cd98f00b204e9800998ecf8427e.css' \ 90 | && (echo 'Stylesheet is md5 tagged test (index): pass' && exit 0) \ 91 | || (echo 'Stylesheet is md5 tagged test (index): fail' && exit 1) 92 | curl http://localhost:5000/about/ \ 93 | | grep -q 'https://cdn.example.com/about/static/app-297c671953f6c24f1354214b3f706e0b.css' \ 94 | && (echo 'Stylesheet is md5 tagged test (about): pass' && exit 0) \ 95 | || (echo 'Stylesheet is md5 tagged test (about): fail' && exit 1) 96 | curl http://localhost:5000/contact \ 97 | | grep -q 'https://cdn.example.com/contact/static/app-b71c3f4f5d026dd5198163b655228776.css' \ 98 | && (echo 'Stylesheet is md5 tagged test (contact): pass' && exit 0) \ 99 | || (echo 'Stylesheet is md5 tagged test (contact): fail' && exit 1) 100 | curl http://localhost:5000/about/team \ 101 | | grep -q 'https://cdn.example.com/about/static/team/app-023f768653c556fc2fcaf338b408c464.css' \ 102 | && (echo 'Stylesheet is md5 tagged test (team): pass' && exit 0) \ 103 | || (echo 'Stylesheet is md5 tagged test (team): fail' && exit 1) 104 | kill $! 105 | flask digest clean \ 106 | && ls -laR example/static/ | wc -l \ 107 | | grep -q "26" \ 108 | && (echo "File count after cleaning test (index): pass" && exit 0) \ 109 | || (echo "File count after cleaning test (index): fail" && exit 1) 110 | ls -laR example/about/static/ | wc -l \ 111 | | grep -q "5" \ 112 | && (echo "File count after cleaning test (about): pass" && exit 0) \ 113 | || (echo "File count after cleaning test (about): fail" && exit 1) 114 | ls -laR example/contact/static/ | wc -l \ 115 | | grep -q "5" \ 116 | && (echo "File count after cleaning test (contact): pass" && exit 0) \ 117 | || (echo "File count after cleaning test (contact): fail" && exit 1) 118 | ls -laR example/about/team/static/ | wc -l \ 119 | | grep -q "5" \ 120 | && (echo "File count after cleaning test (team): pass" && exit 0) \ 121 | || (echo "File count after cleaning test (team): fail" && exit 1) 122 | flask run & 123 | sleep 5 \ 124 | && curl http://localhost:5000 \ 125 | | grep -q 'https://cdn.example.com/static/js/app.js' \ 126 | && (echo 'Javascript is not md5 tagged test: pass' && exit 0) \ 127 | || (echo 'Javascript is not md5 tagged test: fail' && exit 1) 128 | flask digest clean 129 | kill $! 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What is Flask-Static-Digest? ![CI](https://github.com/nickjj/flask-static-digest/workflows/CI/badge.svg?branch=master) 2 | 3 | It is a Flask extension that will help make your static files production ready 4 | with very minimal effort on your part. It does this by creating md5 tagged 5 | versions and gzip and / or brotli compressed versions of your static files by 6 | running a `flask digest compile` command that this extension adds to your Flask 7 | app. 8 | 9 | It should be the last thing you do to your static files before uploading them 10 | to your server or CDN. Speaking of which, if you're using a CDN this extension 11 | optionally lets you configure a host URL that will get prepended to your static 12 | file paths. If you're not using a CDN, no problem everything will work as you 13 | would expect by default. 14 | 15 | Other web frameworks like Django, Ruby on Rails and Phoenix all have this 16 | feature built into their framework, and now with this extension Flask does too. 17 | 18 | **This extension will work if you're not using any asset build tools but at the 19 | same time it also works with esbuild, Webpack, Grunt, Gulp or any other build 20 | tool you can think of. This tool does not depend on or compete with existing 21 | asset build tools.** 22 | 23 | If you're already using Webpack or a similar tool, that's great. Webpack takes 24 | care of bundling your assets and helps convert things like SASS to CSS and ES6+ 25 | JS to browser compatible JS. That is solving a completely different problem 26 | than what this extension solves. This extension will further optimize your 27 | static files after your build tool produces its output files. 28 | 29 | This extension does things that Webpack alone cannot do because in order for 30 | things like md5 tagging to work Flask needs to be aware of how to map those 31 | hashed file names back to regular file names you would reference in your Jinja 32 | 2 templates. 33 | 34 | ## How does it work? 35 | 36 | There's 3 pieces to this extension: 37 | 38 | 1. It adds a custom Flask CLI command to your project. When you run this 39 | command it looks at your static files and then generates an md5 tagged 40 | version of each file along with optionally compressing them with gzip 41 | and / or brotli. 42 | 43 | 2. When the above command finishes it creates a `cache_manifest.json` file in 44 | your static folder which maps the regular file names, such as 45 | `images/flask.png` to `images/flask-f86b271a51b3cfad5faa9299dacd987f.png`. 46 | 47 | 3. It adds a new template helper called `static_url_for` which uses Flask's 48 | `url_for` under the hood but is aware of the `cache_manifest.json` file so 49 | it knows how to resolve `images/flask.png` to the md5 tagged file name. 50 | 51 | ### Demo video 52 | 53 | This 25 minute video goes over using this extension but it also spends a lot 54 | of time on the "why" where we cover topics like cache busting and why IMO you 55 | might want to use this extension in all of your Flask projects. 56 | 57 | If you prefer reading instead of video, this README file covers installing, 58 | configuring and using this extension too. 59 | 60 | [![Demo 61 | Video](https://img.youtube.com/vi/-Xd84hlIjkI/0.jpg)](https://www.youtube.com/watch?v=-Xd84hlIjkI) 62 | 63 | #### Changes since this video 64 | 65 | - `FLASK_STATIC_DIGEST_HOST_URL` has been added to configure an optional external host, aka. CDN ([explained here](#configuring-this-extension)) 66 | - If your blueprints have static files they will get digested now too (including nested blueprints!) 67 | - Optional Brotli support has been added 68 | - `FLASK_STATIC_DIGEST_COMPRESSION` has been added to control compression ([explained here](#configuring-this-extension)) 69 | 70 | ## Table of Contents 71 | 72 | - [Installation](#installation) 73 | - [Using the newly added Flask CLI command](#using-the-newly-added-flask-cli-command) 74 | - [Going over the Flask CLI commands](#going-over-the-flask-cli-commands) 75 | - [Configuring this extension](#configuring-this-extension) 76 | - [Modifying your templates to use static_url_for instead of url_for](#modifying-your-templates-to-use-static_url_for-instead-of-url_for) 77 | - [Potentially updating your .gitignore file](#potentially-updating-your-gitignore-file) 78 | - [FAQ](#faq) 79 | - [What about development vs production and performance implications?](#what-about-development-vs-production-and-performance-implications) 80 | - [Why bother compressing your static files here instead of with nginx?](#why-bother-compressing-your-static-files-here-instead-of-with-nginx) 81 | - [How do you use this extension with Webpack or another build tool?](#how-do-you-use-this-extension-with-webpack-or-another-build-tool) 82 | - [Migrating from Flask-Webpack](#migrating-from-flask-webpack) 83 | - [How do you use this extension with Docker?](#how-do-you-use-this-extension-with-docker) 84 | - [How do you use this extension with Heroku?](#how-do-you-use-this-extension-with-heroku) 85 | - [What about user uploaded files?](#what-about-user-uploaded-files) 86 | - [About the author](#about-the-author) 87 | 88 | ## Installation 89 | 90 | *You'll need to be running Python 3.6+ and using Flask 1.0 or greater.* 91 | 92 | `pip install Flask-Static-Digest` 93 | 94 | To install with Brotli support: 95 | 96 | `pip install Flask-Static-Digest[brotli]` 97 | 98 | ### Example directory structure for a 'hello' app 99 | 100 | ``` 101 | ├── hello 102 | │   ├── __init__.py 103 | │   ├── app.py 104 | │   └── static 105 | │   └── css 106 | │   ├── app.css 107 | └── requirements.txt 108 | ``` 109 | 110 | ### Flask app factory example using this extension 111 | 112 | ```py 113 | from flask import Flask 114 | from flask_static_digest import FlaskStaticDigest 115 | 116 | flask_static_digest = FlaskStaticDigest() 117 | 118 | 119 | def create_app(): 120 | app = Flask(__name__) 121 | 122 | flask_static_digest.init_app(app) 123 | 124 | @app.route("/") 125 | def index(): 126 | return "Hello, World!" 127 | 128 | return app 129 | ``` 130 | 131 | *A more complete example app can be found in the [tests/ 132 | directory](https://github.com/nickjj/flask-static-digest/tree/master/tests/example_app).* 133 | 134 | ## Using the newly added Flask CLI command 135 | 136 | You'll want to make sure to at least set the `FLASK_APP` environment variable: 137 | 138 | ```sh 139 | export FLASK_APP=hello.app 140 | export FLASK_ENV=development 141 | ``` 142 | 143 | Then run the `flask` binary to see its help menu: 144 | 145 | ```sh 146 | Usage: flask [OPTIONS] COMMAND [ARGS]... 147 | 148 | ... 149 | 150 | Options: 151 | --version Show the flask version 152 | --help Show this message and exit. 153 | 154 | Commands: 155 | digest md5 tag and compress static files. 156 | routes Show the routes for the app. 157 | run Run a development server. 158 | shell Run a shell in the app context. 159 | ``` 160 | 161 | If all went as planned you should see the new `digest` command added to the 162 | list of commands. 163 | 164 | ## Going over the Flask CLI commands 165 | 166 | Running `flask digest` will produce this help menu: 167 | 168 | ```sh 169 | Usage: flask digest [OPTIONS] COMMAND [ARGS]... 170 | 171 | md5 tag and compress static files. 172 | 173 | Options: 174 | --help Show this message and exit. 175 | 176 | Commands: 177 | clean Remove generated static files and cache manifest. 178 | compile Generate optimized static files and a cache manifest. 179 | ``` 180 | 181 | Each command is labeled, but here's a bit more information on what they do. 182 | 183 | ### compile 184 | 185 | Inspects your Flask app's and blueprint's `static_folder` and uses that as both 186 | the input and output path of where to look for and create the newly digested 187 | and compressed files. 188 | 189 | At a high level it recursively loops over all of the files it finds in that 190 | directory and then generates the md5 tagged and compressed versions of each 191 | file. It also creates a `cache_manifest.json` file in the root of your 192 | `static_folder`. 193 | 194 | That manifest file is machine generated meaning you should not edit it unless 195 | you really know what you're doing. 196 | 197 | This file maps the human readable file name of let's say `images/flask.png` to 198 | the digested file name. It's a simple key / value set up. It's basically a 199 | Python dictionary in JSON format. 200 | 201 | In the end it means if your static folder looked like this originally: 202 | 203 | - `css/app.css` 204 | - `js/app.js` 205 | - `images/flask.png` 206 | 207 | And you decided to run the compile command, it would now look like this: 208 | 209 | - `css/app.css` 210 | - `css/app.css.gz` 211 | - `css/app-5d41402abc4b2a76b9719d911017c592.css` 212 | - `css/app-5d41402abc4b2a76b9719d911017c592.css.gz` 213 | - `js/app.js` 214 | - `js/app.js.gz` 215 | - `js/app-098f6bcd4621d373cade4e832627b4f6.js` 216 | - `js/app-098f6bcd4621d373cade4e832627b4f6.js.gz` 217 | - `images/flask.png` 218 | - `images/flask.png.gz` 219 | - `images/flask-f86b271a51b3cfad5faa9299dacd987f.png` 220 | - `images/flask-f86b271a51b3cfad5faa9299dacd987f.png.gz` 221 | - `cache_manifest.json` 222 | 223 | *Your md5 hashes will be different because it depends on what the contents of 224 | the file are.* 225 | 226 | ### clean 227 | 228 | Inspects your Flask app's and blueprint's `static_folder` and uses that as the 229 | input path of where to look for digested and compressed files. 230 | 231 | It will recursively delete files that have a file extension of `.gz` or `.br` 232 | and files that have been digested. It determines if a file has been digested 233 | based on its file name. In other words, it will delete files that match this 234 | regexp `r"-[a-f\d]{32}"`. 235 | 236 | In the end that means if you had these 6 files in your static folder: 237 | 238 | - `images/flask.png` 239 | - `images/flask.png.gz` 240 | - `images/flask.png.br` 241 | - `images/flask-f86b271a51b3cfad5faa9299dacd987f.png` 242 | - `images/flask-f86b271a51b3cfad5faa9299dacd987f.png.gz` 243 | - `images/flask-f86b271a51b3cfad5faa9299dacd987f.png.br` 244 | 245 | And you decided to run the clean command, the last 5 files would be deleted 246 | leaving you with the original `images/flask.png`. 247 | 248 | ## Configuring this extension 249 | 250 | By default this extension will create md5 tagged versions of all files it finds 251 | in your configured `static_folder`. It will also create gzip'ed versions of each 252 | file and it won't prefix your static files with an external host. 253 | 254 | If you don't like any of this behavior or you wish to enable brotli you can 255 | optionally configure: 256 | 257 | ```py 258 | FLASK_STATIC_DIGEST_BLACKLIST_FILTER = [] 259 | # If you want specific extensions to not get md5 tagged you can add them to 260 | # the list, such as: [".htm", ".html", ".txt"]. Make sure to include the ".". 261 | 262 | FLASK_STATIC_DIGEST_COMPRESSION = ["gzip"] 263 | # Optionally compress your static files, supported values are: 264 | # [] avoids any compression 265 | # ["gzip"] uses gzip 266 | # ["brotli"] uses brotli (prefer either gzip or both) 267 | # ["gzip", "brotli"] uses both 268 | 269 | FLASK_STATIC_DIGEST_HOST_URL = None 270 | # When set to a value such as https://cdn.example.com and you use static_url_for 271 | # it will prefix your static path with this URL. This would be useful if you 272 | # host your files from a CDN. Make sure to include the protocol (aka. https://). 273 | ``` 274 | 275 | You can override these defaults in your Flask app's config file. 276 | 277 | ## Modifying your templates to use static_url_for instead of url_for 278 | 279 | We're all familiar with this code right? 280 | 281 | ```html 282 | Flask logo 284 | ``` 285 | 286 | When you put the above code into a Flask powered Jinja 2 template, it turns 287 | into this: 288 | 289 | ```html 290 | Flask logo 292 | ``` 293 | 294 | The path might vary depending on how you configured your Flask app's 295 | `static_folder` but you get the idea. 296 | 297 | #### Using static_url_for instead of url_for 298 | 299 | Let's use the same example as above: 300 | 301 | ```html 302 | Flask logo 304 | ``` 305 | 306 | But now take a look at the output this produces: 307 | 308 | ```html 309 | Flask logo 311 | ``` 312 | 313 | Or if you set `FLASK_STATIC_DIGEST_HOST_URL = "https://cdn.example.com"` it 314 | would produce: 315 | 316 | ```html 317 | Flask logo 319 | ``` 320 | 321 | Instead of using `url_for` you would use `static_url_for`. This uses Flask's 322 | `url_for` under the hood so things like `_external=True` and everything else 323 | `url_for` supports is available to use with `static_url_for`. 324 | 325 | That means to use this extension you don't have to do anything other than 326 | install it, optionally run the CLI command to generate the manifest and then 327 | rename your static file references to use `static_url_for` instead of 328 | `url_for`. 329 | 330 | If your editor supports performing a find / replace across multiple files you 331 | can quickly make the change by finding `url_for('static'` and replacing that 332 | with `static_url_for('static'`. If you happen to use double quotes instead of 333 | single quotes you'll want to adjust for that too. 334 | 335 | ## Potentially updating your .gitignore file 336 | 337 | If you're using something like Webpack then chances are you're already git 338 | ignoring the static files it produces as output. It's a common pattern to 339 | commit your Webpack source static files but ignore the compiled static files 340 | it produces. 341 | 342 | But if you're not using Webpack or another asset build tool then the static 343 | files that are a part of your project might have the same source and 344 | destination directory. If that's the case, chances are you'll want to git 345 | ignore the md5 tagged files as well as the compressed and `cache_manifest.json` 346 | files from version control. 347 | 348 | For clarity, you want to ignore them because you'll be generating them on your 349 | server at deploy time or within a Docker image if you're using Docker. They 350 | don't need to be tracked in version control. 351 | 352 | Add this to your `.gitignore` file to ignore certain files this extension 353 | creates: 354 | 355 | ``` 356 | *-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].* 357 | *.gz 358 | cache_manifest.json 359 | ``` 360 | 361 | This allows your original static files but ignores everything else this 362 | extension creates. I am aware at how ridiculous that ignore rule is for the md5 363 | hash but using `[0-9a-f]{32}` does not work. If you know of a better way, 364 | please open a PR! 365 | 366 | ## FAQ 367 | 368 | ### What about development vs production and performance implications? 369 | 370 | You would typically only run the CLI command to prepare your static files for 371 | production. Running `flask digest compile` would become a part of your build 372 | process -- typically after you pip install your dependencies. 373 | 374 | In development when the `cache_manifest.json` likely doesn't exist 375 | `static_url_for` calls `url_for` directly. This allows the `static_url_for` 376 | helper to work in both development and production without any fuss. 377 | 378 | It's also worth pointing out the CLI command is expected to be run before you 379 | even start your Flask server (or gunicorn / etc.), so there's no perceivable 380 | run time performance hit. It only involves doing 1 extra dictionary lookup at 381 | run time which is many orders of magnitude faster than even the most simple 382 | database query. 383 | 384 | **In other words, this extension is not going to negatively impact the 385 | performance of your web application. If anything it's going to speed it up and 386 | save you money on hosting**. 387 | 388 | That's because compressed files can be upwards of 5-10x smaller so there's less 389 | bytes to transfer over the network. 390 | 391 | Also with md5 tagging each file it means you can configure your web server such 392 | as nginx to cache each file forever. That means if a user visits your site a 393 | second time in the future, nginx will be smart enough to load it from their 394 | local browser's cache without even contacting your server. It's a 100% local 395 | look up. 396 | 397 | This is as efficient as it gets. You can't do this normally without md5 tagging 398 | each file because if the file changes in the future, nginx will continue 399 | serving the old file until the cache expires so users will never see your 400 | updates. But due to how md5 hashing works, if the contents of a file changes it 401 | will get generated with a new name and nginx will serve the uncached new file. 402 | 403 | This tactic is commonly referred to as "cache busting" and it's a very good 404 | idea to do this in production. You can even go 1 step further and serve your 405 | static files using a CDN. Using this cache busting strategy makes configuring 406 | your CDN a piece of cake since you don't need to worry about ever expiring your 407 | cache manually. 408 | 409 | ### Why bother compressing your static files here instead of with nginx? 410 | 411 | You would still be using nginx's gzip / brotli features, but now instead of 412 | nginx having to compress your files on the fly at run time you can configure 413 | nginx to use the pre-made compressed files that this extension creates. 414 | 415 | This way you can benefit from having maximum compression without having nginx 416 | waste precious CPU cycles compressing files on the fly. This gives you the best 417 | of both worlds -- the highest compression ratio with no noticeable run time 418 | performance penalty. 419 | 420 | ### How do you use this extension with Webpack or another build tool? 421 | 422 | It works out of the box with no extra configuration or plugins needed for 423 | Webpack or your build tool of choice. 424 | 425 | Typically the Webpack (or another build tool) work flow would look like this: 426 | 427 | - You configure Webpack with your source static files directory 428 | - You configure Webpack with your destination static files directory 429 | - Webpack processes your files in the source directory and copies them to the 430 | destination directory 431 | - Flask is configured to serve static files from that destination directory 432 | 433 | For example, your source directory might be `assets/` inside of your project 434 | and the destination might be `myapp/static`. 435 | 436 | This extension will look at your Flask configuration for the `static_folder` 437 | and determine it's set to `myapp/static` so it will md5 tag and compress those 438 | files. Your Webpack source files will not get digested and compressed. 439 | 440 | ### Migrating from Flask-Webpack 441 | 442 | [Flask-Webpack](https://github.com/nickjj/flask-webpack) is another extension I 443 | wrote a long time ago which was specific to Webpack but had a similar idea to 444 | this extension. Flask-Webpack is now deprecated in favor of 445 | Flask-Static-Digest. Migrating is fairly painless. There are a number of 446 | changes but on the bright side you get to delete more code than you add! 447 | 448 | #### Dependency / Flask app changes 449 | 450 | - Remove `Flask-Webpack` from `requirements.txt` 451 | - Remove all references to Flask-Webpack from your Flask app and config 452 | - Remove `manifest-revision-webpack-plugin` from `package.json` 453 | - Remove all references to this webpack plugin from your webpack config 454 | - Add `Flask-Static-Digest` to `requirements.txt` 455 | - Add the Flask-Static-Digest extension to your Flask app 456 | 457 | #### Jinja 2 template changes 458 | 459 | - Replace `stylesheet_tag('main_css') | safe` with `static_url_for('static', filename='css/main.css')` 460 | - Replace `javascript_tag('main_js') | safe` with `static_url_for('static', filename='js/main.js')` 461 | - Replace any occurrences of `asset_url_for('foo.png')` with `static_url_for('static', filename='images/foo.png')` 462 | 463 | ### How do you use this extension with Docker? 464 | 465 | It's really no different than without Docker, but instead of running `flask 466 | digest compile` on your server directly at deploy time you would run it inside 467 | of your Docker image at build time. This way your static files are already set 468 | up and ready to go by the time you pull and use your Docker image in 469 | production. 470 | 471 | You can see a fully working example of this in the open source version of my 472 | [Build a SAAS App with 473 | Flask](https://github.com/nickjj/build-a-saas-app-with-flask) course. It 474 | leverages Docker's build arguments to only compile the static files when 475 | `FLASK_ENV` is set to `production`. The key files to look at are the 476 | `Dockerfile`, `docker-compose.yml` and `.env` files. That wires up the build 477 | arguments and env variables to make it work. 478 | 479 | ### How do you use this extension with Heroku? 480 | 481 | If you're deploying to Heroku using the Python buildpack you can follow these 2 steps: 482 | 483 | 1. Create a `bin/post_compile` file in your project's source code 484 | 2. Copy the lines below into the `bin/post_compile` file, save it and commit the changes 485 | 486 | ```sh 487 | #!/usr/bin/env bash 488 | 489 | set -e 490 | 491 | echo "-----> Digesting static files" 492 | cd "${1}" && flask digest compile 493 | ``` 494 | 495 | The next time you push your code this script will run after your pip 496 | dependencies are installed. It will run before your slug is compiled which 497 | ensures that the digested files are available before any traffic is served to 498 | your Dyno. 499 | 500 | You can view how this file gets executed by Heroku in their [Python buildpack's 501 | source 502 | code](https://github.com/heroku/heroku-buildpack-python/blob/main/bin/steps/hooks/post_compile). 503 | 504 | ### What about user uploaded files? 505 | 506 | Let's say that besides having static files like your logo and CSS / JavaScript 507 | bundles you also have files uploaded by users. This could be things like a user 508 | avatar, blog post images or anything else. 509 | 510 | **You would still want to md5 tag and compress these files but now we've run 511 | into a situation**. The `flask digest compile` command is meant to be run at 512 | deploy time and it could potentially be run from your dev box, inside of a 513 | Docker image, on a CI server or your production server. In these cases you 514 | wouldn't have access to the user uploaded files. 515 | 516 | But at the same time you have users uploading files at run time. They are 517 | changing all the time. 518 | 519 | **Needless to say you can't use the `flask digest compile` command to digest 520 | user uploaded files**. The `cache_manifest.json` file should be reserved for 521 | files that exist in your code repo (such as your CSS / JS bundles, maybe a 522 | logo, fonts, etc.). 523 | 524 | The above files do not change at run time and align well with running the 525 | `flask digest compile` command at deploy time. 526 | 527 | For user uploaded content you wouldn't ever write these entries to the manifest 528 | JSON file. Instead, you would typically upload your files to disk, S3 or 529 | somewhere else and then save the file name of the file you uploaded into your 530 | local database. 531 | 532 | So now when you reference a user uploaded file (let's say an avatar), you would 533 | loop over your users from the database and reference the file name from the DB. 534 | 535 | There's no need for a manifest file to store the user uploaded files because 536 | the database has a reference to the real name and then you are dynamically 537 | referencing that in your template helper (`static_url_for`), so it's never a 538 | hard coded thing that changes at the template level. 539 | 540 | What's cool about this is you already did the database query to retrieve the 541 | record(s) from the database, so there's no extra database work to do. All you 542 | have to do is reference the file name field that's a part of your model. 543 | 544 | But that doesn't fully solve the problem. You'll still want to md5 tag and 545 | compress your user uploaded content at run time and you would want to do this 546 | before you save the uploaded file into its final destination (local file system, 547 | S3, etc.). 548 | 549 | This can be done completely separate from this extension and it's really going 550 | to vary depending on where you host your user uploaded content. For example some 551 | CDNs will automatically create compressed files for you and they use things like 552 | an ETag header in the response to include a unique file name (and this is what 553 | you can store in your DB). 554 | 555 | So maybe md5 hashing and maybe compressing your user uploaded content becomes an 556 | app specific responsibility, although I'm not opposed to maybe creating helper 557 | functions you can use but that would need to be thought out carefully. 558 | 559 | However the implementation is not bad. It's really only about 5 lines of code 560 | to do both things. Feel free to `CTRL + F` around the [code 561 | base](https://github.com/nickjj/flask-static-digest/blob/master/flask_static_digest/digester.py) 562 | for `hashlib`, `gzip` and `brotli` and you'll find the related code. 563 | 564 | **So with that said, here's a work flow you can do to deal with this today:** 565 | 566 | - User uploads file 567 | - Your Flask app potentially md5 tags / gzips the file if necessary 568 | - Your Flask app saves the file name + compressed file to its final destination (local file system, S3, etc.) 569 | - Your Flask app saves the final unique file name to your database 570 | 571 | That final unique file name would be the md5 tagged version of the file that 572 | you created or the unique file name that your CDN returned back to you. I hope 573 | that clears up how to deal with user uploaded files and efficiently serving 574 | them! 575 | 576 | ## About the author 577 | 578 | - Nick Janetakis | | [@nickjanetakis](https://twitter.com/nickjanetakis) 579 | 580 | If you're interested in learning Flask I have a 17+ hour video course called 581 | [Build a SAAS App with 582 | Flask](https://buildasaasappwithflask.com/?utm_source=github&utm_medium=staticdigest&utm_campaign=readme). 583 | It's a course where we build a real world SAAS app. Everything about the course 584 | and demo videos of what we build is on the site linked above. 585 | --------------------------------------------------------------------------------