diff --git a/docs/conf.py b/docs/conf.py index eaa3b484..a1f76ce5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,42 +17,43 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +# sys.path.insert(0, os.path.abspath('.')) + def get_version(): import ast, os, re - filename = os.path.join( - os.path.dirname(__file__), - '../whitenoise/__init__.py') - with open(filename, 'rb') as f: - contents = f.read().decode('utf-8') - version_string = re.search(r'__version__\s+=\s+(.*)', contents).group(1) + + filename = os.path.join(os.path.dirname(__file__), "../whitenoise/__init__.py") + with open(filename, "rb") as f: + contents = f.read().decode("utf-8") + version_string = re.search(r"__version__\s+=\s+(.*)", contents).group(1) return str(ast.literal_eval(version_string)) + # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.extlinks'] +extensions = ["sphinx.ext.extlinks"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'WhiteNoise' -copyright = u'2013-{}, David Evans'.format(datetime.datetime.today().year) +project = u"WhiteNoise" +copyright = u"2013-{}, David Evans".format(datetime.datetime.today().year) # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -65,37 +66,37 @@ def get_version(): # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- @@ -103,139 +104,134 @@ def get_version(): # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -if os.environ.get('READTHEDOCS', None) == 'True': - html_theme = 'default' +if os.environ.get("READTHEDOCS", None) == "True": + html_theme = "default" else: import sphinx_rtd_theme - html_theme = 'sphinx_rtd_theme' + + html_theme = "sphinx_rtd_theme" html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'WhiteNoisedoc' +htmlhelp_basename = "WhiteNoisedoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'WhiteNoise.tex', u'WhiteNoise Documentation', - u'David Evans', 'manual'), + ("index", "WhiteNoise.tex", u"WhiteNoise Documentation", u"David Evans", "manual") ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'whitenoise', u'WhiteNoise Documentation', - [u'David Evans'], 1) -] +man_pages = [("index", "whitenoise", u"WhiteNoise Documentation", [u"David Evans"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -244,20 +240,26 @@ def get_version(): # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'WhiteNoise', u'WhiteNoise Documentation', - u'David Evans', 'WhiteNoise', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "WhiteNoise", + u"WhiteNoise Documentation", + u"David Evans", + "WhiteNoise", + "One line description of project.", + "Miscellaneous", + ) ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' -git_tag = 'v{}'.format(version) if version != 'development' else 'master' -github_base_url = 'https://github.com/evansd/whitenoise/blob/{}/'.format(git_tag) -extlinks = {'file': (github_base_url + '%s', '')} +git_tag = "v{}".format(version) if version != "development" else "master" +github_base_url = "https://github.com/evansd/whitenoise/blob/{}/".format(git_tag) +extlinks = {"file": (github_base_url + "%s", "")} diff --git a/scripts/generate_default_media_types.py b/scripts/generate_default_media_types.py index 643029cc..dc8e41db 100755 --- a/scripts/generate_default_media_types.py +++ b/scripts/generate_default_media_types.py @@ -5,9 +5,9 @@ EXTRA_MIMETYPES = { - 'apple-app-site-association': 'application/pkc7-mime', - '.woff': 'application/font-woff', - '.woff2': 'font/woff2' + "apple-app-site-association": "application/pkc7-mime", + ".woff": "application/font-woff", + ".woff2": "font/woff2", } @@ -26,37 +26,35 @@ def default_types(): """.lstrip() -NGINX_CONFIG_FILE = '/etc/nginx/mime.types' +NGINX_CONFIG_FILE = "/etc/nginx/mime.types" def get_default_types_function(): types_map = get_types_map() - types_map_str = pprint.pformat(types_map, indent=8).strip('{} ') - return FUNCTION_TEMPLATE.format( - triple_quote='"""', - types_map=types_map_str) + types_map_str = pprint.pformat(types_map, indent=8).strip("{} ") + return FUNCTION_TEMPLATE.format(triple_quote='"""', types_map=types_map_str) def get_types_map(): types_map = {} - with open(NGINX_CONFIG_FILE, 'r') as f: + with open(NGINX_CONFIG_FILE, "r") as f: for line in f: line = line.strip() - if not line.endswith(';'): + if not line.endswith(";"): continue - line = line.rstrip(';') + line = line.rstrip(";") bits = line.split() media_type = bits[0] # This is the default media type anyway, no point specifying # it explicitly - if media_type == 'application/octet-stream': + if media_type == "application/octet-stream": continue extensions = bits[1:] for extension in extensions: - types_map['.'+extension] = media_type + types_map["." + extension] = media_type types_map.update(EXTRA_MIMETYPES) return types_map -if __name__ == '__main__': +if __name__ == "__main__": print(get_default_types_function()) diff --git a/setup.cfg b/setup.cfg index d6e10b15..e59de929 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,3 +3,5 @@ universal = 1 [flake8] max-line-length = 100 +# See https://github.com/PyCQA/pycodestyle/issues/373 +ignore = E203 diff --git a/setup.py b/setup.py index 3dc5f426..66ef47e9 100644 --- a/setup.py +++ b/setup.py @@ -6,52 +6,50 @@ PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) -VERSION_RE = re.compile(r'__version__\s+=\s+(.*)') +VERSION_RE = re.compile(r"__version__\s+=\s+(.*)") def read(*path): full_path = os.path.join(PROJECT_ROOT, *path) - with codecs.open(full_path, 'r', encoding='utf-8') as f: + with codecs.open(full_path, "r", encoding="utf-8") as f: return f.read() -version_string = VERSION_RE.search(read('whitenoise/__init__.py')).group(1) +version_string = VERSION_RE.search(read("whitenoise/__init__.py")).group(1) version = str(ast.literal_eval(version_string)) setup( - name='whitenoise', + name="whitenoise", version=version, - author='David Evans', - author_email='d@evans.io', - url='http://whitenoise.evans.io', - packages=find_packages(exclude=['tests*']), - license='MIT', + author="David Evans", + author_email="d@evans.io", + url="http://whitenoise.evans.io", + packages=find_packages(exclude=["tests*"]), + license="MIT", description="Radically simplified static file serving for WSGI applications", - long_description=read('README.rst'), + long_description=read("README.rst"), classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware', - 'Framework :: Django', - 'Framework :: Django :: 1.8', - 'Framework :: Django :: 1.9', - 'Framework :: Django :: 1.10', - 'Framework :: Django :: 1.11', - 'Framework :: Django :: 2.0', - 'Framework :: Django :: 2.1', - 'Framework :: Django :: 2.2', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', + "Development Status :: 5 - Production/Stable", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware", + "Framework :: Django", + "Framework :: Django :: 1.8", + "Framework :: Django :: 1.9", + "Framework :: Django :: 1.10", + "Framework :: Django :: 1.11", + "Framework :: Django :: 2.0", + "Framework :: Django :: 2.1", + "Framework :: Django :: 2.2", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", ], - extras_require={ - 'brotli': ["Brotli"], - }, + extras_require={"brotli": ["Brotli"]}, ) diff --git a/tests/django_settings.py b/tests/django_settings.py index 788a493d..672abffd 100644 --- a/tests/django_settings.py +++ b/tests/django_settings.py @@ -3,51 +3,39 @@ from .utils import TestServer -ALLOWED_HOSTS = ['*'] +ALLOWED_HOSTS = ["*"] -ROOT_URLCONF = 'tests.django_urls' +ROOT_URLCONF = "tests.django_urls" -SECRET_KEY = 'test_secret' +SECRET_KEY = "test_secret" -INSTALLED_APPS = [ - 'whitenoise.runserver_nostatic', - 'django.contrib.staticfiles' -] +INSTALLED_APPS = ["whitenoise.runserver_nostatic", "django.contrib.staticfiles"] -FORCE_SCRIPT_NAME = '/' + TestServer.PREFIX -STATIC_URL = FORCE_SCRIPT_NAME + '/static/' +FORCE_SCRIPT_NAME = "/" + TestServer.PREFIX +STATIC_URL = FORCE_SCRIPT_NAME + "/static/" # This path is not actually used, but we have to set it to something # or Django will complain -STATIC_ROOT = '/dev/null' +STATIC_ROOT = "/dev/null" -STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" if django.VERSION >= (1, 10): - MIDDLEWARE = ['whitenoise.middleware.WhiteNoiseMiddleware'] + MIDDLEWARE = ["whitenoise.middleware.WhiteNoiseMiddleware"] else: - MIDDLEWARE_CLASSES = ['whitenoise.middleware.WhiteNoiseMiddleware'] + MIDDLEWARE_CLASSES = ["whitenoise.middleware.WhiteNoiseMiddleware"] LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'filters': { - 'require_debug_false': { - '()': 'django.utils.log.RequireDebugFalse' + "version": 1, + "disable_existing_loggers": False, + "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, + "handlers": {"log_to_stderr": {"level": "ERROR", "class": "logging.StreamHandler"}}, + "loggers": { + "django.request": { + "handlers": ["log_to_stderr"], + "level": "ERROR", + "propagate": True, } }, - 'handlers': { - 'log_to_stderr': { - 'level': 'ERROR', - 'class': 'logging.StreamHandler' - } - }, - 'loggers': { - 'django.request': { - 'handlers': ['log_to_stderr'], - 'level': 'ERROR', - 'propagate': True, - }, - } } diff --git a/tests/django_urls.py b/tests/django_urls.py index 8e6b4f87..2c063feb 100644 --- a/tests/django_urls.py +++ b/tests/django_urls.py @@ -3,9 +3,7 @@ def hello_world(reqeust): - return HttpResponse(content='Hello Word', content_type='text/plain') + return HttpResponse(content="Hello Word", content_type="text/plain") -urlpatterns = [ - url(r'^hello$', hello_world), -] +urlpatterns = [url(r"^hello$", hello_world)] diff --git a/tests/test_compress.py b/tests/test_compress.py index 0f270215..9dae6046 100644 --- a/tests/test_compress.py +++ b/tests/test_compress.py @@ -11,29 +11,25 @@ from whitenoise.compress import main as compress_main -COMPRESSABLE_FILE = 'application.css' -TOO_SMALL_FILE = 'too-small.css' -WRONG_EXTENSION = 'image.jpg' -TEST_FILES = { - COMPRESSABLE_FILE: b'a' * 1000, - TOO_SMALL_FILE: b'hi', -} +COMPRESSABLE_FILE = "application.css" +TOO_SMALL_FILE = "too-small.css" +WRONG_EXTENSION = "image.jpg" +TEST_FILES = {COMPRESSABLE_FILE: b"a" * 1000, TOO_SMALL_FILE: b"hi"} class CompressTestBase(TestCase): - @classmethod def setUpClass(cls): # Make a temporary directory and copy in test files cls.tmp = tempfile.mkdtemp() for path, contents in TEST_FILES.items(): - path = os.path.join(cls.tmp, path.lstrip('/')) + path = os.path.join(cls.tmp, path.lstrip("/")) try: os.makedirs(os.path.dirname(path)) except OSError as e: if e.errno != errno.EEXIST: raise - with open(path, 'wb') as f: + with open(path, "wb") as f: f.write(contents) timestamp = 1498579535 os.utime(path, (timestamp, timestamp)) @@ -48,25 +44,26 @@ def tearDownClass(cls): class CompressTest(CompressTestBase): - @classmethod def run_compress(cls): compress_main(cls.tmp, quiet=True) def test_compresses_file(self): with contextlib.closing( - gzip.open( - os.path.join(self.tmp, COMPRESSABLE_FILE + '.gz'), 'rb')) as f: + gzip.open(os.path.join(self.tmp, COMPRESSABLE_FILE + ".gz"), "rb") + ) as f: contents = f.read() self.assertEqual(TEST_FILES[COMPRESSABLE_FILE], contents) def test_doesnt_compress_if_no_saving(self): - self.assertFalse(os.path.exists(os.path.join(self.tmp, TOO_SMALL_FILE + 'gz'))) + self.assertFalse(os.path.exists(os.path.join(self.tmp, TOO_SMALL_FILE + "gz"))) def test_ignores_other_extensions(self): - self.assertFalse(os.path.exists(os.path.join(self.tmp, WRONG_EXTENSION + '.gz'))) + self.assertFalse( + os.path.exists(os.path.join(self.tmp, WRONG_EXTENSION + ".gz")) + ) def test_mtime_is_preserved(self): path = os.path.join(self.tmp, COMPRESSABLE_FILE) - gzip_path = path + '.gz' + gzip_path = path + ".gz" self.assertEqual(os.path.getmtime(path), os.path.getmtime(gzip_path)) diff --git a/tests/test_django_whitenoise.py b/tests/test_django_whitenoise.py index 5b09c28a..81fc278e 100644 --- a/tests/test_django_whitenoise.py +++ b/tests/test_django_whitenoise.py @@ -39,18 +39,17 @@ def get_url_path(base, url): @override_settings() class DjangoWhiteNoiseTest(SimpleTestCase): - @classmethod def setUpClass(cls): reset_lazy_object(storage.staticfiles_storage) - cls.static_files = Files('static', js='app.js', nonascii='nonascii\u2713.txt') - cls.root_files = Files('root', robots='robots.txt') + cls.static_files = Files("static", js="app.js", nonascii="nonascii\u2713.txt") + cls.root_files = Files("root", robots="robots.txt") cls.tmp = TEXT_TYPE(tempfile.mkdtemp()) settings.STATICFILES_DIRS = [cls.static_files.directory] settings.STATIC_ROOT = cls.tmp settings.WHITENOISE_ROOT = cls.root_files.directory # Collect static files into STATIC_ROOT - call_command('collectstatic', verbosity=0, interactive=False) + call_command("collectstatic", verbosity=0, interactive=False) # Initialize test application cls.application = get_wsgi_application() cls.server = TestServer(cls.application) @@ -71,35 +70,41 @@ def test_versioned_file_cached_forever(self): url = storage.staticfiles_storage.url(self.static_files.js_path) response = self.server.get(url) self.assertEqual(response.content, self.static_files.js_content) - self.assertEqual(response.headers.get('Cache-Control'), - 'max-age={}, public, immutable'.format(WhiteNoiseMiddleware.FOREVER)) + self.assertEqual( + response.headers.get("Cache-Control"), + "max-age={}, public, immutable".format(WhiteNoiseMiddleware.FOREVER), + ) def test_unversioned_file_not_cached_forever(self): url = settings.STATIC_URL + self.static_files.js_path response = self.server.get(url) self.assertEqual(response.content, self.static_files.js_content) - self.assertEqual(response.headers.get('Cache-Control'), - 'max-age={}, public'.format(WhiteNoiseMiddleware.max_age)) + self.assertEqual( + response.headers.get("Cache-Control"), + "max-age={}, public".format(WhiteNoiseMiddleware.max_age), + ) def test_get_gzip(self): url = storage.staticfiles_storage.url(self.static_files.js_path) response = self.server.get(url) self.assertEqual(response.content, self.static_files.js_content) - self.assertEqual(response.headers['Content-Encoding'], 'gzip') - self.assertEqual(response.headers['Vary'], 'Accept-Encoding') + self.assertEqual(response.headers["Content-Encoding"], "gzip") + self.assertEqual(response.headers["Vary"], "Accept-Encoding") def test_get_brotli(self): url = storage.staticfiles_storage.url(self.static_files.js_path) - response = self.server.get(url, headers={'Accept-Encoding': 'gzip, br'}) - self.assertEqual(brotli.decompress(response.content), self.static_files.js_content) - self.assertEqual(response.headers['Content-Encoding'], 'br') - self.assertEqual(response.headers['Vary'], 'Accept-Encoding') + response = self.server.get(url, headers={"Accept-Encoding": "gzip, br"}) + self.assertEqual( + brotli.decompress(response.content), self.static_files.js_content + ) + self.assertEqual(response.headers["Content-Encoding"], "br") + self.assertEqual(response.headers["Vary"], "Accept-Encoding") def test_no_content_type_when_not_modified(self): - last_mod = 'Fri, 11 Apr 2100 11:47:06 GMT' + last_mod = "Fri, 11 Apr 2100 11:47:06 GMT" url = settings.STATIC_URL + self.static_files.js_path - response = self.server.get(url, headers={'If-Modified-Since': last_mod}) - self.assertNotIn('Content-Type', response.headers) + response = self.server.get(url, headers={"If-Modified-Since": last_mod}) + self.assertNotIn("Content-Type", response.headers) def test_get_nonascii_file(self): url = settings.STATIC_URL + self.static_files.nonascii_path @@ -114,10 +119,7 @@ class UseFindersTest(SimpleTestCase): @classmethod def setUpClass(cls): - cls.static_files = Files( - 'static', - js='app.js', - index='with-index/index.html') + cls.static_files = Files("static", js="app.js", index="with-index/index.html") settings.STATICFILES_DIRS = [cls.static_files.directory] settings.WHITENOISE_USE_FINDERS = True settings.WHITENOISE_AUTOREFRESH = cls.AUTOREFRESH @@ -139,32 +141,32 @@ def test_file_served_from_static_dir(self): self.assertEqual(response.content, self.static_files.js_content) def test_non_ascii_requests_safely_ignored(self): - response = self.server.get(settings.STATIC_URL + u"test\u263A") + response = self.server.get(settings.STATIC_URL + "test\u263A") self.assertEqual(404, response.status_code) def test_requests_for_directory_safely_ignored(self): - url = settings.STATIC_URL + 'directory' + url = settings.STATIC_URL + "directory" response = self.server.get(url) self.assertEqual(404, response.status_code) def test_index_file_served_at_directory_path(self): - path = self.static_files.index_path.rpartition('/')[0] + '/' + path = self.static_files.index_path.rpartition("/")[0] + "/" response = self.server.get(settings.STATIC_URL + path) self.assertEqual(response.content, self.static_files.index_content) def test_index_file_path_redirected(self): - directory_path = self.static_files.index_path.rpartition('/')[0] + '/' + directory_path = self.static_files.index_path.rpartition("/")[0] + "/" index_url = settings.STATIC_URL + self.static_files.index_path response = self.server.get(index_url, allow_redirects=False) - location = get_url_path(response.url, response.headers['Location']) + location = get_url_path(response.url, response.headers["Location"]) self.assertEqual(response.status_code, 302) self.assertEqual(location, settings.STATIC_URL + directory_path) def test_directory_path_without_trailing_slash_redirected(self): - directory_path = self.static_files.index_path.rpartition('/')[0] + '/' - directory_url = settings.STATIC_URL + directory_path.rstrip('/') + directory_path = self.static_files.index_path.rpartition("/")[0] + "/" + directory_url = settings.STATIC_URL + directory_path.rstrip("/") response = self.server.get(directory_url, allow_redirects=False) - location = get_url_path(response.url, response.headers['Location']) + location = get_url_path(response.url, response.headers["Location"]) self.assertEqual(response.status_code, 302) self.assertEqual(location, settings.STATIC_URL + directory_path) diff --git a/tests/test_runserver_nostatic.py b/tests/test_runserver_nostatic.py index 19dcc718..d8a3d31c 100644 --- a/tests/test_runserver_nostatic.py +++ b/tests/test_runserver_nostatic.py @@ -13,9 +13,10 @@ def get_command_instance(name): class RunserverNostaticTest(SimpleTestCase): - def test_command_output(self): - command = get_command_instance('runserver') - parser = command.create_parser('manage.py', 'runserver') - self.assertIn("Wrapped by 'whitenoise.runserver_nostatic'", parser.format_help()) - self.assertFalse(parser.get_default('use_static_handler')) + command = get_command_instance("runserver") + parser = command.create_parser("manage.py", "runserver") + self.assertIn( + "Wrapped by 'whitenoise.runserver_nostatic'", parser.format_help() + ) + self.assertFalse(parser.get_default("use_static_handler")) diff --git a/tests/test_storage.py b/tests/test_storage.py index 9845978f..f9623946 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -7,8 +7,7 @@ import django from django.conf import settings -from django.contrib.staticfiles.storage import ( - HashedFilesMixin, staticfiles_storage) +from django.contrib.staticfiles.storage import HashedFilesMixin, staticfiles_storage from django.core.management import call_command from django.test import SimpleTestCase from django.test.utils import override_settings @@ -30,16 +29,15 @@ def reset_lazy_object(obj): @override_settings() class StorageTestBase(SimpleTestCase): - @classmethod def setUpClass(cls): reset_lazy_object(staticfiles_storage) - cls.files = Files('static') + cls.files = Files("static") cls.tmp = TEXT_TYPE(tempfile.mkdtemp()) settings.STATICFILES_DIRS = [cls.files.directory] settings.STATIC_ROOT = cls.tmp with override_settings(**cls.get_settings()): - call_command('collectstatic', verbosity=0, interactive=False) + call_command("collectstatic", verbosity=0, interactive=False) super(StorageTestBase, cls).setUpClass() @classmethod @@ -50,55 +48,51 @@ def tearDownClass(cls): class CompressedStaticFilesStorageTest(StorageTestBase): - @classmethod def get_settings(self): return { - 'STATICFILES_STORAGE': - 'whitenoise.storage.CompressedStaticFilesStorage' + "STATICFILES_STORAGE": "whitenoise.storage.CompressedStaticFilesStorage" } def test_compressed_files_are_created(self): - for name in ['styles.css.gz', 'styles.css.br']: + for name in ["styles.css.gz", "styles.css.br"]: path = os.path.join(settings.STATIC_ROOT, name) self.assertTrue(os.path.exists(path)) class CompressedManifestStaticFilesStorageTest(StorageTestBase): - @classmethod def get_settings(self): return { - 'STATICFILES_STORAGE': - 'whitenoise.storage.CompressedManifestStaticFilesStorage', - 'WHITENOISE_KEEP_ONLY_HASHED_FILES': True + "STATICFILES_STORAGE": "whitenoise.storage.CompressedManifestStaticFilesStorage", + "WHITENOISE_KEEP_ONLY_HASHED_FILES": True, } def test_make_helpful_exception(self): class TriggerException(HashedFilesMixin): def exists(self, path): return False + exception = None try: - TriggerException().hashed_name('/missing/file.png') + TriggerException().hashed_name("/missing/file.png") except ValueError as e: exception = e helpful_exception = HelpfulExceptionMixin().make_helpful_exception( - exception, - 'styles/app.css' - ) + exception, "styles/app.css" + ) self.assertIsInstance(helpful_exception, MissingFileError) def test_unversioned_files_are_deleted(self): - name = 'styles.css' + name = "styles.css" versioned_url = staticfiles_storage.url(name) versioned_name = basename(versioned_url) - name_pattern = re.compile('^' + name.replace('.', r'\.([0-9a-f]+\.)?') + '$') + name_pattern = re.compile("^" + name.replace(".", r"\.([0-9a-f]+\.)?") + "$") remaining_files = [ - f for f in os.listdir(settings.STATIC_ROOT) - if name_pattern.match(f)] + f for f in os.listdir(settings.STATIC_ROOT) if name_pattern.match(f) + ] self.assertEqual([versioned_name], remaining_files) def test_manifest_file_is_left_in_place(self): - manifest_file = os.path.join(settings.STATIC_ROOT, 'staticfiles.json') + manifest_file = os.path.join(settings.STATIC_ROOT, "staticfiles.json") self.assertTrue(os.path.exists(manifest_file)) diff --git a/tests/test_whitenoise.py b/tests/test_whitenoise.py index 432b48fa..9af287d6 100644 --- a/tests/test_whitenoise.py +++ b/tests/test_whitenoise.py @@ -2,6 +2,7 @@ import tempfile import unittest from unittest import TestCase + try: from urllib.parse import urljoin except ImportError: @@ -20,15 +21,16 @@ # Update Py2 TestCase to support Py3 method names -if not hasattr(TestCase, 'assertRegex'): +if not hasattr(TestCase, "assertRegex"): + class Py3TestCase(TestCase): def assertRegex(self, *args, **kwargs): return self.assertRegexpMatches(*args, **kwargs) + TestCase = Py3TestCase class WhiteNoiseTest(TestCase): - @classmethod def setUpClass(cls): cls.files = cls.init_files() @@ -38,71 +40,82 @@ def setUpClass(cls): @staticmethod def init_files(): - return Files('assets', - js='subdir/javascript.js', - gzip='compressed.css', - gzipped='compressed.css.gz', - custom_mime='custom-mime.foobar', - index='with-index/index.html') + return Files( + "assets", + js="subdir/javascript.js", + gzip="compressed.css", + gzipped="compressed.css.gz", + custom_mime="custom-mime.foobar", + index="with-index/index.html", + ) @staticmethod def init_application(**kwargs): def custom_headers(headers, path, url): - if url.endswith('.css'): - headers['X-Is-Css-File'] = 'True' - kwargs.update(max_age=1000, - mimetypes={'.foobar': 'application/x-foo-bar'}, - add_headers_function=custom_headers, - index_file=True) + if url.endswith(".css"): + headers["X-Is-Css-File"] = "True" + + kwargs.update( + max_age=1000, + mimetypes={".foobar": "application/x-foo-bar"}, + add_headers_function=custom_headers, + index_file=True, + ) return WhiteNoise(demo_app, **kwargs) def test_get_file(self): response = self.server.get(self.files.js_url) self.assertEqual(response.content, self.files.js_content) - self.assertRegex(response.headers['Content-Type'], r'application/javascript\b') - self.assertRegex(response.headers['Content-Type'], r'.*\bcharset="utf-8"') + self.assertRegex(response.headers["Content-Type"], r"application/javascript\b") + self.assertRegex(response.headers["Content-Type"], r'.*\bcharset="utf-8"') def test_get_not_accept_gzip(self): - response = self.server.get(self.files.gzip_url, headers={'Accept-Encoding': ''}) + response = self.server.get(self.files.gzip_url, headers={"Accept-Encoding": ""}) self.assertEqual(response.content, self.files.gzip_content) - self.assertEqual(response.headers.get('Content-Encoding', ''), '') - self.assertEqual(response.headers['Vary'], 'Accept-Encoding') + self.assertEqual(response.headers.get("Content-Encoding", ""), "") + self.assertEqual(response.headers["Vary"], "Accept-Encoding") def test_get_accept_gzip(self): response = self.server.get(self.files.gzip_url) self.assertEqual(response.content, self.files.gzip_content) - self.assertEqual(response.headers['Content-Encoding'], 'gzip') - self.assertEqual(response.headers['Vary'], 'Accept-Encoding') + self.assertEqual(response.headers["Content-Encoding"], "gzip") + self.assertEqual(response.headers["Vary"], "Accept-Encoding") def test_cannot_directly_request_gzipped_file(self): - response = self.server.get(self.files.gzip_url + '.gz') + response = self.server.get(self.files.gzip_url + ".gz") self.assert_is_default_response(response) def test_not_modified_exact(self): response = self.server.get(self.files.js_url) - last_mod = response.headers['Last-Modified'] - response = self.server.get(self.files.js_url, headers={'If-Modified-Since': last_mod}) + last_mod = response.headers["Last-Modified"] + response = self.server.get( + self.files.js_url, headers={"If-Modified-Since": last_mod} + ) self.assertEqual(response.status_code, 304) def test_not_modified_future(self): - last_mod = 'Fri, 11 Apr 2100 11:47:06 GMT' - response = self.server.get(self.files.js_url, headers={'If-Modified-Since': last_mod}) + last_mod = "Fri, 11 Apr 2100 11:47:06 GMT" + response = self.server.get( + self.files.js_url, headers={"If-Modified-Since": last_mod} + ) self.assertEqual(response.status_code, 304) def test_modified(self): - last_mod = 'Fri, 11 Apr 2001 11:47:06 GMT' - response = self.server.get(self.files.js_url, headers={'If-Modified-Since': last_mod}) + last_mod = "Fri, 11 Apr 2001 11:47:06 GMT" + response = self.server.get( + self.files.js_url, headers={"If-Modified-Since": last_mod} + ) self.assertEqual(response.status_code, 200) def test_etag_matches(self): response = self.server.get(self.files.js_url) - etag = response.headers['ETag'] - response = self.server.get(self.files.js_url, headers={'If-None-Match': etag}) + etag = response.headers["ETag"] + response = self.server.get(self.files.js_url, headers={"If-None-Match": etag}) self.assertEqual(response.status_code, 304) def test_etag_doesnt_match(self): etag = '"594bd1d1-36"' - response = self.server.get(self.files.js_url, headers={'If-None-Match': etag}) + response = self.server.get(self.files.js_url, headers={"If-None-Match": etag}) self.assertEqual(response.status_code, 200) def test_etag_overrules_modified_since(self): @@ -111,135 +124,136 @@ def test_etag_overrules_modified_since(self): over the last modified time, so that deploy-rollbacks are handled correctly. """ headers = { - 'If-None-Match': '"594bd1d1-36"', - 'If-Modified-Since': 'Fri, 11 Apr 2100 11:47:06 GMT', + "If-None-Match": '"594bd1d1-36"', + "If-Modified-Since": "Fri, 11 Apr 2100 11:47:06 GMT", } response = self.server.get(self.files.js_url, headers=headers) self.assertEqual(response.status_code, 200) def test_max_age(self): response = self.server.get(self.files.js_url) - self.assertEqual(response.headers['Cache-Control'], 'max-age=1000, public') + self.assertEqual(response.headers["Cache-Control"], "max-age=1000, public") def test_other_requests_passed_through(self): - response = self.server.get('/%s/not/static' % TestServer.PREFIX) + response = self.server.get("/%s/not/static" % TestServer.PREFIX) self.assert_is_default_response(response) def test_non_ascii_requests_safely_ignored(self): - response = self.server.get(u'/{}/test\u263A'.format(TestServer.PREFIX)) + response = self.server.get(u"/{}/test\u263A".format(TestServer.PREFIX)) self.assert_is_default_response(response) def test_add_under_prefix(self): - prefix = '/prefix' + prefix = "/prefix" self.application.add_files(self.files.directory, prefix=prefix) - response = self.server.get('/{}{}/{}'.format(TestServer.PREFIX, prefix, self.files.js_path)) + response = self.server.get( + "/{}{}/{}".format(TestServer.PREFIX, prefix, self.files.js_path) + ) self.assertEqual(response.content, self.files.js_content) def test_response_has_allow_origin_header(self): response = self.server.get(self.files.js_url) - self.assertEqual(response.headers.get('Access-Control-Allow-Origin'), '*') + self.assertEqual(response.headers.get("Access-Control-Allow-Origin"), "*") def test_response_has_correct_content_length_header(self): response = self.server.get(self.files.js_url) - length = int(response.headers['Content-Length']) + length = int(response.headers["Content-Length"]) self.assertEqual(length, len(self.files.js_content)) def test_gzip_response_has_correct_content_length_header(self): response = self.server.get(self.files.gzip_url) - length = int(response.headers['Content-Length']) + length = int(response.headers["Content-Length"]) self.assertEqual(length, len(self.files.gzipped_content)) def test_post_request_returns_405(self): - response = self.server.request('post', self.files.js_url) + response = self.server.request("post", self.files.js_url) self.assertEqual(response.status_code, 405) def test_head_request_has_no_body(self): - response = self.server.request('head', self.files.js_url) + response = self.server.request("head", self.files.js_url) self.assertEqual(response.status_code, 200) self.assertFalse(response.content) def test_custom_mimetype(self): response = self.server.get(self.files.custom_mime_url) - self.assertRegex(response.headers['Content-Type'], r'application/x-foo-bar\b') + self.assertRegex(response.headers["Content-Type"], r"application/x-foo-bar\b") def test_custom_headers(self): response = self.server.get(self.files.gzip_url) - self.assertEqual(response.headers['x-is-css-file'], 'True') + self.assertEqual(response.headers["x-is-css-file"], "True") def test_index_file_served_at_directory_path(self): - directory_url = self.files.index_url.rpartition('/')[0] + '/' + directory_url = self.files.index_url.rpartition("/")[0] + "/" response = self.server.get(directory_url) self.assertEqual(response.content, self.files.index_content) def test_index_file_path_redirected(self): - directory_url = self.files.index_url.rpartition('/')[0] + '/' + directory_url = self.files.index_url.rpartition("/")[0] + "/" response = self.server.get(self.files.index_url, allow_redirects=False) - location = urljoin(self.files.index_url, response.headers['Location']) + location = urljoin(self.files.index_url, response.headers["Location"]) self.assertEqual(response.status_code, 302) self.assertEqual(location, directory_url) def test_directory_path_without_trailing_slash_redirected(self): - directory_url = self.files.index_url.rpartition('/')[0] + '/' - no_slash_url = directory_url.rstrip('/') + directory_url = self.files.index_url.rpartition("/")[0] + "/" + no_slash_url = directory_url.rstrip("/") response = self.server.get(no_slash_url, allow_redirects=False) - location = urljoin(no_slash_url, response.headers['Location']) + location = urljoin(no_slash_url, response.headers["Location"]) self.assertEqual(response.status_code, 302) self.assertEqual(location, directory_url) def test_request_initial_bytes(self): - response = self.server.get( - self.files.js_url, headers={'Range': 'bytes=0-13'}) + response = self.server.get(self.files.js_url, headers={"Range": "bytes=0-13"}) self.assertEqual(response.content, self.files.js_content[0:14]) def test_request_trailing_bytes(self): - response = self.server.get( - self.files.js_url, headers={'Range': 'bytes=-3'}) + response = self.server.get(self.files.js_url, headers={"Range": "bytes=-3"}) self.assertEqual(response.content, self.files.js_content[-3:]) def test_request_middle_bytes(self): - response = self.server.get( - self.files.js_url, headers={'Range': 'bytes=21-30'}) + response = self.server.get(self.files.js_url, headers={"Range": "bytes=21-30"}) self.assertEqual(response.content, self.files.js_content[21:31]) def test_overlong_ranges_truncated(self): response = self.server.get( - self.files.js_url, headers={'Range': 'bytes=21-100000'}) + self.files.js_url, headers={"Range": "bytes=21-100000"} + ) self.assertEqual(response.content, self.files.js_content[21:]) def test_overlong_trailing_ranges_return_entire_file(self): response = self.server.get( - self.files.js_url, headers={'Range': 'bytes=-100000'}) + self.files.js_url, headers={"Range": "bytes=-100000"} + ) self.assertEqual(response.content, self.files.js_content) def test_out_of_range_error(self): response = self.server.get( - self.files.js_url, headers={'Range': 'bytes=10000-11000'}) + self.files.js_url, headers={"Range": "bytes=10000-11000"} + ) self.assertEqual(response.status_code, 416) self.assertEqual( - response.headers['Content-Range'], - 'bytes */%s' % len(self.files.js_content)) + response.headers["Content-Range"], "bytes */%s" % len(self.files.js_content) + ) def test_warn_about_missing_directories(self): with warnings.catch_warnings(record=True) as warning_list: - self.application.add_files(u'/dev/null/nosuchdir\u2713') + self.application.add_files(u"/dev/null/nosuchdir\u2713") self.assertEqual(len(warning_list), 1) def test_handles_missing_path_info_key(self): - response = self.application( - environ={}, start_response=lambda *args: None) + response = self.application(environ={}, start_response=lambda *args: None) self.assertTrue(response) def test_cant_read_absolute_paths_on_windows(self): response = self.server.get( - r'/{}/C:/Windows/System.ini'.format(TestServer.PREFIX)) + r"/{}/C:/Windows/System.ini".format(TestServer.PREFIX) + ) self.assert_is_default_response(response) def assert_is_default_response(self, response): - self.assertIn('Hello world!', response.text) + self.assertIn("Hello world!", response.text) class WhiteNoiseAutorefresh(WhiteNoiseTest): - @classmethod def setUpClass(cls): cls.files = cls.init_files() @@ -251,7 +265,7 @@ def setUpClass(cls): super(WhiteNoiseTest, cls).setUpClass() def test_no_error_on_very_long_filename(self): - response = self.server.get('/blah' * 1000) + response = self.server.get("/blah" * 1000) self.assertNotEqual(response.status_code, 500) def test_warn_about_missing_directories(self): @@ -278,16 +292,16 @@ def copytree(src, dst): class WhiteNoiseUnitTests(TestCase): - def test_immutable_file_test_accepts_regex(self): - instance = WhiteNoise(None, immutable_file_test=r'\.test$') - self.assertTrue(instance.immutable_file_test('', '/myfile.test')) - self.assertFalse(instance.immutable_file_test('', 'file.test.txt')) + instance = WhiteNoise(None, immutable_file_test=r"\.test$") + self.assertTrue(instance.immutable_file_test("", "/myfile.test")) + self.assertFalse(instance.immutable_file_test("", "file.test.txt")) @unittest.skipIf(sys.version_info < (3, 4), "Pathlib was added in Python 3.4") def test_directory_path_can_be_pathlib_instance(self): from pathlib import Path - root = Path(Files('root').directory) + + root = Path(Files("root").directory) # Check we can construct instance without it blowing up WhiteNoise(None, root=root, autorefresh=True) @@ -296,12 +310,11 @@ class FakeStatEntry(object): st_mtime = 0 st_size = 1024 st_mode = stat.S_IFREG - stat_cache = { - __file__: FakeStatEntry() - } + + stat_cache = {__file__: FakeStatEntry()} responder = StaticFile(__file__, [], stat_cache=stat_cache) - response = responder.get_response('GET', {}) + response = responder.get_response("GET", {}) response.file.close() headers_dict = Headers(response.headers) - self.assertNotIn('Last-Modified', headers_dict) - self.assertNotIn('ETag', headers_dict) + self.assertNotIn("Last-Modified", headers_dict) + self.assertNotIn("ETag", headers_dict) diff --git a/tests/utils.py b/tests/utils.py index a230f423..4a5d21c9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -6,11 +6,10 @@ import requests -warnings.filterwarnings(action='ignore', category=DeprecationWarning, - module='requests') +warnings.filterwarnings(action="ignore", category=DeprecationWarning, module="requests") -TEST_FILE_PATH = os.path.join(os.path.dirname(__file__), 'test_files') +TEST_FILE_PATH = os.path.join(os.path.dirname(__file__), "test_files") class SilentWSGIHandler(WSGIRequestHandler): @@ -23,26 +22,28 @@ class TestServer(object): Wraps a WSGI application and allows you to make real HTTP requests against it """ - PREFIX = 'subdir' + + PREFIX = "subdir" def __init__(self, application): self.application = application - self.server = make_server('127.0.0.1', 0, self.serve_under_prefix, - handler_class=SilentWSGIHandler) + self.server = make_server( + "127.0.0.1", 0, self.serve_under_prefix, handler_class=SilentWSGIHandler + ) def serve_under_prefix(self, environ, start_response): prefix = shift_path_info(environ) if prefix != self.PREFIX: - start_response('404 Not Found', []) + start_response("404 Not Found", []) return [] else: return self.application(environ, start_response) def get(self, *args, **kwargs): - return self.request('get', *args, **kwargs) + return self.request("get", *args, **kwargs) def request(self, method, path, *args, **kwargs): - url = u'http://{0[0]}:{0[1]}{1}'.format(self.server.server_address, path) + url = u"http://{0[0]}:{0[1]}{1}".format(self.server.server_address, path) thread = threading.Thread(target=self.server.handle_request) thread.start() response = requests.request(method, url, *args, **kwargs) @@ -54,9 +55,9 @@ class Files(object): def __init__(self, directory, **files): self.directory = os.path.join(TEST_FILE_PATH, directory) for name, path in files.items(): - url = u'/{}/{}' .format(TestServer.PREFIX, path) - with open(os.path.join(self.directory, path), 'rb') as f: + url = u"/{}/{}".format(TestServer.PREFIX, path) + with open(os.path.join(self.directory, path), "rb") as f: content = f.read() - setattr(self, name + '_path', path) - setattr(self, name + '_url', url) - setattr(self, name + '_content', content) + setattr(self, name + "_path", path) + setattr(self, name + "_url", url) + setattr(self, name + "_content", content) diff --git a/whitenoise/__init__.py b/whitenoise/__init__.py index ea8a9194..e5770e68 100644 --- a/whitenoise/__init__.py +++ b/whitenoise/__init__.py @@ -1,5 +1,5 @@ from .base import WhiteNoise -__version__ = '4.1.3' +__version__ = "4.1.3" -__all__ = ['WhiteNoise'] +__all__ = ["WhiteNoise"] diff --git a/whitenoise/base.py b/whitenoise/base.py index 118b955e..1f69de31 100644 --- a/whitenoise/base.py +++ b/whitenoise/base.py @@ -8,20 +8,30 @@ from .media_types import MediaTypes from .scantree import scantree from .responders import StaticFile, MissingFileError, IsDirectoryError, Redirect -from .string_utils import (decode_if_byte_string, decode_path_info, - ensure_leading_trailing_slash) +from .string_utils import ( + decode_if_byte_string, + decode_path_info, + ensure_leading_trailing_slash, +) class WhiteNoise(object): # Ten years is what nginx sets a max age if you use 'expires max;' # so we'll follow its lead - FOREVER = 10*365*24*60*60 + FOREVER = 10 * 365 * 24 * 60 * 60 # Attributes that can be set by keyword args in the constructor - config_attrs = ('autorefresh', 'max_age', 'allow_all_origins', 'charset', - 'mimetypes', 'add_headers_function', 'index_file', - 'immutable_file_test') + config_attrs = ( + "autorefresh", + "max_age", + "allow_all_origins", + "charset", + "mimetypes", + "add_headers_function", + "index_file", + "immutable_file_test", + ) # Re-check the filesystem on every request so that any changes are # automatically picked up. NOTE: For use in development only, not supported # in production @@ -33,7 +43,7 @@ class WhiteNoise(object): # webfonts in Firefox) still work as expected when your static files are # served from a CDN, rather than your primary domain. allow_all_origins = True - charset = 'utf-8' + charset = "utf-8" # Custom mime types mimetypes = None # Callback for adding custom logic when setting headers @@ -51,14 +61,15 @@ def __init__(self, application, root=None, prefix=None, **kwargs): value = decode_if_byte_string(value) setattr(self, attr, value) if kwargs: - raise TypeError("Unexpected keyword argument '{0}'".format( - list(kwargs.keys())[0])) + raise TypeError( + "Unexpected keyword argument '{0}'".format(list(kwargs.keys())[0]) + ) self.media_types = MediaTypes(extra_types=self.mimetypes) self.application = application self.files = {} self.directories = [] if self.index_file is True: - self.index_file = 'index.html' + self.index_file = "index.html" if not callable(self.immutable_file_test): regex = re.compile(self.immutable_file_test) self.immutable_file_test = lambda path, url: bool(regex.search(url)) @@ -66,7 +77,7 @@ def __init__(self, application, root=None, prefix=None, **kwargs): self.add_files(root, prefix) def __call__(self, environ, start_response): - path = decode_path_info(environ.get('PATH_INFO', '')) + path = decode_path_info(environ.get("PATH_INFO", "")) if self.autorefresh: static_file = self.find_file(path) else: @@ -78,11 +89,11 @@ def __call__(self, environ, start_response): @staticmethod def serve(static_file, environ, start_response): - response = static_file.get_response(environ['REQUEST_METHOD'], environ) - status_line = '{} {}'.format(response.status, response.status.phrase) + response = static_file.get_response(environ["REQUEST_METHOD"], environ) + status_line = "{} {}".format(response.status, response.status.phrase) start_response(status_line, list(response.headers)) if response.file is not None: - file_wrapper = environ.get('wsgi.file_wrapper', FileWrapper) + file_wrapper = environ.get("wsgi.file_wrapper", FileWrapper) return file_wrapper(response.file) else: return [] @@ -102,24 +113,24 @@ def add_files(self, root, prefix=None): if os.path.isdir(root): self.update_files_dictionary(root, prefix) else: - warnings.warn(u'No directory at: {}'.format(root)) + warnings.warn(u"No directory at: {}".format(root)) def update_files_dictionary(self, root, prefix): # Build a mapping from paths to the results of `os.stat` calls # so we only have to touch the filesystem once stat_cache = dict(scantree(root)) for path in stat_cache.keys(): - relative_path = path[len(root):] - relative_url = relative_path.replace('\\', '/') + relative_path = path[len(root) :] + relative_url = relative_path.replace("\\", "/") url = prefix + relative_url self.add_file_to_dictionary(url, path, stat_cache=stat_cache) def add_file_to_dictionary(self, url, path, stat_cache=None): if self.is_compressed_variant(path, stat_cache=stat_cache): return - if self.index_file and url.endswith('/' + self.index_file): - index_url = url[:-len(self.index_file)] - index_no_slash = index_url.rstrip('/') + if self.index_file and url.endswith("/" + self.index_file): + index_url = url[: -len(self.index_file)] + index_no_slash = index_url.rstrip("/") self.files[url] = self.redirect(url, index_url) self.files[index_no_slash] = self.redirect(index_no_slash, index_url) url = index_url @@ -128,7 +139,7 @@ def add_file_to_dictionary(self, url, path, stat_cache=None): def find_file(self, url): # Optimization: bail early if the URL can never match a file - if not self.index_file and url.endswith('/'): + if not self.index_file and url.endswith("/"): return if not self.url_is_canonical(url): return @@ -141,7 +152,7 @@ def find_file(self, url): def candidate_paths_for_url(self, url): for root, prefix in self.directories: if url.startswith(prefix): - path = os.path.join(root, url[len(prefix):]) + path = os.path.join(root, url[len(prefix) :]) if os.path.commonprefix((root, path)) == root: yield path @@ -154,18 +165,18 @@ def find_file_at_path(self, path, url): return self.get_static_file(path, url) def find_file_at_path_with_indexes(self, path, url): - if url.endswith('/'): + if url.endswith("/"): path = os.path.join(path, self.index_file) return self.get_static_file(path, url) - elif url.endswith('/' + self.index_file): + elif url.endswith("/" + self.index_file): if os.path.isfile(path): - return self.redirect(url, url[:-len(self.index_file)]) + return self.redirect(url, url[: -len(self.index_file)]) else: try: return self.get_static_file(path, url) except IsDirectoryError: if os.path.isfile(os.path.join(path, self.index_file)): - return self.redirect(url, url + '/') + return self.redirect(url, url + "/") raise MissingFileError(path) @staticmethod @@ -174,16 +185,16 @@ def url_is_canonical(url): Check that the URL path is in canonical format i.e. has normalised slashes and no path traversal elements """ - if '\\' in url: + if "\\" in url: return False normalised = normpath(url) - if url.endswith('/') and url != '/': - normalised += '/' + if url.endswith("/") and url != "/": + normalised += "/" return normalised == url @staticmethod def is_compressed_variant(path, stat_cache=None): - if path[-3:] in ('.gz', '.br'): + if path[-3:] in (".gz", ".br"): uncompressed_path = path[:-3] if stat_cache is None: return os.path.isfile(uncompressed_path) @@ -199,31 +210,31 @@ def get_static_file(self, path, url, stat_cache=None): self.add_mime_headers(headers, path, url) self.add_cache_headers(headers, path, url) if self.allow_all_origins: - headers['Access-Control-Allow-Origin'] = '*' + headers["Access-Control-Allow-Origin"] = "*" if self.add_headers_function: self.add_headers_function(headers, path, url) return StaticFile( - path, headers.items(), - stat_cache=stat_cache, - encodings={ - 'gzip': path + '.gz', 'br': path + '.br'}) + path, + headers.items(), + stat_cache=stat_cache, + encodings={"gzip": path + ".gz", "br": path + ".br"}, + ) def add_mime_headers(self, headers, path, url): media_type = self.media_types.get_type(path) - if (media_type.startswith('text/') or - media_type == 'application/javascript'): - params = {'charset': str(self.charset)} + if media_type.startswith("text/") or media_type == "application/javascript": + params = {"charset": str(self.charset)} else: params = {} - headers.add_header('Content-Type', str(media_type), **params) + headers.add_header("Content-Type", str(media_type), **params) def add_cache_headers(self, headers, path, url): if self.immutable_file_test(path, url): - headers['Cache-Control'] = \ - 'max-age={0}, public, immutable'.format(self.FOREVER) + headers["Cache-Control"] = "max-age={0}, public, immutable".format( + self.FOREVER + ) elif self.max_age is not None: - headers['Cache-Control'] = \ - 'max-age={0}, public'.format(self.max_age) + headers["Cache-Control"] = "max-age={0}, public".format(self.max_age) def immutable_file_test(self, path, url): """ @@ -239,16 +250,14 @@ def redirect(self, from_url, to_url): We use relative redirects as we don't know the absolute URL the app is being hosted under """ - if to_url == from_url + '/': - relative_url = from_url.split('/')[-1] + '/' + if to_url == from_url + "/": + relative_url = from_url.split("/")[-1] + "/" elif from_url == to_url + self.index_file: - relative_url = './' + relative_url = "./" else: - raise ValueError( - 'Cannot handle redirect: {} > {}'.format(from_url, to_url)) + raise ValueError("Cannot handle redirect: {} > {}".format(from_url, to_url)) if self.max_age is not None: - headers = { - 'Cache-Control': 'max-age={0}, public'.format(self.max_age)} + headers = {"Cache-Control": "max-age={0}, public".format(self.max_age)} else: headers = {} return Redirect(relative_url, headers=headers) diff --git a/whitenoise/compress.py b/whitenoise/compress.py index 83d9d9e9..2fcb2539 100644 --- a/whitenoise/compress.py +++ b/whitenoise/compress.py @@ -3,6 +3,7 @@ import gzip import os import re + try: from io import BytesIO except ImportError: @@ -10,6 +11,7 @@ try: import brotli + brotli_installed = True except ImportError: brotli_installed = False @@ -20,16 +22,30 @@ class Compressor(object): # Extensions that it's not worth trying to compress SKIP_COMPRESS_EXTENSIONS = ( # Images - 'jpg', 'jpeg', 'png', 'gif', 'webp', + "jpg", + "jpeg", + "png", + "gif", + "webp", # Compressed files - 'zip', 'gz', 'tgz', 'bz2', 'tbz', 'xz', 'br', + "zip", + "gz", + "tgz", + "bz2", + "tbz", + "xz", + "br", # Flash - 'swf', 'flv', + "swf", + "flv", # Fonts - 'woff', 'woff2') + "woff", + "woff2", + ) - def __init__(self, extensions=None, use_gzip=True, use_brotli=True, - log=print, quiet=False): + def __init__( + self, extensions=None, use_gzip=True, use_brotli=True, log=print, quiet=False + ): if extensions is None: extensions = self.SKIP_COMPRESS_EXTENSIONS self.extension_re = self.get_extension_re(extensions) @@ -41,11 +57,11 @@ def __init__(self, extensions=None, use_gzip=True, use_brotli=True, @staticmethod def get_extension_re(extensions): if not extensions: - return re.compile('^$') + return re.compile("^$") else: return re.compile( - r'\.({0})$'.format('|'.join(map(re.escape, extensions))), - re.IGNORECASE) + r"\.({0})$".format("|".join(map(re.escape, extensions))), re.IGNORECASE + ) def should_compress(self, filename): return not self.extension_re.search(filename) @@ -54,29 +70,30 @@ def log(self, message): pass def compress(self, path): - with open(path, 'rb') as f: + with open(path, "rb") as f: stat_result = os.fstat(f.fileno()) data = f.read() size = len(data) if self.use_brotli: compressed = self.compress_brotli(data) - if self.is_compressed_effectively('Brotli', path, size, compressed): - yield self.write_data(path, compressed, '.br', stat_result) + if self.is_compressed_effectively("Brotli", path, size, compressed): + yield self.write_data(path, compressed, ".br", stat_result) else: # If Brotli compression wasn't effective gzip won't be either return if self.use_gzip: compressed = self.compress_gzip(data) - if self.is_compressed_effectively('Gzip', path, size, compressed): - yield self.write_data(path, compressed, '.gz', stat_result) + if self.is_compressed_effectively("Gzip", path, size, compressed): + yield self.write_data(path, compressed, ".gz", stat_result) @staticmethod def compress_gzip(data): output = BytesIO() # Explicitly set mtime to 0 so gzip content is fully determined # by file content (0 = "no timestamp" according to gzip spec) - with gzip.GzipFile(filename='', mode='wb', fileobj=output, - compresslevel=9, mtime=0) as gz_file: + with gzip.GzipFile( + filename="", mode="wb", fileobj=output, compresslevel=9, mtime=0 + ) as gz_file: gz_file.write(data) return output.getvalue() @@ -92,17 +109,22 @@ def is_compressed_effectively(self, encoding_name, path, orig_size, data): ratio = compressed_size / orig_size is_effective = ratio <= 0.95 if is_effective: - self.log('{0} compressed {1} ({2}K -> {3}K)'.format( - encoding_name, path, orig_size // 1024, - compressed_size // 1024)) + self.log( + "{0} compressed {1} ({2}K -> {3}K)".format( + encoding_name, path, orig_size // 1024, compressed_size // 1024 + ) + ) else: - self.log('Skipping {0} ({1} compression not effective)'.format( - path, encoding_name)) + self.log( + "Skipping {0} ({1} compression not effective)".format( + path, encoding_name + ) + ) return is_effective def write_data(self, path, data, suffix, stat_result): filename = path + suffix - with open(filename, 'wb') as f: + with open(filename, "wb") as f: f.write(data) os.utime(filename, (stat_result.st_atime, stat_result.st_mtime)) return filename @@ -118,25 +140,37 @@ def main(root, **kwargs): pass -if __name__ == '__main__': +if __name__ == "__main__": import argparse + parser = argparse.ArgumentParser( - description="Search for all files inside *not* matching " - " and produce compressed versions with " - "'.gz' and '.br' suffixes (as long as this results in a " - "smaller file)") - parser.add_argument('-q', '--quiet', help="Don't produce log output", - action='store_true') - parser.add_argument('--no-gzip', help="Don't produce gzip '.gz' files", - action='store_false', dest='use_gzip') - parser.add_argument('--no-brotli', help="Don't produce brotli '.br' files", - action='store_false', dest='use_brotli') - parser.add_argument('root', help='Path root from which to search for files') - parser.add_argument('extensions', - nargs='*', - help='File extensions to exclude from compression ' - '(default: {})'.format(', '.join( - Compressor.SKIP_COMPRESS_EXTENSIONS)), - default=Compressor.SKIP_COMPRESS_EXTENSIONS) + description="Search for all files inside *not* matching " + " and produce compressed versions with " + "'.gz' and '.br' suffixes (as long as this results in a " + "smaller file)" + ) + parser.add_argument( + "-q", "--quiet", help="Don't produce log output", action="store_true" + ) + parser.add_argument( + "--no-gzip", + help="Don't produce gzip '.gz' files", + action="store_false", + dest="use_gzip", + ) + parser.add_argument( + "--no-brotli", + help="Don't produce brotli '.br' files", + action="store_false", + dest="use_brotli", + ) + parser.add_argument("root", help="Path root from which to search for files") + parser.add_argument( + "extensions", + nargs="*", + help="File extensions to exclude from compression " + "(default: {})".format(", ".join(Compressor.SKIP_COMPRESS_EXTENSIONS)), + default=Compressor.SKIP_COMPRESS_EXTENSIONS, + ) args = parser.parse_args() main(**vars(args)) diff --git a/whitenoise/django.py b/whitenoise/django.py index 57c69693..07756b5e 100644 --- a/whitenoise/django.py +++ b/whitenoise/django.py @@ -1,6 +1,7 @@ raise ImportError( - '\n\n' - 'Your WhiteNoise configuration is incompatible with WhiteNoise v4.0\n' - 'This can be fixed by following the upgrade instructions at:\n' - 'http://whitenoise.evans.io/en/stable/changelog.html#v4-0\n' - '\n') + "\n\n" + "Your WhiteNoise configuration is incompatible with WhiteNoise v4.0\n" + "This can be fixed by following the upgrade instructions at:\n" + "http://whitenoise.evans.io/en/stable/changelog.html#v4-0\n" + "\n" +) diff --git a/whitenoise/httpstatus_backport.py b/whitenoise/httpstatus_backport.py index 719c78cb..4275849b 100644 --- a/whitenoise/httpstatus_backport.py +++ b/whitenoise/httpstatus_backport.py @@ -16,9 +16,9 @@ def __new__(cls, code, phrase): return instance -HTTPStatus.OK = HTTPStatus(200, 'OK') -HTTPStatus.PARTIAL_CONTENT = HTTPStatus(206, 'Partial Content') -HTTPStatus.FOUND = HTTPStatus(302, 'Found') -HTTPStatus.NOT_MODIFIED = HTTPStatus(304, 'Not Modified') -HTTPStatus.METHOD_NOT_ALLOWED = HTTPStatus(405, 'Method Not Allowed') -HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE = HTTPStatus(416, 'Range Not Satisfiable') +HTTPStatus.OK = HTTPStatus(200, "OK") +HTTPStatus.PARTIAL_CONTENT = HTTPStatus(206, "Partial Content") +HTTPStatus.FOUND = HTTPStatus(302, "Found") +HTTPStatus.NOT_MODIFIED = HTTPStatus(304, "Not Modified") +HTTPStatus.METHOD_NOT_ALLOWED = HTTPStatus(405, "Method Not Allowed") +HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE = HTTPStatus(416, "Range Not Satisfiable") diff --git a/whitenoise/media_types.py b/whitenoise/media_types.py index 3f5fdfa3..0cd41c12 100644 --- a/whitenoise/media_types.py +++ b/whitenoise/media_types.py @@ -2,8 +2,7 @@ class MediaTypes(object): - - def __init__(self, default='application/octet-stream', extra_types=None): + def __init__(self, default="application/octet-stream", extra_types=None): self.types_map = default_types() self.default = default if extra_types: @@ -27,103 +26,103 @@ def default_types(): """ return { - '.3gp': 'video/3gpp', - '.3gpp': 'video/3gpp', - '.7z': 'application/x-7z-compressed', - '.ai': 'application/postscript', - '.asf': 'video/x-ms-asf', - '.asx': 'video/x-ms-asf', - '.atom': 'application/atom+xml', - '.avi': 'video/x-msvideo', - '.bmp': 'image/x-ms-bmp', - '.cco': 'application/x-cocoa', - '.crt': 'application/x-x509-ca-cert', - '.css': 'text/css', - '.der': 'application/x-x509-ca-cert', - '.doc': 'application/msword', - '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - '.ear': 'application/java-archive', - '.eot': 'application/vnd.ms-fontobject', - '.eps': 'application/postscript', - '.flv': 'video/x-flv', - '.gif': 'image/gif', - '.hqx': 'application/mac-binhex40', - '.htc': 'text/x-component', - '.htm': 'text/html', - '.html': 'text/html', - '.ico': 'image/x-icon', - '.jad': 'text/vnd.sun.j2me.app-descriptor', - '.jar': 'application/java-archive', - '.jardiff': 'application/x-java-archive-diff', - '.jng': 'image/x-jng', - '.jnlp': 'application/x-java-jnlp-file', - '.jpeg': 'image/jpeg', - '.jpg': 'image/jpeg', - '.js': 'application/javascript', - '.json': 'application/json', - '.kar': 'audio/midi', - '.kml': 'application/vnd.google-earth.kml+xml', - '.kmz': 'application/vnd.google-earth.kmz', - '.m3u8': 'application/vnd.apple.mpegurl', - '.m4a': 'audio/x-m4a', - '.m4v': 'video/x-m4v', - '.mid': 'audio/midi', - '.midi': 'audio/midi', - '.mml': 'text/mathml', - '.mng': 'video/x-mng', - '.mov': 'video/quicktime', - '.mp3': 'audio/mpeg', - '.mp4': 'video/mp4', - '.mpeg': 'video/mpeg', - '.mpg': 'video/mpeg', - '.ogg': 'audio/ogg', - '.pdb': 'application/x-pilot', - '.pdf': 'application/pdf', - '.pem': 'application/x-x509-ca-cert', - '.pl': 'application/x-perl', - '.pm': 'application/x-perl', - '.png': 'image/png', - '.ppt': 'application/vnd.ms-powerpoint', - '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - '.prc': 'application/x-pilot', - '.ps': 'application/postscript', - '.ra': 'audio/x-realaudio', - '.rar': 'application/x-rar-compressed', - '.rpm': 'application/x-redhat-package-manager', - '.rss': 'application/rss+xml', - '.rtf': 'application/rtf', - '.run': 'application/x-makeself', - '.sea': 'application/x-sea', - '.shtml': 'text/html', - '.sit': 'application/x-stuffit', - '.svg': 'image/svg+xml', - '.svgz': 'image/svg+xml', - '.swf': 'application/x-shockwave-flash', - '.tcl': 'application/x-tcl', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.tk': 'application/x-tcl', - '.ts': 'video/mp2t', - '.txt': 'text/plain', - '.wasm': 'application/wasm', - '.war': 'application/java-archive', - '.wbmp': 'image/vnd.wap.wbmp', - '.webm': 'video/webm', - '.webp': 'image/webp', - '.wml': 'text/vnd.wap.wml', - '.wmlc': 'application/vnd.wap.wmlc', - '.wmv': 'video/x-ms-wmv', - '.woff': 'application/font-woff', - '.woff2': 'font/woff2', - '.xhtml': 'application/xhtml+xml', - '.xls': 'application/vnd.ms-excel', - '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - '.xml': 'text/xml', - '.xpi': 'application/x-xpinstall', - '.xspf': 'application/xspf+xml', - '.zip': 'application/zip', - 'apple-app-site-association': 'application/pkc7-mime', + ".3gp": "video/3gpp", + ".3gpp": "video/3gpp", + ".7z": "application/x-7z-compressed", + ".ai": "application/postscript", + ".asf": "video/x-ms-asf", + ".asx": "video/x-ms-asf", + ".atom": "application/atom+xml", + ".avi": "video/x-msvideo", + ".bmp": "image/x-ms-bmp", + ".cco": "application/x-cocoa", + ".crt": "application/x-x509-ca-cert", + ".css": "text/css", + ".der": "application/x-x509-ca-cert", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".ear": "application/java-archive", + ".eot": "application/vnd.ms-fontobject", + ".eps": "application/postscript", + ".flv": "video/x-flv", + ".gif": "image/gif", + ".hqx": "application/mac-binhex40", + ".htc": "text/x-component", + ".htm": "text/html", + ".html": "text/html", + ".ico": "image/x-icon", + ".jad": "text/vnd.sun.j2me.app-descriptor", + ".jar": "application/java-archive", + ".jardiff": "application/x-java-archive-diff", + ".jng": "image/x-jng", + ".jnlp": "application/x-java-jnlp-file", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "application/javascript", + ".json": "application/json", + ".kar": "audio/midi", + ".kml": "application/vnd.google-earth.kml+xml", + ".kmz": "application/vnd.google-earth.kmz", + ".m3u8": "application/vnd.apple.mpegurl", + ".m4a": "audio/x-m4a", + ".m4v": "video/x-m4v", + ".mid": "audio/midi", + ".midi": "audio/midi", + ".mml": "text/mathml", + ".mng": "video/x-mng", + ".mov": "video/quicktime", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".mpeg": "video/mpeg", + ".mpg": "video/mpeg", + ".ogg": "audio/ogg", + ".pdb": "application/x-pilot", + ".pdf": "application/pdf", + ".pem": "application/x-x509-ca-cert", + ".pl": "application/x-perl", + ".pm": "application/x-perl", + ".png": "image/png", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".prc": "application/x-pilot", + ".ps": "application/postscript", + ".ra": "audio/x-realaudio", + ".rar": "application/x-rar-compressed", + ".rpm": "application/x-redhat-package-manager", + ".rss": "application/rss+xml", + ".rtf": "application/rtf", + ".run": "application/x-makeself", + ".sea": "application/x-sea", + ".shtml": "text/html", + ".sit": "application/x-stuffit", + ".svg": "image/svg+xml", + ".svgz": "image/svg+xml", + ".swf": "application/x-shockwave-flash", + ".tcl": "application/x-tcl", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".tk": "application/x-tcl", + ".ts": "video/mp2t", + ".txt": "text/plain", + ".wasm": "application/wasm", + ".war": "application/java-archive", + ".wbmp": "image/vnd.wap.wbmp", + ".webm": "video/webm", + ".webp": "image/webp", + ".wml": "text/vnd.wap.wml", + ".wmlc": "application/vnd.wap.wmlc", + ".wmv": "video/x-ms-wmv", + ".woff": "application/font-woff", + ".woff2": "font/woff2", + ".xhtml": "application/xhtml+xml", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xml": "text/xml", + ".xpi": "application/x-xpinstall", + ".xspf": "application/xspf+xml", + ".zip": "application/zip", + "apple-app-site-association": "application/pkc7-mime", # Adobe Products - see: # https://www.adobe.com/devnet-docs/acrobatetk/tools/AppSec/xdomain.html#policy-file-host-basics - 'crossdomain.xml': 'text/x-cross-domain-policy' + "crossdomain.xml": "text/x-cross-domain-policy", } diff --git a/whitenoise/middleware.py b/whitenoise/middleware.py index 31746752..7415ccdd 100644 --- a/whitenoise/middleware.py +++ b/whitenoise/middleware.py @@ -7,6 +7,7 @@ from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles import finders from django.http import FileResponse + try: from urllib.parse import urlparse # PY3 except ImportError: @@ -16,7 +17,7 @@ from .string_utils import decode_if_byte_string, ensure_leading_trailing_slash -__all__ = ['WhiteNoiseMiddleware'] +__all__ = ["WhiteNoiseMiddleware"] class WhiteNoiseMiddleware(WhiteNoise): @@ -28,8 +29,7 @@ class WhiteNoiseMiddleware(WhiteNoise): either MIDDLEWARE or MIDDLEWARE_CLASSES. """ - config_attrs = WhiteNoise.config_attrs + ( - 'root', 'use_finders', 'static_prefix') + config_attrs = WhiteNoise.config_attrs + ("root", "use_finders", "static_prefix") root = None use_finders = False static_prefix = None @@ -66,7 +66,7 @@ def serve(static_file, request): status = int(response.status) http_response = FileResponse(response.file or (), status=status) # Remove default content-type - del http_response['content-type'] + del http_response["content-type"] for key, value in response.headers: http_response[key] = value return http_response @@ -75,16 +75,16 @@ def configure_from_settings(self, settings): # Default configuration self.autorefresh = settings.DEBUG self.use_finders = settings.DEBUG - self.static_prefix = urlparse(settings.STATIC_URL or '').path + self.static_prefix = urlparse(settings.STATIC_URL or "").path if settings.FORCE_SCRIPT_NAME: - script_name = settings.FORCE_SCRIPT_NAME.rstrip('/') + script_name = settings.FORCE_SCRIPT_NAME.rstrip("/") if self.static_prefix.startswith(script_name): - self.static_prefix = self.static_prefix[len(script_name):] + self.static_prefix = self.static_prefix[len(script_name) :] if settings.DEBUG: self.max_age = 0 # Allow settings to override default attributes for attr in self.config_attrs: - settings_key = 'WHITENOISE_{0}'.format(attr.upper()) + settings_key = "WHITENOISE_{0}".format(attr.upper()) try: value = getattr(settings, settings_key) except AttributeError: @@ -99,12 +99,15 @@ def add_files_from_finders(self): files = {} for finder in finders.get_finders(): for path, storage in finder.list(None): - prefix = (getattr(storage, 'prefix', None) or '').strip('/') - url = u''.join(( + prefix = (getattr(storage, "prefix", None) or "").strip("/") + url = u"".join( + ( self.static_prefix, prefix, - '/' if prefix else '', - path.replace('\\', '/'))) + "/" if prefix else "", + path.replace("\\", "/"), + ) + ) # Use setdefault as only first matching file should be used files.setdefault(url, storage.path(path)) stat_cache = {path: os.stat(path) for path in files.values()} @@ -113,7 +116,7 @@ def add_files_from_finders(self): def candidate_paths_for_url(self, url): if self.use_finders and url.startswith(self.static_prefix): - path = finders.find(url[len(self.static_prefix):]) + path = finders.find(url[len(self.static_prefix) :]) if path: yield path paths = super(WhiteNoiseMiddleware, self).candidate_paths_for_url(url) @@ -128,7 +131,7 @@ def immutable_file_test(self, path, url): """ if not url.startswith(self.static_prefix): return False - name = url[len(self.static_prefix):] + name = url[len(self.static_prefix) :] name_without_hash = self.get_name_without_hash(name) if name == name_without_hash: return False diff --git a/whitenoise/responders.py b/whitenoise/responders.py index 08f3349b..a953818a 100644 --- a/whitenoise/responders.py +++ b/whitenoise/responders.py @@ -1,6 +1,7 @@ from collections import namedtuple from email.utils import formatdate, parsedate import errno + try: from http import HTTPStatus except ImportError: @@ -9,6 +10,7 @@ import re import stat from time import mktime + try: from urllib.parse import quote except ImportError: @@ -16,40 +18,44 @@ from wsgiref.headers import Headers -Response = namedtuple('Response', ('status', 'headers', 'file')) +Response = namedtuple("Response", ("status", "headers", "file")) NOT_ALLOWED_RESPONSE = Response( - status=HTTPStatus.METHOD_NOT_ALLOWED, - headers=[('Allow', 'GET, HEAD')], - file=None) + status=HTTPStatus.METHOD_NOT_ALLOWED, headers=[("Allow", "GET, HEAD")], file=None +) # Headers which should be returned with a 304 Not Modified response as # specified here: http://tools.ietf.org/html/rfc7232#section-4.1 -NOT_MODIFIED_HEADERS = ('Cache-Control', 'Content-Location', 'Date', 'ETag', - 'Expires', 'Vary') +NOT_MODIFIED_HEADERS = ( + "Cache-Control", + "Content-Location", + "Date", + "ETag", + "Expires", + "Vary", +) class StaticFile(object): - def __init__(self, path, headers, encodings=None, stat_cache=None): files = self.get_file_stats(path, encodings, stat_cache) headers = self.get_headers(headers, files) - self.last_modified = parsedate(headers['Last-Modified']) - self.etag = headers['ETag'] + self.last_modified = parsedate(headers["Last-Modified"]) + self.etag = headers["ETag"] self.not_modified_response = self.get_not_modified_response(headers) self.alternatives = self.get_alternatives(headers, files) def get_response(self, method, request_headers): - if method not in ('GET', 'HEAD'): + if method not in ("GET", "HEAD"): return NOT_ALLOWED_RESPONSE if self.is_not_modified(request_headers): return self.not_modified_response path, headers = self.get_path_and_headers(request_headers) - if method != 'HEAD': - file_handle = open(path, 'rb') + if method != "HEAD": + file_handle = open(path, "rb") else: file_handle = None - range_header = request_headers.get('HTTP_RANGE') + range_header = request_headers.get("HTTP_RANGE") if range_header: try: return self.get_range_response(range_header, headers, file_handle) @@ -63,7 +69,7 @@ def get_response(self, method, request_headers): def get_range_response(self, range_header, base_headers, file_handle): headers = [] for item in base_headers: - if item[0] == 'Content-Length': + if item[0] == "Content-Length": size = int(item[1]) else: headers.append(item) @@ -72,10 +78,8 @@ def get_range_response(self, range_header, base_headers, file_handle): return self.get_range_not_satisfiable_response(file_handle, size) if file_handle is not None and start != 0: file_handle.seek(start) - headers.append( - ('Content-Range', 'bytes {}-{}/{}'.format(start, end, size))) - headers.append( - ('Content-Length', str(end-start+1))) + headers.append(("Content-Range", "bytes {}-{}/{}".format(start, end, size))) + headers.append(("Content-Length", str(end - start + 1))) return Response(HTTPStatus.PARTIAL_CONTENT, headers, file_handle) def get_byte_range(self, range_header, size): @@ -90,12 +94,12 @@ def get_byte_range(self, range_header, size): @staticmethod def parse_byte_range(range_header): - units, _, range_spec = range_header.strip().partition('=') - if units != 'bytes': + units, _, range_spec = range_header.strip().partition("=") + if units != "bytes": raise ValueError() # Only handle a single range spec. Multiple ranges will trigger a # ValueError below which will result in the Range header being ignored - start_str, sep, end_str = range_spec.strip().partition('-') + start_str, sep, end_str = range_spec.strip().partition("-") if not sep: raise ValueError() if not start_str: @@ -111,9 +115,10 @@ def get_range_not_satisfiable_response(file_handle, size): if file_handle is not None: file_handle.close() return Response( - HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE, - [('Content-Range', 'bytes */{}'.format(size))], - None) + HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE, + [("Content-Range", "bytes */{}".format(size))], + None, + ) @staticmethod def get_file_stats(path, encodings, stat_cache): @@ -131,19 +136,20 @@ def get_headers(self, headers_list, files): headers = Headers(headers_list) main_file = files[None] if len(files) > 1: - headers['Vary'] = 'Accept-Encoding' - if 'Last-Modified' not in headers: + headers["Vary"] = "Accept-Encoding" + if "Last-Modified" not in headers: mtime = main_file.stat.st_mtime # Not all filesystems report mtimes, and sometimes they report an # mtime of 0 which we know is incorrect if mtime: - headers['Last-Modified'] = formatdate(mtime, usegmt=True) - if 'ETag' not in headers: - last_modified = parsedate(headers['Last-Modified']) + headers["Last-Modified"] = formatdate(mtime, usegmt=True) + if "ETag" not in headers: + last_modified = parsedate(headers["Last-Modified"]) if last_modified: timestamp = int(mktime(last_modified)) - headers['ETag'] = '"{:x}-{:x}"'.format( - timestamp, main_file.stat.st_size) + headers["ETag"] = '"{:x}-{:x}"'.format( + timestamp, main_file.stat.st_size + ) return headers @staticmethod @@ -153,9 +159,8 @@ def get_not_modified_response(headers): if key in headers: not_modified_headers.append((key, headers[key])) return Response( - status=HTTPStatus.NOT_MODIFIED, - headers=not_modified_headers, - file=None) + status=HTTPStatus.NOT_MODIFIED, headers=not_modified_headers, file=None + ) @staticmethod def get_alternatives(base_headers, files): @@ -164,29 +169,29 @@ def get_alternatives(base_headers, files): files_by_size = sorted(files.items(), key=lambda i: i[1].stat.st_size) for encoding, file_entry in files_by_size: headers = Headers(base_headers.items()) - headers['Content-Length'] = str(file_entry.stat.st_size) + headers["Content-Length"] = str(file_entry.stat.st_size) if encoding: - headers['Content-Encoding'] = encoding - encoding_re = re.compile(r'\b%s\b' % encoding) + headers["Content-Encoding"] = encoding + encoding_re = re.compile(r"\b%s\b" % encoding) else: - encoding_re = re.compile('') + encoding_re = re.compile("") alternatives.append((encoding_re, file_entry.path, headers.items())) return alternatives def is_not_modified(self, request_headers): - previous_etag = request_headers.get('HTTP_IF_NONE_MATCH') + previous_etag = request_headers.get("HTTP_IF_NONE_MATCH") if previous_etag is not None: return previous_etag == self.etag if self.last_modified is None: return False try: - last_requested = request_headers['HTTP_IF_MODIFIED_SINCE'] + last_requested = request_headers["HTTP_IF_MODIFIED_SINCE"] except KeyError: return False return parsedate(last_requested) >= self.last_modified def get_path_and_headers(self, request_headers): - accept_encoding = request_headers.get('HTTP_ACCEPT_ENCODING', '') + accept_encoding = request_headers.get("HTTP_ACCEPT_ENCODING", "") # These are sorted by size so first match is the best for encoding_re, path, headers in self.alternatives: if encoding_re.search(accept_encoding): @@ -194,10 +199,9 @@ def get_path_and_headers(self, request_headers): class Redirect(object): - def __init__(self, location, headers=None): headers = list(headers.items()) if headers else [] - headers.append(('Location', quote(location.encode('utf8')))) + headers.append(("Location", quote(location.encode("utf8")))) self.response = Response(HTTPStatus.FOUND, headers, None) def get_response(self, method, request_headers): @@ -217,7 +221,6 @@ class IsDirectoryError(MissingFileError): class FileEntry(object): - def __init__(self, path, stat_cache=None): stat_function = os.stat if stat_cache is None else stat_cache.__getitem__ self.stat = self.stat_regular_file(path, stat_function) @@ -240,7 +243,7 @@ def stat_regular_file(path, stat_function): raise if not stat.S_ISREG(stat_result.st_mode): if stat.S_ISDIR(stat_result.st_mode): - raise IsDirectoryError(u'Path is a directory: {0}'.format(path)) + raise IsDirectoryError(u"Path is a directory: {0}".format(path)) else: - raise NotARegularFileError(u'Not a regular file: {0}'.format(path)) + raise NotARegularFileError(u"Not a regular file: {0}".format(path)) return stat_result diff --git a/whitenoise/runserver_nostatic/management/commands/runserver.py b/whitenoise/runserver_nostatic/management/commands/runserver.py index 6a42be90..c07ec62f 100644 --- a/whitenoise/runserver_nostatic/management/commands/runserver.py +++ b/whitenoise/runserver_nostatic/management/commands/runserver.py @@ -16,7 +16,7 @@ def get_next_runserver_command(): Return the next highest priority "runserver" command class """ for app_name in get_lower_priority_apps(): - module_path = '%s.management.commands.runserver' % app_name + module_path = "%s.management.commands.runserver" % app_name try: return import_module(module_path).Command except (ImportError, AttributeError): @@ -27,25 +27,25 @@ def get_lower_priority_apps(): """ Yield all app module names below the current app in the INSTALLED_APPS list """ - self_app_name = '.'.join(__name__.split('.')[:-3]) + self_app_name = ".".join(__name__.split(".")[:-3]) reached_self = False for app_config in apps.get_app_configs(): if app_config.name == self_app_name: reached_self = True elif reached_self: yield app_config.name - yield 'django.core' + yield "django.core" RunserverCommand = get_next_runserver_command() class Command(RunserverCommand): - def add_arguments(self, parser): super(Command, self).add_arguments(parser) - if parser.get_default('use_static_handler') is True: + if parser.get_default("use_static_handler") is True: parser.set_defaults(use_static_handler=False) - parser.description += \ - "\n(Wrapped by 'whitenoise.runserver_nostatic' to always"\ + parser.description += ( + "\n(Wrapped by 'whitenoise.runserver_nostatic' to always" " enable '--nostatic')" + ) diff --git a/whitenoise/scantree.py b/whitenoise/scantree.py index 3824106e..103e111a 100644 --- a/whitenoise/scantree.py +++ b/whitenoise/scantree.py @@ -7,6 +7,7 @@ """ import os import stat + try: from os import scandir except ImportError: @@ -17,6 +18,7 @@ if scandir: + def scantree(root): for entry in scandir(root): if entry.is_dir(): @@ -24,7 +26,10 @@ def scantree(root): yield item else: yield entry.path, entry.stat() + + else: + def scantree(root): for filename in os.listdir(root): path = os.path.join(root, filename) diff --git a/whitenoise/storage.py b/whitenoise/storage.py index 587690ce..3b62bbfa 100644 --- a/whitenoise/storage.py +++ b/whitenoise/storage.py @@ -7,7 +7,9 @@ from django.conf import settings from django.contrib.staticfiles.storage import ( - ManifestStaticFilesStorage, StaticFilesStorage) + ManifestStaticFilesStorage, + StaticFilesStorage, +) from .compress import Compressor @@ -20,10 +22,11 @@ class CompressedStaticFilesMixin(object): def post_process(self, *args, **kwargs): super_post_process = getattr( super(CompressedStaticFilesMixin, self), - 'post_process', - self.fallback_post_process) + "post_process", + self.fallback_post_process, + ) files = super_post_process(*args, **kwargs) - if not kwargs.get('dry_run'): + if not kwargs.get("dry_run"): files = self.post_process_with_compression(files) return files @@ -38,8 +41,7 @@ def create_compressor(self, **kwargs): return Compressor(**kwargs) def post_process_with_compression(self, files): - extensions = getattr(settings, - 'WHITENOISE_SKIP_COMPRESS_EXTENSIONS', None) + extensions = getattr(settings, "WHITENOISE_SKIP_COMPRESS_EXTENSIONS", None) compressor = self.create_compressor(extensions=extensions, quiet=True) for name, hashed_name, processed in files: yield name, hashed_name, processed @@ -55,8 +57,7 @@ def post_process_with_compression(self, files): yield name, compressed_name, True -class CompressedStaticFilesStorage( - CompressedStaticFilesMixin, StaticFilesStorage): +class CompressedStaticFilesStorage(CompressedStaticFilesMixin, StaticFilesStorage): pass @@ -72,7 +73,8 @@ class HelpfulExceptionMixin(object): ERROR_MSG_RE = re.compile("^The file '(.+)' could not be found") - ERROR_MSG = textwrap.dedent(u"""\ + ERROR_MSG = textwrap.dedent( + u"""\ {orig_message} The {ext} file '{filename}' references a file which could not be found: @@ -80,7 +82,8 @@ class HelpfulExceptionMixin(object): Please check the URL references in this {ext} file, particularly any relative paths which might be pointing to the wrong location. - """) + """ + ) def post_process(self, *args, **kwargs): files = super(HelpfulExceptionMixin, self).post_process(*args, **kwargs) @@ -91,16 +94,17 @@ def post_process(self, *args, **kwargs): def make_helpful_exception(self, exception, name): if isinstance(exception, ValueError): - message = exception.args[0] if len(exception.args) else '' + message = exception.args[0] if len(exception.args) else "" # Stringly typed exceptions. Yay! match = self.ERROR_MSG_RE.search(message) if match: - extension = os.path.splitext(name)[1].lstrip('.').upper() + extension = os.path.splitext(name)[1].lstrip(".").upper() message = self.ERROR_MSG.format( - orig_message=message, - filename=name, - missing=match.group(1), - ext=extension) + orig_message=message, + filename=name, + missing=match.group(1), + ext=extension, + ) exception = MissingFileError(message) return exception @@ -110,17 +114,21 @@ class MissingFileError(ValueError): class CompressedManifestStaticFilesStorage( - HelpfulExceptionMixin, ManifestStaticFilesStorage): + HelpfulExceptionMixin, ManifestStaticFilesStorage +): """ Extends ManifestStaticFilesStorage instance to create compressed versions of its output files and, optionally, to delete the non-hashed files (i.e. those without the hash in their name) """ + _new_files = None def post_process(self, *args, **kwargs): - files = super(CompressedManifestStaticFilesStorage, self).post_process(*args, **kwargs) - if not kwargs.get('dry_run'): + files = super(CompressedManifestStaticFilesStorage, self).post_process( + *args, **kwargs + ) + if not kwargs.get("dry_run"): files = self.post_process_with_compression(files) return files @@ -151,7 +159,9 @@ def post_process_with_compression(self, files): yield name, compressed_name, True def hashed_name(self, *args, **kwargs): - name = super(CompressedManifestStaticFilesStorage, self).hashed_name(*args, **kwargs) + name = super(CompressedManifestStaticFilesStorage, self).hashed_name( + *args, **kwargs + ) if self._new_files is not None: self._new_files.add(self.clean_name(name)) return name @@ -164,7 +174,7 @@ def stop_tracking_new_files(self): @property def keep_only_hashed_files(self): - return getattr(settings, 'WHITENOISE_KEEP_ONLY_HASHED_FILES', False) + return getattr(settings, "WHITENOISE_KEEP_ONLY_HASHED_FILES", False) def delete_files(self, files_to_delete): for name in files_to_delete: @@ -178,8 +188,7 @@ def create_compressor(self, **kwargs): return Compressor(**kwargs) def compress_files(self, names): - extensions = getattr(settings, - 'WHITENOISE_SKIP_COMPRESS_EXTENSIONS', None) + extensions = getattr(settings, "WHITENOISE_SKIP_COMPRESS_EXTENSIONS", None) compressor = self.create_compressor(extensions=extensions, quiet=True) for name in names: if compressor.should_compress(name): diff --git a/whitenoise/string_utils.py b/whitenoise/string_utils.py index 0afc4f27..ad245968 100644 --- a/whitenoise/string_utils.py +++ b/whitenoise/string_utils.py @@ -11,7 +11,7 @@ def decode_if_byte_string(s, force_text=False): if isinstance(s, BINARY_TYPE): - s = s.decode('utf-8') + s = s.decode("utf-8") if force_text and not isinstance(s, TEXT_TYPE): s = TEXT_TYPE(s) return s @@ -21,13 +21,17 @@ def decode_if_byte_string(s, force_text=False): # implicit ISO-8859-1 decoding applied in Python 3). Strictly speaking, URLs # should only be ASCII anyway, but UTF-8 can be found in the wild. if sys.version_info[0] >= 3: + def decode_path_info(path_info): - return path_info.encode('iso-8859-1', 'replace').decode('utf-8', 'replace') + return path_info.encode("iso-8859-1", "replace").decode("utf-8", "replace") + + else: + def decode_path_info(path_info): - return path_info.decode('utf-8', 'replace') + return path_info.decode("utf-8", "replace") def ensure_leading_trailing_slash(path): - path = (path or u'').strip(u'/') - return u'/{0}/'.format(path) if path else u'/' + path = (path or u"").strip(u"/") + return u"/{0}/".format(path) if path else u"/"