Plugin Entities (NemoEntity + NemoEntitiesClient)
Defining an Entity
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 workspaceworkspace: str— workspace identifier (required, validated againstID_PATTERN)project: str | None— optional project associationid: 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:
# 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__ = Trueonly exempts the class that declares it directly. It is not inherited — subclasses ofBasePluginEntitythat don't re-declare__abstract__ = Truemust provideentity_type.
Entity CRUD — FastAPI Endpoints
Imports used in every CRUD route:
from nemo_platform_plugin.entity_client import (
NemoEntitiesClient,
NemoEntityConflictError,
NemoEntityNotFoundError,
get_entity_client,
)
from fastapi import Depends, HTTPException
Create (POST → 201):
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):
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):
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):
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):
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.
# 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:
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:
- Create: entity with that
(name, workspace, entity_type)already exists → respond 409 - Update:
_db_versionmismatch — another request modified the entity between yourget()andupdate()→ respond 409 in services, log debug + skip in controllers
Cross-Workspace Listing (Controllers)
# 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:
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:
@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
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:
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
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):
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_typeREQUIRED on concrete classes:TypeErrorat class-definition time if missing. Error message shows exact correct syntax.entity.idis""(empty string) before persistence: Never return an unsaved entity from a route — the emptyidwill appear in the API response. Always callentity_client.create()orentity_client.get()first.filter_objwithdata.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_typeauto-derivation is DISABLED:NemoEntityexplicitly disables theEntityBasebehavior of derivingentity_typefrom the class name. You must always pass it as a keyword.workspaceandnamemust matchID_PATTERN: Pattern is^[\w\-\+.@:]+$. Spaces and forward slashes cause validation errors on both fields.NemoEntityConflictErrorhas two meanings: check call site —create()means duplicate;update()means version mismatch.