~ubuntuone-pqm-team/whitenoise/stable

« back to all changes in this revision

Viewing changes to whitenoise/base.py

  • Committer: Simon Davy
  • Date: 2015-05-19 09:10:41 UTC
  • Revision ID: simon.davy@canonical.com-20150519091041-y8pgzy5ayhyp3q9m
importing whitenoise 1.0.6

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
from __future__ import absolute_import
 
2
 
 
3
from email.utils import parsedate, formatdate
 
4
import mimetypes
 
5
import os
 
6
import os.path
 
7
import re
 
8
from time import gmtime
 
9
from wsgiref.headers import Headers
 
10
 
 
11
 
 
12
class StaticFile(object):
 
13
 
 
14
    gzip_path = None
 
15
 
 
16
    def __init__(self, path):
 
17
        self.path = path
 
18
        self.headers = Headers([])
 
19
 
 
20
 
 
21
class WhiteNoise(object):
 
22
 
 
23
    BLOCK_SIZE = 16 * 4096
 
24
    GZIP_SUFFIX = '.gz'
 
25
    ACCEPT_GZIP_RE = re.compile(r'\bgzip\b')
 
26
    # All mimetypes starting 'text/' take a charset parameter, plus the
 
27
    # additions in this set
 
28
    MIMETYPES_WITH_CHARSET = frozenset((
 
29
        'application/javascript', 'application/xml'))
 
30
    # Ten years is what nginx sets a max age if you use 'expires max;'
 
31
    # so we'll follow its lead
 
32
    FOREVER = 10*365*24*60*60
 
33
 
 
34
    # Attributes that can be set by keyword args in the constructor
 
35
    config_attrs = ('max_age', 'allow_all_origins', 'charset')
 
36
    max_age = 60
 
37
    # Set 'Access-Control-Allow-Orign: *' header on all files.
 
38
    # As these are all public static files this is safe (See
 
39
    # http://www.w3.org/TR/cors/#security) and ensures that things (e.g
 
40
    # webfonts in Firefox) still work as expected when your static files are
 
41
    # served from a CDN, rather than your primary domain.
 
42
    allow_all_origins = True
 
43
    charset = 'utf-8'
 
44
 
 
45
    def __init__(self, application, root=None, prefix=None, **kwargs):
 
46
        for attr in self.config_attrs:
 
47
            try:
 
48
                setattr(self, attr, kwargs.pop(attr))
 
49
            except KeyError:
 
50
                pass
 
51
        if kwargs:
 
52
            raise TypeError("Unexpected keyword argument '{}'".format(
 
53
                list(kwargs.keys())[0]))
 
54
        self.application = application
 
55
        self.files = {}
 
56
        if root is not None:
 
57
            self.add_files(root, prefix)
 
58
 
 
59
    def __call__(self, environ, start_response):
 
60
        static_file = self.files.get(environ['PATH_INFO'])
 
61
        if static_file is None:
 
62
            return self.application(environ, start_response)
 
63
        else:
 
64
            return self.serve(static_file, environ, start_response)
 
65
 
 
66
    def serve(self, static_file, environ, start_response):
 
67
        method = environ['REQUEST_METHOD']
 
68
        if method != 'GET' and method != 'HEAD':
 
69
            start_response('405 Method Not Allowed', [('Allow', 'GET, HEAD')])
 
70
            return []
 
71
        if self.file_not_modified(static_file, environ):
 
72
            start_response('304 Not Modified', [])
 
73
            return []
 
74
        path, headers = self.get_path_and_headers(static_file, environ)
 
75
        start_response('200 OK', headers.items())
 
76
        if method == 'HEAD':
 
77
            return []
 
78
        file_wrapper = environ.get('wsgi.file_wrapper', self.yield_file)
 
79
        fileobj = open(path, 'rb')
 
80
        return file_wrapper(fileobj)
 
81
 
 
82
    def get_path_and_headers(self, static_file, environ):
 
83
        if static_file.gzip_path:
 
84
            if self.ACCEPT_GZIP_RE.search(environ.get('HTTP_ACCEPT_ENCODING', '')):
 
85
                return static_file.gzip_path, static_file.gzip_headers
 
86
        return static_file.path, static_file.headers
 
87
 
 
88
    def file_not_modified(self, static_file, environ):
 
89
        try:
 
90
            last_requested = environ['HTTP_IF_MODIFIED_SINCE']
 
91
        except KeyError:
 
92
            return False
 
93
        # Exact match, no need to parse
 
94
        if last_requested == static_file.headers['Last-Modified']:
 
