Files
shopdb-flask/docs/PLUGIN-EXTERNAL-REPO.md
cproudlock 11f3d00a04
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Installer prerequisites: REQ-D through REQ-G
REQ-D: restore waitress and tzdata to requirements.in. They existed ONLY in the
generated requirements.txt (hand-added in bf9e60e), so the next
`uv pip compile` would have silently removed the WSGI server and the IANA
timezone database from every Windows install.

REQ-E: split production and development requirements. requirements.txt was
installing pytest, pytest-cov, pytest-flask, coverage, iniconfig and pluggy onto
production servers. Verified on a real Windows Server box before this change.
CI, scripts/test-external-plugin.sh and the dev docs now use requirements-dev.txt.

REQ-F: standardise on Python 3.14. The repo declared four different versions
(Dockerfile 3.12, DEPLOY-WINDOWS-IIS 3.12, INSTALL-WINDOWS-IIS 3.13, CI 3.13,
plus README, web.config and PLUGIN-EXTERNAL-REPO). 3.14 is in active bugfix
support until ~Apr 2027 and supported to Oct 2030; 3.13 entered security-only in
Apr 2026. All four compiled dependencies publish win_amd64 wheels for 3.14
(cryptography via an abi3 wheel), verified by building an offline wheelhouse and
installing it on Windows Server 2025.

REQ-G: state MySQL 8.0 as the standard for new installs; 5.7+/5.6 remain
supported on an existing server.

Lockfiles regenerated with uv pip compile. Production deps 44 -> 38.
2026-08-02 14:15:18 -04:00

353 lines
14 KiB
Markdown

# 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](adr/ADR-003-plugin-distribution.md):
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).
> **Windows / VS Code:** command examples use the Linux venv path
> `venv/bin/python`; on Windows use `venv\Scripts\python` and
> `$env:FLASK_APP="shopdb"` (not `export`). Full Windows onboarding:
> [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md).
If you have not written a plugin before, start with
[PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) and the hook reference in
[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md). This document only covers the parts that are
different when the plugin lives in its own repo.
## Recommended repo layout
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](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](adr/ADR-008-plugin-migration-ownership.md).
- 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.
```bash
# 1. Clone the framework and your plugin repo side by side.
git clone https://github.com/ge-aero/shopdb-flask.git
git clone https://github.com/ge-aero/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)
#
# Windows: use a directory junction instead of ln -s. In an ADMIN prompt
# (or with Developer Mode on) from the shopdb-flask dir:
# mklink /D plugins\shipping ..\..\wjsf-shipping
# The plugin loader treats a junction the same as a real directory.
# 3. Set up the framework as usual.
python3 -m venv venv
venv/bin/pip install -r requirements-dev.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](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`:
```python
__contract_version__ = '0.13.0'
```
Recommended pin in your `manifest.json`, per ADR-002 (pip-style `>=,<` ranges):
```json
{
"name": "shipping",
"version": "1.0.0",
"description": "Tracks shipping-station scanners and label printers",
"core_version": ">=0.13.0,<0.14.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.
```bash
# 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:
```bash
#!/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://github.com/ge-aero/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://github.com/ge-aero/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-dev.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:
```python
# 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):
```yaml
name: plugin-contract
on:
push:
pull_request:
jobs:
contract:
runs-on: ubuntu-latest
env:
FRAMEWORK_REF: v0.5.0
FRAMEWORK_URL: https://github.com/ge-aero/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.14'
- 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
- [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - generate and install a plugin fast
- [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) - the full hook and import-surface reference
- [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md) - what is settled vs still churning before 1.0
- [ADR-002](adr/ADR-002-plugin-versioning.md) - contract versioning and core_version ranges
- [ADR-003](adr/ADR-003-plugin-distribution.md) - the bundled vs external distribution model
- [ADR-008](adr/ADR-008-plugin-migration-ownership.md) - per-plugin migration ownership