Extension Packaging & Extraction¶
Every extension is its own wheel — built independently of the engine and activated from the installed wheel via its entry point, with no edit to core. This page documents the packaging pattern, originally proven by two extraction pilots and since applied to the whole first-party set:
ext_twilio— the trivial pilot (SMS strategy,.py-only surface).ext_excise_engine— the hard proof: every contribution surface (models, per-ext Alembic migrations, admin Svelte, GraphQL, Temporal workflows/schedules, search indexes, nav/settings/perms/lifecycle).
Where this fits
- Authoring contract (manifests, contribution surfaces): Building Extensions.
- Third-party / commercial extension authoring: Building Extensions (External Developers).
- The import surface & contract suites: Extension SDK & Testkit.
- Per-extension migration chains: Migrations.
Repo topology today — extensions live out-of-tree
Every ext_* package has graduated out of the core repo. The 48
first-party extensions live in the sibling
vectiscommerce/enterprise-extensions
repo; the core's backend/vectis/extensions/ directory contains only the
namespace __init__.py. Each extension carries its own pyproject.toml
next to its code and builds into a standalone wheel that pip-installs and
activates on its own — the entry point is declared only there, not in
the engine's pyproject.toml. In development the core discovers a checkout
via the $VECTIS_DEV_EXTENSIONS dev-link (see
Building Extensions).
PyPI publishing and namespace-package decoupling are not yet shipped
(see Not Yet Shipped).
An extension as a standalone-installable package¶
The per-ext pyproject.toml is the extension's independent installable
identity. Building it produces a wheel (e.g. vectis_ext_twilio-0.1.0) that,
when pip installed alongside the engine, drops the leaf
vectis/extensions/ext_<name>/ package into site-packages and registers the
extension's vectis.extensions entry point — so discover_and_load activates
it via importlib.metadata without the on-disk ext_* package scan.
[project]
name = "vectis-ext-twilio"
version = "0.1.0"
requires-python = ">=3.12"
license = { text = "MIT" }
dependencies = [
"vectis-sdk>=0.1", # the open API surface; pin to the core version you target
"httpx>=0.28",
]
# Discovered by the core via this entry-point group (importlib.metadata) when
# the wheel is installed. In dev, $VECTIS_DEV_EXTENSIONS discovery uses the
# same `vectis.extensions.ext_twilio.extension:extension` object.
[project.entry-points."vectis.extensions"]
twilio = "vectis.extensions.ext_twilio.extension:extension"
What the wheel depends on¶
The wheel is not a vectis-free distribution at runtime. Every extension
module imports vectis_sdk.* — so the extension declares a dependency on the
public vectis-sdk (Apache-2.0), pinned to the core version it was built
against, not on the proprietary vectis engine (which isn't publicly
installable). Importing the SDK is free, but resolving its symbols needs the
engine present — an extension only ever runs in a core-present environment
(cloud, licensed self-host, or the developer distribution), where the licensed
core supplies the engine behind the facade. vectis_sdk / vectis_testkit
themselves carry the engine pin in an optional [engine] extra so their
public PyPI wheels resolve without the non-public engine.
The version cascade¶
The co-shipped satellite distributions — vectis_sdk and vectis_testkit —
pin the engine exactly (in their [engine] extra). A bump that updates
backend/pyproject.toml but forgets a satellite would publish a wheel that can
never resolve against its matching engine. Extension wheels sit outside the
lockstep: they pin vectis-sdk to the core version they target and cut their
own releases from the enterprise-extensions repo (a core version bump may
require a matching extension release). The tools that keep the core-side
cascade consistent:
scripts/check_version_cascade.py(run asmake check-version-cascade, part ofmake check) reads the engine version frombackend/pyproject.tomland, for every satellitepyproject.toml, checks that itsvectis==Xpin equals the engine version. It additionally checks that the co-shipped packages' ownversionequals the engine version, sincevectis_sdk/vectis_testkitride inside the engine wheel via[tool.hatch.build.targets.wheel] packages.scripts/bump_version.pyperforms the lockstep bump: one command rewrites the engine version and every satellite's ownversionand everyvectis==Xpin to a single new value, preserving file formatting.
python scripts/bump_version.py 0.2.0 # apply
python scripts/bump_version.py 0.2.0 --check # dry run; exit 1 if anything would change
make release-checkis the pre-publish dry run of the open surface: it asserts the version cascade, then builds the publishable open wheels (vectis_sdk+vectis_testkit) in an ephemeral host venv, runstwine checkon the metadata, and emits a CycloneDX SBOM (best-effort). Extension wheels build from their own repo instead (see Building the wheel). Becausebuildneeds network for an isolated build env, it is not part ofmake check— but the load-bearingcheck-version-cascadeis.
Version-pin drift is enforced in the gate
Bumping the engine version bumps every satellite pin in lockstep.
check-version-cascade fails make check if the engine version moves
without a satellite pin (or a co-shipped package's own version) following.
Use scripts/bump_version.py rather than hand-editing pins.
Publishing the open surface (release.yml)¶
Publishing runs from a dormant GitHub Actions workflow,
.github/workflows/release.yml. It publishes only the open
developer surface — vectis-sdk + vectis-testkit (Apache-2.0), and later
vectis-ext-example. The proprietary vectis engine is never published to
public PyPI; it deploys to the SaaS via the prod-image pipeline (a private
licensed index for self-host arrives later).
The workflow is deliberately inert until both are true:
- A PyPI Trusted Publisher (OIDC, no stored API token) is provisioned for the packages, and
- the org/repo variable
vars.ENABLE_PYPI_PUBLISH == 'true'is set.
Until then, every job is gated off by its if: guard, so a manual dispatch is
a no-op. It is also the only re-introduction of GitHub Actions since the CI
suite was disabled — kept to a single opt-in, publish-only workflow; the
day-to-day gate remains make check run locally.
The include globs — one per contribution surface¶
The wheel ships only the leaf vectis/extensions/ext_<name>/ package. It
does not ship the parent vectis/__init__.py or
vectis/extensions/__init__.py — the engine wheel supplies those, and both
co-install into the same site-packages tree so the leaf merges into the
engine's package tree. sources remaps the flat-on-disk files into the nested
import path the engine expects; the pyproject.toml itself is excluded (it is
build input, not shipped code).
[tool.hatch.build.targets.wheel.sources]
"" = "vectis/extensions/ext_excise_engine"
[tool.hatch.build.targets.wheel]
exclude = ["__pycache__", "pyproject.toml"]
The extraction-pilot lesson: include = [\"*.py\"] silently drops admin data
The trivial ext_twilio include was ["*.py", "py.typed"] — fine, because
its only surface is Python. ext_excise_engine carries non-.py
package data the trivial pilot never had, and a naive *.py-only include
silently drops it — the wheel builds green but the admin surface is
missing at runtime. The include list must therefore name one glob per
surface that ships non-.py files:
[tool.hatch.build.targets.wheel]
include = [
"*.py",
"migrations/**/*.py", # per-ext Alembic revisions
"admin/**/*.svelte", # admin slot components
"admin_pages/**/*.svelte", # admin route
"admin_pages/**/*.ts", # admin route loader
]
exclude = ["__pycache__", "pyproject.toml"]
Rule of thumb: the include globs must match the extension's contribution
surfaces. Because all data lives inside the leaf, plain recursive globs
reach it — no hatch force-include or artifacts needed. Migration .py
revisions must ship because the per-extension migration runner resolves them
from the installed path via importlib.resources + Alembic ScriptDirectory;
the admin .svelte/.ts must ship because sync_admin_extensions reads
them as bytes from the installed dist.
The engine wheel ships no extension code¶
The engine wheel packages vectis, vectis_sdk, and vectis_testkit —
and since the graduation, vectis/extensions/ holds only the namespace
__init__.py, so no extension code (and no per-ext pyproject.toml) rides
inside the engine dist:
[tool.hatch.build.targets.wheel]
packages = ["vectis", "vectis_sdk", "vectis_testkit"]
exclude = ["vectis/extensions/*/pyproject.toml"] # residual guard from the in-tree era
Building the wheel¶
An extension wheel builds from the extension repo with standard tooling — each
ext_<name>/ directory is its own build root:
cd enterprise-extensions
python -m pip install build
python -m build --wheel ext_twilio
# → dist/vectis_ext_twilio-0.1.0-py3-none-any.whl
List the wheel's contents (python -m zipfile -l dist/*.whl) to eyeball that
every expected surface shipped — the cheapest guard against the *.py-only
drop above. The enterprise-extensions CI builds every extension's wheel on
push.
Entry-point discovery from an installed package¶
Installed standalone, the extension activates through the same path as a
dev-linked one: discover_and_load enumerates
importlib.metadata.entry_points(group="vectis.extensions") and imports each
target. The entry point lives only in the extension's own pyproject.toml,
and $VECTIS_DEV_EXTENSIONS discovery loads the same
vectis.extensions.ext_<name>.extension:extension object — so wheel-install
and dev-link discovery can never drift to different modules.
Proven in the extraction pilots: with the engine wheel + the ext wheel
co-installed into a single venv, every surface resolves from site-packages
with cwd=/ (i.e. nowhere near the extensions source dir) — models,
migrations, admin Svelte, GraphQL, Temporal, search,
nav/settings/perms/lifecycle all load. No core file was edited to make any
of this work.
Known constraints¶
Editable-engine dev trap
A wheel-installed leaf merges into the engine tree only when co-located in
the same site-packages — the parents (vectis/, vectis/extensions/) are
regular packages, not PEP 420 namespace packages. So a standalone ext
wheel does not merge into an editable (pip install -e) engine
checkout. For editable-engine development, use the $VECTIS_DEV_EXTENSIONS
dev-link instead (it grafts the checkout onto vectis.extensions.__path__);
to exercise the standalone path, co-install both as wheels into one env.
True external-repo namespace-package work is not yet shipped.
- Version drift — an extension's
vectis-sdkpin targets a specific core version; a core version bump may require a matching extension release (see the version cascade). - Migration runner reads via a filesystem path — pip unpacks the wheel to disk, so the per-extension migration runner resolves revisions fine; a zipapp / zip-import install would break it.
ee_*tables use the grandfathered allowlist —ext_excise_engine's tables (ee_tax_classes,ee_locations,ee_tax_regions,ee_tax_rates,ee_sync_log) predate theext_<name>_table-prefix rule and are grandfathered incheck_core_migrations_ext_tables.py, not renamed. New extensions use theext_prefix.extensions.lockstill gates the install set — adding, removing, or version-bumping a discovered extension (dev-linked or installed) requiresmake lock-extensions;make checkfails on drift.
Not Yet Shipped¶
The following are explicitly not shipped:
- PyPI publishing is authored but dormant —
release.ymlpublishes the open surface only once a Trusted Publisher is provisioned andENABLE_PYPI_PUBLISH == 'true'(see above). Extractedvectis-ext-*wheels publish via their own pipelines. - Namespace-package decoupling — making the
vectis/vectis.extensionsparents PEP 420 namespace packages so an out-of-tree ext wheel can merge without co-location in one site-packages. - Runtime entitlement enforcement — the
ExtensionEntitlementrecord is honor-system metadata + a warn-only boot advisory today; real per-instance enforcement arrives with licensed self-host. See Building Extensions (External Developers).
The external-repo home
All 48 first-party extensions live outside the core monorepo in the
vectiscommerce/enterprise-extensions
repo (proprietary commercial license). Not every extension there is itself
sellable — ext_excise_engine, for example, is an MIT-licensed
connector to the paid external Excise Engine service, not a commercial
extension. See Building Extensions (External Developers).