├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── contrib └── sleepycalc │ ├── app.py │ └── templates │ └── index.html ├── flask_webcache ├── __init__.py ├── handlers.py ├── modifiers.py ├── recache.py ├── storage.py ├── utils.py └── validation.py ├── setup.py └── tests ├── requirements.txt ├── test_handler.py ├── test_modifiers.py ├── test_storage.py ├── test_validation.py └── testutils.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | dist 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | # command to install dependencies 6 | install: 7 | - "pip install . --use-mirrors" 8 | - "pip install -r tests/requirements.txt --use-mirrors" 9 | # command to run tests 10 | script: nosetests 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Fusic Ltd., Yaniv Aknin. 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flask-webcache 2 | 3 | [![Build Status](https://travis-ci.org/fusic-com/flask-webcache.png)](https://travis-ci.org/fusic-com/flask-webcache) 4 | 5 | 6 | A Flask extension that adds HTTP based caching to Flask apps. This extension aims for relatively strict\* and complete rfc2616 implementation. In other words, deviation from the standard in code or even terminology should be considered a bug and can be filed as such (see *Contribution*, below). For the purposes of flask-webcache related documentation (including and especially this document), a webcache means rfc2616 based and compliant caching. If you're not sure what HTTP based caching means, see *What's HTTP based caching*, below; it also explains how this package differs from many other caching related Flask extensions. 7 | 8 | \* Although practicality beats purity. 9 | 10 | ## Features 11 | 12 | 1. Set of decorators to facilitate settings webcache related headers (`Expires`, `Cache-Control`, etc) 13 | 2. Validation related post-request processing (automatic `ETag` headers, `304 NOT MODIFIED` responses, etc) 14 | 3. Werkzeug Cache based caching of responses (like a reverse proxy sitting in front of your app) 15 | 16 | ## Usage 17 | 18 | flask-webcache is typically made of two handlers, the `RequestHandler` and the `ResponseHandler` - each handling requests or responses. The request handler tries to fulfill responses from cache rather than invoking application code (as well as do some setting up). The response handler takes care of adding cache headers to the response (flask-webcache provides some helpers for that, see below), resource validation and response caching (based on the caching headers in the response). You can install both handlers together by calling `flask.ext.webcache.easy_setup()`, or use the mixin classes that make up the request and response handlers to use or customize specific functionality (fairly advanced use). 19 | 20 | For most serious websites you'd like to install handlers separately. When installed separately, the order of handler installation is important, as well as what is done between handler installations. There's no magic here - the RequestHandler installs a regular Flask [`@before_request`](http://flask.pocoo.org/docs/api/#flask.Flask.before_request) function and the ResponseHandler uses [`@after_request`](http://flask.pocoo.org/docs/api/#flask.Flask.after_request). This leads to a conceptual flow of "requests handled from top to bottom, responses from bottom to top". For example, here's installation order from a real website: 21 | 22 | import models ; models # must import models before we can init logging, so logging can count queries 23 | initialize_logging(settings) 24 | flask_web_cache.RequestHandler(cache, app, web_cache_config) 25 | import assets ; assets 26 | import auth ; auth 27 | import views ; views 28 | import api ; api 29 | import admin ; admin 30 | import system_views ; system_views 31 | flask_web_cache.ResponseHandler(cache, app, web_cache_config) 32 | install_gzip_compression(app, skip_contenttype=is_unlikely_to_compress_well) 33 | 34 | Without going into the specifics of extensions and hooks in this app, you will intuitively see that cached responses will be logged (because `RequestHandler` is installed "below" logging initialization and requests "come from above") and that cached responses will be stored after compression (because `ResponseHandler` is installed "above" gzip compression and responses "come from below"). 35 | 36 | You will note that the handlers are passed a `cache` object - this should be a [`werkzeug.contrib.cache`](http://werkzeug.pocoo.org/docs/contrib/cache/) based cache. `flask.ext.webcache.easy_setup()` will create a `SimpleCache` by default, but for anything serious you'll want to pass an instance of a better performing shared backend (like `RedisCache` or `MemcachedCache`). 37 | 38 | ### Configuration 39 | 40 | You can pass a `flask.ext.webcache.storage.Config` object to the handlers to change caching behaviour a bit. Parameters are passed as constructor keyword arguments to the `Config` object. While there's not much to be configured at this time, both options are fairly useful: 41 | 42 | * `resource_exemptions`: a set of URL prefixes for which no cache-storage will occur. If you're serving static files with Flask, you almost definitely want to pass your static URLs here. 43 | * `master_salt`: a serialized version of `flask.ext.webcache.storage.Metadata` is stored for every cached resource (if a single resource has more than one cached representation, just one metadata object is stored). This metadata contains the [selecting request-headers](http://tools.ietf.org/html/rfc2616#section-13.6) for that resource and a "salt". The salt is just a bit of randomness mixed into the keys in the cache namespace, making resource invalidation easy (just change the salt of the resource). The 'master salt' is another bit of randomness mixed into *every* resource, making *complete* cache invalidation easy - just change the master salt. By default, the master salt is regenerated every time the code is loaded when in debug mode - so if you're using the debug reloader, your cache is effectively flushed when you change your code. When debug is off, the master salt is fixed to an empty string and has no substantial use. 44 | * `request_controls_cache`: when this flag is False, request caching headers will be ignored (non-compliant!). 45 | 46 | ## What's HTTP based caching? 47 | 48 | HTTP has quite a few caching-related features, about which you can read in [this](http://www.mnot.net/cache_docs/) excellent introduction or in HTTP's actual specification ([rfc2616](http://www.ietf.org/rfc/rfc2616.txt)). Ultimately, these features help HTTP origin servers, proxies, gateways and user-agents that implement them know if a request can be served from cache or not. These features make it known what pieces of informations to store, under what conditions and for how long. Furthermore, these features allow user-agents to make conditional or partial requests, as well as allow servers to return partial or even entirely body-less responses. These features are typically used to make the web more performant and scalable, and more seldomly can sometimes be used to implement complex protocol logic (talking about conditional requests here). 49 | 50 | Contrast this description with typical 'application cache' solutions. These solutions allow the developer of a piece of software to identify bottlenecks in the program and speed them up by caching the results. For example, an expensive database query can be cached, so subsequent queries will appear faster at the cost of serving somewhat older data. While this can be a good approach in some scenarios, it's not without drawbacks. HTTP caching leverages the caching capabilities and potential worldwide distribution of proxies, gateways and user-agents, which application caches simply can not. Generally speaking, I believe well-designed web application and services should rely on HTTP caching and avoid application specific caching if practical. 51 | 52 | ## Contribution 53 | 54 | flask-webcache was written by Yaniv Aknin (`@aknin`) while working for [www.fusic.com](http://www.fusic.com), and is released as an independent BSD licensed extension maintained privately by Yaniv. If you'd like to contribute to the project, please follow these steps: 55 | 56 | 1. clone the repository (`git clone git://github.com/fusic-com/flask-webcache.git`; or clone from your fork) 57 | 2. create and activate a virtualenv (`virtualenv .venv && source .venv/bin/activate`) 58 | 3. install an editable version of the extension (`pip install -e .`) 59 | 4. install test requirements (`pip install -r tests/requirements.txt`) 60 | 5. run the tests (`nosetests`) 61 | 6. make modifications to code, docs and tests 62 | 7. re-run the tests (and see they pass) 63 | 8. push to github and send a pull request 64 | 65 | Naturally, contributions with a pull request are the best kind and most likely to be merged. But don't let that stop you from opening an issue if you aren't sure how to solve a particular problem or if you can't provide a pull request - just open an issue, these are highly appreciated too. As earlier mentioned and in particular, any deviation from rfc2616 should be reported as an issue. 66 | 67 | While obviously every Flask extensions relies to some extent on Flask, flask-webcache is especially reliant on Werkzeug, the terrific HTTP/WSGI swiss army knife by Armin Ronacher (also author of Flask). 68 | -------------------------------------------------------------------------------- /contrib/sleepycalc/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | sleepycalc - an Ajax based calculator meant to demonstrate flask-webcache 4 | 5 | sleepycalc is a simple webapp that lets you do basic addition calculations from the browser. The website's frontend 6 | component is a small HTML page with some Javascript to send a form to an HTTP endpoint and display the result. The 7 | website's backend is a single HTTP endpoint that receives two terms and returns their sum. So, for example, if 8 | your sleepycalc is running on localhost:5000, you could GET http://localhost:5000/addition?term1=5&term2=10 and 9 | get the result (15). 10 | 11 | The only "problem" with sleepycalc is that it sleeps for X milliseconds whenever result X is returned (so adding 12 | 10+5 will take 15 milliseconds, but 1000+500 will take 15 seconds). In order to speed things up, HTTP caching is 13 | used to get the cached response for calculations that were already made. 14 | 15 | As you can read below, we do the following things: 16 | - Create a werkzeug filesystem cache instance caching in /tmp/.sleepycalc 17 | (see http://werkzeug.pocoo.org/docs/contrib/cache/ for more useful cache implementations) 18 | - Use flask.ext.webcache.easy_setup to initialize webcache on our app 19 | flask-webcache requires the installation of two handlers: the RequestHandler and the ResponseHandlers. 20 | easy_setup will install both at once (try reading easy_setup()'s code, it's trivial); some complex scenario 21 | may want to insert a different handler (your own, or another extension's) between the request handler and 22 | the response handler. If you're not sure whether or not you need this, you probably don't. 23 | - Use the flask.ext.webcache.modifiers.cache_for decorator to add caching headers for /addition 24 | Once the extension is installed, it will automatically cache any response who'se headers permit caching. 25 | 26 | Consider /addition's headers before the extension: 27 | % http get localhost:5000/addition\?term1=400\&term2=600 28 | HTTP/1.0 200 OK 29 | Content-Length: 4 30 | Content-Type: text/plain 31 | Date: Wed, 25 Dec 2013 20:52:53 GMT 32 | Server: Werkzeug/0.9.4 Python/2.7.5+ 33 | 34 | 1000 35 | 36 | vs. after the extension: 37 | % http get localhost:5000/addition\?term1=400\&term2=600 38 | HTTP/1.0 200 OK 39 | Cache-Control: max-age=30 40 | Content-Length: 4 41 | Content-Type: text/plain 42 | Date: Wed, 25 Dec 2013 20:52:36 GMT 43 | ETag: "a9b7ba70783b617e9998dc4dd82eb3c5" 44 | Expires: Wed, 25 Dec 2013 20:53:06 GMT 45 | Last-Modified: Wed, 25 Dec 2013 20:52:36 GMT 46 | Server: Werkzeug/0.9.4 Python/2.7.5+ 47 | X-Cache: miss 48 | 49 | 1000 50 | """ 51 | from __future__ import division, unicode_literals 52 | from six.moves.http_client import BAD_REQUEST, OK 53 | from time import sleep 54 | 55 | from werkzeug.contrib.cache import FileSystemCache 56 | 57 | from flask import Flask, render_template, request 58 | from flask.ext.webcache import easy_setup, modifiers 59 | 60 | app = Flask(__name__) 61 | werkzeug_cache = FileSystemCache('/tmp/.sleepycalc') 62 | easy_setup(app, werkzeug_cache) 63 | 64 | PLAINTEXT = (('Content-Type', 'text/plain'),) 65 | 66 | @app.route("/") 67 | def index(): 68 | return render_template("index.html") 69 | 70 | @app.route("/addition") 71 | @modifiers.cache_for(seconds=30) 72 | def addition(): 73 | try: 74 | term1, term2 = int(request.args['term1']), int(request.args['term2']) 75 | except (KeyError, ValueError): 76 | return 'term1/term2 expected as integer query arguments', BAD_REQUEST, PLAINTEXT 77 | result = term1 + term2 78 | sleep(result/1000.0) 79 | return str(result), OK, PLAINTEXT 80 | 81 | if __name__ == '__main__': 82 | app.run(debug=True) 83 | -------------------------------------------------------------------------------- /contrib/sleepycalc/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | sleepycalc 6 | 7 | 8 | 26 | 27 | 28 |

