diff --git a/.gitignore b/.gitignore index 1b1e40f..7ba3d7c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ dist/ *.egg-info build/ *.so -html/dist.css \ No newline at end of file +html/dist.css +38venv +39venv +311venv diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c4d69d..0d3ba93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,16 @@ 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). -## Unreleased +## [1.0.0-alpha5] - 2023-09-24 - Added `app.query` and `app.body` - Patched warning with starting app from incorrect filename - Updated `__all__` for `routing.py` - Added `view.Response` and `view.HTML` - Fixed `__view_result__` +- Added support for `__view_body__` and `__view_construct__` +- Added support for Pydantic, `NamedTuple`, and dataclasses for type validation +- Support for direct union types (i.e. `str | int`, `Union[str, int]`) on type validation - Added support for non async routes ## [1.0.0-alpha4] - 2023-09-10 diff --git a/docs/parameters.md b/docs/parameters.md index fa17a88..71d4f84 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -78,7 +78,7 @@ view.py will ensure that the type sent to the server is compatible with what you ```py @app.get("/") -@query("number", int) +@app.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 @@ -94,12 +94,16 @@ The following types are supported: - `float` - `dict` (or `typing.Dict`) - `None` +- Pydantic Models +- Dataclasses +- `typing.TypedDict` +- `NamedTuple` You can allow unions by just passing more parameters: ```py @app.get('/hello') -@query("name", str, None) +@app.query("name", str, None) async def hello(name: str | None): if not name: return "hello world" @@ -111,7 +115,7 @@ 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 +@app.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 "..." @@ -119,3 +123,135 @@ async def something(data: dict[str, int]): 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!) +### Objects + +Here's an example of using an object type with `dataclasses`: + +```py +from view import new_app, query +from dataclasses import dataclass, field + +app = new_app() + +def now() -> str: + ... # Calculate current time + +@dataclass +class Post: + content: str + created_at = field(default_factory=now) + + +@app.post("/create") +@app.query("data", Post) +async def create(data: Post): + print(f"Created post {data.created_at}") + return "Success", 201 +``` + +You may also have recursive types, like so: + +```py +class MyOtherObject(NamedTuple): + something: int + +class MyObject(NamedTuple): + something: str + another_thing: MyOtherObject +``` + +### Typed Dictionaries + +You may use `typing.TypedDict` to type your dictionary inputs if you don't want to use a basic `dict[..., ...]` (or `typing.Dict`), like so: + +```py +from view import new_app +from typing import TypedDict + +app = new_app() + +class MyDict(TypedDict): + a: str + b: int + +@app.get("/") +@app.query("data", MyDict) +async def index(data: MyDict): + return data["a"] + +app.run() +``` + +You may also use `NotRequired` to allow certain keys to get omitted: + +```py +class MyDict(TypedDict): + a: str + b: NotRequired[int] +``` + +## View Body Protocol + +If you would like to create your own object that gets validated by view.py, you may use the `__view_body__` protocol. + +A `__view_body__` should contain a dictionary containing the keys and their corresponding types, like so: + +```py +from view import new_app + +app = new_app() + +class MyObject: + __view_body__ = {"a": str, "b": int} + +@app.get("/") +@app.query("data", MyObject) +async def index(data: MyObject): + ... + +app.run() +``` + +The above would ensure the body contains something like the following in JSON: + +```json +{ + "data": { + "a": "...", + "b": 0 + } +} +``` + +A default type can be annotated via `view.BodyParam`: + +```py +class MyObject: + __view_body__ = { + "hello": BodyParam(types=(str, int), default="world"), + "world": BodyParam(types=str, default="hello"), + } +``` + +Note that `__view_body__` can also be a static function, like so: + +```py +class MyObject: + @staticmethod + def __view_body__(): + return {"a": str, "b": int} +``` + +### Initialization + +By default, an object supporting `__view_body__` will have the proper keyword arguments passed to it's `__init__`. + +If you would like to have special behavior in your `__init__`, you may instead add a static `__view_construct__` function that returns an instance: + +```py +class MyObject: + __view_body__ = {"a": str, "b": int} + + def __view_construct__(**kwargs): + return MyObject() +``` diff --git a/docs/running.md b/docs/running.md index 7b3e6dc..4712e6d 100644 --- a/docs/running.md +++ b/docs/running.md @@ -1,12 +1,5 @@ # Running -Is your view app running, you better go catch it! - -!!! danger "Apologies" - - I'm sorry, you may make a PR to delete this. - - ## Serving You can simply run a view app via the `view serve` command, but if you're like me and would rather to just use the `python3` command, that's just as easy. diff --git a/src/_view/app.c b/src/_view/app.c index 18a9d30..aef3463 100644 --- a/src/_view/app.c +++ b/src/_view/app.c @@ -63,6 +63,7 @@ #define TYPECODE_DICT 5 #define TYPECODE_NONE 6 #define TYPECODE_CLASS 7 +#define TYPECODE_CLASSTYPES 8 typedef struct _route_input route_input; typedef struct _app_parsers app_parsers; @@ -99,6 +100,7 @@ struct _type_info { PyObject* ob; type_info** children; Py_ssize_t children_size; + PyObject* df; }; typedef struct _route_input { @@ -171,6 +173,7 @@ char* v_strsep(char** stringp, const char* delim) { static void free_type_info(type_info* ti) { Py_XDECREF(ti->ob); + if ((intptr_t) ti->df > 0) Py_DECREF(ti->df); for (int i = 0; i < ti->children_size; i++) { free_type_info(ti->children[i]); } @@ -217,7 +220,7 @@ route* route_new( void route_free(route* r) { for (int i = 0; i < r->inputs_size; i++) { - Py_DECREF(r->inputs[i]->df); + Py_XDECREF(r->inputs[i]->df); free_type_codes( r->inputs[i]->types, r->inputs[i]->types_size @@ -374,18 +377,18 @@ static PyObject* query_parser( return obj; // no need for null check } -#define TC_VERIFY(typeobj) { if (PyObject_IsInstance( \ - value, \ - (PyObject*) &typeobj \ +#define TC_VERIFY(typeobj) if (typeobj( \ + value \ )) { \ verified = true; \ - }; break; } + } break; static int verify_dict_typecodes( type_info** codes, Py_ssize_t len, PyObject* dict ) { + if (!PyDict_Size(dict)) return 0; PyObject* iter = PyObject_GetIter(dict); PyObject* key; while ((key = PyIter_Next(iter))) { @@ -407,10 +410,15 @@ static int verify_dict_typecodes( 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_STR: TC_VERIFY(PyUnicode_CheckExact); + case TYPECODE_INT: { + if (PyLong_CheckExact(value)) { + verified = true; + } + break; + }; + case TYPECODE_BOOL: TC_VERIFY(PyBool_Check); + case TYPECODE_FLOAT: TC_VERIFY(PyFloat_CheckExact); case TYPECODE_DICT: { if (PyObject_IsInstance( value, @@ -427,17 +435,18 @@ static int verify_dict_typecodes( }; break; }; + case TYPECODE_CLASSTYPES: default: Py_FatalError("invalid dict typecode"); }; } - if (!verified) return 1; } Py_DECREF(iter); - - if (PyErr_Occurred()) + if (PyErr_Occurred()) { + PyErr_Print(); return -1; + } return 0; } @@ -473,10 +482,11 @@ static PyObject* cast_from_typecodes( break; } case TYPECODE_INT: { - if (PyObject_IsInstance( - item, - (PyObject*) &PyLong_Type - )) return item; + if (PyLong_CheckExact( + item + )) { + return Py_NewRef(item); + } PyObject* py_int = PyLong_FromUnicodeObject( item, 10 @@ -488,10 +498,9 @@ static PyObject* cast_from_typecodes( return py_int; } case TYPECODE_BOOL: { - if (PyObject_IsInstance( - item, - (PyObject*) &PyBool_Type - )) return item; + if (PyBool_Check( + item + )) return Py_NewRef(item); const char* str = PyUnicode_AsUTF8(item); PyObject* py_bool = NULL; if (!str) return NULL; @@ -511,10 +520,9 @@ static PyObject* cast_from_typecodes( break; } case TYPECODE_FLOAT: { - if (PyObject_IsInstance( - item, - (PyObject*) &PyFloat_Type - )) return item; + if (PyFloat_CheckExact( + item + )) return Py_NewRef(item); PyObject* flt = PyFloat_FromString(item); if (!flt) { PyErr_Clear(); @@ -523,32 +531,159 @@ static PyObject* cast_from_typecodes( return flt; } case TYPECODE_DICT: { - PyObject* obj = PyObject_Vectorcall( - json_parser, - (PyObject*[]) { item }, - 1, - NULL - ); + PyObject* obj; + if (PyDict_Check( + item + )) { + obj = Py_NewRef(item); + } else { + obj = PyObject_Vectorcall( + json_parser, + (PyObject*[]) { item }, + 1, + NULL + ); + } if (!obj) { - if (PyObject_IsInstance( - item, - (PyObject*) &PyDict_Type - )) obj = item; - else { - PyErr_Clear(); - break; - } + PyErr_Clear(); + break; } int res = verify_dict_typecodes( ti->children, ti->children_size, obj ); - if (res == -1) return NULL; - if (res == 1) return NULL; + if (res == -1) { + Py_DECREF(obj); + return NULL; + } + if (res == 1) { + Py_DECREF(obj); + return NULL; + } return obj; } - default: Py_FatalError("invalid typecode"); + case TYPECODE_CLASS: { + PyObject* kwargs = PyDict_New(); + if (!kwargs) return NULL; + PyObject* obj; + if (PyDict_CheckExact(item) || PyObject_IsInstance( + item, + (PyObject*) Py_TYPE(ti->ob) + )) { + obj = Py_NewRef(item); + } else { + obj = PyObject_Vectorcall( + json_parser, + (PyObject*[]) { item }, + 1, + NULL + ); + } + + if (!obj) { + PyErr_Clear(); + Py_DECREF(kwargs); + break; + } + + bool ok = true; + for (Py_ssize_t i = 0; i < ti->children_size; i++) { + + type_info* info = ti->children[i]; + PyObject* got_item = PyDict_GetItem( + obj, + info->ob + ); + + if (!got_item) { + if ((intptr_t) info->df != -1) { + if (info->df) { + got_item = info->df; + if (PyCallable_Check(got_item)) { + got_item = PyObject_CallNoArgs(got_item); // its a factory + if (!got_item) { + PyErr_Print(); + Py_DECREF(kwargs); + Py_DECREF(obj); + ok = false; + break; + } + } + } else { + ok = false; + Py_DECREF(kwargs); + Py_DECREF(obj); + break; + } + } else { + continue; + } + } + + PyObject* parsed_item = cast_from_typecodes( + info->children, + info->children_size, + got_item, + json_parser + ); + + if (!parsed_item) { + Py_DECREF(kwargs); + Py_DECREF(obj); + ok = false; + break; + } + + if (PyDict_SetItem( + kwargs, + info->ob, + parsed_item + ) < 0) { + Py_DECREF(kwargs); + Py_DECREF(obj); + Py_DECREF(parsed_item); + return NULL; + }; + Py_DECREF(parsed_item); + }; + + if (!ok) break; + + PyObject* caller; + caller = PyObject_GetAttrString( + ti->ob, + "__view_construct__" + ); + if (!caller) { + PyErr_Clear(); + caller = ti->ob; + } + + PyObject* built = PyObject_VectorcallDict( + caller, + NULL, + 0, + kwargs + ); + + Py_DECREF(kwargs); + if (!built) { + PyErr_Print(); + return NULL; + } + + return built; + } + case TYPECODE_CLASSTYPES: + default: { + fprintf( + stderr, + "got bad typecode in cast_from_typecodes: %d\n", + ti->typecode + ); + Py_FatalError("invalid typecode"); + } } } if ((CHECK(NULL_ALLOWED)) && (item == NULL || item == @@ -557,7 +692,8 @@ static PyObject* cast_from_typecodes( if (!PyObject_IsInstance( item, (PyObject*) &PyUnicode_Type - )) return NULL; + )) + return NULL; return Py_NewRef(item); } return NULL; @@ -854,166 +990,166 @@ static uint16_t hash_client_error(int status) { static const char* get_err_str(int status) { switch (status) { - ER( - 400, - "Bad Request" - ); - ER( - 401, - "Unauthorized" - ); - ER( - 402, - "Payment Required" - ); - ER( - 403, - "Forbidden" - ); - ER( - 404, - "Not Found" - ); - ER( - 405, - "Method Not Allowed" - ); - ER( - 406, - "Not Acceptable" - ); - ER( - 407, - "Proxy Authentication Required" - ); - ER( - 408, - "Request Timeout" - ); - ER( - 409, - "Conflict" - ); - ER( - 410, - "Gone" - ); - ER( - 411, - "Length Required" - ); - ER( - 412, - "Precondition Failed" - ); - ER( - 413, - "Payload Too Large" - ); - ER( - 414, - "URI Too Long" - ); - ER( - 415, - "Unsupported Media Type" - ); - ER( - 416, - "Range Not Satisfiable" - ); - ER( - 417, - "Expectation Failed" - ); - ER( - 418, - "I'm a teapot" - ); - ER( - 421, - "Misdirected Request" - ); - ER( - 422, - "Unprocessable Content" - ); - ER( - 423, - "Locked" - ); - ER( - 424, - "Failed Dependency" - ); - ER( - 425, - "Too Early" - ); - ER( - 426, - "Upgrade Required" - ); - ER( - 428, - "Precondition Required" - ); - ER( - 429, - "Too Many Requests" - ); - ER( - 431, - "Request Header Fields Too Large" - ); - ER( - 451, - "Unavailable for Legal Reasons" - ); - ER( - 500, - "Internal Server Error" - ); - ER( - 501, - "Not Implemented" - ); - ER( - 502, - "Bad Gateway" - ); - ER( - 503, - "Service Unavailable" - ); - ER( - 504, - "Gateway Timeout" - ); - ER( - 505, - "HTTP Version Not Supported" - ); - ER( - 506, - "Variant Also Negotiates" - ); - ER( - 507, - "Insufficent Storage" - ); - ER( - 508, - "Loop Detected" - ); - ER( - 510, - "Not Extended" - ); - ER( - 511, - "Network Authentication Required" - ); + ER( + 400, + "Bad Request" + ); + ER( + 401, + "Unauthorized" + ); + ER( + 402, + "Payment Required" + ); + ER( + 403, + "Forbidden" + ); + ER( + 404, + "Not Found" + ); + ER( + 405, + "Method Not Allowed" + ); + ER( + 406, + "Not Acceptable" + ); + ER( + 407, + "Proxy Authentication Required" + ); + ER( + 408, + "Request Timeout" + ); + ER( + 409, + "Conflict" + ); + ER( + 410, + "Gone" + ); + ER( + 411, + "Length Required" + ); + ER( + 412, + "Precondition Failed" + ); + ER( + 413, + "Payload Too Large" + ); + ER( + 414, + "URI Too Long" + ); + ER( + 415, + "Unsupported Media Type" + ); + ER( + 416, + "Range Not Satisfiable" + ); + ER( + 417, + "Expectation Failed" + ); + ER( + 418, + "I'm a teapot" + ); + ER( + 421, + "Misdirected Request" + ); + ER( + 422, + "Unprocessable Content" + ); + ER( + 423, + "Locked" + ); + ER( + 424, + "Failed Dependency" + ); + ER( + 425, + "Too Early" + ); + ER( + 426, + "Upgrade Required" + ); + ER( + 428, + "Precondition Required" + ); + ER( + 429, + "Too Many Requests" + ); + ER( + 431, + "Request Header Fields Too Large" + ); + ER( + 451, + "Unavailable for Legal Reasons" + ); + ER( + 500, + "Internal Server Error" + ); + ER( + 501, + "Not Implemented" + ); + ER( + 502, + "Bad Gateway" + ); + ER( + 503, + "Service Unavailable" + ); + ER( + 504, + "Gateway Timeout" + ); + ER( + 505, + "HTTP Version Not Supported" + ); + ER( + 506, + "Variant Also Negotiates" + ); + ER( + 507, + "Insufficent Storage" + ); + ER( + 508, + "Loop Detected" + ); + ER( + 510, + "Not Extended" + ); + ER( + 511, + "Network Authentication Required" + ); } Py_FatalError("got bad status code"); @@ -1143,9 +1279,8 @@ static int find_result_for( PyObject* t_value; while ((t_value = PyIter_Next(t_iter))) { - if (!PyObject_IsInstance( - t_value, - (PyObject*) &PyTuple_Type + if (!PyTuple_Check( + t_value )) { PyErr_SetString( PyExc_TypeError, @@ -1199,16 +1334,14 @@ static int handle_result( return -1; } else result = raw_result; - if (PyObject_IsInstance( - result, - (PyObject*) &PyUnicode_Type + if (PyUnicode_CheckExact( + result )) { const char* tmp = PyUnicode_AsUTF8(result); if (!tmp) return -1; res_str = strdup(tmp); - } else if (PyObject_IsInstance( - result, - (PyObject*) &PyTuple_Type + } else if (PyTuple_CheckExact( + result )) { if (PySequence_Size(result) > 3) { PyErr_SetString( @@ -1764,7 +1897,6 @@ static int handle_route_impl( NULL ); } - PyObject** params = json_parser( &self->parsers, body, @@ -1985,6 +2117,7 @@ static int handle_route_query(PyObject* awaitable, char* query) { &self->parsers, query ); + if (!query_obj) { PyErr_Clear(); return fire_error( @@ -2009,7 +2142,6 @@ static int handle_route_query(PyObject* awaitable, char* query) { if (size == NULL) size = &fake_size; - PyObject** params = calloc( r->inputs_size, sizeof(PyObject*) @@ -2230,6 +2362,7 @@ static PyObject* app( PyObject* const* args, Py_ssize_t nargs ) { + assert(nargs == 3); PyObject* scope = args[0]; PyObject* receive = args[1]; PyObject* send = args[2]; @@ -2425,7 +2558,6 @@ static PyObject* app( &path, "/" ))) { - puts(token); if (skip) { skip = false; continue; @@ -2437,7 +2569,6 @@ static PyObject* app( "/%s", token ); - puts(s); assert(target); if ((!did_save && rt && rt->r) || last_r) { @@ -2660,7 +2791,6 @@ static PyObject* app( return awaitable; } - if (PyAwaitable_SaveArbValues( awaitable, 3, @@ -2671,6 +2801,7 @@ static PyObject* app( Py_DECREF(awaitable); return NULL; } + if (r->inputs_size != 0) { if (!r->has_body) { if (handle_route_query( @@ -2867,6 +2998,23 @@ static type_info** build_type_codes(PyObject* type_codes, Py_ssize_t len) { 2 ); + PyObject* df = PyTuple_GetItem( + info, + 3 + ); + + if (df) { + if (PyObject_HasAttrString( + df, + "__VIEW_NODEFAULT__" + )) df = NULL; + else if (PyObject_HasAttrString( + df, + "__VIEW_NOREQ__" + )) + df = (PyObject*) -1; + } + if (!type_code || !obj || !children) { for (int x = 0; x < i; x++) free_type_info(tps[x]); @@ -2875,11 +3023,16 @@ static type_info** build_type_codes(PyObject* type_codes, Py_ssize_t len) { return NULL; } + if (!df) PyErr_Clear(); + Py_ssize_t code = PyLong_AsLong(type_code); Py_XINCREF(obj); ti->ob = obj; ti->typecode = code; + // we cant use Py_XINCREF or Py_XDECREF because it could be -1 + if ((intptr_t) df > 0) Py_INCREF(df); + ti->df = df; Py_ssize_t children_len = PySequence_Size(children); if (children_len == -1) { @@ -2888,6 +3041,7 @@ static type_info** build_type_codes(PyObject* type_codes, Py_ssize_t len) { free(tps); Py_XDECREF(obj); + if ((intptr_t) df > 0) Py_DECREF(df); return NULL; } @@ -2903,6 +3057,7 @@ static type_info** build_type_codes(PyObject* type_codes, Py_ssize_t len) { free(tps); Py_XDECREF(obj); + if ((intptr_t) df) Py_DECREF(df); return NULL; } @@ -3155,9 +3310,8 @@ int load_parts(ViewApp* app, map* routes, PyObject* parts, route* r) { while ((item = PyIter_Next(iter))) { ++index; - if (PyObject_IsInstance( - item, - (PyObject*) &PyUnicode_Type + if (PyUnicode_CheckExact( + item )) { // path part const char* str = PyUnicode_AsUTF8(item); diff --git a/src/view/_loader.py b/src/view/_loader.py index 71461a7..38b1448 100644 --- a/src/view/_loader.py +++ b/src/view/_loader.py @@ -3,20 +3,51 @@ import os import runpy import warnings +from dataclasses import _MISSING_TYPE, Field from pathlib import Path -from typing import TYPE_CHECKING, Iterable, get_args + +try: + from types import UnionType +except ImportError: + UnionType = None +from typing import (TYPE_CHECKING, Iterable, NamedTuple, TypedDict, Union, + get_args, get_type_hints) + +try: + from pydantic.fields import ModelField +except ImportError: + from pydantic.fields import FieldInfo as ModelField from ._logging import Internal from ._util import set_load from .exceptions import InvalidBodyError, LoaderWarning -from .routing import Method, Route, RouteInput, _NoDefault +from .routing import BodyParam, Method, Route, RouteInput, _NoDefault from .typing import Any, RouteInputDict, TypeInfo, ValueType +ExtNotRequired = None +try: + from typing import NotRequired +except ImportError: + NotRequired = None + from typing_extensions import NotRequired as ExtNotRequired + +_NOT_REQUIRED_TYPES = [] + +if ExtNotRequired: + _NOT_REQUIRED_TYPES.append(ExtNotRequired) + +if NotRequired: + _NOT_REQUIRED_TYPES.append(NotRequired) + if TYPE_CHECKING: from .app import ViewApp + _TypedDictMeta = None +else: + from typing import _TypedDictMeta __all__ = "load_fs", "load_simple", "finalize" +TypingUnionType = type(Union[str, int]) TYPECODE_ANY = 0 TYPECODE_STR = 1 @@ -26,6 +57,7 @@ TYPECODE_DICT = 5 TYPECODE_NONE = 6 TYPECODE_CLASS = 7 +TYPECODE_CLASSTYPES = 8 _BASIC_CODES = { @@ -43,12 +75,53 @@ - Type Code - Type Object (only set when using a __view_body__ object) - Children (i.e. the `int` part of dict[str, int]) + - Default (only set when typecode is TYPECODE_CLASSTYPES) This can be formatted as so: [(union1_tc, None, []), (union2_tc, None, [(type_tc, obj, [])])] """ +class _ViewNotRequired: + __VIEW_NOREQ__ = 1 + + +def _format_body(vbody_types: dict, *, not_required: set[str] | None = None) -> list[TypeInfo]: + not_required = not_required or set() + if not isinstance(vbody_types, dict): + raise InvalidBodyError( + f"__view_body__ should return a dict, not {type(vbody_types)}", # noqa + ) + + vbody_final = {} + vbody_defaults = {} + + for k, raw_v in vbody_types.items(): + if not isinstance(k, str): + raise InvalidBodyError( + f"all keys returned by __view_body__ should be strings, not {type(k)}" # noqa + ) + + default = _NoDefault + v = raw_v.types if isinstance(raw_v, BodyParam) else raw_v + + if isinstance(raw_v, BodyParam): + default = raw_v.default + + if (getattr(raw_v, "__origin__", None) in _NOT_REQUIRED_TYPES) or (k in not_required): + v = get_args(raw_v) + default = _ViewNotRequired + + iter_v = v if isinstance(v, (tuple, list)) else (v,) + vbody_final[k] = _build_type_codes(iter_v) + vbody_defaults[k] = default + + return [ + (TYPECODE_CLASSTYPES, k, v, vbody_defaults[k]) + for k, v in vbody_final.items() + ] + + def _build_type_codes(inp: Iterable[type[ValueType]]) -> list[TypeInfo]: if not inp: return [] @@ -61,22 +134,104 @@ def _build_type_codes(inp: Iterable[type[ValueType]]) -> list[TypeInfo]: if type_code: codes.append((type_code, None, [])) continue + + if (TypedDict in getattr(tp, "__orig_bases__", [])) or (type(tp) == _TypedDictMeta): + try: + body = get_type_hints(tp) + except KeyError: + body = tp.__annotations__ + + opt = getattr(tp, "__optional_keys__", None) + + class _Transport: + @staticmethod + def __view_construct__(**kwargs): + return kwargs + + codes.append( + ( + TYPECODE_CLASS, + _Transport, + _format_body(body, not_required=opt), + ), + ) + continue + + if (NamedTuple in getattr(tp, "__orig_bases__", [])) or (hasattr(tp, "_field_defaults")): + defaults = tp._field_defaults # type: ignore + tps = {} + try: + hints = get_type_hints(tp) + except KeyError: + hints = getattr(tp, "_field_types", tp.__annotations__) + + for k, v in hints.items(): + if k in defaults: + tps[k] = BodyParam(v, defaults[k]) + else: + tps[k] = v + + codes.append((TYPECODE_CLASS, tp, _format_body(tps))) + continue + + dataclass_fields: dict[str, Field] | None = getattr( + tp, "__dataclass_fields__", None + ) + + if dataclass_fields: + tps = {} + for k, v in dataclass_fields.items(): + if isinstance(v.default, _MISSING_TYPE) and ( + isinstance(v.default_factory, _MISSING_TYPE) + ): + tps[k] = v.type + else: + default = ( + v.default + if not isinstance(v.default, _MISSING_TYPE) + else v.default_factory + ) + tps[k] = BodyParam(v.type, default) + + codes.append((TYPECODE_CLASS, tp, _format_body(tps))) + continue + + pydantic_fields: dict[str, ModelField] | None = getattr( + tp, "__fields__", None + ) or getattr(tp, "model_fields", None) + if pydantic_fields: + tps = {} + + for k, v in pydantic_fields.items(): + if (not v.default) and (not v.default_factory): + tps[k] = v.outer_type_ + else: + tps[k] = BodyParam( + v.outer_type_, + v.default or v.default_factory, + ) + + codes.append((TYPECODE_CLASS, tp, _format_body(tps))) + 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))) - """ - + codes.append((TYPECODE_CLASS, tp, _format_body(vbody_types))) + continue + origin = getattr(tp, "__origin__", None) # typing.GenericAlias + + if (type(tp) in {UnionType, TypingUnionType}) and (origin is not dict): + new_codes = _build_type_codes(get_args(tp)) + codes.extend(new_codes) + continue - if (not origin) and (origin is not dict): + if origin is not dict: raise InvalidBodyError(f"{tp} is not a valid type for routes") key, value = get_args(tp) @@ -191,13 +346,16 @@ def load_fs(app: ViewApp, target_dir: Path): else: path_obj = Path(path) stripped = list( - path_obj.parts[len(target_dir.parts) :] - ) # noqa + path_obj.parts[len(target_dir.parts) :] # noqa + ) if stripped[-1] == "index.py": stripped.pop(len(stripped) - 1) stripped_obj = Path(*stripped) - stripped_path = str(stripped_obj).rsplit(".", maxsplit=1)[0] + stripped_path = str(stripped_obj).rsplit( + ".", + maxsplit=1, + )[0] x.path = "/" + stripped_path for x in current_routes: diff --git a/src/view/_parsers.py b/src/view/_parsers.py index 0b50a64..0ef20af 100644 --- a/src/view/_parsers.py +++ b/src/view/_parsers.py @@ -13,6 +13,7 @@ def query_parser(data: str) -> ViewBody: parsed = parse_qs(data) + final = {} for k, v in parsed.items(): if len(v) == 1: diff --git a/src/view/app.py b/src/view/app.py index d2b1e94..3d01e08 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -4,7 +4,6 @@ import faulthandler import importlib import inspect -import json import logging import os import sys @@ -18,8 +17,9 @@ 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 urllib.parse import quote_plus, urlencode +import ujson from rich import print from rich.traceback import install @@ -92,7 +92,7 @@ async def _request( async def receive(): return { - "body": json.dumps(body).encode(), + "body": ujson.dumps(body).encode(), "more_body": False, "type": "http.request", } @@ -111,12 +111,19 @@ async def send(obj: dict[str, Any]): raise TypeError(f"bad type: {obj['type']}") truncated_route = route[: route.find("?")] if "?" in route else route + query_str = {} + + for k, v in (query or {}).items(): + query_str[k] = v if not isinstance(v, dict) else ujson.dumps(v) + await self.app( { "type": "http", "http_version": "1.1", "path": truncated_route, - "query_string": urlencode(query).encode() if query else b"", + "query_string": urlencode(query_str).encode() + if query + else b"", # noqa "headers": [], "method": method, }, diff --git a/src/view/routing.py b/src/view/routing.py index a43b6db..519e19e 100644 --- a/src/view/routing.py +++ b/src/view/routing.py @@ -22,7 +22,8 @@ "query", "body", "route_types", - "cache" + "cache", + "BodyParam", ) PART = re.compile(r"{(((\w+)(: *(\w+)))|(\w+))}") @@ -40,6 +41,12 @@ class Method(Enum): V = TypeVar("V", bound="ValueType") +@dataclass +class BodyParam(Generic[V]): + types: type[V] | list[type[V]] | tuple[type[V], ...] + default: V + + @dataclass class RouteInput(Generic[V]): name: str @@ -258,7 +265,7 @@ def options( class _NoDefault: - ... + __VIEW_NODEFAULT__ = 1 _NoDefaultType = Type[_NoDefault] diff --git a/src/view/typing.py b/src/view/typing.py index 9b429cf..127498e 100644 --- a/src/view/typing.py +++ b/src/view/typing.py @@ -60,7 +60,11 @@ ValidatorResult = Union[bool, Tuple[bool, str]] Validator = Callable[[V], ValidatorResult] -TypeInfo = Tuple[int, Union[Type[Any], None], List["TypeInfo"]] +TypeObject = Union[Type[Any], None] +TypeInfo = Union[ + Tuple[int, TypeObject, List["TypeInfo"]], + Tuple[int, TypeObject, List["TypeInfo"], Any], +] class RouteInputDict(TypedDict, Generic[V]): @@ -80,16 +84,12 @@ class _SupportsViewBodyCV(Protocol): class _SupportsViewBodyF(Protocol): - def __view_body__(self) -> ViewBody: + @staticmethod + def __view_body__() -> ViewBody: ... -class _SupportsAnnotations(Protocol): - __annotations__: ClassVar[dict[str, Any]] - - -SupportsViewBody = Union[_SupportsViewBodyCV, _SupportsViewBodyF] -ViewBodyLike = Union[SupportsViewBody, _SupportsAnnotations] +ViewBodyLike = Union[_SupportsViewBodyCV, _SupportsViewBodyF] ValueType = Union[ ViewBodyLike, str, @@ -97,7 +97,7 @@ class _SupportsAnnotations(Protocol): Dict[str, "ValueType"], bool, float, - Type[Any], + Any, ] Parser = Callable[[str], ViewBody] diff --git a/tests/test_app.py b/tests/test_app.py index d44ee7b..822d4ae 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,8 +1,11 @@ -from typing import Union +from dataclasses import dataclass, field +from typing import Dict, NamedTuple, TypedDict, Union +from pydantic import BaseModel, Field +from typing_extensions import NotRequired from ward import test -from view import Response, body, new_app, query +from view import BodyParam, Response, body, new_app, query @test("responses") @@ -65,8 +68,8 @@ async def _(): app = new_app() class MyObject: - def __view_result__(self) -> str: - return "hello" + def __view_result__(self): + return "hello", 200 @app.get("/") async def index(): @@ -212,6 +215,178 @@ async def index(): assert res.headers["hello"] == "world" +@test("object validation") +async def _(): + app = new_app() + + @dataclass + class Dataclass: + a: str + b: Union[str, int] + c: Dict[str, int] + d: dict = field(default_factory=dict) + + class Pydantic(BaseModel): + a: str + b: Union[str, int] + c: Dict[str, int] + d: dict = Field(default_factory=dict) + + class ND(NamedTuple): + a: str + b: Union[str, int] + c: Dict[str, int] + + class VB: + __view_body__ = { + "hello": str, + "world": BodyParam((str, int), default="hello"), + } + + @staticmethod + def __view_construct__(hello: str, world: Union[str, int]): + assert isinstance(hello, str) + assert world == "hello" + + class TypedD(TypedDict): + a: str + b: Union[str, int] + c: Dict[str, int] + d: NotRequired[str] + + @app.get("/td") + @app.query("data", TypedD) + async def td(data: TypedD): + assert data["a"] == "1" + assert data["b"] == 2 + assert data["c"]["3"] == 4 + assert "d" not in data + return "hello" + + @app.get("/dc") + @app.query("data", Dataclass) + async def dc(data: Dataclass): + assert data.a == "1" + assert data.b == 2 + assert data.c["3"] == 4 + assert data.d == {} + return "hello" + + @app.get("/pd") + @app.query("data", Pydantic) + async def pd(data: Pydantic): + assert data.a == "1" + assert data.c["3"] == 4 + assert data.d == {} + return "world" + + @app.get("/nd") + @app.query("data", ND) + async def nd(data: ND): + assert data.a == "1" + assert data.b == 2 + assert data.c["3"] == 4 + return "foo" + + @app.get("/vb") + @app.query("data", VB) + async def vb(data: VB): + return "yay" + + class NestedC(NamedTuple): + c: Union[str, int] + + class NestedB(NamedTuple): + b: NestedC + + class NestedA(NamedTuple): + a: NestedB + + @app.get("/nested") + @app.query("data", NestedA) + async def nested(data: NestedA): + assert data.a.b.c in {"hello", 1} + return "hello" + + async with app.test() as test: + assert ( + 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}}} + ) + ).message == "hello" + assert ( + 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}}} + ) + ).message == "foo" + assert ( + await test.get( + "/pd", query={"data": {"a": "1", "b": 2, "c": {"3": "4"}}} + ) + ).status == 400 + 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": {}}} + ) + ).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("/nested", query={"data": {"a": {"b": {"c": {}}}}}) + ).status == 400 + assert ( + await test.get( + "/dc", query={"data": {"a": "1", "b": True, "c": {"3": 4}}} + ) + ).status == 400 + + +@test("dict validation") +async def _(): + app = new_app() + + class Object(NamedTuple): + a: str + b: Union[str, int] + + @app.get("/") + @app.query("data", Dict[str, Object]) + async def index(data: Dict[str, Object]): + assert data["a"].a == "a" + assert data["b"].b in {"a", 1} + return "hello" + + async with app.test() as test: + assert ( + await test.get( + "/", + query={"a": {"a": "a", "b": "b"}, "b": {"a": "a", "b": "a"}}, + ) + ).message + + @test("non async routes") async def _(): app = new_app()