Skip to content

Commit

Permalink
Merge pull request #180 from ZeroIntensity/send-bytes
Browse files Browse the repository at this point in the history
Send Bytes
  • Loading branch information
ZeroIntensity committed May 28, 2024
2 parents c5aa656 + 256f914 commit 08858d9
Show file tree
Hide file tree
Showing 34 changed files with 900 additions and 287 deletions.
41 changes: 41 additions & 0 deletions .github/workflows/memory_check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Memory Check

on:
push:
branches:
- master
pull_request:
branches:
- master

env:
PYTHONUNBUFFERED: "1"
FORCE_COLOR: "1"
PYTHONIOENCODING: "utf8"

jobs:
run:
name: Valgrind on Ubuntu
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2

- name: Set up Python 3.12
uses: actions/setup-python@v2
with:
python-version: 3.12

- name: Install PyTest
run: |
pip install pytest pytest-asyncio
shell: bash

- name: Build project
run: pip install .[full]

- name: Install Valgrind
run: sudo apt-get -y install valgrind

- name: Run tests with Valgrind
run: valgrind --suppressions=valgrind-python.supp --error-exitcode=1 pytest -x
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for coroutines in `PyAwaitable` (vendored)
- Finished websocket implementation
- Added the `custom` loader
- Added support for returning `bytes` objects in the body.
- **Breaking Change:** Removed the `hijack` configuration setting
- **Breaking Change:** Removed the `post_init` parameter from `new_app`, as well as renamed the `store_address` parameter to `store`.
- **Breaking Change:** `load()` now takes routes via variadic arguments, instead of a list of routes.

## [1.0.0-alpha10] - 2024-5-26

Expand Down
46 changes: 32 additions & 14 deletions docs/building-projects/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
```
Expand All @@ -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"}`)

