Skip to content

Commit

Permalink
Merge branch 'main' of github.com:NZX/extra
Browse files Browse the repository at this point in the history
  • Loading branch information
sebastien committed Sep 12, 2024
2 parents 32bf3d9 + 859b8b6 commit 34821d1
Show file tree
Hide file tree
Showing 9 changed files with 156 additions and 101 deletions.
33 changes: 18 additions & 15 deletions src/py/extra/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import NamedTuple, ClassVar, AsyncGenerator
from typing import NamedTuple, ClassVar, AsyncGenerator, Self, Any, Iterator
from urllib.parse import quote_plus, urlparse
from contextvars import ContextVar
from contextlib import contextmanager
Expand All @@ -13,9 +13,10 @@
HTTPRequestBody,
HTTPBodyBlob,
HTTPAtom,
HTTPProcessingStatus,
headername,
)
from .http.parser import HTTPParser, HTTPProcessingStatus
from .http.parser import HTTPParser


# --
Expand Down Expand Up @@ -105,7 +106,7 @@ class Connection:
# A streaming connection won't be reused
isStreaming: bool = False

def close(self):
def close(self) -> Self:
"""Closes the writer."""
self.writer.close()
self.until = None
Expand All @@ -116,7 +117,7 @@ def isValid(self) -> bool | None:
"""Tells if the connection is still valid."""
return (time.monotonic() <= self.until) if self.until else None

