Enforce plugin contract purity: single import surface via shopdb.api

Plugins were reaching into internal core paths (shopdb.core.models.*,
shopdb.extensions, shopdb.utils.*), coupling them to core's file layout and
violating the ADR-001 contract. Consolidate onto one versioned surface.

- shopdb.api: expand from 2 helpers to the full plugin import surface -
  db, cache; BaseModel, AuditMixin; core models (Asset, AssetType,
  AssetStatus, Vendor, Model, Communication, CommunicationType, Location,
  Setting, AuditLog, Application, AppVersion, OperatingSystem); response +
  pagination helpers; employee_connection. Documented in PLUGIN-HOOKS.md.
- Migrate all 22 plugin source files to import only from shopdb.api (plus
  shopdb.plugins.base for the ABC).
- Drop the printers plugin's legacy MachineType dependency: remove
  _ensure_legacy_machine_types and the seed_supplies machinetypeid lookup
  (Model.machinetypeid is nullable; printers carry type via PrinterType).
- Guard test test_plugins_only_import_contract_surface scans plugin source
  and fails on any core import outside shopdb.api / shopdb.plugins.base.
- Scaffold templates updated so generated plugins are contract-pure.
- Bump __contract_version__ 0.2.0 -> 0.3.0 (additive surface expansion;
  manifests pin <1.0.0 so they still satisfy).

145 tests pass, naming/style green, app factory boots all 6 plugins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 16:45:06 -04:00
parent 37ffb4add5
commit f663cc5bbe
29 changed files with 2152 additions and 2107 deletions

View File

@@ -233,6 +233,34 @@ These run when the plugin's installation state changes. All optional.
| `on_enable(app)` | When the plugin is enabled at runtime | Subscribe to events, warm caches |
| `on_disable(app)` | When the plugin is disabled at runtime | Unsubscribe, drain queues |
## The import surface (`shopdb.api`)
`shopdb.api` is the ONLY core module a plugin may import from (besides
`shopdb.plugins.base` for `BasePlugin` / `PluginMeta`). Importing internal
paths like `shopdb.core.models.*`, `shopdb.extensions`, or `shopdb.utils.*`
is a contract violation and fails the test
`tests/test_plugin_contract.py::test_plugins_only_import_contract_surface`.
What `shopdb.api` exposes:
- Infrastructure: `db`, `cache`
- Model bases: `BaseModel`, `AuditMixin`
- Core models: `Asset`, `AssetType`, `AssetStatus`, `Vendor`, `Model`,
`Communication`, `CommunicationType`, `Location`, `Setting`, `AuditLog`,
`Application`, `AppVersion`, `OperatingSystem`
- Responses: `success_response`, `error_response`, `paginated_response`,
`ErrorCodes`
- Pagination: `get_pagination_params`, `paginate_query`
- Helpers: `audit_log`, `resolve_asset_position`
- Legacy employee directory: `employee_connection`
```python
from shopdb.api import db, Asset, AssetType, success_response, paginate_query
```
Adding a name to `shopdb.api` is an additive (minor) contract bump; removing
one is breaking (major). See ADR-002.
## Helpers exposed to plugins
The framework provides helper APIs in `shopdb.api` (the public namespace).

View File

@@ -3,18 +3,7 @@
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import (
Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog,
Communication, CommunicationType,
)
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Computer, ComputerType, ComputerInstalledApp

View File

@@ -1,7 +1,6 @@
"""Computer plugin models."""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
from shopdb.api import db, BaseModel
class ComputerType(BaseModel):

View File

