I'm currently writing a library that generates CST hierarchy relations for TreeSitter grammars. Each node should be typed to constrain the permitted parents and children, and common tree functionality is defined on the base node type. Generated classes then inherit from the base node types and should be extendable with language / node-specific functionality. The library is then used e.g. for writing a linter. This is working mostly OK at runtime, but I'm having trouble with the static typing because recursive generics make my head hurt. I've boiled it down to a simplified version that exhibits the same problem. It's all a flat file, I'll just split it for comments between sections. I'm open to any kind of refactor that makes it easier to work with, but hopefully there's some way to just alter it lightly. I'm not a dev primarily and I don't have formal education, so it's very possible that I just don't know some fundamental concept or pattern that would help here. Base node from __future__ import annotations from abc import ABC from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, Protocol, cast from pydantic import BaseModel, PrivateAttr from pydantic_core import core_schema if TYPE_CHECKING: from collections.abc import Iterator class AnyNode: def __get_pydantic_core_schema__(self, _source_type): _ = _source_type return core_schema.any_schema() class NoNode: def __get_pydantic_core_schema__(self, _source_type): _ = _source_type return core_schema.any_schema() @dataclass class WalkResult: parent: Unknown | None child: Unknown Abstract = NoNode | AnyNode class Base[P: Abstract, C: Abstract](BaseModel, AnyNode, ABC): _id: ClassVar[str] _typ: ClassVar[str] _name: str _parent: P = PrivateAttr() _children: list[C] = PrivateAttr(default_factory=list) _registry: ClassVar[dict[str, dict[str, type[Unknown]]]] = {} def __init__(self, parent: P, name: str, *args, **kwargs): super().__init__(*args, **kwargs) self._parent = parent self._name = name @property def parent(self) -> P | None: """Get the parent of the node.""" return self._parent @property def children(self) -> list[C] | None: """Get the children of the node.""" return self._children def add_child(self, child: C) -> None: self._children.append(child) def walk(self) -> Iterator[WalkResult]: parent = cast('Unknown', self.parent) yield WalkResult(parent=parent, child=self) for child in self.children or []: child = cast('Unknown', child) yield from child.walk() @classmethod def get_id(cls) -> str: """Get the identifier of the node class.""" return cls._id @classmethod def get_typ(cls) -> str: """Get the identifier of the node class.""" return cls._typ @classmethod def register(cls, sub: type[Unknown]): sub_typ = sub.get_typ() sub_id = sub.get_id() if sub_typ not in cls._registry: cls._registry[sub_typ] = {} if sub_id in cls._registry[sub_typ]: _err = f'Node type "{sub_id}" already registered for "{sub_typ}".' raise ValueError(_err) cls._registry[sub_typ][sub_id] = sub @classmethod def get_cls(cls, typ: str, id_: str) -> type[Unknown]: return cls._registry[typ][id_] def __repr__(self) -> str: return f'{self.__class__.__name__}(name="{self._name}", typ="{self._typ}", id="{self._id}")' def register_node(cls): if not cls.__name__.startswith('_'): Base.register(cls) return cls Unknown = Base[Abstract, Abstract] walk() is the only tree method I have on the demo version, but of course there are many more in practice. Most things here relate to the node registry, which allow me to get the correct class after identifying language and node type. Tree nodes Next, there are more specific subclasses for root/branch/leaf nodes: class Orphan[C: Abstract](Base[NoNode, C]): _parent: NoNode = PrivateAttr(default_factory=NoNode) @property def parent(self) -> None: """Get the parent of the orphan node.""" return None @property def children(self) -> list[C]: """Get the children of the orphan node.""" return self._children class Branch[P: Abstract, C: Abstract](Base[P, C]): @property def parent(self) -> P: """Get the parent of the branch node.""" return self._parent @property def children(self) -> list[C]: """Get the children of the branch node.""" return self._children class Leaf[P: Abstract](Base[P, NoNode]): _children: list[NoNode] = PrivateAttr(default_factory=list) def add_child(self, child: NoNode) -> None: _ = child _err = 'Leaf nodes cannot have children.' raise NotImplementedError(_err) @property def parent(self) -> P: """Get the parent of the leaf node.""" return self._parent @property def children(self) -> None: """Get the children of the leaf node.""" return None These pretty much just narrow the types that each node can have. Interfaces Interfaces are defined as well, although I'm underusing them due to the same typing worries: class ITree(Protocol): def walk(self) -> Iterator[WalkResult]: """Walk the tree and yield nodes.""" ... class INode[P: Abstract, C: Abstract](ITree, Protocol): @property def parent(self) -> P | None: """Get the parent of the node.""" ... @property def children(self) -> list[C] | None: """Get the children of the node.""" ... def add_child(self, child: C) -> None: """Add a child to the node.""" ... class INodeOrphan[C: Abstract](INode[NoNode, C], Protocol): @property def parent(self) -> None: """Get the parent of the orphan node.""" ... @property def children(self) -> list[C]: """Get the children of the orphan node.""" ... class INodeBranch[P: Abstract, C: Abstract](INode[P, C], Protocol): @property def parent(self) -> P: """Get the parent of the branch node.""" ... @property def children(self) -> list[C]: """Get the children of the branch node.""" ... class INodeLeaf[P: Abstract](INode[P, NoNode], Protocol): @property def parent(self) -> P: """Get the parent of the leaf node.""" ... @property def children(self) -> None: """Get the children of the leaf node.""" return None if TYPE_CHECKING: _a: type[INode[Abstract, Abstract]] = Base _b: type[INodeOrphan[Abstract]] = Orphan _c: type[INodeBranch[Abstract, Abstract]] = Branch _d: type[INodeLeaf[Abstract]] = Leaf They validate correctly. ITree is split off because there are some special nodes like file/module that are only trees but not nodes - I think adding these here would only confuse things. Base mixins Next, there are some base mixins - they have more functionality in practice but here, they're only there to replicate the hierarchy. "Sub" would be the language-specific type in everything that follows. class BaseMixin(BaseModel, ABC): _mix_typ: ClassVar[str] class _Generated__SubMixin: _mix_typ: ClassVar[str] = 'sub_mixin' class SubMixin(BaseMixin): def do(self, value: str) -> None: """Example method in a mixin.""" print(f'This is a mixin with value: {value}') class SubBase[P: Abstract, C: Abstract](Base[P, C], SubMixin): _typ: ClassVar[str] = 'sub' SubUnknown = SubBase[Abstract, Abstract] Language tree For each language, the base types are inherited, and the generics then define permitted parents and children. So here, a body is the root node, which can contain blocks, and each block can contain strings, which are leaves. class SubOrphan[C: Abstract](Orphan[C], SubMixin): _typ: ClassVar[str] = 'sub' class SubBranch[P: Abstract, C: Abstract](Branch[P, C], SubMixin): _typ: ClassVar[str] = 'sub' class SubLeaf[P: Abstract](Leaf[P], SubMixin): _typ: ClassVar[str] = 'sub' class _Generated__SubBody(SubOrphan['SubBlock']): _id: ClassVar[str] = 'body' class _Generated__SubBlock(SubBranch['SubBody', 'SubString']): _id: ClassVar[str] = 'block' class _Generated__SubString(SubLeaf['SubBlock']): _id: ClassVar[str] = 'string' Node mixins - the problem class SubBlockMixin(SubMixin): def foo(self) -> SubUnknown: """Example method in a mixin.""" me = cast('SubBlock', self) if TYPE_CHECKING else self me.do('Hello from SubBlockMixin') print('This is a block mixin') return next(me.walk()).child The me cast is the only way I found to make the class behave as a SubBlock (i.e. it has the BaseNode methods / properties and the do from the SubMixin - see below for composition.) The self cannot be typed to SubBlock because it's outside the hierarchy. Note that the foo() is also defined to return a SubUnknown. This is of course a problem, because Diagnostics: 1. Type "Unknown" is not assignable to return type "SubUnknown" "Base[Abstract, Abstract]" is not assignable to "SubBase[Abstract, Abstract]" [reportReturnType] next(walk()).child is an Unknown, not a SubUnknown. Of course I can also cast here, but this is becoming really ugly. But for the outside user (the linter), it's important to have the language-specific functionality on the return value, not just a generic BaseNode. Composition In practice, there are a lot of these - importlib determines whether a node has a mixin and then adds it to the hierarchy. These are the classes that are supposed to be used outside the library, and they combine the functionality of the correct BaseNode subtype, the language-specific mixin and the node mixin. @register_node class SubBody(_Generated__SubMixin, _Generated__SubBody): pass @register_node class SubBlock(SubBlockMixin, _Generated__SubMixin, _Generated__SubBlock): pass @register_node class SubString(_Generated__SubMixin, _Generated__SubString): pass Usage Just a minimal usage example: def main(): sub_body = Base.get_cls('sub', 'body')(parent=NoNode(), name='root') sub_block_a = Base.get_cls('sub', 'block')(parent=sub_body, name='block_a') sub_block_b = Base.get_cls('sub', 'block')(parent=sub_body, name='block_b') sub_body.add_child(sub_block_a) sub_body.add_child(sub_block_b) sub_string_aa = Base.get_cls('sub', 'string')(parent=sub_block_a, name='string_aa') sub_string_ab = Base.get_cls('sub', 'string')(parent=sub_block_a, name='string_ab') sub_string_ba = Base.get_cls('sub', 'string')(parent=sub_block_b, name='string_ba') sub_string_bb = Base.get_cls('sub', 'string')(parent=sub_block_b, name='string_bb') sub_block_a.add_child(sub_string_aa) sub_block_a.add_child(sub_string_ab) sub_block_b.add_child(sub_string_ba) sub_block_b.add_child(sub_string_bb) for walk_result in sub_body.walk(): print(walk_result) print('---') sub_string_aa = cast('SubString', sub_string_aa) reveal_type(sub_string_aa.parent) # Type of sub_string_aa.parent is SubBlock sub_string_aa.parent.do('Hello, World!') ret = sub_string_aa.parent.foo() print(f'Returned: {ret!r}') if __name__ == '__main__': main() This works as expected, and the do() and foo() access are accepted by the static type checker. WalkResult(parent=None, child=SubBody(name="root", typ="sub", id="body")) WalkResult(parent=SubBody(name="root", typ="sub", id="body"), child=SubBlock(name="block_a", typ="sub", id="block")) WalkResult(parent=SubBlock(name="block_a", typ="sub", id="block"), child=SubString(name="string_aa", typ="sub", id="string")) WalkResult(parent=SubBlock(name="block_a", typ="sub", id="block"), child=SubString(name="string_ab", typ="sub", id="string")) WalkResult(parent=SubBody(name="root", typ="sub", id="body"), child=SubBlock(name="block_b", typ="sub", id="block")) WalkResult(parent=SubBlock(name="block_b", typ="sub", id="block"), child=SubString(name="string_ba", typ="sub", id="string")) WalkResult(parent=SubBlock(name="block_b", typ="sub", id="block"), child=SubString(name="string_bb", typ="sub", id="string")) --- Runtime type is 'SubBlock' This is a mixin with value: Hello, World! This is a mixin with value: Hello from SubBlockMixin This is a block mixin Returned: SubBlock(name="block_a", typ="sub", id="block") Can anyone suggest a shed some light on how to proceed? Is it even possible to refactor this to achieve my goal, or did I bite off more than can be chewed? I'm not tied to the existing hierarchy - in the end, I just want to Define as much functionality as possible on the base nodes Allow specifying additional functionality for languages and nodes manually Keep the generated code light because it's messy enough as it is Keep the outside interface as simple and human-compatible as possible Thanks in advance for any advice! Continue reading...