Files
shopdb-flask/docs/PLUGIN-EXTERNAL-REPO.md
cproudlock 81178b7be3
All checks were successful
CI / backend (push) Successful in 23s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Make the import-surface scan cover symlinked external plugins
Path.rglob does not descend symlinks, so a symlinked external plugin
(the ADR-003 dev loop) silently escaped the contract-purity scan. The
scanner now resolves plugin dirs before walking, a regression test
plants a symlinked plugin with a real violation and asserts it is
flagged, and the known-limitation notes in the external-repo docs are
lifted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:36:28 -04:00

13 KiB

Developing a plugin in its own repo

This guide is for a sister GE Aerospace site (or any third party) building a shopdb-flask plugin in a git repo it owns, outside the framework tree. It is the "external plugin" path in ADR-003: the framework ships a bundled set, and you drop your own plugin into <framework>/plugins/<name>/ by clone, submodule, or symlink. No pip packaging is required for v1 (pip distribution is deferred to v2 per ADR-003).

If you have not written a plugin before, start with PLUGIN-QUICKSTART.md and the hook reference in PLUGIN-HOOKS.md. This document only covers the parts that are different when the plugin lives in its own repo.

Your repo root holds the plugin directory contents directly, so the whole repo can be dropped in at <framework>/plugins/<name>/. A plugin named shipping in a repo named wjsf-shipping looks like this:

wjsf-shipping/            # your git repo root == the plugin directory
  manifest.json           # required: name, version, description, core_version
  plugin.py               # required: your BasePlugin subclass
  __init__.py
  models/
    __init__.py           # exports every model
    shipping.py
  api/
    __init__.py
    routes.py             # Flask Blueprint returned by get_blueprint()
  migrations/             # per-plugin Alembic chain (ADR-008), if you own tables
    env.py
    script.py.mako
    versions/
      0001_shipping_baseline.py
  tests/                  # your own tests (the CI harness runs these)
    test_shipping.py
  README.md               # what it tracks, who maintains it, where to file issues

Notes:

  • manifest.json is the single source of truth for metadata (ADR-002). The name field must match the directory name the site installs it under and follows the framework naming convention (lowercase concatenated, no underscores or dashes). Prefix site-specific names with the site code when a collision across sites is possible, e.g. wjsf-shipping (see PLUGINS.md naming policy and ADR-003).
  • migrations/ is only needed if your plugin owns tables. A plugin built outside the tree never had its tables created by the framework's core chain, so its 0001 is a REAL baseline that creates them, not a stamp-only anchor. This is the same "baseline vs anchor" distinction ADR-008 draws for post-cutover plugins; see ADR-008.
  • Import core code ONLY through shopdb.api (plus shopdb.plugins.base for the ABC). Deep imports of shopdb.core.*, shopdb.extensions, or shopdb.utils.* are contract violations. See PLUGIN-HOOKS.md for the exposed surface.

Local dev workflow

Symlinking lets you edit in your own repo while the framework loads the plugin live. The loader discovers a symlinked directory the same as a real one.

# 1. Clone the framework and your plugin repo side by side.
git clone https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
git clone https://gitea.proudtech.net/wjsf/wjsf-shipping.git

# 2. Symlink your repo into the framework's plugins/ directory.
#    The link name is the plugin name from your manifest.json.
cd shopdb-flask
ln -s ../../wjsf-shipping plugins/shipping
#    (use an absolute path if you prefer: ln -s "$(pwd)/../wjsf-shipping" plugins/shipping)

# 3. Set up the framework as usual.
python3 -m venv venv
venv/bin/pip install -r requirements.txt

# 4. Install (enable) your plugin.
venv/bin/flask plugin install shipping

# 5. If your plugin owns tables, apply its migrations.
venv/bin/flask db upgrade            # core chain
venv/bin/flask plugin upgrade-all    # plugin chains, including yours

# 6. Run tests.
venv/bin/python -m pytest tests/test_plugin_contract.py
venv/bin/python -m pytest ../wjsf-shipping/tests

Edits in ../wjsf-shipping are picked up on the next framework restart, because the symlink points back at your working tree.

core_version pinning for sister sites

The framework is pre-1.0. Under semver, any 0.x minor bump is allowed to break the contract, and this project uses that latitude (see the history in CONTRACT-STABILITY.md). So pin a TIGHT range that admits only the contract minor you tested against, not the whole 0.x line.

The current contract version is declared in shopdb/__init__.py:

__contract_version__ = '0.6.0'

