Validation
Parsing guarantees structure; validators enforce your rules on top of it. The registry supports three validator kinds, each running at a different point in the block lifecycle.
Three validation stages
| Stage | Registered with | Signature | Runs when |
|---|---|---|---|
| Metadata | registry.add_metadata_validator(block_type, fn) |
(raw: str, parsed: dict \| None) -> ValidationResult |
Metadata section completes, before content accumulates |
| Content | registry.add_content_validator(block_type, fn) |
(raw: str, parsed: dict \| None) -> ValidationResult |
Content section completes, before the final BlockEndEvent |
| Block | registry.add_validator(block_type, fn) or register(..., validators=[fn]) |
(block: ExtractedBlock) -> bool |
After the full block is parsed |
Validators of each kind run in registration order; the first failure stops the chain.
Early metadata validation
Metadata validators receive the raw metadata string and the parsed dict (or None if parsing failed) and return a ValidationResult:
def validate_required_fields(raw: str, parsed: dict[str, Any] | None) -> ValidationResult:
"""Validate that required metadata fields are present."""
if not parsed:
return ValidationResult.failure("No metadata parsed")
if "id" not in parsed:
return ValidationResult.failure("Missing required field: id")
return ValidationResult.success()
def validate_id_format(raw: str, parsed: dict[str, Any] | None) -> ValidationResult:
"""Validate that ID follows naming convention."""
if not parsed:
return ValidationResult.success() # Let other validator handle this
block_id = parsed.get("id", "")
if not block_id.startswith("ops-"):
return ValidationResult.failure(f"ID must start with 'ops-', got: {block_id}")
return ValidationResult.success()
ValidationResult is a small dataclass with two constructors:
Because metadata validators fire at the end of the metadata section, you can reject a block before its content streams in, useful for skipping large payloads you already know you don't want.
Failure modes
What happens after a metadata validation failure is controlled by the registry's metadata_failure_mode:
| Mode | Behavior |
|---|---|
MetadataValidationFailureMode.ABORT_BLOCK (default) |
Emit a BlockErrorEvent immediately and stop processing the block |
MetadataValidationFailureMode.CONTINUE |
Log a warning and keep processing content normally |
MetadataValidationFailureMode.SKIP_CONTENT |
Skip content accumulation and emit a partial block |
# Mode 1: ABORT_BLOCK (default) - stop processing on validation failure
registry = Registry(
syntax=syntax,
metadata_failure_mode=MetadataValidationFailureMode.ABORT_BLOCK,
)
registry.register("files_operations", FileOperations)
registry.add_metadata_validator("files_operations", validate_id_format)
processor = StreamBlockProcessor(registry)
# This block has an invalid ID (doesn't start with 'ops-')
text = dedent("""
!!start
---
id: invalid-id
block_type: files_operations
---
src/main.py:C
!!end
""").strip()
print("=== ABORT_BLOCK mode ===")
async for event in processor.process_stream(simple_text_stream(text)):
if isinstance(event, BlockErrorEvent):
print(f"Error: {event.reason}")
elif isinstance(event, BlockEndEvent):
print(f"Block extracted: {event.get_block()}")
Content and block validators
Content validators mirror metadata validators but run on the raw content when the content section ends. Block validators are plain predicates over the final ExtractedBlock; returning False rejects the block with a BlockErrorEvent (VALIDATION_FAILED).
Composing validators
All three kinds combine on a single block type:
def validate_has_description(raw: str, parsed: dict[str, Any] | None) -> ValidationResult:
"""First metadata validator: check for description."""
if parsed and "description" not in parsed:
return ValidationResult.failure("Missing 'description' in metadata")
return ValidationResult.success()
def validate_id_length(raw: str, parsed: dict[str, Any] | None) -> ValidationResult:
"""Second metadata validator: check ID length."""
min_id_length = 3
if parsed:
block_id = str(parsed.get("id", ""))
if len(block_id) < min_id_length:
return ValidationResult.failure(f"ID too short: {len(block_id)} < {min_id_length}")
return ValidationResult.success()
def validate_max_operations(block: ExtractedBlock[Any, Any]) -> bool:
"""General validator: limit number of operations."""
max_ops = 5
if hasattr(block.content, "operations"):
return len(block.content.operations) <= max_ops
return True
# Register metadata validators (run in order, first failure stops)
registry.add_metadata_validator("files_operations", validate_has_description)
registry.add_metadata_validator("files_operations", validate_id_length)
# Register content validators (run in order, first failure stops)
registry.add_content_validator("files_operations", validate_has_operations)
registry.add_content_validator("files_operations", validate_no_delete)
# Register general validator (runs after full block extraction)
registry.add_validator("files_operations", validate_max_operations)
A block must pass every registered validator to be delivered through BlockEndEvent.
Next steps
- Error Handling: react to
BlockErrorEventand its error codes. - Defining Custom Blocks: parsing, the stage before validation.
- Events:
BlockMetadataEndEventandBlockContentEndEventcarry the validation outcome.