Skip to content

Commit bffb7be

Browse files
authored
Merge pull request #88 from petercorke/refactor/narrow-with-helpers
refactor: narrow optional edge ends and vertex names with a checked helper
2 parents 343a0f2 + 7adc047 commit bffb7be

3 files changed

Lines changed: 127 additions & 41 deletions

File tree

‎src/pgraph/PGraph.py‎

Lines changed: 76 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,14 +1482,14 @@ def remove_vertex(self, vertex: Vertex) -> None:
14821482
"""
14831483
if vertex._graph is not self:
14841484
raise ValueError("vertex does not belong to this graph")
1485-
assert vertex.name is not None
1485+
name = vertex._require_name()
14861486

14871487
# its own lists name every edge at it, so no need to search the graph
14881488
for edge in vertex._incident_edges():
14891489
self.remove_edge(edge)
14901490

14911491
self._vertexlist.remove(vertex)
1492-
del self._vertexdict[vertex.name]
1492+
del self._vertexdict[name]
14931493
vertex._graph = None # it is not in any graph now
14941494
vertex._label = None # and so not in a component
14951495
self._connectivitychange = True
@@ -2273,7 +2273,8 @@ def near(self, coord: ArrayLike, radius: float) -> list[tuple[Vertex, float]]:
22732273

22742274
found = []
22752275
for vertex in candidates:
2276-
assert vertex.coord is not None # the candidates all have a position
2276+
# type narrowing only: the candidates all have a position
2277+
assert vertex.coord is not None # nosec B101
22772278
dist = float(self.metric(vertex.coord - coord))
22782279
if dist <= radius:
22792280
found.append((vertex, dist))
@@ -2339,19 +2340,19 @@ def _edge_layout(self) -> tuple[dict[Edge, float], dict[Edge, int]]:
23392340
groups: dict[frozenset[int], list[Edge]] = {}
23402341
loops: dict[Vertex, list[Edge]] = {}
23412342
for e in self._edges:
2342-
assert e.v1 is not None and e.v2 is not None
2343-
if e.v1 is e.v2:
2344-
loops.setdefault(e.v1, []).append(e)
2343+
v1, v2 = e._ends()
2344+
if v1 is v2:
2345+
loops.setdefault(v1, []).append(e)
23452346
else:
2346-
groups.setdefault(frozenset((index[e.v1], index[e.v2])), []).append(e)
2347+
groups.setdefault(frozenset((index[v1], index[v2])), []).append(e)
23472348
rads: dict[Edge, float] = {}
23482349
for edges in groups.values():
23492350
n = len(edges)
23502351
for k, e in enumerate(edges):
2351-
assert e.v1 is not None and e.v2 is not None
2352+
v1, v2 = e._ends()
23522353
offset = (k - (n - 1) / 2) * _ARC_STEP
23532354
# the same side in space for an edge drawn the other way round
2354-
rads[e] = offset if index[e.v1] < index[e.v2] else -offset
2355+
rads[e] = offset if index[v1] < index[v2] else -offset
23552356
loopno = {e: k for edges in loops.values() for k, e in enumerate(edges)}
23562357
return rads, loopno
23572358

@@ -2380,8 +2381,7 @@ def _edge_artist(
23802381
from matplotlib.patches import FancyArrowPatch
23812382
from matplotlib.transforms import Affine2D, ScaledTranslation
23822383
from ._plot import _Arrow3D, _Loop3D, _loop_path
2383-
v1, v2 = edge.v1, edge.v2
2384-
assert v1 is not None and v2 is not None
2384+
v1, v2 = edge._ends()
23852385
shrink = markersize / 2 + 1
23862386
if v1 is v2:
23872387
radius = _LOOP_RADIUS + _LOOP_GROWTH * loopno
@@ -2627,10 +2627,10 @@ def plot(
26272627
# the edges, each once
26282628
rads, loopno = self._edge_layout()
26292629
for e in self._edges:
2630-
assert e.v1 is not None
2630+
start = e._ends()[0]
26312631
kwargs = dict(edge_kwargs)
26322632
if color_components:
2633-
kwargs = {**dict(color=color[e.v1.label, :]), **kwargs}
2633+
kwargs = {**dict(color=color[start.label, :]), **kwargs}
26342634
artist = self._edge_artist(
26352635
ax,
26362636
e,
@@ -2656,10 +2656,8 @@ def plot(
26562656
)
26572657
dx, dy = abs(x1 - x0), abs(y1 - y0)
26582658
for e in loopno:
2659-
assert e.v1 is not None
2660-
ax.update_datalim(
2661-
[(e.v1.x - dx, e.v1.y - dy), (e.v1.x + dx, e.v1.y + dy)]
2662-
)
2659+
v = e._ends()[0]
2660+
ax.update_datalim([(v.x - dx, v.y - dy), (v.x + dx, v.y + dy)])
26632661
ax.autoscale_view()
26642662

26652663
ax.grid(grid)
@@ -3443,11 +3441,11 @@ def incidence(self) -> NDArray:
34433441
vdict[vertex] = i
34443442

34453443
for j, e in enumerate(edges):
3446-
assert e.v1 is not None and e.v2 is not None
3447-
I[vdict[e.v1], j] = 1
3448-
I[vdict[e.v2], j] = 1
3449-
if e.v1 is e.v2 and not self.directed:
3450-
I[vdict[e.v1], j] = 2 # self-loop, counted at both ends
3444+
v1, v2 = e._ends()
3445+
I[vdict[v1], j] = 1
3446+
I[vdict[v2], j] = 1
3447+
if v1 is v2 and not self.directed:
3448+
I[vdict[v1], j] = 2 # self-loop, counted at both ends
34513449

34523450
return I
34533451

@@ -3800,8 +3798,7 @@ def path_UCS(
38003798

38013799
parent_names: dict[str, str] = {}
38023800
for v, p in parent.items():
3803-
assert v.name is not None and p.name is not None
3804-
parent_names[v.name] = p.name
3801+
parent_names[v._require_name()] = p._require_name()
38053802

38063803
if summary or verbose:
38073804
print(
@@ -3938,8 +3935,7 @@ def path_Astar(
39383935

39393936
parent_names: dict[str, str] = {}
39403937
for v, p in parent.items():
3941-
assert v.name is not None and p.name is not None
3942-
parent_names[v.name] = p.name
3938+
parent_names[v._require_name()] = p._require_name()
39433939

39443940
if summary or verbose:
39453941
print(
@@ -3980,9 +3976,9 @@ def _attach(self, edge: Edge) -> None:
39803976
from either end. A self-loop is therefore held twice by its vertex,
39813977
which is why it counts 2 towards the degree.
39823978
"""
3983-
assert edge.v1 is not None and edge.v2 is not None
3984-
edge.v1._edgelist.append(edge)
3985-
edge.v2._edgelist.append(edge)
3979+
v1, v2 = edge._ends()
3980+
v1._edgelist.append(edge)
3981+
v2._edgelist.append(edge)
39863982

39873983
def _detach(self, edge: Edge) -> None:
39883984
"""
@@ -4035,9 +4031,9 @@ def _attach(self, edge: Edge) -> None:
40354031
The edge goes in the start vertex's list of outgoing edges and in the
40364032
end vertex's list of incoming ones.
40374033
"""
4038-
assert edge.v1 is not None and edge.v2 is not None
4039-
edge.v1._edgelist.append(edge)
4040-
edge.v2._inlist.append(edge)
4034+
v1, v2 = edge._ends()
4035+
v1._edgelist.append(edge)
4036+
v2._inlist.append(edge)
40414037

40424038
def _detach(self, edge: Edge) -> None:
40434039
"""
@@ -4210,6 +4206,27 @@ def _set_endpoints(self, v1: Vertex | None, v2: Vertex | None) -> None:
42104206
self._v1 = v1
42114207
self._v2 = v2
42124208

4209+
def _ends(self) -> tuple[Vertex, Vertex]:
4210+
"""
4211+
Both vertices of the edge, raising clearly if it is not connected (private method)
4212+
4213+
:raises ValueError: the edge has no vertices
4214+
:return: the first and second vertex
4215+
:rtype: tuple of Vertex subclass
4216+
4217+
``v1`` and ``v2`` are None for an edge that has not been connected.
4218+
Every edge in a graph has both, so the code that works on them takes
4219+
them from here, which tells the type checker so, rather than testing
4220+
for None at each use. Unlike an ``assert`` this check is not removed
4221+
by ``python -O``.
4222+
4223+
:seealso: :attr:`v1` :attr:`v2`
4224+
"""
4225+
v1, v2 = self._v1, self._v2
4226+
if v1 is None or v2 is None:
4227+
raise ValueError("the edge is not connected to two vertices")
4228+
return v1, v2
4229+
42134230
def __reduce__(self) -> Any:
42144231
"""
42154232
Refuse to pickle or copy an edge on its own
@@ -4341,12 +4358,11 @@ def next(self, vertex: Vertex) -> Vertex:
43414358
43424359
"""
43434360

4344-
if self.v1 is vertex:
4345-
assert self.v2 is not None
4346-
return self.v2
4347-
elif self.v2 is vertex:
4348-
assert self.v1 is not None
4349-
return self.v1
4361+
v1, v2 = self._ends()
4362+
if v1 is vertex:
4363+
return v2
4364+
elif v2 is vertex:
4365+
return v1
43504366
else:
43514367
raise ValueError("should not happen")
43524368

@@ -4385,8 +4401,7 @@ def endpoints(self) -> list[Vertex]:
43854401
43864402
:seealso: :meth:`vertices`
43874403
"""
4388-
assert self.v1 is not None and self.v2 is not None
4389-
return [self.v1, self.v2]
4404+
return list(self._ends())
43904405

