Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
82fb001
squash
matthewgrossman Jun 24, 2026
4c69439
reduce diff
matthewgrossman Jun 24, 2026
113160c
make more concise
matthewgrossman Jun 24, 2026
05a8140
query params
matthewgrossman Jun 24, 2026
3eb4961
lint
matthewgrossman Jun 24, 2026
f10ea57
Merge branch 'main' into mgrossman/aircore-827-migrate-first-plugin-t…
matthewgrossman Jun 24, 2026
4af5133
fix: compare SecretRef.root instead of SecretRef object against string
matthewgrossman Jun 24, 2026
c4b9e5e
Merge branch 'main' into mgrossman/aircore-827-migrate-first-plugin-t…
matthewgrossman Jun 24, 2026
567ecac
vendor
matthewgrossman Jun 24, 2026
8ea1d2c
Merge branch 'main' into mgrossman/aircore-827-migrate-first-plugin-t…
matthewgrossman Jun 26, 2026
8966a96
fixes
matthewgrossman Jun 30, 2026
2e9991e
Merge branch 'main' into mgrossman/aircore-827-migrate-first-plugin-t…
matthewgrossman Jun 30, 2026
60083cc
clenaup
matthewgrossman Jul 1, 2026
4025935
Merge branch 'main' into mgrossman/aircore-827-migrate-first-plugin-t…
matthewgrossman Jul 1, 2026
1a6f8ca
self code review
matthewgrossman Jul 1, 2026
9ff20bb
Merge branch 'main' into mgrossman/aircore-827-migrate-first-plugin-t…
matthewgrossman Jul 1, 2026
038c583
improve perf of stream
matthewgrossman Jul 1, 2026
9caa76b
Merge branch 'main' into mgrossman/aircore-827-migrate-first-plugin-t…
matthewgrossman Jul 2, 2026
a413ac8
add new client.py for files
matthewgrossman Jul 2, 2026
201d06a
merge
matthewgrossman Jul 2, 2026
a333f20
update sync/async handling
matthewgrossman Jul 2, 2026
945006b
add comments
matthewgrossman Jul 2, 2026
be8fbf2
self code review
matthewgrossman Jul 2, 2026
b9f7c6c
cleanup
matthewgrossman Jul 2, 2026
3de2f54
vendor
matthewgrossman Jul 2, 2026
7cb16ce
self review
matthewgrossman Jul 2, 2026
d389a46
update tests
matthewgrossman Jul 2, 2026
0ad1329
lint
matthewgrossman Jul 2, 2026
528f209
fix openapi
matthewgrossman Jul 2, 2026
ea717b9
Merge branch 'main' into mgrossman/aircore-827-migrate-first-plugin-t…
matthewgrossman Jul 2, 2026
53b77aa
fix: resolve ty lint errors for FilesetsSubResource migration
matthewgrossman Jul 2, 2026
83887a8
fix: remap NemoClient errors to Stainless SDK errors for backward compat
matthewgrossman Jul 2, 2026
4869d05
fix: remap NemoClient errors to Stainless SDK errors for backward compat
matthewgrossman Jul 2, 2026
11a9a2d
Merge branch 'main' into mgrossman/aircore-827-migrate-first-plugin-t…
matthewgrossman Jul 2, 2026
2c063af
fix: update callers for NemoPaginatedResponse interface
matthewgrossman Jul 2, 2026
76c16dc
feat: replace __iter__/__aiter__ with explicit items()/pages() on pag…
matthewgrossman Jul 2, 2026
0a6416d
self code review
matthewgrossman Jul 2, 2026
b1fe7de
Merge branch 'main' into mgrossman/aircore-827-migrate-first-plugin-t…
matthewgrossman Jul 2, 2026
b0e6f05
self code review
matthewgrossman Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 112 additions & 81 deletions packages/filesets/src/filesets/filesystem/filesystem.py

Large diffs are not rendered by default.

