Skip to content
674 changes: 561 additions & 113 deletions src/pgraph/PGraph.py

Large diffs are not rendered by default.

20 changes: 19 additions & 1 deletion src/pgraph/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,19 @@
from pgraph.PGraph import UGraph, DGraph, UVertex, DVertex, Edge
from pgraph.PGraph import (
BaseGraph,
BaseVertex,
UGraph,
DGraph,
UVertex,
DVertex,
Edge,
)

__all__ = [
"BaseGraph",
"BaseVertex",
"UGraph",
"DGraph",
"UVertex",
"DVertex",
"Edge",
]
43 changes: 30 additions & 13 deletions src/pgraph/exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Export a graph as text (GraphViz DOT, Mermaid, GraphML, ELK JSON) or as a
rendered image (via the GraphViz command line tools).

This module is reached through :meth:`pgraph.PGraph._BaseGraph.export`, which
This module is reached through :meth:`pgraph.PGraph.BaseGraph.export`, which
dispatches on the format name or the file extension. Each text exporter is a
plain function ``f(graph, **options) -> str``, registered with
:func:`register_exporter`, so a user can add a format without touching the
Expand All @@ -27,7 +27,7 @@ def gexf(graph, *, version="1.3") -> str:
from typing import IO, TYPE_CHECKING, Any, Callable

if TYPE_CHECKING:
from .PGraph import BaseVertex, Edge, _BaseGraph
from .PGraph import BaseVertex, Edge, BaseGraph

TextExporter = Callable[..., str]

Expand Down Expand Up @@ -75,6 +75,23 @@ def decorator(func: TextExporter) -> TextExporter:
return decorator


def get_exporter(name: str) -> TextExporter:
"""
Look up a text exporter by format name

:param name: format name, eg. ``"dot"``
:return: the function ``f(graph, **options) -> str``
:raises ValueError: no text exporter has that name; the message lists those
that do
"""
try:
return _TEXT[name]
except KeyError:
raise ValueError(
f"unknown export format {name!r}, expected one of {formats()}"
) from None


def formats() -> list[str]:
"""
Names of all supported export formats
Expand Down Expand Up @@ -114,20 +131,20 @@ def format_from_filename(filename: str | os.PathLike | IO | None) -> str | None:
# helpers shared by the exporters


def _is_directed(graph: _BaseGraph) -> bool:
def _is_directed(graph: BaseGraph) -> bool:
from .PGraph import DGraph # not at module level: PGraph imports this module

return isinstance(graph, DGraph)


def _ordered_edges(graph: _BaseGraph) -> list[tuple[Edge, BaseVertex, BaseVertex]]:
def _ordered_edges(graph: BaseGraph) -> list[tuple[Edge, BaseVertex, BaseVertex]]:
"""
Edges in a reproducible order

``graph.edges()`` is a set, so its order changes from run to run and the
exported text would not be stable (useless for diffs and golden files).
Instead walk the vertices in creation order and each vertex's own edge
list, keeping the first sight of every edge. Identity is by object, not by
Instead walk the vertices in creation order and each vertex's own
outgoing edges, keeping the first sight of every edge. Identity is by object, not by
endpoints, so parallel edges are all kept, and an undirected edge (held by
both ends, twice for a self-loop) is listed once.

Expand All @@ -136,15 +153,15 @@ def _ordered_edges(graph: _BaseGraph) -> list[tuple[Edge, BaseVertex, BaseVertex
seen: set[int] = set()
edges: list[tuple[Edge, BaseVertex, BaseVertex]] = []
for vertex in graph:
for e in vertex._edgelist:
for e in vertex.out_edges():
if id(e) in seen or e.v1 is None or e.v2 is None:
continue
seen.add(id(e))
edges.append((e, e.v1, e.v2))
return edges


def _component_colors(graph: _BaseGraph) -> dict[BaseVertex, str]:
def _component_colors(graph: BaseGraph) -> dict[BaseVertex, str]:
"""
Map each vertex to a ``#rrggbb`` colour for its component, as plot() does
"""
Expand Down Expand Up @@ -194,7 +211,7 @@ def _dot_quote(text: str) -> str:

@register_exporter("dot", ".dot", ".gv")
def dot(
graph: _BaseGraph,
graph: BaseGraph,
*,
direction: str | None = None,
colorcomponents: bool = False,
Expand Down Expand Up @@ -243,7 +260,7 @@ def dot(

@register_exporter("mermaid", ".mmd", ".mermaid")
def mermaid(
graph: _BaseGraph,
graph: BaseGraph,
*,
direction: str = "TB",
colorcomponents: bool = False,
Expand Down Expand Up @@ -292,7 +309,7 @@ def mermaid(


@register_exporter("graphml", ".graphml")
def graphml(graph: _BaseGraph) -> str:
def graphml(graph: BaseGraph) -> str:
"""
GraphML XML text

Expand Down Expand Up @@ -357,7 +374,7 @@ def key(key_id: str, domain: str, name: str, type_: str) -> str:

@register_exporter("elk", ".elk.json", ".elk")
def elk(
graph: _BaseGraph,
graph: BaseGraph,
*,
size: tuple[float, float] = (60, 40),
layout_options: dict[str, str] | None = None,
Expand Down Expand Up @@ -433,7 +450,7 @@ def elk(


def render(
graph: _BaseGraph,
graph: BaseGraph,
fmt: str,
*,
engine: str | None = None,
Expand Down
116 changes: 116 additions & 0 deletions tests/test_add_vertex_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""
A graph's vertices have unique names (the name is the key ``g[name]`` looks
them up by), and a vertex belongs to at most one graph. ``add_vertex`` used to
overwrite the earlier vertex's table entry on a clash, and the default
``#N`` names could clash after a removal; it now refuses, and chooses a default
name that is free.
"""

import unittest

from pgraph import DGraph, UGraph, UVertex


class _AddVertex:
Graph: type
Vertex: type

def setUp(self):
self.g = self.Graph()

def snapshot(self, g):
return (g.n, sorted(g._vertexdict), [v.name for v in g])

def test_duplicate_name_is_refused(self):
a = self.g.add_vertex(name="a")
before = self.snapshot(self.g)
with self.assertRaises(ValueError) as cm:
self.g.add_vertex(name="a")
self.assertIn("'a'", str(cm.exception))
self.assertEqual(self.snapshot(self.g), before)
self.assertIs(self.g["a"], a)
self.assertEqual(self.g.validate(), [])

def test_duplicate_through_a_vertex_instance(self):
self.g.add_vertex(name="a")
clash = self.Vertex([1, 1], name="a")
with self.assertRaises(ValueError):
self.g.add_vertex(clash)
self.assertIsNone(clash._graph) # untouched: not half added
self.assertEqual(self.g.n, 1)

def test_name_argument_overriding_the_instance_name_is_checked_too(self):
self.g.add_vertex(name="taken")
v = self.Vertex([0, 0], name="free")
with self.assertRaises(ValueError):
self.g.add_vertex(v, name="taken")
self.assertEqual(v.name, "free") # a refused add does not rename it

def test_default_names_are_unique_after_removals(self):
vs = [self.g.add_vertex() for _ in range(4)] # #0 .. #3
self.g.remove_vertex(vs[0])
self.g.remove_vertex(vs[2])
more = [self.g.add_vertex() for _ in range(3)]
names = [v.name for v in self.g]
self.assertEqual(len(names), len(set(names)))
self.assertEqual(self.g.n, 5)
self.assertEqual(self.g.validate(), [])
for v in more:
self.assertIs(self.g[v.name], v)

def test_default_name_skips_one_given_explicitly(self):
self.g.add_vertex(name="#1")
a = self.g.add_vertex()
b = self.g.add_vertex()
self.assertEqual(len({"#1", a.name, b.name}), 3)
self.assertEqual(self.g.validate(), [])

def test_default_names_still_count_from_zero(self):
self.assertEqual([self.g.add_vertex().name for _ in range(3)], ["#0", "#1", "#2"])

def test_a_vertex_in_this_graph_cannot_be_added_again(self):
a = self.g.add_vertex(name="a")
with self.assertRaises(ValueError):
self.g.add_vertex(a)
self.assertEqual(self.g.n, 1)
self.assertEqual(self.g.validate(), [])

def test_a_vertex_in_another_graph_cannot_be_added(self):
other = self.Graph()
v = other.add_vertex(name="v")
with self.assertRaises(ValueError):
self.g.add_vertex(v)
self.assertIs(v._graph, other) # still where it was
self.assertEqual(other.validate(), [])
self.assertEqual(self.g.n, 0)

def test_a_removed_vertex_can_be_added_again_or_elsewhere(self):
a = self.g.add_vertex(name="a")
self.g.remove_vertex(a)
self.assertIs(self.g.add_vertex(a), a)
self.g.remove_vertex(a)
other = self.Graph()
self.assertIs(other.add_vertex(a), a)
self.assertEqual(other.validate(), [])

def test_wrong_dimension_changes_nothing(self):
g = self.Graph(dim=3)
v = self.Vertex([1, 2], name="short")
with self.assertRaises(ValueError):
g.add_vertex(v)
self.assertEqual(g.n, 0)
self.assertIsNone(v._graph)


class TestUGraph(_AddVertex, unittest.TestCase):
Graph, Vertex = UGraph, UVertex


class TestDGraph(_AddVertex, unittest.TestCase):
from pgraph import DVertex as Vertex

Graph = DGraph


if __name__ == "__main__":
unittest.main()
Loading
Loading