Plugin and API reference

Registration

[project.entry-points."transpiler_mate.plugins"]
cwl2webgl = "cwl2webgl.plugin:cwl2webgl"

The plugin is registered with transpiler_mate.api.transpiler_plugin. Import it and its options from cwl2webgl.plugin; the package root exports __version__.

Options

Python field CLI option Type Default Meaning
output --output pathlib.Path workflow.html Destination HTML file
overwrite --overwrite bool False Allow replacement of an existing destination

CWL2WebGLOptions rejects unknown fields. Workflow selection and the title are provided by context.process_id and context.metadata.name, respectively.

Execute from a host

from pathlib import Path
from transpiler_mate.api import TranspilerContext
from cwl2webgl.plugin import CWL2WebGLOptions, cwl2webgl


def export_workflow(context: TranspilerContext) -> None:
    cwl2webgl.execute(
        context,
        CWL2WebGLOptions(output=Path("workflow.html"), overwrite=True),
    )

The host supplies these context fields:

Field Use
source Base location for relative references and view identity
document / processes Already-loaded cwl-utils process objects
process_id Optional selected workflow ID; otherwise include all workflows
metadata.name Viewer heading and browser title
resolver Resolves external process references into another context

Execution returns None and writes the destination. The plugin reads the DOM without modifying it. CWL 1.0, 1.1, and 1.2 DOMs are covered by the tests.

Render without writing

render(context, options) returns the complete HTML as a string. It does not write files or apply overwrite checks. The output and overwrite options are used by plugin execution, not by rendering.

from cwl2webgl.plugin import CWL2WebGLOptions, render

# context is supplied by the host.
html = render(context, CWL2WebGLOptions())

Projection internals

Projection(context).build(workflow_id=None) returns presentation data with version, roots, and views. A supplied workflow ID takes precedence over context.process_id. Exact IDs are preferred; unique short IDs are accepted for root selection. Process-reference resolution does not use basename matching.

Views contain workflow details, nodes, and edges. Nodes retain cwlId, kind, label, and details; subworkflow nodes also reference a child view. Edges retain source and target node IDs, exact port IDs, and binding details. This is internal rendering data, not a separate CWL model or a stable interchange API.

Error behavior

PluginFailureError reports invalid selections, no workflows, ambiguous or recursive references, duplicate nodes or source ports, unknown connection sources, and protected output paths.

PluginExecutionError wraps resolver errors and unexpected generation or I/O errors. The original exception is retained as its cause. Existing output is only replaced after rendering and writing the temporary file succeed. Temporary files are cleaned up on completion or failure.

Generated API documentation

Bases: BaseModel

Options accepted by the CWL to WebGL plugin.

Source code in src/cwl2webgl/plugin.py
40
41
42
43
44
45
46
class CWL2WebGLOptions(BaseModel):
    """Options accepted by the CWL to WebGL plugin."""

    model_config = ConfigDict(extra="forbid")

    output: Path = Field(default=Path("workflow.html"), description="Output HTML file")
    overwrite: bool = Field(default=False, description="Replace an existing output file")
Source code in src/cwl2webgl/plugin.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def render(context: TranspilerContext, options: CWL2WebGLOptions) -> str:
    payload = Projection(context).build(context.process_id)
    payload["title"] = context.metadata.name
    # JSON inside an HTML script element must not contain an HTML closing tag.
    encoded = (
        json.dumps(payload, ensure_ascii=True)
        .replace("<", "\\u003c")
        .replace(">", "\\u003e")
        .replace("&", "\\u0026")
    )
    assets = files("cwl2webgl").joinpath("assets")
    template = assets.joinpath("viewer.html").read_text(encoding="utf-8")
    return (
        template.replace("/*__STYLE__*/", assets.joinpath("viewer.css").read_text(encoding="utf-8"))
        .replace("/*__SCRIPT__*/", assets.joinpath("viewer.js").read_text(encoding="utf-8"))
        .replace("__PAYLOAD__", encoded)
    )

Per-invocation traversal state, containing references to the existing DOM.

