diff --git a/DEVELOPERS b/DEVELOPERS index 7fa97fb36d..b57058a177 100644 --- a/DEVELOPERS +++ b/DEVELOPERS @@ -3013,6 +3013,11 @@ F: package/lrzip/ N: Sam Voss F: package/ripgrep/ +N: Sebastian Michel +F: package/python-servestatic/ +F: support/testing/tests/package/sample_python_servestatic.py +F: support/testing/tests/package/test_python_servestatic.py + N: Sébastien Szymanski F: board/armadeus/ F: configs/armadeus* diff --git a/package/Config.in b/package/Config.in index dcca214383..dc829675ff 100644 --- a/package/Config.in +++ b/package/Config.in @@ -1420,6 +1420,7 @@ menu "External python modules" source "package/python-sentry-sdk/Config.in" source "package/python-serial/Config.in" source "package/python-serial-asyncio/Config.in" + source "package/python-servestatic/Config.in" source "package/python-service-identity/Config.in" source "package/python-setproctitle/Config.in" source "package/python-setuptools/Config.in" diff --git a/package/python-servestatic/Config.in b/package/python-servestatic/Config.in new file mode 100644 index 0000000000..b2a1e2a45d --- /dev/null +++ b/package/python-servestatic/Config.in @@ -0,0 +1,11 @@ +config BR2_PACKAGE_PYTHON_SERVESTATIC + bool "python-servestatic" + select BR2_PACKAGE_PYTHON_ASGIREF # runtime dependency + select BR2_PACKAGE_PYTHON3_ZLIB # runtime dependency for cli / servestatic.storage usage + help + Production-grade static file server for Python WSGI and ASGI. + + ServeStatic runs standalone or as middleware for an existing + WSGI, ASGI, or Django application. + + https://github.com/Archmonger/ServeStatic diff --git a/package/python-servestatic/python-servestatic.hash b/package/python-servestatic/python-servestatic.hash new file mode 100644 index 0000000000..6d1fb6ecf9 --- /dev/null +++ b/package/python-servestatic/python-servestatic.hash @@ -0,0 +1,3 @@ +# Locally computed sha256 checksums +sha256 1ab67d0654910c000864f9b6d7fefa8befc21a2156d539cf9e519afcc27c4d63 python-servestatic-4.3.2.tar.gz +sha256 1770e2fc440cce75b2b2bd1580c6d59f58a497f0bb7abe91a0136bee0f688bf3 LICENSE.md diff --git a/package/python-servestatic/python-servestatic.mk b/package/python-servestatic/python-servestatic.mk new file mode 100644 index 0000000000..26d93fe9b5 --- /dev/null +++ b/package/python-servestatic/python-servestatic.mk @@ -0,0 +1,13 @@ +################################################################################ +# +# python-servestatic +# +################################################################################ + +PYTHON_SERVESTATIC_VERSION = 4.3.2 +PYTHON_SERVESTATIC_SITE = $(call github,Archmonger,ServeStatic,$(PYTHON_SERVESTATIC_VERSION)) +PYTHON_SERVESTATIC_SETUP_TYPE = hatch +PYTHON_SERVESTATIC_LICENSE = MIT +PYTHON_SERVESTATIC_LICENSE_FILES = LICENSE.md + +$(eval $(python-package)) diff --git a/support/testing/tests/package/sample_python_servestatic.py b/support/testing/tests/package/sample_python_servestatic.py new file mode 100644 index 0000000000..22c69cdbcf --- /dev/null +++ b/support/testing/tests/package/sample_python_servestatic.py @@ -0,0 +1,118 @@ +import asyncio +import os +import tempfile +from collections.abc import Callable +from types import TracebackType +from typing import TypeAlias +from wsgiref.types import StartResponse + +from asgiref.typing import HTTPScope, HTTPRequestEvent, ASGIVersions + +from servestatic import ServeStatic, ServeStaticASGI + +CONTENT = b"ServeStatic works on Buildroot.\n" + +_ExcInfo: TypeAlias = tuple[type[BaseException], BaseException, TracebackType] +_OptExcInfo: TypeAlias = _ExcInfo | tuple[None, None, None] + + +def assert_response_ok(status, headers, body): + assert status == 200 + assert headers["content-length"] == str(len(CONTENT)) + assert headers["content-type"].startswith("text/plain") + assert body == CONTENT + + +def test_wsgi(content_directory): + app = ServeStatic(None, root=content_directory) + response_data = {} + + class DummyStartResponse(StartResponse): + def __call__(self, status: str, headers: list[tuple[str, str]], exc_info: _OptExcInfo | None = None, /) -> \ + Callable[[bytes], object]: + response_data["status"] = int(status.split()[0]) + response_data["headers"] = { + key.lower(): value for key, value in headers + } + return super().__call__(status, headers, exc_info) + + start_response = DummyStartResponse() + + environ_test_request = { + "PATH_INFO": "/test_content.txt", + "REQUEST_METHOD": "GET", + } + response = app(environ_test_request, start_response) + try: + body = b"".join(response) + finally: + close = getattr(response, "close", None) + if callable(close): + close: Callable[None] + close() + + assert_response_ok( + response_data["status"], response_data["headers"], body + ) + + +async def test_asgi(content_directory): + app = ServeStaticASGI(None, root=content_directory) + messages_received_from_application = [] + + async def receive() -> HTTPRequestEvent: + return HTTPRequestEvent( + type="http.request", + body=b"", + more_body=False, + ) + + # will be invoked when the application "sends" data back -> append all sent data to the messages list + async def send(message): + messages_received_from_application.append(message) + + # run the test request + scope_test_request = HTTPScope( + type="http", + asgi=ASGIVersions( + version="3.0", + spec_version="2.5" + ), + http_version="1.1", + method="GET", + scheme="http", + path="/test_content.txt", + raw_path=b"/hello.txt", + query_string=b"", + root_path="", + headers=[], + client=("127.0.0.1", 12345), + server=("127.0.0.1", 80), + extensions=None + ) + await app(scope_test_request, receive, send) + + # first segment contains the headers + message_with_headers = messages_received_from_application[0] + + # convert headers into correct form for assert_response_ok + headers = { + key.decode(): value.decode() for key, value in message_with_headers["headers"] + } + + # collect content of all "body" type messages + body = b"".join( + message.get("body", b"") + for message in messages_received_from_application + if message["type"] == "http.response.body" + ) + + # assert everything works as expected + assert_response_ok(message_with_headers["status"], headers, body) + + +with tempfile.TemporaryDirectory() as tmp_content_directory: + with open(os.path.join(tmp_content_directory, "test_content.txt"), "wb") as static_file: + static_file.write(CONTENT) + test_wsgi(tmp_content_directory) + asyncio.run(test_asgi(tmp_content_directory)) diff --git a/support/testing/tests/package/test_python_servestatic.py b/support/testing/tests/package/test_python_servestatic.py new file mode 100644 index 0000000000..d9c762962a --- /dev/null +++ b/support/testing/tests/package/test_python_servestatic.py @@ -0,0 +1,19 @@ +from tests.package.test_python import TestPythonPackageBase + + +class TestPythonServeStatic(TestPythonPackageBase): + __test__ = True + config = TestPythonPackageBase.config + \ + """ + BR2_PACKAGE_PYTHON3=y + BR2_PACKAGE_PYTHON_SERVESTATIC=y + """ + + sample_scripts = ["tests/package/sample_python_servestatic.py"] + + def run_sample_scripts(self): + self.assertRunOk( + "servestatic --help | grep -q 'Process static files'" + ) + + super().run_sample_scripts()