43914406
def remove(self) -> None:
43924407
"""
@@ -4811,7 +4826,8 @@ def _bind_edge(self, edge: Edge, dest: Vertex) -> None:
48114826
endpoints disagree with the vertices' edge lists.
48124827
"""
48134828
graph = self._graph
4814-
assert graph is not None # connect() has checked
4829+
# type narrowing only: connect() has checked
4830+
assert graph is not None # nosec B101
48154831
if edge.v1 is None and edge.v2 is None:
48164832
edge._set_endpoints(self, dest)
48174833
elif not graph._endpoints_match(edge, self, dest):
@@ -5148,6 +5164,25 @@ def _incident_edges(self) -> list[Edge]:
51485164
seen.setdefault(id(e), e)
51495165
return list(seen.values())
51505166

5167+
def _require_name(self) -> str:
5168+
"""
5169+
The name, raising clearly if the vertex has none (private method)
5170+
5171+
:raises ValueError: the vertex has no name
5172+
:return: the name
5173+
:rtype: str
5174+
5175+
A vertex that is not in a graph can have no name, but one in a graph
5176+
always has, so the code that works on the vertices of a graph takes
5177+
the name from here, which tells the type checker so. Unlike an
5178+
``assert`` this check is not removed by ``python -O``.
5179+
5180+
:seealso: :attr:`name`
5181+
"""
5182+
if self._name is None:
5183+
raise ValueError("the vertex has no name")
5184+
return self._name
5185+
51515186
@property
51525187
def name(self) -> str | None:
51535188
"""

‎tests/test_edge_endpoints.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,5 +120,42 @@ def test_repr_shows_the_endpoints_and_an_edge_alone_is_not_copied(self):
120120
self.assertEqual((dup.v1.name, dup.v2.name), ("a", "b"))
121121

122122

123+
class TestUnconnected(unittest.TestCase):
124+
"""The code that works on both ends of an edge says so if there are none"""
125+
126+
def test_a_connected_edge_gives_its_ends(self):
127+
g = UGraph()
128+
a = g.add_vertex([0, 0], name="a")
129+
b = g.add_vertex([1, 0], name="b")
130+
e = a.connect(b)
131+
self.assertEqual(e.endpoints, [a, b])
132+
self.assertIs(e.next(a), b)
133+
self.assertIs(e.next(b), a)
134+
135+
def test_an_edge_with_no_vertices_raises_clearly(self):
136+
e = Edge()
137+
self.assertIsNone(e.v1)
138+
self.assertIsNone(e.v2)
139+
v = UGraph().add_vertex([0, 0], name="a")
140+
for name, call in (
141+
("_ends", e._ends),
142+
("endpoints", lambda: e.endpoints),
143+
("next", lambda: e.next(v)),
144+
):
145+
with self.subTest(name=name):
146+
with self.assertRaises(ValueError) as cm:
147+
call()
148+
self.assertIn("not connected", str(cm.exception))
149+
150+
def test_next_of_a_vertex_that_is_not_an_end(self):
151+
g = UGraph()
152+
a = g.add_vertex([0, 0], name="a")
153+
b = g.add_vertex([1, 0], name="b")
154+
c = g.add_vertex([2, 0], name="c")
155+
e = a.connect(b)
156+
with self.assertRaises(ValueError):
157+
e.next(c)
158+
159+
123160
if __name__ == "__main__":
124161
unittest.main()

‎tests/test_vertex_init.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,5 +161,19 @@ def test_assigning_other_attributes_is_not_a_change(self):
161161
self.assertFalse(g._spatialchange)
162162

163163

164+
class TestRequireName(unittest.TestCase):
165+
"""The code that works on the vertices of a graph takes their names from here"""
166+
167+
def test_a_named_vertex_gives_its_name(self):
168+
self.assertEqual(Vertex(name="a")._require_name(), "a")
169+
# a vertex in a graph always has one: the default is "#0", "#1", ...
170+
self.assertEqual(UGraph().add_vertex()._require_name(), "#0")
171+
172+
def test_a_vertex_with_no_name_raises_clearly(self):
173+
with self.assertRaises(ValueError) as cm:
174+
Vertex()._require_name()
175+
self.assertIn("no name", str(cm.exception))
176+
177+
164178
if __name__ == "__main__":
165179
unittest.main()

0 commit comments

Comments
 (0)