From 238b6160ef1401562fcb20e07a48285b43f51665 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Mon, 12 Oct 2026 11:17:44 +1000 Subject: [PATCH] feat: Vertex.edges_to() lists every edge from a vertex to another edgeto() returns the one edge a path would use, the cheapest of any parallel edges. There was no way to see the parallel edges themselves other than filtering incidences() by hand. edges_to() returns all of them, in connection order, as a new list: empty rather than an error when there are none, only the edges from self to dest for a directed graph, and an undirected self-loop once, not once per end. edgeto() is now built on it with the same result, and says adjacent rather than neighbour. The seealso links join the two and the edge-list family. Co-Authored-By: Claude Sonnet 5.5 --- src/pgraph/PGraph.py | 64 ++++++++++++++++++++++--- tests/test_edges_to.py | 103 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 tests/test_edges_to.py diff --git a/src/pgraph/PGraph.py b/src/pgraph/PGraph.py index 7351806b..0454d6b1 100644 --- a/src/pgraph/PGraph.py +++ b/src/pgraph/PGraph.py @@ -3902,6 +3902,7 @@ def incidences(self) -> list[tuple[Vertex, Edge]]: :seealso: :meth:`adjacent` :meth:`isadjacent` :meth:`successors` :meth:`out_edges` :meth:`in_edges` :meth:`predecessors` + :meth:`edgeto` :meth:`edges_to` :meth:`edges` :meth:`edgeto` :attr:`degree` :meth:`Graph.adjacency` """ @@ -4016,11 +4017,11 @@ def _bind_edge(self, edge: Edge, dest: Vertex) -> None: def edgeto(self, dest: Vertex) -> Edge: """ - Get edge connecting vertex to specific neighbour + The edge from this vertex to an adjacent vertex - :param dest: a neigbouring vertex + :param dest: an adjacent vertex :type dest: ``Vertex`` subclass - :raises ValueError: ``dest`` is not a neighbour + :raises ValueError: ``dest`` is not adjacent to this vertex :return: the edge from this vertex to ``dest`` :rtype: Edge @@ -4038,8 +4039,10 @@ def edgeto(self, dest: Vertex) -> Edge: >>> v2 = g.add_vertex(coord=[1,1], name='v2') >>> g.add_edge(v1, v2, cost=1.414) >>> print(v1.edgeto(v2)) + + :seealso: :meth:`edges_to` :meth:`incidences` :meth:`adjacent` """ - edges = [e for n, e in self.incidences() if n is dest] + edges = self.edges_to(dest) if not edges: raise ValueError("dest is not a neighbour") costed = [e for e in edges if e.cost is not None] @@ -4047,6 +4050,54 @@ def edgeto(self, dest: Vertex) -> Edge: return min(costed, key=lambda e: e.cost) # type: ignore[arg-type,return-value] return edges[0] + def edges_to(self, dest: Vertex) -> list[Edge]: + """ + All the edges from this vertex to another vertex + + :param dest: the vertex at the far end + :type dest: ``Vertex`` subclass + :return: every edge from this vertex to ``dest``, in the order they + were connected, which is empty if there are none + :rtype: list of Edge + + This is how to see parallel edges. :meth:`edgeto` returns the one + edge that a path would use, the cheapest, whereas this returns them + all. + + .. note:: + + - For a directed graph only the edges leaving this vertex for + ``dest`` are included, so ``a.edges_to(b)`` and + ``b.edges_to(a)`` are different lists + - For an undirected graph the edges are found from either end, + whichever vertex they were connected from + - A self-loop is listed once, although its vertex holds it twice + (see :meth:`edges`) + - Unlike :meth:`edgeto`, there being no edge is not an error, the + list is empty + - The list is a new one, so changing it does not change the graph + + .. runblock:: pycon + + >>> from pgraph import UGraph + >>> g = UGraph() + >>> v1 = g.add_vertex(coord=[0,0], name='v1') + >>> v2 = g.add_vertex(coord=[1,1], name='v2') + >>> g.add_edge(v1, v2, cost=3) + >>> g.add_edge(v1, v2, cost=1) + >>> print(v1.edges_to(v2)) + >>> print(v1.edgeto(v2)) + + :seealso: :meth:`edgeto` :meth:`edges` :meth:`out_edges` + :meth:`incidences` :meth:`adjacent` + """ + found: list[Edge] = [] + for vertex, edge in self.incidences(): + # a self-loop is held twice by its vertex, so is met twice + if vertex is dest and not any(edge is e for e in found): + found.append(edge) + return found + def edges(self) -> list[Edge]: """ Return a list of the edges for vertex @@ -4074,7 +4125,8 @@ def edges(self) -> list[Edge]: >>> print(v1.edges()) :seealso: :meth:`out_edges` :meth:`in_edges` :meth:`adjacent` - :meth:`incidences` :attr:`degree` :meth:`Graph.edges` + :meth:`incidences` :meth:`edges_to` :attr:`degree` + :meth:`Graph.edges` """ return list(self._edgelist) @@ -4226,7 +4278,7 @@ def out_edges(self) -> list[Edge]: graph every incident edge is outgoing. :seealso: :meth:`in_edges` :meth:`edges` :meth:`adjacent` :meth:`successors` - :meth:`incidences` :attr:`out_degree` + :meth:`incidences` :meth:`edges_to` :attr:`out_degree` """ return list(self._edgelist) diff --git a/tests/test_edges_to.py b/tests/test_edges_to.py new file mode 100644 index 00000000..cf3a375f --- /dev/null +++ b/tests/test_edges_to.py @@ -0,0 +1,103 @@ +"""``edges_to(dest)`` lists every edge from a vertex to ``dest``. + +``edgeto(dest)`` returns the one edge a path would use, the cheapest of any +parallel edges. ``edges_to`` is the whole set, so that parallel edges can be +inspected: in connection order, empty rather than an error when there are none, +and with an undirected self-loop listed once, not once per end. +""" + +import unittest + +from pgraph import DGraph, UGraph + + +class _EdgesTo: + Graph: type + + def setUp(self): + self.g = self.Graph() + self.a, self.b, self.c = (self.g.add_vertex(name=n) for n in "abc") + + def test_single_edge(self): + e = self.a.connect(self.b, cost=3) + self.assertEqual(self.a.edges_to(self.b), [e]) + + def test_parallel_edges_in_connection_order(self): + e1 = self.a.connect(self.b, cost=5) + e2 = self.a.connect(self.b, cost=2) + e3 = self.a.connect(self.b, cost=9) + self.assertEqual(self.a.edges_to(self.b), [e1, e2, e3]) + + def test_edgeto_is_the_cheapest_of_edges_to(self): + edges = [self.a.connect(self.b, cost=c) for c in (5, 2, 9)] + self.assertIs(self.a.edgeto(self.b), edges[1]) + self.assertIn(self.a.edgeto(self.b), self.a.edges_to(self.b)) + + def test_none_is_an_empty_list_not_an_error(self): + self.a.connect(self.b) + self.assertEqual(self.a.edges_to(self.c), []) + with self.assertRaises(ValueError): + self.a.edgeto(self.c) # edgeto has no edge to return + + def test_other_destinations_are_not_included(self): + e = self.a.connect(self.b) + self.a.connect(self.c) + self.assertEqual(self.a.edges_to(self.b), [e]) + + def test_self_loop_is_listed_once(self): + loop = self.a.connect(self.a, cost=1) + self.assertEqual(self.a.edges_to(self.a), [loop]) + + def test_loop_and_parallel_loops(self): + l1 = self.a.connect(self.a) + l2 = self.a.connect(self.a) + self.assertEqual(self.a.edges_to(self.a), [l1, l2]) + + def test_is_a_new_list(self): + self.a.connect(self.b) + self.a.edges_to(self.b).clear() + self.assertEqual(len(self.a.edges_to(self.b)), 1) + self.assertEqual(self.g.validate(), []) + + def test_removed_edge_is_gone(self): + e1 = self.a.connect(self.b) + e2 = self.a.connect(self.b) + self.g.remove_edge(e1) + self.assertEqual(self.a.edges_to(self.b), [e2]) + + def test_unconnected_vertex(self): + self.assertEqual(self.a.edges_to(self.b), []) + + +class TestUndirectedEdgesTo(_EdgesTo, unittest.TestCase): + Graph = UGraph + + def test_found_from_either_end(self): + e = self.a.connect(self.b) + self.assertEqual(self.a.edges_to(self.b), [e]) + self.assertEqual(self.b.edges_to(self.a), [e]) + + def test_edges_connected_from_the_other_end_are_included(self): + e1 = self.a.connect(self.b) + e2 = self.b.connect(self.a) + self.assertEqual(self.a.edges_to(self.b), [e1, e2]) + self.assertEqual(self.b.edges_to(self.a), [e1, e2]) + + +class TestDirectedEdgesTo(_EdgesTo, unittest.TestCase): + Graph = DGraph + + def test_only_the_direction_given(self): + e = self.a.connect(self.b) + self.assertEqual(self.a.edges_to(self.b), [e]) + self.assertEqual(self.b.edges_to(self.a), []) + + def test_opposite_edges_are_separate(self): + ab = self.a.connect(self.b) + ba = self.b.connect(self.a) + self.assertEqual(self.a.edges_to(self.b), [ab]) + self.assertEqual(self.b.edges_to(self.a), [ba]) + + +if __name__ == "__main__": + unittest.main()