Expand Down
25 changes: 18 additions & 7 deletions src/_view/results.c
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ static int find_result_for(
const char* tmp = PyUnicode_AsUTF8(target);
if (!tmp) return -1;
*res_str = strdup(tmp);
} else if (Py_IS_TYPE(
target,
&PyBytes_Type
)) {
const char* tmp = PyBytes_AsString(target);
if (!tmp) return -1;
*res_str = strdup(tmp);
} else if (Py_IS_TYPE(
target,
&PyDict_Type
Expand Down Expand Up @@ -63,8 +70,6 @@ static int find_result_for(
return -1;
};

Py_DECREF(item_bytes);

PyObject* v_bytes = PyBytes_FromString(v_str);

if (!v_bytes) {
Expand All @@ -81,8 +86,6 @@ static int find_result_for(
return -1;
};

Py_DECREF(v_bytes);

if (PyList_Append(
headers,
header_list
Expand Down Expand Up @@ -131,7 +134,7 @@ static int find_result_for(
} else {
PyErr_SetString(
PyExc_TypeError,
"returned tuple should only contain a str, int, or dict"
"returned tuple should only contain a str, bytes, int, or dict"
);
return -1;
}
Expand Down Expand Up @@ -168,6 +171,10 @@ static int handle_result_impl(
const char* tmp = PyUnicode_AsUTF8(result);
if (!tmp) return -1;
res_str = strdup(tmp);
} else if (PyBytes_CheckExact(result)) {
const char* tmp = PyBytes_AsString(result);
if (!tmp) return -1;
res_str = strdup(tmp);
} else if (PyTuple_CheckExact(
result
)) {
Expand Down Expand Up @@ -254,11 +261,15 @@ int handle_result(
method
);

if (!PyObject_Call(route_log, args, NULL)) {
if (!PyObject_Call(
route_log,
args,
NULL
)) {
Py_DECREF(args);
return -1;
}
Py_DECREF(args);

return res;
}
}
1 change: 0 additions & 1 deletion src/_view/routing.c
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,6 @@ int handle_route_callback(
if (!dct)
return -1;


coro = PyObject_Vectorcall(
send,
(PyObject*[]) { dct },
Expand Down
6 changes: 2 additions & 4 deletions src/view/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,17 @@
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

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 *
Expand Down
3 changes: 1 addition & 2 deletions src/view/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,7 @@ def main(ctx: click.Context, debug: bool, version: bool) -> None:


@main.group()
def logs():
...
def logs(): ...


@logs.command()
Expand Down
40 changes: 15 additions & 25 deletions src/view/_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@
from typing import _eval_type
else:

def _eval_type(*args) -> Any:
...
def _eval_type(*args) -> Any: ...


import inspect

from typing_extensions import get_origin

from ._logging import Internal
from ._util import docs_hint, is_annotated, is_union, set_load
from .exceptions import (DuplicateRouteError, InvalidBodyError,
Expand All @@ -38,7 +39,6 @@ def _eval_type(*args) -> Any:
NotRequired = None
from typing_extensions import NotRequired as ExtNotRequired

from typing_extensions import get_origin

_NOT_REQUIRED_TYPES: list[Any] = []

Expand Down Expand Up @@ -193,7 +193,7 @@ def _build_type_codes(

for tp in inp:
tps: dict[str, type[Any] | BodyParam]

if is_annotated(tp):
if doc is None:
raise InvalidBodyError(f"Annotated is not valid here ({tp})")
Expand Down Expand Up @@ -222,7 +222,7 @@ def _build_type_codes(
codes.append((type_code, None, []))
continue

if (TypedDict in getattr(tp, "__orig_bases__", [])) or (
if (TypedDict in getattr(tp, "__orig_bases__", [])) or ( # type: ignore
type(tp) == _TypedDictMeta
):
try:
Expand Down Expand Up @@ -347,9 +347,7 @@ def __view_construct__(**kwargs):
vbody_types = vbody

doc = {}
codes.append(
(TYPECODE_CLASS, tp, _format_body(vbody_types, doc, tp))
)
codes.append((TYPECODE_CLASS, tp, _format_body(vbody_types, doc, tp)))
setattr(tp, "_view_doc", doc)
continue

Expand All @@ -363,9 +361,7 @@ def __view_construct__(**kwargs):
key, value = get_args(tp)

if key is not str:
raise InvalidBodyError(
f"dictionary keys must be strings, not {key}"
)
raise InvalidBodyError(f"dictionary keys must be strings, not {key}")

tp_codes = _build_type_codes((value,))
codes.append((TYPECODE_DICT, None, tp_codes))
Expand Down Expand Up @@ -405,7 +401,7 @@ def _format_inputs(
return result


def finalize(routes: list[Route], app: ViewApp):
def finalize(routes: Iterable[Route], app: ViewApp):
"""Attach list of routes to an app and validate all parameters.
Args:
Expand Down Expand Up @@ -433,9 +429,7 @@ def finalize(routes: list[Route], app: ViewApp):

for step in route.steps or []:
if step not in app.config.build.steps:
raise UnknownBuildStepError(
f"build step {step!r} is not defined"
)
raise UnknownBuildStepError(f"build step {step!r} is not defined")

if route.method:
target = targets[route.method]
Expand All @@ -444,7 +438,9 @@ def finalize(routes: list[Route], app: ViewApp):
for i in route.inputs:
if isinstance(i, RouteInput):
if i.is_body:
raise InvalidRouteError(f"websocket routes cannot have body inputs")
raise InvalidRouteError(
f"websocket routes cannot have body inputs"
)
else:
target = None

Expand All @@ -466,7 +462,6 @@ def finalize(routes: list[Route], app: ViewApp):
sig = inspect.signature(route.func)
route.inputs = [i for i in reversed(route.inputs)]


if len(sig.parameters) != len(route.inputs):
names = [i.name for i in route.inputs if isinstance(i, RouteInput)]
index = 0
Expand All @@ -482,9 +477,7 @@ def finalize(routes: list[Route], app: ViewApp):
route.inputs.insert(index, 1)
continue

default = (
v.default if v.default is not inspect._empty else _NoDefault
)
default = v.default if v.default is not inspect._empty else _NoDefault

route.inputs.insert(
index,
Expand Down Expand Up @@ -578,9 +571,7 @@ def load_fs(app: ViewApp, target_dir: Path) -> None:
)
else:
path_obj = Path(path)
stripped = list(
path_obj.parts[len(target_dir.parts) :]
) # noqa
stripped = list(path_obj.parts[len(target_dir.parts) :]) # noqa
if stripped[-1] == "index.py":
stripped.pop(len(stripped) - 1)

Expand Down Expand Up @@ -633,8 +624,7 @@ def load_simple(app: ViewApp, target_dir: Path) -> None:
for route in mini_routes:
if not route.path:
raise InvalidRouteError(
"omitting path is only supported"
" on filesystem loading",
"omitting path is only supported" " on filesystem loading",
)

routes.append(route)
Expand Down
Loading

0 comments on commit 08858d9

Please sign in to comment.