Source code in src/cwl2webgl/projection.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
class Projection:
    """Per-invocation traversal state, containing references to the existing DOM."""

    def __init__(self, context: TranspilerContext) -> None:
        self.context = context
        self.views: dict[str, dict[str, Any]] = {}
        self.resolved: dict[str, TranspilerContext] = {}
        self.active: set[str] = set()

    def run(
        self, step: WorkflowStep, context: TranspilerContext
    ) -> tuple[Process, TranspilerContext]:
        """Resolve a step process and its owning context.

        Raises:
            PluginExecutionError: If the external resolver fails.
            PluginFailureError: If the reference is ambiguous or unresolved.
        """
        if not isinstance(step.run, str):
            return step.run, context
        process = find_process(context, step.run)
        if process is not None:
            return process, context
        # cwl-utils normally supplies absolute URIs; normalized DOMs may retain fragments.
        location = urljoin(str(context.source), step.run)
        process = find_process(context, location)
        if process is not None:
            return process, context
        if location not in self.resolved:
            try:
                self.resolved[location] = context.resolver.resolve(location)
            except Exception as exc:
                raise PluginExecutionError(
                    f"Cannot resolve run {step.run!r} for {step.id}"
                ) from exc
        other = self.resolved[location]
        return self._resolved_process(other, location), other

    def _resolved_process(self, other: TranspilerContext, location: str) -> Process:
        """Select the referenced process from a resolved document."""
        process = find_process(other, location)
        if process is None and other.process_id:
            process = find_process(other, other.process_id)
        if process is None and not urldefrag(location)[1] and len(other.document) == 1:
            process = next(iter(other.processes))
        if process is None:
            raise PluginFailureError(f"Resolved source does not identify run {location!r}")
        return process

    def view(self, workflow: Workflow, context: TranspilerContext) -> str:
        """Build a workflow view, rejecting recursive workflow references."""
        key = str(context.source) + "|" + workflow.id
        if key in self.active:
            raise PluginFailureError(f"Recursive workflow reference: {workflow.id}")
        if key in self.views:
            return key
        self.active.add(key)
        sources: dict[str, tuple[str, str]] = {}
        nodes = self._nodes(workflow, context, sources)
        edges = self._edges(workflow, sources)
        self.views[key] = {
            "id": workflow.id,
            "label": workflow.label or short(workflow.id),
            "details": details(workflow, ("id", "label", "doc", "requirements", "hints")),
            "nodes": nodes,
            "edges": edges,
        }
        self.active.remove(key)
        return key

    def _nodes(
        self,
        workflow: Any,
        context: TranspilerContext,
        sources: dict[str, tuple[str, str]],
    ) -> list[dict[str, Any]]:
        """Build presentation nodes and register their source ports in place."""
        nodes: list[dict[str, Any]] = []

        def node(identifier: str, kind: str, obj: Any, info: dict[str, Any]) -> dict[str, Any]:
            result = {
                "id": node_id("step" if kind == "workflow" else kind, identifier),
                "cwlId": identifier,
                "kind": kind,
                "label": getattr(obj, "label", None) or short(identifier),
                "details": info,
            }
            if any(n["id"] == result["id"] for n in nodes):
                raise PluginFailureError(f"Duplicate node ID: {identifier}")
            nodes.append(result)
            return result

        for port in workflow.inputs:
            node(port.id, "input", port, details(port, PORT_FIELDS))
            sources[port.id] = (node_id("input", port.id), port.id)
        for step in workflow.steps:
            process, owner = self.run(step, context)
            info = details(step, STEP_FIELDS)
            info["run"] = process.id
            info["bindings"] = [details(port, BINDING_FIELDS) for port in step.in_]
            info["inputs"] = [details(port, PORT_FIELDS) for port in process.inputs]
            info["outputs"] = [details(port, PORT_FIELDS) for port in process.outputs]
            info["process"] = details(
                process,
                (
                    "class_",
                    "label",
                    "doc",
                    "requirements",
                    "hints",
                    "baseCommand",
                    "arguments",
                    "expression",
                ),
            )
            result = node(
                step.id,
                "workflow" if isinstance(process, Workflow) else "step",
                step,
                info,
            )
            if isinstance(process, Workflow):
                result["child"] = self.view(process, owner)
            self._register_outputs(step, sources)
        for port in workflow.outputs:
            node(
                port.id,
                "output",
                port,
                details(port, (*PORT_FIELDS, "outputSource", "linkMerge", "pickValue")),
            )

        return nodes

    @staticmethod
    def _register_outputs(step: WorkflowStep, sources: dict[str, tuple[str, str]]) -> None:
        """Register step outputs, rejecting duplicate source ports."""
        for output in step.out:
            identifier = output if isinstance(output, str) else output.id
            # cwl-loader's normalized DOM has out=["result"], source="step/result".
            if "/" not in identifier and "#" not in identifier:
                identifier = step.id + "/" + identifier
            if identifier in sources:
                raise PluginFailureError(f"Duplicate source port: {identifier}")
            sources[identifier] = (node_id("step", step.id), identifier)

    @staticmethod
    def _edge(
        reference: str,
        target: str,
        port: str,
        binding: dict[str, Any],
        sources: dict[str, tuple[str, str]],
        workflow_id: str,
    ) -> dict[str, Any]:
        """Create an edge after checking that its source exists."""
        if reference not in sources:
            raise PluginFailureError(f"Unknown source {reference!r} in workflow {workflow_id}")
        source, source_port = sources[reference]
        return {
            "source": source,
            "target": target,
            "sourcePort": source_port,
            "targetPort": port,
            "binding": binding,
        }

    def _edges(self, workflow: Any, sources: dict[str, tuple[str, str]]) -> list[dict[str, Any]]:
        """Connect workflow ports to their validated source nodes."""
        edges: list[dict[str, Any]] = []

        for step in workflow.steps:
            for port in step.in_:
                edges.extend(
                    self._edge(
                        reference,
                        node_id("step", step.id),
                        port.id,
                        details(port, BINDING_FIELDS),
                        sources,
                        workflow.id,
                    )
                    for reference in many(port.source)
                )
        for port in workflow.outputs:
            edges.extend(
                self._edge(
                    reference,
                    node_id("output", port.id),
                    port.id,
                    details(port, ("linkMerge", "pickValue")),
                    sources,
                    workflow.id,
                )
                for reference in many(port.outputSource)
            )
        return edges

    def build(self, workflow_id: str | None = None) -> dict[str, Any]:
        requested = workflow_id or self.context.process_id
        if requested:
            selected = find_process(self.context, requested)
            if selected is None:
                # Convenient short selection is allowed only when unique, never for run resolution.
                matches = [
                    p for p in self.context.processes if short(p.id) == requested.lstrip("#")
                ]
                selected = matches[0] if len(matches) == 1 else None
            if selected is None or not isinstance(selected, Workflow):
                raise PluginFailureError(f"Select an existing Workflow ID; got {requested!r}")
            workflows = [selected]
        else:
            workflows = [
                process for process in self.context.processes if isinstance(process, Workflow)
            ]
        if not workflows:
            raise PluginFailureError("The source contains no Workflow processes")
        roots = [self.view(workflow, self.context) for workflow in workflows]
        return {"version": 1, "roots": roots, "views": self.views}

