Skip to content

Commit c5aa656

Browse files
Merge pull request #179 from ZeroIntensity/custom-loaders
Custom Loaders
2 parents 9b8e5c2 + 697dfc6 commit c5aa656

File tree

7 files changed

+121
-16
lines changed

7 files changed

+121
-16
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Added `.gitignore` generation to `view init`
1313
- Added support for coroutines in `PyAwaitable` (vendored)
1414
- Finished websocket implementation
15+
- Added the `custom` loader
1516
- **Breaking Change:** Removed the `hijack` configuration setting
17+
- **Breaking Change:** Removed the `post_init` parameter from `new_app`, as well as renamed the `store_address` parameter to `store`.
1618

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

docs/building-projects/routing.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
## Loaders
44

5-
Routing is a big part of any web library, and there are many ways to do it. View does it's best to support as many methods as possible to give you a well-rounded approach to routing. In view, your choice of routing is called the loader/loader strategy, and there are four of them:
5+
Routing is a big part of any web library, and there are many ways to do it. View does it's best to support as many methods as possible to give you a well-rounded approach to routing. In view, your choice of routing is called the loader/loader strategy, and there are five of them:
66

77
- `manual`
88
- `simple`
99
- `filesystem`
1010
- `patterns`
11+
- `custom`
1112

1213
## Manually Routing
1314

