Skip to content

Commit 08858d9

Browse files
Merge pull request #180 from ZeroIntensity/send-bytes
Send Bytes
2 parents c5aa656 + 256f914 commit 08858d9

34 files changed

+900
-287
lines changed

.github/workflows/memory_check.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Memory Check
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
pull_request:
8+
branches:
9+
- master
10+
11+
env:
12+
PYTHONUNBUFFERED: "1"
13+
FORCE_COLOR: "1"
14+
PYTHONIOENCODING: "utf8"
15+
16+
jobs:
17+
run:
18+
name: Valgrind on Ubuntu
19+
runs-on: ubuntu-latest
20+
21+
steps:
22+
- uses: actions/checkout@v2
23+
24+
- name: Set up Python 3.12
25+
uses: actions/setup-python@v2
26+
with:
27+
python-version: 3.12
28+
29+
- name: Install PyTest
30+
run: |
31+
pip install pytest pytest-asyncio
32+
shell: bash
33+
34+
- name: Build project
35+
run: pip install .[full]
36+
37+
- name: Install Valgrind
38+
run: sudo apt-get -y install valgrind
39+
40+
- name: Run tests with Valgrind
41+
run: valgrind --suppressions=valgrind-python.supp --error-exitcode=1 pytest -x

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Added support for coroutines in `PyAwaitable` (vendored)
1414
- Finished websocket implementation
1515
- Added the `custom` loader
16+
- Added support for returning `bytes` objects in the body.
1617
- **Breaking Change:** Removed the `hijack` configuration setting
1718
- **Breaking Change:** Removed the `post_init` parameter from `new_app`, as well as renamed the `store_address` parameter to `store`.
19+
- **Breaking Change:** `load()` now takes routes via variadic arguments, instead of a list of routes.
1820

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

docs/building-projects/responses.md

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Basic Responses
44

5-
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.
5+
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.
66

77
```py
88
from view import new_app
@@ -242,32 +242,50 @@ class ListResponse(Response[list]):
242242

243243
## Middleware
244244

245-
### What is middleware?
245+
### The Middleware API
246246

247-
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.
247+
`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.
248248

249-
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.
249+
```py
250+
from view import new_app, CallNext
250251

251-
!!! question "Why no `call_next`?"
252+
app = new_app()
252253

253-
view.py doesn't use the `call_next` function because of the nature of it's routing system.
254+
@app.get("/")
255+
def index():
256+
return "my response!"
254257

255-
### The Middleware API
258+
@index.middleware
259+
async def index_middleware(call_next: CallNext):
260+
print("this is called before index()!")
261+
res = await call_next()
262+
print("this is called after index()!")
263+
return res
256264

257-
`Route.middleware` is used to define a middleware function for a route.
265+
app.run()
266+
```
267+
268+
### Response Parsing
269+
270+
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:
258271

259272
```py
260-
from view import new_app
273+
from view import new_app, CallNext, to_response
274+
from time import perf_counter
261275

262276
app = new_app()
263277

264278
@app.get("/")
265-
async def index():
266-
...
279+
def index():
280+
return "my response!"
267281

268282
@index.middleware
269-
async def index_middleware():
270-
print("this is called before index()!")
283+
async def took_time_middleware(call_next: CallNext):
284+
a = perf_counter()
285+
res = to_response(await call_next())
286+
b = perf_counter()
287+
res.headers["X-Time-Elapsed"] = str(b - a)
288+
return res
271289

272290
app.run()
273291
```
@@ -276,7 +294,7 @@ app.run()
276294

277295
Responses can be returned with a string, integer, and/or dictionary in any order.
278296

279-
- The string represents the body of the response (e.g. the HTML or JSON)
297+
- The string represents the body of the response (e.g. HTML or JSON)
280298
- The integer represents the status code (200 by default)
281299
- The dictionary represents the headers (e.g. `{"x-www-my-header": "some value"}`)
282300

