Skip to main content

plugin-entities

Defines NemoEntity subclasses and uses NemoEntitiesClient for CRUD in the NeMo Platform entity store. Use when defining a new entity type, storing plugin data in the entity store, handling optimistic locking conflicts, listing entities with filters, or building entity clients for controllers. Trigger keywords: entity, entity store, NemoEntity, entity_type, NemoEntitiesClient, entity client, store data, optimistic lock, EntityConflictError, EntityNotFoundError.

Source facts

Repository
NVIDIA-NeMo/nemo-platform
Last source activity
August 14, 2026 at 17:32
Detected SKILL.md language
English
Stars
79
Forks
24

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
plugin-entities
description
Defines NemoEntity subclasses and uses NemoEntitiesClient for CRUD in the NeMo Platform entity store. Use when defining a new entity type, storing plugin data in the entity store, handling optimistic locking conflicts, listing entities with filters, or building entity clients for controllers. Trigger keywords: entity, entity store, NemoEntity, entity_type, NemoEntitiesClient, entity client, store data, optimistic lock, EntityConflictError, EntityNotFoundError.
# Plugin Entities (NemoEntity + NemoEntitiesClient) ## Defining an Entity ```python from nemo_platform_plugin.entity import NemoEntity class Widget(NemoEntity, entity_type="example_widget"): """Stored in entity store under entity type 'example_widget'.""" colour: str weight_kg: float = 0.0 tags: list[str] = [] ``` `entity_type` is **required** on every concrete subclass. It must be snake_case and plugin-scoped (e.g., `"my_plugin_widget"` not `"widget"`). Omitting it raises `TypeError` at class-definition time. Inherited fields from `EntityBase` (no need to declare): - `name: str` — human-readable name within workspace - `workspace: str` — workspace identifier (required, validated against `ID_PATTERN`) - `project: str | None` — optional project association - `id: str` — UUID (empty string `""` before persistence — NEVER use in response before save) - `created_at`, `updated_at`, `created_by`, `updated_by` ## Abstract Intermediate Bases Two patterns to exempt a class from the `entity_type` requirement: ```python # Option 1: __abstract__ = True class BasePluginEntity(NemoEntity): __abstract__ = True status: str = "pending" class Widget(BasePluginEntity, entity_type="example_widget"): colour: str # Option 2: inherit from ABC from abc import ABC class BasePluginEntity(NemoEntity, ABC): status: str = "pending" ``` > **Note:** `__abstract__ = True` only exempts the class that declares it directly. It is **not inherited** — subclasses of `BasePluginEntity` that don't re-declare `__abstract__ = True` must provide `entity_type`. ## Entity CRUD — FastAPI Endpoints Imports used in every CRUD route: ```python from nemo_platform_plugin.entity_client import ( NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError, get_entity_client, ) from fastapi import Depends, HTTPException ``` **Create (POST → 201):** ```python widget = Widget(name=body.name, workspace=workspace, colour=body.colour) try: saved = await entity_client.create(widget) except NemoEntityConflictError as exc: raise HTTPException(status_code=409, detail=f"Widget '{body.name}' already exists.") from exc return saved ``` **List (GET → 200 with pagination):** ```python filter_dict = filter if isinstance(filter, dict) else filter.model_dump(exclude_none=True) result = await entity_client.list( Widget, workspace=workspace, page=page, page_size=page_size, sort=sort, filter_obj=filter_dict or None, ) pagination = PaginationData.model_validate(result.pagination.model_dump()) if result.pagination else None return WidgetPage(data=result.data, pagination=pagination, ...) ``` **Get single (GET → 200 or 404):** ```python try: widget = await entity_client.get(Widget, name=name, workspace=workspace) except NemoEntityNotFoundError as exc: raise HTTPException(status_code=404, detail=f"Widget '{name}' not found.") from exc return widget ``` **Update (PATCH → 200 or 404 or 409):** ```python try: widget = await entity_client.get(Widget, name=name, workspace=workspace) except NemoEntityNotFoundError as exc: raise HTTPException(status_code=404, detail=f"Widget '{name}' not found.") from exc if body.colour is not None: widget.colour = body.colour try: saved = await entity_client.update(widget) except NemoEntityConflictError as exc: raise HTTPException(status_code=409, detail="Concurrent modification.") from exc except NemoEntityNotFoundError as exc: raise HTTPException(status_code=404, detail=f"Widget '{name}' not found.") from exc return saved ``` **Delete (DELETE → 204):** ```python try: await entity_client.delete(Widget, name=name, workspace=workspace) except NemoEntityNotFoundError as exc: raise HTTPException(status_code=404, detail=f"Widget '{name}' not found.") from exc ``` ## filter_obj Patterns Pass field names directly — **NEVER add a `data.` prefix**. The entity client adds it automatically for non-base fields. ```python # CORRECT — client adds data. prefix for custom fields automatically filter_obj={"colour": "red"} # → search: {"data.colour": "red"} filter_obj={"name": "my-widget"} # base field → no prefix: {"name": "my-widget"} # WRONG — double-prefix bug filter_obj={"data.colour": "red"} # → search: {"data.data.colour": "red"} ← broken! ``` Base fields (no prefix): `name`, `workspace`, `project`, `id`, `created_at`, `updated_at`, `entity_type`. ## Pagination `entity_client.list()` returns `PaginationInfo` (internal). `NemoListResponse` expects `PaginationData` (API layer). They are structurally identical — always convert: ```python from nemo_platform_plugin.schema import PaginationData pagination = PaginationData.model_validate(result.pagination.model_dump()) if result.pagination else None ``` Sort format: `"-created_at"` (descending), `"status"` (ascending). Non-base fields are auto-prefixed with `data.` by the client. ## Optimistic Locking `NemoEntityConflictError` covers two distinct scenarios: 1. **Create**: entity with that `(name, workspace, entity_type)` already exists → respond 409 2. **Update**: `_db_version` mismatch — another request modified the entity between your `get()` and `update()` → respond 409 in services, log debug + skip in controllers ## Cross-Workspace Listing (Controllers) ```python # workspace="-" lists entities across ALL workspaces result = await entity_client.list(Widget, workspace="-") ``` `workspace="-"` is a sentinel value handled specially by the entity store. **Never create or update entities with `workspace="-"`.** ## Building Entity Client Without Request Context For controllers and background tasks where FastAPI's `Depends()` is not available: ```python from nmp.common.sdk_factory import get_async_platform_sdk from nemo_platform_plugin.client.adapter import client_from_platform from nemo_platform_plugin.entities.client import AsyncEntitiesClient from nmp.common.entities.client import EntityClient sdk = get_async_platform_sdk(as_service="my-plugin", internal=True) entity_client = EntityClient(client_from_platform(sdk, AsyncEntitiesClient)) ``` `internal=True` adds headers that suppress the access log flood from controller polling every few seconds. Always use `internal=True` for background/controller clients. ## Entity computed fields in API responses `NemoEntity` subclasses expose `id`, `created_at`, `updated_at` as `@computed_field` properties that appear in `model_dump()` output automatically. Return entity objects directly from route handlers: ```python @router.post("/widgets", response_model=Widget, status_code=201) async def create_widget(...) -> Widget: saved = await entity_client.create(widget) return saved # id, created_at, etc. are in the serialized output ``` ## Test Helper Pattern ```python from datetime import datetime, timezone from unittest.mock import AsyncMock from nemo_platform_plugin.entity_client import NemoEntityNotFoundError NOW = datetime(2024, 1, 1, tzinfo=timezone.utc) def _make_widget(name: str = "w1", workspace: str = "default") -> Widget: """Build a fake persisted Widget with store-populated private attrs.""" w = Widget(name=name, workspace=workspace, colour="red") w._id = f"id-{name}" # set private attr directly w._created_at = NOW return w # AsyncMock setup from unittest.mock import MagicMock from nemo_platform_plugin.entity_client import NemoPaginationInfo mock_client = AsyncMock() mock_client.create.return_value = _make_widget("w1") mock_client.get.side_effect = NemoEntityNotFoundError("not found") # ListResponse mock — use MagicMock to avoid internal import dependencies list_resp = MagicMock() list_resp.data = [_make_widget("w1")] list_resp.pagination = NemoPaginationInfo( page=1, page_size=20, current_page_size=1, total_pages=1, total_results=1 ) mock_client.list.return_value = list_resp ``` ## Retry on Optimistic Lock (Services) In services (not controllers), retry with backoff rather than returning 409: ```python import asyncio from nemo_platform_plugin.entity_client import NemoEntityConflictError async def update_with_retry(entity_client, name, workspace, update_fn): for attempt in range(3): try: entity = await entity_client.get(Widget, name=name, workspace=workspace) update_fn(entity) return await entity_client.update(entity) except NemoEntityConflictError: if attempt == 2: raise await asyncio.sleep(0.1 * (attempt + 1)) ``` ## Page Through All Entities ```python async def list_all(entity_client, workspace): page = 1 while True: result = await entity_client.list(Widget, workspace=workspace, page=page, page_size=100) for entity in result.data: yield entity if page >= result.pagination.total_pages: break page += 1 ``` ## Parent-Child Entities Child uniqueness is `(workspace, entity_type, parent, name)` instead of the usual `(workspace, entity_type, name)`: ```python parent = await entity_client.create(ParentEntity(name="p1", workspace="default")) child = ChildEntity(name="c1", workspace="default") child._parent = parent.id # link via UUID child = await entity_client.create(child) # Retrieve with parent context child = await entity_client.get(ChildEntity, "c1", workspace="default", parent=parent.id) ``` ## Gotchas - **`entity_type` REQUIRED on concrete classes**: `TypeError` at class-definition time if missing. Error message shows exact correct syntax. - **`entity.id` is `""` (empty string) before persistence**: Never return an unsaved entity from a route — the empty `id` will appear in the API response. Always call `entity_client.create()` or `entity_client.get()` first. - **`filter_obj` with `data.` prefix**: Double-prefix bug — `{"data.colour": "red"}` becomes `{"data.data.colour": "red"}` in the actual query. Always pass bare field names. - **`workspace="-"` is a sentinel**: Never use it for creating/updating entities — only for listing across workspaces. - **`entity_type` auto-derivation is DISABLED**: `NemoEntity` explicitly disables the `EntityBase` behavior of deriving `entity_type` from the class name. You must always pass it as a keyword. - **`workspace` and `name` must match `ID_PATTERN`**: Pattern is `^[\w\-\+.@:]+$`. Spaces and forward slashes cause validation errors on both fields. - **`NemoEntityConflictError` has two meanings**: check call site — `create()` means duplicate; `update()` means version mismatch.
View on GitHub