@@ -198,10 +199,62 @@ def index():
198199
return "Hello, view.py!"
199200
```
200201

202+
## Custom Routing
203+
204+
The `custom` loader is, you guessed it, a user-defined loader. To start, decorate a function with `custom_loader`:
205+
206+
```py
207+
from pathlib import Path
208+
from typing import Iterable
209+
from view import Route, new_app
210+
211+
app = new_app()
212+
213+
@app.custom_loader
214+
def my_loader(app: App, path: Path) -> Iterable[Route]:
215+
return [...]
216+
217+
app.run()
218+
```
219+
220+
As shown above, there are two parameters to the `custom_loader` callback:
221+
222+
- The `App` instance.
223+
- The `Path` set by the `loader_path` config setting.
224+
225+
The `custom_loader` callback is expected to return a list (or any iterable) of collected routes.
226+
227+
!!! tip "Don't reimplement router functions!"
228+
229+
You might be confused about the `Route` constructor. That's because it's undocumented, and still technically a private API (meaning it can change at any time, for no reason). Don't try and instantiate a route yourself! Instead, let router functions do it (e.g. `get` or `query`), and collect the functions (or really, `Route` instances)
230+
231+
For example, if you wanted to implement a loader that added one route:
232+
233+
```py
234+
from pathlib import Path
235+
from typing import Iterable
236+
from view import Route, new_app, get
237+
238+
app = new_app()
239+
240+
@app.custom_loader
241+
def my_loader(app: App, path: Path) -> Iterable[Route]:
242+
# Disregarding the app and path here! Don't do that!
243+
@get("/my_route")
244+
def my_route():
245+
return "Hello from my loader!"
246+
247+
return [my_route]
248+
249+
app.run()
250+
```
251+
201252
## Review
202253

203254
In view, a loader is defined as the method of routing used. There are three loaders in view.py: `manual`, `simple`, and `filesystem`.
204255

205256
- `manual` is good for small projects that are similar to Python libraries like [Flask](https://flask.palletsprojects.com/en/3.0.x/) or [FastAPI](https://fastapi.tiangolo.com).
206257
- `simple` routing is the recommended loader for full-scale view.py applications.
207258
- `filesystem` routing is similar to how JavaScript frameworks like [NextJS](https://nextjs.org) handle routing.
259+
- `patterns` is similar to [Django](https://djangoproject.com/) routing.
260+
- `custom` let's you decide - you can make your own loader and figure it out as you please.

src/view/app.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@
1818
from threading import Thread
1919
from types import FrameType as Frame
2020
from types import TracebackType as Traceback
21-
from typing import (Any, AsyncIterator, Callable, Coroutine, Generic, Iterable,
22-
TextIO, TypeVar, get_type_hints, overload)
21+
from typing import (Any, AsyncIterator, Callable, Coroutine, Generic,
22+
TextIO, TypeVar, get_type_hints, overload, Iterable)
2323
from urllib.parse import urlencode
24+
from collections.abc import Iterable as CollectionsIterable
2425

2526
import ujson
2627
from rich import print
@@ -38,7 +39,7 @@
3839
from ._util import make_hint, needs_dep
3940
from .build import build_app, build_steps
4041
from .config import Config, load_config
41-
from .exceptions import BadEnvironmentError, ViewError, ViewInternalError
42+
from .exceptions import BadEnvironmentError, ViewError, ViewInternalError, InvalidCustomLoaderError
4243
from .logging import _LogArgs, log
4344
from .response import HTML
4445
from .routing import Path as _RouteDeco
@@ -69,6 +70,7 @@
6970
_ConfigSpecified = None
7071

7172
B = TypeVar("B", bound=BaseException)
73+
CustomLoader: TypeAlias = Callable[["App", Path], Iterable[Route]]
7274

7375
ERROR_CODES: tuple[int, ...] = (
7476
400,
@@ -472,6 +474,7 @@ def __init__(
472474
self.loaded_routes: list[Route] = []
473475
self.templaters: dict[str, Any] = {}
474476
self._register_error(error_class)
477+
self._user_loader: CustomLoader | None = None
475478

476479
os.environ.update({k: str(v) for k, v in config.env.items()})
477480

@@ -594,6 +597,9 @@ def inner(r: RouteOrCallable[P]) -> Route[P]:
594597

595598
return inner
596599

600+
def custom_loader(self, loader: CustomLoader):
601+
self._user_loader = loader
602+
597603
def _method_wrapper(
598604
self,
599605
path: str,
@@ -1002,6 +1008,16 @@ def load(self, routes: list[Route] | None = None) -> None:
10021008
load_simple(self, self.config.app.loader_path)
10031009
elif self.config.app.loader == "patterns":
10041010
load_patterns(self, self.config.app.loader_path)
1011+
elif self.config.app.loader == "custom":
1012+
if not self._user_loader:
1013+
raise InvalidCustomLoaderError("custom loader was not set")
1014+
1015+
routes = self._user_loader(self, self.config.app.loader_path)
1016+
if not isinstance(routes, CollectionsIterable):
1017+
raise InvalidCustomLoaderError(
1018+
f"expected custom loader to return a list of routes, got {routes!r}"
1019+
)
1020+
finalize([i for i in routes], self)
10051021
else:
10061022
finalize([*(routes or ()), *self._manual_routes], self)
10071023

@@ -1313,15 +1329,15 @@ def docs(
13131329

13141330
return None
13151331

1332+
_last_app: App | None = None
13161333

13171334
def new_app(
13181335
*,
13191336
start: bool = False,
13201337
config_path: Path | str | None = None,
13211338
config_directory: Path | str | None = None,
1322-
post_init: Callback | None = None,
13231339
app_dealloc: Callback | None = None,
1324-
store_address: bool = True,
1340+
store: bool = True,
13251341
config: Config | None = None,
13261342
error_class: type[Error] = Error,
13271343
) -> App:
@@ -1331,9 +1347,8 @@ def new_app(
13311347
start: Should the app be started automatically? (In a new thread)
13321348
config_path: Path of the target configuration file
13331349
config_directory: Directory path to search for a configuration
1334-
post_init: Callback to run after the App instance has been created
13351350
app_dealloc: Callback to run when the App instance is freed from memory
1336-
store_address: Whether to store the address of the instance to allow use from get_app
1351+
store: Whether to store the app, to allow use from get_app()
13371352
config: Raw `Config` object to use instead of loading the config.
13381353
error_class: Class to be recognized as the view.py HTTP error object.
13391354
"""
@@ -1344,9 +1359,6 @@ def new_app(
13441359

13451360
app = App(config, error_class=error_class)
13461361

1347-
if post_init:
1348-
post_init()
1349-
13501362
if start:
13511363
app.run_threaded()
13521364

@@ -1359,7 +1371,7 @@ def finalizer():
13591371

13601372
weakref.finalize(app, finalizer)
13611373

1362-
if store_address:
1374+
if store:
13631375
os.environ["_VIEW_APP_ADDRESS"] = str(id(app))
13641376
# id() on cpython returns the address, but it is
13651377
# implementation dependent however, view.py

src/view/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
# https://github.com/python/mypy/issues/11036
3434
class AppConfig(ConfigModel, env_prefix="view_app_"): # type: ignore
35-
loader: Literal["manual", "simple", "filesystem", "patterns"] = "manual"
35+
loader: Literal["manual", "simple", "filesystem", "patterns", "custom"] = "manual"
3636
app_path: str = ConfigField("app.py:app")
3737
uvloop: Union[Literal["decide"], bool] = "decide"
3838
loader_path: Path = Path("./routes")

src/view/exceptions.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"WebSocketError",
3030
"WebSocketExpectError",
3131
"WebSocketHandshakeError",
32+
"InvalidCustomLoaderError",
3233
)
3334

3435

@@ -138,4 +139,7 @@ class WebSocketHandshakeError(WebSocketError):
138139
"""WebSocket handshake went wrong somehow."""
139140

140141
class WebSocketExpectError(WebSocketError, AssertionError, TypeError):
141-
"""WebSocket received unexpected message."""
142+
"""WebSocket received unexpected message."""
143+
144+
class InvalidCustomLoaderError(ViewError):
145+
"""Custom loader is invalid."""

src/view/routing.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"context",
3838
"route",
3939
"websocket",
40+
"Route",
4041
)
4142

4243
PART = re.compile(r"{(((\w+)(: *(\w+)))|(\w+))}")

tests/test_loaders.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from pathlib import Path
22

33
import pytest
4-
5-
from view import delete, get, new_app, options, patch, post, put
4+
from typing import List
5+
from view import delete, get, new_app, options, patch, post, put, App, Route, InvalidCustomLoaderError
66

77
@pytest.mark.asyncio
88
async def test_manual_loader():
@@ -83,3 +83,36 @@ async def test_patterns_loader():
8383
assert (await test.options("/options")).message == "options"
8484
assert (await test.options("/any")).message == "any"
8585
assert (await test.post("/inputs", query={"a": "a"})).message == "a"
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_custom_loader():
90+
app = new_app()
91+
app.config.app.loader = "custom"
92+
93+
@app.custom_loader
94+
def my_loader(app: App, path: Path) -> List[Route]:
95+
@get("/")
96+
async def index():
97+
return "test"
98+
99+
return [index]
100+
101+
async with app.test() as test:
102+
assert (await test.get("/")).message == "test"
103+
104+
105+
@pytest.mark.asyncio
106+
def test_custom_loader_errors():
107+
app = new_app()
108+
app.config.app.loader = "custom"
109+
110+
with pytest.raises(InvalidCustomLoaderError):
111+
app.load()
112+
113+
@app.custom_loader
114+
def my_loader(app: App, path: Path) -> List[Route]:
115+
return 123
116+
117+
with pytest.raises(InvalidCustomLoaderError):
118+
app.load()

0 commit comments

Comments
 (0)