├── .editorconfig ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── pyproject.toml ├── src └── blacknoise │ ├── __about__.py │ ├── __init__.py │ ├── _impl.py │ └── compress.py └── tests ├── __init__.py ├── static ├── hello.txt ├── hello.txt.gz ├── hello2.txt.gz └── hello3.txt └── test_blacknoise.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.py] 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | python -m pip install hatch 28 | - name: Test with pytest 29 | run: | 30 | hatch run test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | __pycache__ 3 | .coverage* 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ".yarn/|yarn.lock|\\.min\\.(css|js)$" 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-builtin-literals 8 | - id: check-executables-have-shebangs 9 | - id: check-merge-conflict 10 | - id: check-toml 11 | - id: check-yaml 12 | - id: detect-private-key 13 | - id: end-of-file-fixer 14 | - id: mixed-line-ending 15 | - id: trailing-whitespace 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: "v0.8.2" 18 | hooks: 19 | - id: ruff 20 | args: [--unsafe-fixes] 21 | - id: ruff-format 22 | - repo: https://github.com/pre-commit/mirrors-prettier 23 | rev: v4.0.0-alpha.8 24 | hooks: 25 | - id: prettier 26 | entry: env PRETTIER_LEGACY_CLI=1 prettier 27 | types_or: [javascript, css, markdown] 28 | args: [--no-semi] 29 | exclude: "^conf/|.*\\.html$" 30 | - repo: https://github.com/tox-dev/pyproject-fmt 31 | rev: v2.5.0 32 | hooks: 33 | - id: pyproject-fmt 34 | - repo: https://github.com/abravalheri/validate-pyproject 35 | rev: v0.23 36 | hooks: 37 | - id: validate-pyproject 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## Unreleased 4 | 5 | ## 1.2 (2024-12-09) 6 | 7 | - Added Python 3.13 to the CI. 8 | - Updated our pre-commit hooks. 9 | 10 | ## 1.1 (2024-09-23) 11 | 12 | - Stopped interleaving `print()` statements from different compression threads. 13 | - Started running the testsuite using GitHub actions. 14 | - Don't crash when compressing if brotli isn't installed. 15 | - Replaced our own HTTP Range support with the implementation added to 16 | Starlette 0.39. 17 | 18 | ## 1.0 (2024-05-18) 19 | 20 | - I'm declaring this package to be production-ready. 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-present Matthias Kestenholz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # blacknoise 2 | 3 | [![PyPI - Version](https://img.shields.io/pypi/v/blacknoise.svg)](https://pypi.org/project/blacknoise) 4 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/blacknoise.svg)](https://pypi.org/project/blacknoise) 5 | 6 | blacknoise is an [ASGI](https://asgi.readthedocs.io/en/latest/) app for static 7 | file serving inspired by [whitenoise](https://github.com/evansd/whitenoise/) 8 | and following the principles of [low maintenance 9 | software](https://406.ch/writing/low-maintenance-software/). 10 | 11 | 12 | ## Using blacknoise to serve static files 13 | 14 | Install blacknoise into your Python environment: 15 | 16 | ```console 17 | pip install blacknoise 18 | ``` 19 | 20 | Wrap your ASGI application with the `BlackNoise` app: 21 | 22 | ```python 23 | from blacknoise import BlackNoise 24 | from django.core.asgi import get_asgi_application 25 | from pathlib import Path 26 | 27 | BASE_DIR = Path(__file__).parent 28 | 29 | application = BlackNoise(get_asgi_application()) 30 | application.add(BASE_DIR / "static", "/static") 31 | ``` 32 | 33 | The example uses Django, but you can wrap any ASGI application. 34 | 35 | `BlackNoise` will automatically handle all paths below the prefixes added, and 36 | either return the files or return 404 errors if files do not exist. The files 37 | are added on server startup, which also means that `BlackNoise` only knows 38 | about files which existed at that particular point in time. 39 | 40 | `BlackNoise` doesn't watch the added folders for changes; if you add new files 41 | you have to restart the server, otherwise those files aren't served. It doesn't 42 | cache file contents though, so changes to files are directly picked up. 43 | 44 | ## Improving performance 45 | 46 | `BlackNoise` has worse performance than when using an optimized webserver such 47 | as nginx and others. Sometimes it doesn't matter much if the app is behind a 48 | caching reverse proxy or behind a content delivery network anyway. To further 49 | support this use case `BlackNoise` can be configured to serve media files with 50 | far-future expiry headers and has support for serving compressed assets. 51 | 52 | ### Serving pre-compressed assets 53 | 54 | Compressing is possible by running: 55 | 56 | ```console 57 | python -m blacknoise.compress static/ 58 | ``` 59 | 60 | `BlackNoise` will try compress non-binary files using gzip or brotli (if the 61 | [Brotli](ttps://pypi.org/project/Brotli/) library is available), and will serve 62 | the compressed version if the compression actually results in (significantly) 63 | smaller files and if the client also supports it. Files are compressed in 64 | parallel for faster completion times. 65 | 66 | ### Setting far-future expiry headers 67 | 68 | Far-future expiry headers can be enabled by passing the `immutable_file_test` 69 | callable to the `BlackNoise` constructor: 70 | 71 | ```python 72 | def immutable_file_test(path): 73 | return True # Enable far-future expiry headers for all files 74 | 75 | application = BlackNoise( 76 | get_asgi_application(), 77 | immutable_file_test=immutable_file_test, 78 | ) 79 | ``` 80 | 81 | Maybe you want to add some other logic, for example check if the path contains 82 | a hash based upon the contents of the static file. Such hashes can be added by 83 | Django's `ManifestStaticFilesStorage` or by appropriately configuring bundlers 84 | such as `webpack` and others. 85 | 86 | ## License 87 | 88 | `blacknoise` is distributed under the terms of the 89 | [MIT](https://spdx.org/licenses/MIT.html) license. 90 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatchling", 5 | ] 6 | 7 | [project] 8 | name = "blacknoise" 9 | description = "An ASGI app for static file serving inspired by whitenoise" 10 | readme = "README.md" 11 | keywords = [ 12 | ] 13 | license = { text = "MIT" } 14 | authors = [ 15 | { name = "Matthias Kestenholz", email = "mk@feinheit.ch" }, 16 | ] 17 | requires-python = ">=3.9" 18 | classifiers = [ 19 | "Development Status :: 4 - Beta", 20 | "Programming Language :: Python", 21 | "Programming Language :: Python :: 3 :: Only", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | "Programming Language :: Python :: Implementation :: CPython", 28 | "Programming Language :: Python :: Implementation :: PyPy", 29 | ] 30 | dynamic = [ 31 | "version", 32 | ] 33 | dependencies = [ 34 | "starlette>=0.39", 35 | ] 36 | urls.Documentation = "https://github.com/matthiask/blacknoise#readme" 37 | urls.Issues = "https://github.com/matthiask/blacknoise/issues" 38 | urls.Source = "https://github.com/matthiask/blacknoise" 39 | 40 | [tool.hatch.version] 41 | path = "src/blacknoise/__about__.py" 42 | 43 | [tool.hatch.envs.default] 44 | dependencies = [ 45 | "brotli", 46 | "coverage[toml]>=6.5", 47 | "httpx", 48 | "httpx-ws", 49 | "pytest", 50 | "pytest-asyncio", 51 | ] 52 | [tool.hatch.envs.default.scripts] 53 | test = "pytest {args:tests}" 54 | test-cov = "coverage run -m pytest {args:tests}" 55 | cov-report = [ 56 | "- coverage combine", 57 | "coverage report -m", 58 | ] 59 | cov = [ 60 | "test-cov", 61 | "cov-report", 62 | ] 63 | 64 | [[tool.hatch.envs.all.matrix]] 65 | python = [ 66 | "3.9", 67 | "3.10", 68 | "3.11", 69 | "3.12", 70 | ] 71 | 72 | [tool.ruff] 73 | target-version = "py39" 74 | 75 | fix = true 76 | lint.select = [ 77 | "ARG", 78 | "B", 79 | "C", 80 | "DTZ", 81 | "E", 82 | "EM", 83 | "F", 84 | "FBT", 85 | "I", 86 | "ICN", 87 | "ISC", 88 | "N", 89 | "PLC", 90 | "PLE", 91 | "PLR", 92 | "PLW", 93 | "Q", 94 | "RUF", 95 | "S", 96 | # "T", 97 | "TID", 98 | "UP", 99 | "W", 100 | "YTT", 101 | ] 102 | lint.ignore = [ 103 | # Allow non-abstract empty methods in abstract base classes 104 | "B027", 105 | # Ignore complexity 106 | "C901", 107 | # Stop warning about line lengths 108 | "E501", 109 | # Allow boolean positional values in function calls, like `dict.get(... True)` 110 | "FBT003", 111 | "PLR0911", 112 | "PLR0912", 113 | "PLR0913", 114 | "PLR0915", 115 | # Ignore checks for possible passwords 116 | "S105", 117 | "S106", 118 | "S107", 119 | ] 120 | 121 | lint.per-file-ignores."tests/**/*" = [ 122 | "PLR2004", 123 | "S101", 124 | "TID252", 125 | ] 126 | lint.unfixable = [ 127 | # Don't touch unused imports 128 | "F401", 129 | ] 130 | lint.flake8-tidy-imports.ban-relative-imports = "all" 131 | # Tests can use magic values, assertions, and relative imports 132 | lint.isort.known-first-party = [ 133 | "blacknoise", 134 | ] 135 | 136 | [tool.coverage.run] 137 | source_pkgs = [ 138 | "blacknoise", 139 | "tests", 140 | ] 141 | branch = true 142 | parallel = true 143 | omit = [ 144 | "src/blacknoise/__about__.py", 145 | ] 146 | 147 | [tool.coverage.paths] 148 | blacknoise = [ 149 | "src/blacknoise", 150 | "*/blacknoise/src/blacknoise", 151 | ] 152 | tests = [ 153 | "tests", 154 | "*/blacknoise/tests", 155 | ] 156 | 157 | [tool.coverage.report] 158 | exclude_lines = [ 159 | "no cov", 160 | "if __name__ == .__main__.:", 161 | "if TYPE_CHECKING:", 162 | ] 163 | -------------------------------------------------------------------------------- /src/blacknoise/__about__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Matthias Kestenholz 2 | # 3 | # SPDX-License-Identifier: MIT 4 | __version__ = "1.2.0" 5 | -------------------------------------------------------------------------------- /src/blacknoise/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Matthias Kestenholz 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | try: 6 | from blacknoise._impl import BlackNoise # noqa 7 | except ModuleNotFoundError: # no cov 8 | pass 9 | -------------------------------------------------------------------------------- /src/blacknoise/_impl.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from starlette.datastructures import Headers 4 | from starlette.responses import FileResponse, PlainTextResponse 5 | 6 | # Ten years is what nginx sets as max age if you use 'expires max;' 7 | # so we'll follow its lead 8 | FOREVER = f"max-age={10 * 365 * 24 * 60 * 60}, public, immutable" 9 | A_LITTE_WHILE = "max-age=60, public" 10 | SUFFIX_ENCODINGS = {".br": "br", ".gz": "gzip"} 11 | 12 | 13 | def never(_path): 14 | return False # no cov 15 | 16 | 17 | class BlackNoise: 18 | def __init__(self, application, *, immutable_file_test=never): 19 | self._files = {} 20 | self._prefixes = () 21 | self._application = application 22 | 23 | self._immutable_file_test = immutable_file_test 24 | 25 | def add(self, path, prefix): 26 | self._prefixes = (*self._prefixes, prefix) 27 | 28 | for base, _dirs, files in os.walk(path): 29 | path_prefix = os.path.join(prefix, base[len(str(path)) :].strip("/")) 30 | self._files |= { 31 | os.path.join(path_prefix, file): os.path.join(base, file) 32 | for file in files 33 | if all( 34 | # File is not a compressed file 35 | not file.endswith(suffix) 36 | # The uncompressed variant does not exist 37 | or file.removesuffix(suffix) not in files 38 | for suffix in SUFFIX_ENCODINGS 39 | ) 40 | } 41 | 42 | async def __call__(self, scope, receive, send): 43 | path = os.path.normpath(scope["path"].removeprefix(scope["root_path"])) 44 | 45 | if scope["type"] != "http" or not path.startswith(self._prefixes): 46 | response = self._application 47 | 48 | elif scope["method"] not in ("GET", "HEAD"): 49 | response = PlainTextResponse("Method Not Allowed", status_code=405) 50 | 51 | elif file := self._files.get(path): 52 | response = await _file_response( 53 | scope, file, self._immutable_file_test(path) 54 | ) 55 | 56 | else: 57 | response = PlainTextResponse("Not Found", status_code=404) 58 | 59 | await response(scope, receive, send) 60 | 61 | 62 | async def _file_response(scope, file, immutable): 63 | headers = { 64 | "accept-ranges": "bytes", 65 | "access-control-allow-origin": "*", 66 | "cache-control": FOREVER if immutable else A_LITTE_WHILE, 67 | } 68 | h = Headers(scope=scope) 69 | 70 | # Defer to Starlette when we get a HTTP Range request. 71 | if h.get("range"): 72 | return FileResponse(file, headers=headers) 73 | 74 | accept_encoding = h.get("accept-encoding", "") 75 | for suffix, encoding in SUFFIX_ENCODINGS.items(): 76 | if encoding not in accept_encoding: 77 | continue 78 | 79 | if file.endswith(suffix): 80 | return FileResponse(file, headers=headers | {"content-encoding": encoding}) 81 | 82 | compressed_file = f"{file}{suffix}" 83 | if os.path.exists(compressed_file): 84 | return FileResponse( 85 | compressed_file, 86 | headers=headers | {"content-encoding": encoding}, 87 | ) 88 | 89 | return FileResponse(file, headers=headers) 90 | -------------------------------------------------------------------------------- /src/blacknoise/compress.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import gzip 3 | import io 4 | import os 5 | from concurrent.futures import ThreadPoolExecutor 6 | from pathlib import Path 7 | 8 | try: 9 | import brotli 10 | except ModuleNotFoundError: # no cov 11 | brotli = None 12 | 13 | # Extensions that it's not worth trying to compress 14 | SKIP_COMPRESS_EXTENSIONS = ( 15 | # Images 16 | ".jpg", 17 | ".jpeg", 18 | ".png", 19 | ".gif", 20 | ".webp", 21 | # Compressed files 22 | ".zip", 23 | ".gz", 24 | ".tgz", 25 | ".bz2", 26 | ".tbz", 27 | ".xz", 28 | ".br", 29 | # Flash 30 | ".swf", 31 | ".flv", 32 | # Fonts 33 | ".woff", 34 | ".woff2", 35 | # Video 36 | ".3gp", 37 | ".3gpp", 38 | ".asf", 39 | ".avi", 40 | ".m4v", 41 | ".mov", 42 | ".mp4", 43 | ".mpeg", 44 | ".mpg", 45 | ".webm", 46 | ".wmv", 47 | ) 48 | 49 | 50 | def _write_if_smaller(path, orig_bytes, compress_bytes, algorithm, suffix): 51 | orig_len = len(orig_bytes) 52 | compress_len = len(compress_bytes) 53 | if compress_len < orig_len * 0.9: 54 | compress_improvement = (compress_len - orig_len) / orig_len 55 | Path(str(path) + suffix).write_bytes(compress_bytes) 56 | return f"{path!s}: {algorithm} compressed {orig_len} to {compress_len} bytes ({int(100 * compress_improvement)}%)" 57 | return f"{path!s}: {algorithm} didn't produce useful compression results" 58 | 59 | 60 | def try_gzip(path, orig_bytes): 61 | with io.BytesIO() as f: 62 | with gzip.GzipFile( 63 | filename="", mode="wb", fileobj=f, compresslevel=9, mtime=0 64 | ) as compress_file: 65 | compress_file.write(orig_bytes) 66 | return _write_if_smaller( 67 | path, 68 | orig_bytes, 69 | f.getvalue(), 70 | "Gzip", 71 | ".gz", 72 | ) 73 | 74 | 75 | def try_brotli(path, orig_bytes): 76 | if not brotli: # no cov 77 | return "" 78 | return _write_if_smaller( 79 | path, 80 | orig_bytes, 81 | brotli.compress(orig_bytes), 82 | "Brotli", 83 | ".br", 84 | ) 85 | 86 | 87 | def _compress_path(path): 88 | orig_bytes = path.read_bytes() 89 | return ( 90 | try_brotli(path, orig_bytes), 91 | try_gzip(path, orig_bytes), 92 | ) 93 | 94 | 95 | def _paths(root): 96 | for dir_, _dirs, files in os.walk(root): 97 | dir = Path(dir_) 98 | for filename in files: 99 | path = dir / filename 100 | if path.suffix not in SKIP_COMPRESS_EXTENSIONS: 101 | yield path 102 | 103 | 104 | def compress(root): 105 | with ThreadPoolExecutor() as executor: 106 | for result in executor.map(_compress_path, _paths(root)): 107 | print("\n".join(filter(None, result))) 108 | return 0 109 | 110 | 111 | def parse_args(args=None): 112 | parser = argparse.ArgumentParser() 113 | parser.add_argument("root", help="Path containing static files to compress") 114 | return parser.parse_args(args) 115 | 116 | 117 | if __name__ == "__main__": 118 | args = parse_args() 119 | raise SystemExit(compress(args.root)) 120 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024-present Matthias Kestenholz 2 | # 3 | # SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /tests/static/hello.txt: -------------------------------------------------------------------------------- 1 | world 2 | -------------------------------------------------------------------------------- /tests/static/hello.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/blacknoise/aecc40df4ad76d240fdab14e7c98c0d23e4b3ebf/tests/static/hello.txt.gz -------------------------------------------------------------------------------- /tests/static/hello2.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/blacknoise/aecc40df4ad76d240fdab14e7c98c0d23e4b3ebf/tests/static/hello2.txt.gz -------------------------------------------------------------------------------- /tests/static/hello3.txt: -------------------------------------------------------------------------------- 1 | world3 2 | -------------------------------------------------------------------------------- /tests/test_blacknoise.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from pathlib import Path 3 | 4 | import httpx 5 | import pytest 6 | from httpx_ws import aconnect_ws 7 | from httpx_ws.transport import ASGIWebSocketTransport 8 | from starlette.applications import Starlette 9 | from starlette.responses import PlainTextResponse 10 | from starlette.routing import Route, WebSocketRoute 11 | 12 | from blacknoise import BlackNoise 13 | from blacknoise.compress import compress, parse_args 14 | 15 | 16 | async def http_hello(request): 17 | return PlainTextResponse(f"Hello from {request.url.path}") 18 | 19 | 20 | async def ws_hello(websocket): 21 | await websocket.accept() 22 | await websocket.send_text("Hello World!") 23 | await websocket.close() 24 | 25 | 26 | app = Starlette( 27 | routes=[ 28 | Route("/http", http_hello), 29 | WebSocketRoute("/ws", ws_hello), 30 | ] 31 | ) 32 | 33 | 34 | @pytest.fixture 35 | def bn(): 36 | this = Path(__file__).parent 37 | blacknoise = BlackNoise(app, immutable_file_test=lambda path: "hello3" in path) 38 | blacknoise.add(this / "static", "/static/") 39 | return blacknoise 40 | 41 | 42 | def test_files_contents(bn): 43 | this = Path(__file__).parent 44 | 45 | assert "/static/hello.txt" in bn._files 46 | assert "/static/hello.txt.gz" not in bn._files 47 | assert "/static/hello2.txt.gz" in bn._files 48 | assert "/static/hello3.txt" in bn._files 49 | assert "/static/foo" not in bn._files 50 | 51 | assert bn._files["/static/hello.txt"] == str(this / "static" / "hello.txt") 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_static_file_serving(bn): 56 | transport = httpx.ASGITransport(app=bn) 57 | async with httpx.AsyncClient( 58 | transport=transport, base_url="http://testserver" 59 | ) as client: 60 | r = await client.get("/static/hello.txt") 61 | assert r.status_code == 200 62 | assert r.text == "world\n" 63 | assert r.headers["content-encoding"] == "gzip" 64 | assert r.headers["cache-control"] == "max-age=60, public" 65 | assert r.headers["access-control-allow-origin"] == "*" 66 | 67 | r = await client.get("/static/hello2.txt.gz") 68 | assert r.status_code == 200 69 | assert r.text == "world2\n" 70 | assert r.headers["content-encoding"] == "gzip" 71 | assert r.headers["cache-control"] == "max-age=60, public" 72 | assert r.headers["access-control-allow-origin"] == "*" 73 | 74 | r = await client.get("/static/hello3.txt") 75 | assert r.status_code == 200 76 | assert r.text == "world3\n" 77 | assert "content-encoding" not in r.headers 78 | assert r.headers["cache-control"] == "max-age=315360000, public, immutable" 79 | assert r.headers["access-control-allow-origin"] == "*" 80 | 81 | r = await client.post("/static/hello.txt") 82 | assert r.status_code == 405 83 | 84 | r = await client.get("/static/foo") 85 | assert r.status_code == 404 86 | 87 | r = await client.get("/http") 88 | assert r.status_code == 200 89 | assert r.text == "Hello from /http" 90 | 91 | 92 | @pytest.mark.asyncio 93 | async def test_accept_encoding(bn): 94 | transport = httpx.ASGITransport(app=bn) 95 | async with httpx.AsyncClient( 96 | transport=transport, base_url="http://testserver" 97 | ) as client: 98 | r = await client.get("/static/hello.txt", headers={"accept-encoding": "gzip"}) 99 | assert r.status_code == 200 100 | assert r.text == "world\n" 101 | assert r.headers["content-encoding"] == "gzip" 102 | 103 | r = await client.get( 104 | "/static/hello.txt", headers={"accept-encoding": "identity"} 105 | ) 106 | assert r.status_code == 200 107 | assert r.text == "world\n" 108 | assert "content-encoding" not in r.headers 109 | 110 | 111 | @pytest.mark.asyncio 112 | async def test_ws(bn): 113 | async with httpx.AsyncClient(transport=ASGIWebSocketTransport(bn)) as client: 114 | http_response = await client.get("http://server/http") 115 | assert http_response.status_code == 200 116 | 117 | async with aconnect_ws("http://server/ws", client) as ws: 118 | message = await ws.receive_text() 119 | assert message == "Hello World!" 120 | 121 | 122 | def test_compress(): 123 | with tempfile.TemporaryDirectory() as root_: 124 | root = Path(root_) 125 | 126 | (root / "hello.txt").write_text("hello " * 100) 127 | (root / "hello2.txt").write_text("hello") 128 | (root / "hello3.jpeg").write_text("") 129 | compress(root) 130 | 131 | assert {file.name for file in root.glob("*")} == { 132 | "hello.txt", 133 | "hello.txt.gz", 134 | "hello.txt.br", 135 | "hello2.txt", 136 | "hello3.jpeg", 137 | } 138 | 139 | 140 | def test_parse_args(): 141 | with pytest.raises(SystemExit): 142 | parse_args([]) 143 | 144 | args = parse_args(["hello"]) 145 | assert args.root == "hello" 146 | 147 | 148 | @pytest.mark.asyncio 149 | async def test_range(bn): 150 | transport = httpx.ASGITransport(app=bn) 151 | async with httpx.AsyncClient( 152 | transport=transport, base_url="http://testserver" 153 | ) as client: 154 | r = await client.get("/static/hello.txt", headers={"range": "words=1-2"}) 155 | assert r.status_code == 400 156 | # assert r.text == "world\n" 157 | 158 | r = await client.get("/static/hello.txt", headers={"range": "bytes=2-1"}) 159 | assert r.status_code == 206 160 | assert r.text == "" 161 | 162 | r = await client.get("/static/hello.txt", headers={"range": "bytes=1-2"}) 163 | assert r.status_code == 206 164 | assert r.text == "or" 165 | assert r.headers["content-range"] == "bytes 1-2/6" 166 | 167 | r = await client.get("/static/hello.txt", headers={"range": "bytes=-2"}) 168 | assert r.status_code == 206 169 | assert r.text == "d\n" 170 | assert r.headers["content-range"] == "bytes 4-5/6" 171 | --------------------------------------------------------------------------------