Skip to content

Commit eb5ff7b

Browse files
petercorkeclaude
andcommitted
refactor: narrow optional edge ends and vertex names with a checked helper
Fourteen of the sixteen assert statements in PGraph.py only told mypy that an optional value was set: the ends of an edge (v1 and v2 are None until it is connected) and the name of a vertex (None until it is in a graph). Bandit reports each as B101, and python -O removes them. Add Edge._ends(), which returns both vertices or raises ValueError, and Vertex._require_name(), which returns the name or raises ValueError, in the manner of Graph._require_cost(). They replace the asserts, so the narrowing is still there for mypy and the check is a real one. The two asserts left (the coordinate of a candidate in near(), the graph in Vertex.connect()) are type narrowing too, and are marked # nosec B101. A connected edge behaves as before. An unconnected one, which no graph holds, now raises ValueError from endpoints and next() instead of AssertionError (or, from next(), "should not happen"). Part of #23. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 4e1c1f9 commit eb5ff7b

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
@@ -1459,14 +1459,14 @@ def remove_vertex(self, vertex: Vertex) -> None:
14591459
"""
14601460
if vertex._graph is not self:
14611461
raise ValueError("vertex does not belong to this graph")
1462-
assert vertex.name is not None
1462+
name = vertex._require_name()
14631463

14641464
# its own lists name every edge at it, so no need to search the graph
14651465
for edge in vertex._incident_edges():
14661466
self.remove_edge(edge)
14671467

14681468
self._vertexlist.remove(vertex)
1469-
del self._vertexdict[vertex.name]
1469+
del self._vertexdict[name]
14701470
vertex._graph = None # it is not in any graph now
14711471
vertex._label = None # and so not in a component
14721472
self._connectivitychange = True
@@ -2266,7 +2266,8 @@ def near(self, coord: ArrayLike, radius: float) -> list[tuple[Vertex, float]]:
22662266

22672267
found = []
22682268
for vertex in candidates:
2269-
assert vertex.coord is not None # the candidates all have a position
2269+
# type narrowing only: the candidates all have a position
2270+
assert vertex.coord is not None # nosec B101
22702271
dist = float(self.metric(vertex.coord - coord))
22712272
if dist <= radius:
22722273
found.append((vertex, dist))
@@ -2332,19 +2333,19 @@ def _edge_layout(self) -> tuple[dict[Edge, float], dict[Edge, int]]:
23322333
groups: dict[frozenset[int], list[Edge]] = {}
23332334
loops: dict[Vertex, list[Edge]] = {}
23342335
for e in self._edges:
2335-
assert e.v1 is not None and e.v2 is not None
2336-
if e.v1 is e.v2:
2337-
loops.setdefault(e.v1, []).append(e)
2336+
v1, v2 = e._ends()
2337+
if v1 is v2:
2338+
loops.setdefault(v1, []).append(e)
23382339
else:
2339-
groups.setdefault(frozenset((index[e.v1], index[e.v2])), []).append(e)
2340+
groups.setdefault(frozenset((index[v1], index[v2])), []).append(e)
23402341
rads: dict[Edge, float] = {}
23412342
for edges in groups.values():
23422343
n = len(edges)
23432344
for k, e in enumerate(edges):
2344-
assert e.v1 is not None and e.v2 is not None
2345+
v1, v2 = e._ends()
23452346
offset = (k - (n - 1) / 2) * _ARC_STEP
23462347
# the same side in space for an edge drawn the other way round
2347-
rads[e] = offset if index[e.v1] < index[e.v2] else -offset
2348+
rads[e] = offset if index[v1] < index[v2] else -offset
23482349
loopno = {e: k for edges in loops.values() for k, e in enumerate(edges)}
23492350
return rads, loopno
23502351

@@ -2373,8 +2374,7 @@ def _edge_artist(
23732374
from matplotlib.patches import FancyArrowPatch
23742375
from matplotlib.transforms import Affine2D, ScaledTranslation
23752376
from ._plot import _Arrow3D, _Loop3D, _loop_path
2376-
v1, v2 = edge.v1, edge.v2
2377-
assert v1 is not None and v2 is not None
2377+
v1, v2 = edge._ends()
23782378
shrink = markersize / 2 + 1
23792379
if v1 is v2:
23802380
radius = _LOOP_RADIUS + _LOOP_GROWTH * loopno
@@ -2620,10 +2620,10 @@ def plot(
26202620
# the edges, each once
26212621
rads, loopno = self._edge_layout()
26222622
for e in self._edges:
2623-
assert e.v1 is not None
2623+
start = e._ends()[0]
26242624
kwargs = dict(edge_kwargs)
26252625
if color_components:
2626-
kwargs = {**dict(color=color[e.v1.label, :]), **kwargs}
2626+
kwargs = {**dict(color=color[start.label, :]), **kwargs}
26272627
artist = self._edge_artist(
26282628
ax,
26292629
e,
@@ -2649,10 +2649,8 @@ def plot(
26492649
)
26502650
dx, dy = abs(x1 - x0), abs(y1 - y0)
26512651
for e in loopno:
2652-
assert e.v1 is not None
2653-
ax.update_datalim(
2654-
[(e.v1.x - dx, e.v1.y - dy), (e.v1.x + dx, e.v1.y + dy)]
2655-
)
2652+
v = e._ends()[0]
2653+
ax.update_datalim([(v.x - dx, v.y - dy), (v.x + dx, v.y + dy)])
26562654
ax.autoscale_view()
26572655

26582656
ax.grid(grid)
@@ -3436,11 +3434,11 @@ def incidence(self) -> NDArray:
34363434
vdict[vertex] = i
34373435

34383436
for j, e in enumerate(edges):
3439-
assert e.v1 is not None and e.v2 is not None
3440-
I[vdict[e.v1], j] = 1
3441-
I[vdict[e.v2], j] = 1
3442-
if e.v1 is e.v2 and not self.directed:
3443-
I[vdict[e.v1], j] = 2 # self-loop, counted at both ends
3437+
v1, v2 = e._ends()
3438+
I[vdict[v1], j] = 1
3439+
I[vdict[v2], j] = 1
3440+
if v1 is v2 and not self.directed:
3441+
I[vdict[v1], j] = 2 # self-loop, counted at both ends
34443442

34453443
return I
34463444

@@ -3793,8 +3791,7 @@ def path_UCS(
37933791

37943792
parent_names: dict[str, str] = {}
37953793
for v, p in parent.items():
3796-
assert v.name is not None and p.name is not None
3797-
parent_names[v.name] = p.name
3794+
parent_names[v._require_name()] = p._require_name()
37983795

37993796
if summary or verbose:
38003797
print(
@@ -3931,8 +3928,7 @@ def path_Astar(
39313928

39323929
parent_names: dict[str, str] = {}
39333930
for v, p in parent.items():
3934-
assert v.name is not None and p.name is not None
3935-
parent_names[v.name] = p.name
3931+
parent_names[v._require_name()] = p._require_name()
39363932

39373933
if summary or verbose:
39383934
print(
@@ -3973,9 +3969,9 @@ def _attach(self, edge: Edge) -> None:
39733969
from either end. A self-loop is therefore held twice by its vertex,
39743970
which is why it counts 2 towards the degree.
39753971
"""
3976-
assert edge.v1 is not None and edge.v2 is not None
3977-
edge.v1._edgelist.append(edge)
3978-
edge.v2._edgelist.append(edge)
3972+
v1, v2 = edge._ends()
3973+
v1._edgelist.append(edge)
3974+
v2._edgelist.append(edge)
39793975