src/_view/results.c

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ static int find_result_for(
1818
const char* tmp = PyUnicode_AsUTF8(target);
1919
if (!tmp) return -1;
2020
*res_str = strdup(tmp);
21+
} else if (Py_IS_TYPE(
22+
target,
23+
&PyBytes_Type
24+
)) {
25+
const char* tmp = PyBytes_AsString(target);
26+
if (!tmp) return -1;
27+
*res_str = strdup(tmp);
2128
} else if (Py_IS_TYPE(
2229
target,
2330
&PyDict_Type
@@ -63,8 +70,6 @@ static int find_result_for(
6370
return -1;
6471
};
6572

66-
Py_DECREF(item_bytes);
67-
6873
PyObject* v_bytes = PyBytes_FromString(v_str);
6974

7075
if (!v_bytes) {
@@ -81,8 +86,6 @@ static int find_result_for(
8186
return -1;
8287
};
8388

84-
Py_DECREF(v_bytes);
85-
8689
if (PyList_Append(
8790
headers,
8891
header_list
@@ -131,7 +134,7 @@ static int find_result_for(
131134
} else {
132135
PyErr_SetString(
133136
PyExc_TypeError,
134-
"returned tuple should only contain a str, int, or dict"
137+
"returned tuple should only contain a str, bytes, int, or dict"
135138
);
136139
return -1;
137140
}
@@ -168,6 +171,10 @@ static int handle_result_impl(
168171
const char* tmp = PyUnicode_AsUTF8(result);
169172
if (!tmp) return -1;
170173
res_str = strdup(tmp);
174+
} else if (PyBytes_CheckExact(result)) {
175+
const char* tmp = PyBytes_AsString(result);
176+
if (!tmp) return -1;
177+
res_str = strdup(tmp);
171178
} else if (PyTuple_CheckExact(
172179
result
173180
)) {
@@ -254,11 +261,15 @@ int handle_result(
254261
method
255262
);
256263

257-
if (!PyObject_Call(route_log, args, NULL)) {
264+
if (!PyObject_Call(
265+
route_log,
266+
args,
267+
NULL
268+
)) {
258269
Py_DECREF(args);
259270
return -1;
260271
}
261272
Py_DECREF(args);
262273

263274
return res;
264-
}
275+
}

src/_view/routing.c

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,6 @@ int handle_route_callback(
443443
if (!dct)
444444
return -1;
445445

446-
447446
coro = PyObject_Vectorcall(
448447
send,
449448
(PyObject*[]) { dct },

src/view/__init__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,17 @@
88
try:
99
import _view
1010
except ModuleNotFoundError as e:
11-
raise ImportError(
12-
"_view has not been built, did you forget to compile it?"
13-
) from e
11+
raise ImportError("_view has not been built, did you forget to compile it?") from e
1412

1513
# these are re-exports
1614
from _view import Context, InvalidStatusError
1715

1816
from . import _codec
1917
from .__about__ import *
2018
from .app import *
19+
from .build import *
2120
from .components import *
2221
from .default_page import *
23-
from .build import *
2422
from .exceptions import *
2523
from .logging import *
2624
from .patterns import *

src/view/__main__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,7 @@ def main(ctx: click.Context, debug: bool, version: bool) -> None:
116116

117117

118118
@main.group()
119-
def logs():
120-
...
119+
def logs(): ...
121120

122121

123122
@logs.command()

src/view/_loader.py

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@
1616
from typing import _eval_type
1717
else:
1818

19-
def _eval_type(*args) -> Any:
20-
...
19+
def _eval_type(*args) -> Any: ...
2120

2221

2322
import inspect
2423

24+
from typing_extensions import get_origin
25+
2526
from ._logging import Internal
2627
from ._util import docs_hint, is_annotated, is_union, set_load
2728
from .exceptions import (DuplicateRouteError, InvalidBodyError,
@@ -38,7 +39,6 @@ def _eval_type(*args) -> Any:
3839
NotRequired = None
3940
from typing_extensions import NotRequired as ExtNotRequired
4041

41-
from typing_extensions import get_origin
4242

4343
_NOT_REQUIRED_TYPES: list[Any] = []
4444

@@ -193,7 +193,7 @@ def _build_type_codes(
193193

194194
for tp in inp:
195195
tps: dict[str, type[Any] | BodyParam]
196-
196+
197197
if is_annotated(tp):
198198
if doc is None:
199199
raise InvalidBodyError(f"Annotated is not valid here ({tp})")
@@ -222,7 +222,7 @@ def _build_type_codes(
222222
codes.append((type_code, None, []))
223223
continue
224224

225-
if (TypedDict in getattr(tp, "__orig_bases__", [])) or (
225+
if (TypedDict in getattr(tp, "__orig_bases__", [])) or ( # type: ignore
226226
type(tp) == _TypedDictMeta
227227
):
228228
try:
@@ -347,9 +347,7 @@ def __view_construct__(**kwargs):
347347
vbody_types = vbody
348348

349349
doc = {}
350-
codes.append(
351-
(TYPECODE_CLASS, tp, _format_body(vbody_types, doc, tp))
352-
)
350+
codes.append((TYPECODE_CLASS, tp, _format_body(vbody_types, doc, tp)))
353351
setattr(tp, "_view_doc", doc)
354352
continue
355353

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

365363
if key is not str:
366-
raise InvalidBodyError(
367-
f"dictionary keys must be strings, not {key}"
368-
)
364+
raise InvalidBodyError(f"dictionary keys must be strings, not {key}")
369365

370366
tp_codes = _build_type_codes((value,))
371367
codes.append((TYPECODE_DICT, None, tp_codes))
@@ -405,7 +401,7 @@ def _format_inputs(
405401
return result
406402

407403

408-
def finalize(routes: list[Route], app: ViewApp):
404+
def finalize(routes: Iterable[Route], app: ViewApp):
409405
"""Attach list of routes to an app and validate all parameters.
410406
411407
Args:
@@ -433,9 +429,7 @@ def finalize(routes: list[Route], app: ViewApp):
433429

434430
for step in route.steps or []:
435431
if step not in app.config.build.steps:
436-
raise UnknownBuildStepError(
437-
f"build step {step!r} is not defined"
438-
)
432+
raise UnknownBuildStepError(f"build step {step!r} is not defined")
439433

440434
if route.method:
441435
target = targets[route.method]
@@ -444,7 +438,9 @@ def finalize(routes: list[Route], app: ViewApp):
444438
for i in route.inputs:
445439
if isinstance(i, RouteInput):
446440
if i.is_body:
447-
raise InvalidRouteError(f"websocket routes cannot have body inputs")
441+
raise InvalidRouteError(
442+
f"websocket routes cannot have body inputs"
443+
)
448444
else:
449445
target = None
450446

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

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

485-
default = (
486-
v.default if v.default is not inspect._empty else _NoDefault
487-
)
480+
default = v.default if v.default is not inspect._empty else _NoDefault
488481

489482
route.inputs.insert(
490483
index,
@@ -578,9 +571,7 @@ def load_fs(app: ViewApp, target_dir: Path) -> None:
578571
)
579572
else:
580573
path_obj = Path(path)
581-
stripped = list(
582-
path_obj.parts[len(target_dir.parts) :]
583-
) # noqa
574+
stripped = list(path_obj.parts[len(target_dir.parts) :]) # noqa
584575
if stripped[-1] == "index.py":
585576
stripped.pop(len(stripped) - 1)
586577

@@ -633,8 +624,7 @@ def load_simple(app: ViewApp, target_dir: Path) -> None:
633624
for route in mini_routes:
634625
if not route.path:
635626
raise InvalidRouteError(
636-
"omitting path is only supported"
637-
" on filesystem loading",
627+
"omitting path is only supported" " on filesystem loading",
638628
)
639629

640630
routes.append(route)

0 commit comments

Comments
 (0)