Recommended pin in your manifest.json, per ADR-002 (pip-style >=,< ranges):

{
  "name": "shipping",
  "version": "1.0.0",
  "description": "Tracks shipping-station scanners and label printers",
  "core_version": ">=0.6.0,<0.7.0",
  "dependencies": []
}

Do NOT pin the loose >=0.2.0,<1.0.0 default that PluginMeta falls back to. That default exists so bundled plugins keep loading across minor bumps; an external plugin should be deliberate and re-test before widening its range.

What happens at load time on a mismatch (ADR-002):

Environment Behavior on core_version mismatch
dev / test The loader re-raises. Startup fails loud so you notice immediately.
production The loader logs an error, marks the plugin incompatible, and excludes it from registration. The rest of the app still starts.

When you move a site to a newer framework, bump your core_version upper bound only after the harness below passes against the new ref.

CI recipe

scripts/test-external-plugin.sh (in the framework repo) stands up a throwaway framework at a pinned ref, drops your plugin in as a symlink, and runs the framework contract tests plus your own tests/. It has two modes:

  • CI / remote (default): clones the framework at FRAMEWORK_REF and builds a fresh venv. Needs network access to git and PyPI.
  • Local / offline: set LOCAL_FRAMEWORK to a framework checkout on disk. The script exports that checkout at HEAD with git archive and reuses its existing venv, so it runs with no internet. Useful for air-gapped verification.
# CI: test the plugin in the current repo against a pinned tag
PLUGIN_DIR=. FRAMEWORK_REF=v0.5.0 scripts/test-external-plugin.sh

# Offline: test against a framework checkout already on disk
LOCAL_FRAMEWORK=/opt/shopdb-flask PLUGIN_DIR=. scripts/test-external-plugin.sh

The full script:

#!/usr/bin/env bash
#
# test-external-plugin.sh
#
# Verify an out-of-tree shopdb-flask plugin against a pinned framework build.
# Stand up a throwaway copy of the framework, drop the plugin into
# plugins/<name>/ as a symlink (the mechanism ADR-003 documents for sister
# sites), and run the framework contract tests plus the plugin's own tests.
# Nonzero exit means the plugin is not compatible with that framework ref.
#
# Two modes:
#
#   CI / remote (default): clone the framework at FRAMEWORK_REF from
#     FRAMEWORK_URL, build a fresh venv, pip install requirements. Needs
#     network access to git and PyPI.
#
#   Local / offline: set LOCAL_FRAMEWORK to a framework checkout on disk.
#     The script exports that checkout at HEAD with `git archive` (no network)
#     and reuses the checkout's existing venv, so it runs with no internet.
#
# Inputs (env var, or positional):
#   PLUGIN_DIR      ($1)  path to the plugin directory (holds manifest.json). Required.
#   FRAMEWORK_REF   ($2)  git ref to test against in CI mode. Default: main.
#   FRAMEWORK_URL         framework git URL for CI mode.
#                         Default: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
#   LOCAL_FRAMEWORK       path to an existing framework checkout. Set it to run offline.

set -eu

PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}"
FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}"
FRAMEWORK_URL="${FRAMEWORK_URL:-https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git}"
LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}"

if [ -z "$PLUGIN_DIR" ]; then
  echo "ERROR: PLUGIN_DIR is required (env var or first argument)." >&2
  exit 2
fi
if [ ! -f "$PLUGIN_DIR/manifest.json" ]; then
  echo "ERROR: $PLUGIN_DIR has no manifest.json - not a plugin directory." >&2
  exit 2
fi

PLUGIN_ABS="$(cd "$PLUGIN_DIR" && pwd)"
PLUGIN_NAME="$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_ABS/manifest.json" | head -n1)"
if [ -z "$PLUGIN_NAME" ]; then
  PLUGIN_NAME="$(basename "$PLUGIN_ABS")"
fi

WORKDIR="$(mktemp -d)"
cleanup() { rm -rf "$WORKDIR"; }
trap cleanup EXIT

FRAMEWORK="$WORKDIR/framework"
mkdir -p "$FRAMEWORK"

