From edae18340b9859a2e486fd69a874f963b7309195 Mon Sep 17 00:00:00 2001 From: tarsil Date: Sun, 19 Jan 2025 15:16:36 +0100 Subject: [PATCH 1/6] Add name to handlers for lookup --- esmerald/openapi/openapi.py | 3 +- esmerald/routing/gateways.py | 15 +++--- esmerald/routing/handlers.py | 90 ++++++++++++++++++++++++++++++++++++ esmerald/routing/router.py | 26 ++++++++--- 4 files changed, 120 insertions(+), 14 deletions(-) diff --git a/esmerald/openapi/openapi.py b/esmerald/openapi/openapi.py index 6dc91c46..e4ab1035 100644 --- a/esmerald/openapi/openapi.py +++ b/esmerald/openapi/openapi.py @@ -178,7 +178,8 @@ def get_openapi_operation( if route.handler.summary: operation.summary = route.handler.summary else: - operation.summary = route.handler.name.replace("_", " ").replace("-", " ").title() + name = route.handler.name or route.name + operation.summary = name.replace("_", " ").replace("-", " ").title() # Handle the handler description if route.handler.description: diff --git a/esmerald/routing/gateways.py b/esmerald/routing/gateways.py index 3ce8733a..f0130f26 100644 --- a/esmerald/routing/gateways.py +++ b/esmerald/routing/gateways.py @@ -42,7 +42,6 @@ def handle_middleware( class GatewayUtil: - def is_class_based( self, handler: Union["HTTPHandler", "WebSocketHandler", "ParentType"] ) -> bool: @@ -286,7 +285,8 @@ def __init__( if not name: if not isinstance(handler, View): - name = clean_string(handler.fn.__name__) + if not handler.name: + name = clean_string(handler.fn.__name__) else: name = clean_string(handler.__class__.__name__) @@ -328,17 +328,17 @@ def __init__( self.operation_id = operation_id if self.is_handler(self.handler): # type: ignore - self.handler.name = self.name - if self.operation_id or handler.operation_id is not None: handler_id = self.generate_operation_id( - name=self.name, handler=self.handler # type: ignore + name=self.name, + handler=self.handler, # type: ignore ) self.operation_id = f"{operation_id}_{handler_id}" if operation_id else handler_id elif not handler.operation_id: handler.operation_id = self.generate_operation_id( - name=self.name, handler=self.handler # type: ignore + name=self.name, + handler=self.handler, # type: ignore ) async def handle_dispatch(self, scope: "Scope", receive: "Receive", send: "Send") -> None: @@ -691,5 +691,6 @@ def __init__( if not handler.operation_id: handler.operation_id = self.generate_operation_id( - name=self.name, handler=self.handler # type: ignore + name=self.name, + handler=self.handler, # type: ignore ) diff --git a/esmerald/routing/handlers.py b/esmerald/routing/handlers.py index 1cda8c05..89379677 100644 --- a/esmerald/routing/handlers.py +++ b/esmerald/routing/handlers.py @@ -50,6 +50,14 @@ def get( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -397,6 +405,7 @@ async def get() -> str: def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.GET], summary=summary, description=description, @@ -441,6 +450,14 @@ def head( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -666,6 +683,7 @@ def head( def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.HEAD], summary=summary, description=description, @@ -722,6 +740,14 @@ def post( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -1074,6 +1100,7 @@ async def create() -> None: def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, status_code=status_code, content_encoding=content_encoding, content_media_type=content_media_type, @@ -1130,6 +1157,14 @@ def put( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -1477,6 +1512,7 @@ async def update() -> str: def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.PUT], summary=summary, description=description, @@ -1533,6 +1569,14 @@ def patch( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -1880,6 +1924,7 @@ async def update_partial() -> str: def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.PATCH], summary=summary, description=description, @@ -1936,6 +1981,14 @@ def delete( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -2283,6 +2336,7 @@ async def remove() -> None: def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.DELETE], summary=summary, description=description, @@ -2327,6 +2381,14 @@ def options( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -2552,6 +2614,7 @@ def options( def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.OPTIONS], summary=summary, description=description, @@ -2596,6 +2659,14 @@ def trace( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -2822,6 +2893,7 @@ def trace( def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.TRACE], summary=summary, description=description, @@ -2878,6 +2950,14 @@ def route( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, methods: Annotated[ List[str], Doc( @@ -3259,6 +3339,7 @@ async def operate() -> str: def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=methods, summary=summary, description=description, @@ -3316,6 +3397,14 @@ def websocket( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, dependencies: Annotated[ Optional["Dependencies"], Doc( @@ -3356,6 +3445,7 @@ def wrapper(func: Any) -> WebSocketHandler: exception_handlers=exception_handlers, permissions=permissions, middleware=middleware, + name=name, ) handler.fn = func handler.handler = func diff --git a/esmerald/routing/router.py b/esmerald/routing/router.py index fb205991..a54fe49e 100644 --- a/esmerald/routing/router.py +++ b/esmerald/routing/router.py @@ -481,9 +481,9 @@ async def another(request: Request) -> str: path = "/" else: assert path.startswith("/"), "A path prefix must start with '/'" - assert not path.endswith("/"), ( - "A path must not end with '/', as the routes will start with '/'" - ) + assert not path.endswith( + "/" + ), "A path must not end with '/', as the routes will start with '/'" new_routes: list[Any] = [] for route in routes or []: @@ -511,9 +511,9 @@ async def another(request: Request) -> str: ) new_routes.append(route) - assert lifespan is None or (on_startup is None and on_shutdown is None), ( - "Use either 'lifespan' or 'on_startup'/'on_shutdown', not both." - ) + assert lifespan is None or ( + on_startup is None and on_shutdown is None + ), "Use either 'lifespan' or 'on_startup'/'on_shutdown', not both." super().__init__( redirect_slashes=redirect_slashes, @@ -1929,6 +1929,7 @@ class HTTPHandler(Dispatcher, OpenAPIFieldInfoMixin, LilyaPath): "_dependencies", "_response_handler", "_middleware", + "name", "methods", "status_code", "content_encoding", @@ -1958,6 +1959,7 @@ def __init__( path: Optional[str] = None, handler: Callable[..., Any] = None, *, + name: Optional[str] = None, methods: Optional[Sequence[str]] = None, status_code: Optional[int] = None, content_encoding: Optional[str] = None, @@ -1991,6 +1993,7 @@ def __init__( handler=handler, include_in_schema=include_in_schema, exception_handlers=exception_handlers, + name=name, ) self._permissions: Union[List[Permission], VoidType] = Void @@ -2002,6 +2005,7 @@ def __init__( self.path = path self.handler = handler self.summary = summary + self.name = name self.tags = tags or [] self.include_in_schema = include_in_schema self.deprecated = deprecated @@ -2379,6 +2383,7 @@ class WebSocketHandler(Dispatcher, LilyaWebSocketPath): "permissions", "middleware", "parent", + "name", "__type__", ) @@ -2391,6 +2396,14 @@ def __init__( exception_handlers: Optional[ExceptionHandlerMap] = None, permissions: Optional[List[Permission]] = None, middleware: Optional[List[Middleware]] = None, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, ): if not path: path = "/" @@ -2411,6 +2424,7 @@ def __init__( self.fn: Optional[AnyCallable] = None self.tags: Sequence[str] = [] self.__type__: Union[str, None] = None + self.name = name def validate_reserved_words(self, signature: Signature) -> None: """ From 45893f875c892df308734f0711bc4b7118311e62 Mon Sep 17 00:00:00 2001 From: tarsil Date: Sun, 19 Jan 2025 16:08:16 +0100 Subject: [PATCH 2/6] Fix reverse lookup for CBV --- esmerald/routing/apis/base.py | 2 +- esmerald/routing/gateways.py | 15 ++++++--- tests/routing/test_path_lookup.py | 53 +++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 tests/routing/test_path_lookup.py diff --git a/esmerald/routing/apis/base.py b/esmerald/routing/apis/base.py index 326d07ad..19458aee 100644 --- a/esmerald/routing/apis/base.py +++ b/esmerald/routing/apis/base.py @@ -387,7 +387,7 @@ def get_routes( route_kwargs = { "path": path, "handler": route_handler, - "name": name or route_handler.fn.__name__, + "name": name or route_handler.name or route_handler.fn.__name__, "middleware": middleware, "interceptors": interceptors, "permissions": permissions, diff --git a/esmerald/routing/gateways.py b/esmerald/routing/gateways.py index f0130f26..d028e87c 100644 --- a/esmerald/routing/gateways.py +++ b/esmerald/routing/gateways.py @@ -1,4 +1,5 @@ import re +import warnings from typing import TYPE_CHECKING, Any, Callable, List, Optional, Sequence, Union, cast from lilya._internal._path import clean_path @@ -283,10 +284,16 @@ def __init__( self.methods = getattr(handler, "http_methods", None) + if name and isinstance(handler, View): + warnings.warn( + "When using a class based view, the name will be automatically generated from the class name if the handlers of the Class based view are not specified. This can cause problems with reverse lookup.", + UserWarning, + stacklevel=2, + ) + if not name: if not isinstance(handler, View): - if not handler.name: - name = clean_string(handler.fn.__name__) + name = handler.name or clean_string(handler.fn.__name__) else: name = clean_string(handler.__class__.__name__) @@ -330,14 +337,14 @@ def __init__( if self.is_handler(self.handler): # type: ignore if self.operation_id or handler.operation_id is not None: handler_id = self.generate_operation_id( - name=self.name, + name=self.name or "", handler=self.handler, # type: ignore ) self.operation_id = f"{operation_id}_{handler_id}" if operation_id else handler_id elif not handler.operation_id: handler.operation_id = self.generate_operation_id( - name=self.name, + name=self.name or "", handler=self.handler, # type: ignore ) diff --git a/tests/routing/test_path_lookup.py b/tests/routing/test_path_lookup.py new file mode 100644 index 00000000..75e10399 --- /dev/null +++ b/tests/routing/test_path_lookup.py @@ -0,0 +1,53 @@ +import pytest +from lilya.compat import reverse + +from esmerald import Controller, Gateway, Include, get, post +from esmerald.testclient import create_client + + +@get("/hello") +async def get_hello() -> str: + return "Hello World" + + +@post("/new") +async def post_new() -> str: + return "New World" + + +class TestController(Controller): + @get("/int", name="int") + async def get_int(self, id: int) -> int: + return id + + +routes = [ + Include( + "/api", + routes=[ + Include( + routes=[ + Gateway(handler=get_hello, name="hello"), + Gateway(handler=post_new, name="new"), + Gateway(handler=TestController, name="test"), + ], + name="v1", + ), + ], + name="api", + ), +] + + +@pytest.mark.filterwarnings(r"ignore" r":UserWarning") +def test_can_reverse_lookup(test_client_factory): + with create_client(routes=routes) as client: + app = client.app + + assert app.path_for("api:v1:hello") == "/api/hello" + assert app.path_for("api:v1:new") == "/api/new" + assert reverse("api:v1:hello") == "/api/hello" + assert reverse("api:v1:new") == "/api/new" + + assert app.path_for("api:v1:int") == "/api/int" + assert reverse("api:v1:int") == "/api/int" From 124d4a2a97e4ffbdb76f713df3aab17550d2bd94 Mon Sep 17 00:00:00 2001 From: tarsil Date: Sun, 19 Jan 2025 16:09:44 +0100 Subject: [PATCH 3/6] Clean tests --- tests/routing/test_path_lookup.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/routing/test_path_lookup.py b/tests/routing/test_path_lookup.py index 75e10399..3deb4c45 100644 --- a/tests/routing/test_path_lookup.py +++ b/tests/routing/test_path_lookup.py @@ -1,4 +1,3 @@ -import pytest from lilya.compat import reverse from esmerald import Controller, Gateway, Include, get, post @@ -29,7 +28,7 @@ async def get_int(self, id: int) -> int: routes=[ Gateway(handler=get_hello, name="hello"), Gateway(handler=post_new, name="new"), - Gateway(handler=TestController, name="test"), + Gateway(handler=TestController), ], name="v1", ), @@ -39,7 +38,6 @@ async def get_int(self, id: int) -> int: ] -@pytest.mark.filterwarnings(r"ignore" r":UserWarning") def test_can_reverse_lookup(test_client_factory): with create_client(routes=routes) as client: app = client.app From 8c3098bf6f11bcd9048da93b9c05e04372f493c8 Mon Sep 17 00:00:00 2001 From: tarsil Date: Sun, 19 Jan 2025 17:08:46 +0100 Subject: [PATCH 4/6] Address naming lookup tests --- esmerald/routing/apis/base.py | 8 +- esmerald/routing/gateways.py | 18 ++++- tests/openapi/test_duplicate_operation_id.py | 8 +- tests/openapi/test_include_with_apiview.py | 3 +- tests/routing/test_path_lookup.py | 79 ++++++++++++++++++-- 5 files changed, 101 insertions(+), 15 deletions(-) diff --git a/esmerald/routing/apis/base.py b/esmerald/routing/apis/base.py index 19458aee..bfe26a65 100644 --- a/esmerald/routing/apis/base.py +++ b/esmerald/routing/apis/base.py @@ -387,12 +387,18 @@ def get_routes( route_kwargs = { "path": path, "handler": route_handler, - "name": name or route_handler.name or route_handler.fn.__name__, "middleware": middleware, "interceptors": interceptors, "permissions": permissions, "exception_handlers": exception_handlers, } + + route_name_list = [ + self.parent.name, + ] + + # Make sure the proper name is set for the route + route_kwargs["name"] = ":".join(route_name_list) route_path = ( Gateway(**route_kwargs) # type: ignore if isinstance(route_handler, HTTPHandler) diff --git a/esmerald/routing/gateways.py b/esmerald/routing/gateways.py index d028e87c..bf897a1b 100644 --- a/esmerald/routing/gateways.py +++ b/esmerald/routing/gateways.py @@ -60,9 +60,7 @@ def generate_operation_id( We need to be able to handle with edge cases when a view does not default a path like `/format` and a default name needs to be passed when its a class based view. """ if self.is_class_based(handler.parent): - operation_id = ( - handler.parent.__class__.__name__.lower() + f"_{name}" + handler.path_format - ) + operation_id = handler.parent.__class__.__name__.lower() + handler.path_format else: operation_id = name + handler.path_format @@ -297,6 +295,12 @@ def __init__( else: name = clean_string(handler.__class__.__name__) + else: + route_name_list = [name] + if not isinstance(handler, View) and handler.name: + route_name_list.append(handler.name) + name = ":".join(route_name_list) + # Handle middleware self.middleware = middleware or [] self._middleware: List["Middleware"] = self.handle_middleware( @@ -510,10 +514,16 @@ def __init__( if not name: if not isinstance(handler, View): - name = clean_string(handler.fn.__name__) + name = handler.name or clean_string(handler.fn.__name__) else: name = clean_string(handler.__class__.__name__) + else: + route_name_list = [name] + if not isinstance(handler, View) and handler.name: + route_name_list.append(handler.name) + name = ":".join(route_name_list) + # Handle middleware self.middleware = middleware or [] self._middleware: List["Middleware"] = self.handle_middleware( diff --git a/tests/openapi/test_duplicate_operation_id.py b/tests/openapi/test_duplicate_operation_id.py index 1d270383..54efa8f8 100644 --- a/tests/openapi/test_duplicate_operation_id.py +++ b/tests/openapi/test_duplicate_operation_id.py @@ -64,9 +64,9 @@ def test_open_api_schema(test_client_factory): "/api/v1/admin/users": { "post": { "tags": ["User"], - "summary": "Create", + "summary": "Userapiview", "description": "", - "operationId": "userapiview_create__post", + "operationId": "userapiview__post", "responses": { "201": { "description": "Successful response", @@ -79,9 +79,9 @@ def test_open_api_schema(test_client_factory): "/api/v1/admin/profiles": { "post": { "tags": ["Profile"], - "summary": "Create", + "summary": "Profileapiview", "description": "", - "operationId": "profileapiview_create__post", + "operationId": "profileapiview__post", "responses": { "201": { "description": "Successful response", diff --git a/tests/openapi/test_include_with_apiview.py b/tests/openapi/test_include_with_apiview.py index 7f65374c..1de56479 100644 --- a/tests/openapi/test_include_with_apiview.py +++ b/tests/openapi/test_include_with_apiview.py @@ -25,6 +25,7 @@ class MyAPI(value): @get( "/item", + summary="Read Item", description="Read an item", responses={ 200: OpenAPIResponse(model=Item, description="The SKU information of an item") @@ -57,7 +58,7 @@ async def read_item(self) -> JSON: ... "get": { "summary": "Read Item", "description": "Read an item", - "operationId": "myapi_read_item_item_get", + "operationId": "myapi_item_get", "responses": { "200": { "description": "The SKU information of an item", diff --git a/tests/routing/test_path_lookup.py b/tests/routing/test_path_lookup.py index 3deb4c45..1bfc8509 100644 --- a/tests/routing/test_path_lookup.py +++ b/tests/routing/test_path_lookup.py @@ -14,12 +14,30 @@ async def post_new() -> str: return "New World" +@get("/home", name="home") +async def home() -> str: + return "Hello World" + + +@post("/new-home", name="new-home") +async def post_new_home() -> str: + return "New World" + + class TestController(Controller): @get("/int", name="int") async def get_int(self, id: int) -> int: return id +class Test2Controller(Controller): + path = "/test" + + @get("/int", name="int") + async def get_int(self, id: int) -> int: + return id + + routes = [ Include( "/api", @@ -28,7 +46,10 @@ async def get_int(self, id: int) -> int: routes=[ Gateway(handler=get_hello, name="hello"), Gateway(handler=post_new, name="new"), - Gateway(handler=TestController), + Gateway(handler=home, name="home"), + Gateway(handler=post_new_home, name="new-home"), + Gateway(handler=TestController, name="test"), + Gateway(handler=Test2Controller), ], name="v1", ), @@ -38,14 +59,62 @@ async def get_int(self, id: int) -> int: ] -def test_can_reverse_lookup(test_client_factory): - with create_client(routes=routes) as client: +def test_can_reverse_simple(test_client_factory): + with create_client(routes=routes, enable_openapi=False) as client: + app = client.app + + assert app.path_for("api:v1:hello") == "/api/hello" + assert app.path_for("api:v1:new") == "/api/new" + + assert reverse("api:v1:hello") == "/api/hello" + assert reverse("api:v1:new") == "/api/new" + + +def test_can_reverse_with_gateway_and_handler_name(test_client_factory): + with create_client(routes=routes, enable_openapi=False) as client: + app = client.app + + assert app.path_for("api:v1:home:home") == "/api/home" + assert reverse("api:v1:home:home") == "/api/home" + + assert app.path_for("api:v1:new-home:new-home") == "/api/new-home" + assert reverse("api:v1:new-home:new-home") == "/api/new-home" + + +def test_can_reverse_with_controller_and_handler_name(test_client_factory): + with create_client(routes=routes, enable_openapi=False) as client: + app = client.app + + assert app.path_for("api:v1:test:int") == "/api/int" + assert reverse("api:v1:test:int") == "/api/int" + + +def test_can_reverse_with_no_controller_name_and_handler_name(test_client_factory): + with create_client(routes=routes, enable_openapi=False) as client: + app = client.app + + assert app.path_for("api:v1:test2controller:int") == "/api/test/int" + assert reverse("api:v1:test2controller:int") == "/api/test/int" + + +def test_can_reverse_lookup_all(test_client_factory): + with create_client(routes=routes, enable_openapi=False) as client: app = client.app assert app.path_for("api:v1:hello") == "/api/hello" assert app.path_for("api:v1:new") == "/api/new" + assert reverse("api:v1:hello") == "/api/hello" assert reverse("api:v1:new") == "/api/new" - assert app.path_for("api:v1:int") == "/api/int" - assert reverse("api:v1:int") == "/api/int" + assert app.path_for("api:v1:home:home") == "/api/home" + assert reverse("api:v1:home:home") == "/api/home" + + assert app.path_for("api:v1:new-home:new-home") == "/api/new-home" + assert reverse("api:v1:new-home:new-home") == "/api/new-home" + + assert app.path_for("api:v1:test:int") == "/api/int" + assert reverse("api:v1:test:int") == "/api/int" + + assert app.path_for("api:v1:test2controller:int") == "/api/test/int" + assert reverse("api:v1:test2controller:int") == "/api/test/int" From 220b080fbd075eca1d3fb32328dd34efa8636327 Mon Sep 17 00:00:00 2001 From: tarsil Date: Sun, 19 Jan 2025 18:21:22 +0100 Subject: [PATCH 5/6] Fix show urls --- esmerald/__init__.py | 3 ++- .../core/directives/operations/show_urls.py | 18 +++++++++++---- esmerald/openapi/openapi.py | 2 +- esmerald/routing/base.py | 23 +++++++++++++++++++ esmerald/routing/gateways.py | 8 ------- 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/esmerald/__init__.py b/esmerald/__init__.py index 52024376..4f48e3f5 100644 --- a/esmerald/__init__.py +++ b/esmerald/__init__.py @@ -33,7 +33,7 @@ from .routing.apis import APIView, Controller, SimpleAPIView from .routing.gateways import Gateway, WebhookGateway, WebSocketGateway from .routing.handlers import delete, get, head, options, patch, post, put, route, trace, websocket -from .routing.router import Include, Router +from .routing.router import Host, Include, Router from .routing.webhooks import ( whdelete, whead, @@ -75,6 +75,7 @@ "Include", "Inject", "Factory", + "Host", "Injects", "ImproperlyConfigured", "JSON", diff --git a/esmerald/core/directives/operations/show_urls.py b/esmerald/core/directives/operations/show_urls.py index 20433811..1b5ab524 100644 --- a/esmerald/core/directives/operations/show_urls.py +++ b/esmerald/core/directives/operations/show_urls.py @@ -8,7 +8,7 @@ from rich.console import Console from rich.table import Table -from esmerald import Gateway +from esmerald import Gateway, Router from esmerald.core.directives.constants import ESMERALD_DISCOVER_APP from esmerald.core.directives.env import DirectiveEnv from esmerald.core.terminal import OutputColour, Print, Terminal @@ -19,7 +19,6 @@ from lilya.routing import BasePath from esmerald.applications import ChildEsmerald, Esmerald - from esmerald.routing.router import Router printer = Print() writer = Terminal() @@ -48,6 +47,10 @@ def get_http_verb(mapping: Any) -> str: return HttpMethod.DELETE.value elif getattr(mapping, "header", None): return HttpMethod.HEAD.value + elif getattr(mapping, "trace", None): + return HttpMethod.TRACE.value + elif getattr(mapping, "options", None): + return HttpMethod.OPTIONS.value return HttpMethod.GET.value @@ -77,7 +80,7 @@ def get_routes_table(app: Optional[Union["Esmerald", "ChildEsmerald"]], table: T """Prints the routing system""" table.add_column("Path", style=OutputColour.GREEN, vertical="middle") table.add_column("Path Parameters", style=OutputColour.BRIGHT_CYAN, vertical="middle") - table.add_column("Name", style=OutputColour.CYAN, vertical="middle") + table.add_column("Name & Path Lookup", style=OutputColour.CYAN, vertical="middle") table.add_column("Type", style=OutputColour.YELLOW, vertical="middle") table.add_column("HTTP Methods", style=OutputColour.RED, vertical="middle") @@ -106,9 +109,15 @@ def parse_routes( fn_type = "sync" # Http methods + names = route.handler.get_lookup_path() + + # We need to escape the character ':' to avoid the error + # of the table not being able to render the string + route_name = ":\u200d".join(names) + http_methods = ", ".join(sorted(route.methods)) parameters = ", ".join(sorted(route.stringify_parameters)) - table.add_row(path, parameters, route.name, fn_type, http_methods) + table.add_row(path, parameters, route_name, fn_type, http_methods) continue route_app = getattr(route, "app", None) @@ -118,7 +127,6 @@ def parse_routes( path = clean_path(prefix + route.path) # type: ignore if any(element in path for element in DOCS_ELEMENTS): continue - parse_routes(route, table, prefix=f"{path}") parse_routes(app, table) diff --git a/esmerald/openapi/openapi.py b/esmerald/openapi/openapi.py index e4ab1035..1201e190 100644 --- a/esmerald/openapi/openapi.py +++ b/esmerald/openapi/openapi.py @@ -191,7 +191,7 @@ def get_openapi_operation( if operation_id in operation_ids: message = ( - f"Duplicate Operation ID {operation_id} for function " + f"{route.handler.__name__}" + f"Duplicate Operation ID {operation_id} for function " + f"{route.handler.fn.__name__}" ) file_name = getattr(route.handler, "__globals__", {}).get("__file__") if file_name: diff --git a/esmerald/routing/base.py b/esmerald/routing/base.py index 70ba00b1..6ea4a5e6 100644 --- a/esmerald/routing/base.py +++ b/esmerald/routing/base.py @@ -674,6 +674,29 @@ def parent_levels(self) -> List[Any]: current = current.parent return list(reversed(levels)) + def get_lookup_path(self) -> List[str]: + """ + Constructs and returns the lookup path for the current object by traversing + its parent hierarchy. + + The method collects the 'name' attribute of the current object and its + ancestors, if they exist, and returns them as a list in reverse order + (from the root ancestor to the current object). + + Returns: + List[str]: A list of names representing the lookup path from the root + ancestor to the current object. + """ + + names = [] + current: Any = self + + while current: + if getattr(current, "name", None) is not None: + names.append(current.name) + current = current.parent + return list(reversed(names)) + @property def dependency_names(self) -> Set[str]: """ diff --git a/esmerald/routing/gateways.py b/esmerald/routing/gateways.py index bf897a1b..65128cfd 100644 --- a/esmerald/routing/gateways.py +++ b/esmerald/routing/gateways.py @@ -1,5 +1,4 @@ import re -import warnings from typing import TYPE_CHECKING, Any, Callable, List, Optional, Sequence, Union, cast from lilya._internal._path import clean_path @@ -282,13 +281,6 @@ def __init__( self.methods = getattr(handler, "http_methods", None) - if name and isinstance(handler, View): - warnings.warn( - "When using a class based view, the name will be automatically generated from the class name if the handlers of the Class based view are not specified. This can cause problems with reverse lookup.", - UserWarning, - stacklevel=2, - ) - if not name: if not isinstance(handler, View): name = handler.name or clean_string(handler.fn.__name__) From 03a55936300baeda90163dfdbefece928d2821ff Mon Sep 17 00:00:00 2001 From: tarsil Date: Sun, 19 Jan 2025 18:31:55 +0100 Subject: [PATCH 6/6] Fix show urls --- tests/routing/test_path_lookup.py | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/routing/test_path_lookup.py b/tests/routing/test_path_lookup.py index 1bfc8509..d7b71638 100644 --- a/tests/routing/test_path_lookup.py +++ b/tests/routing/test_path_lookup.py @@ -56,6 +56,29 @@ async def get_int(self, id: int) -> int: ], name="api", ), + Include( + "/esmerald", + routes=[ + Include( + "/api", + routes=[ + Include( + routes=[ + Gateway(handler=get_hello, name="hello"), + Gateway(handler=post_new, name="new"), + Gateway(handler=home, name="home"), + Gateway(handler=post_new_home, name="new-home"), + Gateway(handler=TestController, name="test"), + Gateway(handler=Test2Controller), + ], + name="v1", + ), + ], + name="api", + ), + ], + name="esmerald", + ), ] @@ -118,3 +141,26 @@ def test_can_reverse_lookup_all(test_client_factory): assert app.path_for("api:v1:test2controller:int") == "/api/test/int" assert reverse("api:v1:test2controller:int") == "/api/test/int" + + +def test_can_reverse_lookup_all_nested(test_client_factory): + with create_client(routes=routes, enable_openapi=False) as client: + app = client.app + + assert app.path_for("esmerald:api:v1:hello") == "/esmerald/api/hello" + assert app.path_for("esmerald:api:v1:new") == "/esmerald/api/new" + + assert reverse("esmerald:api:v1:hello") == "/esmerald/api/hello" + assert reverse("esmerald:api:v1:new") == "/esmerald/api/new" + + assert app.path_for("esmerald:api:v1:home:home") == "/esmerald/api/home" + assert reverse("esmerald:api:v1:home:home") == "/esmerald/api/home" + + assert app.path_for("esmerald:api:v1:new-home:new-home") == "/esmerald/api/new-home" + assert reverse("esmerald:api:v1:new-home:new-home") == "/esmerald/api/new-home" + + assert app.path_for("esmerald:api:v1:test:int") == "/esmerald/api/int" + assert reverse("esmerald:api:v1:test:int") == "/esmerald/api/int" + + assert app.path_for("esmerald:api:v1:test2controller:int") == "/esmerald/api/test/int" + assert reverse("esmerald:api:v1:test2controller:int") == "/esmerald/api/test/int"