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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `.gitignore` generation to `view init`
- Added support for coroutines in `PyAwaitable` (vendored)
- Finished websocket implementation
- Added the `custom` loader
- **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`.

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

Expand Down
55 changes: 54 additions & 1 deletion docs/building-projects/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

## Loaders

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:
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:

- `manual`
- `simple`
- `filesystem`
- `patterns`
- `custom`

## Manually Routing

Expand Down Expand Up @@ -198,10 +199,62 @@ def index():
return "Hello, view.py!"
```

## Custom Routing

The `custom` loader is, you guessed it, a user-defined loader. To start, decorate a function with `custom_loader`:

```py
from pathlib import Path
from typing import Iterable
from view import Route, new_app

app = new_app()

@app.custom_loader
def my_loader(app: App, path: Path) -> Iterable[Route]:
return [...]

app.run()
```

As shown above, there are two parameters to the `custom_loader` callback:

- The `App` instance.
- The `Path` set by the `loader_path` config setting.

The `custom_loader` callback is expected to return a list (or any iterable) of collected routes.

!!! tip "Don't reimplement router functions!"

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)

For example, if you wanted to implement a loader that added one route:

```py
from pathlib import Path
from typing import Iterable
from view import Route, new_app, get

app = new_app()

@app.custom_loader
def my_loader(app: App, path: Path) -> Iterable[Route]:
# Disregarding the app and path here! Don't do that!
@get("/my_route")
def my_route():
return "Hello from my loader!"

return [my_route]

app.run()
```

## Review

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