if [ -n "$LOCAL_FRAMEWORK" ]; then
  echo "==> Local mode: exporting framework from $LOCAL_FRAMEWORK (HEAD)"
  LOCAL_ABS="$(cd "$LOCAL_FRAMEWORK" && pwd)"
  git -C "$LOCAL_ABS" archive HEAD | tar -x -C "$FRAMEWORK"
  if [ -x "$LOCAL_ABS/venv/bin/python" ]; then
    PYTHON="$LOCAL_ABS/venv/bin/python"
  elif [ -x "$LOCAL_ABS/.venv/bin/python" ]; then
    PYTHON="$LOCAL_ABS/.venv/bin/python"
  else
    echo "ERROR: no venv found under $LOCAL_ABS (looked for venv/ and .venv/)." >&2
    exit 2
  fi
else
  echo "==> CI mode: cloning $FRAMEWORK_URL @ $FRAMEWORK_REF"
  git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" "$FRAMEWORK"
  echo "==> Creating venv and installing requirements"
  python3 -m venv "$WORKDIR/venv"
  PYTHON="$WORKDIR/venv/bin/python"
  "$PYTHON" -m pip install --upgrade pip >/dev/null
  "$PYTHON" -m pip install -r "$FRAMEWORK/requirements.txt"
fi

echo "==> Linking plugin '$PLUGIN_NAME' into framework plugins/"
rm -rf "$FRAMEWORK/plugins/$PLUGIN_NAME"
ln -s "$PLUGIN_ABS" "$FRAMEWORK/plugins/$PLUGIN_NAME"

rm -f "$FRAMEWORK/instance/plugins.json"

RC=0

echo "==> Running framework contract tests"
( cd "$FRAMEWORK" && "$PYTHON" -m pytest tests/test_plugin_contract.py -q ) || RC=1

if [ -d "$PLUGIN_ABS/tests" ]; then
  echo "==> Running plugin's own tests"
  ( cd "$FRAMEWORK" && "$PYTHON" -m pytest "$PLUGIN_ABS/tests" -q ) || RC=1
else
  echo "==> Plugin has no tests/ directory - skipping plugin test step"
fi

if [ "$RC" -eq 0 ]; then
  echo "==> PASS: plugin '$PLUGIN_NAME' is compatible with framework ref '$FRAMEWORK_REF'"
else
  echo "==> FAIL: plugin '$PLUGIN_NAME' - see output above" >&2
fi
exit "$RC"

What the harness does and does not check

The symlinked plugin is discovered and loaded by the plugin loader when the app starts under test. That validates the things that break a real install: the manifest parses, the core_version range admits the framework's __contract_version__ (an out-of-range plugin makes startup fail loud, so the contract test run errors and the script exits nonzero), models expose __tablename__, and hooks return the right shapes.

The import-surface scan (test_plugins_only_import_contract_surface) covers symlinked plugins too: the scanner resolves each plugin directory before walking it, because Path.rglob alone does not descend symlinks (pinned by test_import_scan_covers_symlinked_plugins). You can additionally keep an equivalent import-surface assertion in your own tests/, so violations fail in your repo's CI even when run without the framework harness. A minimal version:

# tests/test_import_surface.py
import re
from pathlib import Path

ALLOWED = ('shopdb.api', 'shopdb.plugins.base')
IMPORT_RE = re.compile(r'^\s*(?:from (shopdb[\w.]*) import|import (shopdb[\w.]*))', re.MULTILINE)

def test_only_contract_surface_imports():
    root = Path(__file__).resolve().parent.parent
    bad = []
    for path in root.rglob('*.py'):
        if '__pycache__' in path.parts or 'migrations' in path.parts:
            continue
        for m in IMPORT_RE.finditer(path.read_text()):
            mod = m.group(1) or m.group(2)
            if mod and not any(mod == a or mod.startswith(a + '.') for a in ALLOWED):
                bad.append(f'{path.name}: {mod}')
    assert not bad, 'core imports outside shopdb.api / shopdb.plugins.base: ' + '; '.join(bad)

GitHub Actions example

For a plugin repo hosted on GitHub, a workflow calling the harness against a pinned framework tag (config only, adjust URLs and ref to your setup):

name: plugin-contract

on:
  push:
  pull_request:

jobs:
  contract:
    runs-on: ubuntu-latest
    env:
      FRAMEWORK_REF: v0.5.0
      FRAMEWORK_URL: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
    steps:
      - name: Check out the plugin
        uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Fetch the harness from the framework
        run: |
          git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" /tmp/framework
      - name: Run the contract harness against this plugin
        run: |
          PLUGIN_DIR="$GITHUB_WORKSPACE" \
          FRAMEWORK_REF="$FRAMEWORK_REF" \
          FRAMEWORK_URL="$FRAMEWORK_URL" \
          bash /tmp/framework/scripts/test-external-plugin.sh

See also