Build your first plugin¶
This tutorial creates a small plugin that writes the names and CWL classes found in a resolved document to JSON. Along the way, you will define options, register the execution function, report a domain failure, and test the result without a runtime.
1. Create the package¶
Start with this layout:
process-inventory/
├── pyproject.toml
├── src/
│ └── process_inventory/
│ ├── __init__.py
│ └── plugin.py
└── tests/
└── test_plugin.py
Create pyproject.toml:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "transpiler-mate-process-inventory"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"transpiler-mate-api>=1,<2",
]
[project.optional-dependencies]
test = ["pytest>=8"]
The dependency range is an example for the current API major version. Adjust it to the compatibility range your plugin actually tests.
Important
This API package defines no discovery entry-point group. Do not copy an invented [project.entry-points] section. Export the plugin object from your package and follow the chosen runtime's discovery documentation.
2. Define typed options¶
Create src/process_inventory/plugin.py:
from __future__ import annotations
import json
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field
from transpiler_mate.api import (
PluginFailureError,
TranspilerContext,
transpiler_plugin,
)
class InventoryOptions(BaseModel):
model_config = ConfigDict(extra="forbid")
output: Path
indent: int = Field(default=2, ge=0, le=8)
options_model must be a BaseModel subclass. Pydantic supplies coercion and validation before execution when a host constructs it. extra="forbid" catches misspelled option names instead of silently ignoring them.
3. Register the execution function¶
Continue in the same file:
@transpiler_plugin(
name="process-inventory",
description="Write an inventory of the resolved CWL processes",
options_model=InventoryOptions,
)
def plugin(context: TranspilerContext, options: InventoryOptions) -> None:
rows = [
{
"id": getattr(process, "id", None),
"class": type(process).__name__,
}
for process in context.processes
]
if not rows:
raise PluginFailureError("the CWL document contains no processes")
options.output.write_text(
json.dumps(rows, indent=options.indent) + "\n",
encoding="utf-8",
)
The decorator replaces the function name with an immutable PluginRegistration. The original function is available as plugin.execute. Execution returns None; files and other results are side effects owned by the plugin.
context.document maps process IDs to parsed CWL Process objects. The
context.processes iterable exposes its values when a plugin does not need the
IDs.
4. Export the plugin object¶
Create src/process_inventory/__init__.py:
from process_inventory.plugin import plugin
__all__ = ["plugin"]
This makes the object importable as process_inventory.plugin. Importability is not the same as runtime discovery: wire this object into the target runtime using that runtime's adapter or discovery mechanism.
5. Test execution directly¶
Create tests/test_plugin.py:
from pathlib import Path
from cwl_utils.parser.cwl_v1_2 import Workflow
from pydantic import AnyUrl
from process_inventory import plugin
from process_inventory.plugin import InventoryOptions
from transpiler_mate.api import TranspilerContext
from transpiler_mate.api.software_application_models import SoftwareApplication
class UnusedResolver:
def resolve(self, location: str) -> TranspilerContext:
raise AssertionError("the plugin should not resolve another source")
def test_writes_an_inventory(tmp_path: Path) -> None:
output = tmp_path / "inventory.json"
process = Workflow(id="main", inputs=[], outputs=[], steps=[])
context = TranspilerContext(
source=AnyUrl((tmp_path / "workflow.cwl").as_uri()),
metadata=SoftwareApplication.model_construct(),
document={"main": process},
resolver=UnusedResolver(),
)
plugin.execute(context, InventoryOptions(output=output))
assert '"class": "Workflow"' in output.read_text(encoding="utf-8")
SoftwareApplication.model_construct() keeps this focused test small by bypassing validation of metadata the plugin never reads. Use a fully validated model in tests that depend on metadata.
Run it:
python -m pip install -e '.[test]'
pytest
You now have the API-side portion of a plugin: a packaged, typed, importable, and directly testable TranspilerPlugin. The remaining discovery step is deliberately runtime-specific.