feature:contract

Table of contents

  1. Introduction
  2. How it works
  3. Writing the contract
  4. Declaring what the scan cannot see
  5. Declaring an optional dependency
    1. Imports under a guard
    2. Links under has_endpoint
    3. Optional service use
  6. Requiring a minimum release
  7. Keeping config.py and the settings in step
  8. Advanced
    1. Which copy it reads
  9. Reference
    1. Options
    2. What is scanned
    3. Errors and what to do
  10. Requirements
  11. 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

What feature:contract reads, and what a dry run and a write produce SCANNED, TESTS LEFT OUT __init__.py routes.py models.py services.py hooks.py signals.py commands.py docker/ and every import feature:contract 1 Infer what it provides and needs 2 Merge what you wrote by hand 3 Compare with pyproject.toml RESULT Dry run, the default prints the contract and what would change --write rewrites it, and keeps description, env, category, tags, the _manual lists, features_optional and every other section
The dry run shows exactly what a write would produce. A write replaces only what the scan infers.

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.

{% 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 .py file under tests/ 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/. --write needs the working copy.

See also


Back to top

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