Compare commits
4 Commits
544229b7a6
...
dev
Author | SHA1 | Date | |
---|---|---|---|
f158698380
|
|||
56c8e796b7
|
|||
8f0320f262
|
|||
ee6e645cc1
|
@@ -1,7 +1,8 @@
|
|||||||
from ._app import BugisApp
|
from ._app import BugisApp
|
||||||
from ._http_method import HttpMethod
|
from ._http_method import HttpMethod
|
||||||
from ._http_context import HttpContext
|
from ._http_context import HttpContext
|
||||||
from ._tree import Tree, PathHandler, PathIterator
|
from ._tree import Tree, PathIterator
|
||||||
|
from ._path_handler import PathHandler
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
@@ -2,13 +2,18 @@ from abc import ABC, abstractmethod
|
|||||||
from asyncio import Queue, AbstractEventLoop
|
from asyncio import Queue, AbstractEventLoop
|
||||||
from asyncio import get_running_loop
|
from asyncio import get_running_loop
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Callable, Awaitable, Any, Mapping, Sequence, Optional
|
from typing import Callable, Awaitable, Any, Mapping, Sequence, Optional, Unpack, Tuple, TYPE_CHECKING
|
||||||
|
from pathlib import Path, PurePath
|
||||||
from pwo import Maybe, AsyncQueueIterator
|
from pwo import Maybe, AsyncQueueIterator
|
||||||
|
from hashlib import md5
|
||||||
from ._http_context import HttpContext
|
from ._http_context import HttpContext
|
||||||
from ._http_method import HttpMethod
|
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:
|
try:
|
||||||
from ._rsgi import RsgiContext
|
from ._rsgi import RsgiContext
|
||||||
from granian._granian import RSGIHTTPProtocol, RSGIHTTPScope # type: ignore
|
from granian._granian import RSGIHTTPProtocol, RSGIHTTPScope # type: ignore
|
||||||
@@ -21,7 +26,8 @@ from ._types.asgi import LifespanScope, HTTPScope as ASGIHTTPScope, WebSocketSco
|
|||||||
|
|
||||||
log = getLogger(__name__)
|
log = getLogger(__name__)
|
||||||
|
|
||||||
type HttpHandler = Callable[[HttpContext], Awaitable[None]]
|
type HttpHandler = Callable[[HttpContext, Unpack[Any]], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
class AbstractBugisApp(ABC):
|
class AbstractBugisApp(ABC):
|
||||||
async def __call__(self,
|
async def __call__(self,
|
||||||
@@ -84,43 +90,60 @@ class BugisApp(AbstractBugisApp):
|
|||||||
self._tree = Tree()
|
self._tree = Tree()
|
||||||
|
|
||||||
async def handle_request(self, ctx: HttpContext) -> None:
|
async def handle_request(self, ctx: HttpContext) -> None:
|
||||||
handler = self._tree.get_handler(ctx.path, ctx.method)
|
result = self._tree.get_handler(ctx.path, ctx.method)
|
||||||
if handler is not None:
|
if result is not None:
|
||||||
await handler.handle_request(ctx)
|
handler, captured = result
|
||||||
|
await handler.handle_request(ctx, captured)
|
||||||
else:
|
else:
|
||||||
await ctx.send_empty(404)
|
await ctx.send_empty(404)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def route(self,
|
def route(self,
|
||||||
path: str,
|
paths: StrOrStrings,
|
||||||
methods: Optional[Sequence[HttpMethod]] = None) -> Callable[[HttpHandler], HttpHandler]:
|
methods: Optional[HttpMethod | Sequence[HttpMethod]] = None,
|
||||||
|
recursive: bool = False) -> Callable[[HttpHandler], HttpHandler]:
|
||||||
|
|
||||||
def wrapped(handler: HttpHandler) -> HttpHandler:
|
def wrapped(handler: HttpHandler) -> HttpHandler:
|
||||||
if methods is not None:
|
nonlocal methods
|
||||||
for method in methods:
|
nonlocal paths
|
||||||
self._tree.register(path, method, handler)
|
_methods: Tuple[Optional[HttpMethod], ...]
|
||||||
|
if methods is None:
|
||||||
|
_methods = (None,)
|
||||||
|
elif isinstance(methods, HttpMethod):
|
||||||
|
_methods = (methods,)
|
||||||
else:
|
else:
|
||||||
self._tree.register(path, None, handler)
|
_methods = tuple(methods)
|
||||||
|
_paths: Tuple[str, ...]
|
||||||
|
if isinstance(paths, str):
|
||||||
|
_paths = (paths,)
|
||||||
|
else:
|
||||||
|
_paths = tuple(paths)
|
||||||
|
for method in _methods:
|
||||||
|
for path in _paths:
|
||||||
|
self._tree.register(path, method, handler, recursive)
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
def GET(self, path: str) -> Callable[[HttpHandler], HttpHandler]:
|
def GET(self, path: str, recursive: bool = False) -> Callable[[HttpHandler], HttpHandler]:
|
||||||
return self.route(path, (HttpMethod.GET,))
|
return self.route(path, (HttpMethod.GET,), recursive)
|
||||||
|
|
||||||
def POST(self, path: str) -> Callable[[HttpHandler], HttpHandler]:
|
def POST(self, path: str, recursive: bool = False) -> Callable[[HttpHandler], HttpHandler]:
|
||||||
return self.route(path, (HttpMethod.POST,))
|
return self.route(path, (HttpMethod.POST,), recursive)
|
||||||
|
|
||||||
def PUT(self, path: str) -> Callable[[HttpHandler], HttpHandler]:
|
def PUT(self, path: str, recursive: bool = False) -> Callable[[HttpHandler], HttpHandler]:
|
||||||
return self.route(path, (HttpMethod.PUT,))
|
return self.route(path, (HttpMethod.PUT,), recursive)
|
||||||
|
|
||||||
def DELETE(self, path: str) -> Callable[[HttpHandler], HttpHandler]:
|
def DELETE(self, path: str, recursive: bool = False) -> Callable[[HttpHandler], HttpHandler]:
|
||||||
return self.route(path, (HttpMethod.DELETE,))
|
return self.route(path, (HttpMethod.DELETE,), recursive)
|
||||||
|
|
||||||
def OPTIONS(self, path: str) -> Callable[[HttpHandler], HttpHandler]:
|
def OPTIONS(self, path: str, recursive: bool = False) -> Callable[[HttpHandler], HttpHandler]:
|
||||||
return self.route(path, (HttpMethod.OPTIONS,))
|
return self.route(path, (HttpMethod.OPTIONS,), recursive)
|
||||||
|
|
||||||
|
def HEAD(self, path: str, recursive: bool = False) -> Callable[[HttpHandler], HttpHandler]:
|
||||||
|
return self.route(path, (HttpMethod.HEAD,), recursive)
|
||||||
|
|
||||||
|
def PATCH(self, path: str, recursive: bool = False) -> Callable[[HttpHandler], HttpHandler]:
|
||||||
|
return self.route(path, (HttpMethod.PATCH,), recursive)
|
||||||
|
|
||||||
def HEAD(self, path: str) -> Callable[[HttpHandler], HttpHandler]:
|
|
||||||
return self.route(path, (HttpMethod.HEAD,))
|
|
||||||
|
|
||||||
def PATCH(self, path: str) -> Callable[[HttpHandler], HttpHandler]:
|
|
||||||
return self.route(path, (HttpMethod.PATCH,))
|
|
||||||
|
@@ -34,11 +34,11 @@ def decode_headers(headers: Iterable[Tuple[bytes, bytes]]) -> Dict[str, Sequence
|
|||||||
raise NotImplementedError('This should never happen')
|
raise NotImplementedError('This should never happen')
|
||||||
if isinstance(value, bytes):
|
if isinstance(value, bytes):
|
||||||
value_str = value.decode()
|
value_str = value.decode()
|
||||||
elif isinstance(key, str):
|
elif isinstance(value, str):
|
||||||
value_str = value
|
value_str = value
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError('This should never happen')
|
raise NotImplementedError('This should never happen')
|
||||||
ls = result.setdefault(key_str, list())
|
ls = result.setdefault(key_str.lower(), list())
|
||||||
ls.append(value_str)
|
ls.append(value_str)
|
||||||
return {
|
return {
|
||||||
k: tuple(v) for k, v in result.items()
|
k: tuple(v) for k, v in result.items()
|
||||||
@@ -91,7 +91,7 @@ class AsgiContext(HttpContext):
|
|||||||
async def stream_body(self,
|
async def stream_body(self,
|
||||||
status: int,
|
status: int,
|
||||||
body_generator: AsyncGenerator[bytes, None],
|
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)
|
await self._send_head(status, headers)
|
||||||
async for chunk in body_generator:
|
async for chunk in body_generator:
|
||||||
await self.send({
|
await self.send({
|
||||||
@@ -105,21 +105,21 @@ class AsgiContext(HttpContext):
|
|||||||
'more_body': False
|
'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_head(status, headers)
|
||||||
await self.send({
|
await self.send({
|
||||||
'type': 'http.response.body',
|
'type': 'http.response.body',
|
||||||
'body': 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_head(status, headers)
|
||||||
await self.send({
|
await self.send({
|
||||||
'type': 'http.response.body',
|
'type': 'http.response.body',
|
||||||
'body': body.encode(),
|
'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({
|
await self.send({
|
||||||
'type': 'http.response.start',
|
'type': 'http.response.start',
|
||||||
'status': status,
|
'status': status,
|
||||||
@@ -129,7 +129,7 @@ class AsgiContext(HttpContext):
|
|||||||
async def send_file(self,
|
async def send_file(self,
|
||||||
status: int,
|
status: int,
|
||||||
path: Path,
|
path: Path,
|
||||||
headers: Optional[Mapping[str, Sequence[str]]] = None) -> None:
|
headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
if self.pathsend:
|
if self.pathsend:
|
||||||
await self._send_head(status, headers)
|
await self._send_head(status, headers)
|
||||||
await self.send({
|
await self.send({
|
||||||
@@ -139,7 +139,7 @@ class AsgiContext(HttpContext):
|
|||||||
else:
|
else:
|
||||||
raise NotImplementedError()
|
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_head(status, headers)
|
||||||
await self.send({
|
await self.send({
|
||||||
'type': 'http.response.body',
|
'type': 'http.response.body',
|
||||||
|
@@ -13,6 +13,7 @@ from abc import ABC, abstractmethod
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ._http_method import HttpMethod
|
from ._http_method import HttpMethod
|
||||||
|
from ._types.base import StrOrStrings
|
||||||
|
|
||||||
|
|
||||||
class HttpContext(ABC):
|
class HttpContext(ABC):
|
||||||
@@ -32,20 +33,20 @@ class HttpContext(ABC):
|
|||||||
async def stream_body(self,
|
async def stream_body(self,
|
||||||
status: int,
|
status: int,
|
||||||
body_generator: AsyncGenerator[bytes, None],
|
body_generator: AsyncGenerator[bytes, None],
|
||||||
headers: Optional[Mapping[str, Sequence[str]]] = None) -> None:
|
headers: Optional[Mapping[str, StrOrStrings]] = None) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
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)
|
await self.send_bytes(status, body.encode(), headers)
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
pass
|
||||||
|
12
core/src/bugis/core/_node.py
Normal file
12
core/src/bugis/core/_node.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import (
|
||||||
|
Optional,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
)
|
||||||
|
from ._types import NodeType
|
||||||
|
from ._path_handler import PathHandler
|
||||||
|
from ._path_matcher import PathMatcher
|
||||||
|
|
||||||
|
|
||||||
|
|
33
core/src/bugis/core/_path_handler.py
Normal file
33
core/src/bugis/core/_path_handler.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import (
|
||||||
|
Sequence,
|
||||||
|
Dict,
|
||||||
|
Optional
|
||||||
|
)
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from ._http_context import HttpContext
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Matches:
|
||||||
|
|
||||||
|
kwargs: Dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
path: Optional[Sequence[str]] = None
|
||||||
|
|
||||||
|
unmatched_paths: Sequence[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PathHandler(ABC):
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def handle_request(self, ctx: HttpContext, captured: Matches) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def recursive(self) -> bool:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
type PathHandlers = (PathHandler | Sequence[PathHandler])
|
97
core/src/bugis/core/_path_matcher.py
Normal file
97
core/src/bugis/core/_path_matcher.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from fnmatch import fnmatch
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional, Sequence, Dict, List, Union
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from ._path_handler import PathHandler
|
||||||
|
from ._types import NodeType, PathMatcherResult
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Node:
|
||||||
|
key: NodeType
|
||||||
|
parent: Optional[Union['Node', 'PathMatcher']]
|
||||||
|
children: Dict[NodeType, 'Node']
|
||||||
|
handlers: List[PathHandler]
|
||||||
|
path_matchers: List['PathMatcher']
|
||||||
|
|
||||||
|
|
||||||
|
class PathMatcher(ABC):
|
||||||
|
parent: Optional[Union['Node', 'PathMatcher']]
|
||||||
|
children: Dict[NodeType, Node]
|
||||||
|
handlers: List[PathHandler]
|
||||||
|
path_matchers: List['PathMatcher']
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
parent: Optional[Union['Node', 'PathMatcher']],
|
||||||
|
children: Dict[NodeType, Node],
|
||||||
|
handlers: List[PathHandler],
|
||||||
|
path_matchers: List['PathMatcher']
|
||||||
|
):
|
||||||
|
self.parent = parent
|
||||||
|
self.children = children
|
||||||
|
self.handlers = handlers
|
||||||
|
self.path_matchers = path_matchers
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def match(self, path: Sequence[str]) -> Optional[PathMatcherResult]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StrMatcher(PathMatcher):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
name: str,
|
||||||
|
parent: Optional[Node | PathMatcher],
|
||||||
|
children: Dict[NodeType, Node],
|
||||||
|
handlers: List[PathHandler],
|
||||||
|
path_matchers: List[PathMatcher],
|
||||||
|
):
|
||||||
|
super().__init__(parent, children, handlers, path_matchers)
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def match(self, path: Sequence[str]) -> Optional[PathMatcherResult]:
|
||||||
|
if len(path):
|
||||||
|
return {self.name: path[0]}
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class IntMatcher(PathMatcher):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
name: str,
|
||||||
|
parent: Optional[Node | PathMatcher],
|
||||||
|
children: Dict[NodeType, Node],
|
||||||
|
handlers: List[PathHandler],
|
||||||
|
path_matchers: List[PathMatcher],
|
||||||
|
):
|
||||||
|
super().__init__(parent, children, handlers, path_matchers)
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def match(self, path: Sequence[str]) -> Optional[PathMatcherResult]:
|
||||||
|
if len(path) > 0:
|
||||||
|
try:
|
||||||
|
return {self.name: int(path[0])}
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class GlobMatcher(PathMatcher):
|
||||||
|
pattern: str
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
pattern: str,
|
||||||
|
parent: Optional[Node | PathMatcher],
|
||||||
|
children: Dict[NodeType, Node],
|
||||||
|
handlers: List[PathHandler],
|
||||||
|
path_matchers: List[PathMatcher],
|
||||||
|
):
|
||||||
|
super().__init__(parent, children, handlers, path_matchers)
|
||||||
|
self.pattern = pattern
|
||||||
|
|
||||||
|
def match(self, path: Sequence[str]) -> Optional[PathMatcherResult]:
|
||||||
|
return path if fnmatch('/'.join(path), self.pattern) else None
|
@@ -14,10 +14,10 @@ from typing import (
|
|||||||
cast
|
cast
|
||||||
)
|
)
|
||||||
|
|
||||||
from granian.rsgi import Scope
|
from granian._granian import RSGIHTTPProtocol, RSGIHTTPScope
|
||||||
from granian._granian import RSGIHTTPProtocol
|
|
||||||
from pwo import Maybe
|
from pwo import Maybe
|
||||||
|
|
||||||
|
from ._types import StrOrStrings
|
||||||
from ._http_context import HttpContext
|
from ._http_context import HttpContext
|
||||||
from ._http_method import HttpMethod
|
from ._http_method import HttpMethod
|
||||||
|
|
||||||
@@ -34,14 +34,14 @@ class RsgiContext(HttpContext):
|
|||||||
request_body: AsyncIterator[bytes]
|
request_body: AsyncIterator[bytes]
|
||||||
head = Optional[Tuple[int, Sequence[Tuple[str, str]]]]
|
head = Optional[Tuple[int, Sequence[Tuple[str, str]]]]
|
||||||
|
|
||||||
def __init__(self, scope: Scope, protocol: RSGIHTTPProtocol):
|
def __init__(self, scope: RSGIHTTPScope, protocol: RSGIHTTPProtocol):
|
||||||
self.scheme = scope.scheme
|
self.scheme = scope.scheme
|
||||||
self.path = scope.path
|
self.path = scope.path
|
||||||
self.method = HttpMethod(scope.method)
|
self.method = HttpMethod(scope.method)
|
||||||
self.query_string = scope.query_string
|
self.query_string = scope.query_string
|
||||||
|
|
||||||
def acc(d: Dict[str, List[str]], t: Tuple[str, str]) -> Dict[str, List[str]]:
|
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
|
return d
|
||||||
|
|
||||||
fun = cast(Callable[[Mapping[str, Sequence[str]], tuple[str, str]], Mapping[str, Sequence[str]]], acc)
|
fun = cast(Callable[[Mapping[str, Sequence[str]], tuple[str, str]], Mapping[str, Sequence[str]]], acc)
|
||||||
@@ -55,16 +55,27 @@ class RsgiContext(HttpContext):
|
|||||||
self.request_body = aiter(protocol)
|
self.request_body = aiter(protocol)
|
||||||
self.protocol = 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
|
@staticmethod
|
||||||
def _rearrange_headers(headers: Mapping[str, Sequence[str]]) -> List[Tuple[str, str]]:
|
def _rearrange_headers(headers: Mapping[str, StrOrStrings]) -> List[Tuple[str, str]]:
|
||||||
return list(
|
result = []
|
||||||
((key, value) for key, values in headers.items() for value in values)
|
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,
|
async def stream_body(self,
|
||||||
status: int,
|
status: int,
|
||||||
body_generator: AsyncGenerator[bytes, None],
|
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,
|
transport = self.protocol.response_stream(status,
|
||||||
Maybe.of_nullable(headers)
|
Maybe.of_nullable(headers)
|
||||||
.map(self._rearrange_headers)
|
.map(self._rearrange_headers)
|
||||||
@@ -72,24 +83,25 @@ class RsgiContext(HttpContext):
|
|||||||
async for chunk in body_generator:
|
async for chunk in body_generator:
|
||||||
await transport.send_bytes(chunk)
|
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())
|
rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list())
|
||||||
if len(body) > 0:
|
if len(body) > 0:
|
||||||
self.protocol.response_bytes(status, rearranged_headers, body)
|
self.protocol.response_bytes(status, rearranged_headers, body)
|
||||||
else:
|
else:
|
||||||
self.protocol.response_empty(status, rearranged_headers)
|
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())
|
rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list())
|
||||||
if len(body) > 0:
|
if len(body) > 0:
|
||||||
self.protocol.response_str(status, rearranged_headers, body)
|
self.protocol.response_str(status, rearranged_headers, body)
|
||||||
else:
|
else:
|
||||||
self.protocol.response_empty(status, rearranged_headers)
|
self.protocol.response_empty(status, rearranged_headers)
|
||||||
|
|
||||||
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:
|
||||||
rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list())
|
rearranged_headers = (Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers)
|
||||||
|
.or_else(list()))
|
||||||
self.protocol.response_file(status, rearranged_headers, str(path))
|
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())
|
rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list())
|
||||||
self.protocol.response_empty(status, rearranged_headers)
|
self.protocol.response_empty(status, rearranged_headers)
|
||||||
|
@@ -1,64 +1,74 @@
|
|||||||
from typing import Sequence, Dict, Awaitable, Callable, Optional, Generator, Self, List
|
|
||||||
from ._http_method import HttpMethod
|
|
||||||
from ._http_context import HttpContext
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
from typing import (
|
||||||
|
Sequence,
|
||||||
|
Awaitable,
|
||||||
|
Callable,
|
||||||
|
Optional,
|
||||||
|
Generator,
|
||||||
|
Self,
|
||||||
|
List,
|
||||||
|
Tuple,
|
||||||
|
Mapping,
|
||||||
|
Any,
|
||||||
|
)
|
||||||
|
from typing_extensions import Unpack
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from pwo import Maybe
|
|
||||||
|
|
||||||
type NodeType = (str | HttpMethod)
|
from pwo import Maybe, index_of_with_escape
|
||||||
|
|
||||||
type PathHandlers = (PathHandler | Sequence[PathHandler])
|
from ._http_context import HttpContext
|
||||||
|
from ._http_method import HttpMethod
|
||||||
|
from ._path_handler import PathHandler
|
||||||
class PathHandler(ABC):
|
from ._path_matcher import PathMatcher, IntMatcher, GlobMatcher, StrMatcher, Node
|
||||||
|
from ._path_handler import Matches
|
||||||
@abstractmethod
|
from ._types import NodeType
|
||||||
def match(self, subpath: Sequence[str], method: HttpMethod) -> bool:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def handle_request(self, ctx: HttpContext) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Node:
|
|
||||||
key: NodeType
|
|
||||||
parent: Optional['Node']
|
|
||||||
children: Dict[NodeType, 'Node']
|
|
||||||
handlers: Sequence[PathHandler]
|
|
||||||
|
|
||||||
|
|
||||||
class Tree:
|
class Tree:
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.root = Node('/', None, {}, [])
|
self.root = Node('/', None, {}, [], [])
|
||||||
|
|
||||||
def search(self, path: Generator[str, None, None], method: HttpMethod) -> Optional[Node]:
|
def search(self, path: Generator[str, None, None], method: HttpMethod) \
|
||||||
lineage: Generator[NodeType, None, None] = (it for it in chain(path, (method,)))
|
-> Optional[Tuple[Node | PathMatcher, Matches]]:
|
||||||
result = self.root
|
paths: List[str] = list(path)
|
||||||
it = iter(lineage)
|
result: Node | PathMatcher = self.root
|
||||||
|
|
||||||
|
matches = Matches()
|
||||||
|
it, i = iter((it for it in paths)), -1
|
||||||
while True:
|
while True:
|
||||||
node = result
|
node = result
|
||||||
leaf = next(it, None)
|
leaf, i = next(it, None), i + 1
|
||||||
if leaf is None:
|
if leaf is None:
|
||||||
break
|
break
|
||||||
child = node.children.get(leaf)
|
child = node.children.get(leaf)
|
||||||
if child is None:
|
if child is None and isinstance(leaf, str):
|
||||||
|
for matcher in node.path_matchers:
|
||||||
|
match = matcher.match(paths[i:])
|
||||||
|
if match is not None:
|
||||||
|
if isinstance(match, Mapping):
|
||||||
|
matches.kwargs.update(match)
|
||||||
|
elif isinstance(match, Sequence):
|
||||||
|
matches.path = match
|
||||||
|
result = matcher
|
||||||
|
break
|
||||||
|
else:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
result = child
|
result = child
|
||||||
return None if result == self.root else result
|
child = result.children.get(method)
|
||||||
|
if child is not None:
|
||||||
|
result = child
|
||||||
|
matches.unmatched_paths = paths[i:]
|
||||||
|
return None if result == self.root else (result, matches)
|
||||||
|
|
||||||
def add(self, path: Generator[str, None, None], method: Optional[HttpMethod], *path_handlers: PathHandler) -> Node:
|
def add(self, path: Generator[str, None, None], method: Optional[HttpMethod], *path_handlers: PathHandler) -> Node | PathMatcher:
|
||||||
lineage: Generator[NodeType, None, None] = (it for it in
|
lineage: Generator[NodeType, None, None] = (it for it in
|
||||||
chain(path,
|
chain(path,
|
||||||
Maybe.of_nullable(method)
|
Maybe.of_nullable(method)
|
||||||
.map(lambda it: [it])
|
.map(lambda it: [it])
|
||||||
.or_else([])))
|
.or_else([])))
|
||||||
result = self.root
|
result: Node | PathMatcher = self.root
|
||||||
it = iter(lineage)
|
it = iter(lineage)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -73,49 +83,92 @@ class Tree:
|
|||||||
result = child
|
result = child
|
||||||
key = leaf
|
key = leaf
|
||||||
while key is not None:
|
while key is not None:
|
||||||
new_node = Node(key=key, parent=result, children={}, handlers=[])
|
new_node = self.parse(key, result)
|
||||||
|
if isinstance(new_node, Node):
|
||||||
result.children[key] = new_node
|
result.children[key] = new_node
|
||||||
|
else:
|
||||||
|
result.path_matchers.append(new_node)
|
||||||
result = new_node
|
result = new_node
|
||||||
key = next(it, None)
|
key = next(it, None)
|
||||||
|
|
||||||
result.handlers = tuple(chain(result.handlers, path_handlers))
|
result.handlers = list(chain(result.handlers, path_handlers))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def register(self,
|
def register(self,
|
||||||
path: str,
|
path: str,
|
||||||
method: Optional[HttpMethod],
|
method: Optional[HttpMethod],
|
||||||
callback: Callable[[HttpContext], Awaitable[None]]) -> None:
|
callback: Callable[[HttpContext, Unpack[Any]], Awaitable[None]],
|
||||||
|
recursive: bool) -> None:
|
||||||
class Handler(PathHandler):
|
class Handler(PathHandler):
|
||||||
|
|
||||||
def match(self, subpath: Sequence[str], method: HttpMethod) -> bool:
|
async def handle_request(self, ctx: HttpContext, captured: Matches) -> None:
|
||||||
return len(subpath) == 0
|
args = Maybe.of_nullable(captured.path).map(lambda it: [it]).or_else([])
|
||||||
|
await callback(ctx, *args, **captured.kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recursive(self) -> bool:
|
||||||
|
return recursive
|
||||||
|
|
||||||
async def handle_request(self, ctx: HttpContext) -> None:
|
|
||||||
await callback(ctx)
|
|
||||||
handler = Handler()
|
handler = Handler()
|
||||||
self.add((p for p in PathIterator(path)), method, handler)
|
self.add((p for p in PathIterator(path)), method, handler)
|
||||||
|
|
||||||
def find_node(self, path: Generator[str, None, None], method: HttpMethod = HttpMethod.GET) -> Optional[Node]:
|
def find_node(self, path: Generator[str, None, None], method: HttpMethod = HttpMethod.GET) \
|
||||||
|
-> Optional[Tuple[Node | PathMatcher, Matches]]:
|
||||||
return (Maybe.of_nullable(self.search(path, method))
|
return (Maybe.of_nullable(self.search(path, method))
|
||||||
.filter(lambda it: len(it.handlers) > 0)
|
.filter(lambda it: len(it[0].handlers) > 0)
|
||||||
.or_none())
|
.or_none())
|
||||||
|
|
||||||
def get_handler(self, url: str, method: HttpMethod = HttpMethod.GET) -> Optional[PathHandler]:
|
def get_handler(self, url: str, method: HttpMethod = HttpMethod.GET) \
|
||||||
|
-> Optional[Tuple[PathHandler, Matches]]:
|
||||||
path = urlparse(url).path
|
path = urlparse(url).path
|
||||||
node = self.find_node((p for p in PathIterator(path)), method)
|
result: Optional[Tuple[Node | PathMatcher, Matches]] = self.find_node((p for p in PathIterator(path)), method)
|
||||||
if node is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
requested = (p for p in PathIterator(path))
|
node, captured = result
|
||||||
found = reversed([n.key for n in NodeAncestryIterator(node) if n.key != '/'])
|
# requested = (p for p in PathIterator(path))
|
||||||
unmatched: List[str] = []
|
# found = reversed([n for n in NodeAncestryIterator(node) if n != self.root])
|
||||||
for r, f in zip(requested, found):
|
# unmatched: List[str] = []
|
||||||
if f is None:
|
# for r, f in zip(requested, found):
|
||||||
unmatched.append(r)
|
# if f is None:
|
||||||
|
# unmatched.append(r)
|
||||||
for handler in node.handlers:
|
for handler in node.handlers:
|
||||||
if handler.match(unmatched, method):
|
if len(captured.unmatched_paths) == 0:
|
||||||
return handler
|
return handler, captured
|
||||||
|
elif handler.recursive:
|
||||||
|
return handler, captured
|
||||||
|
# if handler.match(unmatched, method):
|
||||||
|
# return (handler, unmatched)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def parse(self, leaf: str, parent: Optional[Node | PathMatcher]) -> Node | PathMatcher:
|
||||||
|
start = 0
|
||||||
|
result = index_of_with_escape(leaf, '${', '\\', 0)
|
||||||
|
if result >= 0:
|
||||||
|
start = result + 2
|
||||||
|
end = leaf.index('}', start + 2)
|
||||||
|
definition = leaf[start:end]
|
||||||
|
try:
|
||||||
|
colon = definition.index(':')
|
||||||
|
except ValueError:
|
||||||
|
colon = None
|
||||||
|
if colon is None:
|
||||||
|
key = definition
|
||||||
|
kind = 'str'
|
||||||
|
else:
|
||||||
|
key = definition[:colon]
|
||||||
|
kind = definition[colon+1:] if colon is not None else 'str'
|
||||||
|
if kind == 'str':
|
||||||
|
return StrMatcher(name=key, parent=parent, children={}, handlers=[], path_matchers=[])
|
||||||
|
elif kind == 'int':
|
||||||
|
return IntMatcher(name=key, parent=parent, children={}, handlers=[], path_matchers=[])
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown kind: '{kind}'")
|
||||||
|
result = index_of_with_escape(leaf, '*', '\\', 0)
|
||||||
|
if result >= 0:
|
||||||
|
return GlobMatcher(pattern=leaf, parent=parent, children={}, handlers=[], path_matchers=[])
|
||||||
|
else:
|
||||||
|
return Node(key=leaf, parent=parent, children={}, handlers=[], path_matchers=[])
|
||||||
|
|
||||||
|
|
||||||
class PathIterator:
|
class PathIterator:
|
||||||
path: str
|
path: str
|
||||||
@@ -154,7 +207,7 @@ class PathIterator:
|
|||||||
|
|
||||||
|
|
||||||
class NodeAncestryIterator:
|
class NodeAncestryIterator:
|
||||||
node: Node
|
node: Node | PathMatcher
|
||||||
|
|
||||||
def __init__(self, node: Node):
|
def __init__(self, node: Node):
|
||||||
self.node = node
|
self.node = node
|
||||||
@@ -162,7 +215,7 @@ class NodeAncestryIterator:
|
|||||||
def __iter__(self) -> Self:
|
def __iter__(self) -> Self:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __next__(self) -> Node:
|
def __next__(self) -> Node | PathMatcher:
|
||||||
parent = self.node.parent
|
parent = self.node.parent
|
||||||
if parent is None:
|
if parent is None:
|
||||||
raise StopIteration()
|
raise StopIteration()
|
||||||
|
@@ -1,70 +0,0 @@
|
|||||||
from typing import (
|
|
||||||
Sequence,
|
|
||||||
TypedDict,
|
|
||||||
Literal,
|
|
||||||
Iterable,
|
|
||||||
Tuple,
|
|
||||||
Optional,
|
|
||||||
NotRequired,
|
|
||||||
Dict,
|
|
||||||
Any,
|
|
||||||
Union
|
|
||||||
)
|
|
||||||
|
|
||||||
type StrOrStrings = (str | Sequence[str])
|
|
||||||
|
|
||||||
class ASGIVersions(TypedDict):
|
|
||||||
spec_version: str
|
|
||||||
version: Union[Literal["2.0"], Literal["3.0"]]
|
|
||||||
|
|
||||||
|
|
||||||
class HTTPScope(TypedDict):
|
|
||||||
type: Literal["http"]
|
|
||||||
asgi: ASGIVersions
|
|
||||||
http_version: str
|
|
||||||
method: str
|
|
||||||
scheme: str
|
|
||||||
path: str
|
|
||||||
raw_path: bytes
|
|
||||||
query_string: bytes
|
|
||||||
root_path: str
|
|
||||||
headers: Iterable[Tuple[bytes, bytes]]
|
|
||||||
client: Optional[Tuple[str, int]]
|
|
||||||
server: Optional[Tuple[str, Optional[int]]]
|
|
||||||
state: NotRequired[Dict[str, Any]]
|
|
||||||
extensions: Optional[Dict[str, Dict[object, object]]]
|
|
||||||
class WebSocketScope(TypedDict):
|
|
||||||
type: Literal["websocket"]
|
|
||||||
asgi: ASGIVersions
|
|
||||||
http_version: str
|
|
||||||
scheme: str
|
|
||||||
path: str
|
|
||||||
raw_path: bytes
|
|
||||||
query_string: bytes
|
|
||||||
root_path: str
|
|
||||||
headers: Iterable[Tuple[bytes, bytes]]
|
|
||||||
client: Optional[Tuple[str, int]]
|
|
||||||
server: Optional[Tuple[str, Optional[int]]]
|
|
||||||
subprotocols: Iterable[str]
|
|
||||||
state: NotRequired[Dict[str, Any]]
|
|
||||||
extensions: Optional[Dict[str, Dict[object, object]]]
|
|
||||||
|
|
||||||
|
|
||||||
class LifespanScope(TypedDict):
|
|
||||||
type: Literal["lifespan"]
|
|
||||||
asgi: ASGIVersions
|
|
||||||
state: NotRequired[Dict[str, Any]]
|
|
||||||
|
|
||||||
class RSGI:
|
|
||||||
class Scope(TypedDict):
|
|
||||||
proto: Literal['http'] = 'http'
|
|
||||||
rsgi_version: str
|
|
||||||
http_version: str
|
|
||||||
server: str
|
|
||||||
client: str
|
|
||||||
scheme: str
|
|
||||||
method: str
|
|
||||||
path: str
|
|
||||||
query_string: str
|
|
||||||
headers: Mapping[str, str]
|
|
||||||
authority: Optional[str]
|
|
@@ -1,3 +1,91 @@
|
|||||||
from typing import Sequence
|
from typing import (
|
||||||
|
TypedDict,
|
||||||
|
Literal,
|
||||||
|
Iterable,
|
||||||
|
Tuple,
|
||||||
|
Optional,
|
||||||
|
NotRequired,
|
||||||
|
Dict,
|
||||||
|
Any,
|
||||||
|
Union,
|
||||||
|
Mapping,
|
||||||
|
Sequence
|
||||||
|
)
|
||||||
|
|
||||||
type StrOrStrings = (str | Sequence[str])
|
from .base import StrOrStrings, PathMatcherResult
|
||||||
|
|
||||||
|
from bugis.core._http_method import HttpMethod
|
||||||
|
|
||||||
|
type NodeType = (str | HttpMethod)
|
||||||
|
|
||||||
|
|
||||||
|
class ASGIVersions(TypedDict):
|
||||||
|
spec_version: str
|
||||||
|
version: Union[Literal["2.0"], Literal["3.0"]]
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPScope(TypedDict):
|
||||||
|
type: Literal["http"]
|
||||||
|
asgi: ASGIVersions
|
||||||
|
http_version: str
|
||||||
|
method: str
|
||||||
|
scheme: str
|
||||||
|
path: str
|
||||||
|
raw_path: bytes
|
||||||
|
query_string: bytes
|
||||||
|
root_path: str
|
||||||
|
headers: Iterable[Tuple[bytes, bytes]]
|
||||||
|
client: Optional[Tuple[str, int]]
|
||||||
|
server: Optional[Tuple[str, Optional[int]]]
|
||||||
|
state: NotRequired[Dict[str, Any]]
|
||||||
|
extensions: Optional[Dict[str, Dict[object, object]]]
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketScope(TypedDict):
|
||||||
|
type: Literal["websocket"]
|
||||||
|
asgi: ASGIVersions
|
||||||
|
http_version: str
|
||||||
|
scheme: str
|
||||||
|
path: str
|
||||||
|
raw_path: bytes
|
||||||
|
query_string: bytes
|
||||||
|
root_path: str
|
||||||
|
headers: Iterable[Tuple[bytes, bytes]]
|
||||||
|
client: Optional[Tuple[str, int]]
|
||||||
|
server: Optional[Tuple[str, Optional[int]]]
|
||||||
|
subprotocols: Iterable[str]
|
||||||
|
state: NotRequired[Dict[str, Any]]
|
||||||
|
extensions: Optional[Dict[str, Dict[object, object]]]
|
||||||
|
|
||||||
|
|
||||||
|
class LifespanScope(TypedDict):
|
||||||
|
type: Literal["lifespan"]
|
||||||
|
asgi: ASGIVersions
|
||||||
|
state: NotRequired[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class RSGI:
|
||||||
|
class Scope(TypedDict):
|
||||||
|
proto: Literal['http'] # = 'http'
|
||||||
|
rsgi_version: str
|
||||||
|
http_version: str
|
||||||
|
server: str
|
||||||
|
client: str
|
||||||
|
scheme: str
|
||||||
|
method: str
|
||||||
|
path: str
|
||||||
|
query_string: str
|
||||||
|
headers: Mapping[str, str]
|
||||||
|
authority: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'HttpMethod',
|
||||||
|
'HTTPScope',
|
||||||
|
'LifespanScope',
|
||||||
|
'RSGI',
|
||||||
|
'ASGIVersions',
|
||||||
|
'WebSocketScope',
|
||||||
|
'NodeType',
|
||||||
|
'StrOrStrings'
|
||||||
|
]
|
||||||
|
4
core/src/bugis/core/_types/base.py
Normal file
4
core/src/bugis/core/_types/base.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from typing import Sequence, Mapping, Any
|
||||||
|
|
||||||
|
type StrOrStrings = (str | Sequence[str])
|
||||||
|
type PathMatcherResult = Mapping[str, Any] | Sequence[str]
|
@@ -1,7 +1,9 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
from pwo import async_test
|
from pwo import async_test
|
||||||
from bugis.core import BugisApp, HttpContext
|
from bugis.core import BugisApp, HttpContext, HttpMethod
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
|
||||||
class AsgiTest(unittest.TestCase):
|
class AsgiTest(unittest.TestCase):
|
||||||
@@ -18,6 +20,39 @@ class AsgiTest(unittest.TestCase):
|
|||||||
print(chunk)
|
print(chunk)
|
||||||
await ctx.send_str(200, 'Hello World!')
|
await ctx.send_str(200, 'Hello World!')
|
||||||
|
|
||||||
|
@self.app.route(('/foo/bar',), HttpMethod.PUT, recursive=True)
|
||||||
|
async def handle_request(ctx: HttpContext) -> None:
|
||||||
|
async for chunk in ctx.request_body:
|
||||||
|
print(chunk)
|
||||||
|
await ctx.send_str(200, ctx.path)
|
||||||
|
|
||||||
|
@self.app.route(('/foo/*',), HttpMethod.PUT, recursive=True)
|
||||||
|
async def handle_request(ctx: HttpContext, path: Sequence[str]) -> None:
|
||||||
|
async for chunk in ctx.request_body:
|
||||||
|
print(chunk)
|
||||||
|
await ctx.send_str(200, json.dumps(path))
|
||||||
|
|
||||||
|
@self.app.GET('/employee/${employee_id}')
|
||||||
|
async def handle_request(ctx: HttpContext, employee_id: str) -> None:
|
||||||
|
async for chunk in ctx.request_body:
|
||||||
|
print(chunk)
|
||||||
|
await ctx.send_str(200, employee_id)
|
||||||
|
|
||||||
|
@self.app.GET('/square/${x:int}')
|
||||||
|
async def handle_request(ctx: HttpContext, x: int) -> None:
|
||||||
|
async for chunk in ctx.request_body:
|
||||||
|
print(chunk)
|
||||||
|
await ctx.send_str(200, str(x * x))
|
||||||
|
|
||||||
|
@self.app.GET('/department/${department_id:int}/employee/${employee_id:int}')
|
||||||
|
async def handle_request(ctx: HttpContext, department_id: int, employee_id: int) -> None:
|
||||||
|
async for chunk in ctx.request_body:
|
||||||
|
print(chunk)
|
||||||
|
await ctx.send_str(200, json.dumps({
|
||||||
|
'department_id': department_id,
|
||||||
|
'employee_id': employee_id
|
||||||
|
}))
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_hello(self):
|
async def test_hello(self):
|
||||||
transport = httpx.ASGITransport(app=self.app)
|
transport = httpx.ASGITransport(app=self.app)
|
||||||
@@ -38,3 +73,55 @@ class AsgiTest(unittest.TestCase):
|
|||||||
r = await client.get("/hello4")
|
r = await client.get("/hello4")
|
||||||
self.assertEqual(r.status_code, 404)
|
self.assertEqual(r.status_code, 404)
|
||||||
self.assertTrue(len(r.text) == 0)
|
self.assertTrue(len(r.text) == 0)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_foo(self):
|
||||||
|
transport = httpx.ASGITransport(app=self.app)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
|
||||||
|
r = await client.put("/foo/fizz/baz")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
response = json.loads(r.text)
|
||||||
|
self.assertEqual(['fizz', 'baz'], response)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_foo_bar(self):
|
||||||
|
transport = httpx.ASGITransport(app=self.app)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
|
||||||
|
r = await client.put("/foo/bar/baz")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual('/foo/bar/baz', r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_employee(self):
|
||||||
|
transport = httpx.ASGITransport(app=self.app)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
|
||||||
|
r = await client.get("/employee/101325")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.text, '101325')
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_square(self):
|
||||||
|
transport = httpx.ASGITransport(app=self.app)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
|
||||||
|
x = 30
|
||||||
|
r = await client.get(f"/square/{x}")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.text, str(x * x))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_department_employee(self):
|
||||||
|
transport = httpx.ASGITransport(app=self.app)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client:
|
||||||
|
r = await client.get("department/189350/employee/101325")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
response = json.loads(r.text)
|
||||||
|
self.assertEqual({
|
||||||
|
'department_id': 189350,
|
||||||
|
'employee_id': 101325
|
||||||
|
}, response)
|
||||||
|
|
||||||
|
38
core/tests/test_ciao.py
Normal file
38
core/tests/test_ciao.py
Normal file
@@ -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())
|
@@ -32,13 +32,14 @@ class TreeTest(unittest.TestCase):
|
|||||||
|
|
||||||
class TestHandler(PathHandler):
|
class TestHandler(PathHandler):
|
||||||
|
|
||||||
def match(self, subpath: Sequence[str], method: HttpMethod) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def handle_request(self, ctx: HttpContext):
|
def handle_request(self, ctx: HttpContext):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.handlers = [TestHandler() for _ in range(10)]
|
@property
|
||||||
|
def recursive(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.handlers = [TestHandler() for _ in range(20)]
|
||||||
|
|
||||||
routes: Tuple[Tuple[Tuple[str, ...], Optional[HttpMethod], PathHandler], ...] = (
|
routes: Tuple[Tuple[Tuple[str, ...], Optional[HttpMethod], PathHandler], ...] = (
|
||||||
(('home', 'something'), HttpMethod.GET, self.handlers[0]),
|
(('home', 'something'), HttpMethod.GET, self.handlers[0]),
|
||||||
@@ -49,6 +50,10 @@ class TreeTest(unittest.TestCase):
|
|||||||
(('home',), HttpMethod.GET, self.handlers[5]),
|
(('home',), HttpMethod.GET, self.handlers[5]),
|
||||||
(('home',), HttpMethod.POST, self.handlers[6]),
|
(('home',), HttpMethod.POST, self.handlers[6]),
|
||||||
(('home',), None, self.handlers[7]),
|
(('home',), None, self.handlers[7]),
|
||||||
|
(('home', '*.md'), None, self.handlers[8]),
|
||||||
|
(('home', 'something', '*', 'blah', '*.md'), None, self.handlers[9]),
|
||||||
|
(('home', 'bar', '*'), None, self.handlers[10]),
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for path, method, handler in routes:
|
for path, method, handler in routes:
|
||||||
@@ -66,9 +71,13 @@ class TreeTest(unittest.TestCase):
|
|||||||
('http://localhost:127.0.0.1:5432/home', HttpMethod.GET, 5),
|
('http://localhost:127.0.0.1:5432/home', HttpMethod.GET, 5),
|
||||||
('http://localhost:127.0.0.1:5432/home', HttpMethod.POST, 6),
|
('http://localhost:127.0.0.1:5432/home', HttpMethod.POST, 6),
|
||||||
('http://localhost:127.0.0.1:5432/home', HttpMethod.PUT, 7),
|
('http://localhost:127.0.0.1:5432/home', HttpMethod.PUT, 7),
|
||||||
|
('http://localhost:127.0.0.1:5432/home/README.md', HttpMethod.GET, 8),
|
||||||
|
('http://localhost:127.0.0.1:5432/home/something/ciao/blah/README.md', HttpMethod.GET, 9),
|
||||||
|
('http://localhost:127.0.0.1:5432/home/bar/ciao/blah/README.md', HttpMethod.GET, 10),
|
||||||
)
|
)
|
||||||
for url, method, handler_num in cases:
|
for url, method, handler_num in cases:
|
||||||
with self.subTest(f"{str(method)} {url}"):
|
with self.subTest(f"{str(method)} {url}"):
|
||||||
res = self.tree.get_handler(url, method)
|
res = self.tree.get_handler(url, method)
|
||||||
self.assertIs(Maybe.of(handler_num).map(self.handlers.__getitem__).or_none(), res)
|
self.assertIs(Maybe.of(handler_num).map(self.handlers.__getitem__).or_none(),
|
||||||
|
Maybe.of_nullable(res).map(lambda it: it[0]).or_none())
|
||||||
|
|
||||||
|
12
server/example/test_server.py
Normal file
12
server/example/test_server.py
Normal file
@@ -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)
|
||||||
|
|
||||||
|
|
67
server/pyproject.toml
Normal file
67
server/pyproject.toml
Normal file
@@ -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"
|
0
server/src/bugis/server/__init__.py
Normal file
0
server/src/bugis/server/__init__.py
Normal file
44
server/src/bugis/server/cache.py
Normal file
44
server/src/bugis/server/cache.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional, Tuple, Callable
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import PurePath
|
||||||
|
from .renderer import RenderingManager
|
||||||
|
|
||||||
|
|
||||||
|
class Cache(ABC):
|
||||||
|
_rendering_manager: RenderingManager
|
||||||
|
|
||||||
|
def __init__(self, rendering_manager: RenderingManager):
|
||||||
|
self._rendering_manager = rendering_manager
|
||||||
|
|
||||||
|
def get(self, key: bytes, file: PurePath) -> Optional[Tuple[str, bytes]]:
|
||||||
|
if self.filter(file):
|
||||||
|
return self.load(key, file)
|
||||||
|
else:
|
||||||
|
return self._rendering_manager.render(file)
|
||||||
|
|
||||||
|
def filter(self, file: PurePath) -> bool:
|
||||||
|
return file.suffix.lower() in {'md', 'puml', 'dot', 'texi', 'texinfo', 'txi'}
|
||||||
|
|
||||||
|
def load(self, key: bytes, file: PurePath) -> Optional[Tuple[str, bytes]]:
|
||||||
|
return self._rendering_manager.render(file)
|
||||||
|
|
||||||
|
|
||||||
|
class NoCache(Cache):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryCache(Cache):
|
||||||
|
_cached_loader: Callable[[bytes, PurePath], Tuple[str, bytes]]
|
||||||
|
|
||||||
|
def __init__(self, rendering_manager: RenderingManager, max_size: int = 1024):
|
||||||
|
super().__init__(rendering_manager)
|
||||||
|
|
||||||
|
@lru_cache(maxsize=max_size)
|
||||||
|
def cached_loader(key: bytes, file: PurePath) -> Tuple[str, bytes]:
|
||||||
|
return super().load(key, file)
|
||||||
|
|
||||||
|
self._cached_loader = cached_loader
|
||||||
|
|
||||||
|
def load(self, key: bytes, file: PurePath) -> Optional[Tuple[str, bytes]]:
|
||||||
|
return self._cached_loader(key, file)
|
3
server/src/bugis/server/cli.py
Normal file
3
server/src/bugis/server/cli.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
def main():
|
||||||
|
pass
|
16
server/src/bugis/server/renderer.py
Normal file
16
server/src/bugis/server/renderer.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from pathlib import PurePath
|
||||||
|
from typing import Callable, Mapping, Optional, Tuple
|
||||||
|
from pwo import Maybe
|
||||||
|
|
||||||
|
|
||||||
|
class RenderingManager:
|
||||||
|
_renderers: Mapping[str, Callable[[PurePath], Tuple[str, bytes]]]
|
||||||
|
|
||||||
|
def __init__(self, renderers: Mapping[str, Callable[[PurePath], Tuple[str, bytes]]]):
|
||||||
|
self._renderers = renderers
|
||||||
|
|
||||||
|
def render(self, file: PurePath) -> Optional[Tuple[str, bytes]]:
|
||||||
|
return Maybe.of_nullable(self._renderers.get(file.suffix.lower())).map(lambda it: it(file)).or_none()
|
||||||
|
|
||||||
|
# def register(self, suffix: str, renderer: Callable[[PurePath], Tuple[str, bytes]]) -> None:
|
||||||
|
# self._renderers[suffix.lower()] = renderer
|
145
server/src/bugis/server/server.py
Normal file
145
server/src/bugis/server/server.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
from base64 import b64encode, b64decode
|
||||||
|
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, Mapping, Tuple
|
||||||
|
|
||||||
|
from aiofiles.os import listdir
|
||||||
|
from aiofiles.ospath import isdir, isfile
|
||||||
|
from bugis.core import HttpContext, BugisApp
|
||||||
|
from pwo import Maybe
|
||||||
|
from .cache import Cache, InMemoryCache
|
||||||
|
from .renderer import RenderingManager
|
||||||
|
|
||||||
|
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,
|
||||||
|
renderers: Mapping[str, Tuple[str, bytes]] = None,
|
||||||
|
cache_ctor: Callable[[RenderingManager], Cache] = lambda rm: InMemoryCache(rm)
|
||||||
|
):
|
||||||
|
renderer = RenderingManager(renderers or {})
|
||||||
|
cache = cache_ctor(renderer)
|
||||||
|
|
||||||
|
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):
|
||||||
|
|
||||||
|
proposed_etag = ((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 proposed_etag == current_etag:
|
||||||
|
return await context.send_empty(304)
|
||||||
|
else:
|
||||||
|
cache_result = cache.get(b64decode(current_etag), resource)
|
||||||
|
if cache_result is None:
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
content_type, body = cache_result
|
||||||
|
await context.send_bytes(200, body, {
|
||||||
|
'content-type': content_type,
|
||||||
|
'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 = "<!DOCTYPE html><html><head>"
|
||||||
|
if favicon:
|
||||||
|
result += f'<link rel="icon" type="image/x-icon" href="{favicon}">'
|
||||||
|
result += "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">"
|
||||||
|
result += "<title>" + title + "</title></head>"
|
||||||
|
result += "<body><h1>" + title + "</h1><hr>"
|
||||||
|
result += "<ul>"
|
||||||
|
if path_info != '/':
|
||||||
|
result += "<li><a href=\"../\"/>../</li>"
|
||||||
|
|
||||||
|
async def ls(entry_filter: Callable[[PurePath], Awaitable[bool]]) -> AsyncGenerator[str, Any]:
|
||||||
|
async def result():
|
||||||
|
for entry in sorted(await listdir(path)):
|
||||||
|
if await entry_filter(path / entry):
|
||||||
|
yield entry
|
||||||
|
|
||||||
|
return result()
|
||||||
|
|
||||||
|
async for entry in await ls(isdir):
|
||||||
|
result += '<li><a href="' + entry + '/' + '"/>' + entry + '/' + '</li>'
|
||||||
|
|
||||||
|
async def composite_file_filter(entry: PurePath) -> bool:
|
||||||
|
return await isfile(entry) and await file_filter(entry)
|
||||||
|
|
||||||
|
async for entry in await ls(composite_file_filter):
|
||||||
|
result += '<li><a href="' + entry + '"/>' + entry + '</li>'
|
||||||
|
return result
|
Reference in New Issue
Block a user