Skip to content

Close most of the mypy Optional-narrowing gap, fix bugs it surfaced - #10

Merged
petercorke merged 1 commit into
mainfrom
chore/mypy-cleanup
Sep 3, 2026
Merged

petercorke merged 1 commit into
mainfrom
chore/mypy-cleanup

Conversation

@petercorke

Copy link
Copy Markdown
Owner

Summary

Reduces mypy errors on PGraph.py from 60 to 6 (the remainder is in plot()/highlight_vertex's visualization internals -- dozens of individual coord/x/y/name accesses on Optional types, left as debt since the per-site narrowing cost is high for comparatively low-value, non-core code).

Most of the 54 fixed were narrowing-only (asserts/guards at points where an invariant already holds, e.g. an edge returned by self.edges() always has non-None v1/v2), but several were real, previously-undiscovered bugs, each verified empirically:

  • BaseVertex.label had no type annotation on its self.label = None assignment, so mypy inferred its type as bare None everywhere, not int | None -- the root cause of most of the _graphcolor-related noise. Same root cause for Edge.cost, whose type was inferred from the first branch of a multi-branch assignment (self.cost = cost inside if cost is not None:, where cost is narrowed to plain float) rather than the union of all branches. One-line annotation fixes at the actual assignment sites.
  • UGraph._graphcolor(): self._ncomponents = lastlabel + 1 crashes with TypeError if the graph is empty (lastlabel stays None, since range(self.n) never executes) -- reproduced concretely by adding then removing a vertex and calling .nc. Fixed to treat an empty graph as 0 components.
  • _BaseGraph.closest(), BaseVertex.heuristic_distance()/distance()/closest()/x/y/z: none of these guarded against a vertex with no coordinate (coord is None, a legitimate state for a non-embedded vertex) or, for closest()/heuristic_distance()/distance(), a vertex not connected to a graph (closest() was missing the same graph-membership guard already added elsewhere this cycle). All now raise a clear ValueError instead of crashing on a bare arithmetic/indexing TypeError or AttributeError.
  • UVertex.connect()/DVertex.connect(): the documented "pass an existing Edge as other" code path was completely broken -- v.connect(some_edge) crashed unconditionally with "missing 1 required positional argument: 'dest'", confirmed on both UVertex and DVertex, and exercised by no test. Deleted rather than fixed -- the feature raises real ambiguity (does the edge already have both vertices, one, or none?) that a mechanical fix can't resolve without a design decision. This also happened to be one leg of the Liskov-incompatible connect() override already flagged as debt in the type-hints pass; with the ambiguous branch gone, the remaining mismatch (parameter name, and **kwargs vs named optional params) was cheap to close properly by matching BaseVertex.connect()'s signature exactly, resolving that debt item entirely rather than leaving it deferred.
  • Dict()/Adjacency(): return type corrected from the unprovable UGraph | DGraph to the honestly-provable _BaseGraph (matches copy()'s existing pattern on the same class).
  • average_degree(): added a fallback else: raise TypeError(...) for mypy's benefit, since the two isinstance branches aren't statically exhaustive even though they are in practice.

Also removed one genuinely dead line: UVertex.connect() called self._graph._edgelist.add(e) after super().connect(), which already does that same add() internally.

Test plan

  • 44/44 tests pass
  • Sphinx docs build cleanly, no runblock regressions
  • Empirically verified: empty-graph .nc, coordinate/graph-membership guards, and the deleted connect(edge) path's new failure mode

Reduces mypy errors on PGraph.py from 60 to 6 (the remainder is in
plot()/highlight_vertex's visualization internals -- dozens of
individual coord/x/y/name accesses on Optional types, left as debt
since the per-site narrowing cost is high for comparatively low-value,
non-core code).

Most of the 54 fixed were narrowing-only (asserts/guards at points
where an invariant already holds, e.g. an edge returned by
self.edges() always has non-None v1/v2), but several were real,
previously-undiscovered bugs, each verified empirically:

- BaseVertex.label had no type annotation on its `self.label = None`
  assignment, so mypy inferred its type as bare `None` everywhere,
  not `int | None` -- the root cause of most of the _graphcolor-
  related noise. Same root cause for Edge.cost, whose type was
  inferred from the *first* branch of a multi-branch assignment
  (`self.cost = cost` inside `if cost is not None:`, where `cost` is
  narrowed to plain `float`) rather than the union of all branches.
  One-line annotation fixes at the actual assignment sites.
- UGraph._graphcolor(): `self._ncomponents = lastlabel + 1` crashes
  with TypeError if the graph is empty (lastlabel stays None, since
  `range(self.n)` never executes) -- reproduced concretely by adding
  then removing a vertex and calling `.nc`. Fixed to treat an empty
  graph as 0 components.
- _BaseGraph.closest(), BaseVertex.heuristic_distance()/distance()/
  closest()/x/y/z: none of these guarded against a vertex with no
  coordinate (`coord is None`, a legitimate state for a non-embedded
  vertex) or, for closest()/heuristic_distance()/distance(), a vertex
  not connected to a graph (closest() was missing the same graph-
  membership guard already added elsewhere this cycle). All now raise
  a clear ValueError instead of crashing on a bare arithmetic/
  indexing TypeError or AttributeError.
- UVertex.connect()/DVertex.connect(): the documented "pass an
  existing Edge as `other`" code path was completely broken --
  `v.connect(some_edge)` crashed unconditionally with "missing 1
  required positional argument: 'dest'", confirmed on both UVertex
  and DVertex, and exercised by no test. Deleted rather than fixed --
  the feature raises real ambiguity (does the edge already have both
  vertices, one, or none?) that a mechanical fix can't resolve without
  a design decision. This also happened to be one leg of the
  Liskov-incompatible connect() override already flagged as debt in
  the type-hints pass; with the ambiguous branch gone, the remaining
  mismatch (parameter name, and **kwargs vs named optional params) was
  cheap to close properly by matching BaseVertex.connect()'s signature
  exactly, resolving that debt item entirely rather than leaving it
  deferred.
- Dict()/Adjacency(): return type corrected from the unprovable
  `UGraph | DGraph` to the honestly-provable `_BaseGraph` (matches
  copy()'s existing pattern on the same class).
- average_degree(): added a fallback `else: raise TypeError(...)` for
  mypy's benefit, since the two isinstance branches aren't statically
  exhaustive even though they are in practice.

Also removed one genuinely dead line: UVertex.connect() called
self._graph._edgelist.add(e) after super().connect(), which already
does that same add() internally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.33333% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.82%. Comparing base (6c24cfe) to head (04ccc52).

Files with missing lines Patch % Lines
src/pgraph/PGraph.py 73.33% 16 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #10      +/-   ##
==========================================
- Coverage   85.20%   84.82%   -0.38%     
==========================================
  Files           2        2              
  Lines         750      771      +21     
==========================================
+ Hits          639      654      +15     
- Misses        111      117       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@petercorke
petercorke merged commit f963939 into main Sep 3, 2026
9 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant