From c4d0f89c1cc19fda7cb9805e0245a9688ce3a58d Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 9 Sep 2023 23:54:17 +0000 Subject: [PATCH 01/12] type code stuff --- src/_view/app.c | 136 ++++++++++++++++++++++++++++++++++++++--- src/view/_loader.py | 87 +++++++++++++++++++++++--- src/view/_util.py | 35 +---------- src/view/components.py | 2 +- src/view/routing.py | 12 ++-- src/view/typing.py | 35 +++++------ 6 files changed, 230 insertions(+), 77 deletions(-) diff --git a/src/_view/app.c b/src/_view/app.c index 841881b..fd71c1b 100644 --- a/src/_view/app.c +++ b/src/_view/app.c @@ -82,8 +82,18 @@ typedef struct _ViewApp { bool has_path_params; } ViewApp; +typedef struct _type_info type_info; + +struct _type_info { + uint8_t typecode; + PyObject* ob; + type_info** children; + Py_ssize_t children_size; +}; + typedef struct _route_input { - PyObject* type; + type_info** types; + Py_ssize_t types_size; PyObject* df; PyObject** validators; Py_ssize_t validators_size; @@ -2559,6 +2569,94 @@ static inline int bad_input(const char* name) { return -1; } +static void free_type_info(type_info* ti) { + Py_XDECREF(ti->ob); + for (int i = 0; i < ti->children_size; i++) { + free_type_info(ti->children[i]); + } +} + +static type_info** build_type_codes(PyObject* type_codes, Py_ssize_t len) { + type_info** tps = calloc( + sizeof(type_info), + len + ); + + for (Py_ssize_t i = 0; i < len; i++) { + PyObject* info = PyList_GetItem( + type_codes, + i + ); + type_info* ti = malloc(sizeof(type_info)); + + if (!info && ti) { + for (int x = 0; x < i; x++) + free_type_info(tps[x]); + + free(tps); + if (ti) free(ti); + return NULL; + } + + PyObject* type_code = PyTuple_GetItem( + info, + 0 + ); + PyObject* obj = PyTuple_GetItem( + info, + 1 + ); + PyObject* children = PyTuple_GetItem( + info, + 2 + ); + + if (!type_code || !obj || !children) { + for (int x = 0; x < i; x++) + free_type_info(tps[x]); + + free(tps); + return NULL; + } + + Py_ssize_t code = PyLong_AsLong(type_code); + + Py_XINCREF(obj); + ti->ob = obj; + ti->typecode = code; + + Py_ssize_t children_len = PySequence_Size(children); + if (children_len == -1) { + for (int x = 0; x < i; x++) + free_type_info(tps[x]); + + free(tps); + Py_XDECREF(obj); + return NULL; + } + + ti->children_size = children_len; + type_info** children_info = build_type_codes( + children, + children_len + ); + + if (!children_info) { + for (int x = 0; x < i; i++) + free_type_info(tps[x]); + + free(tps); + Py_XDECREF(obj); + return NULL; + } + + ti->children = children_info; + tps[i] = ti; + } + + return tps; +} + static int load( route* r, PyObject* target @@ -2658,19 +2756,41 @@ static int load( Py_DECREF(has_default); - inp->type = Py_XNewRef( - PyDict_GetItemString( - item, - "type" - ) + PyObject* codes = PyDict_GetItemString( + item, + "type_codes" ); - if (!inp->type) { + if (!codes) { Py_DECREF(iter); Py_XDECREF(inp->df); PyMem_Free(r->inputs); PyMem_Free(inp); - return bad_input("type"); + return bad_input("type_codes"); + } + + Py_ssize_t len = PySequence_Size(codes); + if (len == -1) { + Py_DECREF(iter); + Py_XDECREF(inp->df); + PyMem_Free(r->inputs); + PyMem_Free(inp); + return -1; + } + inp->types_size = len; + if (!len) inp->types = NULL; + else { + inp->types = build_type_codes( + codes, + len + ); + if (!inp->types) { + Py_DECREF(iter); + Py_XDECREF(inp->df); + PyMem_Free(r->inputs); + PyMem_Free(inp); + return -1; + } } PyObject* validators = PyDict_GetItemString( diff --git a/src/view/_loader.py b/src/view/_loader.py index 22bb78b..e5daef2 100644 --- a/src/view/_loader.py +++ b/src/view/_loader.py @@ -4,13 +4,13 @@ import runpy import warnings from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Iterable, get_args from ._logging import Internal -from ._util import set_load, validate_body -from .exceptions import LoaderWarning +from ._util import set_load +from .exceptions import InvalidBodyError, LoaderWarning from .routing import Method, Route, RouteInput, _NoDefault -from .typing import RouteInputDict +from .typing import Any, RouteInputDict, TypeInfo, ValueType if TYPE_CHECKING: from .app import ViewApp @@ -18,17 +18,90 @@ __all__ = "load_fs", "load_simple", "finalize" +TYPECODE_ANY = 0 +TYPECODE_STR = 1 +TYPECODE_INT = 2 +TYPECODE_BOOL = 3 +TYPECODE_FLOAT = 4 +TYPECODE_DICT = 5 +TYPECODE_NONE = 6 +TYPECODE_CLASS = 7 + + +_BASIC_CODES = { + str: TYPECODE_STR, + int: TYPECODE_INT, + bool: TYPECODE_BOOL, + float: TYPECODE_FLOAT, + dict: TYPECODE_DICT, + None: TYPECODE_NONE, + Any: TYPECODE_ANY, +} + +""" +Type info should contain three things: + - Type Code + - Type Object (only set when using a __view_body__ object) + - Children (i.e. the `int` part of dict[str, int]) + +This can be formatted as so: + [(union1_tc, None, []), (union2_tc, None, [(type_tc, obj, [])])] +""" + + +def _build_type_codes(inp: Iterable[type[ValueType]]) -> list[TypeInfo]: + if not inp: + return [] + + codes: list[TypeInfo] = [] + + for tp in inp: + type_code = _BASIC_CODES.get(tp) + + if type_code: + codes.append((type_code, None, [])) + continue + + vbody = getattr(tp, "__view_body__", None) + if vbody: + raise NotImplementedError + """ + if callable(vbody): + vbody_types = vbody() + else: + vbody_types = vbody + + codes.append((TYPECODE_CLASS, tp, _build_type_codes(vbody_types))) + """ + + origin = getattr(tp, "__origin__", None) # typing.GenericAlias + + if (not origin) or (origin is not dict): + raise InvalidBodyError(f"{tp} is not a valid type for routes") + + key, value = get_args(tp) + + if key is not str: + raise InvalidBodyError( + f"dictionary keys must be strings, not {key}" + ) + + tp_codes = _build_type_codes(value) + codes.append((TYPECODE_DICT, None, tp_codes)) + + return codes + + def _format_inputs(inputs: list[RouteInput]) -> list[RouteInputDict]: result: list[RouteInputDict] = [] for i in inputs: - if i.tp: - validate_body(i.tp) + type_codes = _build_type_codes(i.tp) result.append( { "name": i.name, - "type": i.tp, + "type_codes": type_codes, "default": i.default, # type: ignore "validators": i.validators, "is_body": i.is_body, diff --git a/src/view/_util.py b/src/view/_util.py index f6ba99c..3ad0ef7 100644 --- a/src/view/_util.py +++ b/src/view/_util.py @@ -12,14 +12,12 @@ from types import FrameType as Frame from types import FunctionType as Function from types import ModuleType as Module -from typing import get_type_hints from rich.panel import Panel from rich.syntax import Syntax from ._logging import Internal -from .exceptions import InvalidBodyError, MissingLibraryError, NotLoadedWarning -from .typing import Any, ViewBody, ViewBodyLike +from .exceptions import MissingLibraryError, NotLoadedWarning class LoadChecker: @@ -119,34 +117,3 @@ def attempt_import(name: str, *, repr_name: str | None = None) -> Module: f"{repr_name or name} is not installed!", hint=shell_hint(f"pip install [bold]{name}[/]"), ) from e - - -_VALID_BODY = {str, int, type, dict, float, bool} - - -def validate_body(v: ViewBody) -> None: - Internal.info(f"validating {v!r}") - if type(v) not in _VALID_BODY: - raise InvalidBodyError( - f"{type(v).__name__} is not a valid type for a body", - ) - - if isinstance(v, dict): - for i in v.values(): - validate_body(get_body(i)) - - if isinstance(v, type): - validate_body(get_body(v)) - - -def get_body(tp: ViewBodyLike | Any) -> ViewBody: - body_attr: ViewBody | None = getattr(tp, "__view_body__", None) - - if body_attr: - if callable(body_attr): - return body_attr() - - assert isinstance(body_attr, dict), "__view_body__ is not a dict" - return body_attr - - return get_type_hints(tp) diff --git a/src/view/components.py b/src/view/components.py index 6e7b1c1..9bb28a9 100644 --- a/src/view/components.py +++ b/src/view/components.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Iterable, Literal +from typing import Any, Literal from typing_extensions import ( NotRequired, diff --git a/src/view/routing.py b/src/view/routing.py index 5fc378c..41c2517 100644 --- a/src/view/routing.py +++ b/src/view/routing.py @@ -31,7 +31,7 @@ class Method(Enum): class RouteInput(Generic[V]): name: str is_body: bool - tp: type[V] | None + tp: tuple[type[V], ...] default: V | None | _NoDefaultType doc: str | None validators: list[Validator[V]] @@ -253,14 +253,13 @@ class _NoDefault: def query( name: str, - tp: type[V] | None = None, + *tps: type[V] | None, doc: str | None = None, - *, default: V | None | _NoDefaultType = _NoDefault, ): def inner(r: RouteOrCallable) -> Route: route = _ensure_route(r) - route.inputs.append(RouteInput(name, False, tp, default, doc, [])) + route.inputs.append(RouteInput(name, False, tps, default, doc, [])) return route return inner @@ -268,14 +267,13 @@ def inner(r: RouteOrCallable) -> Route: def body( name: str, - tp: type[V] | None = None, + *tps: type[V], doc: str | None = None, - *, default: V | None | _NoDefaultType = _NoDefault, ): def inner(r: RouteOrCallable) -> Route: route = _ensure_route(r) - route.inputs.append(RouteInput(name, True, tp, default, doc, [])) + route.inputs.append(RouteInput(name, True, tps, default, doc, [])) return route return inner diff --git a/src/view/typing.py b/src/view/typing.py index 5f7e573..344a656 100644 --- a/src/view/typing.py +++ b/src/view/typing.py @@ -1,16 +1,7 @@ from __future__ import annotations -from typing import ( - Any, - Awaitable, - Callable, - ClassVar, - Dict, - Generic, - Tuple, - TypeVar, - Union, -) +from typing import (Any, Awaitable, Callable, ClassVar, Dict, Generic, List, + Tuple, Type, TypeVar, Union) from typing_extensions import ParamSpec, Protocol, TypedDict @@ -65,27 +56,22 @@ R = TypeVar("R", bound="ViewResponse") ViewRoute = Callable[P, R] - -class BodyLike(Protocol): - __view_body__: ClassVar[dict[str, ValueType]] - - -ValueType = Union[BodyLike, str, int, Dict[str, "ValueType"], bool, float] ValidatorResult = Union[bool, Tuple[bool, str]] Validator = Callable[[V], ValidatorResult] +TypeInfo = Tuple[int, Type[Any] | None, List["TypeInfo"]] + class RouteInputDict(TypedDict, Generic[V]): name: str - type: type[V] | None + type_codes: list[TypeInfo] default: V | None validators: list[Validator[V]] is_body: bool has_default: bool -ViewBodyType = Union[str, int, dict, bool, float] -ViewBody = Dict[str, ViewBodyType] +ViewBody = Dict[str, "ValueType"] class _SupportsViewBodyCV(Protocol): @@ -103,6 +89,15 @@ class _SupportsAnnotations(Protocol): SupportsViewBody = Union[_SupportsViewBodyCV, _SupportsViewBodyF] ViewBodyLike = Union[SupportsViewBody, _SupportsAnnotations] +ValueType = Union[ + ViewBodyLike, + str, + int, + Dict[str, "ValueType"], + bool, + float, + Type[Any], +] Parser = Callable[[str], ViewBody] From c2377be02cc02b2d9a8707864eabdf9929897ad1 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 10 Sep 2023 14:00:55 +0000 Subject: [PATCH 02/12] beginning of implementation --- src/_view/app.c | 255 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 225 insertions(+), 30 deletions(-) diff --git a/src/_view/app.c b/src/_view/app.c index fd71c1b..f112040 100644 --- a/src/_view/app.c +++ b/src/_view/app.c @@ -53,6 +53,15 @@ code, \ msg \ ); +#define CHECK(flags) ((typecode_flags & (flags)) == (flags)) +#define TYPECODE_ANY 0 +#define TYPECODE_STR 1 +#define TYPECODE_INT 2 +#define TYPECODE_BOOL 3 +#define TYPECODE_FLOAT 4 +#define TYPECODE_DICT 5 +#define TYPECODE_NONE 6 +#define TYPECODE_CLASS 7 typedef struct _route_input route_input; typedef struct _app_parsers app_parsers; @@ -130,6 +139,11 @@ struct Route { * we repeat this process until we reach the end of the URL. so, next we do map_get(route->r->routes, "/index"). * */ +typedef enum { + STRING_ALLOWED = 1 << 0, + NULL_ALLOWED = 2 << 0 +} typecode_flag; + static int PyErr_BadASGI(void) { PyErr_SetString( PyExc_RuntimeError, @@ -154,6 +168,18 @@ char* v_strsep(char** stringp, const char* delim) { return rv; } +static void free_type_info(type_info* ti) { + Py_XDECREF(ti->ob); + for (int i = 0; i < ti->children_size; i++) { + free_type_info(ti->children[i]); + } +} + +static void free_type_codes(type_info** codes, Py_ssize_t len) { + for (Py_ssize_t i = 0; i < len; i++) + free_type_info(codes[i]); +} + route* route_new( PyObject* callable, Py_ssize_t inputs_size, @@ -191,7 +217,10 @@ route* route_new( void route_free(route* r) { for (int i = 0; i < r->inputs_size; i++) { Py_DECREF(r->inputs[i]->df); - Py_DECREF(r->inputs[i]->type); + free_type_codes( + r->inputs[i]->types, + r->inputs[i]->types_size + ); for (int i = 0; i < r->inputs[i]->validators_size; i++) { Py_DECREF(r->inputs[i]->validators[i]); @@ -225,13 +254,6 @@ void route_input_print(route_input* ri) { Py_PRINT_RAW ); puts(""); - printf("type: "); - PyObject_Print( - ri->type, - stdout, - Py_PRINT_RAW - ); - puts(""); printf( "is_body: %d\n", ri->is_body @@ -351,6 +373,156 @@ static PyObject* query_parser( return obj; // no need for null check } +#define TC_VERIFY(typeobj) { if (PyObject_IsInstance( \ + value, \ + (PyObject*) &typeobj \ + )) { \ + verified = true; \ + }; break; } + +static int verify_dict_typecodes( + type_info** codes, + Py_ssize_t len, + PyObject* dict +) { + PyObject* iter = PyObject_GetIter(dict); + PyObject* key; + while ((key = PyIter_Next(iter))) { + PyObject* value = PyDict_GetItem( + dict, + key + ); + if (!value) { + Py_DECREF(iter); + return -1; + } + + bool verified = false; + for (Py_ssize_t i = 0; i < len; i++) { + if (verified) break; + switch (codes[i]->typecode) { + case TYPECODE_ANY: return 0; + case TYPECODE_NONE: { + if (value == Py_None) verified = true; + break; + } + case TYPECODE_STR: TC_VERIFY(PyUnicode_Type); + case TYPECODE_INT: TC_VERIFY(PyLong_Type); + case TYPECODE_BOOL: TC_VERIFY(PyBool_Type); + case TYPECODE_FLOAT: TC_VERIFY(PyFloat_Type); + case TYPECODE_DICT: { + if (PyObject_IsInstance( + value, + (PyObject*) &PyDict_Type + )) { + int res = verify_dict_typecodes( + codes[i]->children, + codes[i]->children_size, + value + ); + if (res == 0) verified = true; + else if (res == -1) return -1; + else return 1; + }; + break; + }; + default: Py_FatalError("invalid dict typecode"); + }; + } + + if (!verified) return 1; + } + + Py_DECREF(iter); + + if (PyErr_Occurred()) + return -1; + + return 0; +} + +static PyObject* cast_from_typecodes( + type_info** codes, + Py_ssize_t len, + PyObject* item, + PyObject* json_parser +) { + if (!codes) { + // type is Any + + if (!item) Py_RETURN_NONE; + return item; + }; + + typecode_flag typecode_flags = 0; + + for (Py_ssize_t i = 0; i < len; i++) { + type_info* ti = codes[i]; + + switch (ti->typecode) { + case TYPECODE_ANY: { + return item; + } + case TYPECODE_STR: { + typecode_flags |= STRING_ALLOWED; + break; + } + case TYPECODE_INT: { + PyObject* py_int = PyLong_FromUnicodeObject( + item, + 10 + ); + if (!py_int) break; + return py_int; + } + case TYPECODE_BOOL: { + const char* str = PyUnicode_AsUTF8(item); + PyObject* py_bool = NULL; + if (!str) return NULL; + if (strcmp( + str, + "true" + ) == 0) { + py_bool = Py_NewRef(Py_True); + } else if (strcmp( + str, + "false" + ) == 0) { + py_bool = Py_NewRef(Py_False); + } + + if (py_bool) return py_bool; + break; + } + case TYPECODE_FLOAT: { + PyObject* flt = PyFloat_FromString(item); + if (!flt) break; + return flt; + } + case TYPECODE_DICT: { + PyObject* obj = PyObject_Vectorcall( + json_parser, + (PyObject*[]) { item }, + 1, + NULL + ); + if (!obj) break; + int res = verify_dict_typecodes( + ti->children, + ti->children_size, + obj + ); + if (res == -1) return NULL; + if (res == 1) return NULL; + return obj; + } + default: Py_FatalError("invalid typecode"); + } + } + + return NULL; +} + static PyObject** json_parser( app_parsers* parsers, const char* data, @@ -387,28 +559,25 @@ static PyObject** json_parser( for (int i = 0; i < inputs_size; i++) { route_input* inp = inputs[i]; - PyObject* item = PyDict_GetItemString( + PyObject* raw_item = PyDict_GetItemString( inp->is_body ? obj : query, inp->name ); + PyObject* item = cast_from_typecodes( + inp->types, + inp->types_size, + raw_item, + parsers->json + ); + if (!item) { Py_DECREF(obj); + Py_DECREF(item); free(ob); return NULL; } - if (inp->type) { - /* - if (!PyObject_IsInstance( - item, - inp->type - )) { - return NULL; - } - */ - } - for (int x = 0; x < inp->validators_size; x++) { PyObject* o = PyObject_Vectorcall( inp->validators[x], @@ -1574,6 +1743,7 @@ static int handle_route_impl( if (!params) { // parsing failed + PyErr_Clear(); return fire_error( @@ -1845,8 +2015,32 @@ static int handle_route_query(PyObject* awaitable, char* query) { ++final_size; } - if (item) - params[i] = Py_NewRef(item); + if (item) { + PyObject* parsed_item = cast_from_typecodes( + r->inputs[i]->types, + r->inputs[i]->types_size, + item, + self->parsers.json + ); + puts("abc"); + if (!parsed_item) { + PyErr_Clear(); + for (int i = 0; i < r->inputs_size; i++) { + Py_XDECREF(params[i]); + } + + free(params); + Py_DECREF(query_obj); + return fire_error( + self, + awaitable, + 400, + r, + NULL + ); + } + params[i] = Py_NewRef(parsed_item); + } } PyObject** merged = PyMem_Calloc( @@ -2569,12 +2763,6 @@ static inline int bad_input(const char* name) { return -1; } -static void free_type_info(type_info* ti) { - Py_XDECREF(ti->ob); - for (int i = 0; i < ti->children_size; i++) { - free_type_info(ti->children[i]); - } -} static type_info** build_type_codes(PyObject* type_codes, Py_ssize_t len) { type_info** tps = calloc( @@ -2657,6 +2845,7 @@ static type_info** build_type_codes(PyObject* type_codes, Py_ssize_t len) { return tps; } + static int load( route* r, PyObject* target @@ -2801,7 +2990,10 @@ static int load( if (!validators) { Py_DECREF(iter); Py_XDECREF(inp->df); - Py_DECREF(inp->type); + free_type_codes( + inp->types, + inp->types_size + ); PyMem_Free(r->inputs); PyMem_Free(inp); return bad_input("validators"); @@ -2816,7 +3008,10 @@ static int load( if (!inp->validators) { Py_DECREF(iter); - Py_DECREF(inp->type); + free_type_codes( + inp->types, + inp->types_size + ); Py_XDECREF(inp->df); PyMem_Free(r->inputs); PyMem_Free(inp); From 252d2f9514dd118113685e68ac62734eea6ee5f1 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 10 Sep 2023 14:18:59 +0000 Subject: [PATCH 03/12] #9 prototype --- src/_view/app.c | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/_view/app.c b/src/_view/app.c index f112040..b1172c1 100644 --- a/src/_view/app.c +++ b/src/_view/app.c @@ -467,12 +467,19 @@ static PyObject* cast_from_typecodes( typecode_flags |= STRING_ALLOWED; break; } + case TYPECODE_NONE: { + typecode_flags |= NULL_ALLOWED; + break; + } case TYPECODE_INT: { PyObject* py_int = PyLong_FromUnicodeObject( item, 10 ); - if (!py_int) break; + if (!py_int) { + PyErr_Clear(); + break; + } return py_int; } case TYPECODE_BOOL: { @@ -496,7 +503,10 @@ static PyObject* cast_from_typecodes( } case TYPECODE_FLOAT: { PyObject* flt = PyFloat_FromString(item); - if (!flt) break; + if (!flt) { + PyErr_Clear(); + break; + } return flt; } case TYPECODE_DICT: { @@ -506,7 +516,10 @@ static PyObject* cast_from_typecodes( 1, NULL ); - if (!obj) break; + if (!obj) { + PyErr_Clear(); + break; + } int res = verify_dict_typecodes( ti->children, ti->children_size, @@ -519,7 +532,8 @@ static PyObject* cast_from_typecodes( default: Py_FatalError("invalid typecode"); } } - + if ((CHECK(NULL_ALLOWED)) && (item == NULL)) Py_RETURN_NONE; + if (CHECK(STRING_ALLOWED)) return Py_NewRef(item); return NULL; } @@ -1575,8 +1589,10 @@ static int route_error( } -static int handle_route_callback(PyObject* awaitable, - PyObject* result) { +static int handle_route_callback( + PyObject* awaitable, + PyObject* result +) { PyObject* send; route* r; @@ -2022,7 +2038,6 @@ static int handle_route_query(PyObject* awaitable, char* query) { item, self->parsers.json ); - puts("abc"); if (!parsed_item) { PyErr_Clear(); for (int i = 0; i < r->inputs_size; i++) { @@ -2039,7 +2054,7 @@ static int handle_route_query(PyObject* awaitable, char* query) { NULL ); } - params[i] = Py_NewRef(parsed_item); + params[i] = parsed_item; } } From 39e1f87ee7bfadd8e1b4cbe51d0db5828b8f28db Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 10 Sep 2023 14:20:19 +0000 Subject: [PATCH 04/12] bump version --- setup.py | 2 +- src/view/__about__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index a1e6176..1989b32 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ data = toml.load(f) setup( name="view.py", - version="1.0.0-alpha3", + version="1.0.0-alpha4", packages=["view"], project_urls=data["project"]["urls"], package_dir={"": "src"}, diff --git a/src/view/__about__.py b/src/view/__about__.py index 0ad8bf3..2b0ec7a 100644 --- a/src/view/__about__.py +++ b/src/view/__about__.py @@ -1,2 +1,2 @@ -__version__ = "1.0.0-alpha3" +__version__ = "1.0.0-alpha4" __license__ = "MIT" From 5c971d2c09cd5c53301b0ce8884c479d412b78bf Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 10 Sep 2023 14:25:08 +0000 Subject: [PATCH 05/12] patch dicts --- src/view/_loader.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/view/_loader.py b/src/view/_loader.py index e5daef2..3af74d4 100644 --- a/src/view/_loader.py +++ b/src/view/_loader.py @@ -85,8 +85,13 @@ def _build_type_codes(inp: Iterable[type[ValueType]]) -> list[TypeInfo]: raise InvalidBodyError( f"dictionary keys must be strings, not {key}" ) + + value_args = get_args(value) - tp_codes = _build_type_codes(value) + if not len(value_args): + value_args = (value,) + + tp_codes = _build_type_codes(value_args) codes.append((TYPECODE_DICT, None, tp_codes)) return codes From 25d4d82603f72d9b4832a45d95fa2cb6e1d5175b Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 10 Sep 2023 15:06:26 +0000 Subject: [PATCH 06/12] patch test query strings --- src/view/_loader.py | 4 ++-- src/view/app.py | 26 +++++++++++++++++--------- tests/test_app.py | 40 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/view/_loader.py b/src/view/_loader.py index 3af74d4..70b9902 100644 --- a/src/view/_loader.py +++ b/src/view/_loader.py @@ -76,7 +76,7 @@ def _build_type_codes(inp: Iterable[type[ValueType]]) -> list[TypeInfo]: origin = getattr(tp, "__origin__", None) # typing.GenericAlias - if (not origin) or (origin is not dict): + if (not origin) and (origin is not dict): raise InvalidBodyError(f"{tp} is not a valid type for routes") key, value = get_args(tp) @@ -85,7 +85,7 @@ def _build_type_codes(inp: Iterable[type[ValueType]]) -> list[TypeInfo]: raise InvalidBodyError( f"dictionary keys must be strings, not {key}" ) - + value_args = get_args(value) if not len(value_args): diff --git a/src/view/app.py b/src/view/app.py index 2a45067..f1ff81f 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -17,6 +17,7 @@ from threading import Thread from types import TracebackType as Traceback from typing import Any, Callable, Coroutine, Generic, TypeVar, get_type_hints +from urllib.parse import urlencode from rich import print from rich.traceback import install @@ -81,6 +82,7 @@ async def _request( route: str, *, body: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, ) -> TestingResponse: body_q = asyncio.Queue() start = asyncio.Queue() @@ -105,13 +107,13 @@ async def send(obj: dict[str, Any]): else: raise TypeError(f"bad type: {obj['type']}") - qs = route[route.find("?") :] if "?" in route else "" # noqa + truncated_route = route[: route.find("?")] if "?" in route else route await self.app( { "type": "http", "http_version": "1.1", - "path": route, - "query_string": qs.encode(), + "path": truncated_route, + "query_string": urlencode(query).encode() if query else b"", "headers": [], "method": method, }, @@ -129,48 +131,54 @@ async def get( route: str, *, body: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, ) -> TestingResponse: - return await self._request("GET", route, body=body) + return await self._request("GET", route, body=body, query=query) async def post( self, route: str, *, body: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, ) -> TestingResponse: - return await self._request("POST", route, body=body) + return await self._request("POST", route, body=body, query=query) async def put( self, route: str, *, body: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, ) -> TestingResponse: - return await self._request("PUT", route, body=body) + return await self._request("PUT", route, body=body, query=query) async def patch( self, route: str, *, body: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, ) -> TestingResponse: - return await self._request("PATCH", route, body=body) + return await self._request("PATCH", route, body=body, query=query) async def delete( self, route: str, *, body: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, ) -> TestingResponse: - return await self._request("DELETE", route, body=body) + return await self._request("DELETE", route, body=body, query=query) async def options( self, route: str, *, body: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, ) -> TestingResponse: - return await self._request("OPTIONS", route, body=body) + return await self._request("OPTIONS", route, body=body, query=query) class App(ViewApp, Generic[A]): diff --git a/tests/test_app.py b/tests/test_app.py index 15bd38c..3c47e18 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,6 @@ from ward import test -from view import new_app +from view import body, new_app, query @test("responses") @@ -28,6 +28,7 @@ async def index(): assert res.status == 400 assert res.message == "error" + @test("headers") async def _(): app = new_app() @@ -78,3 +79,40 @@ async def multi(): res = await test.get("/multi") assert res.message == "hello" assert res.status == 201 + + +@test("query type validation") +async def _(): + app = new_app() + + @app.get("/") + @query("name", str) + async def index(name: str): + return name + + @app.get("/status") + @query("status", int) + async def stat(status: int): + return "hello", status + + @app.get("/union") + @query("test", bool, int) + async def union(test: bool | int): + if type(test) is bool: + return "1" + elif type(test) is int: + return "2" + else: + raise Exception + + 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("/union", query={"test": "a"})).status == 400 + assert ( + await test.get("/union", query={"test": "true"}) + ).message == "1" # noqa + assert (await test.get("/union", query={"test": "2"})).message == "2" From 97a715cccc29d2d1becdd0db8ed5f34734d28680 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 10 Sep 2023 15:31:38 +0000 Subject: [PATCH 07/12] patch body parameters --- CHANGELOG.md | 5 +++++ src/_view/app.c | 15 ++++++++----- src/view/_loader.py | 1 - tests/test_app.py | 55 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f4d48..efde074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0-alpha4] - 2023-09-10 +- Added type validation (without support for `__view_body__`) +- Patched query strings on app testing +- Added tests for query and body parameters + ## [1.0.0-alpha3] - 2023-09-9 - Patched header responses - Added tests for headers diff --git a/src/_view/app.c b/src/_view/app.c index b1172c1..ee9f32f 100644 --- a/src/_view/app.c +++ b/src/_view/app.c @@ -1746,7 +1746,6 @@ static int handle_route_impl( ); } - PyObject** params = json_parser( &self->parsers, body, @@ -2207,8 +2206,11 @@ static int handle_route(PyObject* awaitable, char* query) { return 0; } -static PyObject* app(ViewApp* self, PyObject* const* args, Py_ssize_t - nargs) { +static PyObject* app( + ViewApp* self, + PyObject* const* args, + Py_ssize_t nargs +) { PyObject* scope = args[0]; PyObject* receive = args[1]; PyObject* send = args[2]; @@ -2650,7 +2652,6 @@ static PyObject* app(ViewApp* self, PyObject* const* args, Py_ssize_t Py_DECREF(awaitable); return NULL; } - if (r->inputs_size != 0) { if (!r->has_body) { if (handle_route_query( @@ -3053,9 +3054,13 @@ static bool figure_has_body(PyObject* inputs) { PyObject* item; bool res = false; + if (!iter) { + return false; + } + while ((item = PyIter_Next(iter))) { PyObject* is_body = PyDict_GetItemString( - inputs, + item, "is_body" ); diff --git a/src/view/_loader.py b/src/view/_loader.py index 70b9902..71461a7 100644 --- a/src/view/_loader.py +++ b/src/view/_loader.py @@ -102,7 +102,6 @@ def _format_inputs(inputs: list[RouteInput]) -> list[RouteInputDict]: for i in inputs: type_codes = _build_type_codes(i.tp) - result.append( { "name": i.name, diff --git a/tests/test_app.py b/tests/test_app.py index 3c47e18..9ccfe89 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -81,6 +81,52 @@ async def multi(): assert res.status == 201 +@test("body type validation") +async def _(): + app = new_app() + + @app.get("/") + @body("name", str) + async def index(name: str): + return name + + @app.get("/status") + @body("status", int) + async def stat(status: int): + return "hello", status + + @app.get("/union") + @body("test", bool, int) + async def union(test: bool | int): + if type(test) is bool: + return "1" + elif type(test) is int: + return "2" + else: + raise Exception + + @app.get("/multi") + @body("status", int) + @body("name", str) + async def multi(status: int, name: str): + return name, status + + 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("/union", body={"test": "a"})).status == 400 + 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" + + @test("query type validation") async def _(): app = new_app() @@ -105,6 +151,12 @@ async def union(test: bool | int): else: raise Exception + @app.get("/multi") + @query("status", int) + @query("name", str) + async def multi(status: int, name: str): + return name, status + 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 @@ -116,3 +168,6 @@ async def union(test: bool | int): 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" From 7f321a049a14502109dd9f280abb8dcad71e086a Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 10 Sep 2023 16:37:48 +0000 Subject: [PATCH 08/12] bug fixes --- CHANGELOG.md | 3 +++ docs/parameters.md | 54 +++++++++++++++++++++++++++++++++++++++++----- src/_view/app.c | 41 ++++++++++++++++++++++++++--------- src/view/app.py | 11 +++++----- 4 files changed, 89 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efde074..5c30582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added type validation (without support for `__view_body__`) - Patched query strings on app testing - Added tests for query and body parameters +- Patched body parameters +- Documented type validation +- Patched bodies with testing ## [1.0.0-alpha3] - 2023-09-9 - Patched header responses diff --git a/docs/parameters.md b/docs/parameters.md index d7ce110..5f1bc87 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -19,13 +19,9 @@ app.run() The first argument is the name of the parameter in the query string, **not the argument name**, and the second argument is the type that it should take. -!!! danger - - view.py has not yet implemented type checking on parameters - ## Body -Bodies work the exact same way, but with the `body` decorator instead: +Bodies work the exact same way as queries, but with the `body` decorator instead: ```py @app.get("/goodbye") @@ -62,3 +58,51 @@ async def token(user_id: str, token: str): !!! danger This is extremely buggy and not yet recommended for general use. + +## Type Validation + +view.py will ensure that the type sent to the server is compatible with what you passed to the decorator. For example: + +```py +@app.get("/") +@query("number", int) +async def index(number: int): + # number will always be an int. + # if it isn't, an error 400 is sent back to the user automatically + return "..." +``` + +The following types are supported: + +- `typing.Any` +- `str` +- `int` +- `bool` +- `float` +- `dict` (or `typing.Dict`) +- `None` + +You can allow unions by just passing more parameters: + +```py +@app.get('/hello') +@query("name", str, None) +async def hello(name: str | None): + if not name: + return "hello world" + + return f"hello {name}" +``` + +You can pass type arguments to a `dict`, which are also validated by the server: + +```py +@app.get("/something") +@body("data", dict[str, int]) # typing.Dict on 3.8 and 3.9 +async def something(data: dict[str, int]): + # data will always be a dictionary of strings and integers + return "..." +``` + +The key in a dictionary must always be `str` (i.e. `dict[int, str]` is not allowed), but the value can be any supported type (including other dictionaries!) + diff --git a/src/_view/app.c b/src/_view/app.c index ee9f32f..81d64a6 100644 --- a/src/_view/app.c +++ b/src/_view/app.c @@ -1,3 +1,4 @@ +#include #include #include #include @@ -472,6 +473,10 @@ static PyObject* cast_from_typecodes( break; } case TYPECODE_INT: { + if (PyObject_IsInstance( + item, + (PyObject*) &PyLong_Type + )) return item; PyObject* py_int = PyLong_FromUnicodeObject( item, 10 @@ -483,6 +488,10 @@ static PyObject* cast_from_typecodes( return py_int; } case TYPECODE_BOOL: { + if (PyObject_IsInstance( + item, + (PyObject*) &PyBool_Type + )) return item; const char* str = PyUnicode_AsUTF8(item); PyObject* py_bool = NULL; if (!str) return NULL; @@ -502,6 +511,10 @@ static PyObject* cast_from_typecodes( break; } case TYPECODE_FLOAT: { + if (PyObject_IsInstance( + item, + (PyObject*) &PyFloat_Type + )) return item; PyObject* flt = PyFloat_FromString(item); if (!flt) { PyErr_Clear(); @@ -517,8 +530,14 @@ static PyObject* cast_from_typecodes( NULL ); if (!obj) { - PyErr_Clear(); - break; + if (PyObject_IsInstance( + item, + (PyObject*) &PyDict_Type + )) obj = item; + else { + PyErr_Clear(); + break; + } } int res = verify_dict_typecodes( ti->children, @@ -532,8 +551,15 @@ static PyObject* cast_from_typecodes( default: Py_FatalError("invalid typecode"); } } - if ((CHECK(NULL_ALLOWED)) && (item == NULL)) Py_RETURN_NONE; - if (CHECK(STRING_ALLOWED)) return Py_NewRef(item); + if ((CHECK(NULL_ALLOWED)) && (item == NULL || item == + Py_None)) Py_RETURN_NONE; + if (CHECK(STRING_ALLOWED)) { + if (!PyObject_IsInstance( + item, + (PyObject*) &PyUnicode_Type + )) return NULL; + return Py_NewRef(item); + } return NULL; } @@ -545,7 +571,6 @@ static PyObject** json_parser( Py_ssize_t inputs_size ) { PyObject* py_str = PyUnicode_FromString(data); - if (!py_str) return NULL; @@ -555,7 +580,6 @@ static PyObject** json_parser( 1, NULL ); - Py_DECREF(py_str); if (!obj) @@ -587,7 +611,6 @@ static PyObject** json_parser( if (!item) { Py_DECREF(obj); - Py_DECREF(item); free(ob); return NULL; } @@ -599,7 +622,6 @@ static PyObject** json_parser( 1, NULL ); - if (!PyObject_IsTrue(o)) { Py_DECREF(o); free(ob); @@ -1710,7 +1732,6 @@ static int handle_route_impl( ViewApp* self; Py_ssize_t* size; PyObject** path_params; - if (PyAwaitable_UnpackValues( awaitable, &self, @@ -1758,7 +1779,6 @@ static int handle_route_impl( if (!params) { // parsing failed - PyErr_Clear(); return fire_error( @@ -1859,6 +1879,7 @@ static int body_inc_buf(PyObject* awaitable, PyObject* result) { return -1; } + char* buf; Py_ssize_t* size; char* query; diff --git a/src/view/app.py b/src/view/app.py index f1ff81f..de174fa 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -4,6 +4,7 @@ import faulthandler import importlib import inspect +import json import logging import os import sys @@ -88,11 +89,11 @@ async def _request( start = asyncio.Queue() async def receive(): - return ( - {**body, "more_body": False, "type": "http.request"} - if body - else b"" - ) + return { + "body": json.dumps(body).encode(), + "more_body": False, + "type": "http.request", + } async def send(obj: dict[str, Any]): if obj["type"] == "http.response.start": From 54fbed75596144d7714e495ad23b17d397a57ce9 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 10 Sep 2023 16:40:22 +0000 Subject: [PATCH 09/12] switch to typing.union --- src/view/typing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/typing.py b/src/view/typing.py index 344a656..9564360 100644 --- a/src/view/typing.py +++ b/src/view/typing.py @@ -59,7 +59,7 @@ ValidatorResult = Union[bool, Tuple[bool, str]] Validator = Callable[[V], ValidatorResult] -TypeInfo = Tuple[int, Type[Any] | None, List["TypeInfo"]] +TypeInfo = Tuple[int, Union[Type[Any], None], List["TypeInfo"]] class RouteInputDict(TypedDict, Generic[V]): From 63073fba2b49cfb9d2cb8f38b7dcb3e290453d35 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 10 Sep 2023 16:41:58 +0000 Subject: [PATCH 10/12] too lazy to name this commit --- tests/test_app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_app.py b/tests/test_app.py index 9ccfe89..da43af1 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,3 +1,4 @@ +from __future__ import annotations from ward import test from view import body, new_app, query From 18992e61ce45be29fe8ed5c49bf2f6841bf8c144 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 10 Sep 2023 16:44:29 +0000 Subject: [PATCH 11/12] apparently future imports dont matter in ward --- tests/test_app.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index da43af1..116c1de 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,4 +1,3 @@ -from __future__ import annotations from ward import test from view import body, new_app, query @@ -98,7 +97,7 @@ async def stat(status: int): @app.get("/union") @body("test", bool, int) - async def union(test: bool | int): + async def union(test: Union[bool, int]): if type(test) is bool: return "1" elif type(test) is int: @@ -144,7 +143,7 @@ async def stat(status: int): @app.get("/union") @query("test", bool, int) - async def union(test: bool | int): + async def union(test: Union[bool, int]): if type(test) is bool: return "1" elif type(test) is int: From 6dd3ccb4f988980a3d07ada07b40fe4152fd6a48 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 10 Sep 2023 16:46:28 +0000 Subject: [PATCH 12/12] oh my lord nvim i hate you --- tests/test_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_app.py b/tests/test_app.py index 116c1de..52113d5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,7 +1,7 @@ from ward import test from view import body, new_app, query - +from typing import Union @test("responses") async def _():