def touch(self):
def touch(self) -> Self:
"""Touches the connection, bumping its `until` time."""
self.until = time.monotonic() + self.idle
return self
Expand Down Expand Up @@ -166,7 +167,9 @@ async def Make(
class ConnectionPool:
"""Context-aware pool of connections."""

All: ClassVar[ContextVar] = ContextVar("httpConnectionsPool")
All: ClassVar[ContextVar[list["ConnectionPool"]]] = ContextVar(
"httpConnectionsPool"
)

@classmethod
def Get(cls, *, idle: float | None = None) -> "ConnectionPool":
Expand Down Expand Up @@ -232,10 +235,9 @@ def Push(cls, *, idle: float | None = None) -> "ConnectionPool":
return pool

@classmethod
def Pop(cls):
def Pop(cls) -> "ConnectionPool|None":
pools = cls.All.get(None)
if pools:
pools.pop().release()
return pools.pop().release() if pools else None

def __init__(self, idle: float | None = None):
self.connections: dict[ConnectionTarget, list[Connection]] = {}
Expand Down Expand Up @@ -263,7 +265,7 @@ def put(self, connection: Connection) -> None:
as long as it is valid."""
self.connections.setdefault(connection.target, []).append(connection)

def clean(self):
def clean(self) -> Self:
"""Cleans idle connections by closing them and removing them
from available connections."""
to_remove = []
Expand All @@ -279,14 +281,15 @@ def clean(self):
del self.connections[k]
return self

def release(self):
def release(self) -> Self:
"""Releases all the connections registered"""
for l in self.connections.values():
while l:
l.pop().close()
self.connections.clear()
return self

def pop(self):
def pop(self) -> Self:
"""Pops this pool from the connection pool context and release
all its connections."""
pools = ConnectionPool.All.get(None)
Expand All @@ -295,10 +298,10 @@ def pop(self):
self.release()
return self

def __enter__(self):
def __enter__(self) -> Self:
return self

def __exit__(self, type, value, traceback):
def __exit__(self, type: Any, value: Any, traceback: Any) -> None:
"""The connection pool automatically cleans when used
as a context manager."""
self.clean()
Expand Down Expand Up @@ -518,7 +521,7 @@ async def Request(


@contextmanager
def pooling(idle: float | None = None):
def pooling(idle: float | None = None) -> Iterator[ConnectionPool]:
"""Creates a context in which connections will be pooled."""
pool = ConnectionPool().Push(idle=idle)
try:
Expand All @@ -529,7 +532,7 @@ def pooling(idle: float | None = None):

if __name__ == "__main__":

async def main():
async def main() -> None:
async for atom in HTTPClient.Request(
host="google.com",
method="GET",
Expand Down
28 changes: 16 additions & 12 deletions src/py/extra/decorators.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from typing import ClassVar, Union, Callable, NamedTuple, TypeVar, Any
from typing import ClassVar, Union, Callable, NamedTuple, TypeVar, Any, cast

T = TypeVar("T")


class Transform(NamedTuple):
"""Represents a transformation to be applied to a request handler"""

transform: Callable
transform: Callable[..., Any]
args: tuple[Any, ...]
kwargs: dict[str, Any]

Expand All @@ -29,12 +29,12 @@ class Extra:
Annotations: ClassVar[dict[int, dict[str, Any]]] = {}

@staticmethod
def Meta(scope: Any, *, strict: bool = False) -> dict:
def Meta(scope: Any, *, strict: bool = False) -> dict[str, Any]:
"""Returns the dictionary of meta attributes for the given value."""
if isinstance(scope, type):
if not hasattr(scope, "__extra__"):
setattr(scope, "__extra__", {})
return getattr(scope, "__extra__")
return cast(dict[str, Any], getattr(scope, "__extra__"))
else:
if hasattr(scope, "__dict__"):
return scope.__dict__
Expand All @@ -48,7 +48,7 @@ def Meta(scope: Any, *, strict: bool = False) -> dict:


def on(
priority=0, **methods: Union[str, list[str], tuple[str, ...]]
priority: int = 0, **methods: Union[str, list[str], tuple[str, ...]]
) -> Callable[[T], T]:
"""The @on decorator is one of the main important things you will use within
Retro. This decorator allows to wrap an existing method and indicate that
Expand Down Expand Up @@ -104,7 +104,11 @@ class Expose(NamedTuple):


def expose(
priority=0, compress=False, contentType=None, raw=False, **methods
priority: int = 0,
compress: bool = False,
contentType: str | None = None,
raw: bool = False,
**methods: str,
) -> Callable[[T], T]:
"""The @expose decorator is a variation of the @on decorator. The @expose
decorator allows you to _expose_ an existing Python function as a JavaScript
Expand Down Expand Up @@ -145,36 +149,36 @@ def decorator(function: T) -> T:
return decorator


def when(*predicates):
def when(*predicates: Callable[..., bool]) -> Callable[..., T]:
"""The @when(...) decorate allows to specify that the wrapped method will
only be executed when the given predicate (decorated with `@on`)
succeeds."""

def decorator(function):
def decorator(function: T, *args: Any, **kwargs: Any) -> T:
v = Extra.Meta(function).setdefault(Extra.WHEN, [])
v.extend(predicates)
return function

return decorator


def pre(transform: Callable) -> Callable[[T], T]:
def pre(transform: Callable[..., bool]) -> Callable[..., T]:
"""Registers the given `transform` as a pre-processing step of the
decorated function."""

def decorator(function: T, *args, **kwargs) -> T:
def decorator(function: T, *args: Any, **kwargs: Any) -> T:
v = Extra.Meta(function).setdefault(Extra.PRE, [])
v.append(Transform(transform, args, kwargs))
return function

return decorator


def post(transform) -> Callable[[T], T]:
def post(transform: Callable[..., bool]) -> Callable[[T], T]:
"""Registers the given `transform` as a post-processing step of the
decorated function."""

def decorator(function: T, *args, **kwargs) -> T:
def decorator(function: T, *args: Any, **kwargs: Any) -> T:
v = Extra.Meta(function).setdefault(Extra.POST, [])
v.append(Transform(transform, args, kwargs))
return function
Expand Down
6 changes: 3 additions & 3 deletions src/py/extra/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def Create(
return payload

@staticmethod
def AsRequest(event: dict) -> HTTPRequest:
def AsRequest(event: dict[str, Any]) -> HTTPRequest:
body: bytes = (
(
b64decode(event["body"].encode())
Expand Down Expand Up @@ -237,13 +237,13 @@ def event(
uri: str,
headers: dict[str, str] | None = None,
body: str | bytes | None = None,
) -> dict:
) -> dict[str, Any]:
return AWSLambdaEvent.Create(method=method, uri=uri, headers=headers, body=body)


def awslambda(
handler: Callable[[HTTPRequest], HTTPResponse | Coroutine[Any, HTTPResponse, Any]]
):
) -> Callable[[dict[str, Any], dict[str, Any] | None], dict[str, Any]]:
def wrapper(
event: dict[str, Any], context: dict[str, Any] | None = None
) -> dict[str, Any]:
Expand Down
39 changes: 23 additions & 16 deletions src/py/extra/http/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pathlib import Path

from base64 import b64encode

from .status import HTTP_STATUS
from ..utils.json import json
from ..utils.files import contentType
Expand Down Expand Up @@ -41,7 +42,7 @@ def error(
content: str | None = None,
contentType: str = "text/plain",
headers: dict[str, str] | None = None,
):
) -> T:
message = HTTP_STATUS.get(status, "Server Error")
return self.respond(
content=message if content is None else content,
Expand All @@ -54,32 +55,36 @@ def error(
def notAuthorized(
self,
content: str = "Unauthorized",
contentType="text/plain",
contentType: str = "text/plain",
*,
status: int = 403,
):
) -> T:
return self.error(status, content=content, contentType=contentType)

def notFound(
self, content: str = "Not Found", contentType="text/plain", *, status: int = 404
):
self,
content: str = "Not Found",
contentType: str = "text/plain",
*,
status: int = 404,
) -> T:
return self.error(status, content=content, contentType=contentType)

def notModified(self):
pass
def notModified(self) -> None:
raise NotImplementedError

def fail(
self,
content: str | None = None,
*,
status: int = 500,
contentType: str = "text/plain",
):
) -> T:
return self.respondError(
content=content, status=status, contentType=contentType
)

def redirect(self, url: str, permanent: bool = False):
def redirect(self, url: str, permanent: bool = False) -> T:
# SEE: https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections
return self.respondEmpty(
status=301 if permanent else 302, headers={"Location": str(url)}
Expand All @@ -92,7 +97,7 @@ def returns(
*,
status: int = 200,
contentType: str = "application/json",
):
) -> T:
if isinstance(value, bytes):
try:
value = value.decode("ascii")
Expand All @@ -110,20 +115,22 @@ def returns(
def respondText(
self,
content: str | bytes | Iterator[str | bytes],
contentType="text/plain",
contentType: str = "text/plain",
status: int = 200,
):
) -> T:
return self.respond(content=content, contentType=contentType, status=status)

def respondHTML(self, html: str | bytes | Iterator[str | bytes], status: int = 200):
def respondHTML(
self, html: str | bytes | Iterator[str | bytes], status: int = 200
) -> T:
return self.respond(content=html, contentType="text/html", status=status)

def respondFile(
self,
path: Path | str,
headers: dict[str, str] | None = None,
status: int = 200,
):
) -> T:
# TODO: We should have a much more detailed file handling, supporting ranges, etags, etc.
p: Path = path if isinstance(path, Path) else Path(path)
content_type: str = contentType(p)
Expand All @@ -141,10 +148,10 @@ def respondError(
contentType: str = "text/plain",
*,
status: int = 500,
):
) -> T:
return self.error(status, content, contentType)

def respondEmpty(self, status, headers: dict[str, str] | None = None):
def respondEmpty(self, status: int, headers: dict[str, str] | None = None) -> T:
return self.respond(content=None, status=status, headers=headers)


Expand Down
6 changes: 3 additions & 3 deletions src/py/extra/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ def process(
else:
return self.onRouteNotFound(request)

def mount(self, service: Service, prefix: str | None = None):
def mount(self, service: Service, prefix: str | None = None) -> Service:
if service.isMounted:
raise RuntimeError(
f"Cannot mount service, it is already mounted: {service}"
Expand All @@ -160,7 +160,7 @@ def mount(self, service: Service, prefix: str | None = None):
self.dispatcher.register(handler, prefix or service.prefix)
return service

def unmount(self, service: Service):
def unmount(self, service: Service) -> Service:
if not service.isMounted:
raise RuntimeError(
f"Cannot unmount service, it is not already mounted: {service}"
Expand All @@ -172,7 +172,7 @@ def unmount(self, service: Service):
service.app = self
return service

def onRouteNotFound(self, request: HTTPRequest):
def onRouteNotFound(self, request: HTTPRequest) -> HTTPResponse:
return request.notFound()


Expand Down
Loading

0 comments on commit 34821d1

Please sign in to comment.