Skip to content

feat(sdk): Add a generic NemoClient - #370

Merged
matthewgrossman merged 22 commits into
mainfrom
mgrossman/files-with-nemo-plugin-client
Jun 22, 2026
Merged

feat(sdk): Add a generic NemoClient#370
matthewgrossman merged 22 commits into
mainfrom
mgrossman/files-with-nemo-plugin-client

Conversation

@matthewgrossman

@matthewgrossman matthewgrossman commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a typed HTTP client infrastructure to nemo_platform_plugin that plugin authors can use to build fully type-safe SDK clients. Endpoints are the single source of truth for the HTTP contract — request type, response type, path parameters, and HTTP method are all declared once and flow through to the client with full type inference.

Usage

# 1. Define your request and response as plain Pydantic models
from pydantic import BaseModel

class CreateItemRequest(BaseModel):
    name: str
    title: str

class ItemResponse(BaseModel):
    id: int
    name: str
    title: str

# 2. Define path parameters and endpoints
from typing import NotRequired
from nemo_platform_plugin.client.types import BasePath
from nemo_platform_plugin.client.endpoint import get, post, delete

class WorkspacePath(BasePath):
    workspace: NotRequired[str]

class WorkspaceItemPath(BasePath):
    workspace: NotRequired[str]
    name: str

CreateItemEndpoint = post(
    "/apis/my-plugin/v2/workspaces/{workspace}/items",
    path_type=WorkspacePath,
    request_type=CreateItemRequest,
    response_type=ItemResponse,
)
GetItemEndpoint = get(
    "/apis/my-plugin/v2/workspaces/{workspace}/items/{name}",
    path_type=WorkspaceItemPath,
    response_type=ItemResponse,
)
DeleteItemEndpoint = delete(
    "/apis/my-plugin/v2/workspaces/{workspace}/items/{name}",
    path_type=WorkspaceItemPath,
)

# 3. Create sync and async clients — endpoints become methods automatically
from nemo_platform_plugin.client.client import NemoClient, AsyncNemoClient

class _Endpoints:
    create_item = CreateItemEndpoint
    get_item = GetItemEndpoint
    delete_item = DeleteItemEndpoint

class MyPluginClient(_Endpoints, NemoClient):
    pass

class AsyncMyPluginClient(_Endpoints, AsyncNemoClient):
    pass

# 4. Use it
client = MyPluginClient(base_url="http://localhost:8080", workspace="default")

# Typed end-to-end: ty/pyright knows the return type is NemoResponse[ItemResponse]
resp = client.create_item(CreateItemRequest(name="widget", title="My Widget"), workspace="default")
item = resp.data()  # ItemResponse

resp = client.get_item(workspace="default", name="widget")
item = resp.data()  # ItemResponse

client.delete_item(workspace="default", name="widget")

Key design decisions

  • Endpoints are descriptors: when assigned as class attributes on a NemoClient subclass, they return typed bound callables via __get__. No wrapper functions needed.
  • Sync/async from one definition: define endpoints once in a mixin, inherit into both NemoClient and AsyncNemoClient subclasses. The descriptor dispatches the right bound callable.
  • Four response kinds: BaseModel (JSON), None (DELETE), BinaryContent (file download/upload), Stream[T] (SSE/NDJSON). Overloaded send() returns the right type.
  • Path params are typed: TypedDict + Unpack enforces correct kwargs. workspace uses NotRequired so the client default can fill it in.
  • Factory functions not classmethods: ty can't infer class-level TypeVars from classmethods on generic classes (astral-sh/ty#541). Standalone get(), post(), etc. infer correctly.
  • Backward compatible: NemoPluginSDKResources registration still works via from_platform() adapter.

Test plan

  • All existing example-plugin tests pass (service, SDK)
  • New client infrastructure tests pass (endpoint, client)
  • ty check passes on all source files with zero diagnostics

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added a strongly-typed NeMo Platform client with synchronous and asynchronous request support, including typed JSON, binary download, and line-delimited streaming responses.
    • Added an example-plugin typed SDK that exposes typed endpoints, including blob upload/download operations.
  • Refactor

    • Migrated the example plugin from a hand-written resource wrapper to the new typed endpoint-based client.
  • Tests

    • Expanded client and example-plugin SDK test coverage for request building, response parsing, workspace handling, and CRUD workflows.
  • Bug Fixes

    • Improved URL handling by removing trailing slashes from base URLs to prevent double-slash issues.

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
@github-actions github-actions Bot added the feat label Jun 17, 2026
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 21176/27762 76.3% 61.2%
Integration Tests 12216/26531 46.0% 19.5%

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
@matthewgrossman
matthewgrossman marked this pull request as ready for review June 22, 2026 20:02
@matthewgrossman
matthewgrossman requested review from a team as code owners June 22, 2026 20:02
@matthewgrossman
matthewgrossman added this pull request to the merge queue Jun 22, 2026
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 92969b2d-bae0-45a7-a559-57a1e4646ee2

