From b1b6252d465d1283fa323ecddcec2a97d037d5f5 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:14:24 -0400 Subject: [PATCH 01/17] Update config.py --- src/view/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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") From 7af74909b93795b73bb889f1633174392177b897 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:19:35 -0400 Subject: [PATCH 02/17] Update exceptions.py --- src/view/exceptions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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.""" From 1e6a2c0b67e282a549a601c36117ae886f1b972f Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:19:41 -0400 Subject: [PATCH 03/17] Update app.py --- src/view/app.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/view/app.py b/src/view/app.py index fa1e78e..fa33693 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -38,7 +38,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 @@ -1002,6 +1002,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, list): + raise InvalidCustomLoaderError( + f"expected custom loader to return a list of routes, got {routes!r}" + ) + finalize(routes, self) else: finalize([*(routes or ()), *self._manual_routes], self) From 9f84fc154045d3c0a8553ba2e424c748b1be3e72 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:22:21 -0400 Subject: [PATCH 04/17] Update app.py --- src/view/app.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/view/app.py b/src/view/app.py index fa33693..78f90c8 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -19,7 +19,7 @@ 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) + TextIO, TypeVar, get_type_hints, overload, List) from urllib.parse import urlencode import ujson @@ -69,6 +69,7 @@ _ConfigSpecified = None B = TypeVar("B", bound=BaseException) +CustomLoader: TypeAlias = Callable[["App", Path], List[Route]] ERROR_CODES: tuple[int, ...] = ( 400, @@ -472,6 +473,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 +596,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, From b1ac39234cf957ac6e620d96762b62c6e0f9447c Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:27:03 -0400 Subject: [PATCH 05/17] Update test_loaders.py --- tests/test_loaders.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/test_loaders.py b/tests/test_loaders.py index b123a84..f21f12b 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -2,7 +2,7 @@ import pytest -from view import delete, get, new_app, options, patch, post, put +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,34 @@ 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() From 8424bfc4367b3e6129157d318140d2dcfda9a9e0 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:28:32 -0400 Subject: [PATCH 06/17] Update test_loaders.py --- tests/test_loaders.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_loaders.py b/tests/test_loaders.py index f21f12b..40524c2 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -84,6 +84,7 @@ async def test_patterns_loader(): 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() @@ -100,6 +101,7 @@ async def index(): async with app.test() as test: assert (await test.get("/")).message == "test" + @pytest.mark.asyncio def test_custom_loader_errors(): app = new_app() From 96be533809e632add629717c70f5fd8ad74b05e1 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:31:05 -0400 Subject: [PATCH 07/17] Update routing.py --- src/view/routing.py | 1 + 1 file changed, 1 insertion(+) 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+))}") From 5e049df08893c2a7122c05c61e3ac3d03033b63c Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:32:58 -0400 Subject: [PATCH 08/17] Update test_loaders.py --- tests/test_loaders.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_loaders.py b/tests/test_loaders.py index 40524c2..d8f2d77 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -1,7 +1,7 @@ from pathlib import Path import pytest - +from typing import List from view import delete, get, new_app, options, patch, post, put, App, Route, InvalidCustomLoaderError @pytest.mark.asyncio @@ -91,7 +91,7 @@ async def test_custom_loader(): app.config.app.loader = "custom" @app.custom_loader - def my_loader(app: App, path: Path) -> list[Route]: + def my_loader(app: App, path: Path) -> List[Route]: @get("/") async def index(): return "test" @@ -111,7 +111,7 @@ def test_custom_loader_errors(): app.load() @app.custom_loader - def my_loader(app: App, path: Path) -> list[Route]: + def my_loader(app: App, path: Path) -> List[Route]: return 123 with pytest.raises(InvalidCustomLoaderError): From 6a71a71c57b30a57b7cf0934fb5c19962aa59a97 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:35:04 -0400 Subject: [PATCH 09/17] Update app.py --- src/view/app.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 78f90c8..72ede48 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -19,7 +19,7 @@ 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, List) + TextIO, TypeVar, get_type_hints, overload) from urllib.parse import urlencode import ujson @@ -69,7 +69,7 @@ _ConfigSpecified = None B = TypeVar("B", bound=BaseException) -CustomLoader: TypeAlias = Callable[["App", Path], List[Route]] +CustomLoader: TypeAlias = Callable[["App", Path], Iterable[Route]] ERROR_CODES: tuple[int, ...] = ( 400, @@ -1012,11 +1012,11 @@ def load(self, routes: list[Route] | None = None) -> None: raise InvalidCustomLoaderError("custom loader was not set") routes = self._user_loader(self, self.config.app.loader_path) - if not isinstance(routes, list): + if not iterable(routes): raise InvalidCustomLoaderError( f"expected custom loader to return a list of routes, got {routes!r}" ) - finalize(routes, self) + finalize([i for i in routes], self) else: finalize([*(routes or ()), *self._manual_routes], self) From 153b0b85cf0da7b7dcfa1b077d54528871852caf Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:45:41 -0400 Subject: [PATCH 10/17] Update routing.md --- docs/building-projects/routing.md | 55 ++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) 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. From 7914b2e4409245c4f613c7e64319b867f2e86728 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:47:15 -0400 Subject: [PATCH 11/17] Update app.py --- src/view/app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 72ede48..adc856f 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, +from typing import (Any, AsyncIterator, Callable, Coroutine, Generic, TextIO, TypeVar, get_type_hints, overload) from urllib.parse import urlencode +from collections.abc import Iterable import ujson from rich import print @@ -1012,7 +1013,7 @@ def load(self, routes: list[Route] | None = None) -> None: raise InvalidCustomLoaderError("custom loader was not set") routes = self._user_loader(self, self.config.app.loader_path) - if not iterable(routes): + if not isinstance(routes, Iterable): raise InvalidCustomLoaderError( f"expected custom loader to return a list of routes, got {routes!r}" ) From afdbfc83151c8c42861384f83624c574981f2689 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:49:23 -0400 Subject: [PATCH 12/17] Update app.py --- src/view/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index adc856f..6eaa587 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -19,9 +19,9 @@ from types import FrameType as Frame from types import TracebackType as Traceback from typing import (Any, AsyncIterator, Callable, Coroutine, Generic, - TextIO, TypeVar, get_type_hints, overload) + TextIO, TypeVar, get_type_hints, overload, Iterable) from urllib.parse import urlencode -from collections.abc import Iterable +from collections.abc import Iterable as CollectionsIterable import ujson from rich import print @@ -1013,7 +1013,7 @@ def load(self, routes: list[Route] | None = None) -> None: raise InvalidCustomLoaderError("custom loader was not set") routes = self._user_loader(self, self.config.app.loader_path) - if not isinstance(routes, Iterable): + if not isinstance(routes, CollectionsIterable): raise InvalidCustomLoaderError( f"expected custom loader to return a list of routes, got {routes!r}" ) From 1bb3fe446d8903cfb18c2791c3deba675c09db27 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:52:43 -0400 Subject: [PATCH 13/17] Update app.py --- src/view/app.py | 33 ++++++++------------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index 6eaa587..e5fe897 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1329,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: @@ -1347,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. """ @@ -1360,9 +1359,6 @@ def new_app( app = App(config, error_class=error_class) - if post_init: - post_init() - if start: app.run_threaded() @@ -1376,27 +1372,14 @@ def finalizer(): weakref.finalize(app, finalizer) if store_address: - os.environ["_VIEW_APP_ADDRESS"] = str(id(app)) - # id() on cpython returns the address, but it is - # implementation dependent however, view.py - # only supports cpython anyway + _last_app = app return app -# this is forbidden pointers.py technology - -ctypes.pythonapi.Py_IncRef.argtypes = (ctypes.py_object,) - - -def get_app(*, address: int | None = None) -> App: +def get_app() -> App: """Get the last app created by `new_app`.""" - env = os.environ.get("_VIEW_APP_ADDRESS") - addr = address or env - - if (not addr) and (not env): - raise BadEnvironmentError("no view app registered") + if not _last_app: + raise RuntimeError("no app has been set") - app: App = ctypes.cast(int(addr), ctypes.py_object).value # type: ignore - ctypes.pythonapi.Py_IncRef(app) - return app + return _last_app From c86c4e9010003db7bdc84f378d77e45871e39907 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:53:40 -0400 Subject: [PATCH 14/17] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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 From 1fbb85a2900d90ec7ed2c62c641387288c0b3a9d Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:55:19 -0400 Subject: [PATCH 15/17] Update app.py --- src/view/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/app.py b/src/view/app.py index e5fe897..e0543e7 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1371,7 +1371,7 @@ def finalizer(): weakref.finalize(app, finalizer) - if store_address: + if store: _last_app = app return app From 046f761bdac068e6f532e6608932bde7716b4b87 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 08:58:22 -0400 Subject: [PATCH 16/17] Update app.py --- src/view/app.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/view/app.py b/src/view/app.py index e0543e7..d6ad75a 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1371,15 +1371,28 @@ def finalizer(): weakref.finalize(app, finalizer) - if store: - _last_app = app + if store_address: + os.environ["_VIEW_APP_ADDRESS"] = str(id(app)) + # id() on cpython returns the address, but it is + # implementation dependent however, view.py + # only supports cpython anyway return app -def get_app() -> App: +# this is forbidden pointers.py technology + +ctypes.pythonapi.Py_IncRef.argtypes = (ctypes.py_object,) + + +def get_app(*, address: int | None = None) -> App: """Get the last app created by `new_app`.""" - if not _last_app: - raise RuntimeError("no app has been set") + env = os.environ.get("_VIEW_APP_ADDRESS") + addr = address or env - return _last_app + if (not addr) and (not env): + raise BadEnvironmentError("no view app registered") + + app: App = ctypes.cast(int(addr), ctypes.py_object).value # type: ignore + ctypes.pythonapi.Py_IncRef(app) + return app From 697dfc6cec59c01857af2ddbf028f2808dea0837 Mon Sep 17 00:00:00 2001 From: Peter <49501366+ZeroIntensity@users.noreply.github.com> Date: Tue, 28 May 2024 09:00:10 -0400 Subject: [PATCH 17/17] Update app.py --- src/view/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/app.py b/src/view/app.py index d6ad75a..90afd33 100644 --- a/src/view/app.py +++ b/src/view/app.py @@ -1371,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