├── .gitignore ├── MANIFEST.in ├── Makefile ├── README.md ├── dev-requirements.txt ├── entrypoints.conf ├── requirements.txt ├── setup.py ├── static ├── VERSION ├── __init__.py ├── apps.py └── cli.py └── tests ├── __init__.py ├── data ├── noindex │ └── static.html ├── prezip │ ├── nogzipversionpresent.txt │ ├── static.txt │ └── static.txt.gz ├── templates │ ├── foo.css.mst │ ├── index.html.stp │ ├── safe.sstp │ ├── static.txt │ └── unsafe.stp └── withindex │ ├── index.html │ ├── static.html │ └── subdir │ └── index.html └── test_behaviors.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | */*.pyc 4 | */*/*.pyc 5 | .virts 6 | .coverage 7 | build 8 | dist 9 | *.tar.gz 10 | *.log 11 | coverage.xml 12 | *.egg-info 13 | .#* 14 | coverage-html 15 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include */VERSION README.md requirements.txt entrypoints.conf 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | pyversion=2.7 2 | export 3 | clean: 4 | rm coverage.xml -f 5 | rm .coverage -f 6 | rm full-test-coverage-html -rf 7 | rm .virts -rf 8 | develop: 9 | mkdir -p .virts/ 10 | virtualenv .virts/dev 11 | . .virts/dev/bin/activate && pip install -i 'http://pypi.python.org/simple' -r dev-requirements.txt > /dev/null 12 | . .virts/dev/bin/activate && pip install -e '.' > /dev/null 13 | test-python: 14 | virtualenv .virts/$(pyversion) --python=python$(pyversion) 15 | . .virts/$(pyversion)/bin/activate && pip install -i 'http://pypi.python.org/simple' -r dev-requirements.txt > /dev/null 16 | . .virts/$(pyversion)/bin/activate && pip install -e '.' > /dev/null 17 | . .virts/$(pyversion)/bin/activate && nosetests tests/ 18 | stylecheck: 19 | . .virts/dev/bin/activate && flake8 static tests --max-complexity=14 20 | test: 21 | . .virts/dev/bin/activate && nosetests -xs --with-coverage --cover-package static --cover-html --cover-html-dir coverage-html tests/ 22 | viewcoverage: 23 | . .virts/dev/bin/activate && static localhost 6897 coverage-html 24 | fulltest: clean develop test stylecheck 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # static 2 | 3 | Serve static or templated content via WSGI or stand-alone. 4 | 5 | Install Static 6 | 7 | $ pip install static 8 | 9 | Serve up some content: 10 | 11 | $ static localhost 9999 static-content/ 12 | 13 | Or in the context of a WSGI application: 14 | 15 | ```python 16 | import static 17 | wsgi_app = static.Cling('/var/www') 18 | ``` 19 | 20 | You can also use Python template strings, Moustache templates or 21 | easily roll your own templating plugin. See the tests and source 22 | code for examples. 23 | 24 | Pull requests welcome. Happy hacking. 25 | 26 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | nose 2 | nose-cov 3 | wsgi-intercept 4 | flake8 5 | -------------------------------------------------------------------------------- /entrypoints.conf: -------------------------------------------------------------------------------- 1 | [console_scripts] 2 | static = static.cli:run 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pystache 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup, find_packages 4 | 5 | 6 | with open('README.md') as readme_file: 7 | README = readme_file.read().strip() 8 | 9 | PROJECT = README.strip('#').split('\n')[0].strip().split()[0].lower() 10 | DESCRIPTION = README.split('\n')[2] 11 | 12 | with open('%s/VERSION' % PROJECT) as version_file: 13 | VERSION = version_file.read().strip() 14 | 15 | with open('requirements.txt') as reqs_file: 16 | REQS = reqs_file.read() 17 | 18 | with open('entrypoints.conf') as ep_file: 19 | ENTRYPOINTS = ep_file.read() 20 | 21 | 22 | setup(name=PROJECT, 23 | version=VERSION, 24 | description=DESCRIPTION, 25 | long_description=README, 26 | author='Luke Arno', 27 | author_email='luke.arno@gmail.com', 28 | url='http://github.com/lukearno/%s' % PROJECT, 29 | license='MIT', 30 | packages=find_packages(exclude=['tests']), 31 | include_package_data=True, 32 | install_requires=REQS, 33 | entry_points=ENTRYPOINTS, 34 | classifiers=['Development Status :: 4 - Beta', 35 | 'Intended Audience :: Developers', 36 | 'License :: OSI Approved :: MIT License', 37 | 'Natural Language :: English', 38 | 'Operating System :: OS Independent', 39 | 'Programming Language :: Python :: 2.6', 40 | 'Programming Language :: Python :: 2.7', 41 | 'Programming Language :: Python :: 3.2', 42 | 'Programming Language :: Python :: 3.3', 43 | 'Programming Language :: Python :: 3.4', 44 | 'Topic :: Software Development :: Libraries', 45 | 'Topic :: Utilities']) 46 | -------------------------------------------------------------------------------- /static/VERSION: -------------------------------------------------------------------------------- 1 | 1.1.1 -------------------------------------------------------------------------------- /static/__init__.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | 3 | version_file = pkg_resources.resource_filename(__name__, 'VERSION') 4 | with open(version_file) as vf: 5 | __version__ = vf.read() 6 | del version_file 7 | 8 | 9 | from static.apps import ( 10 | BaseMagic, 11 | cling_wrap, 12 | Cling, 13 | MagicError, 14 | MoustacheMagic, 15 | Shock, 16 | StatusApp, 17 | StringMagic) 18 | 19 | 20 | __all__ = ['__version__', 21 | 'BaseMagic', 22 | 'cling_wrap', 23 | 'Cling', 24 | 'MagicError', 25 | 'MoustacheMagic', 26 | 'Shock', 27 | 'StatusApp', 28 | 'StringMagic'] 29 | -------------------------------------------------------------------------------- /static/apps.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2.4 2 | """static - A very simple WSGI way to serve static (or mixed) content. 3 | 4 | (See the docstrings of the various functions and classes.) 5 | """ 6 | 7 | import logging 8 | import mimetypes 9 | from os import path, stat 10 | from email.utils import formatdate, parsedate 11 | import string 12 | import sys 13 | import time 14 | 15 | from wsgiref import util 16 | 17 | from pkg_resources import resource_filename, Requirement 18 | 19 | import pystache 20 | 21 | 22 | class MagicError(Exception): 23 | pass 24 | 25 | 26 | class StatusApp: 27 | """A WSGI app that just returns the given status.""" 28 | 29 | def __init__(self, status, message=None): 30 | self.status = status 31 | if message is None: 32 | self.message = status 33 | else: 34 | self.message = message 35 | 36 | def __call__(self, environ, start_response, headers=[]): 37 | start_response(self.status, headers) 38 | if environ['REQUEST_METHOD'] == 'GET': 39 | return [bytes(self.message.encode('utf-8'))] 40 | else: 41 | return [b""] 42 | 43 | 44 | class Cling(object): 45 | """A very simple way to serve static content via WSGI. 46 | 47 | Serve the file of the same path as PATH_INFO in self.datadir. 48 | 49 | Look up the Content-type in self.content_types by extension 50 | or use 'text/plain' if the extension is not found. 51 | 52 | Serve up the contents of the file or delegate to self.not_found. 53 | """ 54 | 55 | def __init__(self, root, 56 | block_size=16 * 4096, 57 | index_file='index.html', 58 | not_found=None, 59 | not_modified=None, 60 | moved_permanently=None, 61 | method_not_allowed=None, 62 | log_name='static', 63 | log_level=logging.WARN, 64 | log_format=('[%(asctime)s - %(module)20s ' 65 | '- %(process)5d] %(message)s'), 66 | log=None): 67 | self.root = root 68 | self.block_size = block_size 69 | self.index_file = index_file 70 | self.not_found = not_found or StatusApp('404 Not Found') 71 | self.not_modified = not_modified or StatusApp('304 Not Modified', "") 72 | self.moved_permanently \ 73 | = moved_permanently or StatusApp('301 Moved Permanently') 74 | self.method_not_allowed \ 75 | = method_not_allowed or StatusApp('405 Method Not Allowed') 76 | self.log = log or self._stderr_logger(log_name, log_level, log_format) 77 | 78 | def _stderr_logger(self, log_name, log_level, log_format): 79 | log = logging.getLogger(log_name) 80 | log.setLevel(log_level) 81 | hdlr = logging.StreamHandler(sys.stderr) 82 | hdlr.setFormatter(logging.Formatter(log_format)) 83 | log.addHandler(hdlr) 84 | return log 85 | 86 | def __call__(self, environ, start_response): 87 | """Respond to a request when called in the usual WSGI way.""" 88 | if environ['REQUEST_METHOD'] not in ('GET', 'HEAD'): 89 | return self.method_not_allowed(environ, start_response) 90 | path_info = environ.get('PATH_INFO', '') 91 | full_path = self._full_path(path_info) 92 | if not self._is_under_root(full_path): 93 | return self.not_found(environ, start_response) 94 | if path.isdir(full_path): 95 | if full_path[-1] != '/' or full_path == self.root: 96 | location = util.request_uri(environ, include_query=False) + '/' 97 | if environ.get('QUERY_STRING'): 98 | location += '?' + environ.get('QUERY_STRING') 99 | headers = [('Location', location)] 100 | return self.moved_permanently(environ, start_response, headers) 101 | else: 102 | full_path = self._full_path(path_info + self.index_file) 103 | prezipped = ('gzip' in environ.get('HTTP_ACCEPT_ENCODING', '') 104 | and path.exists(full_path + '.gz')) 105 | if prezipped: 106 | full_path += '.gz' 107 | content_type = self._guess_type(full_path) 108 | try: 109 | etag, last_modified = self._conditions(full_path, environ) 110 | headers = [('Date', formatdate(time.time())), 111 | ('Last-Modified', last_modified), 112 | ('ETag', etag)] 113 | if_modified = environ.get('HTTP_IF_MODIFIED_SINCE') 114 | if if_modified and (parsedate(if_modified) 115 | >= parsedate(last_modified)): 116 | return self.not_modified(environ, start_response, headers) 117 | if_none = environ.get('HTTP_IF_NONE_MATCH') 118 | if if_none and (if_none == '*' or etag in if_none): 119 | return self.not_modified(environ, start_response, headers) 120 | file_like = self._file_like(full_path) 121 | headers.append(('Content-Type', content_type)) 122 | if prezipped: 123 | headers.extend([('Content-Encoding', 'gzip'), 124 | ('Vary', 'Accept-Encoding')]) 125 | start_response("200 OK", headers) 126 | if environ['REQUEST_METHOD'] == 'GET': 127 | return self._body(full_path, environ, file_like) 128 | else: 129 | return [''] 130 | except (IOError, OSError): 131 | return self.not_found(environ, start_response) 132 | 133 | def _full_path(self, path_info): 134 | """Return the full path from which to read.""" 135 | return self.root + path_info 136 | 137 | def _is_under_root(self, full_path): 138 | """Guard against arbitrary file retrieval.""" 139 | abs_destination = path.abspath(full_path) + path.sep 140 | abs_root = path.abspath(self.root) + path.sep 141 | if abs_destination.startswith(abs_root): 142 | return True 143 | else: 144 | return False 145 | 146 | def _guess_type(self, full_path): 147 | """Guess the mime type using the mimetypes module.""" 148 | return mimetypes.guess_type(full_path)[0] or 'text/plain' 149 | 150 | def _conditions(self, full_path, environ): 151 | """Return a tuple of etag, last_modified by mtime from stat.""" 152 | mtime = stat(full_path).st_mtime 153 | return str(mtime), formatdate(mtime) 154 | 155 | def _file_like(self, full_path): 156 | """Return the appropriate file object.""" 157 | return open(full_path, 'rb') 158 | 159 | def _body(self, full_path, environ, file_like): 160 | """Return an iterator over the body of the response.""" 161 | way_to_send = environ.get('wsgi.file_wrapper', _iter_and_close) 162 | return way_to_send(file_like, self.block_size) 163 | 164 | 165 | def _iter_and_close(file_like, block_size): 166 | """Yield file contents by block then close the file.""" 167 | while 1: 168 | try: 169 | block = file_like.read(block_size) 170 | if block: 171 | yield block 172 | else: 173 | raise StopIteration 174 | except StopIteration: 175 | file_like.close() 176 | break 177 | 178 | 179 | def cling_wrap(package_name, dir_name, **kw): # pragma: no cover 180 | """Return a Cling that serves from the given package and dir_name. 181 | 182 | This uses pkg_resources.resource_filename which is not the 183 | recommended way, since it extracts the files. 184 | 185 | I think this works fine unless you have some more serious 186 | requirements for static content, in which case you probably 187 | shouldn't be serving it through a WSGI app. 188 | """ 189 | resource = Requirement.parse(package_name) 190 | return Cling(resource_filename(resource, dir_name), **kw) 191 | 192 | 193 | class Shock(Cling): 194 | """A very simple way to serve up mixed content. 195 | 196 | Serves static content just like Cling (it's superclass) 197 | except that it process content with the first matching 198 | "magic" from self.magics if any apply. 199 | 200 | See Cling and classes with "Magic" in their names in this module. 201 | 202 | If you are using Shock with the StringMagic class for instance: 203 | 204 | shock = Shock('/data', magics=[StringMagic(food='cheese')]) 205 | 206 | Let's say you have a file called /data/foo.txt.stp containing one line: 207 | 208 | "I love to eat $food!" 209 | 210 | When you do a GET on /foo.txt you will see this in your browser: 211 | 212 | "I love to eat cheese!" 213 | 214 | This is really nice if you have a color variable in your css files or 215 | something trivial like that. It seems silly to create or change a 216 | handful of objects for a couple of dynamic bits of text. 217 | """ 218 | 219 | def __init__(self, root, magics, **kw): 220 | super(Shock, self).__init__(root, **kw) 221 | self.magics = magics 222 | 223 | def _match_magic(self, full_path): 224 | """Return the first magic that matches this path or None.""" 225 | for magic in self.magics: 226 | if magic.matches(full_path): 227 | return magic 228 | 229 | def _full_path(self, path_info): 230 | """Return the full path from which to read.""" 231 | full_path = self.root + path_info 232 | if path.exists(full_path): 233 | return full_path 234 | else: 235 | for magic in self.magics: 236 | if magic.exists(full_path): 237 | return magic.new_path(full_path) 238 | else: 239 | return full_path 240 | 241 | def _guess_type(self, full_path): 242 | """Guess the mime type magically or using the mimetypes module.""" 243 | magic = self._match_magic(full_path) 244 | if magic is not None: 245 | return (mimetypes.guess_type(magic.old_path(full_path))[0] 246 | or 'text/plain') 247 | else: 248 | return mimetypes.guess_type(full_path)[0] or 'text/plain' 249 | 250 | def _conditions(self, full_path, environ): 251 | """Return Etag and Last-Modified values defaults to now for both.""" 252 | magic = self._match_magic(full_path) 253 | if magic is not None: 254 | return magic.conditions(full_path, environ) 255 | else: 256 | mtime = stat(full_path).st_mtime 257 | return str(mtime), formatdate(mtime) 258 | 259 | def _file_like(self, full_path): 260 | """Return the appropriate file object.""" 261 | magic = self._match_magic(full_path) 262 | if magic is not None: 263 | return magic.file_like(full_path) 264 | else: 265 | return open(full_path, 'rb') 266 | 267 | def _body(self, full_path, environ, file_like): 268 | """Return an iterator over the body of the response.""" 269 | magic = self._match_magic(full_path) 270 | if magic is not None: 271 | return magic.body(environ, file_like) 272 | else: 273 | way_to_send = environ.get('wsgi.file_wrapper', _iter_and_close) 274 | return way_to_send(file_like, self.block_size) 275 | 276 | 277 | class BaseMagic(object): 278 | """Base class for magic file handling. 279 | 280 | Really a do nothing if you were to use this directly. 281 | 282 | In a strait forward case you would just override .extension and body(). 283 | (See StringMagic in this module for a simple example of subclassing.) 284 | 285 | In a more complex case you may need to override many or all methods. 286 | """ 287 | 288 | extension = '' 289 | 290 | def exists(self, full_path): 291 | """Check that self.new_path(full_path) exists.""" 292 | if path.exists(self.new_path(full_path)): 293 | return self.new_path(full_path) 294 | 295 | def new_path(self, full_path): 296 | """Add the self.extension to the path.""" 297 | return full_path + self.extension 298 | 299 | def old_path(self, full_path): 300 | """Remove self.extension.""" 301 | return full_path[:-len(self.extension)] 302 | 303 | def matches(self, full_path): 304 | """Check that path ends with self.extension.""" 305 | if full_path.endswith(self.extension): 306 | return full_path 307 | 308 | def conditions(self, full_path, environ): 309 | """Return Etag and Last-Modified values (based on mtime).""" 310 | mtime = int(time.time()) 311 | return str(mtime), formatdate(mtime) 312 | 313 | def file_like(self, full_path): 314 | """Return a file object for path.""" 315 | return open(full_path, 'rb') 316 | 317 | def body(self, environ, file_like): # pragma: no cover 318 | """Return an iterator over the body of the response.""" 319 | raise NotImplemented 320 | 321 | 322 | class StringMagic(BaseMagic): 323 | """Magic to replace variables in file contents using string.Template. 324 | 325 | Using this requires Python2.4. 326 | """ 327 | 328 | default_extension = '.stp' 329 | 330 | def __init__(self, extension=None, variables=None): 331 | """Keyword arguments populate self.variables.""" 332 | self.extension = extension or self.default_extension 333 | self.variables = variables or {} 334 | 335 | def body(self, environ, file_like): 336 | """Pass environ and self.variables in to template. 337 | 338 | self.variables overrides environ so that suprises in environ don't 339 | cause unexpected output if you are passing a value in explicitly. 340 | """ 341 | variables = environ.copy() 342 | variables.update(self.variables) 343 | template = string.Template(file_like.read().decode('utf-8')) 344 | result = template.safe_substitute(variables) 345 | return [result.encode('utf-8')] 346 | 347 | 348 | class MoustacheMagic(StringMagic): 349 | """Like StringMagic only using Moustache templates.""" 350 | 351 | default_extension = '.mst' 352 | 353 | def body(self, environ, file_like): 354 | """Pass environ and **self.variables into the template.""" 355 | return [pystache.Renderer().render(file_like.read(), 356 | environ=environ, 357 | **self.variables).encode('utf-8')] 358 | -------------------------------------------------------------------------------- /static/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from static import Cling 4 | 5 | 6 | def run(): 7 | host, port, directory = sys.argv[1:4] 8 | app = Cling(directory) 9 | try: 10 | from wsgiref.simple_server import make_server 11 | make_server(host, int(port), app).serve_forever() 12 | except KeyboardInterrupt: 13 | print("Cio, baby!") 14 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukearno/static/65b6a3ebede8ea6cc346be359bc3b5d268970fc7/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/noindex/static.html: -------------------------------------------------------------------------------- 1 | static html 2 | -------------------------------------------------------------------------------- /tests/data/prezip/nogzipversionpresent.txt: -------------------------------------------------------------------------------- 1 | nogzipversion 2 | -------------------------------------------------------------------------------- /tests/data/prezip/static.txt: -------------------------------------------------------------------------------- 1 | static 2 | -------------------------------------------------------------------------------- /tests/data/prezip/static.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukearno/static/65b6a3ebede8ea6cc346be359bc3b5d268970fc7/tests/data/prezip/static.txt.gz -------------------------------------------------------------------------------- /tests/data/templates/foo.css.mst: -------------------------------------------------------------------------------- 1 | body { color: {{color}}; } -------------------------------------------------------------------------------- /tests/data/templates/index.html.stp: -------------------------------------------------------------------------------- 1 | Hello $name -------------------------------------------------------------------------------- /tests/data/templates/safe.sstp: -------------------------------------------------------------------------------- 1 | $name saves his $ $unknown -------------------------------------------------------------------------------- /tests/data/templates/static.txt: -------------------------------------------------------------------------------- 1 | static txt 2 | -------------------------------------------------------------------------------- /tests/data/templates/unsafe.stp: -------------------------------------------------------------------------------- 1 | $name saves his $ $unknown -------------------------------------------------------------------------------- /tests/data/withindex/index.html: -------------------------------------------------------------------------------- 1 | index html 2 | -------------------------------------------------------------------------------- /tests/data/withindex/static.html: -------------------------------------------------------------------------------- 1 | static html 2 | -------------------------------------------------------------------------------- /tests/data/withindex/subdir/index.html: -------------------------------------------------------------------------------- 1 | subdir index html 2 | -------------------------------------------------------------------------------- /tests/test_behaviors.py: -------------------------------------------------------------------------------- 1 | from os import stat 2 | from email.utils import formatdate 3 | import time 4 | 5 | from unittest import TestCase 6 | 7 | from wsgi_intercept import http_client_intercept 8 | import wsgi_intercept 9 | 10 | try: 11 | import http.client as http_lib 12 | except ImportError: 13 | import httplib as http_lib 14 | 15 | import static 16 | 17 | 18 | class StripAcceptEncoding(object): 19 | def __init__(self, application): 20 | self.application = application 21 | 22 | def __call__(self, environ, start_response): 23 | environ.pop('HTTP_ACCEPT_ENCODING', None) 24 | return self.application(environ, start_response) 25 | 26 | 27 | class Intercepted(TestCase): 28 | 29 | def setUp(self): 30 | http_client_intercept.install() 31 | wsgi_intercept.add_wsgi_intercept('statictest', 80, self.get_app) 32 | 33 | def tearDown(self): 34 | wsgi_intercept.remove_wsgi_intercept('statictest', 80) 35 | http_client_intercept.uninstall() 36 | 37 | def assert_response(self, method, path, headers, status, 38 | content=None, file_content=None, 39 | response_headers=None): 40 | client = http_lib.HTTPConnection('statictest') 41 | client.request(method, path, headers=headers) 42 | response = client.getresponse() 43 | real_content = response.read() 44 | real_headers = dict(response.getheaders()) 45 | self.assertEqual(response.status, status) 46 | if content is not None: 47 | self.assertEqual(real_content, content) 48 | if file_content is not None: 49 | with open(file_content, 'rb') as content_fp: 50 | content = content_fp.read() 51 | self.assertEqual(real_content, content) 52 | if response_headers is not None: 53 | for k, v in response_headers.items(): 54 | if k not in real_headers: 55 | k = k.lower() 56 | self.assertTrue(k in real_headers) 57 | self.assertEqual(real_headers[k], v) 58 | 59 | 60 | class StaticClingWithIndexTests(Intercepted): 61 | 62 | def get_app(self): 63 | return static.Cling('tests/data/withindex') 64 | 65 | def test_client_can_get_a_static_file(self): 66 | self.assert_response( 67 | 'GET', '/static.html', {}, 68 | 200, 69 | file_content='tests/data/withindex/static.html') 70 | 71 | def test_client_can_head_a_static_file(self): 72 | self.assert_response( 73 | 'HEAD', '/static.html', {}, 74 | 200, b'') 75 | 76 | def test_client_gets_etag_and_last_modified_headers(self): 77 | file_path = 'tests/data/withindex/static.html' 78 | mtime = stat(file_path).st_mtime 79 | etag = str(mtime) 80 | last_modified = formatdate(mtime) 81 | self.assert_response( 82 | 'GET', '/static.html', {}, 83 | 200, 84 | file_content=file_path, 85 | response_headers={'ETag': etag, 86 | 'Last-Modified': last_modified}) 87 | 88 | def test_client_can_use_etags(self): 89 | file_path = 'tests/data/withindex/static.html' 90 | mtime = stat(file_path).st_mtime 91 | etag = str(mtime) 92 | self.assert_response( 93 | 'GET', '/static.html', {'If-None-Match': etag}, 94 | 304, b'') 95 | 96 | def test_client_can_use_if_modified_since(self): 97 | modified_since = formatdate(time.time()) 98 | self.assert_response( 99 | 'GET', '/static.html', 100 | {'If-Modified-Since': modified_since}, 101 | 304, b'') 102 | 103 | def test_client_gets_index_file_if_path_is_ommitted(self): 104 | self.assert_response( 105 | 'GET', '', {}, 106 | 200, 107 | file_content='tests/data/withindex/index.html') 108 | 109 | def test_client_gets_index_file_on_root(self): 110 | self.assert_response( 111 | 'GET', '/', {}, 112 | 200, 113 | file_content='tests/data/withindex/index.html') 114 | 115 | def test_client_gets_index_file_on_subdirectory(self): 116 | self.assert_response( 117 | 'GET', '/subdir/', {}, 118 | 200, 119 | file_content='tests/data/withindex/subdir/index.html') 120 | 121 | def test_client_gets_301_on_subdirectory_with_no_trailing_slash(self): 122 | self.assert_response( 123 | 'GET', '/subdir?foo=1', {}, 124 | 301, 125 | response_headers={'Location': 'http://statictest/subdir/?foo=1'}) 126 | 127 | def test_client_gets_a_405_on_POST(self): 128 | self.assert_response( 129 | 'POST', '/static.html', {}, 130 | 405) 131 | 132 | def test_client_gets_a_405_on_PUT(self): 133 | self.assert_response( 134 | 'POST', '/static.html', {}, 135 | 405) 136 | 137 | def test_client_gets_a_405_on_DELETE(self): 138 | self.assert_response( 139 | 'POST', '/static.html', {}, 140 | 405) 141 | 142 | def test_client_cant_get_a_static_file_not_in_exposed_directory(self): 143 | self.assert_response( 144 | 'GET', '../__init__.py', {}, 145 | 404) 146 | 147 | def test_client_gets_a_404_for_a_missing_file(self): 148 | self.assert_response( 149 | 'GET', '/no-such-file.txt', {}, 150 | 404) 151 | 152 | 153 | class StaticClingWithNoIndexTests(Intercepted): 154 | 155 | def get_app(self): 156 | return static.Cling('tests/data/noindex') 157 | 158 | def test_client_can_get_a_static_file(self): 159 | self.assert_response( 160 | 'GET', '/static.html', {}, 161 | 200, 162 | file_content='tests/data/noindex/static.html') 163 | 164 | def test_client_gets_a_404_if_path_is_ommitted(self): 165 | self.assert_response( 166 | 'GET', '', {}, 167 | 404) 168 | 169 | def test_client_gets_a_404_on_root(self): 170 | self.assert_response( 171 | 'GET', '/', {}, 172 | 404) 173 | 174 | 175 | class StaticShockTests(Intercepted): 176 | 177 | def get_app(self): 178 | return static.Shock( 179 | 'tests/data/templates', 180 | (static.StringMagic(variables={'name': "Hamm"}), 181 | static.MoustacheMagic(variables={'color': "blue"}))) 182 | 183 | def test_client_can_get_stp_without_extension(self): 184 | self.assert_response( 185 | 'GET', '/index.html', {}, 186 | 200, 187 | b"Hello Hamm", 188 | response_headers={'Content-Type': 'text/html'}) 189 | 190 | def test_client_can_get_stp_with_extension(self): 191 | self.assert_response( 192 | 'GET', '/index.html.stp', {}, 193 | 200, 194 | b"Hello Hamm", 195 | response_headers={'Content-Type': 'text/html'}) 196 | 197 | def test_client_gets_right_content_type_without_extension(self): 198 | self.assert_response( 199 | 'GET', '/foo.css', {}, 200 | 200, 201 | b"body { color: blue; }", 202 | response_headers={'Content-Type': 'text/css'}) 203 | 204 | def test_client_gets_right_content_type_with_extension(self): 205 | self.assert_response( 206 | 'GET', '/foo.css.mst', {}, 207 | 200, 208 | b"body { color: blue; }", 209 | response_headers={'Content-Type': 'text/css'}) 210 | 211 | def test_client_can_get_a_static_file_where_there_is_no_template(self): 212 | self.assert_response( 213 | 'GET', '/static.txt', {}, 214 | 200, 215 | file_content="tests/data/templates/static.txt", 216 | response_headers={'Content-Type': 'text/plain'}) 217 | 218 | def test_client_gets_a_404_for_a_missing_file(self): 219 | self.assert_response( 220 | 'GET', '/no-such-file.txt', {}, 221 | 404) 222 | 223 | 224 | class StaticClingWithPrezipping(Intercepted): 225 | 226 | def setUp(self): 227 | self._app = static.Cling('tests/data/prezip') 228 | super(StaticClingWithPrezipping, self).setUp() 229 | 230 | def get_app(self): 231 | return self._app 232 | 233 | def test_client_gets_prezipped_content(self): 234 | self.assert_response( 235 | 'GET', '/static.txt', {'Accept-Encoding': 'gzip, deflate'}, 236 | 200, 237 | response_headers={'Content-Encoding': 'gzip'}, 238 | file_content="tests/data/prezip/static.txt.gz") 239 | 240 | def test_client_gets_non_prezipped_when_no_accept_encoding_present(self): 241 | # strip HTTP_ACCEPT_ENCODING from the environ 242 | self._app = StripAcceptEncoding(self.get_app()) 243 | self.assert_response( 244 | 'GET', '/static.txt', {}, 245 | 200, 246 | file_content="tests/data/prezip/static.txt") 247 | 248 | def test_client_gets_non_prezipped_when_accept_missing_gzip(self): 249 | self.assert_response( 250 | 'GET', '/static.txt', {}, 251 | 200, 252 | file_content="tests/data/prezip/static.txt") 253 | 254 | def test_client_gets_non_prezipped_when_prezipped_file_not_exist(self): 255 | self.assert_response( 256 | 'GET', '/nogzipversionpresent.txt', 257 | {'Accept-Encoding': 'gzip, deflate'}, 258 | 200, 259 | file_content="tests/data/prezip/nogzipversionpresent.txt") 260 | --------------------------------------------------------------------------------