Files
Medios-Macina/docs/plugin_authoring.md

5.4 KiB

Plugin authoring: ResultTable and plugin adapters

This short guide explains how to write plugins that integrate with the strict ResultTable API: adapters yield ResultModel instances, and plugins register via SYS.result_table_adapters.register_plugin with columns and a selection_fn.

The public terminology is plugin-first, even though some internal classes and metadata fields still use Provider naming.


Quick summary

  • Plugins register a plugin adapter, a columns definition, and a selection_fn.
  • selection_fn returns CLI args for a selected row.
  • For HTML table or list scraping, prefer TablePluginMixin from SYS.plugin_helpers.

Runtime dependency policy

  • Treat required runtime dependencies such as Playwright as mandatory: import them unconditionally and let missing dependencies fail fast.
  • Use guarded imports only for truly optional dependencies such as pandas.
  • Keep plugin code minimal and explicit: fail early and document required runtime dependencies in README and installation notes.

Minimal plugin template

# plugins/my_plugin.py
from typing import Any, Dict, Iterable, List

from SYS.result_table_api import ResultModel, ColumnSpec, title_column
from SYS.result_table_adapters import register_plugin

SAMPLE_ITEMS = [
    {
        "name": "Example File.pdf",
        "path": "https://example.com/x.pdf",
        "ext": "pdf",
        "size": 1024,
        "source": "myplugin",
    },
]


def adapter(items: Iterable[Dict[str, Any]]) -> Iterable[ResultModel]:
    for it in items:
        title = it.get("name") or it.get("title") or str(it.get("path") or "")
        yield ResultModel(
            title=str(title),
            path=str(it.get("path")) if it.get("path") else None,
            ext=str(it.get("ext")) if it.get("ext") else None,
            size_bytes=int(it.get("size")) if it.get("size") is not None else None,
            metadata=dict(it),
            source=str(it.get("source")) if it.get("source") else "myplugin",
        )


def columns_factory(rows: List[ResultModel]) -> List[ColumnSpec]:
    cols = [title_column()]
    if any((row.metadata or {}).get("size") for row in rows):
        cols.append(ColumnSpec("size", "Size", lambda row: row.size_bytes or ""))
    return cols


def selection_fn(row: ResultModel) -> List[str]:
    if row.path:
        return ["-path", row.path]
    return ["-title", row.title or ""]


register_plugin("myplugin", adapter, columns=columns_factory, selection_fn=selection_fn)

Table scraping with TablePluginMixin

If your plugin scrapes HTML tables or list-like results, use TablePluginMixin:

from PluginCore.base import Provider
from SYS.plugin_helpers import TablePluginMixin


class MyTablePlugin(TablePluginMixin, Provider):
    URL = ("https://example.org/search",)

    def validate(self) -> bool:
        return True

    def search(self, query: str, limit: int = 50, **kwargs):
        url = f"{self.URL[0]}?q={quote_plus(query)}"
        return self.search_table_from_url(url, limit=limit)

TablePluginMixin.search_table_from_url returns PluginCore.base.SearchResult entries. If you want to integrate the plugin with the strict ResultTable registry, add a small adapter that converts SearchResult to ResultModel and register it using register_plugin.


Columns and selection

  • columns may be a static List[ColumnSpec] or a factory that inspects sample rows.
  • selection_fn must accept a ResultModel and return a List[str] representing CLI args.
  • For downloadable file rows, prefer explicit URL args such as ['-url', row.path] so downstream downloaders interpret the row unambiguously.
  • Ensure ResultModel.source is set directly or falls back to the registered plugin name during serialization.

Optional pandas support

SYS.html_table.extract_records prefers a pure-lxml path but can fall back to pandas.read_html when pandas is installed and the helper detects it works for the input table. This is optional. Document whether your plugin requires pandas and emit a clear error or log message when it is missing.


Testing and examples

  • Write tests/test_plugin_<name>.py or follow the repo's older naming conventions when extending existing tests.
  • Verify plugin.build_table(...) produces a ResultTable with rows and columns.
  • Verify serialize_rows() yields _selection_args, _selection_action when applicable, and source.
  • When you need an exact CLI stage sequence, call table.set_row_selection_action(row_index, tokens) so replay uses the row action verbatim.
  • For table-oriented plugins, test search_table_from_url with a local HTML fixture or a mocked HTTPClient.

Example test skeleton:

from SYS.result_table_adapters import get_plugin
from plugins import example_plugin


def test_example_plugin_registration():
    plugin = get_plugin("example")
    rows = list(plugin.adapter(example_plugin.SAMPLE_ITEMS))
    assert rows and rows[0].title
    cols = plugin.get_columns(rows)
    assert any(col.name == "title" for col in cols)
    table = plugin.build_table(example_plugin.SAMPLE_ITEMS)
    assert table.provider == "example" and table.rows

References and examples

  • Read plugins/example_plugin.py for a compact example of a strict adapter and dynamic columns.
  • Read plugins/vimm/__init__.py for a table-oriented plugin that uses TablePluginMixin and converts SearchResult to ResultModel for registration.
  • See docs/plugin_guide.md for a broader plugin development checklist.