From a3469df8b578cc2c439605bbc64d55e6885bc505 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 09:30:16 -0400 Subject: [PATCH 01/23] Update typing.py --- src/view/typing.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/view/typing.py b/src/view/typing.py index e00224e..a2c5098 100644 --- a/src/view/typing.py +++ b/src/view/typing.py @@ -44,17 +44,18 @@ List[RawResponseHeader], Tuple[RawResponseHeader, ...], ] - -_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] +ResponseBody = Union[str, bytes] + +_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): From b67959af46dbb66b9638cb699afe042ee0314fcc Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 09:32:47 -0400 Subject: [PATCH 02/23] Update results.c --- src/_view/results.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/_view/results.c b/src/_view/results.c index 912f7c7..4f452b5 100644 --- a/src/_view/results.c +++ b/src/_view/results.c @@ -128,10 +128,14 @@ static int find_result_for( if (PyErr_Occurred()) { return -1; } + } if (Py_IS_TYPE(target, &PyBytes_Type)) { + const char* tmp = PyBytes_AsString(target); + if (!tmp) return -1; + *res_str = strdup(tmp); } 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; } @@ -261,4 +265,4 @@ int handle_result( Py_DECREF(args); return res; -} \ No newline at end of file +} From 201cec801e3e0829b1d50d7f44c8d48fcf7a7f68 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 09:38:26 -0400 Subject: [PATCH 03/23] Update app.py --- src/view/app.py | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 90afd33..c030281 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -116,11 +116,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 = bytes + + @property + def message(self) -> str: + if not self._message: + raise RuntimeError("cannot decode content into string") + + return self._message def _format_qs(query: dict[str, Any]) -> dict[str, Any]: @@ -244,7 +258,7 @@ async def send(obj: dict[str, Any]): ) ) elif obj["type"] == "http.response.body": - await body_q.put(obj["body"].decode()) + await body_q.put(obj["body"]) else: raise ViewInternalError(f"bad type: {obj['type']}") @@ -272,9 +286,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() + + try: + body_s = body_b.decode() + except UnicodeError: + body_s = None - return TestingResponse(body_s, res_headers, status) + return TestingResponse(body_s, res_headers, status, body_b) async def get( self, From d7127026040481d64e0528ab15abe9632c1433b6 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 09:39:27 -0400 Subject: [PATCH 04/23] Update app.py --- src/view/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/app.py b/src/view/app.py index c030281..18a7594 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -289,7 +289,7 @@ async def send(obj: dict[str, Any]): body_b = await body_q.get() try: - body_s = body_b.decode() + body_s: str | None = body_b.decode() except UnicodeError: body_s = None From 25e04ad0d52916a1c0d66c9243e3202cb6a17347 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 09:48:43 -0400 Subject: [PATCH 05/23] Update response.py --- src/view/response.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/view/response.py b/src/view/response.py index 5418537..3bb06fa 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, SameSite, ViewResult, ResponseBody from .util import timestamp T = TypeVar("T") @@ -191,7 +191,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 +202,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 From b2e48df7b6f4c386974d06d2df7608a98f4e5c01 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 09:51:05 -0400 Subject: [PATCH 06/23] Update responses.md --- docs/building-projects/responses.md | 46 ++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 14 deletions(-) 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"}`) From 34d7e3805875375fbcdf88e41ef5c469cb5313c9 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 09:54:20 -0400 Subject: [PATCH 07/23] Update results.c --- src/_view/results.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/_view/results.c b/src/_view/results.c index 4f452b5..397d6a9 100644 --- a/src/_view/results.c +++ b/src/_view/results.c @@ -18,6 +18,10 @@ 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 @@ -128,10 +132,6 @@ static int find_result_for( if (PyErr_Occurred()) { return -1; } - } if (Py_IS_TYPE(target, &PyBytes_Type)) { - const char* tmp = PyBytes_AsString(target); - if (!tmp) return -1; - *res_str = strdup(tmp); } else { PyErr_SetString( PyExc_TypeError, From 249d227f1c82a406cea709a1529fa7a487257a68 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 09:57:57 -0400 Subject: [PATCH 08/23] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f041fd..391c075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ 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`. From a8e125286fbb6243c4fcf65ef0e645fe92a94f4c Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 10:02:51 -0400 Subject: [PATCH 09/23] Update test_functions.py --- tests/test_functions.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/test_functions.py b/tests/test_functions.py index 1682c99..d504d5c 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -5,7 +5,7 @@ from typing_extensions import Annotated import pytest -from view import App, BadEnvironmentError, TypeValidationError, compile_type, env, get_app, new_app +from view import App, BadEnvironmentError, TypeValidationError, compile_type, env, get_app, new_app, CallNext, to_response from leaks import limit_leaks @limit_leaks("1 MB") @@ -126,4 +126,34 @@ 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"hello", {"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"hello" + 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 == "hello" From ff9f02359a9b92af9f444cd8eecbd6429599e475 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 10:06:26 -0400 Subject: [PATCH 10/23] Update test_functions.py --- tests/test_functions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_functions.py b/tests/test_functions.py index d504d5c..7b64552 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -5,7 +5,8 @@ from typing_extensions import Annotated import pytest -from view import App, BadEnvironmentError, TypeValidationError, compile_type, env, get_app, new_app, CallNext, to_response +from view import App, BadEnvironmentError, TypeValidationError, compile_type, env, get_app, new_app, to_response +from view.typing import CallNext from leaks import limit_leaks @limit_leaks("1 MB") From c63f9599a94322318c8d695532cd0c09a1548234 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 10:12:03 -0400 Subject: [PATCH 11/23] Update app.py --- src/view/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/app.py b/src/view/app.py index 18a7594..7f780c4 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -131,7 +131,7 @@ def __init__( @property def message(self) -> str: - if not self._message: + if self._message is None: raise RuntimeError("cannot decode content into string") return self._message From 02dad76fad492e5a31b17bd7de2fbc707cd39e83 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 10:16:44 -0400 Subject: [PATCH 12/23] Update test_app.py --- tests/test_app.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_app.py b/tests/test_app.py index 8f0dbbb..885cd69 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -840,4 +840,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"\t \e \s \t" + + @app.get("/hi") + async def hi(): + return b"hi", 201, {"test": "test"} + + async with app.test() as test: + assert (await test.get("/")).content == b"\t \e \s \t" + assert (await test.get("/hi")).content == b"hi" From 4ea6d86957e2d3f1e6921626f71a6e23eda23458 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 10:17:21 -0400 Subject: [PATCH 13/23] Update test_functions.py --- tests/test_functions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_functions.py b/tests/test_functions.py index 7b64552..a2e549c 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -137,7 +137,7 @@ async def index(): @app.get("/bytes") async def other(): - return b"hello", {"hello": "world"} + return b"test", {"hello": "world"} @index.middleware async def middleware(call_next: CallNext): @@ -151,10 +151,10 @@ async def middleware(call_next: CallNext): @other.middleware async def other_middleware(call_next: CallNext): res = to_response(await call_next()) - assert res.body == b"hello" + 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 == "hello" + assert (await test.get("/bytes")).message == "test" From 8e3154afa990fdc835161733414b73c1805513b2 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 10:20:37 -0400 Subject: [PATCH 14/23] Update test_app.py --- tests/test_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 885cd69..ae6aca7 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -850,12 +850,12 @@ async def test_bytes_response(): @app.get("/") async def index(): - return b"\t \e \s \t" + return b"\t \t" @app.get("/hi") async def hi(): return b"hi", 201, {"test": "test"} async with app.test() as test: - assert (await test.get("/")).content == b"\t \e \s \t" + assert (await test.get("/")).content == b"\t \t" assert (await test.get("/hi")).content == b"hi" From 676c0b7b19a96734901de5f60850d44d273c6ca4 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 10:25:59 -0400 Subject: [PATCH 15/23] Update response.py --- src/view/response.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/view/response.py b/src/view/response.py index 3bb06fa..a44a055 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -116,7 +116,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": @@ -225,5 +228,5 @@ def to_response(result: ViewResult) -> Response[ResponseBody]: res._raw_headers = raw_headers return res - assert isinstance(result, str) + assert isinstance(result, (str, bytes)) return Response(result) From 1da3ba8965e102d4f6c536a95c002b2dc865b95d Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 10:27:47 -0400 Subject: [PATCH 16/23] Update test_loaders.py --- tests/test_loaders.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_loaders.py b/tests/test_loaders.py index d8f2d77..297d0e6 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -102,7 +102,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" From 65009b8e54ebe9dd294f7320f841adb621c5f14a Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 10:40:51 -0400 Subject: [PATCH 17/23] Update app.py --- src/view/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/view/app.py b/src/view/app.py index 7f780c4..06d7b2a 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -239,7 +239,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(): @@ -258,6 +258,7 @@ async def send(obj: dict[str, Any]): ) ) elif obj["type"] == "http.response.body": + assert isinstance(obj["body"], bytes) await body_q.put(obj["body"]) else: raise ViewInternalError(f"bad type: {obj['type']}") From 31f04cb63bf86ba3d24dfde2c991cadff0b9e11b Mon Sep 17 00:00:00 2001 From: ZeroIntensity Date: Tue, 28 May 2024 16:02:29 -0400 Subject: [PATCH 18/23] run formatter and fix typing --- src/_view/results.c | 15 ++- src/view/__init__.py | 4 +- src/view/__main__.py | 3 +- src/view/_loader.py | 65 +++++------ src/view/_logging.py | 12 +- src/view/_util.py | 8 +- src/view/app.py | 124 +++++++++++---------- src/view/build.py | 46 +++++--- src/view/config.py | 17 ++- src/view/databases.py | 42 +++---- src/view/default_page.py | 1 + src/view/exceptions.py | 4 + src/view/patterns.py | 14 ++- src/view/response.py | 4 +- src/view/routing.py | 46 ++++---- src/view/typing.py | 8 +- src/view/util.py | 5 +- src/view/ws.py | 56 ++++------ tests/buildscripts/failing_build_script.py | 2 +- tests/buildscripts/failing_req.py | 5 +- tests/buildscripts/my_build_script.py | 3 +- tests/buildscripts/req.py | 3 +- tests/leaks.py | 4 +- tests/test_app.py | 103 ++++++----------- tests/test_build.py | 27 ++--- tests/test_functions.py | 21 +++- tests/test_loaders.py | 14 ++- tests/test_templates.py | 1 + tests/test_websocket.py | 20 +++- 29 files changed, 345 insertions(+), 332 deletions(-) diff --git a/src/_view/results.c b/src/_view/results.c index 397d6a9..3f4ba86 100644 --- a/src/_view/results.c +++ b/src/_view/results.c @@ -18,7 +18,10 @@ 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)) { + } else if (Py_IS_TYPE( + target, + &PyBytes_Type + )) { const char* tmp = PyBytes_AsString(target); if (!tmp) return -1; *res_str = strdup(tmp); @@ -172,6 +175,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 )) { @@ -258,7 +265,11 @@ int handle_result( method ); - if (!PyObject_Call(route_log, args, NULL)) { + if (!PyObject_Call( + route_log, + args, + NULL + )) { Py_DECREF(args); return -1; } diff --git a/src/view/__init__.py b/src/view/__init__.py index 24e0037..88e723b 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 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..442f3bc 100644 --- a/src/view/_loader.py +++ b/src/view/_loader.py @@ -5,8 +5,15 @@ import warnings from dataclasses import _MISSING_TYPE, Field, dataclass from pathlib import Path -from typing import (TYPE_CHECKING, ForwardRef, Iterable, NamedTuple, TypedDict, - get_args, get_type_hints) +from typing import ( + TYPE_CHECKING, + ForwardRef, + Iterable, + NamedTuple, + TypedDict, + get_args, + get_type_hints, +) from _view import Context @@ -16,19 +23,24 @@ 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, - InvalidRouteError, LoaderWarning, - UnknownBuildStepError, ViewInternalError) -from .routing import (BodyParam, Method, Route, RouteData, RouteInput, - _NoDefault) +from .exceptions import ( + DuplicateRouteError, + InvalidBodyError, + InvalidRouteError, + LoaderWarning, + UnknownBuildStepError, + ViewInternalError, +) +from .routing import BodyParam, Method, Route, RouteData, RouteInput, _NoDefault from .typing import Any, RouteInputDict, TypeInfo, ValueType ExtNotRequired: Any = None @@ -38,7 +50,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 +204,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 +233,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 +358,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 +372,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 +412,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 +440,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 +449,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 +473,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 +488,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 +582,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 +635,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..cf99c64 100644 --- a/src/view/_logging.py +++ b/src/view/_logging.py @@ -826,9 +826,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 +870,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 +888,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..c0705f0 100644 --- a/src/view/_util.py +++ b/src/view/_util.py @@ -56,9 +56,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 +73,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 06d7b2a..e777fc3 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,33 +19,60 @@ 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 from .__main__ import welcome from ._docs import markdown_docs from ._loader import finalize, load_fs, load_patterns, load_simple -from ._logging import (LOGS, Internal, Service, enter_server, exit_server, - format_warnings) +from ._logging import ( + LOGS, + Internal, + Service, + enter_server, + exit_server, + format_warnings, +) from ._parsers import supply_parsers 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 -from .routing import (Route, RouteInput, RouteOrCallable, RouteOrWebsocket, V, - _NoDefault, _NoDefaultType) +from .routing import ( + Route, + RouteInput, + RouteOrCallable, + RouteOrWebsocket, + V, + _NoDefault, + _NoDefaultType, +) from .routing import body as body_impl from .routing import context as context_impl from .routing import delete, get, options, patch, post, put @@ -64,9 +92,7 @@ T = TypeVar("T") P = ParamSpec("P") -_ROUTES_WARN_MSG = ( - "routes argument should only be passed when load strategy is manual" -) +_ROUTES_WARN_MSG = "routes argument should only be passed when load strategy is manual" _ConfigSpecified = None B = TypeVar("B", bound=BaseException) @@ -122,13 +148,13 @@ def __init__( message: str | None, headers: dict[str, str], status: int, - content: bytes + content: bytes, ) -> None: self._message = message self.headers = headers self.status = status - self.content = bytes - + self.content = content + @property def message(self) -> str: if self._message is None: @@ -176,9 +202,7 @@ async def _server_send(self, data: dict): self.send_queue.put_nowait(data) async def send(self, message: str) -> None: - self.recv_queue.put_nowait( - {"type": "websocket.receive", "text": message} - ) + self.recv_queue.put_nowait({"type": "websocket.receive", "text": message}) async def receive(self) -> str: data = await _to_thread(self.send_queue.get) @@ -193,9 +217,7 @@ async def receive(self) -> str: return msg async def handshake(self) -> None: - assert (await _to_thread(self.send_queue.get))[ - "type" - ] == "websocket.accept" + assert (await _to_thread(self.send_queue.get))["type"] == "websocket.accept" class TestingContext: @@ -211,7 +233,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) @@ -219,12 +241,9 @@ async def send(obj: dict[str, Any]): async def stop(self) -> None: await self._lifespan.put("lifespan.shutdown") - def _gen_headers( - self, headers: dict[str, str] - ) -> list[tuple[bytes, bytes]]: + def _gen_headers(self, headers: dict[str, str]) -> list[tuple[bytes, bytes]]: return [ - (key.encode(), value.encode()) - for key, value in (headers or {}).items() + (key.encode(), value.encode()) for key, value in (headers or {}).items() ] def _truncate(self, route: str) -> str: @@ -270,7 +289,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"" @@ -459,9 +477,7 @@ def __init__(self, status: int = 400, message: str | None = None) -> None: message: The (optional) message to send back to the client. If none, uses the default error message (e.g. `Bad Request` for status `400`). """ if status not in ERROR_CODES: - raise InvalidStatusError( - "status code can only be a client or server error" - ) + raise InvalidStatusError("status code can only be a client or server error") self.status = status self.message = message @@ -525,9 +541,7 @@ def _hook(tp: type[B], value: B, traceback: Traceback) -> None: print(value.hint) if isinstance(value, ViewInternalError): - print( - "[bold dim red]This is an internal error, not your fault![/]" - ) + print("[bold dim red]This is an internal error, not your fault![/]") print( "[bold dim red]Please report this at https://github.com/ZeroIntensity/view.py/issues[/]" ) @@ -550,9 +564,7 @@ def _finalize(self) -> None: if self.loaded: return - warnings.warn( - "load() was never called (did you forget to start the app?)" - ) + warnings.warn("load() was never called (did you forget to start the app?)") split = self.config.app.app_path.split(":", maxsplit=1) if len(split) != 2: @@ -619,7 +631,7 @@ def inner(r: RouteOrCallable[P]) -> Route[P]: def custom_loader(self, loader: CustomLoader): self._user_loader = loader - + def _method_wrapper( self, path: str, @@ -684,9 +696,7 @@ async def index(): app.run() ``` """ - return self._method_wrapper( - path, doc, cache_rate, get, steps, parallel_build - ) + return self._method_wrapper(path, doc, cache_rate, get, steps, parallel_build) def post( self, @@ -831,9 +841,7 @@ async def index(): app.run() ``` """ - return self._method_wrapper( - path, doc, cache_rate, put, steps, parallel_build - ) + return self._method_wrapper(path, doc, cache_rate, put, steps, parallel_build) def options( self, @@ -922,9 +930,7 @@ def query( """ def inner(func: RouteOrCallable[P]) -> Route[P]: - route: Route[P] = query_impl(name, *tps, doc=doc, default=default)( - func - ) + route: Route[P] = query_impl(name, *tps, doc=doc, default=default)(func) self._push_route(route) return route @@ -947,9 +953,7 @@ def body( """ def inner(func: RouteOrCallable[P]) -> Route[P]: - route: Route[P] = body_impl(name, *tps, doc=doc, default=default)( - func - ) + route: Route[P] = body_impl(name, *tps, doc=doc, default=default)(func) self._push_route(route) return route @@ -973,9 +977,7 @@ async def template( else: f = frame # type: ignore - return await template( - name, directory, engine, f, app=self, **parameters - ) + return await template(name, directory, engine, f, app=self, **parameters) async def markdown( self, @@ -1007,7 +1009,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: @@ -1031,13 +1033,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) + + collected = self._user_loader(self, self.config.app.loader_path) if not isinstance(routes, 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) @@ -1218,9 +1220,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") @@ -1349,8 +1349,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..fba21d4 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 @@ -22,9 +22,14 @@ import platform from .config import BuildStep, Platform -from .exceptions import (BuildError, BuildWarning, MissingRequirementError, - PlatformNotSupportedError, UnknownBuildStepError, - ViewInternalError) +from .exceptions import ( + BuildError, + BuildWarning, + MissingRequirementError, + PlatformNotSupportedError, + UnknownBuildStepError, + ViewInternalError, +) from .response import to_response __all__ = "build_steps", "build_app" @@ -160,7 +165,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 +177,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 +189,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 +216,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 +317,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 +326,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 +338,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 +395,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..7d95146 100644 --- a/src/view/databases.py +++ b/src/view/databases.py @@ -4,8 +4,7 @@ from abc import ABC, abstractmethod from datetime import datetime from enum import Enum -from typing import (Any, ClassVar, Set, TypeVar, Union, get_origin, - get_type_hints) +from typing import Any, ClassVar, Set, TypeVar, Union, get_origin, get_type_hints from typing_extensions import Annotated, Self, dataclass_transform, get_args @@ -40,24 +39,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] = { @@ -166,11 +160,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 @@ -308,30 +300,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/patterns.py b/src/view/patterns.py index edb8845..72226f7 100644 --- a/src/view/patterns.py +++ b/src/view/patterns.py @@ -2,8 +2,18 @@ from ._util import run_path from .exceptions import DuplicateRouteError, InvalidRouteError -from .routing import (Callable, Method, Route, RouteOrCallable, delete, get, - options, patch, post, put) +from .routing import ( + Callable, + Method, + Route, + RouteOrCallable, + delete, + get, + options, + patch, + post, + put, +) from .routing import route as route_impl from .typing import StrMethod, ViewRoute diff --git a/src/view/response.py b/src/view/response.py index a44a055..85d4437 100644 --- a/src/view/response.py +++ b/src/view/response.py @@ -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( diff --git a/src/view/routing.py b/src/view/routing.py index ba08df9..898ddbf 100644 --- a/src/view/routing.py +++ b/src/view/routing.py @@ -8,8 +8,17 @@ from contextlib import suppress from dataclasses import dataclass, field from enum import Enum -from typing import (Any, Callable, Generic, Iterable, Literal, Type, TypeVar, - Union, overload) +from typing import ( + Any, + Callable, + Generic, + Iterable, + Literal, + Type, + TypeVar, + Union, + overload, +) from typing_extensions import ParamSpec, TypeAlias @@ -17,8 +26,16 @@ from ._util import LoadChecker, make_hint from .build import run_step from .exceptions import InvalidRouteError, MistakeError -from .typing import (TYPE_CHECKING, Middleware, StrMethod, Validator, - ValueType, ViewResult, ViewRoute, WebSocketRoute) +from .typing import ( + TYPE_CHECKING, + Middleware, + StrMethod, + Validator, + ValueType, + ViewResult, + ViewRoute, + WebSocketRoute, +) if TYPE_CHECKING: from .app import App @@ -205,8 +222,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 +254,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 +626,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 +730,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 a2c5098..1a4c2dc 100644 --- a/src/view/typing.py +++ b/src/view/typing.py @@ -59,8 +59,7 @@ class SupportsViewResult(Protocol): - def __view_result__(self) -> ViewResult: - ... + def __view_result__(self) -> ViewResult: ... ViewResult = Union[ @@ -76,7 +75,7 @@ def __view_result__(self) -> ViewResult: _ViewResponseTupleJ, str, SupportsViewResult, - None + None, ] P = ParamSpec("P") V = TypeVar("V", bound="ValueType") @@ -116,8 +115,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..8c2699b 100644 --- a/src/view/ws.py +++ b/src/view/ws.py @@ -23,28 +23,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 +64,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 +82,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 +103,7 @@ async def pair( *, tp: type[str] = str, recv_first: bool = False, - ) -> str: - ... + ) -> str: ... @overload async def pair( @@ -124,8 +112,7 @@ async def pair( *, tp: type[bytes] = bytes, recv_first: bool = False, - ) -> bytes: - ... + ) -> bytes: ... @overload async def pair( @@ -134,8 +121,7 @@ async def pair( *, tp: type[int] = int, recv_first: bool = False, - ) -> int: - ... + ) -> int: ... @overload async def pair( @@ -144,8 +130,7 @@ async def pair( *, tp: type[dict] = dict, recv_first: bool = False, - ) -> dict: - ... + ) -> dict: ... @overload async def pair( @@ -154,8 +139,7 @@ async def pair( *, tp: type[bool] = bool, recv_first: bool = False, - ) -> bool: - ... + ) -> bool: ... async def pair( self, @@ -182,9 +166,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 +196,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..19e6922 100644 --- a/tests/leaks.py +++ b/tests/leaks.py @@ -2,6 +2,7 @@ from typing import Callable import pytest + def limit_leaks(memstring: str): def decorator(func: Callable): if platform.system() != "Windows": @@ -9,4 +10,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 ae6aca7..c3d64a9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -6,12 +6,12 @@ from typing_extensions import NotRequired import pytest -from view import (JSON, BodyParam, Context, Response, body, context, get, - new_app, query) +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") async def test_reponses(): @@ -20,10 +20,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 +33,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 +54,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 +70,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 +94,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(): @@ -124,18 +129,15 @@ async def multi(status: int, name: str): async with app.test() as test: assert (await test.get("/", body={"name": "hi"})).message == "hi" assert (await test.get("/status", body={"status": 404})).status == 404 - assert ( - await test.get("/status", body={"status": "hi"}) - ).status == 400 # noqa + assert (await test.get("/status", body={"status": "hi"})).status == 400 # noqa assert (await test.get("/union", body={"test": "a"})).status == 400 - assert ( - await test.get("/union", body={"test": "true"}) - ).message == "1" # noqa + assert (await test.get("/union", body={"test": "true"})).message == "1" # noqa assert (await test.get("/union", body={"test": "2"})).message == "2" res = await test.get("/multi", body={"status": 404, "name": "test"}) assert res.status == 404 assert res.message == "test" + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_query_type_validation(): @@ -170,18 +172,15 @@ async def multi(status: int, name: str): async with app.test() as test: assert (await test.get("/", query={"name": "hi"})).message == "hi" assert (await test.get("/status", query={"status": 404})).status == 404 - assert ( - await test.get("/status", query={"status": "hi"}) - ).status == 400 # noqa + assert (await test.get("/status", query={"status": "hi"})).status == 400 # noqa assert (await test.get("/union", query={"test": "a"})).status == 400 - assert ( - await test.get("/union", query={"test": "true"}) - ).message == "1" # noqa + assert (await test.get("/union", query={"test": "true"})).message == "1" # noqa assert (await test.get("/union", query={"test": "2"})).message == "2" res = await test.get("/multi", query={"status": 404, "name": "test"}) assert res.status == 404 assert res.message == "test" + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_queries_directly_from_app_and_body(): @@ -199,11 +198,10 @@ async def body_route(name: str): async with app.test() as test: assert (await test.get("/", query={"name": "test"})).message == "test" - 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 +218,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(): @@ -316,53 +315,35 @@ async def nested(data: NestedA): async with app.test() as test: assert ( - await test.get( - "/td", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}} - ) + await test.get("/td", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}}) ).message == "hello" assert ( - await test.get( - "/dc", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}} - ) + await test.get("/dc", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}}) ).message == "hello" assert ( - await test.get( - "/pd", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}} - ) + await test.get("/pd", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}}) ).message == "world" assert ( - await test.get( - "/nd", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}} - ) + await test.get("/nd", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}}) ).message == "foo" assert ( - await test.get( - "/pd", query={"data": {"a": "1", "b": 2, "c": {"3": "4"}}} - ) + await test.get("/pd", query={"data": {"a": "1", "b": 2, "c": {"3": "4"}}}) ).status == 200 assert ( await test.get("/vb", query={"data": {"hello": "world"}}) ).message == "yay" + assert (await test.get("/vb", query={"data": {"hello": 2}})).status == 400 assert ( - await test.get("/vb", query={"data": {"hello": 2}}) - ).status == 400 - assert ( - await test.get( - "/vb", query={"data": {"hello": "world", "world": {}}} - ) + await test.get("/vb", query={"data": {"hello": "world", "world": {}}}) ).status == 400 assert ( - await test.get( - "/nested", query={"data": {"a": {"b": {"c": "hello"}}}} - ) + await test.get("/nested", query={"data": {"a": {"b": {"c": "hello"}}}}) ).message == "hello" assert ( await test.get("/nested", query={"data": {"a": {"b": {"c": 1}}}}) ).message == "hello" assert ( - await test.get( - "/dc", query={"data": {"a": "1", "b": True, "c": {"3": 4}}} - ) + await test.get("/dc", query={"data": {"a": "1", "b": True, "c": {"3": 4}}}) ).status == 400 @@ -398,8 +379,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("/") @@ -453,12 +434,8 @@ async def nested(test: A): async with app.test() as test: assert (await test.get("/", query={"test": [1, 2, 3]})).message == "1" - assert ( - await test.get("/union", query={"test": [1, "2", 3]}) - ).message == "1" - assert ( - await test.get("/", query={"test": [1, "2", True]}) - ).status == 400 + assert (await test.get("/union", query={"test": [1, "2", 3]})).message == "1" + assert (await test.get("/", query={"test": [1, "2", True]})).status == 400 assert ( await test.get("/dict", query={"test": {"a": ["1", "2", "3"]}}) ).message == "1" @@ -596,7 +573,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(): @@ -661,9 +638,7 @@ async def cookies(ctx: Context): return ctx.cookies["hello"] async with app.test() as test: - assert ( - await test.get("/", headers={"hello": "world"}) - ).message == "world" + assert (await test.get("/", headers={"hello": "world"})).message == "world" assert (await test.get("/scheme")).message == "http" assert (await test.get("/method")).message == "GET" assert (await test.post("/method")).message == "POST" @@ -687,9 +662,7 @@ async def index(a: str, ctx: Context, c: str): async with app.test() as test: assert ( - await test.get( - "/", query={"a": "a"}, headers={"b": "b"}, body={"c": "c"} - ) + await test.get("/", query={"a": "a"}, headers={"b": "b"}, body={"c": "c"}) ).message == "abc" @@ -736,9 +709,7 @@ async def both(a: str, ctx: Context, b: str): return "hello" @both.middleware - async def both_middleware( - call_next: CallNext, a: str, ctx: Context, b: str - ): + async def both_middleware(call_next: CallNext, a: str, ctx: Context, b: str): assert a + b == "ab" assert ctx.http_version == "view_test" return await call_next() @@ -855,7 +826,7 @@ async def index(): @app.get("/hi") async def hi(): return b"hi", 201, {"test": "test"} - + async with app.test() as test: assert (await test.get("/")).content == b"\t \t" 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 a2e549c..e71b8bb 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -2,12 +2,22 @@ from dataclasses import dataclass from typing import Dict +import pytest +from leaks import limit_leaks from typing_extensions import Annotated -import pytest -from view import App, BadEnvironmentError, TypeValidationError, compile_type, env, get_app, new_app, to_response +from view import ( + App, + BadEnvironmentError, + TypeValidationError, + compile_type, + env, + get_app, + new_app, + to_response, +) from view.typing import CallNext -from leaks import limit_leaks + @limit_leaks("1 MB") def test_app_creation(): @@ -119,7 +129,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" @@ -127,6 +137,7 @@ 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() @@ -154,7 +165,7 @@ async def other_middleware(call_next: CallNext): 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 297d0e6..aa12bbc 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -2,7 +2,19 @@ import pytest from typing import List -from view import delete, get, new_app, options, patch, post, put, App, Route, InvalidCustomLoaderError +from view import ( + delete, + get, + new_app, + options, + patch, + post, + put, + App, + Route, + InvalidCustomLoaderError, +) + @pytest.mark.asyncio async def test_manual_loader(): 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..b14710a 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,7 +1,15 @@ import pytest -from view import WebSocket, new_app, websocket, WebSocketExpectError, WebSocketHandshakeError, InvalidRouteError +from view import ( + WebSocket, + new_app, + websocket, + WebSocketExpectError, + WebSocketHandshakeError, + InvalidRouteError, +) from leaks import limit_leaks + @pytest.mark.asyncio @limit_leaks("1 MB") async def test_websocket_echo_server(): @@ -22,6 +30,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(): @@ -102,6 +111,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 +132,7 @@ async def casts(ws: WebSocket): with pytest.raises(WebSocketHandshakeError): await ws.accept() - + with pytest.raises(WebSocketHandshakeError): await ws.close() @@ -136,13 +146,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() From 9fce81929bf1c9dc5ad99b4a8e6d87210d3bd31c Mon Sep 17 00:00:00 2001 From: ZeroIntensity Date: Tue, 28 May 2024 16:03:43 -0400 Subject: [PATCH 19/23] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 391c075..5dac792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 From 8a7a19e7fbe2990433c95695cfbc2ef35708c8c2 Mon Sep 17 00:00:00 2001 From: ZeroIntensity Date: Tue, 28 May 2024 16:09:06 -0400 Subject: [PATCH 20/23] run formatter again --- src/view/__init__.py | 2 +- src/view/_loader.py | 25 +++-------- src/view/_logging.py | 3 +- src/view/_util.py | 3 +- src/view/app.py | 94 ++++++++++++++++++++--------------------- src/view/build.py | 11 ++--- src/view/databases.py | 14 ++++-- src/view/patterns.py | 14 +----- src/view/response.py | 2 +- src/view/routing.py | 25 ++--------- src/view/typing.py | 18 ++------ src/view/ws.py | 3 +- tests/leaks.py | 1 + tests/test_app.py | 91 ++++++++++++++++++++++++++++----------- tests/test_functions.py | 12 +----- tests/test_loaders.py | 17 ++------ tests/test_status.py | 2 +- tests/test_websocket.py | 11 ++--- 18 files changed, 160 insertions(+), 188 deletions(-) diff --git a/src/view/__init__.py b/src/view/__init__.py index 88e723b..d6a8d0b 100644 --- a/src/view/__init__.py +++ b/src/view/__init__.py @@ -16,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/_loader.py b/src/view/_loader.py index 442f3bc..3ba64e1 100644 --- a/src/view/_loader.py +++ b/src/view/_loader.py @@ -5,15 +5,8 @@ import warnings from dataclasses import _MISSING_TYPE, Field, dataclass from pathlib import Path -from typing import ( - TYPE_CHECKING, - ForwardRef, - Iterable, - NamedTuple, - TypedDict, - get_args, - get_type_hints, -) +from typing import (TYPE_CHECKING, ForwardRef, Iterable, NamedTuple, TypedDict, + get_args, get_type_hints) from _view import Context @@ -32,15 +25,11 @@ def _eval_type(*args) -> Any: ... from ._logging import Internal from ._util import docs_hint, is_annotated, is_union, set_load -from .exceptions import ( - DuplicateRouteError, - InvalidBodyError, - InvalidRouteError, - LoaderWarning, - UnknownBuildStepError, - ViewInternalError, -) -from .routing import BodyParam, Method, Route, RouteData, RouteInput, _NoDefault +from .exceptions import (DuplicateRouteError, InvalidBodyError, + InvalidRouteError, LoaderWarning, + UnknownBuildStepError, ViewInternalError) +from .routing import (BodyParam, Method, Route, RouteData, RouteInput, + _NoDefault) from .typing import Any, RouteInputDict, TypeInfo, ValueType ExtNotRequired: Any = None diff --git a/src/view/_logging.py b/src/view/_logging.py index cf99c64..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 diff --git a/src/view/_util.py b/src/view/_util.py index c0705f0..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 diff --git a/src/view/app.py b/src/view/app.py index e777fc3..a23f0ba 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -19,18 +19,8 @@ from threading import Thread from types import FrameType as Frame from types import TracebackType as Traceback -from typing import ( - Any, - AsyncIterator, - Callable, - Coroutine, - Generic, - Iterable, - TextIO, - TypeVar, - get_type_hints, - overload, -) +from typing import (Any, AsyncIterator, Callable, Coroutine, Generic, Iterable, + TextIO, TypeVar, get_type_hints, overload) from urllib.parse import urlencode import ujson @@ -43,36 +33,19 @@ from .__main__ import welcome from ._docs import markdown_docs from ._loader import finalize, load_fs, load_patterns, load_simple -from ._logging import ( - LOGS, - Internal, - Service, - enter_server, - exit_server, - format_warnings, -) +from ._logging import (LOGS, Internal, Service, enter_server, exit_server, + format_warnings) from ._parsers import supply_parsers from ._util import make_hint, needs_dep from .build import build_app, build_steps from .config import Config, load_config -from .exceptions import ( - BadEnvironmentError, - InvalidCustomLoaderError, - ViewError, - ViewInternalError, -) +from .exceptions import (BadEnvironmentError, InvalidCustomLoaderError, + ViewError, ViewInternalError) from .logging import _LogArgs, log from .response import HTML from .routing import Path as _RouteDeco -from .routing import ( - Route, - RouteInput, - RouteOrCallable, - RouteOrWebsocket, - V, - _NoDefault, - _NoDefaultType, -) +from .routing import (Route, RouteInput, RouteOrCallable, RouteOrWebsocket, V, + _NoDefault, _NoDefaultType) from .routing import body as body_impl from .routing import context as context_impl from .routing import delete, get, options, patch, post, put @@ -92,7 +65,9 @@ T = TypeVar("T") P = ParamSpec("P") -_ROUTES_WARN_MSG = "routes argument should only be passed when load strategy is manual" +_ROUTES_WARN_MSG = ( + "routes argument should only be passed when load strategy is manual" +) _ConfigSpecified = None B = TypeVar("B", bound=BaseException) @@ -202,7 +177,9 @@ async def _server_send(self, data: dict): self.send_queue.put_nowait(data) async def send(self, message: str) -> None: - self.recv_queue.put_nowait({"type": "websocket.receive", "text": message}) + self.recv_queue.put_nowait( + {"type": "websocket.receive", "text": message} + ) async def receive(self) -> str: data = await _to_thread(self.send_queue.get) @@ -217,7 +194,9 @@ async def receive(self) -> str: return msg async def handshake(self) -> None: - assert (await _to_thread(self.send_queue.get))["type"] == "websocket.accept" + assert (await _to_thread(self.send_queue.get))[ + "type" + ] == "websocket.accept" class TestingContext: @@ -241,9 +220,12 @@ async def send(_: dict[str, Any]): async def stop(self) -> None: await self._lifespan.put("lifespan.shutdown") - def _gen_headers(self, headers: dict[str, str]) -> list[tuple[bytes, bytes]]: + def _gen_headers( + self, headers: dict[str, str] + ) -> list[tuple[bytes, bytes]]: return [ - (key.encode(), value.encode()) for key, value in (headers or {}).items() + (key.encode(), value.encode()) + for key, value in (headers or {}).items() ] def _truncate(self, route: str) -> str: @@ -477,7 +459,9 @@ def __init__(self, status: int = 400, message: str | None = None) -> None: message: The (optional) message to send back to the client. If none, uses the default error message (e.g. `Bad Request` for status `400`). """ if status not in ERROR_CODES: - raise InvalidStatusError("status code can only be a client or server error") + raise InvalidStatusError( + "status code can only be a client or server error" + ) self.status = status self.message = message @@ -541,7 +525,9 @@ def _hook(tp: type[B], value: B, traceback: Traceback) -> None: print(value.hint) if isinstance(value, ViewInternalError): - print("[bold dim red]This is an internal error, not your fault![/]") + print( + "[bold dim red]This is an internal error, not your fault![/]" + ) print( "[bold dim red]Please report this at https://github.com/ZeroIntensity/view.py/issues[/]" ) @@ -564,7 +550,9 @@ def _finalize(self) -> None: if self.loaded: return - warnings.warn("load() was never called (did you forget to start the app?)") + warnings.warn( + "load() was never called (did you forget to start the app?)" + ) split = self.config.app.app_path.split(":", maxsplit=1) if len(split) != 2: @@ -696,7 +684,9 @@ async def index(): app.run() ``` """ - return self._method_wrapper(path, doc, cache_rate, get, steps, parallel_build) + return self._method_wrapper( + path, doc, cache_rate, get, steps, parallel_build + ) def post( self, @@ -841,7 +831,9 @@ async def index(): app.run() ``` """ - return self._method_wrapper(path, doc, cache_rate, put, steps, parallel_build) + return self._method_wrapper( + path, doc, cache_rate, put, steps, parallel_build + ) def options( self, @@ -930,7 +922,9 @@ def query( """ def inner(func: RouteOrCallable[P]) -> Route[P]: - route: Route[P] = query_impl(name, *tps, doc=doc, default=default)(func) + route: Route[P] = query_impl(name, *tps, doc=doc, default=default)( + func + ) self._push_route(route) return route @@ -953,7 +947,9 @@ def body( """ def inner(func: RouteOrCallable[P]) -> Route[P]: - route: Route[P] = body_impl(name, *tps, doc=doc, default=default)(func) + route: Route[P] = body_impl(name, *tps, doc=doc, default=default)( + func + ) self._push_route(route) return route @@ -977,7 +973,9 @@ async def template( else: f = frame # type: ignore - return await template(name, directory, engine, f, app=self, **parameters) + return await template( + name, directory, engine, f, app=self, **parameters + ) async def markdown( self, diff --git a/src/view/build.py b/src/view/build.py index fba21d4..78837b5 100644 --- a/src/view/build.py +++ b/src/view/build.py @@ -22,14 +22,9 @@ import platform from .config import BuildStep, Platform -from .exceptions import ( - BuildError, - BuildWarning, - MissingRequirementError, - PlatformNotSupportedError, - UnknownBuildStepError, - ViewInternalError, -) +from .exceptions import (BuildError, BuildWarning, MissingRequirementError, + PlatformNotSupportedError, UnknownBuildStepError, + ViewInternalError) from .response import to_response __all__ = "build_steps", "build_app" diff --git a/src/view/databases.py b/src/view/databases.py index 7d95146..58e8843 100644 --- a/src/view/databases.py +++ b/src/view/databases.py @@ -4,7 +4,8 @@ from abc import ABC, abstractmethod from datetime import datetime from enum import Enum -from typing import Any, ClassVar, Set, TypeVar, Union, get_origin, get_type_hints +from typing import (Any, ClassVar, Set, TypeVar, Union, get_origin, + get_type_hints) from typing_extensions import Annotated, Self, dataclass_transform, get_args @@ -128,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( @@ -279,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(): diff --git a/src/view/patterns.py b/src/view/patterns.py index 72226f7..edb8845 100644 --- a/src/view/patterns.py +++ b/src/view/patterns.py @@ -2,18 +2,8 @@ from ._util import run_path from .exceptions import DuplicateRouteError, InvalidRouteError -from .routing import ( - Callable, - Method, - Route, - RouteOrCallable, - delete, - get, - options, - patch, - post, - put, -) +from .routing import (Callable, Method, Route, RouteOrCallable, delete, get, + options, patch, post, put) from .routing import route as route_impl from .typing import StrMethod, ViewRoute diff --git a/src/view/response.py b/src/view/response.py index 85d4437..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, ResponseBody +from .typing import BodyTranslateStrategy, ResponseBody, SameSite, ViewResult from .util import timestamp T = TypeVar("T") diff --git a/src/view/routing.py b/src/view/routing.py index 898ddbf..78542d0 100644 --- a/src/view/routing.py +++ b/src/view/routing.py @@ -8,17 +8,8 @@ from contextlib import suppress from dataclasses import dataclass, field from enum import Enum -from typing import ( - Any, - Callable, - Generic, - Iterable, - Literal, - Type, - TypeVar, - Union, - overload, -) +from typing import (Any, Callable, Generic, Iterable, Literal, Type, TypeVar, + Union, overload) from typing_extensions import ParamSpec, TypeAlias @@ -26,16 +17,8 @@ from ._util import LoadChecker, make_hint from .build import run_step from .exceptions import InvalidRouteError, MistakeError -from .typing import ( - TYPE_CHECKING, - Middleware, - StrMethod, - Validator, - ValueType, - ViewResult, - ViewRoute, - WebSocketRoute, -) +from .typing import (TYPE_CHECKING, Middleware, StrMethod, Validator, + ValueType, ViewResult, ViewRoute, WebSocketRoute) if TYPE_CHECKING: from .app import App diff --git a/src/view/typing.py b/src/view/typing.py index 1a4c2dc..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 @@ -73,7 +61,7 @@ def __view_result__(self) -> ViewResult: ... _ViewResponseTupleH, _ViewResponseTupleI, _ViewResponseTupleJ, - str, + ResponseBody, SupportsViewResult, None, ] diff --git a/src/view/ws.py b/src/view/ws.py index 8c2699b..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] diff --git a/tests/leaks.py b/tests/leaks.py index 19e6922..ed6da9d 100644 --- a/tests/leaks.py +++ b/tests/leaks.py @@ -1,5 +1,6 @@ import platform from typing import Callable + import pytest diff --git a/tests/test_app.py b/tests/test_app.py index c3d64a9..c36cd13 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,14 +2,15 @@ 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 (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 @@ -129,9 +130,13 @@ async def multi(status: int, name: str): async with app.test() as test: assert (await test.get("/", body={"name": "hi"})).message == "hi" assert (await test.get("/status", body={"status": 404})).status == 404 - assert (await test.get("/status", body={"status": "hi"})).status == 400 # noqa + assert ( + await test.get("/status", body={"status": "hi"}) + ).status == 400 # noqa assert (await test.get("/union", body={"test": "a"})).status == 400 - assert (await test.get("/union", body={"test": "true"})).message == "1" # noqa + assert ( + await test.get("/union", body={"test": "true"}) + ).message == "1" # noqa assert (await test.get("/union", body={"test": "2"})).message == "2" res = await test.get("/multi", body={"status": 404, "name": "test"}) assert res.status == 404 @@ -172,9 +177,13 @@ async def multi(status: int, name: str): async with app.test() as test: assert (await test.get("/", query={"name": "hi"})).message == "hi" assert (await test.get("/status", query={"status": 404})).status == 404 - assert (await test.get("/status", query={"status": "hi"})).status == 400 # noqa + assert ( + await test.get("/status", query={"status": "hi"}) + ).status == 400 # noqa assert (await test.get("/union", query={"test": "a"})).status == 400 - assert (await test.get("/union", query={"test": "true"})).message == "1" # noqa + assert ( + await test.get("/union", query={"test": "true"}) + ).message == "1" # noqa assert (await test.get("/union", query={"test": "2"})).message == "2" res = await test.get("/multi", query={"status": 404, "name": "test"}) assert res.status == 404 @@ -198,8 +207,12 @@ async def body_route(name: str): async with app.test() as test: assert (await test.get("/", query={"name": "test"})).message == "test" - 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" + assert ( + await test.get("/body", body={"name": "test"}) + ).message == "test" @pytest.mark.asyncio @@ -315,35 +328,53 @@ async def nested(data: NestedA): async with app.test() as test: assert ( - await test.get("/td", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}}) + await test.get( + "/td", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}} + ) ).message == "hello" assert ( - await test.get("/dc", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}}) + await test.get( + "/dc", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}} + ) ).message == "hello" assert ( - await test.get("/pd", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}}) + await test.get( + "/pd", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}} + ) ).message == "world" assert ( - await test.get("/nd", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}}) + await test.get( + "/nd", query={"data": {"a": "1", "b": 2, "c": {"3": 4}}} + ) ).message == "foo" assert ( - await test.get("/pd", query={"data": {"a": "1", "b": 2, "c": {"3": "4"}}}) + await test.get( + "/pd", query={"data": {"a": "1", "b": 2, "c": {"3": "4"}}} + ) ).status == 200 assert ( await test.get("/vb", query={"data": {"hello": "world"}}) ).message == "yay" - assert (await test.get("/vb", query={"data": {"hello": 2}})).status == 400 assert ( - await test.get("/vb", query={"data": {"hello": "world", "world": {}}}) + await test.get("/vb", query={"data": {"hello": 2}}) ).status == 400 assert ( - await test.get("/nested", query={"data": {"a": {"b": {"c": "hello"}}}}) + await test.get( + "/vb", query={"data": {"hello": "world", "world": {}}} + ) + ).status == 400 + assert ( + await test.get( + "/nested", query={"data": {"a": {"b": {"c": "hello"}}}} + ) ).message == "hello" assert ( await test.get("/nested", query={"data": {"a": {"b": {"c": 1}}}}) ).message == "hello" assert ( - await test.get("/dc", query={"data": {"a": "1", "b": True, "c": {"3": 4}}}) + await test.get( + "/dc", query={"data": {"a": "1", "b": True, "c": {"3": 4}}} + ) ).status == 400 @@ -434,8 +465,12 @@ async def nested(test: A): async with app.test() as test: assert (await test.get("/", query={"test": [1, 2, 3]})).message == "1" - assert (await test.get("/union", query={"test": [1, "2", 3]})).message == "1" - assert (await test.get("/", query={"test": [1, "2", True]})).status == 400 + assert ( + await test.get("/union", query={"test": [1, "2", 3]}) + ).message == "1" + assert ( + await test.get("/", query={"test": [1, "2", True]}) + ).status == 400 assert ( await test.get("/dict", query={"test": {"a": ["1", "2", "3"]}}) ).message == "1" @@ -638,7 +673,9 @@ async def cookies(ctx: Context): return ctx.cookies["hello"] async with app.test() as test: - assert (await test.get("/", headers={"hello": "world"})).message == "world" + assert ( + await test.get("/", headers={"hello": "world"}) + ).message == "world" assert (await test.get("/scheme")).message == "http" assert (await test.get("/method")).message == "GET" assert (await test.post("/method")).message == "POST" @@ -662,7 +699,9 @@ async def index(a: str, ctx: Context, c: str): async with app.test() as test: assert ( - await test.get("/", query={"a": "a"}, headers={"b": "b"}, body={"c": "c"}) + await test.get( + "/", query={"a": "a"}, headers={"b": "b"}, body={"c": "c"} + ) ).message == "abc" @@ -709,7 +748,9 @@ async def both(a: str, ctx: Context, b: str): return "hello" @both.middleware - async def both_middleware(call_next: CallNext, a: str, ctx: Context, b: str): + async def both_middleware( + call_next: CallNext, a: str, ctx: Context, b: str + ): assert a + b == "ab" assert ctx.http_version == "view_test" return await call_next() @@ -821,12 +862,12 @@ async def test_bytes_response(): @app.get("/") async def index(): - return b"\t \t" + 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"\t \t" + assert (await test.get("/")).content == b"\x09 \x09" assert (await test.get("/hi")).content == b"hi" diff --git a/tests/test_functions.py b/tests/test_functions.py index e71b8bb..be2fd18 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -6,16 +6,8 @@ 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 import (App, BadEnvironmentError, TypeValidationError, compile_type, + env, get_app, new_app, to_response) from view.typing import CallNext diff --git a/tests/test_loaders.py b/tests/test_loaders.py index aa12bbc..456dda5 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -1,19 +1,10 @@ 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 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_websocket.py b/tests/test_websocket.py index b14710a..8024d1b 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,14 +1,9 @@ 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") From 0d30b56f4d4cc91733e1dce4494080e65e49907f Mon Sep 17 00:00:00 2001 From: ZeroIntensity Date: Tue, 28 May 2024 18:48:44 -0400 Subject: [PATCH 21/23] add valgrind tests --- .github/workflows/memory_check.yml | 41 +++ src/_view/results.c | 2 - src/_view/routing.c | 1 - src/view/app.py | 8 +- tests/test_app.py | 2 +- tests/test_loaders.py | 4 +- tests/test_websocket.py | 2 +- valgrind-python.supp | 490 +++++++++++++++++++++++++++++ 8 files changed, 542 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/memory_check.yml create mode 100644 valgrind-python.supp diff --git a/.github/workflows/memory_check.yml b/.github/workflows/memory_check.yml new file mode 100644 index 0000000..b86e042 --- /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 --supressions=valgrind-python.supp --error-exitcode=1 pytest -x diff --git a/src/_view/results.c b/src/_view/results.c index 3f4ba86..075098f 100644 --- a/src/_view/results.c +++ b/src/_view/results.c @@ -88,8 +88,6 @@ static int find_result_for( return -1; }; - Py_DECREF(v_bytes); - if (PyList_Append( headers, header_list 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/app.py b/src/view/app.py index a23f0ba..ef84c22 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1022,6 +1022,12 @@ def load(self, *routes: Route) -> 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": @@ -1033,7 +1039,7 @@ def load(self, *routes: Route) -> None: raise InvalidCustomLoaderError("custom loader was not set") collected = self._user_loader(self, self.config.app.loader_path) - if not isinstance(routes, CollectionsIterable): + if not isinstance(collected, CollectionsIterable): raise InvalidCustomLoaderError( f"expected custom loader to return a list of routes, got {collected!r}" ) diff --git a/tests/test_app.py b/tests/test_app.py index c36cd13..24cc804 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -779,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" diff --git a/tests/test_loaders.py b/tests/test_loaders.py index 456dda5..733d70a 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -36,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" @@ -114,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_websocket.py b/tests/test_websocket.py index 8024d1b..dad1e27 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -41,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: 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 +} From dab49fb153d92642407794bd9fa5b1d4d8edca85 Mon Sep 17 00:00:00 2001 From: ZeroIntensity Date: Tue, 28 May 2024 18:53:41 -0400 Subject: [PATCH 22/23] update refcnts --- src/_view/results.c | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/_view/results.c b/src/_view/results.c index 075098f..f9c13aa 100644 --- a/src/_view/results.c +++ b/src/_view/results.c @@ -70,8 +70,6 @@ static int find_result_for( return -1; }; - Py_DECREF(item_bytes); - PyObject* v_bytes = PyBytes_FromString(v_str); if (!v_bytes) { From 256f9144b4bfdf0f662f2113a8b9a3efb8254135 Mon Sep 17 00:00:00 2001 From: ZeroIntensity Date: Tue, 28 May 2024 19:12:44 -0400 Subject: [PATCH 23/23] fix typo --- .github/workflows/memory_check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/memory_check.yml b/.github/workflows/memory_check.yml index b86e042..262f179 100644 --- a/.github/workflows/memory_check.yml +++ b/.github/workflows/memory_check.yml @@ -38,4 +38,4 @@ jobs: run: sudo apt-get -y install valgrind - name: Run tests with Valgrind - run: valgrind --supressions=valgrind-python.supp --error-exitcode=1 pytest -x + run: valgrind --suppressions=valgrind-python.supp --error-exitcode=1 pytest -x