Phase 3 (part 1): manifest-first loader, shopdb.api namespace, auto-register

Hardens the plugin framework so sister-site adoption is safe.

Loader rewrite (shopdb/plugins/loader.py):
- Reads manifest.json directly. Dependency sort and version checks
  no longer instantiate plugin classes (avoids __init__ side effects).
- Fail-loud policy: in dev/test (DEBUG or TESTING true), plugin
  errors re-raise. In production, errors log with full context and
  the plugin is excluded from registration. Framework keeps booting.
- Contract-version range check via packaging.SpecifierSet. Plugin's
  manifest.core_version must include the framework's
  __contract_version__ or load fails per the policy above.
- Manifest validation: required fields (name, version, description),
  name matches directory, JSON parseable.

Exceptions (shopdb/exceptions.py):
- PluginNotFoundError, PluginContractError, PluginVersionError,
  PluginDependencyError. Specific types replace generic Exception
  swallowing.

Auto-register core blueprints (shopdb/__init__.py):
- CORE_BLUEPRINT_NAMES tuple drives registration. Adding a core
  resource is one entry, not three lines (import + register call).
- Replaces 27 hand-coded register_blueprint calls.
- Asserts each blueprint is exported by shopdb.core.api at boot.

Public API namespace (shopdb/api/__init__.py):
- audit_log: thin wrapper over AuditLog.log() with stable signature.
- resolve_asset_position: implements ADR-001 position resolution
  (asset > related > location). Asset.mapx/mapy and
  AssetRelationship.inheritsposition columns are part of the locked
  contract surface but not yet in models; helper degrades gracefully
  to location-only fallback until the migration lands.

BasePlugin helpers (shopdb/plugins/base.py):
- get_setting(key, default), set_setting(key, value, ...). Settings
  namespaced as plugin.<pluginname>.<key> so two plugins can use the
  same key without colliding.

Manifest version compatibility (plugins/*/manifest.json):
- Bumped core_version from ">=1.0.0" to ">=0.1.0,<1.0.0" so all
  bundled plugins satisfy the new range check.

Contract version bump (shopdb/__init__.py):
- 0.1.0 -> 0.2.0. Additive surface change (Setting helpers,
  shopdb.api namespace) per ADR-002 minor-bump rules.

Tests (tests/test_plugin_loader.py, tests/test_api_namespace.py):
- 13 loader tests: manifest validation failures, version range
  checks, plugin.py import errors, strict-vs-isolate behavior under
  TESTING vs production-like config, manifest-first dependency sort.
- 8 api-namespace tests: audit_log roundtrip, resolve position
  fallback chain, plugin.get_setting/set_setting roundtrip with
  per-plugin namespacing.

Test count: 66 -> 87 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-05-08 16:15:28 -04:00
parent 5fefb53bca
commit 6f085a175d
14 changed files with 757 additions and 130 deletions

127
tests/test_api_namespace.py Normal file
View File

@@ -0,0 +1,127 @@
"""Tests for the public shopdb.api namespace exposed to plugins.
Pins audit_log, resolve_asset_position, and the BasePlugin
get_setting/set_setting helpers as part of the contract surface.
"""
import pytest
from shopdb.api import audit_log, resolve_asset_position
from shopdb.core.models import AuditLog, Setting
def test_audit_log_creates_row(db, client):
"""audit_log writes an AuditLog row with the standard fields."""
with client.application.test_request_context('/'):
entry = audit_log(
action='created',
entitytype='Computer',
entityid=42,
entityname='PC-1234',
changes={'before': {}, 'after': {'hostname': 'PC-1234'}},
)
assert entry is not None
assert entry.action == 'created'
assert entry.entitytype == 'Computer'
assert entry.entityid == 42
assert entry.entityname == 'PC-1234'
saved = AuditLog.query.filter_by(entityid=42, entitytype='Computer').first()
assert saved is not None
def test_resolve_asset_position_returns_none_when_no_data():
"""An asset with no coords and no location returns None."""
class FakeAsset:
mapx = None
mapy = None
location = None
assert resolve_asset_position(FakeAsset()) is None
def test_resolve_asset_position_uses_self_when_set():
"""Asset-specific coords win over everything else."""
class FakeLocation:
mapx = 10
mapy = 20
class FakeAsset:
mapx = 100
mapy = 200
location = FakeLocation()
result = resolve_asset_position(FakeAsset())
assert result == {'mapx': 100, 'mapy': 200, 'positionsource': 'self'}
def test_resolve_asset_position_falls_back_to_location():
"""When asset has no coords, falls back to location coords."""
class FakeLocation:
mapx = 50
mapy = 75
class FakeAsset:
mapx = None
mapy = None
location = FakeLocation()
result = resolve_asset_position(FakeAsset())
assert result == {'mapx': 50, 'mapy': 75, 'positionsource': 'location'}
def test_resolve_asset_position_handles_asset_without_mapx_attr():
"""Assets that don't yet have mapx/mapy columns degrade gracefully."""
class FakeLocation:
mapx = 1
mapy = 2
class FakeAsset:
location = FakeLocation()
result = resolve_asset_position(FakeAsset())
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'location'}
def test_plugin_get_setting_returns_default_when_unset(db, app):
"""A plugin reading an unset setting gets the default."""
from shopdb.plugins import plugin_manager
with app.app_context():
printers = plugin_manager.get_plugin('printers')
if printers is None:
pytest.skip('printers plugin not loaded')
assert printers.get_setting('nonexistentkey', default='fallback') == 'fallback'
def test_plugin_set_and_get_setting_roundtrip(db, app):
"""A plugin can set and read its own setting; key is namespaced."""
from shopdb.plugins import plugin_manager
with app.app_context():
printers = plugin_manager.get_plugin('printers')
if printers is None:
pytest.skip('printers plugin not loaded')
printers.set_setting('zabbix_url', 'http://zabbix.example.com')
assert printers.get_setting('zabbix_url') == 'http://zabbix.example.com'
raw = Setting.query.filter_by(key='plugin.printers.zabbix_url').first()
assert raw is not None
assert raw.value == 'http://zabbix.example.com'
def test_plugin_setting_is_namespaced_per_plugin(db, app):
"""Two plugins using the same key do not collide."""
from shopdb.plugins import plugin_manager
with app.app_context():
printers = plugin_manager.get_plugin('printers')
computers = plugin_manager.get_plugin('computers')
if printers is None or computers is None:
pytest.skip('required plugins not loaded')
printers.set_setting('shared_key', 'printers_value')
computers.set_setting('shared_key', 'computers_value')
assert printers.get_setting('shared_key') == 'printers_value'
assert computers.get_setting('shared_key') == 'computers_value'