Narrow the four cross-cutting domain mixins, and put mypy on the package - #412
Merged
Merged
Conversation
PR #411 left 124 errors in backend/domain/, all in graph.py and the four cross-cutting mixins (groups 71, branches 26, commands 10, layout 8, graph 9). Every one was the same union-attr shape the per-kind mixins had: SceneNode.state is typed `NodeState | None` against a field-less marker. require_node could not reach them. It looks a node up by id and narrows on the kind, and this code mostly does neither - it iterates every node in the scene and skips what it does not want, or narrows a node handed to it as a parameter. Three additions close that: * is_node_of(node, kind, state_cls) - a TypeGuard for a node already in hand. TypeGuard, not TypeIs, so the repo's declared 3.10 floor is enough and nothing imports typing_extensions at runtime. It narrows only the positive branch, so a continue-on-wrong-kind loop reads as `if is_node_of(...)` instead of `if not ...: continue`. It also requires a non-None state, matching the `and node.state is not None` conjunct every hand-written check it replaces already carried. * require_node/optional_node now take a tuple of kinds as well as one. Group geometry is frame-or-container throughout. `" or ".join` of a one-element tuple is that element, so the single-kind error message is byte-identical. * GroupSizedState, a base holding group_width/group_height, which FrameState and ContainerState now inherit instead of each declaring. FrameState's docstring explains at length why those two kinds are not one class: is_locked and the group_manual_* quartet are meaningless for a container. That reasoning is about those five fields and is unchanged. group_width/group_height are common to both, are named for both by the wire contract, and are read for both by every piece of group geometry - which is exactly the code no single per-kind class could describe. Neither kind's field set changes; the two shared fields are declared one level up. Nothing serializes by field order (there is no asdict/fields() call anywhere in the domain), and both construction sites use keywords. SceneDocumentParts gains last_chat_node_id, final_deliverable_node_id and current_chat_id. Without them BranchOps inferred `str` from its own first assignment and then rejected the `= None` two lines later - which is how SceneDocument's own `final_deliverable_node_id: str | None = None` declaration ended up reported as an error against itself. Four non-narrowing fixes, none behavioural: * _bbox_of_members initialised four accumulators to None on one line and tested only the first before using all four. They are set together or not at all; the condition now says so. * layout.py rebound `parent` (a str elsewhere in the module) to a SceneNode, and passed `fid if fid is not None else cid` to a function taking str after a guard that proves it is not None. Renamed, and hoisted into a variable the guard can narrow. * branches.py reused the `edge` loop variable for a list that also holds an optional edge. * Command.invert/apply took `document: object` and then read four attributes off it. They take SceneDocumentParts, which declares exactly those four. [tool.mypy].files now lists backend/domain rather than individual modules: all 18, up from 3 at the start of the day. 48 source files check clean. Test plan: full suite, 3,245 passed / 19 skipped. The one failure, test_cancel_during_backoff_aborts_promptly, asserts a cancel completes within a 2.0s wall-clock budget; it fails the same way on a clean checkout of main under load, and is unrelated to this change. 117 frame/container/ group/bbox/measured-size tests pass, which is the real check on the _recompute_group_bounds restructure. ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
After #411,
backend/domain/still reported 124 mypy errors, all ingraph.pyand the four cross-cutting mixins:groups.pybranches.pycommands.pygraph.pylayout.pyEvery one is the same union-attr shape the per-kind mixins had:
SceneNode.stateis typedNodeState | Noneagainst a field-less marker,so
node.state.group_widthcannot be verified.require_node(#410) could not reach them. It looks a node up by id andnarrows on its kind, and this code mostly does neither — it iterates every
node in the scene and skips what it does not want, or narrows a node handed
to it as a parameter.
Change
Three additions to the narrowing vocabulary
is_node_of(node, kind, state_cls)— aTypeGuardfor a node alreadyin hand, for the iterate-and-skip shape.
TypeGuardrather thanTypeIs, sothe repo's declared 3.10 floor is enough and nothing has to import
typing_extensionsat runtime. It narrows only the positive branch, so acontinue-on-wrong-kind loop reads as
if is_node_of(...)instead ofif not ...: continue. It also requires a non-Nonestate — matching theand node.state is not Noneconjunct that every hand-written check itreplaces already carried.
require_node/optional_nodetake a tuple of kinds as well as a singleone, because group geometry is frame-or-container throughout.
" or ".joinof a one-element tuple is that element, so the single-kinderror message is byte-identical.
GroupSizedState— a base holdinggroup_width/group_height, whichFrameStateandContainerStatenow inherit instead of each declaring.FrameState's docstring explains at length why those two kinds are not oneclass:
is_lockedand thegroup_manual_*quartet are meaningless for acontainer, and forcing them onto a shared base would resurrect the "every
kind carries fields it never uses" problem the migration exists to remove.
That reasoning is about those five fields and is unchanged.
group_width/group_heightare common to both, are named for both by thewire contract, and are read for both by every piece of group geometry —
which is exactly the code no single per-kind class could describe.
Neither kind's field set changes; the two shared fields are declared one
level up. Nothing serializes by field order (there is no
asdictorfields()call anywhere in the domain package) and both construction sitesuse keyword arguments.
Declarations
SceneDocumentPartsgainslast_chat_node_id,final_deliverable_node_idand
current_chat_id. Without themBranchOpsinferredstrfrom its ownfirst assignment and then rejected the
= Nonetwo lines later — which ishow
SceneDocument's ownfinal_deliverable_node_id: str | None = Nonedeclaration ended up reported as an error against itself.
Four non-narrowing fixes, none behavioural
_bbox_of_membersinitialised four accumulators toNoneon one line andtested only the first before using all four. They are set together or not
at all; the condition now says so.
layout.pyreboundparent— astrelsewhere in the module — to aSceneNode, and passedfid if fid is not None else cidto a functiontaking
strafter a guard that proves it is notNone. Renamed, andhoisted into a variable the guard can narrow.
branches.pyreused theedgeloop variable for a list that also holds anoptional edge.
Command.invert/applytookdocument: objectand then read fourattributes off it. They now take
SceneDocumentParts, which declaresexactly those four.
Scope
[tool.mypy].filesnow listsbackend/domainrather than individualmodules: all 18, up from 3 at the start of the day. 48 source files check
clean.
Test plan
test_cancel_during_backoff_aborts_promptly, asserts acancel completes within a 2.0s wall-clock budget. It fails the same way on
a clean checkout of
mainwith no local changes, and passes on the samecheckout when the machine is idle — it measures the machine, not the code,
and is unrelated to this change.
check on the
_recompute_group_boundsrestructure, which is the one pieceof load-bearing geometry this touches.
ruff check .clean.mypyclean across 48 source files.🤖 Generated with Claude Code