MetaseedClient API¶
MetaseedClient is the recommended entry point for programmatic use of metaseed. It provides a clean, stable API for working with metadata schemas, creating entities, and validating data.
Quick Start¶
from metaseed import MetaseedClient
# Create a client for a specific profile
client = MetaseedClient("miappe", "1.2")
# Create entities
inv = client.create_entity("Investigation", {
"unique_id": "INV-001",
"title": "Drought Tolerance Study"
})
study = client.create_entity("Study", {
"unique_id": "STU-001",
"title": "Field Trial 2024"
}, parent_id=inv.id)
# Validate all entities
result = client.validate()
if not result.valid:
for issue in result.issues:
print(f"{issue.field}: {issue.message}")
# Serialize for storage
data = client.serialize()
Creating a Client¶
From a profile name¶
# Use latest version
client = MetaseedClient("miappe")
# Specify version
client = MetaseedClient("miappe", "1.2")
client = MetaseedClient("isa", "1.0")
client = MetaseedClient("ena", "1.0")
From a custom spec¶
Use from_spec() when working with custom or dynamically-generated schemas:
spec = {
"version": "1.0",
"name": "custom-profile",
"entities": {
"Sample": {
"description": "A biological sample",
"fields": [
{"name": "id", "type": "string", "required": True, "description": "Unique ID"},
{"name": "name", "type": "string", "required": False, "description": "Display name"}
]
}
}
}
client = MetaseedClient.from_spec(spec)
Entity CRUD Operations¶
Create¶
entity = client.create_entity("Investigation", {
"unique_id": "INV-001",
"title": "My Study"
})
# With parent relationship
child = client.create_entity("Study", {
"unique_id": "STU-001",
"title": "Child Study",
"investigation_id": "INV-001"
}, parent_id=entity.id)
Permissive Mode (skip_validation)¶
For progressive editing where users fill in fields over multiple sessions:
# Create draft with incomplete data (skips Pydantic validation)
draft = client.create_entity(
"Investigation",
{"title": "Work in progress"}, # missing required unique_id
skip_validation=True,
)
# Update draft without validation
client.update_entity(
draft.id,
{"title": "Still working on it"},
skip_validation=True,
)
# Validate when ready to check for issues
result = client.validate_entity(draft.id)
if not result.valid:
for issue in result.issues:
print(f"Warning: {issue.field}: {issue.message}")
This is useful for web forms where: - Users create entities with minimal data - Fields are filled in over multiple sessions - Validation warnings are shown but don't block saving
Loading a nested document (load_nested, load_yaml, and so the shipped
examples and every YAML the exporter writes) is permissive in the same way, but
it validates first and only falls back when pydantic refuses. A well-formed
entity therefore gets the profile's declared types: taxon_id: "3702" is stored
as the integer 3702, and a quoted run_date as a date. This matters because
a serialization cannot carry the type — YAML quotes a date, and the Excel export
writes every cell as text on purpose, since Excel otherwise reads gene names as
dates and strips leading zeros from identifiers.
What pydantic refuses is stored as written, exactly as before:
- an entity missing a required field, because the UI persists a draft on purpose
- a value that is not the declared type, because
n/ain an integer field is what a person typed, and replacing it with nothing to satisfy a schema would delete their input
validate() reports both, so nothing is hidden and no entity is dropped. The
difference is only that a value which can be read as its declared type now is.
Read¶
entity = client.get_entity(entity_id)
print(entity.id)
print(entity.entity_type)
print(entity.data)
print(entity.label) # Derived display label
print(entity["unique_id"]) # Subscript access
Update¶
Delete¶
Tree Navigation¶
# Get complete tree
tree = client.get_tree()
for node in tree:
print(f"{node.entity_type}: {node.label}")
for child in node.children:
print(f" - {child.label}")
# Get root entities only
roots = client.get_roots()
# Get children of a specific entity
children = client.get_children(parent_id)
# Get label for an entity
label = client.get_entity_label(entity_id)
Serialization¶
Save¶
import json
# Default flat format (list of entities with _type metadata)
data = client.serialize()
# Tree format (nested hierarchy with labels)
data = client.serialize(format="tree")
with open("dataset.json", "w") as f:
json.dump(data, f, indent=2)
Load¶
with open("dataset.json") as f:
data = json.load(f)
# Auto-detects format (flat or tree)
client.load(data)
# Load from YAML file
client.load_yaml("dataset.yaml")
load_yaml reads both shapes a dataset comes in. A list, or a mapping with an
entities: key, is the serialization serialize() writes, where each entity
carries its _type. Anything else is a nested document — a root object with its
children embedded in its own fields, which is how the shipped examples and
hand-written datasets are written:
unique_id: INV-2024-WHEAT-001
title: Impact of drought stress on wheat yield
studies:
- unique_id: STU-2024-001
title: Field trial 2024
client = MetaseedClient(profile="miappe", version="1.1")
client.load_yaml("wheat-drought-study.yaml") # root and every nested entity
Where a profile declares containment with owns markers, only owned fields
become entities of their own; an embedded value-object stays inline. A plain
string in a child field is a reference, not a child, and is left alone.
A tree node without an id is loaded under a generated one, and its children are attached to that generated id — a stored id is not required to keep a subtree together.
Permissive loading¶
load() is strict by default: a tree node whose entity_type is missing or not defined by the profile aborts the whole load, so one bad node makes a whole dataset unreadable. Pass on_skip to load permissively instead. The callback turns the mode on and receives every dropped node, so a permissive load cannot discard data without telling the caller:
from metaseed import MetaseedClient, SkippedNode
skipped: list[SkippedNode] = []
client = MetaseedClient("miappe", "1.2")
loaded = client.load(data, on_skip=skipped.append)
for skip in skipped:
print(f"dropped {skip.entity_type!r}: {skip.reason}")
A skipped node takes its subtree with it. Re-parenting the orphans to the skipped node's parent would assert a parent-child link the payload never stated and the profile may not permit, so load() drops what it cannot place rather than inventing structure. SkippedNode.node holds the raw payload node, including the dropped subtree, so a caller that wants to recover the children has them.
SkippedNode field |
Meaning |
|---|---|
entity_type |
The node's entity_type, or None if absent or not a string |
reason |
Why the node could not be loaded |
node |
The raw payload node, including its dropped subtree |
descendants_dropped |
Number of nodes below it that were dropped with it |
on_skip applies to the tree format. The flat format is loaded entity by entity and already drops entities it cannot reconstruct, with a warning log.
Serialization Formats¶
Flat format (default):
{
"profile": "miappe",
"version": "1.2",
"entities": [
{"_type": "Investigation", "unique_id": "INV-1", "title": "..."},
{"_type": "Study", "unique_id": "STU-1", "_parent_unique_id": "INV-1"}
]
}
Tree format:
{
"profile": "miappe",
"version": "1.2",
"tree": [
{
"id": "node-1",
"entity_type": "Investigation",
"label": "INV-1",
"data": {"unique_id": "INV-1", "title": "..."},
"parent_field": null,
"children": [...]
}
]
}
parent_field names the field of the parent that holds the node (null on a root). It is required when the parent holds the child's type in more than one field, such as a SEEK Study's Persons.
Clear¶
Validation¶
Validate all entities¶
result = client.validate()
if result.valid:
print("All entities valid")
else:
for issue in result.issues:
print(f"{issue.field}: {issue.message} (rule: {issue.rule})")
Children are stored as separate nodes under their parent, so a parent's own nested list is empty even when it has children. Validation therefore puts each child back into the parent field that holds it before judging the parent, and a list-cardinality rule (for example "a Run must have at least one file") counts the children in the tree. This is one function, metaseed.facade.children.data_with_children, used wherever a dataset is validated: client.validate(), the MCP validate_dataset tool and the web application's validation.
A child is put into the field it was recorded in. A child that records none, such as a File an importer linked to its Run by run_ref alone, goes into the one nested field of the parent that takes its entity type. When the parent has several fields of that type and the child records none, the field is not guessed: the child is left out of the parent's lists and is still validated on its own.
Validate a specific entity¶
result = client.validate_entity(entity_id)
# Use ValidationResult as bool
if result:
print("Valid")
else:
print(f"Found {result.error_count} errors")
# Get errors for a specific field
field_errors = result.get_field_errors("title")
Schema Introspection¶
List entity types¶
Get field information¶
fields = client.get_entity_fields("Investigation")
for field in fields:
status = "(required)" if field.required else ""
print(f"{field.name}: {field.type} {status}")
print(f" {field.description}")
Get complete schema¶
schema = client.get_entity_schema("Investigation")
print(f"Entity: {schema.name}")
print(f"Description: {schema.description}")
print(f"Required fields: {schema.required_fields}")
print(f"Optional fields: {schema.optional_fields}")
Error Handling¶
All errors inherit from MetaseedError:
from metaseed import (
MetaseedError,
ProfileNotFoundError,
EntityNotFoundError,
EntityTypeNotFoundError,
)
try:
client = MetaseedClient("invalid-profile")
except ProfileNotFoundError as e:
print(f"Profile not found: {e.profile}")
try:
entity = client.get_entity("invalid-id")
except EntityNotFoundError as e:
print(f"Entity not found: {e.entity_id}")
try:
client.create_entity("InvalidType", {})
except EntityTypeNotFoundError as e:
print(f"Type {e.entity_type} not in {e.profile}")
# Catch all metaseed errors
try:
...
except MetaseedError as e:
print(f"Metaseed error: {e}")
Domain Objects¶
Entity¶
Represents a stored entity instance:
entity.id # Unique identifier
entity.entity_type # Type name (e.g., "Investigation")
entity.data # Field values dict
entity.parent_id # Parent entity ID or None
entity.label # Derived display label
entity.get("field", default) # Get field with default
entity["field"] # Get field (raises KeyError if missing)
EntityNode¶
Represents an entity in the tree hierarchy:
node.id # Unique identifier
node.entity_type # Type name
node.label # Display label
node.has_children # Boolean
node.children # List of child EntityNodes
node.parent_field # Parent field holding this node (ADR 006), or None
node.to_dict() # Convert to dictionary
FieldInfo¶
Describes an entity field:
field.name # Field name
field.type # Type string (e.g., "string", "list")
field.required # Boolean
field.description # Human-readable description
field.ontology_term # Optional ontology reference
field.items # Item type for lists
field.constraints # Validation constraints dict
EntitySchema¶
Complete schema for an entity type:
schema.name # Entity name
schema.description # Description
schema.ontology_term # Optional ontology reference
schema.fields # Tuple of FieldInfo
schema.required_fields # Tuple of required field names
schema.optional_fields # Tuple of optional field names
schema.all_field_names # All field names
ValidationResult¶
Result of validation:
result.valid # Boolean
result.issues # List of ValidationIssue
result.error_count # Number of issues
bool(result) # True if valid
result.get_field_errors("field") # Filter issues by field
ValidationIssue¶
A single validation issue:
issue.field # The bare field name, e.g. "title"
issue.entity_id # Which entity instance the issue is about
issue.message # Error message
issue.rule # Which spec rule failed, e.g. "required_fields"
Comparison with ProfileFacade¶
| Feature | MetaseedClient | ProfileFacade |
|---|---|---|
| Primary use | Programmatic API | Interactive/Jupyter |
| Entity storage | Built-in | Built-in |
| Tab completion | No | Yes |
| Direct model creation | No | Yes (facade.Investigation(...)) |
| Schema introspection | get_entity_fields() |
helper.field_info() |
| Validation | validate() |
Manual via validators |
For interactive use in Jupyter notebooks, the ProfileFacade convenience functions (miappe(), isa(), etc.) may be more convenient due to their tab-completion support.