Refinement
Table of contents
Introduction
A refinement feature changes another feature without forking it. It can extend models, override services, templates and hooks, and add routes. It declares all of it in [tool.splent.refinement], and the framework checks the declarations before anything loads.
Here notes_tags adds a tags column to the Notes model of notes and overrides its service.
# splent_feature_notes_tags/pyproject.toml
[tool.splent.refinement]
refines = "splent_feature_notes"
[tool.splent.refinement.extends]
models = [{ target = "Notes", mixin = "NotesTagsMixin" }]
[tool.splent.refinement.overrides]
services = [{ target = "NotesService", replacement = "NotesServiceWithTags" }]
# splent_feature_notes_tags/__init__.py
from splent_framework.refinement import refine_model, refine_service
from .models import NotesTagsMixin
from .services import NotesServiceWithTags
def init_feature(app):
refine_model("Notes", NotesTagsMixin)
refine_service(app, "NotesService", NotesServiceWithTags)
splent_feature_notes does not change. The refinement tutorial builds this refiner step by step.
Refinement is the last of three ways to change a feature. Most changes are better made by composing, through the base’s slots and services. Read Refine, extend or compose first.
How the app applies a refinement
product:validate runs the checks of steps 2 and 3 before boot.Each refiner loads after the feature it refines, even when it is listed first. A refiner refines a base that refines nothing, so chains are not supported.
Extending a model
# splent_feature_notes_tags/models.py
from splent_framework.db import db
class NotesTagsMixin:
tags = db.Column(db.String(500), nullable=True, default="")
def get_tags_list(self):
if not self.tags:
return []
return [t.strip() for t in self.tags.split(",") if t.strip()]
The framework applies every mixin declared under extends.models when the refiner loads, before its init_feature. It injects columns (db.Column, or mapped_column with an explicit type), relationships, @declared_attr, methods, properties, classmethods and staticmethods. Anything else is skipped with a warning in the log.
A mixin cannot overwrite what is already there. Startup stops with FeatureError when
- another refiner already added the attribute (
attribute-twice). Rename it in one mixin, or declare the two features incompatible in the UVL with!(a & b), - the base model defines it (
shadows-base). A method or property may replace the base’s only when marked with@typing.override. A column or relationship never can.
from typing import override
class NotesTagsMixin:
@override
def summary(self): # replaces the base's summary on purpose
return f"{self.title} [{self.tags}]"
@property
@override # below @property, so the mark survives
def badge(self):
return "Tagged" if self.tags else ""
Overriding a service
refine_service(app, "NotesService", NotesServiceWithTags)
refine_service builds a class that inherits from both, so super() works. Routes that call service_proxy("NotesService") get the merged class. Declare the service in overrides.services, or startup stops. Two refiners of one service are an error. See Service locator.
Overriding a template
Put the replacement at the same path and declare it.
[tool.splent.refinement.overrides]
templates = [{ target = "shop/checkout_form.html" }]
splent_feature_shop_coupons/templates/shop/checkout_form.html
The framework serves only the declared paths from the refiner, ahead of the base. Your product’s own templates still come first. A file at a base’s path that you do not declare stops startup (undeclared-template), and so does a declared override you do not ship (missing-template).
Prefer a slot when you can. Your copy must follow every change the base makes, and it hides every slot the base opens there. See Refine, extend or compose.
Replacing a hook
# splent_feature_shop_coupons/hooks.py
from splent_framework.hooks.template_hooks import replace_template_hook
replace_template_hook("layout.navbar.authenticated", navbar_with_coupon_badge)
replace_template_hook takes the base’s own callbacks out of the slot and adds yours. Other features’ callbacks stay. Declare the slot in overrides.hooks, or startup stops. See Template hooks.
Adding routes
[tool.splent.refinement.extends]
routes = [{ blueprint = "shop", module = "routes_coupons" }]
# splent_feature_shop_coupons/routes_coupons.py
from splent_io.splent_feature_shop import shop_bp
@shop_bp.route("/checkout/coupon", methods=["POST"])
def apply_coupon():
...
The module is imported right before the base registers its blueprint, since Flask takes no new routes after that. A rule that repeats a URL of the base, or of another refiner, for the same method stops startup (duplicate-route).
Migrating the columns you add
The refiner owns the migrations of the columns it injects. Migrate the base first, then generate the refiner’s migration and review it.
splent db:upgrade splent_feature_shop
splent db:migrate splent_feature_shop_coupons
The refiner’s migration adds the columns, indexes and foreign keys its mixins inject. It never creates or drops the base table, and the base’s autogenerate never includes the refiner’s columns.
When you remove a column from a mixin, write its
drop_columnby hand in the refiner’s migration, and take the matching drop out of the base feature’s next migration.
Requiring the base in the UVL
shop_coupons => shop
The constraint keeps the line honest, so no product selects a refiner without its base. The load order does not depend on it. The framework always loads, migrates and seeds a refiner after its base, which feature:order shows.
When two refiners collide
Several refiners of one base are fine as long as they do not claim the same thing. Before any feature loads, the framework checks them all and stops with one FeatureError listing every collision.
The refiners of this product collide, so it cannot start.
- splent_feature_bundles and splent_feature_sales both override the template shop/list.html of splent_feature_shop. Only one refiner may override it. Keep the override in one of them, or declare them incompatible in the UVL with !(bundles & sales).
product:validate runs the same rules before boot. Validation lists them.
A full example
splent_feature_collections is a content feature of its own that also refines splent_feature_shop, so shop items can be grouped into collections. Its only refinement is a model extension.
[tool.splent.refinement]
refines = "splent_feature_shop"
[tool.splent.refinement.extends]
models = [{ target = "Item", mixin = "ItemCollectionMixin" }]
Everything it shows on the shop pages goes through the slots shop opens, filled from its hooks.py. Three details are worth copying.
- The mixin adds a nullable foreign key with
ON DELETE SET NULL, so removing a collection keeps its items. - The base admin form saves every column of
Item, so the selector the refiner puts in the form slot needs no route of its own. - One context object per request serves every slot callback, and queries only when asked.
Refine, extend or compose walks through the choice.
Advanced
- Early imports. To check collisions, the framework imports each refiner’s and base’s
modelsmodule before features load. Keep a refiner’s package__init__free of work that needs the base, and do it ininit_feature. - Injected columns are tagged. Each carries
info["splent_refiner"]. Passrefiner=toapply_model_mixinto set it yourself. - Undeclared calls. A
refine_modelcall no refinement declares looks the model up by class name across the product and logs a warning asking you to declare it.
See also
- Validation, the declaration rules and collisions
- Extension points, what a base lets others override
- Template hooks, the hook system