run(step, context)

Resolve a step process and its owning context.

Raises:
  • PluginExecutionError –

    If the external resolver fails.

  • PluginFailureError –

    If the reference is ambiguous or unresolved.

Source code in src/cwl2webgl/projection.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def run(
    self, step: WorkflowStep, context: TranspilerContext
) -> tuple[Process, TranspilerContext]:
    """Resolve a step process and its owning context.

    Raises:
        PluginExecutionError: If the external resolver fails.
        PluginFailureError: If the reference is ambiguous or unresolved.
    """
    if not isinstance(step.run, str):
        return step.run, context
    process = find_process(context, step.run)
    if process is not None:
        return process, context
    # cwl-utils normally supplies absolute URIs; normalized DOMs may retain fragments.
    location = urljoin(str(context.source), step.run)
    process = find_process(context, location)
    if process is not None:
        return process, context
    if location not in self.resolved:
        try:
            self.resolved[location] = context.resolver.resolve(location)
        except Exception as exc:
            raise PluginExecutionError(
                f"Cannot resolve run {step.run!r} for {step.id}"
            ) from exc
    other = self.resolved[location]
    return self._resolved_process(other, location), other

view(workflow, context)

Build a workflow view, rejecting recursive workflow references.

Source code in src/cwl2webgl/projection.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def view(self, workflow: Workflow, context: TranspilerContext) -> str:
    """Build a workflow view, rejecting recursive workflow references."""
    key = str(context.source) + "|" + workflow.id
    if key in self.active:
        raise PluginFailureError(f"Recursive workflow reference: {workflow.id}")
    if key in self.views:
        return key
    self.active.add(key)
    sources: dict[str, tuple[str, str]] = {}
    nodes = self._nodes(workflow, context, sources)
    edges = self._edges(workflow, sources)
    self.views[key] = {
        "id": workflow.id,
        "label": workflow.label or short(workflow.id),
        "details": details(workflow, ("id", "label", "doc", "requirements", "hints")),
        "nodes": nodes,
        "edges": edges,
    }
    self.active.remove(key)
    return key