diff --git a/core/src/bugis/core/_app.py b/core/src/bugis/core/_app.py index 2b3089b..fdd70fd 100644 --- a/core/src/bugis/core/_app.py +++ b/core/src/bugis/core/_app.py @@ -2,14 +2,18 @@ from abc import ABC, abstractmethod from asyncio import Queue, AbstractEventLoop from asyncio import get_running_loop from logging import getLogger -from typing import Callable, Awaitable, Any, Mapping, Sequence, Optional, Unpack, Tuple - +from typing import Callable, Awaitable, Any, Mapping, Sequence, Optional, Unpack, Tuple, TYPE_CHECKING +from pathlib import Path, PurePath from pwo import Maybe, AsyncQueueIterator - +from hashlib import md5 from ._http_context import HttpContext from ._http_method import HttpMethod from ._types import StrOrStrings +from base64 import b64encode, b64decode +from mimetypes import guess_type +if TYPE_CHECKING: + from _typeshed import StrOrBytesPath try: from ._rsgi import RsgiContext from granian._granian import RSGIHTTPProtocol, RSGIHTTPScope # type: ignore @@ -141,3 +145,5 @@ class BugisApp(AbstractBugisApp): def PATCH(self, path: str, recursive: bool = False) -> Callable[[HttpHandler], HttpHandler]: return self.route(path, (HttpMethod.PATCH,), recursive) + + diff --git a/core/src/bugis/core/_asgi.py b/core/src/bugis/core/_asgi.py index 20b83c5..5c56e86 100644 --- a/core/src/bugis/core/_asgi.py +++ b/core/src/bugis/core/_asgi.py @@ -34,11 +34,11 @@ def decode_headers(headers: Iterable[Tuple[bytes, bytes]]) -> Dict[str, Sequence raise NotImplementedError('This should never happen') if isinstance(value, bytes): value_str = value.decode() - elif isinstance(key, str): + elif isinstance(value, str): value_str = value else: raise NotImplementedError('This should never happen') - ls = result.setdefault(key_str, list()) + ls = result.setdefault(key_str.lower(), list()) ls.append(value_str) return { k: tuple(v) for k, v in result.items() @@ -91,7 +91,7 @@ class AsgiContext(HttpContext): async def stream_body(self, status: int, body_generator: AsyncGenerator[bytes, None], - headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: await self._send_head(status, headers) async for chunk in body_generator: await self.send({ @@ -105,21 +105,21 @@ class AsgiContext(HttpContext): 'more_body': False }) - async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: await self._send_head(status, headers) await self.send({ 'type': 'http.response.body', 'body': body, }) - async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: await self._send_head(status, headers) await self.send({ 'type': 'http.response.body', 'body': body.encode(), }) - async def _send_head(self, status: int, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + async def _send_head(self, status: int, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: await self.send({ 'type': 'http.response.start', 'status': status, @@ -129,7 +129,7 @@ class AsgiContext(HttpContext): async def send_file(self, status: int, path: Path, - headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: if self.pathsend: await self._send_head(status, headers) await self.send({ @@ -139,7 +139,7 @@ class AsgiContext(HttpContext): else: raise NotImplementedError() - async def send_empty(self, status: int, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + async def send_empty(self, status: int, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: await self._send_head(status, headers) await self.send({ 'type': 'http.response.body', diff --git a/core/src/bugis/core/_http_context.py b/core/src/bugis/core/_http_context.py index 1380855..9c87452 100644 --- a/core/src/bugis/core/_http_context.py +++ b/core/src/bugis/core/_http_context.py @@ -13,6 +13,7 @@ from abc import ABC, abstractmethod from pathlib import Path from ._http_method import HttpMethod +from ._types.base import StrOrStrings class HttpContext(ABC): @@ -32,20 +33,20 @@ class HttpContext(ABC): async def stream_body(self, status: int, body_generator: AsyncGenerator[bytes, None], - headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: pass @abstractmethod - async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: pass - async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: await self.send_bytes(status, body.encode(), headers) @abstractmethod - async def send_file(self, status: int, path: Path, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + async def send_file(self, status: int, path: Path, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: pass @abstractmethod - async def send_empty(self, status: int, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + async def send_empty(self, status: int, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: pass diff --git a/core/src/bugis/core/_rsgi.py b/core/src/bugis/core/_rsgi.py index 5289cab..1f2be10 100644 --- a/core/src/bugis/core/_rsgi.py +++ b/core/src/bugis/core/_rsgi.py @@ -17,6 +17,7 @@ from typing import ( from granian._granian import RSGIHTTPProtocol, RSGIHTTPScope from pwo import Maybe +from ._types import StrOrStrings from ._http_context import HttpContext from ._http_method import HttpMethod @@ -40,7 +41,7 @@ class RsgiContext(HttpContext): self.query_string = scope.query_string def acc(d: Dict[str, List[str]], t: Tuple[str, str]) -> Dict[str, List[str]]: - d.setdefault(t[0], list()).append(t[1]) + d.setdefault(t[0].lower(), list()).append(t[1]) return d fun = cast(Callable[[Mapping[str, Sequence[str]], tuple[str, str]], Mapping[str, Sequence[str]]], acc) @@ -54,16 +55,27 @@ class RsgiContext(HttpContext): self.request_body = aiter(protocol) self.protocol = protocol + # @staticmethod + # def _rearrange_headers(headers: Mapping[str, Sequence[str]]) -> List[Tuple[str, str]]: + # return list( + # ((key, value) for key, values in headers.items() for value in values) + # ) + @staticmethod - def _rearrange_headers(headers: Mapping[str, Sequence[str]]) -> List[Tuple[str, str]]: - return list( - ((key, value) for key, values in headers.items() for value in values) - ) + def _rearrange_headers(headers: Mapping[str, StrOrStrings]) -> List[Tuple[str, str]]: + result = [] + for key, value in headers.items(): + if isinstance(value, str): + result.append((key, value)) + elif isinstance(value, Sequence): + for single_value in value: + result.append((key, single_value)) + return result async def stream_body(self, status: int, body_generator: AsyncGenerator[bytes, None], - headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: transport = self.protocol.response_stream(status, Maybe.of_nullable(headers) .map(self._rearrange_headers) @@ -71,24 +83,25 @@ class RsgiContext(HttpContext): async for chunk in body_generator: await transport.send_bytes(chunk) - async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list()) if len(body) > 0: self.protocol.response_bytes(status, rearranged_headers, body) else: self.protocol.response_empty(status, rearranged_headers) - async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list()) if len(body) > 0: self.protocol.response_str(status, rearranged_headers, body) else: self.protocol.response_empty(status, rearranged_headers) - async def send_file(self, status: int, path: Path, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: - rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list()) + async def send_file(self, status: int, path: Path, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: + rearranged_headers = (Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers) + .or_else(list())) self.protocol.response_file(status, rearranged_headers, str(path)) - async def send_empty(self, status: int, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + async def send_empty(self, status: int, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list()) self.protocol.response_empty(status, rearranged_headers) diff --git a/core/src/bugis/core/_tree.py b/core/src/bugis/core/_tree.py index bc72d69..2e2067a 100644 --- a/core/src/bugis/core/_tree.py +++ b/core/src/bugis/core/_tree.py @@ -20,7 +20,8 @@ from ._http_context import HttpContext from ._http_method import HttpMethod from ._path_handler import PathHandler from ._path_matcher import PathMatcher, IntMatcher, GlobMatcher, StrMatcher, Node -from ._types import NodeType, Matches +from ._path_handler import Matches +from ._types import NodeType class Tree: diff --git a/core/src/bugis/core/_types/__init__.py b/core/src/bugis/core/_types/__init__.py index 73fc6b0..6b0447c 100644 --- a/core/src/bugis/core/_types/__init__.py +++ b/core/src/bugis/core/_types/__init__.py @@ -12,16 +12,12 @@ from typing import ( Sequence ) +from .base import StrOrStrings, PathMatcherResult + from bugis.core._http_method import HttpMethod -from bugis.core._path_handler import PathHandler, Matches - -type StrOrStrings = (str | Sequence[str]) - type NodeType = (str | HttpMethod) -type PathMatcherResult = Mapping[str, Any] | Sequence[str] - class ASGIVersions(TypedDict): spec_version: str @@ -90,7 +86,6 @@ __all__ = [ 'RSGI', 'ASGIVersions', 'WebSocketScope', - 'PathHandler', 'NodeType', - 'Matches' + 'StrOrStrings' ] diff --git a/core/src/bugis/core/_types/base.py b/core/src/bugis/core/_types/base.py new file mode 100644 index 0000000..0a664c1 --- /dev/null +++ b/core/src/bugis/core/_types/base.py @@ -0,0 +1,4 @@ +from typing import Sequence, Mapping, Any + +type StrOrStrings = (str | Sequence[str]) +type PathMatcherResult = Mapping[str, Any] | Sequence[str] diff --git a/core/tests/test_ciao.py b/core/tests/test_ciao.py new file mode 100644 index 0000000..fed2d73 --- /dev/null +++ b/core/tests/test_ciao.py @@ -0,0 +1,38 @@ +import unittest + + +# Define some test cases +class TestAddition(unittest.TestCase): + def test_add_positive(self): + self.assertEqual(1 + 2, 3) + + def test_add_negative(self): + self.assertEqual(-1 + (-1), -2) + + +class TestSubtraction(unittest.TestCase): + def test_subtract_positive(self): + self.assertEqual(5 - 3, 2) + + def test_subtract_negative(self): + self.assertEqual(-5 - (-2), -3) + + +# Now let's create a TestSuite +def suite(): + suite = unittest.TestSuite() + + # Add tests to the suite + suite.addTest(TestAddition('test_add_positive')) + suite.addTest(TestAddition('test_add_negative')) + suite.addTest(TestSubtraction('test_subtract_positive')) + # suite.addTest(TestSubtraction('test_subtract_negative')) + # suite.addTest(TestSubtraction('test_subtract_negative2')) + + return suite + + +# Running the suite +if __name__ == "__main__": + runner = unittest.TextTestRunner() + runner.run(suite()) \ No newline at end of file diff --git a/server/example/test_server.py b/server/example/test_server.py new file mode 100644 index 0000000..2272f44 --- /dev/null +++ b/server/example/test_server.py @@ -0,0 +1,12 @@ + +from bugis.core import BugisApp +from pathlib import PurePath +from bugis.server.server import static_resources +import os + +root = os.getenv('STATIC_ROOT') or '.' +app = BugisApp() + +static_resources(app, '/view', root) + + diff --git a/server/pyproject.toml b/server/pyproject.toml new file mode 100644 index 0000000..12a3853 --- /dev/null +++ b/server/pyproject.toml @@ -0,0 +1,67 @@ +[build-system] +requires = ["setuptools>=61.0", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "bugis_server" +dynamic = ["version"] +authors = [ + { name="Walter Oggioni", email="oggioni.walter@gmail.com" }, +] +description = "Static file renderer" +readme = "README.md" +requires-python = ">=3.10" +classifiers = [ + 'Development Status :: 3 - Alpha', + 'Topic :: Utilities', + 'License :: OSI Approved :: MIT License', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Developers', + 'Environment :: Console', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', +] +dependencies = [ + "bugis_core", + "Markdown", + "Pygments", + "watchdog", + "pwo", + "PyYAML", + "pygraphviz", + "aiofiles", + "httpx[http2]" +] + +[project.optional-dependencies] +dev = [ + "build", "granian", "mypy", "ipdb", "twine" +] + +run = [ + "granian" +] + +[tool.setuptools.package-data] +bugis = ['static/*', 'default-conf/*'] + +[project.urls] +"Homepage" = "https://github.com/woggioni/bugis" +"Bug Tracker" = "https://github.com/woggioni/bugis/issues" + +[tool.mypy] +python_version = "3.12" +disallow_untyped_defs = true +show_error_codes = true +no_implicit_optional = true +warn_return_any = true +warn_unused_ignores = true +exclude = ["scripts", "docs", "test"] +strict = true + +[tool.setuptools_scm] +root='..' +version_file = "src/bugis/server/_version.py" + +[project.scripts] +bugis = "bugis.server.cli:main" \ No newline at end of file diff --git a/server/src/bugis/server/__init__.py b/server/src/bugis/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/src/bugis/server/cache.py b/server/src/bugis/server/cache.py new file mode 100644 index 0000000..6c8daa5 --- /dev/null +++ b/server/src/bugis/server/cache.py @@ -0,0 +1,16 @@ +from abc import ABC, abstractmethod +from typing import Optional + + +class Cache(ABC): + + @abstractmethod + def get(self, key: bytes) -> Optional[bytes]: + pass + + @abstractmethod + def put(self, key: bytes, value: bytes): + pass + + def __contains__(self, key: bytes): + return self.get(key) is not None diff --git a/server/src/bugis/server/cli.py b/server/src/bugis/server/cli.py new file mode 100644 index 0000000..67a4e9a --- /dev/null +++ b/server/src/bugis/server/cli.py @@ -0,0 +1,3 @@ + +def main(): + pass diff --git a/server/src/bugis/server/server.py b/server/src/bugis/server/server.py new file mode 100644 index 0000000..1df50dd --- /dev/null +++ b/server/src/bugis/server/server.py @@ -0,0 +1,129 @@ +from base64 import b64encode +from hashlib import md5 +from mimetypes import guess_type +from pathlib import Path, PurePath +from typing import TYPE_CHECKING, Optional, Callable, Awaitable, AsyncGenerator, Any, Unpack + +from aiofiles.os import listdir +from aiofiles.ospath import isdir, isfile +from bugis.core import HttpContext, BugisApp +from pwo import Maybe + +if TYPE_CHECKING: + from _typeshed import StrOrBytesPath + + +def parse_etag(etag: str) -> Optional[str]: + def skip_weak_marker(s): + if s.startswith('W/'): + return s[2:] + else: + return s + + return ( + Maybe.of_nullable(etag) + .map(skip_weak_marker) + .or_else(None) + ) + + +def static_resources(app: BugisApp, + path: str, + root: 'StrOrBytesPath', + favicon: PurePath = None, + file_filter: Callable[[PurePath], Awaitable[bool]] = None + ): + async def no_filter(_: PurePath): + return True + + if file_filter is None: + file_filter = no_filter + + def compute_etag(resource: Path) -> str: + md = md5() + print(resource) + with resource.open('rb') as file: + while True: + chunk = file.read(0x10000) + if len(chunk): + md.update(chunk) + else: + break + return b64encode(md.digest()).decode() + + if isinstance(root, str): + folder = Path(root) + else: + folder = root + + prefix = PurePath(path) + + async def handler(context: HttpContext, *_: Unpack[Any]) -> None: + requested = (PurePath(context.path)).relative_to(prefix) + resource = folder / requested + if not resource.is_relative_to(folder) or not resource.exists(): + return await context.send_empty(404) + + if await isfile(resource): + + e_tag_header = ((Maybe.of_nullable(context.headers.get('if-none-match')) + .filter(lambda it: len(it) > 0) + .map(lambda it: it[-1]) + .map(parse_etag)) + .or_none()) + current_etag = compute_etag(resource) + + if e_tag_header == current_etag: + return await context.send_empty(304) + else: + mime_type = (Maybe.of(guess_type(resource.name)) + .map(lambda it: it[0]) + .or_else('application/octet-stream')) + return await context.send_file(200, resource, { + 'content-type': mime_type or 'application/octet-stream', + 'etag': 'W/' + current_etag, + 'cache-control': 'no-cache', + }) + elif isdir(resource): + headers = { + 'content-type': 'text/html' + } + await context.send_str(200, await directory_listing(prefix, requested, resource, favicon, file_filter), + headers) + + return app.GET(path, True)(handler) + + +async def directory_listing(prefix: PurePath, + path_info: PurePath, + path: PurePath, + favicon: PurePath, + file_filter: Callable[[PurePath], Awaitable[bool]]) -> str: + title = "Directory listing for %s" % path_info + result = "" + if favicon: + result += f'' + result += "" + result += "" + title + "" + result += "

" + title + "


" + result += "