feature:contract
Table of contents
- Introduction
- How it works
- Writing the contract
- Declaring what the scan cannot see
- Declaring an optional dependency
- Requiring a minimum release
- Keeping
config.pyand the settings in step - Advanced
- Reference
- Requirements
- See also
Introduction
feature:contract reads your feature’s source code and infers its contract, what it provides, what it requires and what other features may extend. It compares that with the [tool.splent.contract] in the feature’s pyproject.toml, and rewrites it when you pass --write. Run it after you add a route, a hook slot or a dependency, so other features and product:validate read an accurate contract.
splent feature:contract notes
feature:contract — splent_feature_notes
────────────────────────────────────────────────────────────
🔍 Scanning source code…
Inferred contract:
[tool.splent.contract]
archetype = "full"
[tool.splent.contract.provides]
routes = ["/notes", "/notes/<int:note_id>/delete"]
blueprints = ["notes_bp"]
models = ["Notes"]
commands = []
hooks = ["layout.authenticated_sidebar"]
services = ["NotesService"]
signals = []
translations = []
docker = []
[tool.splent.contract.requires]
features = ["auth"]
features_optional = []
env_vars = []
signals = []
[tool.splent.contract.extensible]
services = ["NotesService"]
templates = ["hooks/notes_sidebar.html", "notes/index.html"]
models = ["Notes"]
hooks = ["layout.authenticated_sidebar"]
routes = True
Changes detected:
~ extensible.routes: False → True
+ blueprints: notes_bp
Dry-run — run with --write to update pyproject.toml.
Without --write it only reads. It never bumps a version, tags or publishes.
How it works
The dry run merges what you wrote by hand as a write would, so the changes it lists are the ones a write makes.
Writing the contract
splent feature:contract notes --write
The last line becomes ✅ Contract written to pyproject.toml. An up-to-date contract is not rewritten, but the check is recorded, so feature:xray stops offering to refresh it.
A write replaces only what the scan infers. It keeps description, env, category, tags, the hand-kept lists below, your comments inside the contract’s tables, and every section the contract does not own, such as [tool.splent.config].
An entry of requires.features the code does not use goes on the next write, unless requires.features_manual lists it.
Declaring what the scan cannot see
[tool.splent.contract.requires]
features_manual = ["auth"]
[tool.splent.contract.provides]
routes_manual = ["/reports/<slug>"]
requires.features_manual holds dependencies no import shows, such as the feature that gives every account the role your role_required views check. provides.routes_manual holds routes built at registration time from configuration. Each joins its inferred list on every write.
Declaring an optional dependency
[tool.splent.contract.requires]
features_optional = ["reviews"]
A feature that works without another one, and uses it when the product has it, lists it in requires.features_optional. product:validate needs no UVL constraint for it.
The scan adds the ones it finds to what you wrote, in five forms.
| Form | Example | Resolved to |
|---|---|---|
| A service used only where its absence is tolerated | service_proxy("ReviewsService") inside a tolerant try:, see below |
The feature that registers the service |
| A feature imported where its absence is tolerated | An import or importlib.import_module(...) inside a try: that catches ImportError, ModuleNotFoundError, Exception or everything, or under a guard |
The feature the module path names |
| A page linked only where it is served | url_for("reviews.index") under a has_endpoint |
The feature the blueprint is named after |
| A signal of another feature it listens to | @connect_signal("note-submitting", ...) |
The feature that defines the signal |
| A slot of another feature it fills | register_template_hook("shop.admin.form.fields", ...) |
The feature whose templates open the slot |
An entry stays until you delete it, and moves to requires.features when the feature comes to require it. To drop one, delete it and run feature:contract --write. If the code still uses that feature in a tolerant way, the write adds it back. One hard use anywhere makes a feature required.
Imports under a guard
if "splent_feature_gallery" in current_app.extensions:
gallery = importlib.import_module("splent_io.splent_feature_gallery.services")
An import under an if that asks whether the product has the feature, by its package name, is optional. not in guards the else, and guards with both sides and or with neither. importlib.util.find_spec(...) is no guard, since it only says the package is installed.
The module must spell out the feature’s own segment, as a literal, a constant or an f-string such as f"splent_io.splent_feature_gallery.{module}". A dependency only known at run time goes in requires.features_manual, or in [tool.splent.test] features when only the tests need it.
Links under has_endpoint
{% if has_endpoint("reviews.index") %}
<a href="{{ url_for('reviews.index') }}">{{ _("Reviews") }}</a>
{% endif %}
A template that links to another feature’s page only where the product serves it works without that feature. The guard also holds in an elif, in the else of {% if not has_endpoint(...) %} and in url_for(...) if has_endpoint(...) else "#". A has_endpoint on one page of a blueprint guards every link to that blueprint, and or guards nothing.
Optional service use
reviews = service_proxy("ReviewsService")
try:
stars = reviews.average_for(product_id)
except KeyError:
stars = None
Using a service_proxy looks the service up, and an absent service raises KeyError. A use is optional inside a try: whose handler catches it (bare except:, Exception, BaseException, LookupError or KeyError) and carries on. A handler that ends with raise, with no return, break or continue before it, tolerates nothing. reviews is None is not a use, if reviews: is.
Requiring a minimum release
[tool.splent.contract.requires]
features = ["notes>=1.20.0", "gallery"]
features_optional = ["reviews~=1.3"]
When your feature calls an API another feature added in a given release, write a PEP 440 specifier after its name, in any of the three lists. A write keeps it while the name stays. When two lists disagree, features_manual wins over features, and features over features_optional.
product:validate checks each specifier against the release the product pins. A specifier that is not PEP 440 stops the command with exit code 1.
❌ These requirements are not valid, so the contract cannot be written:
requires.features: 'notes>=soon' carries '>=soon', which is not a PEP 440 version specifier such as >=1.20.0
Write a short name with an optional PEP 440 specifier, such as "notes>=1.20.0".
Keeping config.py and the settings in step
splent feature:contract notes --write --inject-config
After a write, a missing config.py, or one that misses a variable the feature reads, gets an offer to run feature:inject-config. --inject-config runs it without asking and --no-inject-config never does. Without a terminal and without either flag, the command only says how to run it.
A hand-written config.py is never regenerated. Add the variables it names by hand, or run feature:inject-config <feature> --force.
On every run a few lines may follow the contract.
⚠ 1 setting(s) read but not declared in [tool.splent.config]: NOTES_PAGE_SIZE
ℹ 1 setting(s) read from app.config that no feature sets (requires.config_keys): SITE_NAV
ℹ 3 parameters declared in [tool.splent.config_schema]
| Line | What to do |
|---|---|
read but not declared |
Add each variable to [tool.splent.config] with its default, so a product can override it. Credentials and framework variables are never asked for |
no feature sets |
A product sets them in its config.py. To make a scalar one a parameter, read it with os.getenv in the feature’s config.py |
parameters declared |
Followed by unusable declarations and by changes since the latest release that break a product. feature:release then needs a major version (a minor one below 1.0.0) |
Advanced
Which copy it reads
notes, splent_feature_notes, splent-io/notes and splent_io/splent_feature_notes name the same feature. The namespace defaults to splent_io.
| You give | It reads |
|---|---|
notes |
The working copy at the workspace root, <workspace>/splent_feature_notes/ |
notes, with no working copy |
An unversioned copy in .splent_cache/features/splent_io/, then the snapshot the selected product pins, else the highest version |
splent-io/notes@v1.2.7 |
That exact snapshot in the cache |
A copy in the cache can be read but not written. To change its contract, run feature:unlock to get a working copy.
Every
.pyfile undertests/is left out of the scan, so a test that imports another feature to build its scenario does not make it a requirement.
Reference
Options
| Option | Description |
|---|---|
FEATURE_REF |
The feature, as in Which copy it reads |
--write |
Write the inferred contract to pyproject.toml |
--inject-config / --no-inject-config |
With --write, run feature:inject-config without asking, or never |
What is scanned
| Field | Source |
|---|---|
archetype |
full with models or migrations, light with routes, service with services, else config |
provides.routes |
@<bp>.route(...) in routes.py |
provides.blueprints |
BaseBlueprint(...), Blueprint(...) or create_blueprint(...) assigned in __init__.py |
provides.models |
class <Name>(db.Model) in models.py |
provides.commands |
@click.command("name") in commands.py |
provides.commands_help |
Each command’s usage and the first line of its short_help, help or docstring |
provides.hooks |
register_template_hook("slot", func) in hooks.py |
provides.services |
BaseService classes in services.py, and register_service(...) or register_<kind>_provider(...) in __init__.py |
provides.docker |
docker-compose*.yml and *.yaml in docker/ |
provides.signals |
define_signal() in signals.py |
provides.translations |
Locale directories in translations/ |
requires.features |
Imports of other features that do not tolerate their absence, service_proxy(...) calls and ForeignKey(...) targets resolved to their feature |
requires.features_optional |
The five optional forms |
requires.env_vars |
os.getenv(...) and os.environ[...] |
requires.config_keys |
Keys read from app.config or with param(...) that no feature sets or declares |
requires.signals |
connect_signal() in signals.py |
extensible.* |
Its services, models and templates, the slots it opens, and routes = True when it has a blueprint |
A service, table, signal or slot becomes a dependency only when exactly one feature in reach provides it.
Errors and what to do
| Message | What to do |
|---|---|
<dir> is a copy in .splent_cache, which products use as fetched and which is not edited in place. |
--write refuses a cached copy. Run splent feature:unlock <name> and write the working copy’s contract |
Feature not found: <name> |
Run the splent feature:clone the message suggests |
Error: pyproject.toml is not valid TOML (<path>) |
Fix the file. Nothing was written |
These requirements are not valid, so the contract cannot be written |
Fix the specifier |
These revision ids are longer than the 32 characters alembic_version.version_num holds |
The upgrade would run the migration and fail to record it. Shorten the revision id and every down_revision that names it. feature:release refuses it too |
Could not write <path>: <error> |
Check the file’s permissions |
Every error exits 1. A dry run, a write and an up-to-date contract exit 0.
Requirements
- A selected product (
SPLENT_APP). - The feature at the workspace root or in
.splent_cache/features/.--writeneeds the working copy.
See also
feature:releasewrites the contract before every release.product:validatechecks the contracts of a whole product.feature:xrayshows refinements and hook usage.export:pumldraws diagrams from contracts.- Feature contract describes the format.