Extending YAFOWIL - Custom Blueprints#
This chapter explains how to create custom blueprints for YAFOWIL.
Blueprint Anatomy#
A blueprint is a collection of callables organized in five chains:
- extractors
Get, convert, and validate data from the request.
- edit_renderers
Generate HTML for edit mode.
- display_renderers
Generate HTML for display/read-only mode.
- preprocessors
Prepare runtime data before extraction/rendering. Rarely needed.
- builders
Modify widget after creation. Rarely needed.
Most custom blueprints only need extractors and renderers. Across 63 blueprint registrations in the YAFOWIL ecosystem, only 2 use builders and none use custom preprocessors.
Chains execute in specific orders:
Extractors: right-to-left (innermost first)
Renderers: left-to-right (outermost first)
Preprocessors: left-to-right
Writing Extractors#
An extractor validates and transforms data from the request.
Signature:
def my_extractor(widget, data):
# Access previous extractor's result
value = data.extracted
# Access request data
raw = data.request.get(widget.dottedpath)
# Validate and transform
if not valid(value):
raise ExtractionError('Invalid value')
return transformed_value
Key points:
Return the extracted/transformed value
Access previous result via
data.extractedRaise
ExtractionErrorfor validation failuresUse
abort=Trueto stop the chain,abort=Falseto continue
from yafowil.base import ExtractionError
def validate_positive(widget, data):
value = data.extracted
if value is not None and value < 0:
raise ExtractionError('Must be positive', abort=False)
return value
Writing Renderers#
Renderers generate HTML output.
Signature:
def my_renderer(widget, data):
# Access HTML tag helper
tag = data.tag
# Access previous renderer's output
inner = data.rendered
# Build HTML
return tag('div', inner, class_='wrapper')
Edit vs Display Renderers#
edit_renderers: Generate form inputs for editing
display_renderers: Generate read-only display markup
Both have the same signature but different purposes.
Utility Functions#
from yafowil.utils import (
attr_value, # Get widget attribute, call if callable
cssid, # Generate CSS ID
cssclasses, # Build CSS class string
)
def my_renderer(widget, data):
tag = data.tag
max_val = attr_value('max_value', widget, data, default=100)
return tag('input',
type='number',
max=max_val,
id=cssid(widget, 'input'),
class_=cssclasses(widget, data),
)
The @managedprops Decorator#
The @managedprops decorator declares which properties a callable uses.
This enables automatic documentation generation.
from yafowil.utils import managedprops
@managedprops('max_value', 'min_value', 'class')
def range_extractor(widget, data):
value = data.extracted
max_val = attr_value('max_value', widget, data, default=100)
min_val = attr_value('min_value', widget, data, default=0)
if value < min_val or value > max_val:
raise ExtractionError(f'Value must be between {min_val} and {max_val}')
return value
Apply to both extractors and renderers that use widget properties.
Documenting Properties#
Register documentation for auto-generated reference:
from yafowil.base import factory
# Blueprint description
factory.doc['blueprint']['mywidget'] = """\
Custom widget for special input.
"""
# Property descriptions
factory.doc['props']['mywidget.max_value'] = """\
Maximum allowed value.
"""
# Default values
factory.defaults['mywidget.max_value'] = 100
The @managedprops decorator links properties to their callables,
enabling the documentation system to show which functions use each property.
Registering Blueprints#
Register with the factory:
factory.register(
'mywidget',
extractors=[my_extractor],
edit_renderers=[my_edit_renderer],
display_renderers=[my_display_renderer],
)
Name restrictions: Blueprint names cannot contain *, :, or #.
Complete Example: Rating Widget#
A practical example implementing a star-rating input.
Specification#
Renders 1-5 radio buttons as stars for edit mode
Displays filled/empty stars for display mode
Extracts integer 1-5 from request
Validates value is in valid range
Implementation#
from yafowil.base import ExtractionError
from yafowil.base import factory
from yafowil.common import generic_extractor
from yafowil.utils import attr_value
from yafowil.utils import cssclasses
from yafowil.utils import cssid
from yafowil.utils import managedprops
@managedprops('max_stars')
def rating_extractor(widget, data):
"""Validate rating is within allowed range."""
value = data.extracted
if value in (None, ''):
return None
try:
value = int(value)
except (ValueError, TypeError):
raise ExtractionError('Invalid rating value')
max_stars = attr_value('max_stars', widget, data, default=5)
if not 1 <= value <= max_stars:
raise ExtractionError(f'Rating must be between 1 and {max_stars}')
return value
@managedprops('max_stars', 'class')
def rating_edit_renderer(widget, data):
"""Render rating as radio button group."""
tag = data.tag
max_stars = attr_value('max_stars', widget, data, default=5)
current = data.value if data.value is not None else 0
name = widget.dottedpath
inputs = []
for i in range(1, max_stars + 1):
checked = 'checked' if i == current else None
inp = tag('input',
type='radio',
name=name,
value=str(i),
checked=checked,
)
label = tag('label', inp, f' {i} star{"s" if i > 1 else ""}')
inputs.append(label)
return tag('div',
'\n'.join(inputs),
id=cssid(widget, 'rating'),
class_=cssclasses(widget, data),
)
@managedprops('max_stars')
def rating_display_renderer(widget, data):
"""Render rating as filled/empty stars."""
tag = data.tag
max_stars = attr_value('max_stars', widget, data, default=5)
value = data.value if data.value is not None else 0
stars = []
for i in range(1, max_stars + 1):
star = '\u2605' if i <= value else '\u2606' # ★ or ☆
stars.append(star)
return tag('span', ''.join(stars), class_='rating-display')
# Register the blueprint
factory.register(
'rating',
extractors=[generic_extractor, rating_extractor],
edit_renderers=[rating_edit_renderer],
display_renderers=[rating_display_renderer],
)
# Document the blueprint
factory.doc['blueprint']['rating'] = """\
Star rating input widget. Renders radio buttons for selection
and displays filled/empty stars in display mode.
"""
factory.defaults['rating.max_stars'] = 5
factory.doc['props']['rating.max_stars'] = """\
Maximum number of stars (default 5).
"""
Tests#
import unittest
from yafowil.base import factory
class TestRatingBlueprint(unittest.TestCase):
def test_edit_renderer_empty(self):
widget = factory('rating', name='score')
html = widget()
self.assertIn('id="rating-score"', html)
self.assertIn('type="radio"', html)
self.assertIn('name="score"', html)
# No radio should be checked
self.assertNotIn('checked', html)
def test_edit_renderer_with_value(self):
widget = factory('rating', name='score', value=3)
html = widget()
self.assertIn('value="3" checked', html)
def test_display_renderer(self):
widget = factory('rating', name='score', value=3, props={
'mode': 'display',
})
html = widget()
self.assertIn('\u2605\u2605\u2605\u2606\u2606', html) # ★★★☆☆
def test_extractor_valid(self):
widget = factory('rating', name='score')
data = widget.extract({'score': '4'})
self.assertEqual(data.extracted, 4)
self.assertFalse(data.errors)
def test_extractor_empty(self):
widget = factory('rating', name='score')
data = widget.extract({'score': ''})
self.assertIsNone(data.extracted)
self.assertFalse(data.errors)
def test_extractor_invalid_type(self):
widget = factory('rating', name='score')
data = widget.extract({'score': 'abc'})
self.assertTrue(data.errors)
self.assertIn('Invalid rating', str(data.errors[0]))
def test_extractor_out_of_range(self):
widget = factory('rating', name='score')
data = widget.extract({'score': '10'})
self.assertTrue(data.errors)
self.assertIn('must be between', str(data.errors[0]))
def test_custom_max_stars(self):
widget = factory('rating', name='score', props={'max_stars': 10})
data = widget.extract({'score': '8'})
self.assertEqual(data.extracted, 8)
self.assertFalse(data.errors)
Packaging for Distribution#
Structure your package in the yafowil.widget.* namespace:
yafowil.widget.rating/
├── pyproject.toml
└── src/
└── yafowil/
└── widget/
└── rating/
├── __init__.py
└── widget.py
Entry Point Registration#
In pyproject.toml, register the entry point:
[project.entry-points."yafowil.plugin"]
"yafowil.widget.rating" = "yafowil.widget.rating:register"
In __init__.py, define the register function with the @entry_point
decorator. This function registers both blueprints and resources:
import os
import webresource as wr
from yafowil.base import factory
from yafowil.utils import entry_point
resources_dir = os.path.join(os.path.dirname(__file__), 'resources')
# Define resources (optional - only if widget needs JS/CSS)
resources = wr.ResourceGroup(
name='yafowil.widget.rating',
directory=resources_dir,
path='yafowil-rating'
)
resources.add(wr.ScriptResource(
name='yafowil-rating-js',
directory=resources_dir,
path='yafowil-rating',
resource='widget.js',
compressed='widget.min.js'
))
resources.add(wr.StyleResource(
name='yafowil-rating-css',
directory=resources_dir,
path='yafowil-rating',
resource='widget.css',
compressed='widget.min.css'
))
@entry_point(order=10)
def register():
# Import triggers blueprint registration in widget.py
from yafowil.widget.rating import widget # noqa
# Register resources for default theme (optional)
factory.register_resources('default', 'yafowil.widget.rating', resources)
This ensures your blueprint is registered when yafowil.loader is imported.