sleepycalc

29 |
30 | + 31 | = 32 | 33 | 34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /flask_webcache/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from werkzeug.contrib.cache import SimpleCache 3 | 4 | from . import storage, validation, handlers, modifiers, utils, recache 5 | modifiers, validation, recache # silence pyflakes etc 6 | 7 | def easy_setup(app, cache=None): 8 | cache = cache or SimpleCache() 9 | config = storage.Config( 10 | resource_exemptions = ('/static/',), 11 | master_salt = utils.make_salt() if app.debug else '', 12 | ) 13 | handlers.RequestHandler(cache, app, config) 14 | handlers.ResponseHandler(cache, app, config) 15 | -------------------------------------------------------------------------------- /flask_webcache/handlers.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from flask import g 3 | 4 | from . import storage, validation, modifiers 5 | 6 | def register_extension(app, name, obj): 7 | if not hasattr(app, 'extensions'): 8 | app.extensions = {} 9 | app.extensions.setdefault('webcache', {})[name] = obj 10 | 11 | class RequestHandler(storage.Retrieval): 12 | def __init__(self, cache, app=None, config=None): 13 | super(RequestHandler, self).__init__(cache, config) 14 | if app is not None: 15 | self.init_app(app) 16 | def init_app(self, app): 17 | app.before_request(self.before_request) 18 | register_extension(app, 'request', self) 19 | def before_request(self): 20 | modifiers.setup_for_this_request() 21 | g.webcache_cached_response = False 22 | try: 23 | if self.should_fetch_response() and not self.is_exempt(): 24 | return self.fetch_response() 25 | except storage.CacheMiss: 26 | pass 27 | 28 | class ResponseHandler(validation.Validation, storage.Store): 29 | def __init__(self, cache, app=None, config=None): 30 | storage.Store.__init__(self, cache, config) 31 | if app is not None: 32 | self.init_app(app) 33 | def init_app(self, app): 34 | app.after_request(self.after_request) 35 | register_extension(app, 'response', self) 36 | def after_request(self, response): 37 | self.add_date_fields(response) 38 | for modifier in modifiers.after_request: 39 | modifier(response) 40 | if self.can_set_etag(response): 41 | self.set_etag(response) 42 | if self.if_none_match(response): 43 | return self.return_not_modified_response(response) or response 44 | if g.webcache_cached_response: 45 | return response 46 | if self.should_cache_response(response) and not self.is_exempt(): 47 | self.cache_response(response) 48 | self.mark_cache_miss(response) 49 | elif self.should_invalidate_resource(response): 50 | self.invalidate_resource() 51 | return response 52 | -------------------------------------------------------------------------------- /flask_webcache/modifiers.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from datetime import timedelta, datetime 3 | from functools import wraps 4 | from six import iteritems 5 | 6 | from flask import _request_ctx_stack 7 | from werkzeug.datastructures import ResponseCacheControl 8 | from werkzeug.local import LocalProxy 9 | 10 | after_request = LocalProxy(lambda: _request_ctx_stack.top.web_cache) 11 | 12 | def setup_for_this_request(): 13 | _request_ctx_stack.top.web_cache = [] 14 | 15 | class BaseModifier(object): 16 | def __call__(self, func): 17 | @wraps(func) 18 | def inner(*args, **kwargs): 19 | self.after_this_request() 20 | return func(*args, **kwargs) 21 | return inner 22 | def after_this_request(self): 23 | after_request.append(self.modify_response) 24 | def modify_response(self, response): 25 | pass 26 | 27 | class cache_for(BaseModifier): 28 | """A single modifier that sets both the Expires header and the max-age 29 | directive of the Cache-Control header""" 30 | def __init__(self, **durations): 31 | self.durations = durations 32 | def modify_response(self, response): 33 | delta = timedelta(**self.durations) 34 | response.cache_control.max_age = int(delta.total_seconds()) 35 | response.expires = datetime.utcnow() + delta 36 | 37 | class cache_control(BaseModifier): 38 | "Modifier that sets arbitrary Cache-Control directives" 39 | def __init__(self, **kwargs): 40 | for key, value in iteritems(kwargs): 41 | if not hasattr(ResponseCacheControl, key): 42 | raise TypeError('%s got an unexpected keyword argument %r' 43 | % (self.__class__.__name__, key)) 44 | self.kwargs = kwargs 45 | def modify_response(self, response): 46 | for key, value in iteritems(self.kwargs): 47 | setattr(response.cache_control, key, value) 48 | -------------------------------------------------------------------------------- /flask_webcache/recache.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from flask import request, current_app 3 | from werkzeug.http import Headers 4 | 5 | RECACHE_HEADER = 'X-Webcache-Recache' 6 | 7 | def get_dispatch_args(app_factory, salt): 8 | headers = Headers(request.headers) 9 | headers[RECACHE_HEADER] = salt 10 | return (app_factory, request.method, request.path, request.query_string, 11 | headers) 12 | 13 | def make_rq_dispatcher(queue=None, app_factory=None): 14 | if queue is None: 15 | from rq import Queue 16 | queue = Queue() 17 | def dispatcher(salt): 18 | args = get_dispatch_args(app_factory, salt) 19 | queue.enqueue_call(dispatch_request, args=args, result_ttl=0) 20 | return dispatcher 21 | 22 | def make_thread_dispatcher(app_factory=None): 23 | from threading import Thread 24 | def dispatcher(salt): 25 | args = get_dispatch_args(app_factory, salt) 26 | thread = Thread(target=dispatch_request, args=args) 27 | thread.start() 28 | return dispatcher 29 | 30 | PROGRAM = """import sys 31 | from pickle import load 32 | from flask.ext.webcache.recache import dispatch_request 33 | dispatch_request(*load(sys.stdin))""" 34 | def make_process_dispatcher(app_factory=None): 35 | from pickle import dumps 36 | import subprocess 37 | def dispatcher(salt): 38 | args = get_dispatch_args(app_factory, salt) 39 | process = subprocess.Popen( 40 | ['python', '-c', PROGRAM], 41 | stdin=subprocess.PIPE 42 | ) 43 | process.stdin.write(dumps(args)) 44 | process.stdin.close() 45 | return dispatcher 46 | 47 | def dispatch_request(app_factory, method, path, query_string, headers): 48 | app = app_factory() if callable(app_factory) else current_app 49 | app.test_client().open( 50 | method = method, 51 | path = path, 52 | query_string = query_string, 53 | headers = headers, 54 | ) 55 | -------------------------------------------------------------------------------- /flask_webcache/storage.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from datetime import datetime 3 | import hashlib 4 | 5 | from flask import request, g 6 | from werkzeug.datastructures import parse_set_header 7 | 8 | from .utils import (make_salt, effective_max_age, none_or_truthy, 9 | werkzeug_cache_get_or_add) 10 | from .recache import RECACHE_HEADER 11 | 12 | class CacheMiss(Exception): pass 13 | class NoResourceMetadata(CacheMiss): pass 14 | class NoMatchingRepresentation(CacheMiss): pass 15 | class NotFreshEnoughForClient(CacheMiss): pass 16 | class RecacheRequested(CacheMiss): pass 17 | 18 | class Config(object): 19 | def __init__(self, resource_exemptions=(), master_salt='', 20 | request_controls_cache=True, preemptive_recache_seconds=0, 21 | preemptive_recache_callback=None): 22 | self.resource_exemptions = resource_exemptions 23 | self.master_salt = master_salt 24 | self.request_controls_cache = request_controls_cache 25 | self.preemptive_recache_seconds = preemptive_recache_seconds 26 | self.preemptive_recache_callback = preemptive_recache_callback 27 | 28 | class Metadata(object): 29 | def __init__(self, vary, salt): 30 | self.vary = vary 31 | self.salt = salt 32 | def __getstate__(self): 33 | return self.salt + ':' + self.vary.to_header() 34 | def __setstate__(self, s): 35 | self.salt, vary = s.split(':', 1) 36 | self.vary = parse_set_header(vary) 37 | def __eq__(self, other): 38 | try: 39 | return self.vary.as_set() == other.vary.as_set() and self.salt == other.salt 40 | except AttributeError: 41 | return False 42 | 43 | class Base(object): 44 | X_CACHE_HEADER = 'X-Cache' 45 | CACHE_SEPARATOR = ':' 46 | DEFAULT_EXPIRATION_SECONDS = 300 47 | def __init__(self, cache, config=None): 48 | self.cache = cache 49 | self.config = config or Config() 50 | def request_path_and_query(self): 51 | if request.query_string: 52 | return '?'.join((request.path, request.query_string.decode('utf-8'))) 53 | return request.path 54 | def make_key(self, *bits): 55 | return self.CACHE_SEPARATOR.join(bits) 56 | def make_response_key(self, namespace, metadata): 57 | ctx = hashlib.md5() 58 | for header in metadata.vary: 59 | ctx.update((header + request.headers.get(header, '')).encode('utf-8')) 60 | ctx.update(metadata.salt.encode('utf-8')) 61 | ctx.update(self.config.master_salt.encode('utf-8')) 62 | return self.make_key(namespace, ctx.hexdigest(), 63 | self.request_path_and_query()) 64 | def metadata_cache_key(self): 65 | return self.make_key('metadata', self.request_path_and_query()) 66 | def response_cache_key(self, metadata): 67 | return self.make_response_key('representation', metadata) 68 | def recache_cache_key(self, metadata): 69 | return self.make_response_key('recache', metadata) 70 | def get_or_miss(self, key, exception): 71 | result = self.cache.get(key) 72 | if result is None: 73 | raise exception() 74 | return result 75 | def is_exempt(self): 76 | for prefix in self.config.resource_exemptions: 77 | if request.path.startswith(prefix): 78 | return True 79 | return False 80 | 81 | class Retrieval(Base): 82 | def should_fetch_response(self): 83 | if request.method not in {'GET', 'HEAD'}: 84 | return False 85 | if self.config.request_controls_cache: 86 | return ( 87 | 'no-cache' not in request.cache_control and 88 | 'no-cache' not in request.pragma and 89 | none_or_truthy(request.cache_control.max_age) 90 | ) 91 | # NOTES: "max-stale" is irrelevant; we expire stale representations 92 | # "no-transform" is irrelevant; we never transform 93 | # we are explicitly allowed to ignore "only-if-cached" (14.9.4) 94 | return True 95 | def fetch_metadata(self): 96 | key = self.metadata_cache_key() 97 | return self.get_or_miss(key, NoResourceMetadata) 98 | def fetch_response(self): 99 | metadata = self.fetch_metadata() 100 | if request.headers.get(RECACHE_HEADER) == metadata.salt: 101 | raise RecacheRequested() 102 | g.webcache_cache_metadata = metadata 103 | key = self.response_cache_key(metadata) 104 | response = self.get_or_miss(key, NoMatchingRepresentation) 105 | freshness = self.response_freshness_seconds(response) 106 | self.verify_response_freshness_or_miss(response, freshness) 107 | if self.should_recache_preemptively(freshness, metadata): 108 | self.config.preemptive_recache_callback(metadata.salt) 109 | g.webcache_cached_response = True 110 | return response 111 | def response_freshness_seconds(self, response): 112 | now = datetime.utcnow() # freeze time for identical comparisons 113 | if response.date: 114 | age = (now - response.date).total_seconds() 115 | else: 116 | age = None 117 | if 'max-age' in response.cache_control and age: 118 | rv = response.cache_control.max_age - age 119 | elif response.expires: 120 | rv = (response.expires - now).total_seconds() 121 | elif age: 122 | rv = self.DEFAULT_EXPIRATION_SECONDS - age 123 | else: 124 | rv = 0 # should never happen for cached responses 125 | return max(0, rv) 126 | def verify_response_freshness_or_miss(self, response, freshness): 127 | if not self.config.request_controls_cache: 128 | return 129 | if 'min-fresh' not in request.cache_control: 130 | return 131 | if freshness >= request.cache_control.min_fresh: 132 | return 133 | raise NotFreshEnoughForClient() 134 | def should_recache_preemptively(self, freshness, metadata): 135 | if self.config.preemptive_recache_callback is None: 136 | return False 137 | if self.config.preemptive_recache_seconds < freshness: 138 | return False 139 | key = self.recache_cache_key(metadata) 140 | if self.cache.get(key): 141 | return False 142 | salt = make_salt() 143 | self.cache.add(key, salt, self.config.preemptive_recache_seconds) 144 | if self.cache.get(key) != salt: 145 | return False 146 | return True 147 | 148 | class Store(Base): 149 | def should_cache_response(self, response): 150 | if (response.is_streamed or # theoretically possible yet unimplemented 151 | response._on_close or # _on_close hooks are often unpickleable 152 | request.method != "GET" or # arbitrarily seems safer to me 153 | str(response.status_code)[0] != '2' or # see 13.4 & 14.9.1 154 | '*' in response.vary): # see 14.44 155 | return False 156 | if (self.config.request_controls_cache and 157 | 'no-store' in request.cache_control): 158 | return False 159 | if 'cache-control' in response.headers: # see 14.9.1 160 | return ( 161 | 'private' not in response.cache_control and 162 | 'no-cache' not in response.cache_control and 163 | 'no-store' not in response.cache_control and 164 | none_or_truthy(effective_max_age(response)) 165 | ) 166 | # FIXME: we ignore field-specific "private" and "no-cache" :( 167 | if 'expires' in response.headers: # see 14.21 168 | return response.expires > datetime.utcnow() 169 | if request.args: 170 | return False # see 13.9 171 | return True 172 | def response_expiry_seconds(self, response): 173 | if response.cache_control.max_age is not None: 174 | return response.cache_control.max_age 175 | if response.expires: 176 | return (response.expires - datetime.utcnow()).total_seconds() 177 | return self.DEFAULT_EXPIRATION_SECONDS 178 | def get_or_create_metadata(self, response, expiry_seconds): 179 | try: 180 | return g.webcache_cache_metadata 181 | except AttributeError: 182 | new = Metadata(response.vary, make_salt()) 183 | key = self.metadata_cache_key() 184 | return werkzeug_cache_get_or_add(self.cache, key, new, 185 | expiry_seconds) 186 | def store_response(self, metadata, response, expiry_seconds): 187 | key = self.response_cache_key(metadata) 188 | response.freeze() 189 | self.cache.set(key, response, expiry_seconds) 190 | def cache_response(self, response): 191 | expiry_seconds = self.response_expiry_seconds(response) 192 | metadata = self.get_or_create_metadata(response, expiry_seconds) 193 | # TODO: warn when metadata.vary != response.vary? 194 | self.mark_cache_hit(response) 195 | self.store_response(metadata, response, expiry_seconds) 196 | self.delete_recache_key(metadata) 197 | def delete_recache_key(self, metadata): 198 | self.cache.delete(self.recache_cache_key(metadata)) 199 | def mark_cache_hit(self, response): 200 | if self.X_CACHE_HEADER: 201 | response.headers[self.X_CACHE_HEADER] = 'hit' 202 | def mark_cache_miss(self, response): 203 | if self.X_CACHE_HEADER: 204 | response.headers[self.X_CACHE_HEADER] = 'miss' 205 | def should_invalidate_resource(self, response): 206 | if response.status[0] not in '23': 207 | return False 208 | if request.method in {"GET", "HEAD"}: 209 | return False 210 | return True # see 13.10 211 | def invalidate_resource(self): 212 | self.cache.delete(self.metadata_cache_key()) 213 | -------------------------------------------------------------------------------- /flask_webcache/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from random import getrandbits 3 | 4 | def make_salt(bits=128): 5 | return hex(getrandbits(bits)) 6 | 7 | def effective_max_age(response): 8 | if response.cache_control.s_maxage is not None: 9 | return response.cache_control.s_maxage 10 | if response.cache_control.max_age is not None: 11 | return response.cache_control.max_age 12 | return None 13 | 14 | def none_or_truthy(v): 15 | if v is None: 16 | return True 17 | return bool(v) 18 | 19 | def werkzeug_cache_get_or_add(cache, key, new_obj, expiry_seconds): 20 | stored_obj = None 21 | while stored_obj is None: 22 | cache.add(key, new_obj, expiry_seconds) 23 | stored_obj = cache.get(key) 24 | return stored_obj 25 | -------------------------------------------------------------------------------- /flask_webcache/validation.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from datetime import datetime 3 | 4 | from six.moves.http_client import NOT_IMPLEMENTED, NOT_MODIFIED, OK 5 | import hashlib 6 | 7 | from flask import request, g 8 | 9 | class Validation(object): 10 | def can_set_etag(self, response): 11 | return not ( 12 | response.is_streamed or 13 | 'etag' in response.headers or 14 | response.status_code != OK 15 | ) 16 | def set_etag(self, response): 17 | response.set_etag(hashlib.md5(response.data).hexdigest()) 18 | 19 | def if_none_match(self, response): 20 | if response.status[0] != '2' and response.status_code != NOT_MODIFIED: 21 | return False 22 | etag, weak = response.get_etag() 23 | return etag in request.if_none_match 24 | def return_not_modified_response(self, response): 25 | if request.method not in {"GET", "HEAD"}: 26 | # HACK: RFC says we MUST NOT have performed this method, but it was 27 | # just performed and there's nothing we can do about it. 28 | # To handle this request correctly, the application logic 29 | # of this app should have made the if-none-match check and 30 | # abort() with 412 PRECONDITION_FAILED. 31 | # Not sure what to do, I'm opting to return the 5xx status 32 | # 501 NOT_IMPLEMENTED. Meh. 33 | if not hasattr(g, 'webcache_ignore_if_none_match'): 34 | response.status_code = NOT_IMPLEMENTED 35 | return 36 | response.data = '' 37 | response.status_code = NOT_MODIFIED 38 | def add_date_fields(self, response): 39 | now = datetime.utcnow() # freeze time for identical dates 40 | if 'last-modified' not in response.headers: 41 | response.last_modified = now 42 | if 'date' not in response.headers: 43 | response.date = now 44 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flask-WebCache 3 | ------------- 4 | 5 | A Flask extension that adds HTTP based caching to Flask apps. 6 | """ 7 | from setuptools import setup 8 | 9 | setup( 10 | name='Flask-WebCache', 11 | version='0.9.1', 12 | url='http://github.com/fusic-com/flask-webcache/', 13 | license='BSD', 14 | author='Yaniv Aknin', 15 | author_email='yaniv@aknin.name', 16 | description='A Flask extension that adds HTTP based caching to Flask apps', 17 | long_description=__doc__, 18 | packages=['flask_webcache'], 19 | zip_safe=False, 20 | include_package_data=True, 21 | platforms='any', 22 | install_requires=[ 23 | 'Flask', 24 | 'six' 25 | ], 26 | classifiers=[ 27 | 'Environment :: Web Environment', 28 | 'Intended Audience :: Developers', 29 | 'License :: OSI Approved :: BSD License', 30 | 'Operating System :: OS Independent', 31 | 'Programming Language :: Python :: 2.7', 32 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 33 | 'Topic :: Software Development :: Libraries :: Python Modules', 34 | 'Development Status :: 4 - Beta' 35 | ] 36 | ) 37 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | nose==1.2.1 2 | -------------------------------------------------------------------------------- /tests/test_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import unittest 3 | from six.moves.http_client import NOT_MODIFIED 4 | 5 | from flask import Flask 6 | from flask_webcache import easy_setup 7 | 8 | class HandlerTestCase(unittest.TestCase): 9 | 10 | def setUp(self): 11 | self.a = Flask(__name__) 12 | easy_setup(self.a) 13 | @self.a.route('/foo') 14 | def foo(): 15 | return 'bar' 16 | 17 | def test_full_cycle(self): 18 | first = self.a.test_client().get('/foo') 19 | second = self.a.test_client().get('/foo') 20 | self.assertIn('x-cache', first.headers) 21 | self.assertIn('x-cache', second.headers) 22 | self.assertEquals(first.headers['x-cache'], 'miss') 23 | self.assertEquals(second.headers['x-cache'], 'hit') 24 | self.assertEquals(first.data, second.data) 25 | 26 | def test_not_modified_cached_response(self): 27 | first = self.a.test_client().get('/foo') 28 | self.assertIn('etag', first.headers) 29 | second = self.a.test_client().get('/foo', headers=(("if-none-match", first.headers['etag']),)) 30 | self.assertEquals(second.status_code, NOT_MODIFIED) 31 | -------------------------------------------------------------------------------- /tests/test_modifiers.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from datetime import datetime, timedelta 3 | import unittest 4 | 5 | from werkzeug.wrappers import Response 6 | from flask_webcache.modifiers import cache_for, cache_control 7 | 8 | from testutils import compare_datetimes 9 | 10 | class ModifiersTestCase(unittest.TestCase): 11 | 12 | def test_cache_for(self): 13 | m = cache_for(minutes=5) 14 | r = Response() 15 | m.modify_response(r) 16 | self.assertTrue(compare_datetimes(r.expires, datetime.utcnow() + timedelta(minutes=5))) 17 | 18 | def test_two_cache_fors(self): 19 | m1 = cache_for(minutes=5) 20 | m2 = cache_for(minutes=3) 21 | r = Response() 22 | m1.modify_response(r) 23 | m2.modify_response(r) 24 | self.assertTrue(compare_datetimes(r.expires, datetime.utcnow() + timedelta(minutes=3))) 25 | 26 | def test_cache_control(self): 27 | m = cache_control(public=True) 28 | r = Response() 29 | m.modify_response(r) 30 | self.assertTrue(r.cache_control.public) 31 | 32 | def test_bad_cache_control(self): 33 | with self.assertRaises(TypeError): 34 | cache_control(foo=True) 35 | 36 | def test_additive_cache_control(self): 37 | m = cache_control(public=True) 38 | r = Response() 39 | r.cache_control.no_transform=True 40 | m.modify_response(r) 41 | self.assertTrue(r.cache_control.public) 42 | self.assertIn('no-transform', r.cache_control) 43 | 44 | def test_overriding_cache_control(self): 45 | m = cache_control(public=True) 46 | r = Response() 47 | r.cache_control.public=False 48 | m.modify_response(r) 49 | self.assertTrue(r.cache_control.public) 50 | -------------------------------------------------------------------------------- /tests/test_storage.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import unittest 3 | from datetime import timedelta, datetime 4 | from six.moves.cPickle import dumps, loads 5 | from six import iteritems 6 | 7 | from flask import Flask, send_file 8 | from werkzeug.wrappers import Response 9 | from werkzeug.datastructures import HeaderSet 10 | from werkzeug.contrib.cache import SimpleCache 11 | from flask_webcache.storage import Config, Metadata, Store, Retrieval 12 | from flask_webcache.storage import (CacheMiss, NoResourceMetadata, NoMatchingRepresentation, NotFreshEnoughForClient, 13 | RecacheRequested) 14 | from flask_webcache.recache import RECACHE_HEADER 15 | from flask_webcache.utils import werkzeug_cache_get_or_add 16 | 17 | from testutils import compare_numbers 18 | a = Flask(__name__) 19 | 20 | class UtilsTestCase(unittest.TestCase): 21 | 22 | def test_config_kwargs(self): 23 | with self.assertRaises(TypeError): 24 | Config(foo=1) 25 | 26 | def test_metadata_datastructure(self): 27 | def check_metadata(m): 28 | self.assertEquals(m.salt, 'qux') 29 | self.assertIn('foo', m.vary) 30 | self.assertIn('bar', m.vary) 31 | m = Metadata(HeaderSet(('foo', 'bar')), 'qux') 32 | check_metadata(m) 33 | check_metadata(loads(dumps(m))) 34 | m2 = Metadata(HeaderSet(('foo', 'bar')), 'qux') 35 | self.assertEquals(m, m2) 36 | m3 = Metadata(HeaderSet(('foo', 'bar')), 'notqux') 37 | self.assertNotEquals(m2, m3) 38 | 39 | class StorageTestCase(unittest.TestCase): 40 | 41 | def setUp(self): 42 | self.c = SimpleCache() 43 | self.s = Store(self.c) 44 | self.r = Retrieval(self.c) 45 | 46 | def test_basic_cachability(self): 47 | with a.test_request_context('/foo'): 48 | self.assertFalse(self.s.should_cache_response(Response(x for x in 'foo'))) 49 | self.assertTrue(self.s.should_cache_response(Response(status=204))) 50 | self.assertFalse(self.s.should_cache_response(Response(status=500))) 51 | self.assertTrue(self.s.should_cache_response(Response('foo'))) 52 | self.assertTrue(self.s.should_cache_response(Response())) 53 | r = Response() 54 | r.vary.add('*') 55 | self.assertFalse(self.s.should_cache_response(r)) 56 | with a.test_request_context('/foo', method='HEAD'): 57 | self.assertFalse(self.s.should_cache_response(Response('foo'))) 58 | with a.test_request_context('/foo', method='POST'): 59 | self.assertFalse(self.s.should_cache_response(Response('foo'))) 60 | 61 | def test_cache_control_cachability(self): 62 | def check_response_with_cache_control(**cc): 63 | r = Response() 64 | for k, v in iteritems(cc): 65 | setattr(r.cache_control, k, v) 66 | return self.s.should_cache_response(r) 67 | with a.test_request_context(): 68 | self.assertTrue(check_response_with_cache_control(max_age=10)) 69 | self.assertTrue(check_response_with_cache_control(must_revalidate=True)) 70 | self.assertFalse(check_response_with_cache_control(max_age=0)) 71 | self.assertFalse(check_response_with_cache_control(private=True)) 72 | self.assertFalse(check_response_with_cache_control(no_cache=True)) 73 | self.assertFalse(check_response_with_cache_control(no_store=True)) 74 | 75 | def test_expire_cachability(self): 76 | def check_response_with_expires(dt): 77 | r = Response() 78 | r.expires = dt 79 | return self.s.should_cache_response(r) 80 | with a.test_request_context(): 81 | self.assertFalse(check_response_with_expires(datetime.utcnow() - timedelta(seconds=1))) 82 | self.assertTrue(check_response_with_expires(datetime.utcnow() + timedelta(seconds=1))) 83 | 84 | def test_default_cachability(self): 85 | with a.test_request_context('/foo'): 86 | self.assertTrue(self.s.should_cache_response(Response())) 87 | with a.test_request_context('/foo', query_string='?bar'): 88 | self.assertFalse(self.s.should_cache_response(Response())) 89 | 90 | def test_x_cache_headers(self): 91 | r = Response() 92 | self.s.mark_cache_hit(r) 93 | self.assertEquals(r.headers[self.s.X_CACHE_HEADER], 'hit') 94 | self.s.mark_cache_miss(r) 95 | self.assertEquals(r.headers[self.s.X_CACHE_HEADER], 'miss') 96 | 97 | def test_metadata_miss(self): 98 | with self.assertRaises(NoResourceMetadata): 99 | with a.test_request_context('/foo'): 100 | self.r.fetch_metadata() 101 | 102 | def test_response_miss(self): 103 | with self.assertRaises(NoResourceMetadata): 104 | with a.test_request_context('/foo'): 105 | self.r.fetch_response() 106 | 107 | def test_store_retrieve_cycle(self): 108 | with a.test_request_context('/foo'): 109 | r = Response('foo') 110 | self.s.cache_response(r) 111 | self.assertEquals(len(self.c._cache), 2) 112 | r2 = self.r.fetch_response() 113 | self.assertEquals(r.data, r2.data) 114 | 115 | def test_vary_miss(self): 116 | with a.test_request_context('/foo', headers=(('accept-encoding', 'gzip'),)): 117 | r = Response('foo') 118 | r.vary.add('accept-encoding') 119 | r.content_encoding = 'gzip' 120 | self.s.cache_response(r) 121 | with self.assertRaises(NoMatchingRepresentation): 122 | with a.test_request_context('/foo'): 123 | self.r.fetch_response() 124 | 125 | def test_invalidation_condition(self): 126 | with a.test_request_context('/foo', method="PUT"): 127 | r = Response('foo') 128 | self.assertTrue(self.s.should_invalidate_resource(r)) 129 | r = Response('foo', status=500) 130 | self.assertFalse(self.s.should_invalidate_resource(r)) 131 | with a.test_request_context('/foo'): 132 | r = Response('foo') 133 | self.assertFalse(self.s.should_invalidate_resource(r)) 134 | 135 | def test_invalidation(self): 136 | with a.test_request_context('/foo'): 137 | r = Response('foo') 138 | self.s.cache_response(r) 139 | self.assertEquals(len(self.c._cache), 2) 140 | with a.test_request_context('/foo', method="PUT"): 141 | r = Response('foo') 142 | self.assertTrue(self.s.should_invalidate_resource(r)) 143 | self.s.invalidate_resource() 144 | self.assertEquals(len(self.c._cache), 1) 145 | with self.assertRaises(CacheMiss): 146 | with a.test_request_context('/foo'): 147 | self.r.fetch_response() 148 | 149 | def test_master_salt_invalidation(self): 150 | with a.test_request_context('/foo'): 151 | r = Response('foo') 152 | self.s.cache_response(r) 153 | self.assertEquals(self.r.fetch_response().data, b'foo') 154 | self.r.config.master_salt = 'newsalt' 155 | with self.assertRaises(NoMatchingRepresentation): 156 | self.r.fetch_response() 157 | 158 | def test_request_cache_controls(self): 159 | with a.test_request_context('/foo'): 160 | self.assertTrue(self.r.should_fetch_response()) 161 | with a.test_request_context('/foo', method='HEAD'): 162 | self.assertTrue(self.r.should_fetch_response()) 163 | with a.test_request_context('/foo', method='POST'): 164 | self.assertFalse(self.r.should_fetch_response()) 165 | with a.test_request_context('/foo', headers=(('cache-control', 'no-cache'),)): 166 | self.assertFalse(self.r.should_fetch_response()) 167 | with a.test_request_context('/foo', headers=(('pragma', 'no-cache'),)): 168 | self.assertFalse(self.r.should_fetch_response()) 169 | with a.test_request_context('/foo', headers=(('cache-control', 'max-age=0'),)): 170 | self.assertFalse(self.r.should_fetch_response()) 171 | with a.test_request_context('/foo', headers=(('cache-control', 'max-age=5'),)): 172 | self.assertTrue(self.r.should_fetch_response()) 173 | 174 | def test_response_freshness_seconds(self): 175 | # this test is raced; if running it takes about a second, it might fail 176 | r = Response() 177 | self.assertEquals(0, self.r.response_freshness_seconds(r)) 178 | r.date = datetime.utcnow() 179 | self.assertTrue(compare_numbers(self.s.DEFAULT_EXPIRATION_SECONDS, 180 | self.r.response_freshness_seconds(r), 181 | 1)) 182 | r.expires = datetime.utcnow() + timedelta(seconds=345) 183 | self.assertTrue(compare_numbers(345, self.r.response_freshness_seconds(r), 1)) 184 | r.cache_control.max_age=789 185 | self.assertTrue(compare_numbers(789, self.r.response_freshness_seconds(r), 1)) 186 | 187 | def test_min_fresh(self): 188 | # this test is raced; if running it takes about a second, it might fail 189 | r = Response() 190 | r.date = datetime.utcnow() - timedelta(seconds=100) 191 | r.cache_control.max_age = 200 192 | f = self.r.response_freshness_seconds(r) 193 | with a.test_request_context('/foo', headers=(('cache-control', 'min-fresh=50'),)): 194 | try: 195 | self.r.verify_response_freshness_or_miss(r, f) 196 | except CacheMiss: 197 | self.fail('unexpected CacheMiss on reasonably fresh response') 198 | with a.test_request_context('/foo', headers=(('cache-control', 'min-fresh=150'),)): 199 | self.assertRaises(NotFreshEnoughForClient, self.r.verify_response_freshness_or_miss, r, f) 200 | 201 | def test_request_cache_control_disobedience(self): 202 | c = SimpleCache() 203 | cfg = Config(request_controls_cache=False) 204 | s = Store(c, cfg) 205 | r = Retrieval(c, cfg) 206 | with a.test_request_context('/foo', headers=(('cache-control', 'no-store'),)): 207 | self.assertTrue(r.should_fetch_response()) 208 | with a.test_request_context('/foo', headers=(('cache-control', 'no-store'),)): 209 | self.assertTrue(s.should_cache_response(Response())) 210 | with a.test_request_context('/foo', headers=(('cache-control', 'no-store'),)): 211 | self.assertTrue(s.should_cache_response(Response())) 212 | resp = Response() 213 | resp.date = datetime.utcnow() - timedelta(seconds=100) 214 | resp.cache_control.max_age = 200 215 | with a.test_request_context('/foo', headers=(('cache-control', 'min-fresh=150'),)): 216 | f = self.r.response_freshness_seconds(resp) 217 | try: 218 | r.verify_response_freshness_or_miss(resp, f) 219 | except CacheMiss: 220 | self.fail('unexpected CacheMiss when ignoring request cache control') 221 | 222 | def test_sequence_converted_responses(self): 223 | with a.test_request_context('/foo'): 224 | r = Response(f for f in 'foo') 225 | r.make_sequence() 226 | self.assertFalse(self.s.should_cache_response(r)) 227 | r = send_file(__file__) 228 | r.make_sequence() 229 | self.assertFalse(self.s.should_cache_response(r)) 230 | 231 | class RecacheTestCase(unittest.TestCase): 232 | 233 | def setUp(self): 234 | self.recached = False 235 | def dispatcher(salt): 236 | self.recached = True 237 | self.c = SimpleCache() 238 | cfg = Config(preemptive_recache_seconds=10, preemptive_recache_callback=dispatcher) 239 | self.s = Store(self.c, cfg) 240 | self.r = Retrieval(self.c, cfg) 241 | 242 | def test_preemptive_recaching_predicate(self): 243 | m = Metadata(HeaderSet(('foo', 'bar')), 'qux') 244 | def mkretr(**kwargs): 245 | return Retrieval(self.c, Config(**kwargs)) 246 | with a.test_request_context('/'): 247 | self.assertFalse(mkretr(preemptive_recache_seconds=10).should_recache_preemptively(10, m)) 248 | self.assertFalse(mkretr(preemptive_recache_callback=lambda x: 0).should_recache_preemptively(10, m)) 249 | self.assertFalse(self.r.should_recache_preemptively(11, m)) 250 | self.assertTrue(self.r.should_recache_preemptively(10, m)) 251 | self.assertFalse(self.r.should_recache_preemptively(10, m)) 252 | self.c.clear() 253 | self.assertTrue(self.r.should_recache_preemptively(10, m)) 254 | 255 | def test_preemptive_recaching_cache_bypass(self): 256 | fresh = Response('foo') 257 | with a.test_request_context('/foo'): 258 | self.s.cache_response(fresh) 259 | metadata = self.r.fetch_metadata() 260 | with a.test_request_context('/foo'): 261 | cached = self.r.fetch_response() 262 | self.assertEquals(cached.headers[self.r.X_CACHE_HEADER], 'hit') 263 | with a.test_request_context('/foo', headers={RECACHE_HEADER: metadata.salt}): 264 | self.assertRaises(RecacheRequested, self.r.fetch_response) 265 | with a.test_request_context('/foo', headers={RECACHE_HEADER: 'incorrect-salt'}): 266 | try: 267 | self.r.fetch_response() 268 | except RecacheRequested: 269 | self.fail('unexpected RecacheRequested for incorrect salt') 270 | 271 | class UtilityTestCase(unittest.TestCase): 272 | 273 | def setUp(self): 274 | self.c = SimpleCache() 275 | 276 | def test_werkzeug_cache_get_or_add_missing_key(self): 277 | self.assertEquals('bar', werkzeug_cache_get_or_add(self.c, 'foo', 'bar', 10)) 278 | 279 | def test_werkzeug_cache_get_or_add_existing_key(self): 280 | self.c.set('foo', 'bar') 281 | self.assertEquals('bar', werkzeug_cache_get_or_add(self.c, 'foo', 'qux', 10)) 282 | -------------------------------------------------------------------------------- /tests/test_validation.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from datetime import datetime 3 | import unittest 4 | 5 | from flask import Flask 6 | from werkzeug.wrappers import Response 7 | from flask_webcache.validation import Validation 8 | 9 | a = Flask(__name__) 10 | v = Validation() 11 | 12 | from testutils import compare_datetimes 13 | 14 | class ValidationTestCase(unittest.TestCase): 15 | 16 | def test_cant_set_etag(self): 17 | self.assertFalse(v.can_set_etag(Response(x for x in 'foo'))) 18 | self.assertFalse(v.can_set_etag(Response(headers={"ETag": "foo"}))) 19 | self.assertFalse(v.can_set_etag(Response(status=500))) 20 | 21 | def test_can_set_etag(self): 22 | self.assertTrue(v.can_set_etag(Response('foo'))) 23 | self.assertTrue(v.can_set_etag(Response(headers={"Server": "foo"}))) 24 | self.assertTrue(v.can_set_etag(Response(status=200))) 25 | 26 | def test_set_etag(self): 27 | r = Response('foo') 28 | v.set_etag(r) 29 | self.assertEquals(r.headers['etag'], '"acbd18db4cc2f85cedef654fccc4a4d8"') 30 | 31 | def test_if_none_match(self): 32 | r = Response() 33 | with a.test_request_context(headers=[("if-none-match", '"foo"')]): 34 | r.set_etag('foo') 35 | self.assertTrue(v.if_none_match(r)) 36 | r.status_code = 400 37 | self.assertFalse(v.if_none_match(r)) 38 | r = Response() 39 | r.set_etag('bar') 40 | self.assertFalse(v.if_none_match(r)) 41 | 42 | def test_not_modified(self): 43 | r = Response('foo') 44 | with a.test_request_context(): 45 | v.return_not_modified_response(r) 46 | self.assertEquals(r.data, b'') 47 | self.assertEquals(r.status_code, 304) 48 | 49 | def test_not_modified_failure(self): 50 | r = Response('foo') 51 | with a.test_request_context(method='PUT'): 52 | v.return_not_modified_response(r) 53 | self.assertEquals(r.data, b'foo') 54 | self.assertEquals(r.status_code, 501) 55 | 56 | def test_date_addition(self): 57 | r = Response() 58 | v.add_date_fields(r) 59 | self.assertTrue(compare_datetimes(r.last_modified, datetime.utcnow())) 60 | self.assertTrue(compare_datetimes(r.date, datetime.utcnow())) 61 | self.assertEquals(r.last_modified, r.date) 62 | 63 | def test_date_no_clobber(self): 64 | r = Response() 65 | r.date = 0 66 | r.last_modified = 0 67 | v.add_date_fields(r) 68 | self.assertEquals(r.date.year, 1970) 69 | self.assertEquals(r.last_modified.year, 1970) 70 | -------------------------------------------------------------------------------- /tests/testutils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from datetime import timedelta 3 | 4 | def compare_datetimes(this, other, max_seconds_difference=1): 5 | if this == other: 6 | return True 7 | delta = timedelta(seconds=max_seconds_difference) 8 | return this + delta > other and this - delta < other 9 | 10 | def compare_numbers(this, other, max_difference): 11 | if this == other: 12 | return True 13 | return abs(this - other) < max_difference 14 | --------------------------------------------------------------------------------