Mosaic Typed Client Reference¶
The typed client gives each schema class its own accessor on MosaicClient —
client.samples.create(Sample(name="S001")) — alongside the generic
client.put("Sample", {...}) that already exists. Both surfaces are coequal:
every SDK capability is reachable from either, neither is preferred, and new
features land in both at the same time.
For the full engineering spec see sec9 §9.8.
Prerequisites¶
The typed surface is built when MosaicClient is constructed with a SchemaRegistry.
Without a registry the typed accessors are not present and the generic surface
continues to work normally.
from mosaic.core.client import MosaicClient
from mosaic.linkml_bridge import SchemaRegistry
registry = SchemaRegistry.from_yaml_file("schemas/my_schema.yaml")
client = MosaicClient(storage=storage, registry=registry)
Namespace-Aware Access Patterns¶
The typed client mirrors the namespace structure declared in the schema.
Root Namespace¶
Classes declared without a namespace: annotation are root-namespace classes.
They are reachable both flat on client and via the explicit client.root.* alias.
The two forms resolve to the same accessor object.
# Flat access (default)
client.samples.create({"name": "S001"})
# Explicit root alias — identical result
client.root.samples.create({"name": "S001"})
Non-Root Namespaces¶
Classes with namespace: tissue are reached through a namespace container:
Non-root classes are not flat on client. client.samples will not be present
as a typed accessor unless a root-namespace class also has the default accessor
samples.
Nested Namespaces (Dot Notation)¶
A namespace string like assay.quant produces a two-level path:
Intermediate containers materialize even when no classes are declared directly
in the parent segment. If only assay.quant has classes, client.assay exists
as a container with a .quant sub-attribute and no direct accessors.
Access Pattern Summary¶
Declared namespace: |
Access form | Example |
|---|---|---|
| (none) — root | Flat | client.samples.create(...) |
| (none) — root | Explicit root | client.root.samples.create(...) |
tissue |
Namespace | client.tissue.samples.create(...) |
assay.quant |
Nested | client.assay.quant.measurements.create(...) |
Accessor Name Derivation¶
Within a namespace, the attribute name for a class follows a deterministic rule:
default accessor = snake_case(ClassName) + "s"
| Class | Default accessor |
|---|---|
Sample |
samples |
TissueType |
tissue_types |
DNASample |
dna_samples |
CellLineQC |
cell_line_qcs |
The rule is intentionally simple — it does not attempt linguistically correct
plurals for irregular cases. When the default is unsuitable, override it with
the hippo_accessor annotation:
classes:
Analysis:
is_a: Entity
annotations:
hippo_accessor: analytics # instead of default "analysises"
Accessor API¶
Every class accessor is an EntityAccessor instance. All methods forward to
the same underlying MosaicClient internals used by the generic surface.
| Method | Signature | Description |
|---|---|---|
create |
create(data) → dict |
Insert a new entity. Raises ValidationFailed on failure. |
put |
put(data, entity_id=None) → dict |
Insert or update by ID. Raises ValidationFailed on failure. |
replace |
replace(entity_id, data) → dict |
Full replacement. Raises ValidationFailed on failure. |
get |
get(entity_id, expand=None) → dict |
Fetch a single entity by ID. |
query |
query(**kwargs) → list[dict] |
Filter entities; accepts the same kwargs as the generic query. |
delete |
delete(entity_id) → bool |
Soft-delete (marks unavailable). SDK hooks run. |
history |
history(entity_id) → list[dict] |
Full provenance history for an entity. |
state_at |
state_at(entity_id, timestamp) → dict \| None |
Entity state as of a given UTC timestamp. |
data may be a plain dict or a Pydantic model instance — see Pydantic model access.
Pydantic Model Access¶
When SchemaRegistry loads a schema, Mosaic generates a Pydantic v2 model class
for each domain class and attaches it to the accessor.
accessor = client.samples
Model = accessor.model_class # Pydantic class for "Sample"
print(Model.__name__) # "Sample"
You can pass either a plain dict or a Pydantic instance to any write method:
# Dict form
client.samples.create({"name": "S001"})
# Pydantic instance — field validation runs at construction time
from mosaic.core.typed_client import EntityAccessor
sample = client.samples.model_class(id="x1", name="S001")
client.samples.create(sample)
When Pydantic generation fails at load time, model_class is None and the
accessor falls back to plain-dict operation with a logged warning.
Direct Import via mosaic.models¶
As an alternative to accessor.model_class, Mosaic registers every generated
class under mosaic.models.<namespace> so you can import classes directly:
# Root-namespace class
from mosaic.models import Sample
sample = Sample(id="x1", name="S001")
client.samples.create(sample)
# Non-root namespace class (hippo_namespace: tissue)
from mosaic.models.tissue import TissueSample
# Nested namespace class (hippo_namespace: assay.quant)
from mosaic.models.assay.quant import Measurement
The mosaic.models package is populated by MosaicClient.__init__ when a
SchemaRegistry is provided. The modules are not available until after the
first MosaicClient(registry=...) construction.
Classes are keyed by their LinkML class name (e.g. Sample, TissueSample)
not by the accessor name (samples, tissue_samples). Root-namespace classes
land on mosaic.models itself; non-root classes land on
mosaic.models.<namespace>. Intermediate parent modules (e.g. mosaic.models.assay
when only assay.quant has classes) are registered as empty modules so import
chains resolve.
Multi-client note: If multiple MosaicClient instances with different
registries coexist in the same process, the last-constructed client's classes
populate mosaic.models. Concurrent multi-registry use in the same process is
not supported.
Error Handling¶
Load-Time Errors — TypedClientError¶
MosaicClient.__init__ raises TypedClientError when the schema produces an
ambiguous accessor surface. Four collision cases, each identified by the
.case field:
.case |
Trigger | Resolution |
|---|---|---|
"duplicate_accessor" |
Two classes in the same namespace derive the same accessor name | Add hippo_accessor to at least one class |
"accessor_vs_namespace" |
A class accessor conflicts with a sub-namespace segment at the same attribute level | Rename the class, namespace, or add hippo_accessor |
"namespace_reserved" |
A namespace segment uses an SDK-reserved name (query, storage, …) |
Rename the namespace |
"reserved_root" |
A class declares namespace: root |
Use a different namespace name — root is reserved |
"accessor_reserved" |
A class's derived or override accessor conflicts with a public MosaicClient attribute |
Override with hippo_accessor |
from mosaic.core.typed_client import TypedClientError
try:
client = MosaicClient(storage=storage, registry=conflicting_registry)
except TypedClientError as exc:
print(exc.case) # e.g. "duplicate_accessor"
print(exc) # actionable message with fix suggestion
Error messages include the class name(s), the conflicting accessor, and a concrete schema fix. Example:
SchemaRegistry load error: typed-client accessor collision.
Classes `tissue.DNASample` and `tissue.DnaSample` both resolve to
accessor `dna_samples` in namespace `tissue`.
Add `hippo_accessor` to at least one class to disambiguate.
Write-Time Errors — ValidationFailed¶
Write methods (create, put, replace) raise ValidationFailed when
validation fails. The exception carries full context:
from mosaic.core.exceptions import ValidationFailed
try:
client.samples.create({"name": "bad"})
except ValidationFailed as exc:
print(exc.entity_type) # "Sample"
print(exc.entity_id) # None for create, the ID for replace/put-with-id
result = exc.result # ValidationResult envelope (may be None for empty-data rejection)
if result:
envelope = result.to_envelope()
for failure in envelope["failures"]:
print(failure["message"])
Empty data ({} or None) is rejected uniformly with ValidationFailed
before any validators run.
Typed vs. Generic Client¶
The typed and generic surfaces share the same SDK internals. No capability exists in one that is absent from the other.
# These two calls produce identical results:
client.samples.create({"name": "S001"}) # typed
client.put("Sample", {"name": "S001"}) # generic
# Entities created via either surface are visible to the other:
entity = client.put("Sample", {"name": "via-generic"})
client.samples.get(entity["id"]) # works
entity2 = client.samples.create({"name": "via-typed"})
client.get("Sample", entity2["id"]) # works
Use the typed surface when you want class-specific IDE completion and Pydantic field validation. Use the generic surface when operating on entity types that are only known at runtime.
Limitations¶
The typed client does not provide:
- Query-builder syntax.
client.samples.query(...)accepts the same filter dict asclient.query("Sample", ...). A typed query-builder is a future refinement, not part of the current surface. - Relationship traversal helpers. Relationship traversal lives on the query engine, which is adapter-agnostic.
- Compile-time schema checks. The Pydantic classes are generated from the schema loaded at runtime; if the schema changes between process starts, the accessor surface changes accordingly.
hippo_coreinfrastructure classes.ProvenanceRecord,Process,Validator,ReferenceLoader, andEntityare system concerns and are not exposed as typed accessors.