diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/adapter.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/adapter.py new file mode 100644 index 0000000000..e44acdf0ec --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/adapter.py @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Adapter to create a :class:`NemoClient` from an existing :class:`NeMoPlatform`. + +This bridges the legacy ``NeMoPlatform`` SDK with the new typed client, +allowing plugins registered via ``NemoPluginSDKResources`` to use the +new endpoint/client infrastructure internally. + +Usage:: + + from nemo_platform_plugin.client.adapter import client_from_platform + + class ExampleClient(_ExampleEndpoints, NemoClient): + pass + + def make_example_client(platform: NeMoPlatform) -> ExampleClient: + return client_from_platform(platform, ExampleClient) +""" + +from __future__ import annotations + +from typing import TypeVar, overload + +from nemo_platform import AsyncNeMoPlatform, NeMoPlatform +from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient + +SyncT = TypeVar("SyncT", bound=NemoClient) +AsyncT = TypeVar("AsyncT", bound=AsyncNemoClient) + + +@overload +def client_from_platform(platform: NeMoPlatform, client_cls: type[SyncT]) -> SyncT: ... +@overload +def client_from_platform(platform: AsyncNeMoPlatform, client_cls: type[AsyncT]) -> AsyncT: ... + + +def client_from_platform( + platform: NeMoPlatform | AsyncNeMoPlatform, + client_cls: type[NemoClient] | type[AsyncNemoClient], +) -> NemoClient | AsyncNemoClient: + """Create a :class:`NemoClient` or :class:`AsyncNemoClient` from a :class:`NeMoPlatform` instance. + + The overloads ensure callers get the correct concrete return type. + """ + return client_cls( + base_url=str(platform.base_url).rstrip("/"), + workspace=platform.workspace, + http_client=platform._client, # type: ignore[arg-type] + ) diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/bound.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/bound.py new file mode 100644 index 0000000000..30de4d270e --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/bound.py @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Bound callables returned by endpoint descriptors. + +When an endpoint is accessed as an attribute on a :class:`NemoClient` or +:class:`AsyncNemoClient` instance, its ``__get__`` returns one of these +bound callables. The self-type overloads on ``__call__`` dispatch the +correct argument signature and return type based on ``RequestT`` and +``ResponseT``. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterable, Callable, Iterable +from typing import Generic, Unpack, overload + +from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient +from nemo_platform_plugin.client.response import ( + AsyncNemoBinaryResponse, + AsyncNemoStreamResponse, + NemoBinaryResponse, + NemoResponse, + NemoStreamResponse, +) +from nemo_platform_plugin.client.types import ( + BinaryContent, + BodyRequestT, + ModelT, + PathT, + PreparedRequest, + RequestT, + ResponseT, + Stream, +) + + +class SyncBoundCall(Generic[PathT, RequestT, ResponseT]): + """Sync callable returned when an :class:`Endpoint` is accessed on a :class:`NemoClient`.""" + + def __init__(self, client: NemoClient, request_fn: Callable[..., PreparedRequest[ResponseT]]) -> None: + self._client = client + self._request_fn = request_fn + + # -- Body (RequestT is BaseModel) × response variants -- + + @overload + def __call__( + self: SyncBoundCall[PathT, BodyRequestT, BinaryContent], payload: BodyRequestT, **kw: Unpack[PathT] + ) -> NemoBinaryResponse: ... + @overload + def __call__( + self: SyncBoundCall[PathT, BodyRequestT, Stream[ModelT]], payload: BodyRequestT, **kw: Unpack[PathT] + ) -> NemoStreamResponse[ModelT]: ... + @overload + def __call__( + self: SyncBoundCall[PathT, BodyRequestT, ResponseT], payload: BodyRequestT, **kw: Unpack[PathT] + ) -> NemoResponse[ResponseT]: ... + + # -- Binary (RequestT is BinaryContent) × response variants -- + + @overload + def __call__( + self: SyncBoundCall[PathT, BinaryContent, BinaryContent], + content: bytes | Iterable[bytes] | AsyncIterable[bytes], + **kw: Unpack[PathT], + ) -> NemoBinaryResponse: ... + @overload + def __call__( + self: SyncBoundCall[PathT, BinaryContent, Stream[ModelT]], + content: bytes | Iterable[bytes] | AsyncIterable[bytes], + **kw: Unpack[PathT], + ) -> NemoStreamResponse[ModelT]: ... + @overload + def __call__( + self: SyncBoundCall[PathT, BinaryContent, ResponseT], + content: bytes | Iterable[bytes] | AsyncIterable[bytes], + **kw: Unpack[PathT], + ) -> NemoResponse[ResponseT]: ... + + # -- No body (RequestT is None) × response variants -- + + @overload + def __call__(self: SyncBoundCall[PathT, None, BinaryContent], **kw: Unpack[PathT]) -> NemoBinaryResponse: ... + @overload + def __call__( + self: SyncBoundCall[PathT, None, Stream[ModelT]], **kw: Unpack[PathT] + ) -> NemoStreamResponse[ModelT]: ... + @overload + def __call__(self: SyncBoundCall[PathT, None, ResponseT], **kw: Unpack[PathT]) -> NemoResponse[ResponseT]: ... + + def __call__(self, *args: object, **kw: object) -> NemoResponse | NemoBinaryResponse | NemoStreamResponse: + return self._client.send(self._request_fn(*args, **kw)) + + +class AsyncBoundCall(Generic[PathT, RequestT, ResponseT]): + """Async callable returned when an :class:`Endpoint` is accessed on an :class:`AsyncNemoClient`.""" + + def __init__(self, client: AsyncNemoClient, request_fn: Callable[..., PreparedRequest[ResponseT]]) -> None: + self._client = client + self._request_fn = request_fn + + # -- Body (RequestT is BaseModel) × response variants -- + + @overload + async def __call__( + self: AsyncBoundCall[PathT, BodyRequestT, BinaryContent], payload: BodyRequestT, **kw: Unpack[PathT] + ) -> AsyncNemoBinaryResponse: ... + @overload + async def __call__( + self: AsyncBoundCall[PathT, BodyRequestT, Stream[ModelT]], payload: BodyRequestT, **kw: Unpack[PathT] + ) -> AsyncNemoStreamResponse[ModelT]: ... + @overload + async def __call__( + self: AsyncBoundCall[PathT, BodyRequestT, ResponseT], payload: BodyRequestT, **kw: Unpack[PathT] + ) -> NemoResponse[ResponseT]: ... + + # -- Binary (RequestT is BinaryContent) × response variants -- + + @overload + async def __call__( + self: AsyncBoundCall[PathT, BinaryContent, BinaryContent], + content: bytes | Iterable[bytes] | AsyncIterable[bytes], + **kw: Unpack[PathT], + ) -> AsyncNemoBinaryResponse: ... + @overload + async def __call__( + self: AsyncBoundCall[PathT, BinaryContent, Stream[ModelT]], + content: bytes | Iterable[bytes] | AsyncIterable[bytes], + **kw: Unpack[PathT], + ) -> AsyncNemoStreamResponse[ModelT]: ... + @overload + async def __call__( + self: AsyncBoundCall[PathT, BinaryContent, ResponseT], + content: bytes | Iterable[bytes] | AsyncIterable[bytes], + **kw: Unpack[PathT], + ) -> NemoResponse[ResponseT]: ... + + # -- No body (RequestT is None) × response variants -- + + @overload + async def __call__( + self: AsyncBoundCall[PathT, None, BinaryContent], **kw: Unpack[PathT] + ) -> AsyncNemoBinaryResponse: ... + @overload + async def __call__( + self: AsyncBoundCall[PathT, None, Stream[ModelT]], **kw: Unpack[PathT] + ) -> AsyncNemoStreamResponse[ModelT]: ... + @overload + async def __call__( + self: AsyncBoundCall[PathT, None, ResponseT], **kw: Unpack[PathT] + ) -> NemoResponse[ResponseT]: ... + + async def __call__( + self, *args: object, **kw: object + ) -> NemoResponse | AsyncNemoBinaryResponse | AsyncNemoStreamResponse: + return await self._client.send(self._request_fn(*args, **kw)) diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py new file mode 100644 index 0000000000..ce8a7e5e60 --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py @@ -0,0 +1,202 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Typed HTTP client for NeMo Platform. + +Sends :class:`~.endpoint.PreparedRequest` objects and returns typed +responses. The return type of :meth:`send` is determined by the endpoint's +``ResponseT``: + +- ``BaseModel`` → :class:`~.response.NemoResponse[T]` +- ``None`` → :class:`~.response.NemoResponse[None]` +- ``BinaryContent`` → :class:`~.response.NemoBinaryResponse` +- ``Stream[T]`` → :class:`~.response.NemoStreamResponse[T]` +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TypeVar, get_args, get_origin, overload + +import httpx +from nemo_platform_plugin.client.response import ( + AsyncNemoBinaryResponse, + AsyncNemoStreamResponse, + NemoBinaryResponse, + NemoResponse, + NemoStreamResponse, +) +from nemo_platform_plugin.client.types import BinaryContent, PreparedRequest, Stream +from pydantic import BaseModel + +ResponseT = TypeVar("ResponseT", bound=BaseModel | None) +ModelT = TypeVar("ModelT", bound=BaseModel) + +DEFAULT_TIMEOUT = 60.0 + + +def _get_stream_model_type(response_type: type) -> type[BaseModel]: + """Extract the ModelT from a Stream[ModelT] generic alias.""" + args = get_args(response_type) + if not args: + raise TypeError(f"Stream response type must be parameterized, got {response_type}") + return args[0] + + +class BaseNemoClient: + """Shared logic for sync and async NeMo clients. + + Handles URL construction and request serialisation. + Subclasses provide the actual HTTP transport (sync or async). + """ + + def __init__(self, *, base_url: str, workspace: str | None = None) -> None: + self._base_url = base_url.rstrip("/") + self._workspace = workspace + + @property + def base_url(self) -> str: + return self._base_url + + @property + def workspace(self) -> str | None: + return self._workspace + + def _resolve_path(self, request: PreparedRequest) -> str: + """Resolve path template with client defaults and explicit params. + + Client-level defaults (e.g. workspace) are merged under explicit + params — explicit always wins. Raises ``ValueError`` if any + placeholders remain unresolved. + """ + params: dict[str, str] = {} + if self._workspace: + params["workspace"] = self._workspace + params.update(request.path_params) + try: + path = request.path_template.format_map(params) + except KeyError as exc: + raise ValueError(f"Missing path parameter {exc} for {request.method} {request.path_template}") from exc + return self._base_url + path + + def _request_headers(self, request: PreparedRequest) -> dict[str, str] | None: + if request.content_type is not None: + return {"Content-Type": request.content_type} + return None + + def _is_binary(self, request: PreparedRequest) -> bool: + return request.response_type is BinaryContent + + def _is_stream(self, request: PreparedRequest) -> bool: + return get_origin(request.response_type) is Stream + + +class NemoClient(BaseNemoClient): + """Sync HTTP client for NeMo Platform APIs.""" + + def __init__( + self, + *, + base_url: str, + workspace: str | None = None, + default_headers: Mapping[str, str] | None = None, + timeout: float = DEFAULT_TIMEOUT, + http_client: httpx.Client | None = None, + ) -> None: + super().__init__(base_url=base_url, workspace=workspace) + self._http = http_client or httpx.Client( + headers=dict(default_headers) if default_headers else None, + timeout=timeout, + ) + + @overload + def send(self, request: PreparedRequest[BinaryContent]) -> NemoBinaryResponse: ... + @overload + def send(self, request: PreparedRequest[Stream[ModelT]]) -> NemoStreamResponse[ModelT]: ... + @overload + def send(self, request: PreparedRequest[None]) -> NemoResponse[None]: ... + @overload + def send(self, request: PreparedRequest[ResponseT]) -> NemoResponse[ResponseT]: ... + + def send(self, request: PreparedRequest) -> NemoResponse | NemoBinaryResponse | NemoStreamResponse: + """Send a prepared request and return a typed response. + + The return type is determined by the endpoint's ``ResponseT``. + + For binary and streaming endpoints, the caller should use the + response as a context manager to ensure the connection is closed:: + + with client.send(DownloadEndpoint.request(...)) as resp: + for chunk in resp: + f.write(chunk) + """ + url = self._resolve_path(request) + headers = self._request_headers(request) + + if self._is_binary(request): + stream_ctx = self._http.stream(request.method, url, content=request.content, headers=headers) + return NemoBinaryResponse(stream_ctx) + + if self._is_stream(request): + assert request.response_type is not None + stream_ctx = self._http.stream(request.method, url, content=request.content, headers=headers) + model_type = _get_stream_model_type(request.response_type) + return NemoStreamResponse(stream_ctx, model_type) + + raw = self._http.request(request.method, url, content=request.content, headers=headers) + body = None + if raw.is_success and request.response_type is not None: + body = request.response_type.model_validate(raw.json()) + return NemoResponse(http_response=raw, body=body) + + +class AsyncNemoClient(BaseNemoClient): + """Async HTTP client for NeMo Platform APIs. + + Async twin of :class:`NemoClient`. + """ + + def __init__( + self, + *, + base_url: str, + workspace: str | None = None, + default_headers: Mapping[str, str] | None = None, + timeout: float = DEFAULT_TIMEOUT, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(base_url=base_url, workspace=workspace) + self._http = http_client or httpx.AsyncClient( + headers=dict(default_headers) if default_headers else None, + timeout=timeout, + ) + + @overload + async def send(self, request: PreparedRequest[BinaryContent]) -> AsyncNemoBinaryResponse: ... + @overload + async def send(self, request: PreparedRequest[Stream[ModelT]]) -> AsyncNemoStreamResponse[ModelT]: ... + @overload + async def send(self, request: PreparedRequest[None]) -> NemoResponse[None]: ... + @overload + async def send(self, request: PreparedRequest[ResponseT]) -> NemoResponse[ResponseT]: ... + + async def send(self, request: PreparedRequest) -> NemoResponse | AsyncNemoBinaryResponse | AsyncNemoStreamResponse: + """Send a prepared request and return a typed response.""" + url = self._resolve_path(request) + headers = self._request_headers(request) + + if self._is_binary(request): + stream_ctx = self._http.stream(request.method, url, content=request.content, headers=headers) + return AsyncNemoBinaryResponse(stream_ctx) + + if self._is_stream(request): + assert request.response_type is not None + stream_ctx = self._http.stream(request.method, url, content=request.content, headers=headers) + model_type = _get_stream_model_type(request.response_type) + return AsyncNemoStreamResponse(stream_ctx, model_type) + + raw = await self._http.request(request.method, url, content=request.content, headers=headers) + body = None + if raw.is_success and request.response_type is not None: + body = request.response_type.model_validate(raw.json()) + return NemoResponse(http_response=raw, body=body) diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py new file mode 100644 index 0000000000..9835035551 --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py @@ -0,0 +1,158 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Typed endpoint definitions and factory functions. + +Endpoints are descriptors: when assigned as class attributes on a +:class:`NemoClient` or :class:`AsyncNemoClient` subclass, accessing them +returns a bound callable that sends the request and returns the typed response. + +Define endpoints once in a mixin, then create sync and async client classes:: + + class _ItemEndpoints: + create = post("/items", path_type=WorkspacePath, request_type=CreateItemRequest, response_type=ItemResponse) + get_item = get("/items/{name}", path_type=WorkspaceItemPath, response_type=ItemResponse) + + class ItemsClient(_ItemEndpoints, NemoClient): + pass + + class AsyncItemsClient(_ItemEndpoints, AsyncNemoClient): + pass +""" + +from __future__ import annotations + +from collections.abc import AsyncIterable, Iterable +from typing import Generic, Unpack, overload + +from nemo_platform_plugin.client.bound import AsyncBoundCall, SyncBoundCall +from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient +from nemo_platform_plugin.client.types import ( + BinaryContent, + BodyRequestT, + PathT, + PreparedRequest, + RequestT, + ResponseT, +) +from pydantic import BaseModel + + +class Endpoint(Generic[PathT, RequestT, ResponseT]): + """A typed HTTP endpoint definition. + + Links a path type ``PathT``, request type ``RequestT``, and response type + ``ResponseT`` together with the HTTP method and path template. + + Also a descriptor: when assigned as a class attribute on a + :class:`NemoClient` or :class:`AsyncNemoClient` subclass, accessing it + returns a :class:`SyncBoundCall` or :class:`AsyncBoundCall`. + """ + + def __init__( + self, path: str, method: str, request_type: type[RequestT] | None, response_type: type[ResponseT] | None + ) -> None: + self.path = path + self.method = method + self.request_type = request_type + self.response_type = response_type + + # -- request() overloads: body / binary / no-body ---------------------- + + @overload + def request( + self: Endpoint[PathT, BodyRequestT, ResponseT], payload: BodyRequestT, **path_params: Unpack[PathT] + ) -> PreparedRequest[ResponseT]: ... + @overload + def request( + self: Endpoint[PathT, BinaryContent, ResponseT], + content: bytes | Iterable[bytes] | AsyncIterable[bytes], + **path_params: Unpack[PathT], + ) -> PreparedRequest[ResponseT]: ... + @overload + def request(self: Endpoint[PathT, None, ResponseT], **path_params: Unpack[PathT]) -> PreparedRequest[ResponseT]: ... + + def request(self, *args: object, **path_params: object) -> PreparedRequest: + """Build a :class:`PreparedRequest` from payload/content and path parameters.""" + params = {k: str(v) for k, v in path_params.items()} + content: bytes | Iterable[bytes] | AsyncIterable[bytes] | None + content_type: str | None + + if self.request_type is None: + content = None + content_type = None + elif self.request_type is BinaryContent: + content = args[0] # type: ignore[assignment] + content_type = "application/octet-stream" + else: + payload = args[0] + assert isinstance(payload, BaseModel) + content = payload.model_dump_json().encode() + content_type = "application/json" + + return PreparedRequest( + path_template=self.path, + path_params=params, + method=self.method, + content=content, + content_type=content_type, + response_type=self.response_type, + ) + + # -- Descriptor: sync vs async ----------------------------------------- + + @overload + def __get__(self, obj: NemoClient, objtype: type | None = None) -> SyncBoundCall[PathT, RequestT, ResponseT]: ... + @overload + def __get__( + self, obj: AsyncNemoClient, objtype: type | None = None + ) -> AsyncBoundCall[PathT, RequestT, ResponseT]: ... + + def __get__( + self, obj: NemoClient | AsyncNemoClient | None, objtype: type | None = None + ) -> SyncBoundCall[PathT, RequestT, ResponseT] | AsyncBoundCall[PathT, RequestT, ResponseT]: + assert obj is not None + if isinstance(obj, AsyncNemoClient): + return AsyncBoundCall(obj, self.request) + return SyncBoundCall(obj, self.request) + + def __repr__(self) -> str: + req = self.request_type.__name__ if self.request_type else "None" + resp = self.response_type.__name__ if self.response_type else "None" + return f"Endpoint({self.method} {self.path}, {req} -> {resp})" + + +# --------------------------------------------------------------------------- +# Factory functions +# --------------------------------------------------------------------------- + + +def get(path: str, path_type: type[PathT], response_type: type[ResponseT]) -> Endpoint[PathT, None, ResponseT]: + """Define a GET endpoint (no request body).""" + return Endpoint(path, "GET", None, response_type) + + +def post( + path: str, path_type: type[PathT], request_type: type[RequestT], response_type: type[ResponseT] +) -> Endpoint[PathT, RequestT, ResponseT]: + """Define a POST endpoint.""" + return Endpoint(path, "POST", request_type, response_type) + + +def put( + path: str, path_type: type[PathT], request_type: type[RequestT], response_type: type[ResponseT] +) -> Endpoint[PathT, RequestT, ResponseT]: + """Define a PUT endpoint.""" + return Endpoint(path, "PUT", request_type, response_type) + + +def patch( + path: str, path_type: type[PathT], request_type: type[RequestT], response_type: type[ResponseT] +) -> Endpoint[PathT, RequestT, ResponseT]: + """Define a PATCH endpoint.""" + return Endpoint(path, "PATCH", request_type, response_type) + + +def delete(path: str, path_type: type[PathT]) -> Endpoint[PathT, None, None]: + """Define a DELETE endpoint (no request body, no response body).""" + return Endpoint(path, "DELETE", None, None) diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/response.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/response.py new file mode 100644 index 0000000000..25d67ec8c1 --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/response.py @@ -0,0 +1,215 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""HTTP response wrappers for JSON, binary, and streaming endpoints.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from contextlib import AbstractAsyncContextManager, AbstractContextManager +from dataclasses import dataclass +from types import TracebackType +from typing import Generic, TypeVar + +import httpx +from nemo_platform_plugin.client.types import BinaryContent, Stream +from pydantic import BaseModel + +ResponseT = TypeVar("ResponseT", bound=BaseModel | BinaryContent | Stream | None) +ModelT = TypeVar("ModelT", bound=BaseModel) + + +@dataclass(frozen=True, slots=True) +class NemoResponse(Generic[ResponseT]): + """Typed HTTP response for JSON endpoints. + + Example:: + + resp = client.send(GetUserEndpoint.request(workspace="default")) + resp.body # UserResponse + resp.http_response # full httpx.Response + + user = resp.data() # raises on non-2xx, otherwise returns body + """ + + http_response: httpx.Response + body: ResponseT + + def data(self) -> ResponseT: + """Return the body if the status is 2xx, otherwise raise.""" + if not (200 <= self.http_response.status_code < 300): + raise NemoHTTPError(self.http_response, self.body) + return self.body + + +# --------------------------------------------------------------------------- +# Sync streaming responses +# --------------------------------------------------------------------------- + + +class NemoBinaryResponse: + """Sync response for binary download endpoints. + + Use as a context manager:: + + with client.send(DownloadEndpoint.request(...)) as resp: + data = resp.read() # all bytes at once + # or: for chunk in resp # iterate chunks + """ + + def __init__(self, stream_ctx: AbstractContextManager[httpx.Response]) -> None: + self._stream_ctx = stream_ctx + self._response: httpx.Response | None = None + + @property + def http_response(self) -> httpx.Response: + assert self._response is not None, "Must enter context manager before accessing response" + return self._response + + def read(self) -> bytes: + """Read and return the entire response body as bytes.""" + return self.http_response.read() + + def __iter__(self) -> Iterator[bytes]: + return self.http_response.iter_bytes() + + def __enter__(self) -> NemoBinaryResponse: + self._response = self._stream_ctx.__enter__() + self._response.raise_for_status() + return self + + def __exit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> None: + self._stream_ctx.__exit__(exc_type, exc_val, exc_tb) + + +class NemoStreamResponse(Generic[ModelT]): + """Sync response for SSE/NDJSON streaming endpoints. + + Use as a context manager:: + + with client.send(ChatEndpoint.request(...)) as resp: + for chunk in resp: + print(chunk.text) + """ + + def __init__(self, stream_ctx: AbstractContextManager[httpx.Response], model_type: type[ModelT]) -> None: + self._stream_ctx = stream_ctx + self._model_type = model_type + self._response: httpx.Response | None = None + + @property + def http_response(self) -> httpx.Response: + assert self._response is not None, "Must enter context manager before accessing response" + return self._response + + def __iter__(self) -> Iterator[ModelT]: + for line in self.http_response.iter_lines(): + line = line.strip() + if line: + yield self._model_type.model_validate_json(line) + + def __enter__(self) -> NemoStreamResponse[ModelT]: + self._response = self._stream_ctx.__enter__() + self._response.raise_for_status() + return self + + def __exit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> None: + self._stream_ctx.__exit__(exc_type, exc_val, exc_tb) + + +# --------------------------------------------------------------------------- +# Async streaming responses +# --------------------------------------------------------------------------- + + +class AsyncNemoBinaryResponse: + """Async response for binary download endpoints. + + Use as an async context manager:: + + async with client.send(DownloadEndpoint.request(...)) as resp: + data = await resp.read() # all bytes at once + # or: async for chunk in resp # iterate chunks + """ + + def __init__(self, stream_ctx: AbstractAsyncContextManager[httpx.Response]) -> None: + self._stream_ctx = stream_ctx + self._response: httpx.Response | None = None + + @property + def http_response(self) -> httpx.Response: + assert self._response is not None, "Must enter async context manager before accessing response" + return self._response + + async def read(self) -> bytes: + """Read and return the entire response body as bytes.""" + return await self.http_response.aread() + + async def __aiter__(self) -> AsyncIterator[bytes]: + async for chunk in self.http_response.aiter_bytes(): + yield chunk + + async def __aenter__(self) -> AsyncNemoBinaryResponse: + self._response = await self._stream_ctx.__aenter__() + self._response.raise_for_status() + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> None: + await self._stream_ctx.__aexit__(exc_type, exc_val, exc_tb) + + +class AsyncNemoStreamResponse(Generic[ModelT]): + """Async response for SSE/NDJSON streaming endpoints. + + Use as an async context manager:: + + async with client.send(ChatEndpoint.request(...)) as resp: + async for chunk in resp: + print(chunk.text) + """ + + def __init__(self, stream_ctx: AbstractAsyncContextManager[httpx.Response], model_type: type[ModelT]) -> None: + self._stream_ctx = stream_ctx + self._model_type = model_type + self._response: httpx.Response | None = None + + @property + def http_response(self) -> httpx.Response: + assert self._response is not None, "Must enter async context manager before accessing response" + return self._response + + async def __aiter__(self) -> AsyncIterator[ModelT]: + async for line in self.http_response.aiter_lines(): + line = line.strip() + if line: + yield self._model_type.model_validate_json(line) + + async def __aenter__(self) -> AsyncNemoStreamResponse[ModelT]: + self._response = await self._stream_ctx.__aenter__() + self._response.raise_for_status() + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> None: + await self._stream_ctx.__aexit__(exc_type, exc_val, exc_tb) + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class NemoHTTPError(Exception): + """Raised by :meth:`NemoResponse.data` on non-2xx responses.""" + + def __init__(self, http_response: httpx.Response, body: object) -> None: + self.http_response = http_response + self.body = body + super().__init__(f"HTTP {http_response.status_code}") diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/types.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/types.py new file mode 100644 index 0000000000..843be75c95 --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/types.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared types for the NeMo client infrastructure. + +This module contains marker types, TypeVars, and data classes that are +used across the client package. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterable, Iterable +from dataclasses import dataclass +from typing import Generic, NotRequired, TypedDict, TypeVar + +from pydantic import BaseModel + +ModelT = TypeVar("ModelT", bound=BaseModel) +BodyRequestT = TypeVar("BodyRequestT", bound=BaseModel) + + +class BinaryContent: + """Marker type: endpoint sends or receives raw bytes. + + Use as ``request_type`` for binary uploads or ``response_type`` for + binary downloads:: + + UploadEndpoint = put("/files/{path}", path_type=FilePath, request_type=BinaryContent, response_type=FileResponse) + DownloadEndpoint = get("/files/{path}", path_type=FilePath, response_type=BinaryContent) + """ + + +class Stream(Generic[ModelT]): + """Marker type: endpoint returns a stream of ``ModelT`` objects (SSE/NDJSON). + + Used as ``response_type`` in endpoint definitions:: + + ChatEndpoint = post("/chat/{workspace}", path_type=WorkspacePath, request_type=ChatRequest, response_type=Stream[ChatChunk]) + """ + + +class PathParams(TypedDict): + """Base class for all path parameter types. + + All path TypedDicts must inherit from this so that ``PathT`` is + properly constrained. + """ + + +class WorkspaceParams(PathParams): + """Path params with an optional workspace (filled by client default).""" + + workspace: NotRequired[str] + + +PathT = TypeVar("PathT", bound=PathParams) +RequestT = TypeVar("RequestT", bound=BaseModel | BinaryContent | None) +ResponseT = TypeVar("ResponseT", bound=BaseModel | BinaryContent | Stream | None) + + +@dataclass(frozen=True, slots=True) +class PreparedRequest(Generic[ResponseT]): + """A request ready to be sent — carries the endpoint metadata and payload. + + Path interpolation is deferred to the client's ``send()`` method, which + merges client-level defaults (e.g. workspace) with the explicit path + params before formatting. + """ + + path_template: str + path_params: dict[str, str] + method: str + content: bytes | Iterable[bytes] | AsyncIterable[bytes] | None + content_type: str | None + response_type: type[ResponseT] | None diff --git a/packages/nemo_platform_plugin/tests/client/test_client.py b/packages/nemo_platform_plugin/tests/client/test_client.py new file mode 100644 index 0000000000..704badfde2 --- /dev/null +++ b/packages/nemo_platform_plugin/tests/client/test_client.py @@ -0,0 +1,216 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import NotRequired +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient +from nemo_platform_plugin.client.endpoint import delete, get, post +from nemo_platform_plugin.client.response import NemoResponse +from nemo_platform_plugin.client.types import PathParams +from pydantic import BaseModel + +BASE = "http://test:8000" + + +class ItemRequest(BaseModel): + name: str + + +class ItemResponse(BaseModel): + id: int + name: str + + +class EmptyPath(PathParams): + pass + + +class NamePath(PathParams): + name: str + + +class WorkspacePath(PathParams): + workspace: NotRequired[str] + + +CREATE_ITEM = post("/apis/test/v2/items", path_type=EmptyPath, request_type=ItemRequest, response_type=ItemResponse) +GET_ITEM = get("/apis/test/v2/items/{name}", path_type=NamePath, response_type=ItemResponse) +DELETE_ITEM = delete("/apis/test/v2/items/{name}", path_type=NamePath) +GET_WS_ITEM = get("/apis/test/v2/workspaces/{workspace}/items", path_type=WorkspacePath, response_type=ItemResponse) + + +class StubClient(NemoClient): + pass + + +class AsyncStubClient(AsyncNemoClient): + pass + + +# --------------------------------------------------------------------------- +# Sync client +# --------------------------------------------------------------------------- + + +def test_send_post() -> None: + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = httpx.Response( + 201, + request=httpx.Request("POST", f"{BASE}/apis/test/v2/items"), + json={"id": 1, "name": "alice"}, + ) + + client = StubClient(base_url=BASE, http_client=mock_http) + resp = client.send(CREATE_ITEM.request(ItemRequest(name="alice"))) + + assert isinstance(resp, NemoResponse) + assert resp.http_response.status_code == 201 + assert resp.body.id == 1 + assert resp.body.name == "alice" + + mock_http.request.assert_called_once_with( + "POST", + f"{BASE}/apis/test/v2/items", + content=ItemRequest(name="alice").model_dump_json().encode(), + headers={"Content-Type": "application/json"}, + ) + + +def test_send_get_with_path_params() -> None: + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = httpx.Response( + 200, + request=httpx.Request("GET", f"{BASE}/apis/test/v2/items/alice"), + json={"id": 1, "name": "alice"}, + ) + + client = StubClient(base_url=BASE, http_client=mock_http) + resp = client.send(GET_ITEM.request(name="alice")) + + assert resp.body.name == "alice" + mock_http.request.assert_called_once_with( + "GET", + f"{BASE}/apis/test/v2/items/alice", + content=None, + headers=None, + ) + + +def test_send_delete() -> None: + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = httpx.Response( + 204, + request=httpx.Request("DELETE", f"{BASE}/apis/test/v2/items/alice"), + content=b"", + ) + + client = StubClient(base_url=BASE, http_client=mock_http) + resp = client.send(DELETE_ITEM.request(name="alice")) + + assert resp.http_response.status_code == 204 + assert resp.body is None + + +def test_data_success() -> None: + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = httpx.Response( + 200, + request=httpx.Request("GET", f"{BASE}/apis/test/v2/items/alice"), + json={"id": 1, "name": "alice"}, + ) + + client = StubClient(base_url=BASE, http_client=mock_http) + item = client.send(GET_ITEM.request(name="alice")).data() + + assert item.name == "alice" + + +def test_base_url_trailing_slash_stripped() -> None: + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = httpx.Response( + 200, + request=httpx.Request("GET", f"{BASE}/apis/test/v2/items/x"), + json={"id": 1, "name": "x"}, + ) + + client = StubClient(base_url=BASE + "/", http_client=mock_http) + client.send(GET_ITEM.request(name="x")) + + url_called = mock_http.request.call_args[0][1] + assert not url_called.startswith(BASE + "//") + + +# --------------------------------------------------------------------------- +# Async client +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_send_post() -> None: + mock_http = AsyncMock(spec=httpx.AsyncClient) + mock_http.request.return_value = httpx.Response( + 201, + request=httpx.Request("POST", f"{BASE}/apis/test/v2/items"), + json={"id": 1, "name": "alice"}, + ) + + client = AsyncStubClient(base_url=BASE, http_client=mock_http) + resp = await client.send(CREATE_ITEM.request(ItemRequest(name="alice"))) + + assert resp.http_response.status_code == 201 + assert resp.body.name == "alice" + + +@pytest.mark.asyncio +async def test_async_send_get() -> None: + mock_http = AsyncMock(spec=httpx.AsyncClient) + mock_http.request.return_value = httpx.Response( + 200, + request=httpx.Request("GET", f"{BASE}/apis/test/v2/items/alice"), + json={"id": 1, "name": "alice"}, + ) + + client = AsyncStubClient(base_url=BASE, http_client=mock_http) + resp = await client.send(GET_ITEM.request(name="alice")) + + assert resp.body.name == "alice" + + +# --------------------------------------------------------------------------- +# Workspace default +# --------------------------------------------------------------------------- + + +def test_workspace_default_fills_path() -> None: + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = httpx.Response( + 200, + request=httpx.Request("GET", f"{BASE}/apis/test/v2/workspaces/default/items"), + json={"id": 1, "name": "alice"}, + ) + + client = StubClient(base_url=BASE, workspace="default", http_client=mock_http) + client.send(GET_WS_ITEM.request()) + + url_called = mock_http.request.call_args[0][1] + assert "/workspaces/default/" in url_called + + +def test_workspace_explicit_overrides_default() -> None: + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = httpx.Response( + 200, + request=httpx.Request("GET", f"{BASE}/apis/test/v2/workspaces/other/items"), + json={"id": 1, "name": "alice"}, + ) + + client = StubClient(base_url=BASE, workspace="default", http_client=mock_http) + client.send(GET_WS_ITEM.request(workspace="other")) + + url_called = mock_http.request.call_args[0][1] + assert "/workspaces/other/" in url_called diff --git a/packages/nemo_platform_plugin/tests/client/test_endpoint.py b/packages/nemo_platform_plugin/tests/client/test_endpoint.py new file mode 100644 index 0000000000..72022a849a --- /dev/null +++ b/packages/nemo_platform_plugin/tests/client/test_endpoint.py @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from nemo_platform_plugin.client.endpoint import delete, get, patch, post +from nemo_platform_plugin.client.types import PathParams, PreparedRequest +from pydantic import BaseModel + + +class FakeRequest(BaseModel): + name: str + + +class FakeResponse(BaseModel): + id: int + name: str + + +class WorkspacePath(PathParams): + workspace: str + + +class WorkspaceItemPath(PathParams): + workspace: str + name: str + + +class IdPath(PathParams): + id: str + + +def test_post_endpoint_produces_prepared_request() -> None: + ep = post( + "/v2/workspaces/{workspace}/items", + path_type=WorkspacePath, + request_type=FakeRequest, + response_type=FakeResponse, + ) + payload = FakeRequest(name="alice") + prepared = ep.request(payload, workspace="default") + + assert isinstance(prepared, PreparedRequest) + assert prepared.path_template == "/v2/workspaces/{workspace}/items" + assert prepared.path_params == {"workspace": "default"} + assert prepared.method == "POST" + assert prepared.content == payload.model_dump_json().encode() + assert prepared.content_type == "application/json" + assert prepared.response_type is FakeResponse + + +def test_get_endpoint_no_body() -> None: + ep = get("/v2/workspaces/{workspace}/items/{name}", path_type=WorkspaceItemPath, response_type=FakeResponse) + prepared = ep.request(workspace="default", name="item-1") + + assert prepared.path_template == "/v2/workspaces/{workspace}/items/{name}" + assert prepared.path_params == {"workspace": "default", "name": "item-1"} + assert prepared.method == "GET" + assert prepared.content is None + assert prepared.content_type is None + assert prepared.response_type is FakeResponse + + +def test_delete_endpoint() -> None: + ep = delete("/v2/workspaces/{workspace}/items/{name}", path_type=WorkspaceItemPath) + prepared = ep.request(workspace="default", name="item-1") + + assert prepared.path_params == {"workspace": "default", "name": "item-1"} + assert prepared.method == "DELETE" + assert prepared.content is None + + +def test_patch_endpoint() -> None: + ep = patch("/items/{id}", path_type=IdPath, request_type=FakeRequest, response_type=FakeResponse) + payload = FakeRequest(name="updated") + prepared = ep.request(payload, id="42") + + assert prepared.path_params == {"id": "42"} + assert prepared.method == "PATCH" + assert prepared.content == payload.model_dump_json().encode() + + +def test_endpoint_repr() -> None: + ep = post("/items/{id}", path_type=IdPath, request_type=FakeRequest, response_type=FakeResponse) + r = repr(ep) + assert "/items" in r + assert "FakeRequest" in r + assert "FakeResponse" in r diff --git a/plugins/example-plugin/src/nemo_example_plugin/schema.py b/plugins/example-plugin/src/nemo_example_plugin/schema.py index c3d5777cce..a8f5bf6352 100644 --- a/plugins/example-plugin/src/nemo_example_plugin/schema.py +++ b/plugins/example-plugin/src/nemo_example_plugin/schema.py @@ -1,55 +1,17 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Example API schema definitions — request bodies and filters. +"""Server-side schema definitions — filters and other FastAPI-specific models. -This module contains only API-layer Pydantic models. Entity definitions -(classes stored in the entity store) live in -:mod:`nemo_example_plugin.entities` — keep the two concerns separate. - -Naming conventions: -- ``CreateXRequest`` / ``UpdateXRequest`` — plain :class:`~pydantic.BaseModel` - for request bodies. No shared base class is used; all fields are explicit. -- ``XFilter`` — extends :class:`~nemo_platform_plugin.schema.NemoFilter` to inherit - ``extra="forbid"``, which turns filter field typos into 422 errors instead - of silently returning unfiltered results. - -Entity objects (subclasses of :class:`~nemo_platform_plugin.entity.NemoEntity`) are -returned directly from route handlers as the API response — no separate -response model is needed. Use ``NemoListResponse[ExampleItem]`` for list -endpoints. +Request/response payloads live in :mod:`nemo_example_plugin.types.payloads`. +This module keeps only server-side concerns (filters, etc.) that are tied +to the FastAPI routing layer. """ from __future__ import annotations -from nemo_example_plugin.entities import ExampleItem -from nemo_platform_plugin.schema import NemoFilter, NemoListResponse -from pydantic import BaseModel, Field - -# --------------------------------------------------------------------------- -# Request bodies — plain Pydantic BaseModel, no shared base -# --------------------------------------------------------------------------- - - -class CreateExampleItemRequest(BaseModel): - """Request body for ``POST /v2/workspaces/{workspace}/items``.""" - - name: str = Field(description="Unique item name within the workspace.") - title: str = Field(description="Short title for the item.") - body: str = Field(default="", description="Long-form body text.") - tags: list[str] = Field(default_factory=list, description="Searchable tags.") - - -class UpdateExampleItemRequest(BaseModel): - """Request body for ``PATCH /v2/workspaces/{workspace}/items/{name}``. - - All fields are optional — omitted fields are left unchanged. - """ - - title: str | None = Field(default=None, description="Updated title.") - body: str | None = Field(default=None, description="Updated body text.") - tags: list[str] | None = Field(default=None, description="Replacement tag list.") - +from nemo_platform_plugin.schema import NemoFilter +from pydantic import Field # --------------------------------------------------------------------------- # Filter — extends NemoFilter so extra fields are rejected (extra="forbid") @@ -77,12 +39,3 @@ class ExampleItemFilter(NemoFilter): default=None, description="Filter to items that have this tag.", ) - - -# --------------------------------------------------------------------------- -# List response — entity used directly as item type -# --------------------------------------------------------------------------- - -#: Paginated list of :class:`~nemo_example_plugin.entities.ExampleItem` objects. -#: Use as ``response_model=ExampleItemPage`` on list endpoints. -ExampleItemPage = NemoListResponse[ExampleItem] diff --git a/plugins/example-plugin/src/nemo_example_plugin/sdk.py b/plugins/example-plugin/src/nemo_example_plugin/sdk.py index 8bc108bcee..ad18dc1a77 100644 --- a/plugins/example-plugin/src/nemo_example_plugin/sdk.py +++ b/plugins/example-plugin/src/nemo_example_plugin/sdk.py @@ -1,185 +1,71 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""SDK resources for the example plugin.""" +"""SDK resources for the example plugin. -from __future__ import annotations +Endpoints are defined once in a mixin, then sync/async client classes +inherit the mixin + the appropriate client base. +The descriptor protocol on each endpoint returns the right bound callable. +""" -from typing import Any +from __future__ import annotations +from nemo_example_plugin.types.endpoints import ( + CountEndpoint, + CreateItemEndpoint, + DeleteItemEndpoint, + DownloadBlobEndpoint, + GetItemEndpoint, + HelloEndpoint, + ListItemsEndpoint, + UpdateItemEndpoint, + UploadBlobEndpoint, +) from nemo_platform import AsyncNeMoPlatform, NeMoPlatform +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient from nemo_platform_plugin.sdk import NemoPluginSDKResources +# -- Endpoint assignments (defined once) ----------------------------------- + + +class _ExampleEndpoints: + hello = HelloEndpoint + create_item = CreateItemEndpoint + list_items = ListItemsEndpoint + get_item = GetItemEndpoint + update_item = UpdateItemEndpoint + delete_item = DeleteItemEndpoint + count = CountEndpoint + upload_blob = UploadBlobEndpoint + download_blob = DownloadBlobEndpoint + + +# -- Client classes: mixin + client base ----------------------------------- + + +class ExampleClient(_ExampleEndpoints, NemoClient): + """Sync client for the example plugin API.""" + + +class AsyncExampleClient(_ExampleEndpoints, AsyncNemoClient): + """Async client for the example plugin API.""" + + +# --------------------------------------------------------------------------- +# Plugin SDK registration — bridges NeMoPlatform to the new typed client +# --------------------------------------------------------------------------- + + +def _make_sync_resource(platform: NeMoPlatform) -> ExampleClient: + return client_from_platform(platform, ExampleClient) + -class ExampleResource: - """Sync SDK namespace mounted as ``client.example``.""" - - def __init__(self, platform: NeMoPlatform) -> None: - self._platform = platform - self._http_client = platform._client - - # ------------------------------------------------------------------ - # Hello - # ------------------------------------------------------------------ - - def hello(self, name: str) -> str: - response = self._http_client.get(self._example_url(f"/hello/{name}")) - response.raise_for_status() - payload: dict[str, Any] = response.json() - return str(payload["message"]) - - # ------------------------------------------------------------------ - # Middleware configs CRUD - # ------------------------------------------------------------------ - - def create_middleware_config( - self, - workspace: str, - name: str, - blocked_keywords: list[str] | None = None, - block_message: str | None = None, - ) -> dict[str, Any]: - """Create an :class:`~nemo_example_plugin.middleware_config.ExampleMiddlewareConfig`.""" - body: dict[str, Any] = {"name": name} - if blocked_keywords is not None: - body["blocked_keywords"] = blocked_keywords - if block_message is not None: - body["block_message"] = block_message - response = self._http_client.post(self._workspace_url(workspace, "/middleware-configs"), json=body) - response.raise_for_status() - return response.json() - - def list_middleware_configs(self, workspace: str) -> list[dict[str, Any]]: - """List all middleware configs in *workspace*.""" - response = self._http_client.get(self._workspace_url(workspace, "/middleware-configs")) - response.raise_for_status() - return response.json() - - def get_middleware_config(self, workspace: str, name: str) -> dict[str, Any]: - """Get a single middleware config by *name*.""" - response = self._http_client.get(self._workspace_url(workspace, f"/middleware-configs/{name}")) - response.raise_for_status() - return response.json() - - def update_middleware_config( - self, - workspace: str, - name: str, - blocked_keywords: list[str] | None = None, - block_message: str | None = None, - ) -> dict[str, Any]: - """Partially update a middleware config.""" - body: dict[str, Any] = {} - if blocked_keywords is not None: - body["blocked_keywords"] = blocked_keywords - if block_message is not None: - body["block_message"] = block_message - response = self._http_client.patch(self._workspace_url(workspace, f"/middleware-configs/{name}"), json=body) - response.raise_for_status() - return response.json() - - def delete_middleware_config(self, workspace: str, name: str) -> None: - """Delete a middleware config.""" - response = self._http_client.delete(self._workspace_url(workspace, f"/middleware-configs/{name}")) - response.raise_for_status() - - # ------------------------------------------------------------------ - # URL helpers - # ------------------------------------------------------------------ - - def _example_url(self, path: str) -> str: - return str(self._platform.base_url).rstrip("/") + "/apis/example" + path - - def _workspace_url(self, workspace: str, path: str) -> str: - return self._example_url(f"/v2/workspaces/{workspace}{path}") - - -class AsyncExampleResource: - """Async SDK namespace mounted as ``client.example``.""" - - def __init__(self, platform: AsyncNeMoPlatform) -> None: - self._platform = platform - self._http_client = platform._client - - # ------------------------------------------------------------------ - # Hello - # ------------------------------------------------------------------ - - async def hello(self, name: str) -> str: - response = await self._http_client.get(self._example_url(f"/hello/{name}")) - response.raise_for_status() - payload: dict[str, Any] = response.json() - return str(payload["message"]) - - # ------------------------------------------------------------------ - # Middleware configs CRUD - # ------------------------------------------------------------------ - - async def create_middleware_config( - self, - workspace: str, - name: str, - blocked_keywords: list[str] | None = None, - block_message: str | None = None, - ) -> dict[str, Any]: - """Create an :class:`~nemo_example_plugin.middleware_config.ExampleMiddlewareConfig`.""" - body: dict[str, Any] = {"name": name} - if blocked_keywords is not None: - body["blocked_keywords"] = blocked_keywords - if block_message is not None: - body["block_message"] = block_message - response = await self._http_client.post(self._workspace_url(workspace, "/middleware-configs"), json=body) - response.raise_for_status() - return response.json() - - async def list_middleware_configs(self, workspace: str) -> list[dict[str, Any]]: - """List all middleware configs in *workspace*.""" - response = await self._http_client.get(self._workspace_url(workspace, "/middleware-configs")) - response.raise_for_status() - return response.json() - - async def get_middleware_config(self, workspace: str, name: str) -> dict[str, Any]: - """Get a single middleware config by *name*.""" - response = await self._http_client.get(self._workspace_url(workspace, f"/middleware-configs/{name}")) - response.raise_for_status() - return response.json() - - async def update_middleware_config( - self, - workspace: str, - name: str, - blocked_keywords: list[str] | None = None, - block_message: str | None = None, - ) -> dict[str, Any]: - """Partially update a middleware config.""" - body: dict[str, Any] = {} - if blocked_keywords is not None: - body["blocked_keywords"] = blocked_keywords - if block_message is not None: - body["block_message"] = block_message - response = await self._http_client.patch( - self._workspace_url(workspace, f"/middleware-configs/{name}"), json=body - ) - response.raise_for_status() - return response.json() - - async def delete_middleware_config(self, workspace: str, name: str) -> None: - """Delete a middleware config.""" - response = await self._http_client.delete(self._workspace_url(workspace, f"/middleware-configs/{name}")) - response.raise_for_status() - - # ------------------------------------------------------------------ - # URL helpers - # ------------------------------------------------------------------ - - def _example_url(self, path: str) -> str: - return str(self._platform.base_url).rstrip("/") + "/apis/example" + path - - def _workspace_url(self, workspace: str, path: str) -> str: - return self._example_url(f"/v2/workspaces/{workspace}{path}") +def _make_async_resource(platform: AsyncNeMoPlatform) -> AsyncExampleClient: + return client_from_platform(platform, AsyncExampleClient) example_sdk_resources = NemoPluginSDKResources( - sync_resource=ExampleResource, - async_resource=AsyncExampleResource, + sync_resource=_make_sync_resource, + async_resource=_make_async_resource, ) diff --git a/plugins/example-plugin/src/nemo_example_plugin/service.py b/plugins/example-plugin/src/nemo_example_plugin/service.py index 4d7b034f98..f18225d8e0 100644 --- a/plugins/example-plugin/src/nemo_example_plugin/service.py +++ b/plugins/example-plugin/src/nemo_example_plugin/service.py @@ -29,16 +29,19 @@ import logging from typing import ClassVar -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import Response from nemo_example_plugin.config import ExampleConfig from nemo_example_plugin.core import say_hello from nemo_example_plugin.entities import ExampleItem from nemo_example_plugin.functions.greet import CountFunction, GreetFunction from nemo_example_plugin.middleware_service import build_middleware_config_router -from nemo_example_plugin.schema import ( +from nemo_example_plugin.schema import ExampleItemFilter +from nemo_example_plugin.types.payloads import ( + BlobUploadResponse, CreateExampleItemRequest, - ExampleItemFilter, ExampleItemPage, + HelloResponse, UpdateExampleItemRequest, ) from nemo_platform_plugin.api.filters import make_filter_obj_dep @@ -50,20 +53,10 @@ from nemo_platform_plugin.functions.routes import add_function_routes from nemo_platform_plugin.schema import PaginationData from nemo_platform_plugin.service import NemoService, RouterSpec -from pydantic import BaseModel logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# Response model for the minimal hello endpoint -# --------------------------------------------------------------------------- - - -class HelloResponse(BaseModel): - message: str - - # --------------------------------------------------------------------------- # Service # --------------------------------------------------------------------------- @@ -118,6 +111,11 @@ def get_routers(self) -> list[RouterSpec]: description="Streaming NDJSON NemoFunction example.", prefix="/v2/workspaces/{workspace}", ), + RouterSpec( + _build_binary_router(), + tag="Example Binary", + description="Binary upload/download endpoints for testing.", + ), ] @@ -151,6 +149,35 @@ async def hello(name: str) -> HelloResponse: return router +# --------------------------------------------------------------------------- +# Binary upload/download router +# --------------------------------------------------------------------------- + + +def _build_binary_router() -> APIRouter: + """Simple binary endpoints for testing the typed client's binary support.""" + router = APIRouter() + + # In-memory store for uploaded bytes (keyed by name) + _store: dict[str, bytes] = {} + + @router.put("/blob/{name}", status_code=200, response_model=BlobUploadResponse) + async def upload_blob(name: str, request: Request) -> BlobUploadResponse: + """Accept raw binary and store it. Returns byte count.""" + data = await request.body() + _store[name] = data + return BlobUploadResponse(name=name, size=len(data)) + + @router.get("/blob/{name}", response_class=Response) + async def download_blob(name: str) -> Response: + """Return stored binary content.""" + if name not in _store: + raise HTTPException(status_code=404, detail=f"Blob '{name}' not found") + return Response(content=_store[name], media_type="application/octet-stream") + + return router + + # --------------------------------------------------------------------------- # Helper: build a request-scoped EntityClient from the platform SDK # --------------------------------------------------------------------------- diff --git a/plugins/example-plugin/src/nemo_example_plugin/types/endpoints.py b/plugins/example-plugin/src/nemo_example_plugin/types/endpoints.py new file mode 100644 index 0000000000..e51afe0789 --- /dev/null +++ b/plugins/example-plugin/src/nemo_example_plugin/types/endpoints.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Typed endpoint definitions for the example plugin. + +These are the single source of truth for the HTTP contract. Both the SDK +client and (eventually) server route registration can be derived from them. + +Request and response models are plain Pydantic — they have no knowledge of +the HTTP layer. +""" + +from __future__ import annotations + +from nemo_example_plugin.entities import ExampleItem +from nemo_example_plugin.types.payloads import ( + BlobUploadResponse, + CountRequest, + CreateExampleItemRequest, + ExampleItemPage, + HelloResponse, + Tick, + UpdateExampleItemRequest, +) +from nemo_platform_plugin.client.endpoint import delete, get, patch, post, put +from nemo_platform_plugin.client.types import BinaryContent, PathParams, Stream, WorkspaceParams + +# -- Path parameter types -------------------------------------------------- + + +class NamePath(PathParams): + name: str + + +class WorkspaceItemPath(WorkspaceParams): + name: str + + +# -- Hello ----------------------------------------------------------------- + +HelloEndpoint = get("/apis/example/hello/{name}", path_type=NamePath, response_type=HelloResponse) + +# -- Items CRUD ------------------------------------------------------------ + +CreateItemEndpoint = post( + "/apis/example/v2/workspaces/{workspace}/items", + path_type=WorkspaceParams, + request_type=CreateExampleItemRequest, + response_type=ExampleItem, +) + +ListItemsEndpoint = get( + "/apis/example/v2/workspaces/{workspace}/items", path_type=WorkspaceParams, response_type=ExampleItemPage +) + +GetItemEndpoint = get( + "/apis/example/v2/workspaces/{workspace}/items/{name}", path_type=WorkspaceItemPath, response_type=ExampleItem +) + +UpdateItemEndpoint = patch( + "/apis/example/v2/workspaces/{workspace}/items/{name}", + path_type=WorkspaceItemPath, + request_type=UpdateExampleItemRequest, + response_type=ExampleItem, +) + +DeleteItemEndpoint = delete("/apis/example/v2/workspaces/{workspace}/items/{name}", path_type=WorkspaceItemPath) + +# -- Functions ------------------------------------------------------------- + +CountEndpoint = post( + "/apis/example/v2/workspaces/{workspace}/count", + path_type=WorkspaceParams, + request_type=CountRequest, + response_type=Stream[Tick], +) + +# -- Binary ---------------------------------------------------------------- + +UploadBlobEndpoint = put( + "/apis/example/blob/{name}", + path_type=NamePath, + request_type=BinaryContent, + response_type=BlobUploadResponse, +) + +DownloadBlobEndpoint = get( + "/apis/example/blob/{name}", + path_type=NamePath, + response_type=BinaryContent, +) diff --git a/plugins/example-plugin/src/nemo_example_plugin/types/payloads.py b/plugins/example-plugin/src/nemo_example_plugin/types/payloads.py new file mode 100644 index 0000000000..95bdff1612 --- /dev/null +++ b/plugins/example-plugin/src/nemo_example_plugin/types/payloads.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Request and response payloads for the example plugin API. + +These are plain Pydantic models with no knowledge of the HTTP layer. They +can be used anywhere in the codebase — domain logic, tests, serialization — +without pulling in transport concerns. +""" + +from __future__ import annotations + +from nemo_example_plugin.entities import ExampleItem +from nemo_platform_plugin.schema import NemoListResponse +from pydantic import BaseModel, Field + +# --------------------------------------------------------------------------- +# Response models +# --------------------------------------------------------------------------- + + +class HelloResponse(BaseModel): + message: str + + +#: Paginated list of :class:`~nemo_example_plugin.entities.ExampleItem` objects. +ExampleItemPage = NemoListResponse[ExampleItem] + + +# --------------------------------------------------------------------------- +# Request bodies — plain Pydantic BaseModel, no shared base +# --------------------------------------------------------------------------- + + +class CreateExampleItemRequest(BaseModel): + """Request body for ``POST /v2/workspaces/{workspace}/items``.""" + + name: str = Field(description="Unique item name within the workspace.") + title: str = Field(description="Short title for the item.") + body: str = Field(default="", description="Long-form body text.") + tags: list[str] = Field(default_factory=list, description="Searchable tags.") + + +class GreetRequest(BaseModel): + """Request body for the greet function.""" + + name: str = Field(default="world", description="Name to greet.") + + +class GreetResponse(BaseModel): + """Response from the greet function.""" + + message: str + workspace: str + + +class CountRequest(BaseModel): + """Request body for the streaming count function.""" + + upto: int = Field(default=3, description="How many tick frames to emit.") + + +class Tick(BaseModel): + """A frame from the count stream (tick or done).""" + + kind: str + n: int | None = None + + +class BlobUploadResponse(BaseModel): + """Response from uploading a binary blob.""" + + name: str + size: int + + +class UpdateExampleItemRequest(BaseModel): + """Request body for ``PATCH /v2/workspaces/{workspace}/items/{name}``. + + All fields are optional — omitted fields are left unchanged. + """ + + title: str | None = Field(default=None, description="Updated title.") + body: str | None = Field(default=None, description="Updated body text.") + tags: list[str] | None = Field(default=None, description="Replacement tag list.") diff --git a/plugins/example-plugin/tests/test_sdk.py b/plugins/example-plugin/tests/test_sdk.py index 1896464f35..8176d3a4ba 100644 --- a/plugins/example-plugin/tests/test_sdk.py +++ b/plugins/example-plugin/tests/test_sdk.py @@ -5,191 +5,185 @@ from __future__ import annotations -from typing import cast from unittest.mock import AsyncMock, MagicMock import httpx import pytest -from nemo_example_plugin.sdk import AsyncExampleResource, ExampleResource -from nemo_platform import AsyncNeMoPlatform, NeMoPlatform +from nemo_example_plugin.sdk import AsyncExampleClient, ExampleClient +from nemo_example_plugin.types.payloads import ( + CreateExampleItemRequest, + UpdateExampleItemRequest, +) BASE = "http://test:8000" WS = "default" -CONFIG_PAYLOAD = { - "id": "default/my-filter", - "name": "my-filter", +ITEM_PAYLOAD = { + "id": "default/my-item", + "name": "my-item", "workspace": "default", - "blocked_keywords": ["bad"], - "block_message": "Blocked.", + "title": "My Item", + "body": "", + "tags": [], + "entity_type": "example_item", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z", } -class _SyncPlatform: - def __init__(self) -> None: - self.base_url = BASE - self._client = MagicMock(spec=httpx.Client) +def _resp(status: int, payload=None) -> httpx.Response: + kwargs: dict = {"request": httpx.Request("GET", BASE)} + if payload is not None: + kwargs["json"] = payload + else: + kwargs["content"] = b"" + return httpx.Response(status, **kwargs) -class _AsyncPlatform: - def __init__(self) -> None: - self.base_url = BASE - self._client = AsyncMock(spec=httpx.AsyncClient) +def _sync_client() -> tuple[ExampleClient, MagicMock]: + mock_http = MagicMock(spec=httpx.Client) + client = ExampleClient(base_url=BASE, http_client=mock_http) + return client, mock_http -def _sync_resp(payload) -> httpx.Response: - return httpx.Response(200, request=httpx.Request("GET", BASE), json=payload) - - -def _async_resp(payload) -> httpx.Response: - return httpx.Response(200, request=httpx.Request("GET", BASE), json=payload) - - -def _mw_url(path: str = "") -> str: - return f"{BASE}/apis/example/v2/workspaces/{WS}/middleware-configs{path}" +def _async_client() -> tuple[AsyncExampleClient, AsyncMock]: + mock_http = AsyncMock(spec=httpx.AsyncClient) + client = AsyncExampleClient(base_url=BASE, http_client=mock_http) + return client, mock_http # --------------------------------------------------------------------------- -# hello (existing) +# hello # --------------------------------------------------------------------------- def test_sync_hello() -> None: - platform = _SyncPlatform() - platform._client.get.return_value = httpx.Response( - 200, request=httpx.Request("GET", f"{BASE}/apis/example/hello/alice"), json={"message": "Hello, alice!"} - ) - assert ExampleResource(cast(NeMoPlatform, platform)).hello("alice") == "Hello, alice!" - platform._client.get.assert_called_once_with(f"{BASE}/apis/example/hello/alice") + client, mock_http = _sync_client() + mock_http.request.return_value = _resp(200, {"message": "Hello, alice!"}) + resp = client.hello(name="alice") + assert resp.data().message == "Hello, alice!" @pytest.mark.asyncio async def test_async_hello() -> None: - platform = _AsyncPlatform() - platform._client.get.return_value = httpx.Response( - 200, request=httpx.Request("GET", f"{BASE}/apis/example/hello/bob"), json={"message": "Hello, bob!"} - ) - assert await AsyncExampleResource(cast(AsyncNeMoPlatform, platform)).hello("bob") == "Hello, bob!" - platform._client.get.assert_awaited_once_with(f"{BASE}/apis/example/hello/bob") + client, mock_http = _async_client() + mock_http.request.return_value = _resp(200, {"message": "Hello, bob!"}) + resp = await client.hello(name="bob") + assert resp.data().message == "Hello, bob!" # --------------------------------------------------------------------------- -# middleware config CRUD — sync +# Items CRUD — sync # --------------------------------------------------------------------------- -def test_sync_create_middleware_config() -> None: - platform = _SyncPlatform() - platform._client.post.return_value = _sync_resp(CONFIG_PAYLOAD) +def test_sync_create_item() -> None: + client, mock_http = _sync_client() + mock_http.request.return_value = _resp(201, ITEM_PAYLOAD) - result = ExampleResource(cast(NeMoPlatform, platform)).create_middleware_config( - WS, "my-filter", blocked_keywords=["bad"] - ) + resp = client.create_item(CreateExampleItemRequest(name="my-item", title="My Item"), workspace=WS) + item = resp.data() - platform._client.post.assert_called_once_with(_mw_url(), json={"name": "my-filter", "blocked_keywords": ["bad"]}) - assert result["name"] == "my-filter" + assert item.name == "my-item" + assert item.title == "My Item" + mock_http.request.assert_called_once() -def test_sync_list_middleware_configs() -> None: - platform = _SyncPlatform() - platform._client.get.return_value = _sync_resp([CONFIG_PAYLOAD]) +def test_sync_get_item() -> None: + client, mock_http = _sync_client() + mock_http.request.return_value = _resp(200, ITEM_PAYLOAD) - result = ExampleResource(cast(NeMoPlatform, platform)).list_middleware_configs(WS) + resp = client.get_item(workspace=WS, name="my-item") - platform._client.get.assert_called_once_with(_mw_url()) - assert len(result) == 1 + assert resp.data().name == "my-item" -def test_sync_get_middleware_config() -> None: - platform = _SyncPlatform() - platform._client.get.return_value = _sync_resp(CONFIG_PAYLOAD) +def test_sync_list_items() -> None: + client, mock_http = _sync_client() + mock_http.request.return_value = _resp( + 200, {"data": [ITEM_PAYLOAD], "pagination": None, "sort": None, "filter": None} + ) - result = ExampleResource(cast(NeMoPlatform, platform)).get_middleware_config(WS, "my-filter") + resp = client.list_items(workspace=WS) + page = resp.data() - platform._client.get.assert_called_once_with(_mw_url("/my-filter")) - assert result["name"] == "my-filter" + assert len(page.data) == 1 + assert page.data[0].name == "my-item" -def test_sync_update_middleware_config() -> None: - platform = _SyncPlatform() - updated = {**CONFIG_PAYLOAD, "block_message": "Updated."} - platform._client.patch.return_value = _sync_resp(updated) +def test_sync_update_item() -> None: + client, mock_http = _sync_client() + updated = {**ITEM_PAYLOAD, "title": "Updated"} + mock_http.request.return_value = _resp(200, updated) - result = ExampleResource(cast(NeMoPlatform, platform)).update_middleware_config( - WS, "my-filter", block_message="Updated." - ) + resp = client.update_item(UpdateExampleItemRequest(title="Updated"), workspace=WS, name="my-item") - platform._client.patch.assert_called_once_with(_mw_url("/my-filter"), json={"block_message": "Updated."}) - assert result["block_message"] == "Updated." + assert resp.data().title == "Updated" -def test_sync_delete_middleware_config() -> None: - platform = _SyncPlatform() - platform._client.delete.return_value = httpx.Response(204, request=httpx.Request("DELETE", _mw_url("/my-filter"))) +def test_sync_delete_item() -> None: + client, mock_http = _sync_client() + mock_http.request.return_value = _resp(204) - ExampleResource(cast(NeMoPlatform, platform)).delete_middleware_config(WS, "my-filter") + client.delete_item(workspace=WS, name="my-item") - platform._client.delete.assert_called_once_with(_mw_url("/my-filter")) + mock_http.request.assert_called_once() # --------------------------------------------------------------------------- -# middleware config CRUD — async +# Items CRUD — async # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_async_create_middleware_config() -> None: - platform = _AsyncPlatform() - platform._client.post.return_value = _async_resp(CONFIG_PAYLOAD) +async def test_async_create_item() -> None: + client, mock_http = _async_client() + mock_http.request.return_value = _resp(201, ITEM_PAYLOAD) - result = await AsyncExampleResource(cast(AsyncNeMoPlatform, platform)).create_middleware_config( - WS, "my-filter", blocked_keywords=["bad"] - ) + resp = await client.create_item(CreateExampleItemRequest(name="my-item", title="My Item"), workspace=WS) - platform._client.post.assert_awaited_once_with(_mw_url(), json={"name": "my-filter", "blocked_keywords": ["bad"]}) - assert result["name"] == "my-filter" + assert resp.data().name == "my-item" @pytest.mark.asyncio -async def test_async_list_middleware_configs() -> None: - platform = _AsyncPlatform() - platform._client.get.return_value = _async_resp([CONFIG_PAYLOAD]) +async def test_async_get_item() -> None: + client, mock_http = _async_client() + mock_http.request.return_value = _resp(200, ITEM_PAYLOAD) - result = await AsyncExampleResource(cast(AsyncNeMoPlatform, platform)).list_middleware_configs(WS) + resp = await client.get_item(workspace=WS, name="my-item") - platform._client.get.assert_awaited_once_with(_mw_url()) - assert len(result) == 1 + assert resp.data().name == "my-item" @pytest.mark.asyncio -async def test_async_get_middleware_config() -> None: - platform = _AsyncPlatform() - platform._client.get.return_value = _async_resp(CONFIG_PAYLOAD) +async def test_async_list_items() -> None: + client, mock_http = _async_client() + mock_http.request.return_value = _resp( + 200, {"data": [ITEM_PAYLOAD], "pagination": None, "sort": None, "filter": None} + ) - result = await AsyncExampleResource(cast(AsyncNeMoPlatform, platform)).get_middleware_config(WS, "my-filter") + resp = await client.list_items(workspace=WS) + page = resp.data() - platform._client.get.assert_awaited_once_with(_mw_url("/my-filter")) - assert result["name"] == "my-filter" + assert len(page.data) == 1 + assert page.data[0].name == "my-item" @pytest.mark.asyncio -async def test_async_update_middleware_config() -> None: - platform = _AsyncPlatform() - updated = {**CONFIG_PAYLOAD, "block_message": "Updated."} - platform._client.patch.return_value = _async_resp(updated) +async def test_async_update_item() -> None: + client, mock_http = _async_client() + updated = {**ITEM_PAYLOAD, "title": "Updated"} + mock_http.request.return_value = _resp(200, updated) - result = await AsyncExampleResource(cast(AsyncNeMoPlatform, platform)).update_middleware_config( - WS, "my-filter", block_message="Updated." - ) + resp = await client.update_item(UpdateExampleItemRequest(title="Updated"), workspace=WS, name="my-item") - platform._client.patch.assert_awaited_once_with(_mw_url("/my-filter"), json={"block_message": "Updated."}) - assert result["block_message"] == "Updated." + assert resp.data().title == "Updated" @pytest.mark.asyncio -async def test_async_delete_middleware_config() -> None: - platform = _AsyncPlatform() - platform._client.delete.return_value = httpx.Response(204, request=httpx.Request("DELETE", _mw_url("/my-filter"))) +async def test_async_delete_item() -> None: + client, mock_http = _async_client() + mock_http.request.return_value = _resp(204) - await AsyncExampleResource(cast(AsyncNeMoPlatform, platform)).delete_middleware_config(WS, "my-filter") + await client.delete_item(workspace=WS, name="my-item") - platform._client.delete.assert_awaited_once_with(_mw_url("/my-filter")) + mock_http.request.assert_awaited_once()