367 changes: 302 additions & 65 deletions packages/filesets/src/filesets/resources.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/nemo_platform/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ nemo-guardrails-plugin = [
nemo-platform-plugin = [
"anthropic>=0.88.0",
"fastapi>=0.115.4",
"jsonschema>=4.0.0",
"lark>=1.1.0",
"nemo-platform-sdk",
"openai>=1.109.1",
Expand Down
1 change: 1 addition & 0 deletions packages/nemo_platform_plugin/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ classifiers = [
dependencies = [
"anthropic>=0.88.0",
"fastapi>=0.115.4",
"jsonschema>=4.0.0",
"lark>=1.1.0",
"nemo-platform-sdk",
"openai>=1.109.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,6 @@ def client_from_platform(
return client_cls(
base_url=str(platform.base_url).rstrip("/"),
workspace=platform.workspace,
default_headers=platform._custom_headers, # type: ignore[arg-type]
http_client=platform._client, # type: ignore[arg-type]
)
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from typing import TypeVar, get_args, get_origin, overload

import httpx
from nemo_platform_plugin.client.errors import raise_for_status
from nemo_platform_plugin.client.response import (
AsyncNemoBinaryResponse,
AsyncNemoStreamResponse,
Expand Down Expand Up @@ -49,9 +50,12 @@ class BaseNemoClient:
Subclasses provide the actual HTTP transport (sync or async).
"""

def __init__(self, *, base_url: str, workspace: str | None = None) -> None:
def __init__(
self, *, base_url: str, workspace: str | None = None, default_headers: Mapping[str, str] | None = None
) -> None:
self._base_url = base_url.rstrip("/")
self._workspace = workspace
self._default_headers = dict(default_headers) if default_headers else {}

@property
def base_url(self) -> str:
Expand All @@ -65,13 +69,19 @@ 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
params — explicit always wins. Path parameter values are
percent-encoded so reserved characters (``#``, ``?``, etc.) in
file paths don't break the URL. Raises ``ValueError`` if any
placeholders remain unresolved.
"""
from urllib.parse import quote
Comment thread
matthewgrossman marked this conversation as resolved.
Outdated

params: dict[str, str] = {}
if self._workspace:
params["workspace"] = self._workspace
params.update(request.path_params)
# Percent-encode values so reserved chars in file paths don't break URLs.
# safe="/" preserves path separators within {path} placeholders.
params.update({k: quote(v, safe="/") for k, v in request.path_params.items()})
try:
path = request.path_template.format_map(params)
except KeyError as exc:
Expand All @@ -80,6 +90,8 @@ def _resolve_path(self, request: PreparedRequest) -> str:

def _request_headers(self, request: PreparedRequest) -> dict[str, str] | None:
headers: dict[str, str] = {}
if self._default_headers:
headers.update(self._default_headers)
if request.content_type is not None:
headers["Content-Type"] = request.content_type
if request.extra_headers:
Expand All @@ -93,10 +105,19 @@ def _is_stream(self, request: PreparedRequest) -> bool:
return get_origin(request.response_type) is Stream

def _resolve_query_params(self, request: PreparedRequest) -> dict[str, str | int | bool] | None:
"""Filter out None values from query params for httpx."""
"""Filter out None values and JSON-serialize dicts/lists in query params."""
if request.query_params is None:
return None
filtered = {k: v for k, v in request.query_params.items() if v is not None}
import json
Comment thread
matthewgrossman marked this conversation as resolved.
Outdated

filtered = {}
for k, v in request.query_params.items():
if v is None:
continue
if isinstance(v, (dict, list)):
filtered[k] = json.dumps(v)
else:
filtered[k] = v
return filtered or None


Expand All @@ -112,7 +133,7 @@ def __init__(
timeout: float = DEFAULT_TIMEOUT,
http_client: httpx.Client | None = None,
) -> None:
super().__init__(base_url=base_url, workspace=workspace)
super().__init__(base_url=base_url, workspace=workspace, default_headers=default_headers)
self._http = http_client or httpx.Client(
headers=dict(default_headers) if default_headers else None,
timeout=timeout,
Expand Down Expand Up @@ -157,22 +178,31 @@ def send(
params = self._resolve_query_params(request)

if self._is_binary(request):
stream_ctx = self._http.stream(
request.method, url, content=request.content, headers=req_headers, params=params
)
return NemoBinaryResponse(stream_ctx, request)
kwargs = {
"method": request.method,
"url": url,
"content": request.content,
"headers": req_headers,
"params": params,
}
return NemoBinaryResponse(self._http, kwargs, request)

if self._is_stream(request):
assert request.response_type is not None
stream_ctx = self._http.stream(
request.method, url, content=request.content, headers=req_headers, params=params
)
kwargs = {
"method": request.method,
"url": url,
"content": request.content,
"headers": req_headers,
"params": params,
}
model_type = _get_stream_model_type(request.response_type)
return NemoStreamResponse(stream_ctx, model_type, request)
return NemoStreamResponse(self._http, kwargs, model_type, request)

raw = self._http.request(request.method, url, content=request.content, headers=req_headers, params=params)
raise_for_status(raw)
body = None
if raw.is_success and request.response_type is not None:
if request.response_type is not None:
body = request.response_type.model_validate(raw.json())
return NemoResponse(http_response=raw, body=body, request=request)

Expand All @@ -192,7 +222,7 @@ def __init__(
timeout: float = DEFAULT_TIMEOUT,
http_client: httpx.AsyncClient | None = None,
) -> None:
super().__init__(base_url=base_url, workspace=workspace)
super().__init__(base_url=base_url, workspace=workspace, default_headers=default_headers)
self._http = http_client or httpx.AsyncClient(
headers=dict(default_headers) if default_headers else None,
timeout=timeout,
Expand Down Expand Up @@ -226,21 +256,30 @@ async def send(
params = self._resolve_query_params(request)

if self._is_binary(request):
stream_ctx = self._http.stream(
request.method, url, content=request.content, headers=req_headers, params=params
)
return AsyncNemoBinaryResponse(stream_ctx, request)
kwargs = {
"method": request.method,
"url": url,
"content": request.content,
"headers": req_headers,
"params": params,
}
return AsyncNemoBinaryResponse(self._http, kwargs, request)

if self._is_stream(request):
assert request.response_type is not None
stream_ctx = self._http.stream(
request.method, url, content=request.content, headers=req_headers, params=params
)
kwargs = {
"method": request.method,
"url": url,
"content": request.content,
"headers": req_headers,
"params": params,
}
model_type = _get_stream_model_type(request.response_type)
return AsyncNemoStreamResponse(stream_ctx, model_type, request)
return AsyncNemoStreamResponse(self._http, kwargs, model_type, request)

raw = await self._http.request(request.method, url, content=request.content, headers=req_headers, params=params)
raise_for_status(raw)
body = None
if raw.is_success and request.response_type is not None:
if request.response_type is not None:
body = request.response_type.model_validate(raw.json())
return NemoResponse(http_response=raw, body=body, request=request)
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def _build_prepared_request(
elif name == "body":
if not isinstance(value, BaseModel):
raise TypeError(f"body must be a BaseModel instance, got {type(value).__name__}")
content = value.model_dump_json().encode()
content = value.model_dump_json(exclude_unset=True).encode()
content_type = "application/json"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
elif name == "content":
content = value
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""HTTP error hierarchy for the NemoClient.

Status-code-specific subclasses also inherit from the corresponding
Stainless SDK exception so that existing ``except ConflictError``
(imported from ``nemo_platform``) catches our exceptions too.

TODO: Once all consumers import from ``nemo_platform_plugin.client.errors``,
remove the Stainless base classes.
"""

from __future__ import annotations

import httpx


class NemoHTTPError(Exception):
"""Raised on non-2xx HTTP responses.

Attributes:
http_response: The raw httpx response.
status_code: The HTTP status code.
detail: A human-readable error message extracted from the response
body (``{"detail": "..."}`` convention used by FastAPI / NeMo
Platform), or the raw response text as a fallback.
body: The parsed JSON response body, or None.
"""

def __init__(self, http_response: httpx.Response) -> None:
self.http_response = http_response
self.status_code = http_response.status_code
self.detail = self._extract_detail(http_response)
self.body = self._extract_body(http_response)
# Call Exception.__init__ directly to avoid Stainless APIStatusError.__init__
# which expects different arguments. Our subclasses inherit from both
# NemoHTTPError and the Stainless exception for isinstance() compatibility.
Exception.__init__(self, f"HTTP {self.status_code}: {self.detail}")

@staticmethod
def _extract_body(resp: httpx.Response) -> object | None:
try:
return resp.json()
except Exception:
return None

@staticmethod
def _extract_detail(resp: httpx.Response) -> str:
try:
body = resp.json()
if isinstance(body, dict) and isinstance(body.get("detail"), str):
return body["detail"]
except Exception:
pass
return resp.text


# ---------------------------------------------------------------------------
# Status-code-specific errors
# ---------------------------------------------------------------------------


def _stainless_base(name: str) -> type:
"""Import a Stainless SDK exception by name, falling back to NemoHTTPError."""
try:
import nemo_platform._exceptions as exc

return getattr(exc, name)
except (ImportError, AttributeError):
return NemoHTTPError


class BadRequestError(NemoHTTPError, _stainless_base("BadRequestError")): # type: ignore[misc]
"""HTTP 400"""


class AuthenticationError(NemoHTTPError, _stainless_base("AuthenticationError")): # type: ignore[misc]
"""HTTP 401"""


class PermissionDeniedError(NemoHTTPError, _stainless_base("PermissionDeniedError")): # type: ignore[misc]
"""HTTP 403"""


class NotFoundError(NemoHTTPError, _stainless_base("NotFoundError")): # type: ignore[misc]
"""HTTP 404"""


class ConflictError(NemoHTTPError, _stainless_base("ConflictError")): # type: ignore[misc]
"""HTTP 409"""


class UnprocessableEntityError(NemoHTTPError, _stainless_base("UnprocessableEntityError")): # type: ignore[misc]
"""HTTP 422"""


class RateLimitError(NemoHTTPError, _stainless_base("RateLimitError")): # type: ignore[misc]
"""HTTP 429"""


class InternalServerError(NemoHTTPError, _stainless_base("InternalServerError")): # type: ignore[misc]
"""HTTP 500+"""


_STATUS_CODE_TO_ERROR: dict[int, type[NemoHTTPError]] = {
400: BadRequestError,
401: AuthenticationError,
403: PermissionDeniedError,
404: NotFoundError,
409: ConflictError,
422: UnprocessableEntityError,
429: RateLimitError,
500: InternalServerError,
}


def raise_for_status(http_response: httpx.Response) -> None:
"""Raise status-code-specific NemoHTTPError subclass for non-2xx responses."""
if 200 <= http_response.status_code < 300:
return
error_cls = _STATUS_CODE_TO_ERROR.get(http_response.status_code, NemoHTTPError)
if error_cls is NemoHTTPError and http_response.status_code >= 500:
error_cls = InternalServerError
raise error_cls(http_response)
Loading