diff --git a/.github/workflows/memory_check.yml b/.github/workflows/memory_check.yml new file mode 100644 index 0000000..262f179 --- /dev/null +++ b/.github/workflows/memory_check.yml @@ -0,0 +1,41 @@ +name: Memory Check + +on: + push: + branches: + - master + pull_request: + branches: + - master + +env: + PYTHONUNBUFFERED: "1" + FORCE_COLOR: "1" + PYTHONIOENCODING: "utf8" + +jobs: + run: + name: Valgrind on Ubuntu + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.12 + uses: actions/setup-python@v2 + with: + python-version: 3.12 + + - name: Install PyTest + run: | + pip install pytest pytest-asyncio + shell: bash + + - name: Build project + run: pip install .[full] + + - name: Install Valgrind + run: sudo apt-get -y install valgrind + + - name: Run tests with Valgrind + run: valgrind --suppressions=valgrind-python.supp --error-exitcode=1 pytest -x diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f041fd..5dac792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for coroutines in `PyAwaitable` (vendored) - Finished websocket implementation - Added the `custom` loader +- Added support for returning `bytes` objects in the body. - **Breaking Change:** Removed the `hijack` configuration setting - **Breaking Change:** Removed the `post_init` parameter from `new_app`, as well as renamed the `store_address` parameter to `store`. +- **Breaking Change:** `load()` now takes routes via variadic arguments, instead of a list of routes. ## [1.0.0-alpha10] - 2024-5-26 diff --git a/docs/building-projects/responses.md b/docs/building-projects/responses.md index 925933a..7c67939 100644 --- a/docs/building-projects/responses.md +++ b/docs/building-projects/responses.md @@ -2,7 +2,7 @@ ## Basic Responses -In any web framework, returning a response can be as simple as returning a string of text or quite complex with all sorts of things like server-side rendering. Right out of the box, View supports returning status codes, headers, and a response without any fancy tooling. A response **must** contain a body (this is a `str`), but may also contain a status (`int`) or headers (`dict[str, str]`). These may be in any order. +In any web framework, returning a response can be as simple as returning a string of text or quite complex with all sorts of things like server-side rendering. Right out of the box, View supports returning status codes, headers, and a response without any fancy tooling. A response **must** contain a body (this is a `str` or `bytes`), but may also contain a status (`int`) or headers (`dict[str, str]`). These may be in any order. ```py from view import new_app @@ -242,32 +242,50 @@ class ListResponse(Response[list]): ## Middleware -### What is middleware? +### The Middleware API -In view.py, middleware is called right before the route is executed, but **not necessarily in the middle.** However, for tradition, View calls it middleware. +`Route.middleware` is used to define a middleware function for a route. Like other web frameworks, middleware functions are given a `call_next`. Note that `call_next` is always asynchronous regardless of whether the route is asynchronous. -The main difference between middleware in view.py and other frameworks is that in view.py, there is no `call_next` function in middleware, and instead just the arguments that would go to the route. +```py +from view import new_app, CallNext -!!! question "Why no `call_next`?" +app = new_app() - view.py doesn't use the `call_next` function because of the nature of it's routing system. +@app.get("/") +def index(): + return "my response!" -### The Middleware API +@index.middleware +async def index_middleware(call_next: CallNext): + print("this is called before index()!") + res = await call_next() + print("this is called after index()!") + return res -`Route.middleware` is used to define a middleware function for a route. +app.run() +``` + +### Response Parsing + +As shown above, `call_next` returns the result of the route. However, dealing with the raw response tuple might be a bit of a hassle. Instead, you can convert the response to a `Response` object using the `to_response` function: ```py -from view import new_app +from view import new_app, CallNext, to_response +from time import perf_counter app = new_app() @app.get("/") -async def index(): - ... +def index(): + return "my response!" @index.middleware -async def index_middleware(): - print("this is called before index()!") +async def took_time_middleware(call_next: CallNext): + a = perf_counter() + res = to_response(await call_next()) + b = perf_counter() + res.headers["X-Time-Elapsed"] = str(b - a) + return res app.run() ``` @@ -276,7 +294,7 @@ app.run() Responses can be returned with a string, integer, and/or dictionary in any order. -- The string represents the body of the response (e.g. the HTML or JSON) +- The string represents the body of the response (e.g. HTML or JSON) - The integer represents the status code (200 by default) - The dictionary represents the headers (e.g. `{"x-www-my-header": "some value"}`) diff --git a/src/_view/results.c b/src/_view/results.c index 912f7c7..f9c13aa 100644 --- a/src/_view/results.c +++ b/src/_view/results.c @@ -18,6 +18,13 @@ static int find_result_for( const char* tmp = PyUnicode_AsUTF8(target); if (!tmp) return -1; *res_str = strdup(tmp); + } else if (Py_IS_TYPE( + target, + &PyBytes_Type + )) { + const char* tmp = PyBytes_AsString(target); + if (!tmp) return -1; + *res_str = strdup(tmp); } else if (Py_IS_TYPE( target, &PyDict_Type @@ -63,8 +70,6 @@ static int find_result_for( return -1; }; - Py_DECREF(item_bytes); - PyObject* v_bytes = PyBytes_FromString(v_str); if (!v_bytes) { @@ -81,8 +86,6 @@ static int find_result_for( return -1; }; - Py_DECREF(v_bytes); - if (PyList_Append( headers, header_list @@ -131,7 +134,7 @@ static int find_result_for( } else { PyErr_SetString( PyExc_TypeError, - "returned tuple should only contain a str, int, or dict" + "returned tuple should only contain a str, bytes, int, or dict" ); return -1; } @@ -168,6 +171,10 @@ static int handle_result_impl( const char* tmp = PyUnicode_AsUTF8(result); if (!tmp) return -1; res_str = strdup(tmp); + } else if (PyBytes_CheckExact(result)) { + const char* tmp = PyBytes_AsString(result); + if (!tmp) return -1; + res_str = strdup(tmp); } else if (PyTuple_CheckExact( result )) { @@ -254,11 +261,15 @@ int handle_result( method ); - if (!PyObject_Call(route_log, args, NULL)) { + if (!PyObject_Call( + route_log, + args, + NULL + )) { Py_DECREF(args); return -1; } Py_DECREF(args); return res; -} \ No newline at end of file +} diff --git a/src/_view/routing.c b/src/_view/routing.c index 2720e0b..c99265f 100644 --- a/src/_view/routing.c +++ b/src/_view/routing.c @@ -443,7 +443,6 @@ int handle_route_callback( if (!dct) return -1; - coro = PyObject_Vectorcall( send, (PyObject*[]) { dct }, diff --git a/src/view/__init__.py b/src/view/__init__.py index 24e0037..d6a8d0b 100644 --- a/src/view/__init__.py +++ b/src/view/__init__.py @@ -8,9 +8,7 @@ try: import _view except ModuleNotFoundError as e: - raise ImportError( - "_view has not been built, did you forget to compile it?" - ) from e + raise ImportError("_view has not been built, did you forget to compile it?") from e # these are re-exports from _view import Context, InvalidStatusError @@ -18,9 +16,9 @@ from . import _codec from .__about__ import * from .app import * +from .build import * from .components import * from .default_page import * -from .build import * from .exceptions import * from .logging import * from .patterns import * diff --git a/src/view/__main__.py b/src/view/__main__.py index fd277b7..c2435b0 100644 --- a/src/view/__main__.py +++ b/src/view/__main__.py @@ -116,8 +116,7 @@ def main(ctx: click.Context, debug: bool, version: bool) -> None: @main.group() -def logs(): - ... +def logs(): ... @logs.command() diff --git a/src/view/_loader.py b/src/view/_loader.py index cf7dd0e..3ba64e1 100644 --- a/src/view/_loader.py +++ b/src/view/_loader.py @@ -16,12 +16,13 @@ from typing import _eval_type else: - def _eval_type(*args) -> Any: - ... + def _eval_type(*args) -> Any: ... import inspect +from typing_extensions import get_origin + from ._logging import Internal from ._util import docs_hint, is_annotated, is_union, set_load from .exceptions import (DuplicateRouteError, InvalidBodyError, @@ -38,7 +39,6 @@ def _eval_type(*args) -> Any: NotRequired = None from typing_extensions import NotRequired as ExtNotRequired -from typing_extensions import get_origin _NOT_REQUIRED_TYPES: list[Any] = [] @@ -193,7 +193,7 @@ def _build_type_codes( for tp in inp: tps: dict[str, type[Any] | BodyParam] - + if is_annotated(tp): if doc is None: raise InvalidBodyError(f"Annotated is not valid here ({tp})") @@ -222,7 +222,7 @@ def _build_type_codes( codes.append((type_code, None, [])) continue - if (TypedDict in getattr(tp, "__orig_bases__", [])) or ( + if (TypedDict in getattr(tp, "__orig_bases__", [])) or ( # type: ignore type(tp) == _TypedDictMeta ): try: @@ -347,9 +347,7 @@ def __view_construct__(**kwargs): vbody_types = vbody doc = {} - codes.append( - (TYPECODE_CLASS, tp, _format_body(vbody_types, doc, tp)) - ) + codes.append((TYPECODE_CLASS, tp, _format_body(vbody_types, doc, tp))) setattr(tp, "_view_doc", doc) continue @@ -363,9 +361,7 @@ def __view_construct__(**kwargs): key, value = get_args(tp) if key is not str: - raise InvalidBodyError( - f"dictionary keys must be strings, not {key}" - ) + raise InvalidBodyError(f"dictionary keys must be strings, not {key}") tp_codes = _build_type_codes((value,)) codes.append((TYPECODE_DICT, None, tp_codes)) @@ -405,7 +401,7 @@ def _format_inputs( return result -def finalize(routes: list[Route], app: ViewApp): +def finalize(routes: Iterable[Route], app: ViewApp): """Attach list of routes to an app and validate all parameters. Args: @@ -433,9 +429,7 @@ def finalize(routes: list[Route], app: ViewApp): for step in route.steps or []: if step not in app.config.build.steps: - raise UnknownBuildStepError( - f"build step {step!r} is not defined" - ) + raise UnknownBuildStepError(f"build step {step!r} is not defined") if route.method: target = targets[route.method] @@ -444,7 +438,9 @@ def finalize(routes: list[Route], app: ViewApp): for i in route.inputs: if isinstance(i, RouteInput): if i.is_body: - raise InvalidRouteError(f"websocket routes cannot have body inputs") + raise InvalidRouteError( + f"websocket routes cannot have body inputs" + ) else: target = None @@ -466,7 +462,6 @@ def finalize(routes: list[Route], app: ViewApp): sig = inspect.signature(route.func) route.inputs = [i for i in reversed(route.inputs)] - if len(sig.parameters) != len(route.inputs): names = [i.name for i in route.inputs if isinstance(i, RouteInput)] index = 0 @@ -482,9 +477,7 @@ def finalize(routes: list[Route], app: ViewApp): route.inputs.insert(index, 1) continue - default = ( - v.default if v.default is not inspect._empty else _NoDefault - ) + default = v.default if v.default is not inspect._empty else _NoDefault route.inputs.insert( index, @@ -578,9 +571,7 @@ def load_fs(app: ViewApp, target_dir: Path) -> None: ) else: path_obj = Path(path) - stripped = list( - path_obj.parts[len(target_dir.parts) :] - ) # noqa + stripped = list(path_obj.parts[len(target_dir.parts) :]) # noqa if stripped[-1] == "index.py": stripped.pop(len(stripped) - 1) @@ -633,8 +624,7 @@ def load_simple(app: ViewApp, target_dir: Path) -> None: for route in mini_routes: if not route.path: raise InvalidRouteError( - "omitting path is only supported" - " on filesystem loading", + "omitting path is only supported" " on filesystem loading", ) routes.append(route) diff --git a/src/view/_logging.py b/src/view/_logging.py index 07206fc..2ae4f50 100644 --- a/src/view/_logging.py +++ b/src/view/_logging.py @@ -19,7 +19,8 @@ from rich.live import Live from rich.logging import RichHandler from rich.panel import Panel -from rich.progress import BarColumn, Progress, Task, TaskProgressColumn, TextColumn +from rich.progress import (BarColumn, Progress, Task, TaskProgressColumn, + TextColumn) from rich.progress_bar import ProgressBar from rich.table import Table from rich.text import Text @@ -826,9 +827,7 @@ def __init__(self, name: str, x: str, y: str) -> None: self.y_label = y self.datasets: dict[str, Dataset] = {} - def dataset( - self, name: str, *, point_limit: int | None = None - ) -> Dataset: + def dataset(self, name: str, *, point_limit: int | None = None) -> Dataset: """Generate or create a new dataset. Args: @@ -872,9 +871,7 @@ def __rich_console__( ) -> RenderResult: if not plt: return Panel( - shell_hint( - "pip install plotext", "pip install view.py[fancy]" - ), + shell_hint("pip install plotext", "pip install view.py[fancy]"), title="This widget needs an external library!", ) self._render(options.max_width, options.max_height) @@ -892,9 +889,7 @@ def __rich_console__( psutil = None if psutil: - layout["very_corner"].split_column( - Panel(system, title="System"), network - ) + layout["very_corner"].split_column(Panel(system, title="System"), network) else: layout["very_corner"].split_column( Panel( diff --git a/src/view/_util.py b/src/view/_util.py index 03e4685..17db6e5 100644 --- a/src/view/_util.py +++ b/src/view/_util.py @@ -11,8 +11,9 @@ import weakref from collections.abc import Iterable from pathlib import Path +from types import CodeType as Code from types import FrameType as Frame -from types import FunctionType as Function, CodeType as Code +from types import FunctionType as Function from typing import Any, NoReturn, Union from rich.markup import escape @@ -56,9 +57,7 @@ class LoadChecker: _view_loaded: bool def _view_load_check(self) -> None: - if (not self._view_loaded) and ( - not os.environ.get("_VIEW_CANCEL_FINALIZERS") - ): + if (not self._view_loaded) and (not os.environ.get("_VIEW_CANCEL_FINALIZERS")): warnings.warn(f"{self} was never loaded", NotLoadedWarning) def __post_init__(self) -> None: @@ -75,9 +74,7 @@ def shell_hint(*commands: str) -> Panel: if os.name == "nt": shell_prefix = f"{os.getcwd()}>" else: - shell_prefix = ( - f"{getpass.getuser()}@{socket.gethostname()}[bold green]$[/]" - ) + shell_prefix = f"{getpass.getuser()}@{socket.gethostname()}[bold green]$[/]" formatted = [f"{shell_prefix} {escape(command)}" for command in commands] return Panel.fit( diff --git a/src/view/app.py b/src/view/app.py index 90afd33..ef84c22 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -9,6 +9,7 @@ import sys import warnings import weakref +from collections.abc import Iterable as CollectionsIterable from contextlib import asynccontextmanager, suppress from dataclasses import dataclass from functools import lru_cache @@ -18,15 +19,14 @@ from threading import Thread from types import FrameType as Frame from types import TracebackType as Traceback -from typing import (Any, AsyncIterator, Callable, Coroutine, Generic, - TextIO, TypeVar, get_type_hints, overload, Iterable) +from typing import (Any, AsyncIterator, Callable, Coroutine, Generic, Iterable, + TextIO, TypeVar, get_type_hints, overload) from urllib.parse import urlencode -from collections.abc import Iterable as CollectionsIterable import ujson from rich import print from rich.traceback import install -from typing_extensions import ParamSpec, Unpack +from typing_extensions import ParamSpec, TypeAlias, Unpack from _view import InvalidStatusError, ViewApp @@ -39,7 +39,8 @@ from ._util import make_hint, needs_dep from .build import build_app, build_steps from .config import Config, load_config -from .exceptions import BadEnvironmentError, ViewError, ViewInternalError, InvalidCustomLoaderError +from .exceptions import (BadEnvironmentError, InvalidCustomLoaderError, + ViewError, ViewInternalError) from .logging import _LogArgs, log from .response import HTML from .routing import Path as _RouteDeco @@ -116,11 +117,25 @@ ) -@dataclass() class TestingResponse: - message: str - headers: dict[str, str] - status: int + def __init__( + self, + message: str | None, + headers: dict[str, str], + status: int, + content: bytes, + ) -> None: + self._message = message + self.headers = headers + self.status = status + self.content = content + + @property + def message(self) -> str: + if self._message is None: + raise RuntimeError("cannot decode content into string") + + return self._message def _format_qs(query: dict[str, Any]) -> dict[str, Any]: @@ -197,7 +212,7 @@ async def start(self) -> None: async def receive(): return await self._lifespan.get() - async def send(obj: dict[str, Any]): + async def send(_: dict[str, Any]): pass await self.app({"type": "lifespan"}, receive, send) @@ -225,7 +240,7 @@ async def _request( query: dict[str, Any] | None = None, headers: dict[str, str] | None = None, ) -> TestingResponse: - body_q: asyncio.Queue[str] = asyncio.Queue() + body_q: asyncio.Queue[bytes] = asyncio.Queue() start: asyncio.Queue[tuple[dict[str, str], int]] = asyncio.Queue() async def receive(): @@ -244,7 +259,8 @@ async def send(obj: dict[str, Any]): ) ) elif obj["type"] == "http.response.body": - await body_q.put(obj["body"].decode()) + assert isinstance(obj["body"], bytes) + await body_q.put(obj["body"]) else: raise ViewInternalError(f"bad type: {obj['type']}") @@ -255,7 +271,6 @@ async def send(obj: dict[str, Any]): await self.app( { "type": "http", - "http_version": "1.1", "path": truncated_route, "query_string": ( urlencode(query_str).encode() if query else b"" @@ -272,9 +287,14 @@ async def send(obj: dict[str, Any]): ) res_headers, status = await start.get() - body_s = await body_q.get() + body_b = await body_q.get() - return TestingResponse(body_s, res_headers, status) + try: + body_s: str | None = body_b.decode() + except UnicodeError: + body_s = None + + return TestingResponse(body_s, res_headers, status, body_b) async def get( self, @@ -599,7 +619,7 @@ def inner(r: RouteOrCallable[P]) -> Route[P]: def custom_loader(self, loader: CustomLoader): self._user_loader = loader - + def _method_wrapper( self, path: str, @@ -987,7 +1007,7 @@ def context( async def _app(self, scope, receive, send) -> None: return await self.asgi_app_entry(scope, receive, send) - def load(self, routes: list[Route] | None = None) -> None: + def load(self, *routes: Route) -> None: """Load the app. This is automatically called most of the time and should only be called manually during manual loading. Args: @@ -1002,6 +1022,12 @@ def load(self, routes: list[Route] | None = None) -> None: if routes and (self.config.app.loader != "manual"): warnings.warn(_ROUTES_WARN_MSG) + for index, i in enumerate(routes): + if not isinstance(i, Route): + raise TypeError( + f"(index {index}) expected Route object, got {i}" + ) + if self.config.app.loader == "filesystem": load_fs(self, self.config.app.loader_path) elif self.config.app.loader == "simple": @@ -1011,13 +1037,13 @@ def load(self, routes: list[Route] | None = None) -> None: elif self.config.app.loader == "custom": if not self._user_loader: raise InvalidCustomLoaderError("custom loader was not set") - - routes = self._user_loader(self, self.config.app.loader_path) - if not isinstance(routes, CollectionsIterable): + + collected = self._user_loader(self, self.config.app.loader_path) + if not isinstance(collected, CollectionsIterable): raise InvalidCustomLoaderError( - f"expected custom loader to return a list of routes, got {routes!r}" + f"expected custom loader to return a list of routes, got {collected!r}" ) - finalize([i for i in routes], self) + finalize(collected, self) else: finalize([*(routes or ()), *self._manual_routes], self) @@ -1198,9 +1224,7 @@ def _run(self, start_target: Callable[..., Any] | None = None) -> Any: ) # mypy thinks asyncio.to_thread doesn't exist for some reason return start( - self._spawn( - asyncio.to_thread(daphne_server.run) # type: ignore - ) + self._spawn(asyncio.to_thread(daphne_server.run)) # type: ignore ) else: raise NotImplementedError("viewserver is not implemented yet") @@ -1329,8 +1353,10 @@ def docs( return None + _last_app: App | None = None + def new_app( *, start: bool = False, diff --git a/src/view/build.py b/src/view/build.py index 52ec399..78837b5 100644 --- a/src/view/build.py +++ b/src/view/build.py @@ -8,7 +8,7 @@ from asyncio import subprocess from collections.abc import Coroutine from pathlib import Path -from typing import TYPE_CHECKING, NamedTuple, NoReturn, Any +from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn import aiofiles import aiofiles.os @@ -160,7 +160,9 @@ async def _check_requirement(req: str) -> None: if reqfunc: res = await reqfunc() if res is False: - raise MissingRequirementError(f"Requirement script in module {target} returned non-True") + raise MissingRequirementError( + f"Requirement script in module {target} returned non-True" + ) elif prefix == "script": path = Path(target) if (not path.exists()) or (not path.is_file()): @@ -170,7 +172,9 @@ async def _check_requirement(req: str) -> None: res = await _call_script(path, call_func="__view_requirement__") if res is False: - raise MissingRequirementError(f"Requirement script at {path} returned non-True") + raise MissingRequirementError( + f"Requirement script at {path} returned non-True" + ) elif prefix == "path": if not Path(target).exists(): raise MissingRequirementError(f"{target} does not exist") @@ -180,6 +184,7 @@ async def _check_requirement(req: str) -> None: else: raise BuildError(f"Invalid requirement prefix: {prefix}") + _PLATFORMS: dict[str, list[Platform]] = { "Linux": ["linux", "Linux"], "Darwin": ["mac", "macOS", "Mac", "MacOS"], @@ -206,12 +211,14 @@ def _is_platform_compatible(plat: Platform | list[Platform] | None) -> bool: return plat in names + def _invalid_platform(name: str) -> NoReturn: system = platform.system() raise PlatformNotSupportedError( f"build step {name!r} does not support {system.lower()}" ) + async def _build_step(step: _BuildStepWithName) -> None: if step.step.platform: if not _is_platform_compatible(step.step.platform): @@ -305,7 +312,7 @@ async def build_steps(app: App) -> None: await _build_step(step) -def _handle_result(res: ViewResult) -> str: +def _handle_result(res: ViewResult) -> str | bytes: response = to_response(res) return response.body @@ -314,10 +321,10 @@ async def _compile_routes( app: App, *, should_await: bool = False, -) -> dict[str, str]: +) -> dict[str, str | bytes]: from .routing import Method - results: dict[str, str] = {} + results: dict[str, str | bytes] = {} coros: list[Coroutine] = [] for i in app.loaded_routes: @@ -326,17 +333,13 @@ async def _compile_routes( continue if not i.path: - warnings.warn( - f"{i} needs path parameters, skipping it", BuildWarning - ) + warnings.warn(f"{i} needs path parameters, skipping it", BuildWarning) continue Internal.info(f"Calling GET {i.path}") if i.inputs: - warnings.warn( - f"{i.path} needs a route input, skipping it", BuildWarning - ) + warnings.warn(f"{i.path} needs a route input, skipping it", BuildWarning) continue res = i.func() # type: ignore @@ -387,8 +390,12 @@ async def build_app(app: App, *, path: Path | None = None) -> None: await aiofiles.os.mkdir(directory) Internal.info(f"Created {directory}") - async with aiofiles.open(file, "w", encoding="utf-8") as f: - await f.write(content) + if isinstance(content, str): + async with aiofiles.open(file, "w", encoding="utf-8") as f: + await f.write(content) + else: + async with aiofiles.open(file, "wb") as f: + await f.write(content) Internal.info(f"Created {file}") Internal.info("Successfully built app") diff --git a/src/view/config.py b/src/view/config.py index 8614fd4..c8c9b64 100644 --- a/src/view/config.py +++ b/src/view/config.py @@ -5,9 +5,9 @@ from ipaddress import IPv4Address from pathlib import Path from typing import Any, Dict, List, Literal, Union -from typing_extensions import TypeAlias from configzen import ConfigField, ConfigModel, field_validator +from typing_extensions import TypeAlias from .exceptions import ViewInternalError from .logging import FileWriteMethod, Urgency @@ -30,6 +30,7 @@ "load_config", ) + # https://github.com/python/mypy/issues/11036 class AppConfig(ConfigModel, env_prefix="view_app_"): # type: ignore loader: Literal["manual", "simple", "filesystem", "patterns", "custom"] = "manual" @@ -74,9 +75,7 @@ class UserLogConfig(ConfigModel, env_prefix="view_user_log_"): # type: ignore class LogConfig(ConfigModel, env_prefix="view_log_"): # type: ignore - level: Union[ - Literal["debug", "info", "warning", "error", "critical"], int - ] = "info" + level: Union[Literal["debug", "info", "warning", "error", "critical"], int] = "info" fancy: bool = True server_logger: bool = False pretty_tracebacks: bool = True @@ -125,7 +124,11 @@ class TemplatesConfig(ConfigModel, env_prefix="view_templates_"): # type: ignor globals: bool = True engine: TemplateEngine = "view" -Platform: TypeAlias = Literal["windows", "mac", "linux", "macOS", "Windows", "Linux", "Mac", "MacOS"] + +Platform: TypeAlias = Literal[ + "windows", "mac", "linux", "macOS", "Windows", "Linux", "Mac", "MacOS" +] + class BuildStep(ConfigModel): # type: ignore platform: Union[List[Platform], Platform, None] = None @@ -137,7 +140,9 @@ class BuildStep(ConfigModel): # type: ignore class BuildConfig(ConfigModel, env_prefix="view_build_"): # type: ignore path: Path = Path("./build") default_steps: Union[List[str], None] = None - steps: Dict[str, Union[BuildStep, List[BuildStep]]] = ConfigField(default_factory=dict) + steps: Dict[str, Union[BuildStep, List[BuildStep]]] = ConfigField( + default_factory=dict + ) parallel: bool = False diff --git a/src/view/databases.py b/src/view/databases.py index 39831d9..58e8843 100644 --- a/src/view/databases.py +++ b/src/view/databases.py @@ -40,24 +40,19 @@ class _Connection(ABC): @abstractmethod - async def connect(self) -> None: - ... + async def connect(self) -> None: ... @abstractmethod - async def close(self) -> None: - ... + async def close(self) -> None: ... @abstractmethod - async def insert(self, table: str, json: dict) -> None: - ... + async def insert(self, table: str, json: dict) -> None: ... @abstractmethod - async def find(self, table: str, json: dict) -> None: - ... + async def find(self, table: str, json: dict) -> None: ... @abstractmethod - async def migrate(self, table: str, vbody: dict) -> None: - ... + async def migrate(self, table: str, vbody: dict) -> None: ... _SQL_TYPES: dict[type, str] = { @@ -134,7 +129,9 @@ def create_database_connection(self): async def connect(self) -> None: try: - self.connection = await asyncio.to_thread(self.create_database_connection) + self.connection = await asyncio.to_thread( + self.create_database_connection + ) self.cursor = await asyncio.to_thread(self.connection.cursor) # type: ignore except psycopg2.Error as e: raise ValueError( @@ -166,11 +163,9 @@ async def close(self) -> None: self.connection = None self.cursor = None - async def insert(self, table: str, json: dict) -> None: - ... + async def insert(self, table: str, json: dict) -> None: ... - async def find(self, table: str, json: dict) -> None: - ... + async def find(self, table: str, json: dict) -> None: ... async def migrate(self, table: str, vbody: dict): assert self.cursor is not None @@ -287,11 +282,14 @@ def __init__(self, *args: Any, **kwargs: Any): setattr(self, k, args[index]) def __init_subclass__(cls, **kwargs: Any): - cls.__view_table__ = kwargs.get("table") or ("vpy_" + cls.__name__.lower()) + cls.__view_table__ = kwargs.get("table") or ( + "vpy_" + cls.__name__.lower() + ) model_hints = get_type_hints(Model) actual_hints = get_type_hints(cls) params = { - k: actual_hints[k] for k in (model_hints.keys() ^ actual_hints.keys()) + k: actual_hints[k] + for k in (model_hints.keys() ^ actual_hints.keys()) } for k, v in params.items(): @@ -308,30 +306,24 @@ def __repr__(self) -> str: __str__ = __repr__ @classmethod - def find(cls) -> list[Self]: - ... + def find(cls) -> list[Self]: ... @classmethod - def unique(cls) -> Self: - ... + def unique(cls) -> Self: ... - def exists(self) -> bool: - ... + def exists(self) -> bool: ... def save(self) -> None: conn = self._assert_conn() conn.insert(self.__view_table__, self._json()) - def _json(self) -> dict[str, Any]: - ... + def _json(self) -> dict[str, Any]: ... - def json(self) -> dict[str, Any]: - ... + def json(self) -> dict[str, Any]: ... @classmethod - def from_json(cls, json: dict[str, Any]) -> Self: - ... + def from_json(cls, json: dict[str, Any]) -> Self: ... @classmethod def _assert_conn(cls) -> _Connection: diff --git a/src/view/default_page.py b/src/view/default_page.py index d2b912d..344f014 100644 --- a/src/view/default_page.py +++ b/src/view/default_page.py @@ -2,6 +2,7 @@ __all__ = ("default_page",) + def default_page() -> HTML: """Return the view.py default page.""" return HTML("") diff --git a/src/view/exceptions.py b/src/view/exceptions.py index 223ee29..ec1869d 100644 --- a/src/view/exceptions.py +++ b/src/view/exceptions.py @@ -132,14 +132,18 @@ class UnknownBuildStepError(BuildError): class PlatformNotSupportedError(BuildError): """Build step does not support the platform.""" + class WebSocketError(ViewError): """Something related to a WebSocket failed.""" + class WebSocketHandshakeError(WebSocketError): """WebSocket handshake went wrong somehow.""" + class WebSocketExpectError(WebSocketError, AssertionError, TypeError): """WebSocket received unexpected message.""" + class InvalidCustomLoaderError(ViewError): """Custom loader is invalid.""" diff --git a/src/view/response.py b/src/view/response.py index 5418537..14fb130 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -9,7 +9,7 @@ from .components import DOMNode from .exceptions import InvalidResultError -from .typing import BodyTranslateStrategy, SameSite, ViewResult +from .typing import BodyTranslateStrategy, ResponseBody, SameSite, ViewResult from .util import timestamp T = TypeVar("T") @@ -39,9 +39,7 @@ def __init__( if body_translate: self.translate = body_translate else: - self.translate = ( - "str" if not hasattr(body, "__view_result__") else "result" - ) + self.translate = "str" if not hasattr(body, "__view_result__") else "result" def _custom(self, body: T) -> str: raise NotImplementedError( @@ -116,7 +114,10 @@ def _build_headers(self) -> tuple[tuple[bytes, bytes], ...]: def __view_result__(self) -> ViewResult: body: str = "" if self.translate == "str": - body = str(self.body) + if isinstance(self.body, bytes): + body = self.body.decode() + else: + body = str(self.body) elif self.translate == "repr": body = repr(self.body) elif self.translate == "custom": @@ -191,7 +192,7 @@ def _custom(self, body: dict[str, Any]) -> str: return ujson.dumps(body) -def to_response(result: ViewResult) -> Response[str]: +def to_response(result: ViewResult) -> Response[ResponseBody]: """Cast a result from a route function to a `Response` object.""" if hasattr(result, "__view_result__"): @@ -202,12 +203,12 @@ def to_response(result: ViewResult) -> Response[str]: status: int = 200 headers: dict[str, str] = {} raw_headers: list[tuple[bytes, bytes]] = [] - body: str | None = None + body: ResponseBody | None = None for value in result: if isinstance(value, int): status = value - elif isinstance(value, str): + elif isinstance(value, (str, bytes)): body = value elif isinstance(value, dict): headers = value @@ -225,5 +226,5 @@ def to_response(result: ViewResult) -> Response[str]: res._raw_headers = raw_headers return res - assert isinstance(result, str) + assert isinstance(result, (str, bytes)) return Response(result) diff --git a/src/view/routing.py b/src/view/routing.py index ba08df9..78542d0 100644 --- a/src/view/routing.py +++ b/src/view/routing.py @@ -205,8 +205,7 @@ def route_types( route.extra_types[data.__name__] = data else: raise InvalidRouteError( - "expected type, tuple of tuples," - f" or a dict, got {type(data).__name__}" + "expected type, tuple of tuples," f" or a dict, got {type(data).__name__}" ) return route @@ -238,17 +237,13 @@ def _method( if not util_path.startswith("/"): raise MistakeError( "paths must started with a slash", - hint=make_hint( - f'This should be "/{util_path}" instead', back_lines=2 - ), + hint=make_hint(f'This should be "/{util_path}" instead', back_lines=2), ) if util_path.endswith("/") and (len(util_path) != 1): raise MistakeError( "paths must not end with a slash", - hint=make_hint( - f'This should be "{util_path[:-1]}" instead', back_lines=2 - ), + hint=make_hint(f'This should be "{util_path[:-1]}" instead', back_lines=2), ) if "{" in util_path: @@ -614,9 +609,7 @@ async def index(): doc, None, cache_rate, - method_list=[_STR_METHOD_MAPPING[i] for i in methods] - if methods - else None, + method_list=[_STR_METHOD_MAPPING[i] for i in methods] if methods else None, steps=steps, parallel_build=parallel_build, ) @@ -720,15 +713,13 @@ def inner(r: RouteOrCallable[P]) -> Route[P]: @overload def context( r_or_none: RouteOrCallable[P], -) -> Route[P]: - ... +) -> Route[P]: ... @overload def context( r_or_none: None = None, -) -> Callable[[RouteOrCallable[P]], Route[P]]: - ... +) -> Callable[[RouteOrCallable[P]], Route[P]]: ... def context( diff --git a/src/view/typing.py b/src/view/typing.py index e00224e..5c35c4d 100644 --- a/src/view/typing.py +++ b/src/view/typing.py @@ -1,19 +1,7 @@ from __future__ import annotations -from typing import ( - TYPE_CHECKING, - Any, - Awaitable, - Callable, - Dict, - Generic, - List, - Literal, - Tuple, - Type, - TypeVar, - Union, -) +from typing import (TYPE_CHECKING, Any, Awaitable, Callable, Dict, Generic, + List, Literal, Tuple, Type, TypeVar, Union) from typing_extensions import Concatenate, ParamSpec, Protocol, TypedDict @@ -44,22 +32,22 @@ List[RawResponseHeader], Tuple[RawResponseHeader, ...], ] +ResponseBody = Union[str, bytes] -_ViewResponseTupleA = Tuple[str, int, ResponseHeaders] -_ViewResponseTupleB = Tuple[int, str, ResponseHeaders] -_ViewResponseTupleC = Tuple[str, ResponseHeaders, int] -_ViewResponseTupleD = Tuple[int, ResponseHeaders, str] -_ViewResponseTupleE = Tuple[ResponseHeaders, str, int] -_ViewResponseTupleF = Tuple[ResponseHeaders, int, str] -_ViewResponseTupleG = Tuple[str, ResponseHeaders] -_ViewResponseTupleH = Tuple[ResponseHeaders, str] -_ViewResponseTupleI = Tuple[str, int] -_ViewResponseTupleJ = Tuple[int, str] +_ViewResponseTupleA = Tuple[ResponseBody, int, ResponseHeaders] +_ViewResponseTupleB = Tuple[int, ResponseBody, ResponseHeaders] +_ViewResponseTupleC = Tuple[ResponseBody, ResponseHeaders, int] +_ViewResponseTupleD = Tuple[int, ResponseHeaders, ResponseBody] +_ViewResponseTupleE = Tuple[ResponseHeaders, ResponseBody, int] +_ViewResponseTupleF = Tuple[ResponseHeaders, int, ResponseBody] +_ViewResponseTupleG = Tuple[ResponseBody, ResponseHeaders] +_ViewResponseTupleH = Tuple[ResponseHeaders, ResponseBody] +_ViewResponseTupleI = Tuple[ResponseBody, int] +_ViewResponseTupleJ = Tuple[int, ResponseBody] class SupportsViewResult(Protocol): - def __view_result__(self) -> ViewResult: - ... + def __view_result__(self) -> ViewResult: ... ViewResult = Union[ @@ -73,9 +61,9 @@ def __view_result__(self) -> ViewResult: _ViewResponseTupleH, _ViewResponseTupleI, _ViewResponseTupleJ, - str, + ResponseBody, SupportsViewResult, - None + None, ] P = ParamSpec("P") V = TypeVar("V", bound="ValueType") @@ -115,8 +103,7 @@ class _SupportsViewBodyCV(Protocol): class _SupportsViewBodyF(Protocol): @staticmethod - def __view_body__() -> ViewBody: - ... + def __view_body__() -> ViewBody: ... ViewBodyLike = Union[_SupportsViewBodyCV, _SupportsViewBodyF] diff --git a/src/view/util.py b/src/view/util.py index 2dfb63a..eea131e 100644 --- a/src/view/util.py +++ b/src/view/util.py @@ -82,6 +82,7 @@ def enable_debug(): # good god why does mypy suck at the very thing it's designed to do + @overload def env(key: str, *, tp: type[str] = str) -> str: # type: ignore ... @@ -139,7 +140,9 @@ def index(): try: return int(value) except ValueError: - raise BadEnvironmentError(f"{value!r} (key {key!r}) is not int-like") from None + raise BadEnvironmentError( + f"{value!r} (key {key!r}) is not int-like" + ) from None if tp is dict: try: diff --git a/src/view/ws.py b/src/view/ws.py index 997094c..c303747 100644 --- a/src/view/ws.py +++ b/src/view/ws.py @@ -5,9 +5,10 @@ import ujson from typing_extensions import Self -from .exceptions import WebSocketExpectError, WebSocketHandshakeError from _view import ViewWebSocket, register_ws_cls +from .exceptions import WebSocketExpectError, WebSocketHandshakeError + __all__ = "WebSocketSendable", "WebSocketReceivable", "WebSocket" WebSocketSendable = Union[str, bytes, dict, int, bool] @@ -23,28 +24,21 @@ def __init__(self, socket: ViewWebSocket) -> None: self.done: bool = False @overload - async def receive(self, tp: type[str] = str) -> str: - ... + async def receive(self, tp: type[str] = str) -> str: ... @overload - async def receive(self, tp: type[bytes] = bytes) -> bytes: - ... + async def receive(self, tp: type[bytes] = bytes) -> bytes: ... @overload - async def receive(self, tp: type[dict] = dict) -> dict: - ... + async def receive(self, tp: type[dict] = dict) -> dict: ... @overload - async def receive(self, tp: type[int] = int) -> int: - ... + async def receive(self, tp: type[int] = int) -> int: ... @overload - async def receive(self, tp: type[bool] = bool) -> bool: - ... + async def receive(self, tp: type[bool] = bool) -> bool: ... - async def receive( - self, tp: type[WebSocketReceivable] = str - ) -> WebSocketReceivable: + async def receive(self, tp: type[WebSocketReceivable] = str) -> WebSocketReceivable: """Receive a message from the WebSocket. Args: @@ -71,19 +65,17 @@ async def receive( return res.encode() if tp is bool: - if (res not in {"True", "true", "False", "false"}) and ( - not res.isdigit() - ): - raise WebSocketExpectError(f"expected boolean-like message, got {res!r}") + if (res not in {"True", "true", "False", "false"}) and (not res.isdigit()): + raise WebSocketExpectError( + f"expected boolean-like message, got {res!r}" + ) if res.isdigit(): return bool(int(res)) return res in {"True", "true"} - raise TypeError( - f"expected type str, bytes, dict, int, or bool, but got {tp!r}" - ) + raise TypeError(f"expected type str, bytes, dict, int, or bool, but got {tp!r}") async def send(self, message: WebSocketSendable) -> None: """Send a message to the client. @@ -91,9 +83,7 @@ async def send(self, message: WebSocketSendable) -> None: Args: message: Message to send.""" if not self.open: - raise WebSocketHandshakeError( - "cannot send to connection that is not open" - ) + raise WebSocketHandshakeError("cannot send to connection that is not open") if isinstance(message, (str, bytes)): await self.socket.send(message) elif isinstance(message, dict): @@ -114,8 +104,7 @@ async def pair( *, tp: type[str] = str, recv_first: bool = False, - ) -> str: - ... + ) -> str: ... @overload async def pair( @@ -124,8 +113,7 @@ async def pair( *, tp: type[bytes] = bytes, recv_first: bool = False, - ) -> bytes: - ... + ) -> bytes: ... @overload async def pair( @@ -134,8 +122,7 @@ async def pair( *, tp: type[int] = int, recv_first: bool = False, - ) -> int: - ... + ) -> int: ... @overload async def pair( @@ -144,8 +131,7 @@ async def pair( *, tp: type[dict] = dict, recv_first: bool = False, - ) -> dict: - ... + ) -> dict: ... @overload async def pair( @@ -154,8 +140,7 @@ async def pair( *, tp: type[bool] = bool, recv_first: bool = False, - ) -> bool: - ... + ) -> bool: ... async def pair( self, @@ -182,9 +167,7 @@ async def pair( async def close(self) -> None: """Close the connection.""" if not self.open: - raise WebSocketHandshakeError( - "cannot close connection that isn't open" - ) + raise WebSocketHandshakeError("cannot close connection that isn't open") self.open = False self.done = True @@ -214,4 +197,4 @@ async def __aexit__(self, *_) -> None: await self.close() -register_ws_cls(WebSocket) \ No newline at end of file +register_ws_cls(WebSocket) diff --git a/tests/buildscripts/failing_build_script.py b/tests/buildscripts/failing_build_script.py index 515e31b..ff8dee8 100644 --- a/tests/buildscripts/failing_build_script.py +++ b/tests/buildscripts/failing_build_script.py @@ -1,2 +1,2 @@ async def __view_build__() -> None: - raise RuntimeError("test") \ No newline at end of file + raise RuntimeError("test") diff --git a/tests/buildscripts/failing_req.py b/tests/buildscripts/failing_req.py index 204e9de..e319ec9 100644 --- a/tests/buildscripts/failing_req.py +++ b/tests/buildscripts/failing_req.py @@ -1,7 +1,8 @@ import aiofiles + async def __view_requirement__() -> bool: async with aiofiles.open("failingreq.test", "w"): pass - - return False \ No newline at end of file + + return False diff --git a/tests/buildscripts/my_build_script.py b/tests/buildscripts/my_build_script.py index 22888a8..6d6c7af 100644 --- a/tests/buildscripts/my_build_script.py +++ b/tests/buildscripts/my_build_script.py @@ -1,5 +1,6 @@ import os + async def __view_build__() -> None: os.environ["_VIEW_TEST_BUILD_SCRIPT"] = "1" - assert __name__ == "__view_build__" \ No newline at end of file + assert __name__ == "__view_build__" diff --git a/tests/buildscripts/req.py b/tests/buildscripts/req.py index b037fb4..25ba626 100644 --- a/tests/buildscripts/req.py +++ b/tests/buildscripts/req.py @@ -1,6 +1,7 @@ import aiofiles + async def __view_requirement__() -> bool: async with aiofiles.open("customreq.test", "w"): pass - return True \ No newline at end of file + return True diff --git a/tests/leaks.py b/tests/leaks.py index 195d8e9..ed6da9d 100644 --- a/tests/leaks.py +++ b/tests/leaks.py @@ -1,7 +1,9 @@ import platform from typing import Callable + import pytest + def limit_leaks(memstring: str): def decorator(func: Callable): if platform.system() != "Windows": @@ -9,4 +11,5 @@ def decorator(func: Callable): return func else: return func - return decorator \ No newline at end of file + + return decorator diff --git a/tests/test_app.py b/tests/test_app.py index 8f0dbbb..24cc804 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,15 +2,16 @@ from typing import Dict, List, NamedTuple, TypedDict, Union import attrs +import pytest +from leaks import limit_leaks from pydantic import BaseModel, Field from typing_extensions import NotRequired -import pytest from view import (JSON, BodyParam, Context, Response, body, context, get, new_app, query) from view import route as route_impl from view.typing import CallNext -from leaks import limit_leaks + @pytest.mark.asyncio @limit_leaks("1 MB") @@ -20,10 +21,11 @@ async def test_reponses(): @app.get("/") async def index(): return "hello" - + async with app.test() as test: assert (await test.get("/")).message == "hello" + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_status_codes(): @@ -32,12 +34,13 @@ async def test_status_codes(): @app.get("/") async def index(): return "error", 400 - + async with app.test() as test: res = await test.get("/") assert res.status == 400 assert res.message == "error" + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_headers(): @@ -52,6 +55,7 @@ async def index(): assert res.headers["a"] == "b" assert res.message == "hello" + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_combination_of_headers_responses_and_status_codes(): @@ -67,6 +71,7 @@ async def index(): assert res.message == "123" assert res.headers["a"] == "b" + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_result_protocol(): @@ -90,6 +95,7 @@ async def multi(): assert res.message == "hello" assert res.status == 201 + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_body_type_validation(): @@ -136,6 +142,7 @@ async def multi(status: int, name: str): assert res.status == 404 assert res.message == "test" + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_query_type_validation(): @@ -182,6 +189,7 @@ async def multi(status: int, name: str): assert res.status == 404 assert res.message == "test" + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_queries_directly_from_app_and_body(): @@ -202,8 +210,11 @@ async def body_route(name: str): assert ( await test.get("/body", body={"name": "test"}) ).message == "test" - assert (await test.get("/body", body={"name": "test"})).message == "test" - + assert ( + await test.get("/body", body={"name": "test"}) + ).message == "test" + + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_response_type(): @@ -220,6 +231,7 @@ async def index(): assert res.status == 201 assert res.headers["hello"] == "world" + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_object_validation(): @@ -398,8 +410,8 @@ async def test_non_async_routes(): @app.get("/") def index(): - return "hello world", 201, {"a":"b"} - + return "hello world", 201, {"a": "b"} + async with app.test() as test: res = await test.get("/") @@ -596,7 +608,7 @@ async def param_std(): results = [(await test.get("/param_std")).message for _ in range(10)] assert all(i == results[0] for i in results) - + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_synchronous_route_inputs(): @@ -767,7 +779,7 @@ def methodless_ctx(context: Context): async def m(context: Context): return context.method - app.load([m]) + app.load(m) async with app.test() as test: assert (await test.get("/")).message == "a" @@ -840,4 +852,22 @@ async def custom(): async with app.test() as test: assert (await test.get("/")).message == repr("a") assert (await test.get("/result")).message == "{}" - assert (await test.get("/custom")).message == "1 2 3" \ No newline at end of file + assert (await test.get("/custom")).message == "1 2 3" + + +@pytest.mark.asyncio +@limit_leaks("1 MB") +async def test_bytes_response(): + app = new_app() + + @app.get("/") + async def index(): + return b"\x09 \x09" + + @app.get("/hi") + async def hi(): + return b"hi", 201, {"test": "test"} + + async with app.test() as test: + assert (await test.get("/")).content == b"\x09 \x09" + assert (await test.get("/hi")).content == b"hi" diff --git a/tests/test_build.py b/tests/test_build.py index 9f2d53e..107a240 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -5,11 +5,10 @@ from view import new_app + @pytest.mark.asyncio async def test_build_requirements(): - app = new_app( - config_path=Path.cwd() / "tests" / "configs" / "build_reqs.toml" - ) + app = new_app(config_path=Path.cwd() / "tests" / "configs" / "build_reqs.toml") @app.get("/") async def index(): @@ -20,7 +19,7 @@ async def index(): @app.get("/foo", steps=("foo",)) async def wont_work(): return "shouldn't be here" - + @app.get("/customreq", steps=("customreq",)) async def should_work(): assert os.path.exists("customreq.test") @@ -38,12 +37,9 @@ async def fail(): assert os.path.exists("failingreq.test") - @pytest.mark.asyncio async def test_build_scripts(): - app = new_app( - config_path=Path.cwd() / "tests" / "configs" / "build_scripts.toml" - ) + app = new_app(config_path=Path.cwd() / "tests" / "configs" / "build_scripts.toml") called = False @@ -61,9 +57,7 @@ async def index(): @pytest.mark.asyncio async def test_build_commands(): - app = new_app( - config_path=Path.cwd() / "tests" / "configs" / "build_commands.toml" - ) + app = new_app(config_path=Path.cwd() / "tests" / "configs" / "build_commands.toml") @app.get("/", steps=["fail"]) async def fail(): @@ -74,12 +68,11 @@ async def fail(): assert os.path.exists("build.test") + @pytest.mark.asyncio async def test_build_platform(): - app = new_app( - config_path=Path.cwd() / "tests" / "configs" / "build_platform.toml" - ) - + app = new_app(config_path=Path.cwd() / "tests" / "configs" / "build_platform.toml") + @app.get("/", steps=["windowsonly"]) async def index(): return "hello world" @@ -90,4 +83,6 @@ async def index(): else: assert (await test.get("/")).status == 500 - assert os.path.exists("linux_build.test" if os.name != "nt" else "windows_build.test") \ No newline at end of file + assert os.path.exists( + "linux_build.test" if os.name != "nt" else "windows_build.test" + ) diff --git a/tests/test_functions.py b/tests/test_functions.py index 1682c99..be2fd18 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -2,11 +2,14 @@ from dataclasses import dataclass from typing import Dict -from typing_extensions import Annotated - import pytest -from view import App, BadEnvironmentError, TypeValidationError, compile_type, env, get_app, new_app from leaks import limit_leaks +from typing_extensions import Annotated + +from view import (App, BadEnvironmentError, TypeValidationError, compile_type, + env, get_app, new_app, to_response) +from view.typing import CallNext + @limit_leaks("1 MB") def test_app_creation(): @@ -118,7 +121,7 @@ async def test_environment_variables(): assert env("_TEST") == "1" assert env("_TEST", tp=int) == 1 os.environ["_TEST2"] = '{"hello": "world"}' - + test2 = env("_TEST2", tp=dict) assert isinstance(test2, dict) assert test2["hello"] == "world" @@ -126,4 +129,35 @@ async def test_environment_variables(): os.environ["_TEST3"] = "false" assert env("_TEST3", tp=bool) is False - + +@pytest.mark.asyncio +async def test_to_response(): + app = new_app() + + @app.get("/") + async def index(): + return "hello", 201, {"a": "b"} + + @app.get("/bytes") + async def other(): + return b"test", {"hello": "world"} + + @index.middleware + async def middleware(call_next: CallNext): + res = to_response(await call_next()) + assert res.body == "hello" + assert res.status == 201 + assert res.headers == {"a": "b"} + res.body = "goodbye" + return res + + @other.middleware + async def other_middleware(call_next: CallNext): + res = to_response(await call_next()) + assert res.body == b"test" + assert res.headers == {"hello": "world"} + return res + + async with app.test() as test: + assert (await test.get("/")).message == "goodbye" + assert (await test.get("/bytes")).message == "test" diff --git a/tests/test_loaders.py b/tests/test_loaders.py index d8f2d77..733d70a 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -1,8 +1,11 @@ from pathlib import Path +from typing import List import pytest -from typing import List -from view import delete, get, new_app, options, patch, post, put, App, Route, InvalidCustomLoaderError + +from view import (App, InvalidCustomLoaderError, Route, delete, get, new_app, + options, patch, post, put) + @pytest.mark.asyncio async def test_manual_loader(): @@ -33,7 +36,7 @@ async def d(): async def o(): return "options" - app.load([g, p, pu, pa, d, o]) + app.load(g, p, pu, pa, d, o) async with app.test() as test: assert (await test.get("/get")).message == "get" @@ -102,7 +105,6 @@ async def index(): assert (await test.get("/")).message == "test" -@pytest.mark.asyncio def test_custom_loader_errors(): app = new_app() app.config.app.loader = "custom" @@ -112,7 +114,7 @@ def test_custom_loader_errors(): @app.custom_loader def my_loader(app: App, path: Path) -> List[Route]: - return 123 + return 123 # type: ignore with pytest.raises(InvalidCustomLoaderError): app.load() diff --git a/tests/test_status.py b/tests/test_status.py index 31035e8..2a0203a 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -1,7 +1,7 @@ import pytest +from leaks import limit_leaks from view import ERROR_CODES, Error, InvalidStatusError, new_app -from leaks import limit_leaks STATUS_CODES = ( 200, diff --git a/tests/test_templates.py b/tests/test_templates.py index 62ac75d..c49f542 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -4,6 +4,7 @@ from view import markdown, new_app, render, template + @pytest.mark.asyncio async def test_view_rendering(): x = 2 diff --git a/tests/test_websocket.py b/tests/test_websocket.py index acc26e5..dad1e27 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,7 +1,10 @@ import pytest -from view import WebSocket, new_app, websocket, WebSocketExpectError, WebSocketHandshakeError, InvalidRouteError from leaks import limit_leaks +from view import (InvalidRouteError, WebSocket, WebSocketExpectError, + WebSocketHandshakeError, new_app, websocket) + + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_websocket_echo_server(): @@ -22,6 +25,7 @@ async def echo(ws: WebSocket): await ws.send("world") assert (await ws.receive()) == "world" + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_websocket_message_pairs(): @@ -37,7 +41,7 @@ async def back_and_forth(ws: WebSocket): assert message == count count += 1 - app.load([back_and_forth]) + app.load(back_and_forth) async with app.test() as test: async with test.websocket("/") as ws: @@ -102,6 +106,7 @@ async def casts(ws: WebSocket): await ws.send("bar") await ws.send("2") + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_websocket_handshake_errors(): @@ -122,7 +127,7 @@ async def casts(ws: WebSocket): with pytest.raises(WebSocketHandshakeError): await ws.accept() - + with pytest.raises(WebSocketHandshakeError): await ws.close() @@ -136,13 +141,13 @@ async def casts(ws: WebSocket): async with test.websocket("/") as ws: assert (await ws.receive()) == "test" + def test_disallow_body_inputs(): app = new_app() @app.websocket("/") @app.body("foo", str) - async def whatever(ws: WebSocket, foo: str): - ... + async def whatever(ws: WebSocket, foo: str): ... with pytest.raises(InvalidRouteError): - app.load() \ No newline at end of file + app.load() diff --git a/valgrind-python.supp b/valgrind-python.supp new file mode 100644 index 0000000..d3737bf --- /dev/null +++ b/valgrind-python.supp @@ -0,0 +1,490 @@ +# +# This is a valgrind suppression file that should be used when using valgrind. +# +# Here's an example of running valgrind: +# +# cd python/dist/src +# valgrind --tool=memcheck --suppressions=Misc/valgrind-python.supp \ +# ./python -E ./Lib/test/regrtest.py -u gui,network +# +# You must edit Objects/obmalloc.c and uncomment Py_USING_MEMORY_DEBUGGER +# to use the preferred suppressions with address_in_range. +# +# If you do not want to recompile Python, you can uncomment +# suppressions for _PyObject_Free and _PyObject_Realloc. +# +# See Misc/README.valgrind for more information. + +# all tool names: Addrcheck,Memcheck,cachegrind,helgrind,massif +{ + ADDRESS_IN_RANGE/Invalid read of size 4 + Memcheck:Addr4 + fun:address_in_range +} + +{ + ADDRESS_IN_RANGE/Invalid read of size 4 + Memcheck:Value4 + fun:address_in_range +} + +{ + ADDRESS_IN_RANGE/Invalid read of size 8 (x86_64 aka amd64) + Memcheck:Value8 + fun:address_in_range +} + +{ + ADDRESS_IN_RANGE/Conditional jump or move depends on uninitialised value + Memcheck:Cond + fun:address_in_range +} + +# +# Leaks (including possible leaks) +# Hmmm, I wonder if this masks some real leaks. I think it does. +# Will need to fix that. +# + +{ + Suppress leaking the GIL after a fork. + Memcheck:Leak + fun:malloc + fun:PyThread_allocate_lock + fun:PyEval_ReInitThreads +} + +{ + Suppress leaking the autoTLSkey. This looks like it shouldn't leak though. + Memcheck:Leak + fun:malloc + fun:PyThread_create_key + fun:_PyGILState_Init + fun:Py_InitializeEx + fun:Py_Main +} + +{ + Hmmm, is this a real leak or like the GIL? + Memcheck:Leak + fun:malloc + fun:PyThread_ReInitTLS +} + +{ + Handle PyMalloc confusing valgrind (possibly leaked) + Memcheck:Leak + fun:realloc + fun:_PyObject_GC_Resize + fun:COMMENT_THIS_LINE_TO_DISABLE_LEAK_WARNING +} + +{ + Handle PyMalloc confusing valgrind (possibly leaked) + Memcheck:Leak + fun:malloc + fun:_PyObject_GC_New + fun:COMMENT_THIS_LINE_TO_DISABLE_LEAK_WARNING +} + +{ + Handle PyMalloc confusing valgrind (possibly leaked) + Memcheck:Leak + fun:malloc + fun:_PyObject_GC_NewVar + fun:COMMENT_THIS_LINE_TO_DISABLE_LEAK_WARNING +} + +# +# Non-python specific leaks +# + +{ + Handle pthread issue (possibly leaked) + Memcheck:Leak + fun:calloc + fun:allocate_dtv + fun:_dl_allocate_tls_storage + fun:_dl_allocate_tls +} + +{ + Handle pthread issue (possibly leaked) + Memcheck:Leak + fun:memalign + fun:_dl_allocate_tls_storage + fun:_dl_allocate_tls +} + +###{ +### ADDRESS_IN_RANGE/Invalid read of size 4 +### Memcheck:Addr4 +### fun:_PyObject_Free +###} +### +###{ +### ADDRESS_IN_RANGE/Invalid read of size 4 +### Memcheck:Value4 +### fun:_PyObject_Free +###} +### +###{ +### ADDRESS_IN_RANGE/Use of uninitialised value of size 8 +### Memcheck:Addr8 +### fun:_PyObject_Free +###} +### +###{ +### ADDRESS_IN_RANGE/Use of uninitialised value of size 8 +### Memcheck:Value8 +### fun:_PyObject_Free +###} +### +###{ +### ADDRESS_IN_RANGE/Conditional jump or move depends on uninitialised value +### Memcheck:Cond +### fun:_PyObject_Free +###} + +###{ +### ADDRESS_IN_RANGE/Invalid read of size 4 +### Memcheck:Addr4 +### fun:_PyObject_Realloc +###} +### +###{ +### ADDRESS_IN_RANGE/Invalid read of size 4 +### Memcheck:Value4 +### fun:_PyObject_Realloc +###} +### +###{ +### ADDRESS_IN_RANGE/Use of uninitialised value of size 8 +### Memcheck:Addr8 +### fun:_PyObject_Realloc +###} +### +###{ +### ADDRESS_IN_RANGE/Use of uninitialised value of size 8 +### Memcheck:Value8 +### fun:_PyObject_Realloc +###} +### +###{ +### ADDRESS_IN_RANGE/Conditional jump or move depends on uninitialised value +### Memcheck:Cond +### fun:_PyObject_Realloc +###} + +### +### All the suppressions below are for errors that occur within libraries +### that Python uses. The problems to not appear to be related to Python's +### use of the libraries. +### + +{ + Generic ubuntu ld problems + Memcheck:Addr8 + obj:/lib/ld-2.4.so + obj:/lib/ld-2.4.so + obj:/lib/ld-2.4.so + obj:/lib/ld-2.4.so +} + +{ + Generic gentoo ld problems + Memcheck:Cond + obj:/lib/ld-2.3.4.so + obj:/lib/ld-2.3.4.so + obj:/lib/ld-2.3.4.so + obj:/lib/ld-2.3.4.so +} + +{ + DBM problems, see test_dbm + Memcheck:Param + write(buf) + fun:write + obj:/usr/lib/libdb1.so.2 + obj:/usr/lib/libdb1.so.2 + obj:/usr/lib/libdb1.so.2 + obj:/usr/lib/libdb1.so.2 + fun:dbm_close +} + +{ + DBM problems, see test_dbm + Memcheck:Value8 + fun:memmove + obj:/usr/lib/libdb1.so.2 + obj:/usr/lib/libdb1.so.2 + obj:/usr/lib/libdb1.so.2 + obj:/usr/lib/libdb1.so.2 + fun:dbm_store + fun:dbm_ass_sub +} + +{ + DBM problems, see test_dbm + Memcheck:Cond + obj:/usr/lib/libdb1.so.2 + obj:/usr/lib/libdb1.so.2 + obj:/usr/lib/libdb1.so.2 + fun:dbm_store + fun:dbm_ass_sub +} + +{ + DBM problems, see test_dbm + Memcheck:Cond + fun:memmove + obj:/usr/lib/libdb1.so.2 + obj:/usr/lib/libdb1.so.2 + obj:/usr/lib/libdb1.so.2 + obj:/usr/lib/libdb1.so.2 + fun:dbm_store + fun:dbm_ass_sub +} + +{ + GDBM problems, see test_gdbm + Memcheck:Param + write(buf) + fun:write + fun:gdbm_open + +} + +{ + Uninitialised byte(s) false alarm, see bpo-35561 + Memcheck:Param + epoll_ctl(event) + fun:epoll_ctl + fun:pyepoll_internal_ctl +} + +{ + ZLIB problems, see test_gzip + Memcheck:Cond + obj:/lib/libz.so.1.2.3 + obj:/lib/libz.so.1.2.3 + fun:deflate +} + +{ + Avoid problems w/readline doing a putenv and leaking on exit + Memcheck:Leak + fun:malloc + fun:xmalloc + fun:sh_set_lines_and_columns + fun:_rl_get_screen_size + fun:_rl_init_terminal_io + obj:/lib/libreadline.so.4.3 + fun:rl_initialize +} + +# Valgrind emits "Conditional jump or move depends on uninitialised value(s)" +# false alarms on GCC builtin strcmp() function. The GCC code is correct. +# +# Valgrind bug: https://bugs.kde.org/show_bug.cgi?id=264936 +{ + bpo-38118: Valgrind emits false alarm on GCC builtin strcmp() + Memcheck:Cond + fun:PyUnicode_Decode +} + + +### +### These occur from somewhere within the SSL, when running +### test_socket_sll. They are too general to leave on by default. +### +###{ +### somewhere in SSL stuff +### Memcheck:Cond +### fun:memset +###} +###{ +### somewhere in SSL stuff +### Memcheck:Value4 +### fun:memset +###} +### +###{ +### somewhere in SSL stuff +### Memcheck:Cond +### fun:MD5_Update +###} +### +###{ +### somewhere in SSL stuff +### Memcheck:Value4 +### fun:MD5_Update +###} + +# Fedora's package "openssl-1.0.1-0.1.beta2.fc17.x86_64" on x86_64 +# See http://bugs.python.org/issue14171 +{ + openssl 1.0.1 prng 1 + Memcheck:Cond + fun:bcmp + fun:fips_get_entropy + fun:FIPS_drbg_instantiate + fun:RAND_init_fips + fun:OPENSSL_init_library + fun:SSL_library_init + fun:init_hashlib +} + +{ + openssl 1.0.1 prng 2 + Memcheck:Cond + fun:fips_get_entropy + fun:FIPS_drbg_instantiate + fun:RAND_init_fips + fun:OPENSSL_init_library + fun:SSL_library_init + fun:init_hashlib +} + +{ + openssl 1.0.1 prng 3 + Memcheck:Value8 + fun:_x86_64_AES_encrypt_compact + fun:AES_encrypt +} + +# +# All of these problems come from using test_socket_ssl +# +{ + from test_socket_ssl + Memcheck:Cond + fun:BN_bin2bn +} + +{ + from test_socket_ssl + Memcheck:Cond + fun:BN_num_bits_word +} + +{ + from test_socket_ssl + Memcheck:Value4 + fun:BN_num_bits_word +} + +{ + from test_socket_ssl + Memcheck:Cond + fun:BN_mod_exp_mont_word +} + +{ + from test_socket_ssl + Memcheck:Cond + fun:BN_mod_exp_mont +} + +{ + from test_socket_ssl + Memcheck:Param + write(buf) + fun:write + obj:/usr/lib/libcrypto.so.0.9.7 +} + +{ + from test_socket_ssl + Memcheck:Cond + fun:RSA_verify +} + +{ + from test_socket_ssl + Memcheck:Value4 + fun:RSA_verify +} + +{ + from test_socket_ssl + Memcheck:Value4 + fun:DES_set_key_unchecked +} + +{ + from test_socket_ssl + Memcheck:Value4 + fun:DES_encrypt2 +} + +{ + from test_socket_ssl + Memcheck:Cond + obj:/usr/lib/libssl.so.0.9.7 +} + +{ + from test_socket_ssl + Memcheck:Value4 + obj:/usr/lib/libssl.so.0.9.7 +} + +{ + from test_socket_ssl + Memcheck:Cond + fun:BUF_MEM_grow_clean +} + +{ + from test_socket_ssl + Memcheck:Cond + fun:memcpy + fun:ssl3_read_bytes +} + +{ + from test_socket_ssl + Memcheck:Cond + fun:SHA1_Update +} + +{ + from test_socket_ssl + Memcheck:Value4 + fun:SHA1_Update +} + +{ + test_buffer_non_debug + Memcheck:Addr4 + fun:PyUnicodeUCS2_FSConverter +} + +{ + test_buffer_non_debug + Memcheck:Addr4 + fun:PyUnicode_FSConverter +} + +{ + wcscmp_false_positive + Memcheck:Addr8 + fun:wcscmp + fun:_PyOS_GetOpt + fun:Py_Main + fun:main +} + +# Additional suppressions for the unified decimal tests: +{ + test_decimal + Memcheck:Addr4 + fun:PyUnicodeUCS2_FSConverter +} + +{ + test_decimal2 + Memcheck:Addr4 + fun:PyUnicode_FSConverter +}