@@ -9,8 +9,7 @@ from flask import Flask, Blueprint
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db
from shopdb.core.models import AssetType, AssetStatus
from shopdb.api import db, AssetType, AssetStatus
from .models import Computer, ComputerType, ComputerInstalledApp
from .api import computers_bp
@@ -87,9 +86,7 @@ class ComputersPlugin(BasePlugin):
def apply_collector_payload(self, payload: Dict) -> Dict:
"""Idempotent upsert of a PC from a collector payload (by hostname)."""
from datetime import datetime
from shopdb.core.models import (
Asset, AssetType, Application, Communication, CommunicationType,
)
from shopdb.api import Asset, AssetType, Application, Communication, CommunicationType
warnings = []
hostname = (payload.get('hostname') or '').strip()
@@ -242,7 +239,7 @@ class ComputersPlugin(BasePlugin):
def stats():
"""Show computer statistics."""
from flask import current_app
from shopdb.core.models import Asset
from shopdb.api import Asset
with current_app.app_context():
total = db.session.query(Computer).join(Asset).filter(

View File

@@ -3,15 +3,7 @@
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, Vendor, Model, AuditLog
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Equipment, EquipmentType
@@ -446,7 +438,7 @@ def dashboard_summary():
).all()
# Count by status
from shopdb.core.models import AssetStatus
from shopdb.api import AssetStatus
by_status = db.session.query(
AssetStatus.status,
db.func.count(Equipment.equipmentid)

View File

@@ -1,7 +1,6 @@
"""Equipment plugin models."""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
from shopdb.api import db, BaseModel
class EquipmentType(BaseModel):

View File

@@ -9,8 +9,7 @@ from flask import Flask, Blueprint
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db
from shopdb.core.models import AssetType, AssetStatus
from shopdb.api import db, AssetType, AssetStatus
from .models import Equipment, EquipmentType
from .api import equipment_bp
@@ -170,7 +169,7 @@ class EquipmentPlugin(BasePlugin):
def stats():
"""Show equipment statistics."""
from flask import current_app
from shopdb.core.models import Asset
from shopdb.api import Asset
with current_app.app_context():
total = db.session.query(Equipment).join(Asset).filter(

View File

@@ -3,15 +3,7 @@
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, Vendor, AuditLog
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.api import db, Asset, AssetType, Vendor, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN

View File

@@ -1,7 +1,6 @@
"""Network device plugin models."""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
from shopdb.api import db, BaseModel
class NetworkDeviceType(BaseModel):

View File

@@ -1,7 +1,6 @@
"""Subnet and VLAN models for network plugin."""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
from shopdb.api import db, BaseModel
class VLAN(BaseModel):

View File

@@ -9,8 +9,7 @@ from flask import Flask, Blueprint
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db
from shopdb.core.models import AssetType
from shopdb.api import db, AssetType
from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
from .api import network_bp
@@ -146,7 +145,7 @@ class NetworkPlugin(BasePlugin):
def stats():
"""Show network device statistics."""
from flask import current_app
from shopdb.core.models import Asset
from shopdb.api import Asset
with current_app.app_context():
total = db.session.query(NetworkDevice).join(Asset).filter(

View File

@@ -4,15 +4,7 @@ from datetime import datetime
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.employee_db import employee_connection
from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, employee_connection
from ..models import Notification, NotificationType

View File

@@ -1,7 +1,7 @@
"""Notifications plugin models - adapted to existing database schema."""
from datetime import datetime
from shopdb.extensions import db
from shopdb.api import db
class NotificationType(db.Model):

View File

@@ -9,7 +9,7 @@ from flask import Flask, Blueprint
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db
from shopdb.api import db
from .models import Notification, NotificationType
from .api import notifications_bp

View File

@@ -5,15 +5,7 @@ import logging
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db, cache
from shopdb.core.models import Asset, AssetType, Vendor, Model, Communication, CommunicationType
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.api import db, cache, Asset, AssetType, Vendor, Model, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Printer, PrinterType, ModelSupply
from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS
@@ -565,7 +557,7 @@ def _get_low_supplies_data():
# location name for the report row
location_name = None
if asset.locationid:
from shopdb.core.models import Location
from shopdb.api import Location
loc = Location.query.get(asset.locationid)
if loc:
location_name = loc.locationname

View File

@@ -6,8 +6,7 @@ drum/waste/maintenance item). Lets new models and their toners be added
through the API/UI without a code change.
"""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
from shopdb.api import db, BaseModel
# allowed values, surfaced to the UI via the /supplies/meta endpoint

View File

@@ -1,7 +1,6 @@
"""Printer plugin models - new Asset-based architecture."""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
from shopdb.api import db, BaseModel
class PrinterType(BaseModel):

View File

@@ -9,9 +9,7 @@ from flask import Flask, Blueprint
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db
from shopdb.core.models.machine import MachineType
from shopdb.core.models import AssetType
from shopdb.api import db, AssetType
from .models import Printer, PrinterType, ModelSupply
from .api import printers_asset_bp
@@ -104,7 +102,6 @@ class PrintersPlugin(BasePlugin):
with app.app_context():
self._ensure_asset_type()
self._ensure_printer_types()
self._ensure_legacy_machine_types()
logger.info("Printers plugin installed")
def _ensure_asset_type(self) -> None:
@@ -149,30 +146,6 @@ class PrintersPlugin(BasePlugin):
db.session.commit()
def _ensure_legacy_machine_types(self) -> None:
"""Ensure basic printer machine types exist (legacy architecture)."""
printertypes = [
('Laser Printer', 'Printer', 'Standard laser printer'),
('Inkjet Printer', 'Printer', 'Inkjet printer'),
('Label Printer', 'Printer', 'Label/barcode printer'),
('Multifunction Printer', 'Printer', 'MFP with scan/copy/fax'),
('Plotter', 'Printer', 'Large format plotter'),
]
for name, category, description in printertypes:
existing = MachineType.query.filter_by(machinetype=name).first()
if not existing:
mt = MachineType(
machinetype=name,
category=category,
description=description,
icon='printer'
)
db.session.add(mt)
logger.debug(f"Created machine type: {name}")
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("Printers plugin uninstalled")

View File

@@ -19,9 +19,7 @@ Key facts encoded here:
import logging
from shopdb.extensions import db
from shopdb.core.models import Vendor, Model
from shopdb.core.models.machine import MachineType
from shopdb.api import db, Vendor, Model
from ..models import ModelSupply
@@ -311,9 +309,6 @@ def seedsupplies():
model that matches a family's keys; if a family matches no existing model,
creates a canonical model row so its toners are still available.
"""
printertype = MachineType.query.filter_by(category='Printer').first()
printertypeid = printertype.machinetypeid if printertype else None
models_touched = 0
supplies_added = 0
@@ -322,10 +317,11 @@ def seedsupplies():
targets = _matching_models(family['matchkeys'], vendor.vendorid)
if not targets:
# machinetypeid is a legacy Model column (nullable); printers are
# asset-based now and carry their type via PrinterType, not here.
model = Model(
modelnumber=family['canonical'],
vendorid=vendor.vendorid,
machinetypeid=printertypeid,
)
db.session.add(model)
db.session.flush()

View File

@@ -24,7 +24,7 @@ from typing import Dict, List, Optional
import requests
from flask import current_app
from shopdb.extensions import cache
from shopdb.api import cache
logger = logging.getLogger(__name__)
@@ -55,7 +55,7 @@ class ZabbixService:
@property
def isenabled(self) -> bool:
"""Whether the integration is switched on."""
from shopdb.core.models import Setting
from shopdb.api import Setting
db_enabled = Setting.get('zabbix_enabled')
if db_enabled is not None:
return bool(db_enabled)
@@ -66,7 +66,7 @@ class ZabbixService:
"""Enabled, and a URL plus token are present."""
if not self.isenabled:
return False
from shopdb.core.models import Setting
from shopdb.api import Setting
self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
return bool(self._url and self._token)

View File

@@ -4,15 +4,7 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime
from shopdb.extensions import db
from shopdb.core.models import AuditLog
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.api import db, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import USBDevice, USBDeviceType, USBCheckout

View File

@@ -1,8 +1,7 @@
"""USB device plugin models."""
from datetime import datetime
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel, AuditMixin
from shopdb.api import db, BaseModel, AuditMixin
class USBDeviceType(BaseModel):

View File

@@ -8,7 +8,7 @@ from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db
from shopdb.api import db
from .models import USBDevice, USBDeviceType, USBCheckout
from .api import usb_bp

View File

@@ -12,7 +12,10 @@ from .plugins import plugin_manager
# ADR-002 for the bump rules. Plugins declare a compatible range in
# their manifest.json `core_version` field. Pre-1.0 (0.x) means the
# contract is still settling; sister sites should pin tight ranges.
__contract_version__ = '0.2.0'
# 0.3.0: shopdb.api expanded to the full plugin import surface (db, cache,
# model bases, core models, response + pagination helpers, employee_connection)
# so plugins no longer import internal core paths. Additive, hence minor bump.
__contract_version__ = '0.3.0'
def create_app(config_name: str = None) -> Flask:

View File

@@ -14,7 +14,47 @@ Setting helpers are exposed via BasePlugin instance methods
from typing import Any, Dict, Optional
from shopdb.core.models import AuditLog
# -- Plugin contract surface (ADR-001, versioned per ADR-002) ----------------
# Everything a plugin is allowed to import from the core lives here. Plugins
# import these from `shopdb.api`, never from internal paths like
# `shopdb.core.models.*` or `shopdb.extensions`. The contract test
# (tests/test_plugin_contract.py) enforces this. Adding a name here is an
# additive (minor) contract change; removing one is breaking (major).
# Infrastructure
from shopdb.extensions import db, cache
# Model base classes for declaring plugin tables
from shopdb.core.models.base import BaseModel, AuditMixin
# Core domain models plugins legitimately reference (the asset contract)
from shopdb.core.models import (
Asset,
AssetType,
AssetStatus,
Vendor,
Model,
Communication,
CommunicationType,
Location,
Setting,
AuditLog,
Application,
AppVersion,
OperatingSystem,
)
# Response + pagination helpers for plugin API blueprints
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes,
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
# Legacy employee directory lookup (read-only) used by notifications
from shopdb.utils.employee_db import employee_connection
def audit_log(
@@ -155,4 +195,37 @@ def resolve_asset_position(asset) -> Optional[Dict[str, Any]]:
return None
__all__ = ['audit_log', 'resolve_asset_position']
__all__ = [
# Helpers
'audit_log',
'resolve_asset_position',
# Infrastructure
'db',
'cache',
# Model bases
'BaseModel',
'AuditMixin',
# Core models
'Asset',
'AssetType',
'AssetStatus',
'Vendor',
'Model',
'Communication',
'CommunicationType',
'Location',
'Setting',
'AuditLog',
'Application',
'AppVersion',
'OperatingSystem',
# Response + pagination helpers
'success_response',
'error_response',
'paginated_response',
'ErrorCodes',
'get_pagination_params',
'paginate_query',
# Legacy employee directory
'employee_connection',
]

View File

@@ -3,13 +3,14 @@
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.utils.responses import (
from shopdb.api import (
success_response,
error_response,
paginated_response,
ErrorCodes,
get_pagination_params,
paginate_query,
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import $Name

View File

@@ -6,8 +6,7 @@ this table holds the $name-specific fields. Replace the example fields
below with your domain model.
"""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
from shopdb.api import db, BaseModel
class $Name(BaseModel):

View File

@@ -11,8 +11,7 @@ from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.core.models import AssetType
from shopdb.extensions import db
from shopdb.api import db, AssetType
from .models import $Name
from .api import ${name}_bp

View File

@@ -6,6 +6,7 @@ plugin's plugin.py / manifest.json.
"""
import json
import re
from pathlib import Path
import pytest
@@ -143,3 +144,38 @@ def test_baseplugin_does_not_have_event_handlers_hook():
def test_baseplugin_has_collector_schema_hook():
"""The collector schema hook is on the contract surface."""
assert hasattr(BasePlugin, 'get_collector_schema')
# Imports a plugin may make from the core. shopdb.api is the contract surface;
# shopdb.plugins.base is the plugin ABC. Anything else (shopdb.core.*,
# shopdb.extensions, shopdb.utils.*) is a contract violation per ADR-001.
ALLOWED_CORE_IMPORTS = ('shopdb.api', 'shopdb.plugins.base')
_PLUGIN_IMPORT_RE = re.compile(
r'^\s*(?:from (shopdb[\w.]*) import|import (shopdb[\w.]*))', re.MULTILINE
)
def _plugin_source_files():
root = Path(__file__).resolve().parent.parent / 'plugins'
return [p for p in root.rglob('*.py') if '__pycache__' not in p.parts]
def test_plugins_only_import_contract_surface():
"""Plugins must import core code only via shopdb.api / shopdb.plugins.base."""
violations = []
for path in _plugin_source_files():
text = path.read_text()
for match in _PLUGIN_IMPORT_RE.finditer(text):
module = match.group(1) or match.group(2)
if not module.startswith('shopdb'):
continue
if any(module == a or module.startswith(a + '.')
for a in ALLOWED_CORE_IMPORTS):
continue
line = text[:match.start()].count('\n') + 1
violations.append(f'{path.name}:{line} imports {module}')
assert not violations, (
'Plugins must import core only via shopdb.api or shopdb.plugins.base. '
'Violations:\n' + '\n'.join(violations)
)