📥 Commits

Reviewing files that changed from the base of the PR and between e7b3687 and edd68f5.

📒 Files selected for processing (1)
  • plugins/example-plugin/tests/test_sdk.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • plugins/example-plugin/tests/test_sdk.py

📝 Walkthrough

Walkthrough

Adds a typed HTTP client system to nemo_platform_plugin with shared types, response wrappers, Endpoint descriptor, bound callables, NemoClient/AsyncNemoClient, and a client_from_platform adapter. The example plugin is then migrated from hand-written resource wrappers to this system, with payload models extracted to types/payloads.py, endpoint contracts declared in types/endpoints.py, and binary blob endpoints added to the service.

Changes

Typed client infrastructure (nemo_platform_plugin)

Layer / File(s) Summary
Shared types, PreparedRequest, and response wrappers
packages/nemo_platform_plugin/src/nemo_platform_plugin/client/types.py, client/response.py
Introduces BinaryContent, Stream[T], PathParams/WorkspaceParams TypedDicts, and the frozen PreparedRequest[ResponseT] dataclass. Adds NemoResponse[T], sync/async binary and streaming context managers (NemoBinaryResponse, NemoStreamResponse, AsyncNemoBinaryResponse, AsyncNemoStreamResponse), and NemoHTTPError.
Endpoint descriptor and verb factories
client/endpoint.py
Implements Endpoint[PathT, RequestT, ResponseT] generic descriptor with typed request() overloads (JSON body, binary, no-body) and __get__ dispatching to SyncBoundCall/AsyncBoundCall. Adds get, post, put, patch, delete factory helpers.
SyncBoundCall and AsyncBoundCall wrappers
client/bound.py
Adds SyncBoundCall and AsyncBoundCall wrappers that hold a client reference and request_fn, expose typed __call__ overloads for binary/stream/typed responses, and forward to client.send().
BaseNemoClient, NemoClient, AsyncNemoClient
client/client.py
Implements BaseNemoClient (URL resolution, workspace merging, header generation, binary/stream classification), NemoClient (sync send with httpx.Client), and AsyncNemoClient (async send with httpx.AsyncClient), plus _get_stream_model_type.
client_from_platform adapter
client/adapter.py
Adds client_from_platform with typed overloads for sync/async, normalizing base_url and reusing the platform's internal _client as http_client.
NemoClient and Endpoint unit tests
tests/client/test_client.py, tests/client/test_endpoint.py
Covers sync/async send() for POST/GET/DELETE, path interpolation, workspace default/override, trailing slash stripping, and PreparedRequest construction for all HTTP verb factories.

Example plugin migration to typed client

Layer / File(s) Summary
Payload models and schema reorganization
plugins/example-plugin/src/nemo_example_plugin/types/payloads.py, schema.py
Creates types/payloads.py with all Pydantic request/response models. Removes CreateExampleItemRequest, UpdateExampleItemRequest, and ExampleItemPage from schema.py.
Typed endpoint contract
plugins/example-plugin/src/nemo_example_plugin/types/endpoints.py
Declares all example plugin HTTP endpoints as typed Endpoint constants (Hello, CRUD items, Count stream, UploadBlob, DownloadBlob) with NamePath/WorkspaceItemPath path parameter classes.
ExampleClient SDK and binary service endpoints
plugins/example-plugin/src/nemo_example_plugin/sdk.py, service.py
Replaces ExampleResource/AsyncExampleResource with ExampleClient/AsyncExampleClient using _ExampleEndpoints mixin wired via client_from_platform. Adds _build_binary_router() with PUT /blob/{name} and GET /blob/{name} to the service.
Example plugin SDK tests
plugins/example-plugin/tests/test_sdk.py
Replaces middleware-config tests with ExampleClient/AsyncExampleClient hello and item CRUD coverage (sync and async), using MagicMock/AsyncMock httpx clients.

