From b0b92fa46404ef584b969b91645f79cc7aef2ed7 Mon Sep 17 00:00:00 2001 From: "itay.dar" Date: Wed, 22 Jan 2025 10:47:36 +0200 Subject: [PATCH 1/3] feat(core): build protocol layer to make pynest framework agnostic --- examples/BlankApp/main.py | 4 +- examples/BlankApp/src/app_module.py | 1 - examples/BlankApp/src/user/user_controller.py | 19 +- examples/BlankApp/src/user/user_model.py | 8 +- examples/BlankApp/src/user/user_service.py | 3 + nest/common/module.py | 2 +- nest/common/route_resolver.py | 16 - nest/core/__init__.py | 2 - nest/core/adapters/__init__.py | 0 nest/core/adapters/click/__init__.py | 0 nest/core/adapters/click/click_adapter.py | 0 nest/core/adapters/fastapi/__init__.py | 0 nest/core/adapters/fastapi/fastapi_adapter.py | 142 ++++++ nest/core/adapters/fastapi/utils.py | 87 ++++ nest/core/decorators/class_based_view.py | 113 ----- nest/core/decorators/cli/cli_decorators.py | 1 - nest/core/decorators/controller.py | 112 ++--- nest/core/protocols.py | 464 ++++++++++++++++++ nest/core/pynest_application.py | 68 +-- nest/core/pynest_container.py | 16 +- nest/core/pynest_factory.py | 52 +- 21 files changed, 813 insertions(+), 297 deletions(-) delete mode 100644 nest/common/route_resolver.py create mode 100644 nest/core/adapters/__init__.py create mode 100644 nest/core/adapters/click/__init__.py create mode 100644 nest/core/adapters/click/click_adapter.py create mode 100644 nest/core/adapters/fastapi/__init__.py create mode 100644 nest/core/adapters/fastapi/fastapi_adapter.py create mode 100644 nest/core/adapters/fastapi/utils.py delete mode 100644 nest/core/decorators/class_based_view.py create mode 100644 nest/core/protocols.py diff --git a/examples/BlankApp/main.py b/examples/BlankApp/main.py index 928e12b..ce7590f 100644 --- a/examples/BlankApp/main.py +++ b/examples/BlankApp/main.py @@ -1,4 +1,4 @@ -import uvicorn +from examples.BlankApp.src.app_module import app if __name__ == "__main__": - uvicorn.run("src.app_module:http_server", host="0.0.0.0", port=8010, reload=True) + app.adapter.run() diff --git a/examples/BlankApp/src/app_module.py b/examples/BlankApp/src/app_module.py index a84edbd..cdd1768 100644 --- a/examples/BlankApp/src/app_module.py +++ b/examples/BlankApp/src/app_module.py @@ -26,4 +26,3 @@ class AppModule: debug=True, ) -http_server: FastAPI = app.get_server() diff --git a/examples/BlankApp/src/user/user_controller.py b/examples/BlankApp/src/user/user_controller.py index b59d9f6..cf44894 100644 --- a/examples/BlankApp/src/user/user_controller.py +++ b/examples/BlankApp/src/user/user_controller.py @@ -1,18 +1,27 @@ -from nest.core import Controller, Depends, Get, Post - +from nest.core import Controller, Get, Post +from nest.core.protocols import Param, Query, Header, Body +import uuid from .user_model import User from .user_service import UserService -@Controller("user") +@Controller("user", tag="user") class UserController: def __init__(self, service: UserService): self.service = service - @Get("/") + @Get("/", response_model=list[User]) def get_user(self): return self.service.get_user() + @Get("/{user_id}", response_model=User) + def get_user_by_id(self, user_id: Param[uuid.UUID]): + return self.service.get_user_by_id(user_id) + @Post("/") - def add_user(self, user: User): + def add_user(self, user: Body[User]): return self.service.add_user(user) + + @Get("/test/new-user/{user_id}") + def test_new_user(self, user_id: Param[uuid.UUID]): + return self.service.get_user_by_id(user_id) diff --git a/examples/BlankApp/src/user/user_model.py b/examples/BlankApp/src/user/user_model.py index cee05ad..1918ca8 100644 --- a/examples/BlankApp/src/user/user_model.py +++ b/examples/BlankApp/src/user/user_model.py @@ -1,5 +1,7 @@ -from pydantic import BaseModel +from dataclasses import dataclass +from uuid import UUID - -class User(BaseModel): +@dataclass +class User: + id: UUID name: str diff --git a/examples/BlankApp/src/user/user_service.py b/examples/BlankApp/src/user/user_service.py index 90354ba..1774ce1 100644 --- a/examples/BlankApp/src/user/user_service.py +++ b/examples/BlankApp/src/user/user_service.py @@ -18,3 +18,6 @@ def get_user(self): def add_user(self, user: User): self.database.append(user) return user + + def get_user_by_id(self, user_id: str): + return next((user for user in self.database if user.id == user_id), None) diff --git a/nest/common/module.py b/nest/common/module.py index a877eab..267c401 100644 --- a/nest/common/module.py +++ b/nest/common/module.py @@ -26,7 +26,7 @@ def has(self, token: str): return True if self.get(token) is not None else False -class Module: +class NestModule: def __init__(self, metatype: Type[object], container): self._id = str(uuid.uuid4()) self._metatype = metatype diff --git a/nest/common/route_resolver.py b/nest/common/route_resolver.py deleted file mode 100644 index 9d545c4..0000000 --- a/nest/common/route_resolver.py +++ /dev/null @@ -1,16 +0,0 @@ -from fastapi import APIRouter, FastAPI - - -class RoutesResolver: - def __init__(self, container, app_ref: FastAPI): - self.container = container - self.app_ref = app_ref - - def register_routes(self): - for module in self.container.modules.values(): - for controller in module.controllers.values(): - self.register_route(controller) - - def register_route(self, controller): - router: APIRouter = controller.get_router() - self.app_ref.include_router(router) diff --git a/nest/core/__init__.py b/nest/core/__init__.py index 50a4cc8..673d426 100644 --- a/nest/core/__init__.py +++ b/nest/core/__init__.py @@ -1,5 +1,3 @@ -from fastapi import Depends - from nest.core.decorators import ( Controller, Delete, diff --git a/nest/core/adapters/__init__.py b/nest/core/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nest/core/adapters/click/__init__.py b/nest/core/adapters/click/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nest/core/adapters/click/click_adapter.py b/nest/core/adapters/click/click_adapter.py new file mode 100644 index 0000000..e69de29 diff --git a/nest/core/adapters/fastapi/__init__.py b/nest/core/adapters/fastapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nest/core/adapters/fastapi/fastapi_adapter.py b/nest/core/adapters/fastapi/fastapi_adapter.py new file mode 100644 index 0000000..e1bc987 --- /dev/null +++ b/nest/core/adapters/fastapi/fastapi_adapter.py @@ -0,0 +1,142 @@ +from typing import Any, Callable, List, Optional +import uvicorn +from fastapi import FastAPI, APIRouter +from fastapi.middleware import Middleware + +from nest.core.protocols import ( + WebFrameworkAdapterProtocol, + RouterProtocol, Container, +) + +from nest.core.adapters.fastapi.utils import wrap_instance_method + + +class FastAPIRouterAdapter(RouterProtocol): + """ + An adapter for registering routes in FastAPI. + """ + + def __init__(self, base_path: str = "") -> None: + """ + Initialize with an optional base path. + """ + print("Initializing FastAPIRouterAdapter") + self._base_path = base_path + self._router = APIRouter(prefix=self._base_path) + + def add_route( + self, + path: str, + endpoint: Callable[..., Any], + methods: List[str], + *, + name: Optional[str] = None, + ) -> None: + """ + Register an HTTP route with FastAPI's APIRouter. + """ + self._router.add_api_route(path, endpoint, methods=methods, name=name) + + + def get_router(self) -> APIRouter: + """ + Return the underlying FastAPI APIRouter. + """ + return self._router + + +############################################################################### +# FastAPI Adapter +############################################################################### + +class FastAPIAdapter(WebFrameworkAdapterProtocol): + """ + A FastAPI-based implementation of WebFrameworkAdapterProtocol. + """ + + def __init__(self) -> None: + self._app: Optional[FastAPI] = None + self._router_adapter = FastAPIRouterAdapter() + self._middlewares: List[Middleware] = [] + self._initialized = False + + def create_app(self, **kwargs: Any) -> FastAPI: + """ + Create and configure the FastAPI application. + """ + print("Creating FastAPI app") + self._app = FastAPI(**kwargs) + self._app.include_router(self._router_adapter.get_router()) + # Add any pre-collected middlewares + for mw in self._middlewares: + self._app.add_middleware(mw.cls, **mw.options) + + self._initialized = True + return self._app + + def get_router(self) -> RouterProtocol: + """ + Return the RouterProtocol implementation. + """ + return self._router_adapter + + def add_middleware( + self, + middleware_cls: Any, + **options: Any, + ) -> None: + """ + Add middleware to the FastAPI application. + """ + if not self._app: + # Collect middlewares before app creation + self._middlewares.append(Middleware(middleware_cls, **options)) + else: + # Add middleware directly if app is already created + self._app.add_middleware(middleware_cls, **options) + + def run(self, host: str = "127.0.0.1", port: int = 8000, **kwargs) -> None: + """ + Run the FastAPI application using Uvicorn. + """ + if not self._initialized or not self._app: + raise RuntimeError("FastAPI app not created yet. Call create_app() first.") + + uvicorn.run(self._app, host=host, port=port, **kwargs) + + async def startup(self) -> None: + """ + Handle any startup tasks if necessary. + """ + if self._app: + await self._app.router.startup() + + async def shutdown(self) -> None: + """ + Handle any shutdown tasks if necessary. + """ + if self._app: + await self._app.router.shutdown() + + def register_routes(self, container: Container) -> None: + """ + Register multiple routes at once. + """ + for module in container.modules.values(): + for controller_cls in module.controllers.values(): + instance = container.get_instance(controller_cls) + + route_definitions = getattr(controller_cls, "__pynest_routes__", []) + for route_definition in route_definitions: + path = route_definition["path"] + method = route_definition["method"] + original_method = route_definition["endpoint"] + + final_endpoint = wrap_instance_method(instance, controller_cls, original_method) + + self._router_adapter.add_route( + path=path, + endpoint=final_endpoint, + methods=[method], + name=f"{controller_cls.__name__}.{original_method.__name__}", + ) diff --git a/nest/core/adapters/fastapi/utils.py b/nest/core/adapters/fastapi/utils.py new file mode 100644 index 0000000..a6f21dc --- /dev/null +++ b/nest/core/adapters/fastapi/utils.py @@ -0,0 +1,87 @@ +from typing import Annotated, Callable + +from fastapi import Path, Query, Header, Body, File, UploadFile, Response, Request, BackgroundTasks, Form +from nest.core.protocols import Param, Query as QueryParam, Header as HeaderParam, Body as BodyParam, \ + Cookie as CookieParam, File as FileParam, Form as FormParam +import functools +import inspect +import typing + + +def wrap_instance_method( + instance, + cls, + method: Callable, +) -> Callable: + """ + 1. Create a new plain function that calls `method(instance, ...)`. + 2. Rewrite its signature so that 'self' is removed, and Param/Query/Body become Annotated[...] for FastAPI. + 3. Return that new function, which you can pass to fastapi's router. + + This avoids "invalid method signature" by not rewriting the bound method in place. + """ + + # The unbound function object: + if hasattr(method, "__func__"): + # If 'method' is a bound method, get the actual function + unbound_func = method.__func__ + else: + # If it's already an unbound function, use it + unbound_func = method + + # Create a wrapper function that calls the unbound function with 'instance' as the first arg + @functools.wraps(unbound_func) + def wrapper(*args, **kwargs): + return unbound_func(instance, *args, **kwargs) + + # Now rewrite the wrapper's signature: + # - removing 'self' + # - converting Param/Query/Body to Annotated + new_wrapper = rewrite_signature_for_fastapi(wrapper) + return new_wrapper + + +def rewrite_signature_for_fastapi(func: Callable) -> Callable: + """ + A function that modifies the signature to remove "self" + and convert Param/Query/Header/Body to FastAPI’s annotated params. + """ + sig = inspect.signature(func) + new_params = [] + + old_parameters = list(sig.parameters.values()) + + # 1) If the first param is named 'self', skip it entirely from the new signature + # (because we have a BOUND method). + if old_parameters and old_parameters[0].name == "self": + old_parameters = old_parameters[1:] + + for param in old_parameters: + annotation = param.annotation + + if typing.get_origin(annotation) == Param: + inner_type = typing.get_args(annotation)[0] + new_annotation = Annotated[inner_type, Path()] + new_params.append(param.replace(annotation=new_annotation)) + + elif typing.get_origin(annotation) == QueryParam: + inner_type = typing.get_args(annotation)[0] + new_annotation = Annotated[inner_type, Query()] + new_params.append(param.replace(annotation=new_annotation)) + + elif typing.get_origin(annotation) == HeaderParam: + inner_type = typing.get_args(annotation)[0] + new_annotation = Annotated[inner_type, Header()] + new_params.append(param.replace(annotation=new_annotation)) + + elif typing.get_origin(annotation) == BodyParam: + inner_type = typing.get_args(annotation)[0] + new_annotation = Annotated[inner_type, Body()] + new_params.append(param.replace(annotation=new_annotation)) + else: + # unchanged param + new_params.append(param) + + new_sig = sig.replace(parameters=new_params) + func.__signature__ = new_sig + return func diff --git a/nest/core/decorators/class_based_view.py b/nest/core/decorators/class_based_view.py deleted file mode 100644 index 18cf0b8..0000000 --- a/nest/core/decorators/class_based_view.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -Credit: FastAPI-Utils -Source: https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/cbv.py -""" - -import inspect -from typing import ( - Any, - Callable, - ClassVar, - List, - Type, - TypeVar, - Union, - get_origin, - get_type_hints, -) - -from fastapi import APIRouter, Depends -from starlette.routing import Route, WebSocketRoute - -T = TypeVar("T") -K = TypeVar("K", bound=Callable[..., Any]) - -CBV_CLASS_KEY = "__cbv_class__" - - -def class_based_view(router: APIRouter, cls: Type[T]) -> Type[T]: - """ - Replaces any methods of the provided class `cls` that are endpoints of routes in `router` with updated - function calls that will properly inject an instance of `cls`. - """ - _init_cbv(cls) - cbv_router = APIRouter() - function_members = inspect.getmembers(cls, inspect.isfunction) - functions_set = set(func for _, func in function_members) - cbv_routes = [ - route - for route in router.routes - if isinstance(route, (Route, WebSocketRoute)) - and route.endpoint in functions_set - ] - for route in cbv_routes: - router.routes.remove(route) - _update_cbv_route_endpoint_signature(cls, route) - cbv_router.routes.append(route) - router.include_router(cbv_router) - return cls - - -def _init_cbv(cls: Type[Any]) -> None: - """ - Idempotently modifies the provided `cls`, performing the following modifications: - * The `__init__` function is updated to set any class-annotated dependencies as instance attributes - * The `__signature__` attribute is updated to indicate to FastAPI what arguments should be passed to the initializer - """ - if getattr(cls, CBV_CLASS_KEY, False): # pragma: no cover - return # Already initialized - old_init: Callable[..., Any] = cls.__init__ - old_signature = inspect.signature(old_init) - old_parameters = list(old_signature.parameters.values())[ - 1: - ] # drop `self` parameter - new_parameters = [ - x - for x in old_parameters - if x.kind - not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) - ] - dependency_names: List[str] = [] - for name, hint in get_type_hints(cls).items(): - if get_origin(hint) is ClassVar: - continue - parameter_kwargs = {"default": getattr(cls, name, Ellipsis)} - dependency_names.append(name) - new_parameters.append( - inspect.Parameter( - name=name, - kind=inspect.Parameter.KEYWORD_ONLY, - annotation=hint, - **parameter_kwargs, - ) - ) - new_signature = old_signature.replace(parameters=new_parameters) - - def new_init(self: Any, *args: Any, **kwargs: Any) -> None: - for dep_name in dependency_names: - dep_value = kwargs.pop(dep_name) - setattr(self, dep_name, dep_value) - old_init(self, *args, **kwargs) - - setattr(cls, "__signature__", new_signature) - setattr(cls, "__init__", new_init) - setattr(cls, CBV_CLASS_KEY, True) - - -def _update_cbv_route_endpoint_signature( - cls: Type[Any], route: Union[Route, WebSocketRoute] -) -> None: - """ - Fixes the endpoint signature for a cbv route to ensure FastAPI performs dependency injection properly. - """ - old_endpoint = route.endpoint - old_signature = inspect.signature(old_endpoint) - old_parameters: List[inspect.Parameter] = list(old_signature.parameters.values()) - old_first_parameter = old_parameters[0] - new_first_parameter = old_first_parameter.replace(default=Depends(cls)) - new_parameters = [new_first_parameter] + [ - parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY) - for parameter in old_parameters[1:] - ] - new_signature = old_signature.replace(parameters=new_parameters) - setattr(route.endpoint, "__signature__", new_signature) \ No newline at end of file diff --git a/nest/core/decorators/cli/cli_decorators.py b/nest/core/decorators/cli/cli_decorators.py index 3d82011..802f3ed 100644 --- a/nest/core/decorators/cli/cli_decorators.py +++ b/nest/core/decorators/cli/cli_decorators.py @@ -2,7 +2,6 @@ import click -from nest.core import Controller from nest.core.decorators.utils import ( get_instance_variables, parse_dependencies, diff --git a/nest/core/decorators/controller.py b/nest/core/decorators/controller.py index 8073083..fb7a76d 100644 --- a/nest/core/decorators/controller.py +++ b/nest/core/decorators/controller.py @@ -1,47 +1,45 @@ from typing import Optional, Type -from fastapi.routing import APIRouter - -from nest.core.decorators.class_based_view import class_based_view as ClassBasedView from nest.core.decorators.http_method import HTTPMethod from nest.core.decorators.utils import get_instance_variables, parse_dependencies def Controller(prefix: Optional[str] = None, tag: Optional[str] = None): """ - Decorator that turns a class into a controller, allowing you to define - routes using FastAPI decorators. + Decorator that marks a class as a controller, collecting route metadata + for future registration in the underlying framework. Args: prefix (str, optional): The prefix to use for all routes. - tag (str, optional): The tag to use for OpenAPI documentation. + tag (str, optional): The tag to use for grouping or doc generation. Returns: - class: The decorated class. + class: The decorated class (with route metadata added). """ - # Default route_prefix to tag_name if route_prefix is not provided route_prefix = process_prefix(prefix, tag) - def wrapper(cls: Type) -> Type[ClassBasedView]: - router = APIRouter(tags=[tag] if tag else None) - - # Process class dependencies + def wrapper(cls: Type) -> Type: + # 1. Process class-level dependencies process_dependencies(cls) - # Set instance variables + # 2. Set instance variables for any non-injected fields set_instance_variables(cls) - # Ensure the class has an __init__ method + # 3. Ensure the class has an __init__ method ensure_init_method(cls) - # Add routes to the router - add_routes(cls, router, route_prefix) + # 4. Gather route metadata + route_definitions = collect_route_definitions(cls, route_prefix) + + # 5. Store routes in class attribute for later usage + setattr(cls, "__pynest_routes__", route_definitions) - # Add get_router method to the class - cls.get_router = classmethod(lambda cls: router) + # (Optional) Store prefix / tag for doc generation + setattr(cls, "__pynest_prefix__", route_prefix) + setattr(cls, "__pynest_tag__", tag) - return ClassBasedView(router=router, cls=cls) + return cls return wrapper @@ -50,21 +48,20 @@ def process_prefix(route_prefix: Optional[str], tag_name: Optional[str]) -> str: """Process and format the prefix.""" if route_prefix is None: if tag_name is None: - return None + return "" else: route_prefix = tag_name if not route_prefix.startswith("/"): route_prefix = "/" + route_prefix - if route_prefix.endswith("/"): - route_prefix = route_prefix.rstrip("/") - + # Remove any trailing slash to keep consistent + route_prefix = route_prefix.rstrip("/") return route_prefix def process_dependencies(cls: Type) -> None: - """Parse and set dependencies for the class.""" + """Parse and set dependencies for the class (via your DI system).""" dependencies = parse_dependencies(cls) setattr(cls, "__dependencies__", dependencies) @@ -79,64 +76,53 @@ def set_instance_variables(cls: Type) -> None: def ensure_init_method(cls: Type) -> None: """Ensure the class has an __init__ method.""" if not hasattr(cls, "__init__"): - raise AttributeError("Class must have an __init__ method") + raise AttributeError(f"{cls.__name__} must have an __init__ method") + + # We do the same removal trick if needed try: delattr(cls, "__init__") except AttributeError: pass -def add_routes(cls: Type, router: APIRouter, route_prefix: str) -> None: - """Add routes from class methods to the router.""" +def collect_route_definitions(cls: Type, base_prefix: str): + """Scan class methods for HTTP method decorators and build route metadata.""" + route_definitions = [] for method_name, method_function in cls.__dict__.items(): if callable(method_function) and hasattr(method_function, "__http_method__"): validate_method_decorator(method_function, method_name) - configure_method_route(method_function, route_prefix) - add_route_to_router(router, method_function) + configure_method_route(method_function, base_prefix) + + route_info = { + "path": method_function.__route_path__, + "method": method_function.__http_method__.value, + "endpoint": method_function, + "kwargs": method_function.__kwargs__, + "status_code": getattr(method_function, "status_code", None), + "name": f"{cls.__name__}.{method_name}", + } + route_definitions.append(route_info) + return route_definitions def validate_method_decorator(method_function: callable, method_name: str) -> None: """Validate that the method has a proper HTTP method decorator.""" - if ( - not hasattr(method_function, "__route_path__") - or not method_function.__route_path__ - ): + if not hasattr(method_function, "__route_path__") or not method_function.__route_path__: raise AssertionError(f"Missing path for method {method_name}") if not isinstance(method_function.__http_method__, HTTPMethod): raise AssertionError(f"Invalid method {method_function.__http_method__}") -def configure_method_route(method_function: callable, route_prefix: str) -> None: - """Configure the route for the method.""" - if not method_function.__route_path__.startswith("/"): - method_function.__route_path__ = "/" + method_function.__route_path__ - - method_function.__route_path__ = ( - route_prefix + method_function.__route_path__ - if route_prefix - else method_function.__route_path__ - ) - - # remove trailing "/" fro __route_path__ - # it converts "/api/users/" to "/api/users" - if ( - method_function.__route_path__ != "/" - and method_function.__route_path__.endswith("/") - ): - method_function.__route_path__ = method_function.__route_path__.rstrip("/") - +def configure_method_route(method_function: callable, base_prefix: str) -> None: + """Configure the final route path by prepending base_prefix.""" + raw_path = method_function.__route_path__ -def add_route_to_router(router: APIRouter, method_function: callable) -> None: - """Add the configured route to the router.""" - route_kwargs = { - "path": method_function.__route_path__, - "endpoint": method_function, - "methods": [method_function.__http_method__.value], - **method_function.__kwargs__, - } + if not raw_path.startswith("/"): + raw_path = "/" + raw_path - if hasattr(method_function, "status_code"): - route_kwargs["status_code"] = method_function.status_code + # Combine prefix + path + full_path = f"{base_prefix}{raw_path}" + full_path = full_path.rstrip("/") if full_path != "/" else full_path - router.add_api_route(**route_kwargs) + method_function.__route_path__ = full_path diff --git a/nest/core/protocols.py b/nest/core/protocols.py new file mode 100644 index 0000000..a2d5727 --- /dev/null +++ b/nest/core/protocols.py @@ -0,0 +1,464 @@ +# protocols.py + +from __future__ import annotations +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Protocol, + Type, + TypeVar, + Union, + runtime_checkable, + Generic, +) + +############################################################################### +# 1. REQUEST & RESPONSE +############################################################################### + +@runtime_checkable +class RequestProtocol(Protocol): + """ + Abstract representation of an HTTP request. + """ + + @property + def method(self) -> str: + """ + HTTP method (GET, POST, PUT, DELETE, etc.). + """ + ... + + @property + def url(self) -> str: + """ + The full request URL (or path). + """ + ... + + @property + def headers(self) -> Dict[str, str]: + """ + A dictionary of header names to values. + """ + ... + + @property + def cookies(self) -> Dict[str, str]: + """ + A dictionary of cookie names to values. + """ + ... + + @property + def query_params(self) -> Dict[str, str]: + """ + A dictionary of query parameter names to values. + """ + ... + + @property + def path_params(self) -> Dict[str, Any]: + """ + A dictionary of path parameter names to values. + Usually extracted from the URL pattern. + """ + ... + + async def body(self) -> Union[bytes, str]: + """ + Return the raw request body (bytes or text). + """ + ... + + +@runtime_checkable +class ResponseProtocol(Protocol): + """ + Abstract representation of an HTTP response. + """ + + def set_status_code(self, status_code: int) -> None: + """ + Set the HTTP status code (e.g. 200, 404, etc.). + """ + ... + + def set_header(self, name: str, value: str) -> None: + """ + Set a single header on the response. + """ + ... + + def set_cookie(self, name: str, value: str, **options: Any) -> None: + """ + Set a cookie on the response. + 'options' might include expires, domain, secure, httponly, etc. + """ + ... + + def delete_cookie(self, name: str, **options: Any) -> None: + """ + Instruct the browser to delete a cookie (by setting an expired cookie). + """ + ... + + def set_body(self, content: Union[str, bytes]) -> None: + """ + Set the final body (string or bytes). + (You might add overloads for JSON or streaming in a real system.) + """ + ... + + def set_json(self, data: Any, status_code: Optional[int] = None) -> None: + """ + Encode data as JSON, set the body, and optionally set the status code. + """ + ... + + +############################################################################### +# 2. ROUTING & HTTP METHODS +############################################################################### + +@runtime_checkable +class RouteDefinition(Protocol): + """ + Represents a single route definition: path, HTTP methods, etc. + """ + path: str + http_methods: List[str] + endpoint: Callable[..., Any] + name: Optional[str] + + +@runtime_checkable +class RouterProtocol(Protocol): + """ + A protocol for registering routes, websocket endpoints, + or other specialized routes (SSE, GraphQL, etc.). + """ + + def add_route( + self, + path: str, + endpoint: Callable[..., Any], + methods: List[str], + *, + name: Optional[str] = None, + ) -> None: + """ + Register a normal HTTP endpoint at the given path with the given methods. + Example: GET/POST/PUT, etc. + """ + ... + + def add_websocket_route( + self, + path: str, + endpoint: Callable[..., Any], + *, + name: Optional[str] = None, + ) -> None: + """ + Register a WebSocket endpoint at the given path. + """ + ... + + +Container = TypeVar("Container") + +@runtime_checkable +class WebFrameworkAdapterProtocol(Protocol): + """ + High-level interface for an HTTP framework adapter. + The PyNest system can call these methods to: + - create and manage the main application object + - get a RouterProtocol to register routes + - add middlewares + - run the server + - optionally handle startup/shutdown hooks, etc. + """ + + def create_app(self, **kwargs: Any) -> Any: + """ + Create and store the main web application object. + **kwargs** can pass parameters (e.g., title, debug, etc.). + Returns the native app object (like FastAPI instance). + """ + ... + + def get_router(self) -> RouterProtocol: + """ + Return a RouterProtocol for the main router (or a sub-router). + """ + ... + + def add_middleware( + self, + middleware_cls: Any, + **options: Any, + ) -> None: + """ + Add a middleware class to the application, with config options. + """ + ... + + def run(self, host: str = "127.0.0.1", port: int = 8000) -> None: + """ + Blockingly run the HTTP server on the given host/port. + In production, you might prefer to return an ASGI app + and let an external server (e.g., gunicorn) run it. + """ + ... + + async def startup(self) -> None: + """ + Optional: If the framework has an 'on_startup' event, run it. + """ + ... + + async def shutdown(self) -> None: + """ + Optional: If the framework has an 'on_shutdown' event, run it. + """ + ... + + def register_routes(self, container: Container) -> None: + """ + Register multiple routes at once. + """ + ... + + +@runtime_checkable +class CLIAdapterProtocol(Protocol): + """ + High-level interface for an CLI adapter. + The PyNest system can call these methods to: + - create and manage the main application object + - run the cli app + - register commands into the cli + """ + + def create_app(self, **kwargs: Any) -> Any: + """ + Create and store the main CLI application object. + **kwargs** can pass parameters + Returns the native app object (like FastAPI instance). + """ + ... + + def get_router(self) -> RouterProtocol: + """ + Return a RouterProtocol for the main router (or a sub-router). + """ + ... + + def add_middleware( + self, + middleware_cls: Any, + **options: Any, + ) -> None: + """ + Add a middleware class to the application, with config options. + """ + ... + + def run(self, host: str = "127.0.0.1", port: int = 8000) -> None: + """ + Blockingly run the HTTP server on the given host/port. + In production, you might prefer to return an ASGI app + and let an external server (e.g., gunicorn) run it. + """ + ... + + async def startup(self) -> None: + """ + Optional: If the framework has an 'on_startup' event, run it. + """ + ... + + async def shutdown(self) -> None: + """ + Optional: If the framework has an 'on_shutdown' event, run it. + """ + ... + + def register_routes(self, container: Container) -> None: + """ + Register multiple routes at once. + """ + ... + + + + +############################################################################### +# 3. PARAMETER EXTRACTION (NEST-JS LIKE) +############################################################################### + +T = TypeVar("T") + + +class RequestAttribute: + """ + Marker/base class for typed request attributes + like Param, Query, Header, Cookie, etc. + """ + ... + + +class Param(Generic[T], RequestAttribute): + """ + Usage: + def get_item(self, id: Param[int]): ... + """ + pass + + +class Query(Generic[T], RequestAttribute): + """ + Usage: + def search(self, q: Query[str]): ... + """ + pass + + +class Header(Generic[T], RequestAttribute): + """ + Usage: + def do_something(self, token: Header[str]): ... + """ + pass + + +class Cookie(Generic[T], RequestAttribute): + """ + Usage: + def show_info(self, user_id: Cookie[str]): ... + """ + pass + + +class Body(Generic[T], RequestAttribute): + """ + Usage: + def create_user(self, data: Body[UserDTO]): ... + """ + pass + + +class Form(Generic[T], RequestAttribute): + """ + Usage: + def post_form(self, form_data: Form[LoginForm]): ... + """ + pass + + +class File(Generic[T], RequestAttribute): + """ + Usage: + def upload(self, file: File[UploadedFile]): ... + """ + pass + + +class RawRequest(RequestAttribute): + """ + Sometimes you just need the entire request object. + Usage: + def debug(self, req: RawRequest): ... + """ + pass + +############################################################################### +# 4. ERROR HANDLING +############################################################################### + +@runtime_checkable +class HTTPExceptionProtocol(Protocol): + """ + A standardized interface for raising an HTTP exception or error + that can be recognized by the underlying framework. + """ + status_code: int + detail: str + + def to_response(self) -> ResponseProtocol: + """ + Convert this exception into a protocol-level response + (or a native framework response). + """ + ... + + +@runtime_checkable +class ExceptionHandlerProtocol(Protocol): + """ + For registering custom exception handlers. + """ + + def handle_exception(self, exc: Exception) -> ResponseProtocol: + """ + Given an exception, return a response. + """ + ... + + +############################################################################### +# 5. MIDDLEWARE & FILTERS +############################################################################### + +@runtime_checkable +class MiddlewareProtocol(Protocol): + """ + A protocol for middleware classes/functions + that can be added to the application. + """ + + def __call__(self, request: RequestProtocol, call_next: Callable) -> Any: + """ + Some frameworks pass 'call_next' to chain the next handler. + Others might do this differently. + This is just an abstract representation. + """ + ... + + +@runtime_checkable +class FilterProtocol(Protocol): + """ + A smaller, more focused pre/post processing "filter" + that can manipulate requests or responses. + Could be integrated as an alternative or layer on top of middleware. + """ + + def before_request(self, request: RequestProtocol) -> None: + ... + + def after_request(self, request: RequestProtocol, response: ResponseProtocol) -> None: + ... + + +############################################################################### +# 6. SECURITY & AUTH +############################################################################### + +@runtime_checkable +class AuthGuardProtocol(Protocol): + """ + Something that checks authentication or authorization + and possibly raises an HTTP exception if unauthorized. + """ + + def check(self, request: RequestProtocol) -> None: + """ + If the user is not authorized, raise an error (HTTPExceptionProtocol). + Otherwise, do nothing. + """ + ... diff --git a/nest/core/pynest_application.py b/nest/core/pynest_application.py index 22e2669..26f9a34 100644 --- a/nest/core/pynest_application.py +++ b/nest/core/pynest_application.py @@ -1,64 +1,32 @@ -from typing import Any - -from fastapi import FastAPI +# nest/core/pynest_application.py -from nest.common.route_resolver import RoutesResolver -from nest.core.pynest_app_context import PyNestApplicationContext -from nest.core.pynest_container import PyNestContainer +from typing import Any +from nest.core.protocols import WebFrameworkAdapterProtocol +from nest.core.pynest_app_context import PyNestApplicationContext # Ensure correct import class PyNestApp(PyNestApplicationContext): - """ - PyNestApp is the main application class for the PyNest framework, - managing the container and HTTP server. - """ + def __init__(self, container, adapter: WebFrameworkAdapterProtocol): + super().__init__(container) + self.adapter = adapter + self._is_listening = False - _is_listening = False + # Register all routes + self.adapter.register_routes(self.container) - @property - def is_listening(self) -> bool: - return self._is_listening - - def __init__(self, container: PyNestContainer, http_server: FastAPI): - """ - Initialize the PyNestApp with the given container and HTTP server. + # Create and configure the web application via the adapter + self.web_app = self.adapter.create_app() - Args: - container (PyNestContainer): The PyNestContainer container instance. - http_server (FastAPI): The FastAPI server instance. - """ - self.container = container - self.http_server = http_server - super().__init__(self.container) - self.routes_resolver = RoutesResolver(self.container, self.http_server) - self.select_context_module() - self.register_routes() - def use(self, middleware: type, **options: Any) -> "PyNestApp": + def use_middleware(self, middleware_cls: type, **options: Any) -> "PyNestApp": """ - Add middleware to the FastAPI server. - - Args: - middleware (type): The middleware class. - **options (Any): Additional options for the middleware. - - Returns: - PyNestApp: The current instance of PyNestApp, allowing method chaining. + Add middleware to the application. """ - self.http_server.add_middleware(middleware, **options) + self.adapter.add_middleware(middleware_cls, **options) return self - def get_server(self) -> FastAPI: - """ - Get the FastAPI server instance. - Returns: - FastAPI: The FastAPI server instance. - """ - return self.http_server + @property + def is_listening(self) -> bool: + return self._is_listening - def register_routes(self): - """ - Register the routes using the RoutesResolver. - """ - self.routes_resolver.register_routes() diff --git a/nest/core/pynest_container.py b/nest/core/pynest_container.py index 9e524f8..b101e03 100644 --- a/nest/core/pynest_container.py +++ b/nest/core/pynest_container.py @@ -11,7 +11,7 @@ UnknownModuleException, ) from nest.common.module import ( - Module, + NestModule, ModuleCompiler, ModuleFactory, ModulesContainer, @@ -95,7 +95,7 @@ def add_module(self, metaclass) -> dict: return {"module_ref": self.modules.get(token), "inserted": False} return {"module_ref": self.register_module(module_factory), "inserted": True} - def register_module(self, module_factory: ModuleFactory) -> Module: + def register_module(self, module_factory: ModuleFactory) -> NestModule: """ Register a module in the container. @@ -108,10 +108,10 @@ def register_module(self, module_factory: ModuleFactory) -> Module: for creating the module. Returns: - Module: The module reference that has been registered in the container. + NestModule: The module reference that has been registered in the container. """ - module_ref = Module(module_factory.type, self) + module_ref = NestModule(module_factory.type, self) module_ref.token = module_factory.token self._modules[module_factory.token] = module_ref @@ -140,7 +140,7 @@ def add_import(self, token: str): if not self.modules.has(token): return module_metadata = self._modules_metadata.get(token) - module_ref: Module = self.modules.get(token) + module_ref: NestModule = self.modules.get(token) imports_mod: List[Any] = module_metadata.get("imports") self.add_modules(imports_mod) module_ref.add_imports(imports_mod) @@ -158,7 +158,7 @@ def add_providers(self, providers: List[Any], module_token: str) -> None: def add_provider(self, token: str, provider): """Add a provider to a module.""" - module_ref: Module = self.modules[token] + module_ref: NestModule = self.modules[token] if not provider: raise CircularDependencyException(module_ref.metatype) @@ -200,7 +200,7 @@ def _add_controller(self, token: str, controller: TController) -> None: """Add a controller to a module.""" if not self.modules.has(token): raise UnknownModuleException() - module_ref: Module = self.modules[token] + module_ref: NestModule = self.modules[token] module_ref.add_controller(controller) if hasattr(controller, DEPENDENCIES): for provider_name, provider_type in getattr( @@ -228,5 +228,5 @@ def add_related_module(self, related_module, token: str) -> None: # UNUSED: This function is currently not used but retained for potential future use. # It retrieves a module from the container by its key. - def get_module_by_key(self, module_key: str) -> Module: + def get_module_by_key(self, module_key: str) -> NestModule: return self._modules[module_key] diff --git a/nest/core/pynest_factory.py b/nest/core/pynest_factory.py index 2d988ae..9bff562 100644 --- a/nest/core/pynest_factory.py +++ b/nest/core/pynest_factory.py @@ -1,49 +1,37 @@ -from abc import ABC, abstractmethod -from typing import Type, TypeVar - -from fastapi import FastAPI +# nest/core/pynest_factory.py +from typing import Type, TypeVar, Optional from nest.core.pynest_application import PyNestApp from nest.core.pynest_container import PyNestContainer +from nest.core.protocols import WebFrameworkAdapterProtocol +from nest.core.adapters.fastapi.fastapi_adapter import FastAPIAdapter ModuleType = TypeVar("ModuleType") -class AbstractPyNestFactory(ABC): - @abstractmethod - def create(self, main_module: Type[ModuleType], **kwargs): - raise NotImplementedError - - -class PyNestFactory(AbstractPyNestFactory): +class PyNestFactory: """Factory class for creating PyNest applications.""" @staticmethod - def create(main_module: Type[ModuleType], **kwargs) -> PyNestApp: + def create( + main_module: Type[ModuleType], + adapter: Optional[WebFrameworkAdapterProtocol] = None, + **kwargs + ) -> PyNestApp: """ - Create a PyNest application with the specified main module class. - - Args: - main_module (ModuleType): The main module for the PyNest application. - **kwargs: Additional keyword arguments for the FastAPI server. - - Returns: - PyNestApp: The created PyNest application. + Create a PyNest application with the specified main module class + and a chosen adapter (defaults to FastAPIAdapter if none given). """ + if adapter is None: + adapter = FastAPIAdapter() # Default to FastAPI + container = PyNestContainer() container.add_module(main_module) - http_server = PyNestFactory._create_server(**kwargs) - return PyNestApp(container, http_server) - @staticmethod - def _create_server(**kwargs) -> FastAPI: - """ - Create a FastAPI server. + # Create the PyNest application + app = PyNestApp(container=container, adapter=adapter) - Args: - **kwargs: Additional keyword arguments for the FastAPI server. + # Optionally add middlewares here before running + # app.use_middleware(SomeMiddlewareClass, optionA=123) - Returns: - FastAPI: The created FastAPI server. - """ - return FastAPI(**kwargs) + return app From 3d4b5380660ef5a29433e0dc0e6553725be9c3c1 Mon Sep 17 00:00:00 2001 From: "itay.dar" Date: Fri, 24 Jan 2025 15:19:32 +0200 Subject: [PATCH 2/3] add protocols to core init path --- nest/core/__init__.py | 11 +++ nest/core/adapters/fastapi/utils.py | 114 +++++++++++++++++++--------- nest/core/cli_factory.py | 7 +- nest/core/protocols.py | 71 ++++++----------- nest/core/pynest_factory.py | 16 ++-- 5 files changed, 130 insertions(+), 89 deletions(-) diff --git a/nest/core/__init__.py b/nest/core/__init__.py index 673d426..9da4b60 100644 --- a/nest/core/__init__.py +++ b/nest/core/__init__.py @@ -9,6 +9,17 @@ Post, Put, ) +from nest.core.protocols import ( + Param, + Query, + Header, + Body, + Cookie, + RequestProtocol, + ResponseProtocol, + File, + Form, +) from nest.core.pynest_application import PyNestApp from nest.core.pynest_container import PyNestContainer from nest.core.pynest_factory import PyNestFactory diff --git a/nest/core/adapters/fastapi/utils.py b/nest/core/adapters/fastapi/utils.py index a6f21dc..545e978 100644 --- a/nest/core/adapters/fastapi/utils.py +++ b/nest/core/adapters/fastapi/utils.py @@ -1,13 +1,22 @@ -from typing import Annotated, Callable +from typing import Callable, Annotated, get_origin, get_args, Optional, Any, Union -from fastapi import Path, Query, Header, Body, File, UploadFile, Response, Request, BackgroundTasks, Form +from fastapi import Path, Query, Header, Body, File, UploadFile, Response, Request, BackgroundTasks, Form, Cookie, \ + Depends from nest.core.protocols import Param, Query as QueryParam, Header as HeaderParam, Body as BodyParam, \ - Cookie as CookieParam, File as FileParam, Form as FormParam + Cookie as CookieParam, File as FileParam, Form as FormParam, BackgroundTasks as BackgroundTasksParam import functools import inspect import typing +def _provide_bg_tasks(bg: BackgroundTasks) -> BackgroundTasks: + """ + A simple dependency function: FastAPI will inject + its `BackgroundTasks` object as 'bg' here, and we return it. + """ + return bg + + def wrap_instance_method( instance, cls, @@ -43,45 +52,82 @@ def wrapper(*args, **kwargs): def rewrite_signature_for_fastapi(func: Callable) -> Callable: """ - A function that modifies the signature to remove "self" - and convert Param/Query/Header/Body to FastAPI’s annotated params. + Modify the function's signature: + - Remove 'self' if it's the first param + - Convert Param[T], QueryParam[T], HeaderParam[T], BodyParam[T], + CookieParam[T], FormParam[T], FileParam[T] into Annotated[InnerType, fastapi.Param(...)] + - Handle nested types like Optional and Union + - Leave special FastAPI types (Request, Response, BackgroundTasks, UploadFile) unchanged """ sig = inspect.signature(func) - new_params = [] + old_params = list(sig.parameters.values()) - old_parameters = list(sig.parameters.values()) + # Remove 'self' if it's the first parameter + if old_params and old_params[0].name == "self": + old_params = old_params[1:] - # 1) If the first param is named 'self', skip it entirely from the new signature - # (because we have a BOUND method). - if old_parameters and old_parameters[0].name == "self": - old_parameters = old_parameters[1:] - - for param in old_parameters: - annotation = param.annotation - - if typing.get_origin(annotation) == Param: - inner_type = typing.get_args(annotation)[0] - new_annotation = Annotated[inner_type, Path()] - new_params.append(param.replace(annotation=new_annotation)) - - elif typing.get_origin(annotation) == QueryParam: - inner_type = typing.get_args(annotation)[0] - new_annotation = Annotated[inner_type, Query()] - new_params.append(param.replace(annotation=new_annotation)) - - elif typing.get_origin(annotation) == HeaderParam: - inner_type = typing.get_args(annotation)[0] - new_annotation = Annotated[inner_type, Header()] + new_params = [] + for param in old_params: + new_annotation = transform_annotation(param.annotation) + if new_annotation: new_params.append(param.replace(annotation=new_annotation)) + continue - elif typing.get_origin(annotation) == BodyParam: - inner_type = typing.get_args(annotation)[0] - new_annotation = Annotated[inner_type, Body()] - new_params.append(param.replace(annotation=new_annotation)) - else: - # unchanged param + # Handle special FastAPI types by keeping them as-is + if param.annotation in (Request, Response, BackgroundTasks, UploadFile): new_params.append(param) + continue + # Leave unchanged + new_params.append(param) + + # Replace the function's signature with the new parameters new_sig = sig.replace(parameters=new_params) func.__signature__ = new_sig return func + + +def transform_annotation(annotation: typing.Any) -> Optional[typing.Any]: + """ + Recursively transform the annotation by replacing custom marker classes + with FastAPI's Annotated types with appropriate parameters. + """ + origin = get_origin(annotation) + args = get_args(annotation) + + if origin is Annotated: + # Already annotated, no further transformation + return annotation + + if origin is Union: + # Handle Union types (e.g., Optional[X] which is Union[X, NoneType]) + transformed_args = tuple(transform_annotation(arg) for arg in args) + return Union[transformed_args] + + # Handle custom marker classes + if origin == Param: + inner_type = args[0] + return Annotated[inner_type, Path()] + elif origin == QueryParam: + inner_type = args[0] + return Annotated[inner_type, Query()] + elif origin == HeaderParam: + inner_type = args[0] + return Annotated[inner_type, Header()] + elif origin == BodyParam: + inner_type = args[0] + return Annotated[inner_type, Body()] + elif origin == CookieParam: + inner_type = args[0] + return Annotated[inner_type, Cookie()] + elif origin == FormParam: + inner_type = args[0] + return Annotated[inner_type, Form()] + elif origin == FileParam: + inner_type = args[0] + return Annotated[inner_type, File()] + if annotation is BackgroundTasksParam: # or if origin == BackgroundTasksParam + return BackgroundTasks + else: + # Not a custom marker, return None to indicate no transformation + return None diff --git a/nest/core/cli_factory.py b/nest/core/cli_factory.py index 99284be..cd70beb 100644 --- a/nest/core/cli_factory.py +++ b/nest/core/cli_factory.py @@ -1,14 +1,13 @@ import asyncio import click +from typing import TypeVar from nest.core.pynest_container import PyNestContainer -from nest.core.pynest_factory import AbstractPyNestFactory, ModuleType +ModuleType = TypeVar("ModuleType") -class CLIAppFactory(AbstractPyNestFactory): - def __init__(self): - super().__init__() +class CLIAppFactory: def create(self, app_module: ModuleType, **kwargs): container = PyNestContainer() diff --git a/nest/core/protocols.py b/nest/core/protocols.py index a2d5727..4216710 100644 --- a/nest/core/protocols.py +++ b/nest/core/protocols.py @@ -171,6 +171,20 @@ def add_websocket_route( Container = TypeVar("Container") + +@runtime_checkable +class FrameworkAdapterProtocol(Protocol): + + def create_app(self, **kwargs: Any) -> Any: + """ + Create and store the main web application object. + **kwargs** can pass parameters (e.g., title, debug, etc.). + Returns the native app object (like FastAPI instance). + """ + ... + + + @runtime_checkable class WebFrameworkAdapterProtocol(Protocol): """ @@ -234,8 +248,7 @@ def register_routes(self, container: Container) -> None: ... -@runtime_checkable -class CLIAdapterProtocol(Protocol): +class CLIAdapterProtocol(FrameworkAdapterProtocol): """ High-level interface for an CLI adapter. The PyNest system can call these methods to: @@ -244,51 +257,8 @@ class CLIAdapterProtocol(Protocol): - register commands into the cli """ - def create_app(self, **kwargs: Any) -> Any: - """ - Create and store the main CLI application object. - **kwargs** can pass parameters - Returns the native app object (like FastAPI instance). - """ - ... - - def get_router(self) -> RouterProtocol: - """ - Return a RouterProtocol for the main router (or a sub-router). - """ - ... - - def add_middleware( - self, - middleware_cls: Any, - **options: Any, - ) -> None: - """ - Add a middleware class to the application, with config options. - """ - ... - - def run(self, host: str = "127.0.0.1", port: int = 8000) -> None: - """ - Blockingly run the HTTP server on the given host/port. - In production, you might prefer to return an ASGI app - and let an external server (e.g., gunicorn) run it. - """ - ... - - async def startup(self) -> None: - """ - Optional: If the framework has an 'on_startup' event, run it. - """ - ... - - async def shutdown(self) -> None: - """ - Optional: If the framework has an 'on_shutdown' event, run it. - """ - ... - def register_routes(self, container: Container) -> None: + def register_commands(self, container: Container) -> None: """ Register multiple routes at once. """ @@ -376,6 +346,15 @@ def debug(self, req: RawRequest): ... """ pass + +class BackgroundTasks(RequestAttribute): + """ + Sometimes you just need the entire request object. + Usage: + def debug(self, req: RawRequest): ... + """ + pass + ############################################################################### # 4. ERROR HANDLING ############################################################################### diff --git a/nest/core/pynest_factory.py b/nest/core/pynest_factory.py index 9bff562..9bf3513 100644 --- a/nest/core/pynest_factory.py +++ b/nest/core/pynest_factory.py @@ -1,13 +1,17 @@ -# nest/core/pynest_factory.py - from typing import Type, TypeVar, Optional from nest.core.pynest_application import PyNestApp from nest.core.pynest_container import PyNestContainer from nest.core.protocols import WebFrameworkAdapterProtocol -from nest.core.adapters.fastapi.fastapi_adapter import FastAPIAdapter ModuleType = TypeVar("ModuleType") +def adapter_map(adapter: str) -> WebFrameworkAdapterProtocol: + if adapter == "fastapi": + from nest.core.adapters.fastapi.fastapi_adapter import FastAPIAdapter + return FastAPIAdapter() + else: + raise ValueError(f"Unknown adapter: {adapter}") + class PyNestFactory: """Factory class for creating PyNest applications.""" @@ -15,16 +19,18 @@ class PyNestFactory: @staticmethod def create( main_module: Type[ModuleType], - adapter: Optional[WebFrameworkAdapterProtocol] = None, + adapter: Optional[str] = "fastapi", **kwargs ) -> PyNestApp: """ Create a PyNest application with the specified main module class and a chosen adapter (defaults to FastAPIAdapter if none given). """ + # Get the adapter instance if adapter is None: - adapter = FastAPIAdapter() # Default to FastAPI + adapter = "fastapi" + adapter = adapter_map(adapter) container = PyNestContainer() container.add_module(main_module) From f619cbd1078dbb34b9fd40cb5759fe0989af4ff6 Mon Sep 17 00:00:00 2001 From: "itay.dar" Date: Fri, 24 Jan 2025 15:27:29 +0200 Subject: [PATCH 3/3] run black --- examples/BlankApp/src/app_module.py | 1 - examples/BlankApp/src/user/user_controller.py | 70 ++++++++++++++----- examples/BlankApp/src/user/user_model.py | 18 +++-- examples/BlankApp/src/user/user_service.py | 13 ++-- nest/core/adapters/fastapi/fastapi_adapter.py | 27 +++---- nest/core/adapters/fastapi/utils.py | 34 +++++++-- nest/core/cli_factory.py | 1 + nest/core/decorators/controller.py | 5 +- nest/core/protocols.py | 30 +++++--- nest/core/pynest_application.py | 7 +- nest/core/pynest_factory.py | 6 +- 11 files changed, 148 insertions(+), 64 deletions(-) diff --git a/examples/BlankApp/src/app_module.py b/examples/BlankApp/src/app_module.py index cdd1768..8d6d83f 100644 --- a/examples/BlankApp/src/app_module.py +++ b/examples/BlankApp/src/app_module.py @@ -25,4 +25,3 @@ class AppModule: version="1.0.0", debug=True, ) - diff --git a/examples/BlankApp/src/user/user_controller.py b/examples/BlankApp/src/user/user_controller.py index cf44894..b8d62c9 100644 --- a/examples/BlankApp/src/user/user_controller.py +++ b/examples/BlankApp/src/user/user_controller.py @@ -1,27 +1,63 @@ -from nest.core import Controller, Get, Post -from nest.core.protocols import Param, Query, Header, Body +# app/controllers/user_controller.py + import uuid -from .user_model import User +from typing import Optional, Any from .user_service import UserService +from nest.core import Controller, Get, Post, Param, Query, Body, Form, File +from .user_model import UserCreateDTO, UserDTO -@Controller("user", tag="user") +@Controller(prefix="/users", tag="Users") class UserController: - def __init__(self, service: UserService): - self.service = service + def __init__(self, user_service: UserService): + self.user_service = user_service - @Get("/", response_model=list[User]) - def get_user(self): - return self.service.get_user() + @Get("/{user_id}") + def get_user_by_id(self, user_id: Param[uuid.UUID]) -> dict: + """ + Retrieve a user by their UUID. + """ + user = self.user_service.get_user_by_id(str(user_id)) + return {"user": user} - @Get("/{user_id}", response_model=User) - def get_user_by_id(self, user_id: Param[uuid.UUID]): - return self.service.get_user_by_id(user_id) + @Get("/") + def list_users( + self, + page: Query[int] = 1, + limit: Query[int] = 50, + search: Optional[Query[str]] = None, + ) -> dict: + """ + List users with pagination and optional search. + """ + # Implement pagination and search logic here + return { + "message": f"Listing users on page={page}, limit={limit}, search={search}" + } @Post("/") - def add_user(self, user: Body[User]): - return self.service.add_user(user) + def create_user(self, user: Body[UserCreateDTO]) -> dict: + """ + Create a new user. + """ + user_data = self.user_service.add_user(user) + return {"message": "User created", "user": user_data} - @Get("/test/new-user/{user_id}") - def test_new_user(self, user_id: Param[uuid.UUID]): - return self.service.get_user_by_id(user_id) + # + @Post("/{user_id}/upload-avatar") + def upload_avatar( + self, + user_id: Param[uuid.UUID], + file: File[bytes], + description: Optional[Form[str]] = None, + ) -> dict: + """ + Upload an avatar for a user. + """ + # avatar_url = self.user_service.upload_avatar(user_id, file, description) + print(f"Uploaded avatar for user {user_id}: {file}") + print(f"Description: {description}") + return { + "message": "Avatar uploaded", + "avatar_url": "http://example.com/avatar.jpg", + } diff --git a/examples/BlankApp/src/user/user_model.py b/examples/BlankApp/src/user/user_model.py index 1918ca8..8043d84 100644 --- a/examples/BlankApp/src/user/user_model.py +++ b/examples/BlankApp/src/user/user_model.py @@ -1,7 +1,15 @@ -from dataclasses import dataclass -from uuid import UUID +import uuid +from pydantic import BaseModel -@dataclass -class User: - id: UUID + +class UserDTO(BaseModel): + id: uuid.UUID + name: str + email: str + age: int + + +class UserCreateDTO(BaseModel): name: str + email: str + age: int diff --git a/examples/BlankApp/src/user/user_service.py b/examples/BlankApp/src/user/user_service.py index 1774ce1..1803eb4 100644 --- a/examples/BlankApp/src/user/user_service.py +++ b/examples/BlankApp/src/user/user_service.py @@ -2,22 +2,23 @@ from nest.core.decorators import Injectable -from .user_model import User +from .user_model import UserDTO, UserCreateDTO @Injectable class UserService: def __init__(self): self.database = [] - time.sleep(5) - print("UserService initialized") - def get_user(self): + def get_user(self) -> list[UserDTO]: return self.database - def add_user(self, user: User): + def add_user(self, user: UserCreateDTO): self.database.append(user) return user - def get_user_by_id(self, user_id: str): + def get_user_by_id(self, user_id: str) -> UserDTO: return next((user for user in self.database if user.id == user_id), None) + + def log_access(self, user_id): + print(user_id, " Access app") diff --git a/nest/core/adapters/fastapi/fastapi_adapter.py b/nest/core/adapters/fastapi/fastapi_adapter.py index e1bc987..3e12c29 100644 --- a/nest/core/adapters/fastapi/fastapi_adapter.py +++ b/nest/core/adapters/fastapi/fastapi_adapter.py @@ -5,7 +5,8 @@ from nest.core.protocols import ( WebFrameworkAdapterProtocol, - RouterProtocol, Container, + RouterProtocol, + Container, ) from nest.core.adapters.fastapi.utils import wrap_instance_method @@ -25,19 +26,18 @@ def __init__(self, base_path: str = "") -> None: self._router = APIRouter(prefix=self._base_path) def add_route( - self, - path: str, - endpoint: Callable[..., Any], - methods: List[str], - *, - name: Optional[str] = None, + self, + path: str, + endpoint: Callable[..., Any], + methods: List[str], + *, + name: Optional[str] = None, ) -> None: """ Register an HTTP route with FastAPI's APIRouter. """ self._router.add_api_route(path, endpoint, methods=methods, name=name) - def get_router(self) -> APIRouter: """ Return the underlying FastAPI APIRouter. @@ -49,6 +49,7 @@ def get_router(self) -> APIRouter: # FastAPI Adapter ############################################################################### + class FastAPIAdapter(WebFrameworkAdapterProtocol): """ A FastAPI-based implementation of WebFrameworkAdapterProtocol. @@ -81,9 +82,9 @@ def get_router(self) -> RouterProtocol: return self._router_adapter def add_middleware( - self, - middleware_cls: Any, - **options: Any, + self, + middleware_cls: Any, + **options: Any, ) -> None: """ Add middleware to the FastAPI application. @@ -132,7 +133,9 @@ def register_routes(self, container: Container) -> None: method = route_definition["method"] original_method = route_definition["endpoint"] - final_endpoint = wrap_instance_method(instance, controller_cls, original_method) + final_endpoint = wrap_instance_method( + instance, controller_cls, original_method + ) self._router_adapter.add_route( path=path, diff --git a/nest/core/adapters/fastapi/utils.py b/nest/core/adapters/fastapi/utils.py index 545e978..e586e2c 100644 --- a/nest/core/adapters/fastapi/utils.py +++ b/nest/core/adapters/fastapi/utils.py @@ -1,9 +1,29 @@ from typing import Callable, Annotated, get_origin, get_args, Optional, Any, Union -from fastapi import Path, Query, Header, Body, File, UploadFile, Response, Request, BackgroundTasks, Form, Cookie, \ - Depends -from nest.core.protocols import Param, Query as QueryParam, Header as HeaderParam, Body as BodyParam, \ - Cookie as CookieParam, File as FileParam, Form as FormParam, BackgroundTasks as BackgroundTasksParam +from fastapi import ( + Path, + Query, + Header, + Body, + File, + UploadFile, + Response, + Request, + BackgroundTasks, + Form, + Cookie, + Depends, +) +from nest.core.protocols import ( + Param, + Query as QueryParam, + Header as HeaderParam, + Body as BodyParam, + Cookie as CookieParam, + File as FileParam, + Form as FormParam, + BackgroundTasks as BackgroundTasksParam, +) import functools import inspect import typing @@ -18,9 +38,9 @@ def _provide_bg_tasks(bg: BackgroundTasks) -> BackgroundTasks: def wrap_instance_method( - instance, - cls, - method: Callable, + instance, + cls, + method: Callable, ) -> Callable: """ 1. Create a new plain function that calls `method(instance, ...)`. diff --git a/nest/core/cli_factory.py b/nest/core/cli_factory.py index cd70beb..fe3c04d 100644 --- a/nest/core/cli_factory.py +++ b/nest/core/cli_factory.py @@ -7,6 +7,7 @@ ModuleType = TypeVar("ModuleType") + class CLIAppFactory: def create(self, app_module: ModuleType, **kwargs): diff --git a/nest/core/decorators/controller.py b/nest/core/decorators/controller.py index fb7a76d..e929761 100644 --- a/nest/core/decorators/controller.py +++ b/nest/core/decorators/controller.py @@ -107,7 +107,10 @@ def collect_route_definitions(cls: Type, base_prefix: str): def validate_method_decorator(method_function: callable, method_name: str) -> None: """Validate that the method has a proper HTTP method decorator.""" - if not hasattr(method_function, "__route_path__") or not method_function.__route_path__: + if ( + not hasattr(method_function, "__route_path__") + or not method_function.__route_path__ + ): raise AssertionError(f"Missing path for method {method_name}") if not isinstance(method_function.__http_method__, HTTPMethod): diff --git a/nest/core/protocols.py b/nest/core/protocols.py index 4216710..ce18804 100644 --- a/nest/core/protocols.py +++ b/nest/core/protocols.py @@ -19,6 +19,7 @@ # 1. REQUEST & RESPONSE ############################################################################### + @runtime_checkable class RequestProtocol(Protocol): """ @@ -124,11 +125,13 @@ def set_json(self, data: Any, status_code: Optional[int] = None) -> None: # 2. ROUTING & HTTP METHODS ############################################################################### + @runtime_checkable class RouteDefinition(Protocol): """ Represents a single route definition: path, HTTP methods, etc. """ + path: str http_methods: List[str] endpoint: Callable[..., Any] @@ -184,7 +187,6 @@ def create_app(self, **kwargs: Any) -> Any: ... - @runtime_checkable class WebFrameworkAdapterProtocol(Protocol): """ @@ -257,7 +259,6 @@ class CLIAdapterProtocol(FrameworkAdapterProtocol): - register commands into the cli """ - def register_commands(self, container: Container) -> None: """ Register multiple routes at once. @@ -265,8 +266,6 @@ def register_commands(self, container: Container) -> None: ... - - ############################################################################### # 3. PARAMETER EXTRACTION (NEST-JS LIKE) ############################################################################### @@ -279,6 +278,7 @@ class RequestAttribute: Marker/base class for typed request attributes like Param, Query, Header, Cookie, etc. """ + ... @@ -287,6 +287,7 @@ class Param(Generic[T], RequestAttribute): Usage: def get_item(self, id: Param[int]): ... """ + pass @@ -295,6 +296,7 @@ class Query(Generic[T], RequestAttribute): Usage: def search(self, q: Query[str]): ... """ + pass @@ -303,6 +305,7 @@ class Header(Generic[T], RequestAttribute): Usage: def do_something(self, token: Header[str]): ... """ + pass @@ -311,6 +314,7 @@ class Cookie(Generic[T], RequestAttribute): Usage: def show_info(self, user_id: Cookie[str]): ... """ + pass @@ -319,6 +323,7 @@ class Body(Generic[T], RequestAttribute): Usage: def create_user(self, data: Body[UserDTO]): ... """ + pass @@ -327,6 +332,7 @@ class Form(Generic[T], RequestAttribute): Usage: def post_form(self, form_data: Form[LoginForm]): ... """ + pass @@ -335,6 +341,7 @@ class File(Generic[T], RequestAttribute): Usage: def upload(self, file: File[UploadedFile]): ... """ + pass @@ -344,6 +351,7 @@ class RawRequest(RequestAttribute): Usage: def debug(self, req: RawRequest): ... """ + pass @@ -353,18 +361,22 @@ class BackgroundTasks(RequestAttribute): Usage: def debug(self, req: RawRequest): ... """ + pass + ############################################################################### # 4. ERROR HANDLING ############################################################################### + @runtime_checkable class HTTPExceptionProtocol(Protocol): """ A standardized interface for raising an HTTP exception or error that can be recognized by the underlying framework. """ + status_code: int detail: str @@ -393,6 +405,7 @@ def handle_exception(self, exc: Exception) -> ResponseProtocol: # 5. MIDDLEWARE & FILTERS ############################################################################### + @runtime_checkable class MiddlewareProtocol(Protocol): """ @@ -417,17 +430,18 @@ class FilterProtocol(Protocol): Could be integrated as an alternative or layer on top of middleware. """ - def before_request(self, request: RequestProtocol) -> None: - ... + def before_request(self, request: RequestProtocol) -> None: ... - def after_request(self, request: RequestProtocol, response: ResponseProtocol) -> None: - ... + def after_request( + self, request: RequestProtocol, response: ResponseProtocol + ) -> None: ... ############################################################################### # 6. SECURITY & AUTH ############################################################################### + @runtime_checkable class AuthGuardProtocol(Protocol): """ diff --git a/nest/core/pynest_application.py b/nest/core/pynest_application.py index 26f9a34..420ee72 100644 --- a/nest/core/pynest_application.py +++ b/nest/core/pynest_application.py @@ -2,7 +2,9 @@ from typing import Any from nest.core.protocols import WebFrameworkAdapterProtocol -from nest.core.pynest_app_context import PyNestApplicationContext # Ensure correct import +from nest.core.pynest_app_context import ( + PyNestApplicationContext, +) class PyNestApp(PyNestApplicationContext): @@ -17,7 +19,6 @@ def __init__(self, container, adapter: WebFrameworkAdapterProtocol): # Create and configure the web application via the adapter self.web_app = self.adapter.create_app() - def use_middleware(self, middleware_cls: type, **options: Any) -> "PyNestApp": """ Add middleware to the application. @@ -25,8 +26,6 @@ def use_middleware(self, middleware_cls: type, **options: Any) -> "PyNestApp": self.adapter.add_middleware(middleware_cls, **options) return self - @property def is_listening(self) -> bool: return self._is_listening - diff --git a/nest/core/pynest_factory.py b/nest/core/pynest_factory.py index 9bf3513..af499f6 100644 --- a/nest/core/pynest_factory.py +++ b/nest/core/pynest_factory.py @@ -5,9 +5,11 @@ ModuleType = TypeVar("ModuleType") + def adapter_map(adapter: str) -> WebFrameworkAdapterProtocol: if adapter == "fastapi": from nest.core.adapters.fastapi.fastapi_adapter import FastAPIAdapter + return FastAPIAdapter() else: raise ValueError(f"Unknown adapter: {adapter}") @@ -18,9 +20,7 @@ class PyNestFactory: @staticmethod def create( - main_module: Type[ModuleType], - adapter: Optional[str] = "fastapi", - **kwargs + main_module: Type[ModuleType], adapter: Optional[str] = "fastapi", **kwargs ) -> PyNestApp: """ Create a PyNest application with the specified main module class