Skip to content
194 changes: 113 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 @@ -115,11 +115,13 @@ def __init__(
workspace: str | None = None,
auth: TokenProvider | str | None = None,
retry: RetryPolicy | None = None,
default_headers: Mapping[str, str] | None = None,
) -> None:
self._base_url = base_url.rstrip("/")
self._workspace = workspace
self._auth: TokenProvider | None = StaticToken(auth) if isinstance(auth, str) else auth
self._retry = retry
self._default_headers = dict(default_headers) if default_headers else {}

@property
def base_url(self) -> str:
Expand All @@ -143,13 +145,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

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 @@ -158,6 +166,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 @@ -174,10 +184,19 @@ def _is_paginated(self, request: PreparedRequest) -> bool:
return get_origin(request.response_type) is Paginated

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

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

def _apply_client_options(self, request: PreparedRequest, response: NemoResponse) -> NemoResponse:
Expand Down Expand Up @@ -216,7 +235,9 @@ def __init__(
retry: RetryPolicy | None = None,
http_client: httpx.Client | None = None,
) -> None:
super().__init__(base_url=base_url, workspace=workspace, auth=auth, retry=retry)
super().__init__(
base_url=base_url, workspace=workspace, auth=auth, retry=retry, 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 @@ -408,7 +429,9 @@ def __init__(
retry: RetryPolicy | None = None,
http_client: httpx.AsyncClient | None = None,
) -> None:
super().__init__(base_url=base_url, workspace=workspace, auth=auth, retry=retry)
super().__init__(
base_url=base_url, workspace=workspace, auth=auth, retry=retry, default_headers=default_headers
)
self._http = http_client or httpx.AsyncClient(
headers=dict(default_headers) if default_headers else None,
timeout=timeout,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,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 on lines 129 to 133

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all BaseModel request types passed as `body` and inspect their default-valued fields.
rg -nP '@(post|put)\b' packages/nemo_platform_plugin/src/nemo_platform_plugin/files/endpoints.py -A6
rg -nP 'class \w+Request\b' packages/nemo_platform_plugin/src/nemo_platform_plugin/files/types.py -A20

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 2453


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py
printf '\n--- types.py excerpt ---\n'
sed -n '90,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/files/types.py
printf '\n--- endpoint usages ---\n'
rg -n "body:\s*\w+Request|model_dump_json\(exclude_unset=True\)" packages/nemo_platform_plugin/src/nemo_platform_plugin -n

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 10147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py
printf '\n--- types.py excerpt ---\n'
sed -n '90,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/files/types.py
printf '\n--- endpoint usages ---\n'
rg -n "body:\s*\w+Request|model_dump_json\(exclude_unset=True\)" packages/nemo_platform_plugin/src/nemo_platform_plugin -n

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 10147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py
printf '\n--- types.py excerpt ---\n'
sed -n '90,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/files/types.py
printf '\n--- endpoint usages ---\n'
rg -n "body:\s*\w+Request|model_dump_json\(exclude_unset=True\)" packages/nemo_platform_plugin/src/nemo_platform_plugin -n

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 10147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py
printf '\n--- types.py excerpt ---\n'
sed -n '90,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/files/types.py
printf '\n--- endpoint usages ---\n'
rg -n "body:\s*\w+Request|model_dump_json\(exclude_unset=True\)" packages/nemo_platform_plugin/src/nemo_platform_plugin -n

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 10147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py
printf '\n--- types.py excerpt ---\n'
sed -n '90,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/files/types.py
printf '\n--- endpoint usages ---\n'
rg -n "body:\s*\w+Request|model_dump_json\(exclude_unset=True\)" packages/nemo_platform_plugin/src/nemo_platform_plugin -n

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 10147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py
printf '\n--- types.py excerpt ---\n'
sed -n '90,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/files/types.py
printf '\n--- endpoint usages ---\n'
rg -n "body:\s*\w+Request|model_dump_json\(exclude_unset=True\)" packages/nemo_platform_plugin/src/nemo_platform_plugin -n

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 10147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py
printf '\n--- types.py excerpt ---\n'
sed -n '90,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/files/types.py
printf '\n--- endpoint usages ---\n'
rg -n "body:\s*\w+Request|model_dump_json\(exclude_unset=True\)" packages/nemo_platform_plugin/src/nemo_platform_plugin -n

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 10147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py
printf '\n--- types.py excerpt ---\n'
sed -n '90,220p' packages/nemo_platform_plugin/src/nemo_platform_plugin/files/types.py
printf '\n--- endpoint usages ---\n'
rg -n "body:\s*\w+Request|model_dump_json\(exclude_unset=True\)" packages/nemo_platform_plugin/src/nemo_platform_plugin -n

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 10147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n packages/nemo_platform_plugin/src/nemo_platform_plugin/files/types.py | sed -n '95,190p'

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 3498


exclude_unset=True drops default-valued request fields.

CreateFilesetRequest has defaults (purpose, metadata, custom_fields, cache), so create_fileset() will omit them unless callers set them explicitly. If the server doesn’t reapply those defaults, POST/PUT bodies can lose 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 `@packages/nemo_platform_plugin/src/nemo_platform_plugin/client/endpoint.py`
around lines 74 - 78, The body serialization in the endpoint handling for "body"
is dropping default-valued fields because `model_dump_json(exclude_unset=True)`
omits them, which affects requests like `create_fileset()` using
`CreateFilesetRequest`. Update the serialization in the `endpoint.py` body
branch to include default values so request payloads preserve fields such as
`purpose`, `metadata`, `custom_fields`, and `cache` unless there is an explicit
reason to omit them.

elif name == "content":
content = value
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# 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
try:
return resp.text
except httpx.ResponseNotRead:
return f"HTTP {resp.status_code}"


# ---------------------------------------------------------------------------
# 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
Loading