diff --git a/CHANGELOG.md b/CHANGELOG.md index bc673bb..3f041fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/building-projects/routing.md b/docs/building-projects/routing.md index 604fcca..e783eab 100644 --- a/docs/building-projects/routing.md +++ b/docs/building-projects/routing.md @@ -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 @@ -198,6 +199,56 @@ 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`. @@ -205,3 +256,5 @@ In view, a loader is defined as the method of routing used. There are three load - `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. diff --git a/src/view/app.py b/src/view/app.py index fa1e78e..90afd33 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -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 @@ -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 @@ -69,6 +70,7 @@ _ConfigSpecified = None B = TypeVar("B", bound=BaseException) +CustomLoader: TypeAlias = Callable[["App", Path], Iterable[Route]] ERROR_CODES: tuple[int, ...] = ( 400, @@ -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()}) @@ -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, @@ -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) @@ -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: @@ -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. """ @@ -1344,9 +1359,6 @@ def new_app( app = App(config, error_class=error_class) - if post_init: - post_init() - if start: app.run_threaded() @@ -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 diff --git a/src/view/config.py b/src/view/config.py index 09c9158..8614fd4 100644 --- a/src/view/config.py +++ b/src/view/config.py @@ -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") diff --git a/src/view/exceptions.py b/src/view/exceptions.py index fbb48a1..223ee29 100644 --- a/src/view/exceptions.py +++ b/src/view/exceptions.py @@ -29,6 +29,7 @@ "WebSocketError", "WebSocketExpectError", "WebSocketHandshakeError", + "InvalidCustomLoaderError", ) @@ -138,4 +139,7 @@ class WebSocketHandshakeError(WebSocketError): """WebSocket handshake went wrong somehow.""" class WebSocketExpectError(WebSocketError, AssertionError, TypeError): - """WebSocket received unexpected message.""" \ No newline at end of file + """WebSocket received unexpected message.""" + +class InvalidCustomLoaderError(ViewError): + """Custom loader is invalid.""" diff --git a/src/view/routing.py b/src/view/routing.py index 7b5b7b5..ba08df9 100644 --- a/src/view/routing.py +++ b/src/view/routing.py @@ -37,6 +37,7 @@ "context", "route", "websocket", + "Route", ) PART = re.compile(r"{(((\w+)(: *(\w+)))|(\w+))}") diff --git a/tests/test_loaders.py b/tests/test_loaders.py index b123a84..d8f2d77 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -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(): @@ -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()