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_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 | | `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 ## Helpers exposed to plugins
The framework provides helper APIs in `shopdb.api` (the public namespace). The framework provides helper APIs in `shopdb.api` (the public namespace).

View File

@@ -3,18 +3,7 @@
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db 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 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 ..models import Computer, ComputerType, ComputerInstalledApp from ..models import Computer, ComputerType, ComputerInstalledApp

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,15 +3,7 @@
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.api import db, Asset, AssetType, Vendor, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
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 ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN

View File

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

View File

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

View File

@@ -9,8 +9,7 @@ from flask import Flask, Blueprint
import click import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db, AssetType
from shopdb.core.models import AssetType
from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
from .api import network_bp from .api import network_bp
@@ -146,7 +145,7 @@ class NetworkPlugin(BasePlugin):
def stats(): def stats():
"""Show network device statistics.""" """Show network device statistics."""
from flask import current_app from flask import current_app
from shopdb.core.models import Asset from shopdb.api import Asset
with current_app.app_context(): with current_app.app_context():
total = db.session.query(NetworkDevice).join(Asset).filter( 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 import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, employee_connection
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 ..models import Notification, NotificationType from ..models import Notification, NotificationType

View File

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

View File

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

View File

@@ -5,15 +5,7 @@ import logging
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db, cache 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 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 ..models import Printer, PrinterType, ModelSupply from ..models import Printer, PrinterType, ModelSupply
from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS 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 for the report row
location_name = None location_name = None
if asset.locationid: if asset.locationid:
from shopdb.core.models import Location from shopdb.api import Location
loc = Location.query.get(asset.locationid) loc = Location.query.get(asset.locationid)
if loc: if loc:
location_name = loc.locationname 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. through the API/UI without a code change.
""" """
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
# allowed values, surfaced to the UI via the /supplies/meta endpoint # allowed values, surfaced to the UI via the /supplies/meta endpoint

View File

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

View File

@@ -9,9 +9,7 @@ from flask import Flask, Blueprint
import click import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db, AssetType
from shopdb.core.models.machine import MachineType
from shopdb.core.models import AssetType
from .models import Printer, PrinterType, ModelSupply from .models import Printer, PrinterType, ModelSupply
from .api import printers_asset_bp from .api import printers_asset_bp
@@ -104,7 +102,6 @@ class PrintersPlugin(BasePlugin):
with app.app_context(): with app.app_context():
self._ensure_asset_type() self._ensure_asset_type()
self._ensure_printer_types() self._ensure_printer_types()
self._ensure_legacy_machine_types()
logger.info("Printers plugin installed") logger.info("Printers plugin installed")
def _ensure_asset_type(self) -> None: def _ensure_asset_type(self) -> None:
@@ -149,30 +146,6 @@ class PrintersPlugin(BasePlugin):
db.session.commit() 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: def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled.""" """Called when plugin is uninstalled."""
logger.info("Printers plugin uninstalled") logger.info("Printers plugin uninstalled")

View File

@@ -19,9 +19,7 @@ Key facts encoded here:
import logging import logging
from shopdb.extensions import db from shopdb.api import db, Vendor, Model
from shopdb.core.models import Vendor, Model
from shopdb.core.models.machine import MachineType
from ..models import ModelSupply from ..models import ModelSupply
@@ -311,9 +309,6 @@ def seedsupplies():
model that matches a family's keys; if a family matches no existing model, 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. 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 models_touched = 0
supplies_added = 0 supplies_added = 0
@@ -322,10 +317,11 @@ def seedsupplies():
targets = _matching_models(family['matchkeys'], vendor.vendorid) targets = _matching_models(family['matchkeys'], vendor.vendorid)
if not targets: 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( model = Model(
modelnumber=family['canonical'], modelnumber=family['canonical'],
vendorid=vendor.vendorid, vendorid=vendor.vendorid,
machinetypeid=printertypeid,
) )
db.session.add(model) db.session.add(model)
db.session.flush() db.session.flush()

View File

@@ -24,7 +24,7 @@ from typing import Dict, List, Optional
import requests import requests
from flask import current_app from flask import current_app
from shopdb.extensions import cache from shopdb.api import cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -55,7 +55,7 @@ class ZabbixService:
@property @property
def isenabled(self) -> bool: def isenabled(self) -> bool:
"""Whether the integration is switched on.""" """Whether the integration is switched on."""
from shopdb.core.models import Setting from shopdb.api import Setting
db_enabled = Setting.get('zabbix_enabled') db_enabled = Setting.get('zabbix_enabled')
if db_enabled is not None: if db_enabled is not None:
return bool(db_enabled) return bool(db_enabled)
@@ -66,7 +66,7 @@ class ZabbixService:
"""Enabled, and a URL plus token are present.""" """Enabled, and a URL plus token are present."""
if not self.isenabled: if not self.isenabled:
return False 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._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') self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
return bool(self._url and self._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 flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime from datetime import datetime
from shopdb.extensions import db from shopdb.api import db, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
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 ..models import USBDevice, USBDeviceType, USBCheckout from ..models import USBDevice, USBDeviceType, USBCheckout

View File

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

View File

@@ -8,7 +8,7 @@ from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db
from .models import USBDevice, USBDeviceType, USBCheckout from .models import USBDevice, USBDeviceType, USBCheckout
from .api import usb_bp 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 # 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 # their manifest.json `core_version` field. Pre-1.0 (0.x) means the
# contract is still settling; sister sites should pin tight ranges. # 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: 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 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( def audit_log(
@@ -155,4 +195,37 @@ def resolve_asset_position(asset) -> Optional[Dict[str, Any]]:
return None 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 import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.utils.responses import ( from shopdb.api import (
success_response, success_response,
error_response, error_response,
paginated_response, paginated_response,
ErrorCodes, ErrorCodes,
get_pagination_params,
paginate_query,
) )
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import $Name 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. below with your domain model.
""" """
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
class $Name(BaseModel): class $Name(BaseModel):

View File

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

View File

@@ -6,6 +6,7 @@ plugin's plugin.py / manifest.json.
""" """
import json import json
import re
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -143,3 +144,38 @@ def test_baseplugin_does_not_have_event_handlers_hook():
def test_baseplugin_has_collector_schema_hook(): def test_baseplugin_has_collector_schema_hook():
"""The collector schema hook is on the contract surface.""" """The collector schema hook is on the contract surface."""
assert hasattr(BasePlugin, 'get_collector_schema') 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)
)