StuffArtefact & Image Rendering
StuffArtefact is a thin delegation adapter that wraps Stuff objects for Jinja2 template access. The ImageRenderable protocol enables image extraction from nested content without circular imports between the template layer and domain layer.
Design Principle
Two problems, one pattern:
- Template Access: Templates need to access content fields via
{{ my_stuff.field_name }}without knowing Pydantic internals - Image Extraction: The
with_imagesfilter needs to traverse content hierarchies without importing concrete types
Solution: StuffArtefact delegates attribute access to underlying content. ImageRenderable uses @runtime_checkable Protocol for duck-typed image extraction.
StuffArtefact wraps Stuff → delegates to StuffContent → Protocol enables traversal
Protocol over Inheritance
ImageRenderable uses duck typing. Any class that defines a render_with_images() method satisfies the protocol, with no base class required. The filters check the class rather than the instance, so a value that merely holds such a method as an attribute is never rendered through it.
Template Access Patterns
| Pattern | Result |
|---|---|
{{ doc.title }} |
Content field value |
{{ doc.pages }} |
Nested content (list, struct, etc.) |
{{ combo.summary }} |
A named part of a Composite output |
{{ doc._stuff_name }} |
Metadata: variable name |
{{ doc._content_class }} |
Metadata: content class name |
{{ doc._concept_code }} |
Metadata: concept code |
{{ doc._stuff_code }} |
Metadata: stuff code |
{{ doc \| with_images }} |
Text with [Image N] tokens |
{{ doc \| tag }} |
Tagged output (no images) |
These are the only underscore names a template may read on an input. Templates render under the Template Sandbox, which refuses every other private name. The wrapped Stuff is not an attribute of the artefact at all: Python code reaches it through unwrap_stuff_artefact(artefact=...).
Image Extraction Behavior
| Content Type | render_with_images() Behavior |
|---|---|
ImageContent |
Self-registers, returns [Image N] |
ListContent |
Iterates items, recurses into nested |
TextAndImagesContent |
Renders text first, then images |
StructuredContent |
Iterates model fields, recurses into nested lists/tuples/dicts |
StuffArtefact |
Delegates to underlying content |
Architecture
flowchart TB
subgraph TEMPLATE["Jinja2 Template"]
VAR["{{ doc | with_images }}"]
end
subgraph FILTER["with_images Filter"]
direction TB
CHECK["ImageRenderable, defined by its class?"]
CALL["value.render_with_images(registry=registry, text_format=text_format)"]
CHECK --> CALL
end
subgraph ARTEFACT["StuffArtefact"]
DELEGATE["Delegates to _stuff.content"]
end
subgraph CONTENT["Content Implementations"]
direction TB
IMG["ImageContent: register + return token"]
LIST["ListContent: iterate items"]
TEXT["TextAndImagesContent: text then images"]
STRUCT["StructuredContent: iterate model_fields"]
end
subgraph REGISTRY["ImageRegistry"]
direction TB
STORE["Append to list (0-based)"]
DEDUP["Skip if URL exists"]
end
VAR --> CHECK
CALL --> DELEGATE
DELEGATE --> IMG & LIST & TEXT & STRUCT
IMG --> STORE
STORE --> DEDUP
StuffArtefact Implementation
Attribute Access Priority
StuffArtefact uses __getattribute__ to intercept all attribute access:
def __getattribute__(self, key: str) -> Any:
# 1. Passthrough: _stuff, methods, magic attributes
if key in _PASSTHROUGH_ATTRS or key.startswith("__"):
return object.__getattribute__(self, key)
# 2. Content fields, then 3. metadata fields (_stuff_name, _content_class, _concept_code, _stuff_code)
try:
return _get_template_value(stuff=stuff, key=key)
except KeyError:
# 4. Fallback to normal lookup
return object.__getattribute__(self, key)
_get_template_value is the whole of what a string key resolves to: a content field (a declared field, or a public extra field such as a Composite's named parts), else one of the four metadata fields, else KeyError.
Content Field Priority
Content fields shadow StuffArtefact methods. If your content has a field named items, accessing artefact.items returns the field value, not the iteration method. Use artefact.iter_items() for explicit dict-like iteration.
Dict-like Access
StuffArtefact supports bracket notation and iteration:
| Method | Purpose |
|---|---|
artefact["field"] |
Bracket access (via __getitem__): a content field or a metadata field only |
artefact.get("field", default=...) |
Safe access with default, resolving the same keys as brackets |
"field" in artefact |
Membership test |
artefact.iter_keys() |
Iterate field names |
artefact.iter_items() |
Iterate (key, value) pairs |
artefact.iter_values() |
Iterate values |
Bracket access and get never resolve to the artefact's own attributes, such as the wrapped Stuff or a method. The accessors above and the four metadata fields are what StuffArtefact declares as its template surface: the only methods a template may call on it and the only underscore names it may read.
ImageRenderable Protocol
Definition
from typing import Protocol, runtime_checkable
@runtime_checkable
class ImageRenderable(Protocol):
"""Protocol for types that can render with image extraction."""
def render_with_images(
self,
*,
registry: ImageRegistry,
text_format: TextFormat,
) -> str:
"""Render to string, registering images to the registry.
Returns:
String with [Image N] tokens where images appear.
"""
...
Why Protocol?
- Avoids circular imports:
tools/jinja2/can check types fromcore/stuffs/without importing them - Duck typing: No inheritance required—any matching method signature works
- Runtime checking:
isinstance(value, ImageRenderable)works at runtime, and the filters addtype_implements(value=value, protocol=ImageRenderable), which reads the members from the class, since on Python 3.11 theisinstancecheck alone accepts an instance merely holding the method
Content Implementations
ImageContent
Self-registers and returns a token:
def render_with_images(self, *, registry, text_format) -> str:
image_index = registry.register_image(self)
return f"[Image {image_index + 1}]"
ListContent
Iterates items, delegating to nested ImageRenderable objects:
def render_with_images(self, *, registry, text_format) -> str:
parts: list[str] = []
for item in self.items:
if isinstance(item, ImageRenderable):
rendered = item.render_with_images(registry=registry, text_format=text_format)
else:
rendered = item.rendered_markdown()
if rendered:
parts.append(rendered)
return "\n".join(parts)
TextAndImagesContent
Renders text first, then registers each image:
def render_with_images(self, *, registry, text_format) -> str:
parts: list[str] = []
if self.text:
parts.append(self.text.rendered_for_prompt(text_format=text_format))
if self.images:
for image in self.images:
image_index = registry.register_image(image)
parts.append(f"[Image {image_index + 1}]")
return "\n".join(parts)
StructuredContent
Default implementation for structured models iterates Pydantic model fields:
def render_with_images(self, *, registry, text_format) -> str:
parts: list[str] = []
for field_name in type(self).model_fields:
field_value = getattr(self, field_name)
if field_value is None:
continue
rendered = self._render_value_with_images(field_value, registry=registry, text_format=text_format)
if rendered:
parts.append(f"{field_name}: {rendered}")
return "\n".join(parts)
The _render_value_with_images helper recurses: a nested ImageRenderable gets render_with_images(...), plain lists/tuples/dicts are traversed item by item, a non-renderable StuffContent falls back to rendered_for_prompt(text_format=text_format), and anything else to str(value). A plain StuffContent subclass that is not a StructuredContent does not implement the protocol.
Image Numbering
Registry Behavior
| Operation | Result |
|---|---|
| First image registered | Returns index 0 |
| Second image registered | Returns index 1 |
| Same URL registered again | Returns existing index (deduplication) |
| Display in template | [Image {index + 1}] (1-based) |
Example
registry = ImageRegistry()
idx = registry.register_image(img_a) # idx=0 → [Image 1]
idx = registry.register_image(img_b) # idx=1 → [Image 2]
idx = registry.register_image(img_a) # idx=0 → [Image 1] (same URL)
len(registry.images) # 2 (deduplicated)
The with_images Filter
Execution Flow
@pass_context
def with_images(context: Context, value: Any, _: Any = None) -> str:
# 1. Validate input
if isinstance(value, Undefined):
raise Jinja2ContextError("Cannot use with_images on undefined")
# 2. Get/create registry from context
registry = context.get(Jinja2ContextKey.IMAGE_REGISTRY)
if registry is None:
registry = ImageRegistry()
# 3. Get text format: no fallback, a render without a templating style fails loudly
text_format = TextFormat(require_templating_style_value(context=context, jinja2_context_key=Jinja2ContextKey.TEXT_FORMAT))
# 4. Protocol-based rendering, licensed by the value's class, never by an attribute the instance holds
if isinstance(value, ImageRenderable) and type_implements(value=value, protocol=ImageRenderable):
return value.render_with_images(registry=registry, text_format=text_format)
# 5. Handle plain sequences
if isinstance(value, (list, tuple)):
return _render_sequence_with_images(value, registry=registry, text_format=text_format)
# 6. Reject unsupported types
raise Jinja2ContextError(f"{type(value).__name__} does not implement ImageRenderable")
Filter Order Matters
with_images must receive structured data to extract images. Place it before any filter that converts to string.
| Pattern | Works? |
|---|---|
{{ doc \| with_images }} |
Yes |
{{ doc \| with_images \| tag }} |
Yes |
{{ doc \| tag \| with_images }} |
No—tag stringifies first |
Syntax Quick Reference
| Pattern | Purpose |
|---|---|
{{ x.field }} |
Access content field |
{{ x["field"] }} |
Bracket access |
{{ x._stuff_name }} |
Variable name metadata |
{{ x._content_class }} |
Content class name |
{{ x._concept_code }} |
Concept code |
{{ x._stuff_code }} |
Stuff code |
{{ x \| with_images }} |
Extract images as tokens |
{{ x \| with_images \| tag }} |
Extract then wrap in tags |
Files Reference
| File | Purpose |
|---|---|
pipelex/core/stuffs/stuff_artefact.py |
Thin delegation adapter for Jinja2 |
pipelex/tools/jinja2/image_renderable.py |
@runtime_checkable Protocol for images |
pipelex/tools/jinja2/tag_renderable.py |
@runtime_checkable Protocol for tagging |
pipelex/tools/jinja2/jinja2_with_images_filter.py |
with_images filter |
pipelex/tools/jinja2/jinja2_filters.py |
tag and format filters |
pipelex/tools/jinja2/image_registry.py |
Image tracking with deduplication |
pipelex/core/stuffs/structured_content.py |
Field-iterating render_with_images() for structured models |
pipelex/core/stuffs/image_content.py |
Self-registering image token |
pipelex/core/stuffs/list_content.py |
List iteration for images |
pipelex/core/stuffs/text_and_images_content.py |
Text + images rendering |