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

What the framework does with refiners when the app starts AT STARTUP, IN THIS ORDER 1 Order Each refiner loads after the feature it refines 2 Validate Each declaration against the base's extension points 3 Check collisions Every refiner against every other, before any feature loads 4 Load Mixins applied, routes added, init_feature runs 5 Finish Template overrides and hook replacements take effect FeatureError lists every problem, and the app does not start
Steps 2 to 4 stop the start on any problem, in strict and non-strict mode alike. 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_column by 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 models module before features load. Keep a refiner’s package __init__ free of work that needs the base, and do it in init_feature.
  • Injected columns are tagged. Each carries info["splent_refiner"]. Pass refiner= to apply_model_mixin to set it yourself.
  • Undeclared calls. A refine_model call no refinement declares looks the model up by class name across the product and logs a warning asking you to declare it.

See also


Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es