95
            return True
 
96
        return parsedate(last_requested) >= static_file.mtime_tuple
 
97
 
 
98
    def yield_file(self, fileobj):
 
99
        # Only used as a fallback in case environ doesn't supply a
 
100
        # wsgi.file_wrapper
 
101
        try:
 
102
            while True:
 
103
                block = fileobj.read(self.BLOCK_SIZE)
 
104
                if block:
 
105
                    yield block
 
106
                else:
 
107
                    break
 
108
        finally:
 
109
            fileobj.close()
 
110
 
 
111
    def add_files(self, root, prefix=None, followlinks=False):
 
112
        prefix = (prefix or '').strip('/')
 
113
        prefix = '/{}/'.format(prefix) if prefix else '/'
 
114
        files = {}
 
115
        for dir_path, _, filenames in os.walk(root, followlinks=followlinks):
 
116
            for filename in filenames:
 
117
                file_path = os.path.join(dir_path, filename)
 
118
                url = prefix + os.path.relpath(file_path, root).replace('\\', '/')
 
119
                files[url] = self.get_static_file(file_path, url)
 
120
        self.find_gzipped_alternatives(files)
 
121
        self.files.update(files)
 
122
 
 
123
    def get_static_file(self, file_path, url):
 
124
        static_file = StaticFile(file_path)
 
125
        self.add_stat_headers(static_file, url)
 
126
        self.add_mime_headers(static_file, url)
 
127
        self.add_cache_headers(static_file, url)
 
128
        self.add_cors_headers(static_file, url)
 
129
        self.add_extra_headers(static_file, url)
 
130
        return static_file
 
131
 
 
132
    def add_stat_headers(self, static_file, url):
 
133
        stat = os.stat(static_file.path)
 
134
        static_file.mtime_tuple = gmtime(stat.st_mtime)
 
135
        static_file.headers['Last-Modified'] = formatdate(
 
136
                stat.st_mtime, usegmt=True)
 
137
        static_file.headers['Content-Length'] = str(stat.st_size)
 
138
 
 
139
    def add_mime_headers(self, static_file, url):
 
140
        mimetype, encoding = mimetypes.guess_type(static_file.path)
 
141
        mimetype = mimetype or 'application/octet-stream'
 
142
        charset = self.get_charset(mimetype, static_file, url)
 
143
        params = {'charset': charset} if charset else {}
 
144
        static_file.headers.add_header('Content-Type', mimetype, **params)
 
145
        if encoding:
 
146
            static_file.headers['Content-Encoding'] = encoding
 
147
 
 
148
    def get_charset(self, mimetype, static_file, url):
 
149
        if (mimetype.startswith('text/')
 
150
                or mimetype in self.MIMETYPES_WITH_CHARSET):
 
151
            return self.charset
 
152
 
 
153
    def add_cache_headers(self, static_file, url):
 
154
        if self.is_immutable_file(static_file, url):
 
155
            max_age = self.FOREVER
 
156
        else:
 
157
            max_age = self.max_age
 
158
        if max_age is not None:
 
159
            cache_control = 'public, max-age={}'.format(max_age)
 
160
            static_file.headers['Cache-Control'] = cache_control
 
161
 
 
162
    def is_immutable_file(self, static_file, url):
 
163
        """
 
164
        This should be implemented by sub-classes (see e.g. DjangoWhiteNoise)
 
165
        """
 
166
        return False
 
167
 
 
168
    def add_cors_headers(self, static_file, url):
 
169
        if self.allow_all_origins:
 
170
            static_file.headers['Access-Control-Allow-Origin'] = '*'
 
171
 
 
172
    def add_extra_headers(self, static_file, url):
 
173
        """
 
174
        This is provided as a hook for sub-classes, by default a no-op
 
175
        """
 
176
        pass
 
177
 
 
178
    def find_gzipped_alternatives(self, files):
 
179
        for url, static_file in files.items():
 
180
            gzip_url = url + self.GZIP_SUFFIX
 
181
            try:
 
182
                gzip_file = files[gzip_url]
 
183
            except KeyError:
 
184
                continue
 
185
            static_file.gzip_path = gzip_file.path
 
186
            static_file.headers['Vary'] = 'Accept-Encoding'
 
187
            # Copy the headers and add the appropriate encoding and length
 
188
            gzip_headers = Headers(static_file.headers.items())
 
189
            gzip_headers['Content-Encoding'] = 'gzip'
 
190
            gzip_headers['Content-Length'] = gzip_file.headers['Content-Length']
 
191
            static_file.gzip_headers = gzip_headers