Skip to content

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

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.

enterprise-extensions/ext_twilio/pyproject.toml
[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 as make check-version-cascade, part of make check) reads the engine version from backend/pyproject.toml and, for every satellite pyproject.toml, checks that its vectis==X pin equals the engine version. It additionally checks that the co-shipped packages' own version equals the engine version, since vectis_sdk / vectis_testkit ride inside the engine wheel via [tool.hatch.build.targets.wheel] packages.
  • scripts/bump_version.py performs the lockstep bump: one command rewrites the engine version and every satellite's own version and every vectis==X pin 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-check is 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, runs twine check on the metadata, and emits a CycloneDX SBOM (best-effort). Extension wheels build from their own repo instead (see Building the wheel). Because build needs network for an isolated build env, it is not part of make check — but the load-bearing check-version-cascade is.
make release-check

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 surfacevectis-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:

  1. A PyPI Trusted Publisher (OIDC, no stored API token) is provisioned for the packages, and
  2. 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).

hatchling wheel config (leaf-only, remapped import path)
[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:

ext_excise_engine — include one glob per surface
[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:

backend/pyproject.toml
[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-sdk pin 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 allowlistext_excise_engine's tables (ee_tax_classes, ee_locations, ee_tax_regions, ee_tax_rates, ee_sync_log) predate the ext_<name>_ table-prefix rule and are grandfathered in check_core_migrations_ext_tables.py, not renamed. New extensions use the ext_ prefix.
  • extensions.lock still gates the install set — adding, removing, or version-bumping a discovered extension (dev-linked or installed) requires make lock-extensions; make check fails on drift.

Not Yet Shipped

The following are explicitly not shipped:

  • PyPI publishing is authored but dormantrelease.yml publishes the open surface only once a Trusted Publisher is provisioned and ENABLE_PYPI_PUBLISH == 'true' (see above). Extracted vectis-ext-* wheels publish via their own pipelines.
  • Namespace-package decoupling — making the vectis/vectis.extensions parents PEP 420 namespace packages so an out-of-tree ext wheel can merge without co-location in one site-packages.
  • Runtime entitlement enforcement — the ExtensionEntitlement record 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).