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()