Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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