- `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).
- `simple` routing is the recommended loader for full-scale view.py applications.
- `filesystem` routing is similar to how JavaScript frameworks like [NextJS](https://nextjs.org) handle routing.
- `patterns` is similar to [Django](https://djangoproject.com/) routing.
- `custom` let's you decide - you can make your own loader and figure it out as you please.
34 changes: 23 additions & 11 deletions src/view/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
from threading import Thread
from types import FrameType as Frame
from types import TracebackType as Traceback
from typing import (Any, AsyncIterator, Callable, Coroutine, Generic, Iterable,
TextIO, TypeVar, get_type_hints, overload)
from typing import (Any, AsyncIterator, Callable, Coroutine, Generic,
TextIO, TypeVar, get_type_hints, overload, Iterable)
from urllib.parse import urlencode
from collections.abc import Iterable as CollectionsIterable

import ujson
from rich import print
Expand All @@ -38,7 +39,7 @@
from ._util import make_hint, needs_dep
from .build import build_app, build_steps
from .config import Config, load_config
from .exceptions import BadEnvironmentError, ViewError, ViewInternalError
from .exceptions import BadEnvironmentError, ViewError, ViewInternalError, InvalidCustomLoaderError
from .logging import _LogArgs, log
from .response import HTML
from .routing import Path as _RouteDeco
Expand Down Expand Up @@ -69,6 +70,7 @@
_ConfigSpecified = None

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

ERROR_CODES: tuple[int, ...] = (
400,
Expand Down Expand Up @@ -472,6 +474,7 @@ def __init__(
self.loaded_routes: list[Route] = []
self.templaters: dict[str, Any] = {}
self._register_error(error_class)
self._user_loader: CustomLoader | None = None

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

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

return inner

def custom_loader(self, loader: CustomLoader):
self._user_loader = loader

def _method_wrapper(
self,
path: str,
Expand Down Expand Up @@ -1002,6 +1008,16 @@ def load(self, routes: list[Route] | None = None) -> None:
load_simple(self, self.config.app.loader_path)
elif self.config.app.loader == "patterns":
load_patterns(self, self.config.app.loader_path)
elif self.config.app.loader == "custom":
if not self._user_loader:
raise InvalidCustomLoaderError("custom loader was not set")

routes = self._user_loader(self, self.config.app.loader_path)
if not isinstance(routes, CollectionsIterable):
raise InvalidCustomLoaderError(
f"expected custom loader to return a list of routes, got {routes!r}"
)
finalize([i for i in routes], self)
else:
finalize([*(routes or ()), *self._manual_routes], self)

Expand Down Expand Up @@ -1313,15 +1329,15 @@ def docs(

return None

_last_app: App | None = None

def new_app(
*,
start: bool = False,
config_path: Path | str | None = None,
config_directory: Path | str | None = None,
post_init: Callback | None = None,
app_dealloc: Callback | None = None,
store_address: bool = True,
store: bool = True,
config: Config | None = None,
error_class: type[Error] = Error,
) -> App:
Expand All @@ -1331,9 +1347,8 @@ def new_app(
start: Should the app be started automatically? (In a new thread)
config_path: Path of the target configuration file
config_directory: Directory path to search for a configuration
post_init: Callback to run after the App instance has been created
app_dealloc: Callback to run when the App instance is freed from memory
store_address: Whether to store the address of the instance to allow use from get_app
store: Whether to store the app, to allow use from get_app()
config: Raw `Config` object to use instead of loading the config.
error_class: Class to be recognized as the view.py HTTP error object.
"""
Expand All @@ -1344,9 +1359,6 @@ def new_app(

app = App(config, error_class=error_class)

if post_init:
post_init()

if start:
app.run_threaded()

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

weakref.finalize(app, finalizer)

if store_address:
if store:
os.environ["_VIEW_APP_ADDRESS"] = str(id(app))
# id() on cpython returns the address, but it is
# implementation dependent however, view.py
Expand Down
2 changes: 1 addition & 1 deletion src/view/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

# https://github.com/python/mypy/issues/11036
class AppConfig(ConfigModel, env_prefix="view_app_"): # type: ignore
loader: Literal["manual", "simple", "filesystem", "patterns"] = "manual"
loader: Literal["manual", "simple", "filesystem", "patterns", "custom"] = "manual"
app_path: str = ConfigField("app.py:app")
uvloop: Union[Literal["decide"], bool] = "decide"
loader_path: Path = Path("./routes")
Expand Down
6 changes: 5 additions & 1 deletion src/view/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"WebSocketError",
"WebSocketExpectError",
"WebSocketHandshakeError",
"InvalidCustomLoaderError",
)


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

class WebSocketExpectError(WebSocketError, AssertionError, TypeError):
"""WebSocket received unexpected message."""
"""WebSocket received unexpected message."""

class InvalidCustomLoaderError(ViewError):
"""Custom loader is invalid."""
1 change: 1 addition & 0 deletions src/view/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"context",
"route",
"websocket",
"Route",
)

PART = re.compile(r"{(((\w+)(: *(\w+)))|(\w+))}")
Expand Down
37 changes: 35 additions & 2 deletions tests/test_loaders.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from pathlib import Path

import pytest

from view import delete, get, new_app, options, patch, post, put
from typing import List
from view import delete, get, new_app, options, patch, post, put, App, Route, InvalidCustomLoaderError

@pytest.mark.asyncio
async def test_manual_loader():
Expand Down Expand Up @@ -83,3 +83,36 @@ async def test_patterns_loader():
assert (await test.options("/options")).message == "options"
assert (await test.options("/any")).message == "any"
assert (await test.post("/inputs", query={"a": "a"})).message == "a"


@pytest.mark.asyncio
async def test_custom_loader():
app = new_app()
app.config.app.loader = "custom"

@app.custom_loader
def my_loader(app: App, path: Path) -> List[Route]:
@get("/")
async def index():
return "test"

return [index]

async with app.test() as test:
assert (await test.get("/")).message == "test"


@pytest.mark.asyncio
def test_custom_loader_errors():
app = new_app()
app.config.app.loader = "custom"

with pytest.raises(InvalidCustomLoaderError):
app.load()

@app.custom_loader
def my_loader(app: App, path: Path) -> List[Route]:
return 123

with pytest.raises(InvalidCustomLoaderError):
app.load()