Suggested Reviewers

  • SandyChapman
  • mikeknep
  • arpitsardhana
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(sdk): Add a generic NemoClient' directly captures the main change: introduction of a new generic typed HTTP client infrastructure for the nemo_platform_plugin.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mgrossman/files-with-nemo-plugin-client

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
plugins/example-plugin/tests/test_sdk.py (1)

137-164: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Missing async tests for list_items and update_item.

Sync tests cover all CRUD operations, but async is missing list and update. Consider adding for parity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/example-plugin/tests/test_sdk.py` around lines 137 - 164, Add two
missing async test functions to achieve parity with the sync tests:
test_async_list_items and test_async_update_item. For test_async_list_items,
follow the pattern of the existing async tests by decorating with
`@pytest.mark.asyncio`, creating a client with _async_client(), mocking the HTTP
response with _resp(200, ...), calling client.list_items(...) with appropriate
parameters, and asserting on the response data. For test_async_update_item,
follow the same pattern but call client.update_item(...) with an
UpdateExampleItemRequest object and verify the response. Ensure both tests
include the mock_http.request.assert_awaited_once() assertion to verify the HTTP
request was made.
plugins/example-plugin/src/nemo_example_plugin/service.py (1)

163-168: 🧹 Nitpick | 🔵 Trivial

Add BlobUploadResponse import and use it as the typed return for upload_blob.

upload_blob currently returns untyped dict, which produces a vague OpenAPI schema. Import BlobUploadResponse from types.payloads and use it as both the return type and response_model parameter for better API documentation.

Suggested change
 from nemo_example_plugin.types.payloads import (
     CreateExampleItemRequest,
     ExampleItemPage,
+    BlobUploadResponse,
     HelloResponse,
     UpdateExampleItemRequest,
 )
-    `@router.put`("/blob/{name}", status_code=200)
-    async def upload_blob(name: str, request: Request) -> dict:
+    `@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 {"name": name, "size": len(data)}
+        return BlobUploadResponse(name=name, size=len(data))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/example-plugin/src/nemo_example_plugin/service.py` around lines 163 -
168, The upload_blob function currently returns an untyped dict, resulting in
poor OpenAPI schema documentation. Import BlobUploadResponse from
types.payloads, then update the upload_blob function by adding
response_model=BlobUploadResponse to the `@router.put`() decorator and changing
the function's return type annotation from dict to BlobUploadResponse. This will
ensure the API generates proper typed response documentation.
packages/nemo_platform_plugin/tests/client/test_client.py (1)

1-216: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider adding tests for binary and streaming responses.

The PR introduces BinaryContent and Stream[T] response types as key features, but these aren't covered in this test module. Adding tests for those code paths would increase confidence.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nemo_platform_plugin/tests/client/test_client.py` around lines 1 -
216, Add test cases for binary and streaming response types by first creating
endpoint definitions similar to CREATE_ITEM and GET_ITEM that use BinaryContent
and Stream[T] as response_type parameters. Then write test functions following
the same pattern as test_send_post and test_send_get_with_path_params that
verify the StubClient and AsyncStubClient properly handle binary content
responses and streaming responses respectively, including appropriate mock
assertions for both sync and async variants.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py`:
- Around line 146-149: The synchronous send method (around line 146-149) and the
async send method (around line 197-200) both call raise_for_status() immediately
after receiving the HTTP response, which prevents NemoResponse from being
constructed with error responses. Remove the raise_for_status() calls from both
methods so that all responses (including errors) are wrapped in NemoResponse
objects, allowing the deferred error checking in NemoResponse.data() to handle
error raising with full response context and enable callers to inspect error
details.

---

Nitpick comments:
In `@packages/nemo_platform_plugin/tests/client/test_client.py`:
- Around line 1-216: Add test cases for binary and streaming response types by
first creating endpoint definitions similar to CREATE_ITEM and GET_ITEM that use
BinaryContent and Stream[T] as response_type parameters. Then write test
functions following the same pattern as test_send_post and
test_send_get_with_path_params that verify the StubClient and AsyncStubClient
properly handle binary content responses and streaming responses respectively,
including appropriate mock assertions for both sync and async variants.

In `@plugins/example-plugin/src/nemo_example_plugin/service.py`:
- Around line 163-168: The upload_blob function currently returns an untyped
dict, resulting in poor OpenAPI schema documentation. Import BlobUploadResponse
from types.payloads, then update the upload_blob function by adding
response_model=BlobUploadResponse to the `@router.put`() decorator and changing
the function's return type annotation from dict to BlobUploadResponse. This will
ensure the API generates proper typed response documentation.

In `@plugins/example-plugin/tests/test_sdk.py`:
- Around line 137-164: Add two missing async test functions to achieve parity
with the sync tests: test_async_list_items and test_async_update_item. For
test_async_list_items, follow the pattern of the existing async tests by
decorating with `@pytest.mark.asyncio`, creating a client with _async_client(),
mocking the HTTP response with _resp(200, ...), calling client.list_items(...)
with appropriate parameters, and asserting on the response data. For
test_async_update_item, follow the same pattern but call client.update_item(...)
with an UpdateExampleItemRequest object and verify the response. Ensure both
tests include the mock_http.request.assert_awaited_once() assertion to verify
the HTTP request was made.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 3bb1e3a7-b507-4de0-89b9-20b34fad6771

📥 Commits

Reviewing files that changed from the base of the PR and between bade16f and da241eb.

📒 Files selected for processing (15)
  • NEMO_CLIENT_NOTES.local.md
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/adapter.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/bound.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/response.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/types.py
  • packages/nemo_platform_plugin/tests/client/test_client.py
  • packages/nemo_platform_plugin/tests/client/test_endpoint.py
  • plugins/example-plugin/src/nemo_example_plugin/schema.py
  • plugins/example-plugin/src/nemo_example_plugin/sdk.py
  • plugins/example-plugin/src/nemo_example_plugin/service.py
  • plugins/example-plugin/src/nemo_example_plugin/types/endpoints.py
  • plugins/example-plugin/src/nemo_example_plugin/types/payloads.py
  • plugins/example-plugin/tests/test_sdk.py

@matthewgrossman
matthewgrossman removed this pull request from the merge queue due to a manual request Jun 22, 2026
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py (1)

107-110: ⚠️ Potential issue | 🟠 Major

Add explicit close/aclose for owned HTTP clients.

When http_client is omitted, these constructors create internal httpx clients with connection pools that are never closed. Although all current code paths pass explicit clients, this is a public API risk.

Proposed fix
 class NemoClient(BaseNemoClient):
@@
     ) -> None:
         super().__init__(base_url=base_url, workspace=workspace)
-        self._http = http_client or httpx.Client(
+        self._owns_http = http_client is None
+        self._http = http_client or httpx.Client(
             headers=dict(default_headers) if default_headers else None,
             timeout=timeout,
         )
+
+    def close(self) -> None:
+        if self._owns_http:
+            self._http.close()
@@
 class AsyncNemoClient(BaseNemoClient):
@@
     ) -> None:
         super().__init__(base_url=base_url, workspace=workspace)
-        self._http = http_client or httpx.AsyncClient(
+        self._owns_http = http_client is None
+        self._http = http_client or httpx.AsyncClient(
             headers=dict(default_headers) if default_headers else None,
             timeout=timeout,
         )
+
+    async def aclose(self) -> None:
+        if self._owns_http:
+            await self._http.aclose()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py`
around lines 107 - 110, The constructor creates an internal httpx.Client when
http_client parameter is None and stores it in self._http, but never closes it,
causing a resource leak. Add a flag to track whether the HTTP client is owned
internally (created by the constructor) or was provided externally. Then
implement a __del__ method that calls self._http.close() only when the client is
owned internally, ensuring proper cleanup of connection pools for internally
created clients while leaving externally provided clients for the caller to
manage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py`:
- Around line 107-110: The constructor creates an internal httpx.Client when
http_client parameter is None and stores it in self._http, but never closes it,
causing a resource leak. Add a flag to track whether the HTTP client is owned
internally (created by the constructor) or was provided externally. Then
implement a __del__ method that calls self._http.close() only when the client is
owned internally, ensuring proper cleanup of connection pools for internally
created clients while leaving externally provided clients for the caller to
manage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 289ffe00-2127-4ae3-aacc-423aac107c17

📥 Commits

Reviewing files that changed from the base of the PR and between da241eb and e7b3687.

📒 Files selected for processing (3)
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/client/client.py
  • plugins/example-plugin/src/nemo_example_plugin/service.py
  • plugins/example-plugin/tests/test_sdk.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • plugins/example-plugin/tests/test_sdk.py
  • plugins/example-plugin/src/nemo_example_plugin/service.py

Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants