Skip to content

graflo.plot.merge

Draw a merge preview: the declaration graph, with its conflicts marked.

The picture a :class:~graflo.architecture.evolution.preview.MergePreview is asking to be: each side's classes down its own column, the merged names between them, and every declaration as an edge. Classes are drawn as tables — one row per attribute, each its own Graphviz port — so a property rename lands on the row it renames rather than somewhere on the box. That is the whole reason to draw this at all: class_A - attr_a - attr_b - class_B is a statement about attributes, and a diagram that only draws classes cannot show it.

Findings colour what they name and are numbered into a legend, so the picture says where as well as what. A refusal — the one merge actually raised — is red; everything the structural pass found on its own is amber; the declarations a completion suggests are dashed green, because a refusal that carries its own fix should look like one.

:func:build_preview_graph returns a plain :mod:networkx graph and needs no Graphviz, so what is drawn can be asserted directly; :func:plot_merge_preview is the half that writes a file.

Attributes

EDGE_STYLE = {'member': {'style': 'solid', 'color': '#2C3E50', 'penwidth': '1.4'}, 'map': {'style': 'dashed', 'color': '#1F5FBF'}, 'property_map': {'style': 'dashed', 'color': '#1F5FBF', 'penwidth': '0.7'}, 'property_equivalence': {'style': 'dotted', 'color': '#6B4C9A', 'penwidth': '0.9'}, 'suggested': {'style': 'dashed', 'color': '#1E8449', 'penwidth': '1.2'}} module-attribute

HEADER_COLOR = {'left': '#B7D1DF', 'right': '#BEDFC8', 'merged': '#FFE5B4', 'canonical': '#DDD0E5', 'ghost': '#E4E1D7', 'relation': '#FFFFFF'} module-attribute

SEVERITY_COLOR = {'refusal': '#C0392B', 'possible': '#E67E22', 'note': '#7F8C8D'} module-attribute

__all__ = ['EDGE_STYLE', 'HEADER_COLOR', 'SEVERITY_COLOR', 'build_preview_graph', 'plot_merge_preview'] module-attribute

Classes

Functions:

build_preview_graph(preview, *, max_rows=12, legend=True)

The preview as a networkx graph, styled but not yet drawn.

Only classes, relations and merged names become nodes: attributes are rows on their owner, addressed by port. Every node carries the preview id it came from as preview_id, so a caller can map back.

Parameters:

Name Type Description Default
preview MergePreview

What :func:~graflo.architecture.evolution.preview.preview_merge returned.

required
max_rows int

Attribute rows to draw per class before summarising the rest.

12
legend bool

Whether to include the findings table.

True

Returns:

Type Description
MultiDiGraph

A graph whose node and edge attributes are Graphviz attributes.

Source code in graflo/plot/merge.py
def build_preview_graph(
    preview: MergePreview, *, max_rows: int = 12, legend: bool = True
) -> nx.MultiDiGraph:
    """The preview as a networkx graph, styled but not yet drawn.

    Only classes, relations and merged names become nodes: attributes are
    rows on their owner, addressed by port. Every node carries the preview id
    it came from as ``preview_id``, so a caller can map back.

    Args:
        preview: What :func:`~graflo.architecture.evolution.preview.preview_merge` returned.
        max_rows: Attribute rows to draw per class before summarising the rest.
        legend: Whether to include the findings table.

    Returns:
        A graph whose node and edge attributes are Graphviz attributes.
    """
    graph = nx.MultiDiGraph()
    graph.graph["graph"] = {
        "rankdir": "LR",
        "splines": "spline",
        "fontname": "Helvetica",
        "labelloc": "t",
        "label": _title(preview),
    }
    graph.graph["node"] = {"fontname": "Helvetica", "shape": "plain"}
    graph.graph["edge"] = {"fontname": "Helvetica", "fontsize": "9"}

    node_flags, edge_flags, badges = _flags(preview)
    taken: dict[str, str] = {}
    attributes: dict[str, list[PreviewNode]] = {}
    for node in preview.nodes:
        if node.owner is not None:
            attributes.setdefault(node.owner, []).append(node)

    row_flags_by_owner: dict[str, dict[str, str]] = {}
    for node in preview.nodes:
        if node.owner is not None and node.id in node_flags:
            row_flags_by_owner.setdefault(node.owner, {})[node.id] = node_flags[node.id]

    for node in preview.nodes:
        if node.owner is not None:
            continue  # an attribute: a row on its owner
        safe = sanitize_id(node.id, taken)
        graph.add_node(
            safe,
            label=_table(
                node,
                attributes.get(node.id, []),
                badges=badges.get(node.id, []),
                flagged=node_flags.get(node.id),
                row_flags=row_flags_by_owner.get(node.id, {}),
                max_rows=max_rows,
            ),
            preview_id=node.id,
            kind=node.kind,
            side=node.side or "",
        )

    for edge in preview.edges:
        source = preview.node(edge.source)
        target = preview.node(edge.target)
        if source is None or target is None:
            continue
        tail = source.owner or source.id
        head = target.owner or target.id
        if tail not in taken or head not in taken:
            continue
        attrs: dict[str, Any] = {
            **EDGE_STYLE.get(edge.kind, {}),
            "preview_id": edge.id,
            "kind": edge.kind,
        }
        if source.owner is not None:
            attrs["tailport"] = _port(source.name)
        if target.owner is not None:
            attrs["headport"] = _port(target.name)
        if edge.label:
            attrs["label"] = edge.label
        severity = edge_flags.get(edge.id)
        if severity:
            attrs["color"] = SEVERITY_COLOR[severity]
            attrs["penwidth"] = "2"
        if source.side == "right":
            # rankdir=LR puts a tail on the left. Drawing the right column's
            # edges backwards keeps that column on the right where it belongs
            # -- which means swapping the ports along with the endpoints, or
            # each lands on a row the other node does not have.
            attrs["dir"] = "back"
            attrs["tailport"], attrs["headport"] = (
                attrs.pop("headport", None),
                attrs.pop("tailport", None),
            )
            attrs = {k: v for k, v in attrs.items() if v is not None}
            graph.add_edge(taken[head], taken[tail], **attrs)
        else:
            graph.add_edge(taken[tail], taken[head], **attrs)

    if legend:
        graph.add_node(
            "legend", label=_legend(preview), kind="legend", preview_id="legend"
        )
    return graph

plot_merge_preview(preview, path, *, output_format=None, prog='dot', dpi=None, max_rows=12, legend=True)

Draw preview to path.

Parameters:

Name Type Description Default
preview MergePreview

What preview_merge returned.

required
path str | PathLike[str]

Where to write; the suffix picks the format (svg/pdf/png/dot).

required
output_format str | None

Override the format the suffix implies.

None
prog str

Graphviz layout program.

'dot'
dpi int | None

Raster resolution, for png.

None
max_rows int

Attribute rows per class before the rest are summarised.

12
legend bool

Whether to draw the findings table.

True

Returns:

Type Description
Path

The path written.

Raises:

Type Description
RuntimeError

pygraphviz or system Graphviz is not installed.

ValueError

The format is not one this can write.

Source code in graflo/plot/merge.py
def plot_merge_preview(
    preview: MergePreview,
    path: str | os.PathLike[str],
    *,
    output_format: str | None = None,
    prog: str = "dot",
    dpi: int | None = None,
    max_rows: int = 12,
    legend: bool = True,
) -> Path:
    """Draw *preview* to *path*.

    Args:
        preview: What ``preview_merge`` returned.
        path: Where to write; the suffix picks the format (svg/pdf/png/dot).
        output_format: Override the format the suffix implies.
        prog: Graphviz layout program.
        dpi: Raster resolution, for ``png``.
        max_rows: Attribute rows per class before the rest are summarised.
        legend: Whether to draw the findings table.

    Returns:
        The path written.

    Raises:
        RuntimeError: pygraphviz or system Graphviz is not installed.
        ValueError: The format is not one this can write.
    """
    graph = build_preview_graph(preview, max_rows=max_rows, legend=legend)
    agraph = to_agraph(graph)
    _columns(graph, agraph)
    return draw(agraph, path, output_format=output_format, prog=prog, dpi=dpi)