39803976
def _detach(self, edge: Edge) -> None:
39813977
"""
@@ -4028,9 +4024,9 @@ def _attach(self, edge: Edge) -> None:
40284024
The edge goes in the start vertex's list of outgoing edges and in the
40294025
end vertex's list of incoming ones.
40304026
"""
4031-
assert edge.v1 is not None and edge.v2 is not None
4032-
edge.v1._edgelist.append(edge)
4033-
edge.v2._inlist.append(edge)
4027+
v1, v2 = edge._ends()
4028+
v1._edgelist.append(edge)
4029+
v2._inlist.append(edge)
40344030

40354031
def _detach(self, edge: Edge) -> None:
40364032
"""
@@ -4203,6 +4199,27 @@ def _set_endpoints(self, v1: Vertex | None, v2: Vertex | None) -> None:
42034199
self._v1 = v1
42044200
self._v2 = v2
42054201

4202+
def _ends(self) -> tuple[Vertex, Vertex]:
4203+
"""
4204+
Both vertices of the edge, raising clearly if it is not connected (private method)
4205+
4206+
:raises ValueError: the edge has no vertices
4207+
:return: the first and second vertex
4208+
:rtype: tuple of Vertex subclass
4209+
4210+
``v1`` and ``v2`` are None for an edge that has not been connected.
4211+
Every edge in a graph has both, so the code that works on them takes
4212+
them from here, which tells the type checker so, rather than testing
4213+
for None at each use. Unlike an ``assert`` this check is not removed
4214+
by ``python -O``.
4215+
4216+
:seealso: :attr:`v1` :attr:`v2`
4217+
"""
4218+
v1, v2 = self._v1, self._v2
4219+
if v1 is None or v2 is None:
4220+
raise ValueError("the edge is not connected to two vertices")
4221+
return v1, v2
4222+
42064223
def __reduce__(self) -> Any:
42074224
"""
42084225
Refuse to pickle or copy an edge on its own
@@ -4334,12 +4351,11 @@ def next(self, vertex: Vertex) -> Vertex:
43344351
43354352
"""
43364353

4337-
if self.v1 is vertex:
4338-
assert self.v2 is not None
4339-
return self.v2
4340-
elif self.v2 is vertex:
4341-
assert self.v1 is not None
4342-
return self.v1
4354+
v1, v2 = self._ends()
4355+
if v1 is vertex:
4356+
return v2
4357+
elif v2 is vertex:
4358+
return v1
43434359
else:
43444360
raise ValueError("should not happen")
43454361

@@ -4378,8 +4394,7 @@ def endpoints(self) -> list[Vertex]:
43784394
43794395
:seealso: :meth:`vertices`
43804396
"""
4381-
assert self.v1 is not None and self.v2 is not None
4382-
return [self.v1, self.v2]
4397+
return list(self._ends())
43834398

43844399
def remove(self) -> None:
43854400
"""
@@ -4804,7 +4819,8 @@ def _bind_edge(self, edge: Edge, dest: Vertex) -> None:
48044819
endpoints disagree with the vertices' edge lists.
48054820
"""
48064821
graph = self._graph
4807-
assert graph is not None # connect() has checked
4822+
# type narrowing only: connect() has checked
4823+
assert graph is not None # nosec B101
48084824
if edge.v1 is None and edge.v2 is None:
48094825
edge._set_endpoints(self, dest)
48104826
elif not graph._endpoints_match(edge, self, dest):
@@ -5141,6 +5157,25 @@ def _incident_edges(self) -> list[Edge]:
51415157
seen.setdefault(id(e), e)
51425158
return list(seen.values())
51435159

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

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