Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ __pycache__/
.eggs/
/doc/_generated
.svg
# pixi environments
.pixi/*
!.pixi/config.toml
pixi.toml
pixi.lock
21 changes: 21 additions & 0 deletions examples/convert_ewoks_to_elk_graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from pprint import pprint

from ewokscore import load_graph
from ewokscore.tests.examples.graphs import get_graph
from ewoksdraw import build_svg_task_group
from ewoksdraw.layout.elk_converter import ElkGraph
from ewoksdraw.layout.elk_converter import convert_ewoks_to_elk_graph
from ewoksdraw.svg.svg_task_group import SvgTaskGroup
from ewoksdraw.svg.svg_task_group import TaskSizes

graph_description, _ = get_graph("acyclic1")
ewoks_graph = load_graph(graph_description)

svg_task_group: SvgTaskGroup = build_svg_task_group(ewoks_graph)
task_sizes: TaskSizes = svg_task_group.extract_task_sizes()
elk_graph: ElkGraph = convert_ewoks_to_elk_graph(ewoks_graph, task_sizes)

pprint(dict(ewoks_graph.graph.nodes(data=True)))
pprint(list(ewoks_graph.graph.edges(data=True)))
pprint(task_sizes)
pprint(elk_graph)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies = [
"ewokscore<5",
"reportlab",
"xmltodict",
"pyelk @ git+https://github.com/LudoBroche/pyelk.git@ewoksdraw-modifications",
]

[project.urls]
Expand Down
29 changes: 13 additions & 16 deletions src/ewoksdraw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,31 @@

from .svg import SvgCanvas
from .svg import SvgTask
from .svg import SvgTaskGroup

GAP = 10
DEFAULT_HEIGHT = 500


def graph_to_svg(graph: TaskGraph, output_path: str | Path):
svg_tasks = []
width = GAP
def build_svg_task_group(graph: TaskGraph) -> SvgTaskGroup:
svg_tasks = {}
for node_id, node_attrs in graph.graph.nodes.items():
node_inputs = _get_all_node_inputs(node_id, node_attrs)
node_outputs = _get_all_task_output_names(
node_attrs["task_type"], node_attrs["task_identifier"]
)
svg_task = SvgTask(
svg_tasks[node_id] = SvgTask(
task_name=node_id,
input_names=[n.name for n in node_inputs],
output_names=node_outputs,
)
svg_tasks.append(svg_task)
svg_task.translate(x=width, y=GAP)
width += svg_task.width + GAP

if len(svg_tasks) > 0:
height = max([svg_task.height for svg_task in svg_tasks])
else:
height = DEFAULT_HEIGHT
canvas = SvgCanvas(width=width, height=height + 2 * GAP)
return SvgTaskGroup(svg_tasks)


def graph_to_svg(graph: TaskGraph, output_path: str | Path):
task_group = build_svg_task_group(graph)
task_group.arrange_horizontally(GAP)

canvas = SvgCanvas(width=task_group.width, height=task_group.height + 2 * GAP)
canvas.add_background()
for svg_task in svg_tasks:
canvas.add_element(svg_task)
canvas.add_element(task_group)
canvas.draw(output_path)
7 changes: 7 additions & 0 deletions src/ewoksdraw/config/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@
IO_ANCHOR_TEXT_MARGIN = 10
IO_TOP_MARGIN = 5
IO_INTER_IO_MARGIN = 3

ELK_ALGORITHM = "layered"
ELK_DIRECTION = "RIGHT"
ELK_SPACING_TASKS = 60
ELK_SPACING_TASK_LAYERS = 80
ELK_SPACING_LINK = 20
ELK_ROUTING_MODE = "CONSERVATIVE_SOFT"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a usecase to access each constant individually?

If not, we could only have ELK_LAYOUT_OPTIONS as a constant.

1 change: 1 addition & 0 deletions src/ewoksdraw/layout/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .elk_converter import convert_ewoks_to_elk_graph # noqa: F401

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah.

68 changes: 68 additions & 0 deletions src/ewoksdraw/layout/elk_converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from typing import Any

from ewokscore.graph import TaskGraph

from ..config.constants import ELK_ALGORITHM
from ..config.constants import ELK_DIRECTION
from ..config.constants import ELK_ROUTING_MODE
from ..config.constants import ELK_SPACING_LINK
from ..config.constants import ELK_SPACING_TASK_LAYERS
from ..config.constants import ELK_SPACING_TASKS
from ..svg import TaskSizes

ElkGraph = dict[str, Any]

LAYOUT_OPTIONS = {
"org.eclipse.elk.algorithm": ELK_ALGORITHM,
"org.eclipse.elk.direction": ELK_DIRECTION,
"org.eclipse.elk.spacing.nodeNode": ELK_SPACING_TASKS,
"org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": ELK_SPACING_TASK_LAYERS,
"org.eclipse.elk.spacing.edgeEdge": ELK_SPACING_LINK,
"org.eclipse.elk.edgeRouting": "SPLINES",
"elk.layered.edgeRouting.splines.mode": ELK_ROUTING_MODE,
}


def convert_ewoks_to_elk_graph(
ewoks_graph: TaskGraph, task_sizes: TaskSizes
) -> ElkGraph:
"""Convert an Ewoks task graph into an ELK layout graph.

:param ewoks_graph: the task graph to convert, e.g. from ``ewokscore.load_graph``.
:param task_sizes: ``(width, height)`` per task in ``ewoks_graph``, and no
other task id, e.g. ``{"task1": (39.56, 55.0), ...}``.
:returns: an ELK graph, i.e.
``{"id": str, "layoutOptions": dict,
"children": [{"id", "width", "height"}, ...],
"edges": [{"id", "sources": [str], "targets": [str]}, ...]}``.
Comment on lines +34 to +37

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be way better if this was encoded in the type ElkGraph

"""
node_ids = set(ewoks_graph.graph.nodes)
if node_ids != task_sizes.keys():
raise ValueError(
f"task_sizes {sorted(task_sizes)} do not match ewoks_graph task ids "
f"{sorted(node_ids)}"
)

children = []
for task_id in ewoks_graph.graph.nodes:
width, height = task_sizes[task_id]
children.append({"id": task_id, "width": width, "height": height})

edges = []
for source, target, link_attrs in ewoks_graph.graph.edges(data=True):
n_mappings = len(link_attrs.get("data_mapping") or []) or 1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
n_mappings = len(link_attrs.get("data_mapping") or []) or 1
n_mappings = len(link_attrs.get("data_mapping", [])) or 1

for index in range(n_mappings):
edges.append(
{
"id": f"edge_{source}_{target}_{index}",
"sources": [source],
"targets": [target],
}
)

return {
"id": "root",
"layoutOptions": LAYOUT_OPTIONS,
"children": children,
"edges": edges,
}
2 changes: 2 additions & 0 deletions src/ewoksdraw/svg/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from .svg_task import SvgTask # noqa: F401
from .svg_task_anchor_link import SvgTaskAnchorLink # noqa: F401
from .svg_task_box import SvgTaskBox # noqa: F401
from .svg_task_group import SvgTaskGroup # noqa: F401
from .svg_task_group import TaskSizes # noqa: F401
from .svg_task_io import SvgTaskIO # noqa: F401
from .svg_task_title import SvgTaskTitle # noqa: F401
from .svg_text import SvgText # noqa: F401
44 changes: 44 additions & 0 deletions src/ewoksdraw/svg/svg_task_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from .svg_group import SvgGroup
from .svg_task import SvgTask

TaskSizes = dict[str, tuple[float, float]]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be best to have a namedtuple instead of tuple[float, float] since it is very easy to confuse width and height if they are only addressed by their index.



class SvgTaskGroup(SvgGroup):
"""
Represents a positioned collection of SvgTask elements.
"""

def __init__(self, svg_tasks: dict[str, SvgTask]):
super().__init__()
self._svg_tasks = svg_tasks
self._width = 0.0
self.add_elements(svg_tasks.values())

def arrange_horizontally(self, gap: float) -> None:
"""
Lays out the tasks left to right, each separated by `gap`.

:param gap: The spacing before, between, and after the tasks.
"""
x = gap
for svg_task in self._svg_tasks.values():
svg_task.translate(x=x, y=gap)
x += svg_task.width + gap
self._width = x

@property
def width(self) -> float:
return self._width

@property
def height(self) -> float:
if not self._svg_tasks:
return 0.0
return max(svg_task.height for svg_task in self._svg_tasks.values())

def extract_task_sizes(self) -> TaskSizes:
return {
task_id: (svg_task.width, svg_task.height)
for task_id, svg_task in self._svg_tasks.items()
}
145 changes: 145 additions & 0 deletions src/ewoksdraw/tests/test_elk_converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import pytest
from ewokscore import load_graph
from ewokscore.tests.examples.graphs import get_graph
from ewokscore.tests.examples.graphs import graph_names
from pyelk.graph import validate_graph

from ewoksdraw.layout.elk_converter import LAYOUT_OPTIONS
from ewoksdraw.layout.elk_converter import convert_ewoks_to_elk_graph

_TASK_TYPE = "ewokscore.tests.examples.tasks.sumtask.SumTask"


def _node(node_id: str) -> dict:
return {"id": node_id, "task_type": "class", "task_identifier": _TASK_TYPE}


def _task_sizes(graph) -> dict[str, tuple[float, float]]:
return {
node_id: (10.0 * i, 20.0 * i)
for i, node_id in enumerate(graph.graph.nodes, start=1)
}


def test_top_level_structure():
graph_description, _ = get_graph("acyclic1")
graph = load_graph(graph_description)

elk_graph = convert_ewoks_to_elk_graph(graph, _task_sizes(graph))

assert elk_graph["id"] == "root"
assert elk_graph["layoutOptions"] == LAYOUT_OPTIONS
assert "children" in elk_graph
assert "edges" in elk_graph


def test_children_match_task_sizes():
graph_description, _ = get_graph("acyclic1")
graph = load_graph(graph_description)
task_sizes = _task_sizes(graph)

elk_graph = convert_ewoks_to_elk_graph(graph, task_sizes)

assert len(elk_graph["children"]) == len(task_sizes)
for child in elk_graph["children"]:
width, height = task_sizes[child["id"]]
assert child["width"] == width
assert child["height"] == height


def test_link_without_data_mapping_produces_one_elk_edge():
graph_description = {
"graph": {"id": "g", "label": "g", "schema_version": "1.1"},
"nodes": [_node("a"), _node("b")],
"links": [{"source": "a", "target": "b"}],
}
graph = load_graph(graph_description)

elk_graph = convert_ewoks_to_elk_graph(graph, {"a": (1.0, 1.0), "b": (1.0, 1.0)})

assert elk_graph["edges"] == [
{"id": "edge_a_b_0", "sources": ["a"], "targets": ["b"]}
]


def test_link_with_multiple_data_mappings_produces_one_elk_edge_per_mapping():
graph_description = {
"graph": {"id": "g", "label": "g", "schema_version": "1.1"},
"nodes": [_node("a"), _node("b")],
"links": [
{
"source": "a",
"target": "b",
"data_mapping": [
{"source_output": "result", "target_input": "a"},
{"source_output": "result", "target_input": "b"},
],
}
],
}
graph = load_graph(graph_description)

elk_graph = convert_ewoks_to_elk_graph(graph, {"a": (1.0, 1.0), "b": (1.0, 1.0)})

assert elk_graph["edges"] == [
{"id": "edge_a_b_0", "sources": ["a"], "targets": ["b"]},
{"id": "edge_a_b_1", "sources": ["a"], "targets": ["b"]},
]


def test_graph_without_link():
graph_description, _ = get_graph("empty")
graph = load_graph(graph_description)

elk_graph = convert_ewoks_to_elk_graph(graph, {})

assert elk_graph["children"] == []
assert elk_graph["edges"] == []


@pytest.mark.parametrize("graph_name", graph_names())
def test_children_and_edges_count_across_example_graphs(graph_name):
graph_description, _ = get_graph(graph_name)
graph = load_graph(graph_description)
task_sizes = _task_sizes(graph)

elk_graph = convert_ewoks_to_elk_graph(graph, task_sizes)

assert len(elk_graph["children"]) == graph.graph.number_of_nodes()

expected_edge_count = sum(
len(link_attrs.get("data_mapping") or []) or 1
for _, _, link_attrs in graph.graph.edges(data=True)
)
assert len(elk_graph["edges"]) == expected_edge_count


def test_task_sizes_missing_a_node_raises():
graph_description, _ = get_graph("acyclic1")
graph = load_graph(graph_description)

with pytest.raises(ValueError):
convert_ewoks_to_elk_graph(graph, {"task1": (10.0, 20.0)})


def test_task_sizes_with_extra_task_id_raises():
graph_description, _ = get_graph("acyclic1")
graph = load_graph(graph_description)
task_sizes = _task_sizes(graph)
task_sizes["not_a_node"] = (1.0, 1.0)

with pytest.raises(ValueError):
convert_ewoks_to_elk_graph(graph, task_sizes)


@pytest.mark.parametrize("graph_name", graph_names())
def test_output_is_a_valid_pyelk_graph(graph_name):
"""Check graph validation from pyelk"""

graph_description, _ = get_graph(graph_name)
graph = load_graph(graph_description)
task_sizes = _task_sizes(graph)

elk_graph = convert_ewoks_to_elk_graph(graph, task_sizes)

validate_graph(elk_graph)
3 changes: 2 additions & 1 deletion src/ewoksdraw/tests/test_graph_to_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
def _get_svg_groups(output_path: Path):
tree = ElementTree.parse(output_path)
root = tree.getroot()
return [child for child in root if child.tag.endswith("g")]
task_group = next(child for child in root if child.tag.endswith("g"))
return [child for child in task_group if child.tag.endswith("g")]


@pytest.mark.parametrize("graph_name", graph_names())
Expand Down
Loading