Add plugin registry for report exporters
Replace the hardcoded if/elif in Export.exec() with a dict registry. Built-in formats (text, json, junit, html) are registered as lazy loaders; missing optional deps (junit_xml, lxml) print a clear message with a pip install hint instead of raising. Entry-points (group "testium.exporters") are discovered at import time — installed plugins are auto-detected with no extra config. An unknown or unavailable format prints an info line and skips the export; the test run is not interrupted. Validation: - New testium-fake-exporter package under test/validation/fake_exporter/ installed automatically by scripts/build_env.sh on venv creation. It registers fake_format via entry-points and exports the tests table to CSV — a real, useful exporter that exercises the plugin contract end-to-end (entry-point discovery, dispatch, SQLite query). - New dedicated items/report_plugin/ test exercises both the unknown-format skip path and the fake_format plugin path, with a py_func check (file_check.py) on the produced CSV. Runs once per validation suite. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,4 +27,10 @@ if [ ! -d "$PY_VENV_DIR" ]; then
|
|||||||
python3 -m venv "$PY_VENV_DIR"
|
python3 -m venv "$PY_VENV_DIR"
|
||||||
source "$PY_VENV_DIR/bin/activate"
|
source "$PY_VENV_DIR/bin/activate"
|
||||||
pip install --extra-index-url https://pypi.python.org/pypi -r $REQ_PATH
|
pip install --extra-index-url https://pypi.python.org/pypi -r $REQ_PATH
|
||||||
|
# Validation suite plugin used to verify the report-exporter
|
||||||
|
# entry-points discovery end-to-end.
|
||||||
|
FAKE_EXPORTER_DIR="$(dirname "$REQ_PATH")/../test/validation/fake_exporter"
|
||||||
|
if [ -d "$FAKE_EXPORTER_DIR" ]; then
|
||||||
|
pip install -e "$FAKE_EXPORTER_DIR"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -20,6 +20,53 @@ sqlite3.register_converter('JSON', convert_json)
|
|||||||
TEST_REPORT_FILE_REV = '0.1'
|
TEST_REPORT_FILE_REV = '0.1'
|
||||||
|
|
||||||
|
|
||||||
|
def _load_text():
|
||||||
|
from interpreter.test_report.report_export_txt import ReportExportTxt
|
||||||
|
return ReportExportTxt
|
||||||
|
|
||||||
|
def _load_json():
|
||||||
|
from interpreter.test_report.report_export_json import ReportExportJSON
|
||||||
|
return ReportExportJSON
|
||||||
|
|
||||||
|
def _load_junit():
|
||||||
|
try:
|
||||||
|
from interpreter.test_report.report_export_junit import ReportExportJUnit
|
||||||
|
return ReportExportJUnit
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
raise ETUMRuntimeError(
|
||||||
|
'Report format "junit" requires "junit_xml" — pip install junit-xml')
|
||||||
|
|
||||||
|
def _load_html():
|
||||||
|
try:
|
||||||
|
from interpreter.test_report.report_export_html import ReportExportHTML
|
||||||
|
return ReportExportHTML
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
raise ETUMRuntimeError(
|
||||||
|
'Report format "html" requires "lxml" — pip install lxml')
|
||||||
|
|
||||||
|
_EXPORTER_REGISTRY: dict = {
|
||||||
|
cst.REP_TYPE_TEXT: _load_text,
|
||||||
|
cst.REP_TYPE_JSON: _load_json,
|
||||||
|
cst.REP_TYPE_JUNIT: _load_junit,
|
||||||
|
cst.REP_TYPE_HTML: _load_html,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _discover_plugins():
|
||||||
|
try:
|
||||||
|
from importlib.metadata import entry_points
|
||||||
|
for ep in entry_points(group='testium.exporters'):
|
||||||
|
try:
|
||||||
|
cls = ep.load()
|
||||||
|
_EXPORTER_REGISTRY[ep.name] = lambda c=cls: c
|
||||||
|
print(f'[testium] Loaded report exporter plugin: "{ep.name}"')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[testium] Failed to load report exporter plugin "{ep.name}": {e}')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_discover_plugins()
|
||||||
|
|
||||||
|
|
||||||
def tr_procedure(f):
|
def tr_procedure(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def wrapper(self, *args, **kwds):
|
def wrapper(self, *args, **kwds):
|
||||||
@@ -82,28 +129,19 @@ class Export:
|
|||||||
else:
|
else:
|
||||||
path = os.path.join(path, fname)
|
path = os.path.join(path, fname)
|
||||||
|
|
||||||
if et == cst.REP_TYPE_TEXT:
|
if et == cst.REP_TYPE_SQLITE:
|
||||||
from interpreter.test_report.report_export_txt import ReportExportTxt
|
|
||||||
ReportExportTxt(name, con, path, pats, keys, no_header)
|
|
||||||
elif et == cst.REP_TYPE_JSON:
|
|
||||||
from interpreter.test_report.report_export_json import ReportExportJSON
|
|
||||||
ReportExportJSON(name, con, path, pats, keys, no_header)
|
|
||||||
elif et == cst.REP_TYPE_JUNIT:
|
|
||||||
try:
|
|
||||||
from interpreter.test_report.report_export_junit import ReportExportJUnit
|
|
||||||
ReportExportJUnit(name, con, path, pats, keys, no_header)
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
raise ETUMRuntimeError('"junit_xml" module not available')
|
|
||||||
elif et == cst.REP_TYPE_HTML:
|
|
||||||
try:
|
|
||||||
from interpreter.test_report.report_export_html import ReportExportHTML
|
|
||||||
ReportExportHTML(name, con, path, pats, keys, no_header)
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
raise ETUMRuntimeError('"lxml" module not available')
|
|
||||||
elif et == cst.REP_TYPE_SQLITE:
|
|
||||||
pass
|
pass
|
||||||
|
elif et in _EXPORTER_REGISTRY:
|
||||||
|
try:
|
||||||
|
cls = _EXPORTER_REGISTRY[et]()
|
||||||
|
cls(name, con, path, pats, keys, no_header)
|
||||||
|
except ETUMRuntimeError as e:
|
||||||
|
print(f'[report] Export skipped: {e}')
|
||||||
else:
|
else:
|
||||||
raise ETUMSyntaxError('Report export not recognized')
|
available = ', '.join(
|
||||||
|
sorted(_EXPORTER_REGISTRY.keys()) + [cst.REP_TYPE_SQLITE])
|
||||||
|
print(f'[report] Export skipped: format "{et}" not found. '
|
||||||
|
f'Available: {available}')
|
||||||
|
|
||||||
class TestReport:
|
class TestReport:
|
||||||
TEST_COLS = [[cst.DB_TEST_TIMESTAMP_START, 'INT'],
|
TEST_COLS = [[cst.DB_TEST_TIMESTAMP_START, 'INT'],
|
||||||
|
|||||||
42
test/validation/fake_exporter/fake_exporter/__init__.py
Normal file
42
test/validation/fake_exporter/fake_exporter/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""CSV report exporter — used as a real plugin by the testium validation suite.
|
||||||
|
|
||||||
|
Demonstrates the contract: take the SQLite connection, output path, optional
|
||||||
|
name/key filters, and produce the output. Has no dependency on testium
|
||||||
|
internals.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
|
||||||
|
|
||||||
|
class FakeExporter:
|
||||||
|
COLUMNS = [
|
||||||
|
'timestamp_start',
|
||||||
|
'test_id',
|
||||||
|
'parent_id',
|
||||||
|
'level',
|
||||||
|
'test_name',
|
||||||
|
'test_type',
|
||||||
|
'report_key',
|
||||||
|
'result',
|
||||||
|
'message',
|
||||||
|
'duration',
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, name, con, path, pats, keys, no_header=False):
|
||||||
|
clauses = []
|
||||||
|
for p in pats:
|
||||||
|
clauses.append(f'test_name LIKE "{p}"')
|
||||||
|
for k in keys:
|
||||||
|
clauses.append(f'report_key LIKE "{k}"')
|
||||||
|
where = ('WHERE ' + ' OR '.join(clauses) + ' ') if clauses else ''
|
||||||
|
cols = ', '.join(self.COLUMNS)
|
||||||
|
rows = con.execute(
|
||||||
|
f'SELECT {cols} FROM tests {where}ORDER BY timestamp_start'
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
with open(path, 'w', newline='', encoding='utf-8') as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
if not no_header:
|
||||||
|
writer.writerow(self.COLUMNS)
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow(row)
|
||||||
14
test/validation/fake_exporter/pyproject.toml
Normal file
14
test/validation/fake_exporter/pyproject.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "testium-fake-exporter"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Fake report exporter used by testium validation suite"
|
||||||
|
|
||||||
|
[project.entry-points."testium.exporters"]
|
||||||
|
fake_format = "fake_exporter:FakeExporter"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
8
test/validation/items/report_plugin/file_check.py
Normal file
8
test/validation/items/report_plugin/file_check.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def file_contains(path, text):
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return False
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
return text in f.read()
|
||||||
1
test/validation/items/report_plugin/param.yaml
Normal file
1
test/validation/items/report_plugin/param.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
no_param: Null
|
||||||
23
test/validation/items/report_plugin/test.tum
Normal file
23
test/validation/items/report_plugin/test.tum
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
- report:
|
||||||
|
name: Unknown exporter is skipped (must pass)
|
||||||
|
key: $(test)_PASS
|
||||||
|
export:
|
||||||
|
- definitely_not_a_format:
|
||||||
|
path: $(validation_report_path)$(psep)$(test)_unknown.txt
|
||||||
|
|
||||||
|
- report:
|
||||||
|
name: Plugin exporter from entry-points (fake_format CSV)
|
||||||
|
key: $(test)_PASS
|
||||||
|
export:
|
||||||
|
- fake_format:
|
||||||
|
path: $(validation_report_path)$(psep)$(test)_fake.csv
|
||||||
|
|
||||||
|
- py_func:
|
||||||
|
name: Check fake_format CSV content
|
||||||
|
file: $(test_path)$(psep)file_check.py
|
||||||
|
func_name: file_contains
|
||||||
|
key: $(test)_PASS
|
||||||
|
param:
|
||||||
|
- $(validation_report_path)$(psep)$(test)_fake.csv
|
||||||
|
- "Test preparation,Group"
|
||||||
|
expected_result: True
|
||||||
Reference in New Issue
Block a user