diff --git a/pydoctor/model.py b/pydoctor/model.py index c73cf58a4..a7fdc0cd9 100644 --- a/pydoctor/model.py +++ b/pydoctor/model.py @@ -32,7 +32,6 @@ from pydoctor import factory, qnmatch, utils, linker, astutils, mro from pydoctor.epydoc.markup import ParsedDocstring from pydoctor.sphinx import CacheT, SphinxInventory -from pydoctor.topsort import topsort if TYPE_CHECKING: from typing import Literal, Protocol, TypeAlias @@ -41,6 +40,7 @@ Literal = {True: bool, False: bool} ASTBuilder = Protocol = object +T = TypeVar('T') # originally when I started to write pydoctor I had this idea of a big # tree of Documentables arranged in an almost arbitrary tree. @@ -583,7 +583,34 @@ def is_exception(cls: 'Class') -> bool: return True return False -_ClassOrStr: TypeAlias = 'Class | str' +Graph: TypeAlias = 'dict[T, list[T]]' + +if sys.version_info >= (3, 9): + from graphlib import TopologicalSorter + def topsort(graph: Graph[T]) -> Iterable[T]: + """ + Wrapper for L{graphlib.TopologicalSorter.static_order}. + """ + return TopologicalSorter(graph).static_order() +else: + from collections import deque + def topsort(graph: Graph[T]) -> Iterable[T]: + result = deque() + visited = set() + stack = [[key for key in graph]] + while stack: + for i in stack[-1]: + if i in visited and i not in result: + result.appendleft(i) + if i not in visited: + visited.add(i) + stack.append(graph[i]) + break + else: + stack.pop() + return result + +ClassOrStr: TypeAlias = 'Class | str' class ClassHierarchyFinalizer: """ @@ -630,7 +657,7 @@ def _init_finalbaseobjects(o: Class, path:list[Class] | None = None) -> None: o._finalbases = finalbases @staticmethod - def _getbases(o: Class) -> Iterator[_ClassOrStr]: + def _getbases(o: Class) -> Iterator[ClassOrStr]: """ Like L{Class.baseobjects} but fallback to the expanded name if the base is not resolved to a L{Class} object. @@ -645,8 +672,8 @@ def __init__(self, classes: Iterable[Class]) -> None: # this calls _init_finalbaseobjects for every class and # create the graph object for the ones that did not raised # a cycle-error. - self.graph: dict[_ClassOrStr, list[_ClassOrStr]] = {} - self.computed_mros: dict[_ClassOrStr, list[_ClassOrStr]] = {} + self.graph: dict[ClassOrStr, list[ClassOrStr]] = {} + self.computed_mros: dict[ClassOrStr, list[ClassOrStr]] = {} for cls in classes: try: @@ -670,7 +697,7 @@ def compute_mros(self) -> None: # If this raises a CycleError, our code is boggus since we already # checked for cycles ourself. - static_order: Iterable[_ClassOrStr] = topsort(self.graph) + static_order: Iterable[ClassOrStr] = topsort(self.graph) for cls in static_order: if cls in self.computed_mros: @@ -680,13 +707,13 @@ def compute_mros(self) -> None: assert isinstance(cls, Class) self.computed_mros[cls] = cls._mro = self._compute_mro(cls) - def _compute_mro(self, cls: Class) -> list[_ClassOrStr]: + def _compute_mro(self, cls: Class) -> list[ClassOrStr]: """ Compute the method resolution order for this class. This assumes that the MRO of the bases of the class have already been computed and stored in C{self.computed_mros}. """ - result: list[_ClassOrStr] = [cls] + result: list[ClassOrStr] = [cls] if not (bases:=self.graph[cls]): return result @@ -951,8 +978,6 @@ class Attribute(Inheritable): _ModuleT = Module _PackageT = Package -T = TypeVar('T') - def import_mod_from_file_location(module_full_name:str, path: Path) -> types.ModuleType: spec = importlib.util.spec_from_file_location(module_full_name, path) if spec is None: diff --git a/pydoctor/topsort.py b/pydoctor/topsort.py deleted file mode 100644 index fb6f31d43..000000000 --- a/pydoctor/topsort.py +++ /dev/null @@ -1,270 +0,0 @@ -""" -This module offer a compatibility layer on top of L{graphlib.TopologicalSorter.static_order}. -""" -from __future__ import annotations - -import sys -from typing import Iterable, TypeVar, TYPE_CHECKING - -if TYPE_CHECKING: - from typing import TypeAlias - -T = TypeVar('T') -Graph: TypeAlias = 'dict[T, list[T]]' - -if sys.version_info >= (3, 9): - from graphlib import TopologicalSorter, CycleError -else: - # TODO: Remove me when we drop support for python 3.8! - - _NODE_OUT = -1 - _NODE_DONE = -2 - - class _NodeInfo: - __slots__ = "node", "npredecessors", "successors" - - def __init__(self, node): - # The node this class is augmenting. - self.node = node - - # Number of predecessors, generally >= 0. When this value falls to 0, - # and is returned by get_ready(), this is set to _NODE_OUT and when the - # node is marked done by a call to done(), set to _NODE_DONE. - self.npredecessors = 0 - - # List of successor nodes. The list can contain duplicated elements as - # long as they're all reflected in the successor's npredecessors attribute. - self.successors = [] - - class CycleError(ValueError): - """Subclass of ValueError raised by TopologicalSorter.prepare if cycles - exist in the working graph. - - If multiple cycles exist, only one undefined choice among them will be reported - and included in the exception. The detected cycle can be accessed via the second - element in the *args* attribute of the exception instance and consists in a list - of nodes, such that each node is, in the graph, an immediate predecessor of the - next node in the list. In the reported list, the first and the last node will be - the same, to make it clear that it is cyclic. - """ - - pass - - class TopologicalSorter: - """Provides functionality to topologically sort a graph of hashable nodes""" - - def __init__(self, graph=None): - self._node2info = {} - self._ready_nodes = None - self._npassedout = 0 - self._nfinished = 0 - - if graph is not None: - for node, predecessors in graph.items(): - self.add(node, *predecessors) - - def _get_nodeinfo(self, node): - if (result := self._node2info.get(node)) is None: - self._node2info[node] = result = _NodeInfo(node) - return result - - def add(self, node, *predecessors): - """Add a new node and its predecessors to the graph. - - Both the *node* and all elements in *predecessors* must be hashable. - - If called multiple times with the same node argument, the set of dependencies - will be the union of all dependencies passed in. - - It is possible to add a node with no dependencies (*predecessors* is not provided) - as well as provide a dependency twice. If a node that has not been provided before - is included among *predecessors* it will be automatically added to the graph with - no predecessors of its own. - - Raises ValueError if called after "prepare". - """ - if self._ready_nodes is not None: - raise ValueError("Nodes cannot be added after a call to prepare()") - - # Create the node -> predecessor edges - nodeinfo = self._get_nodeinfo(node) - nodeinfo.npredecessors += len(predecessors) - - # Create the predecessor -> node edges - for pred in predecessors: - pred_info = self._get_nodeinfo(pred) - pred_info.successors.append(node) - - def prepare(self): - """Mark the graph as finished and check for cycles in the graph. - - If any cycle is detected, "CycleError" will be raised, but "get_ready" can - still be used to obtain as many nodes as possible until cycles block more - progress. After a call to this function, the graph cannot be modified and - therefore no more nodes can be added using "add". - """ - if self._ready_nodes is not None: - raise ValueError("cannot prepare() more than once") - - self._ready_nodes = [ - i.node for i in self._node2info.values() if i.npredecessors == 0 - ] - # ready_nodes is set before we look for cycles on purpose: - # if the user wants to catch the CycleError, that's fine, - # they can continue using the instance to grab as many - # nodes as possible before cycles block more progress - cycle = self._find_cycle() - if cycle: - raise CycleError("nodes are in a cycle", cycle) - - def get_ready(self): - """Return a tuple of all the nodes that are ready. - - Initially it returns all nodes with no predecessors; once those are marked - as processed by calling "done", further calls will return all new nodes that - have all their predecessors already processed. Once no more progress can be made, - empty tuples are returned. - - Raises ValueError if called without calling "prepare" previously. - """ - if self._ready_nodes is None: - raise ValueError("prepare() must be called first") - - # Get the nodes that are ready and mark them - result = tuple(self._ready_nodes) - n2i = self._node2info - for node in result: - n2i[node].npredecessors = _NODE_OUT - - # Clean the list of nodes that are ready and update - # the counter of nodes that we have returned. - self._ready_nodes.clear() - self._npassedout += len(result) - - return result - - def is_active(self): - """Return ``True`` if more progress can be made and ``False`` otherwise. - - Progress can be made if cycles do not block the resolution and either there - are still nodes ready that haven't yet been returned by "get_ready" or the - number of nodes marked "done" is less than the number that have been returned - by "get_ready". - - Raises ValueError if called without calling "prepare" previously. - """ - if self._ready_nodes is None: - raise ValueError("prepare() must be called first") - return self._nfinished < self._npassedout or bool(self._ready_nodes) - - def __bool__(self): - return self.is_active() - - def done(self, *nodes): - """Marks a set of nodes returned by "get_ready" as processed. - - This method unblocks any successor of each node in *nodes* for being returned - in the future by a call to "get_ready". - - Raises :exec:`ValueError` if any node in *nodes* has already been marked as - processed by a previous call to this method, if a node was not added to the - graph by using "add" or if called without calling "prepare" previously or if - node has not yet been returned by "get_ready". - """ - - if self._ready_nodes is None: - raise ValueError("prepare() must be called first") - - n2i = self._node2info - - for node in nodes: - - # Check if we know about this node (it was added previously using add() - if (nodeinfo := n2i.get(node)) is None: - raise ValueError(f"node {node!r} was not added using add()") - - # If the node has not being returned (marked as ready) previously, inform the user. - stat = nodeinfo.npredecessors - if stat != _NODE_OUT: - if stat >= 0: - raise ValueError( - f"node {node!r} was not passed out (still not ready)" - ) - elif stat == _NODE_DONE: - raise ValueError(f"node {node!r} was already marked done") - else: - assert False, f"node {node!r}: unknown status {stat}" - - # Mark the node as processed - nodeinfo.npredecessors = _NODE_DONE - - # Go to all the successors and reduce the number of predecessors, collecting all the ones - # that are ready to be returned in the next get_ready() call. - for successor in nodeinfo.successors: - successor_info = n2i[successor] - successor_info.npredecessors -= 1 - if successor_info.npredecessors == 0: - self._ready_nodes.append(successor) - self._nfinished += 1 - - def _find_cycle(self): - n2i = self._node2info - stack = [] - itstack = [] - seen = set() - node2stacki = {} - - for node in n2i: - if node in seen: - continue - - while True: - if node in seen: - # If we have seen already the node and is in the - # current stack we have found a cycle. - if node in node2stacki: - return stack[node2stacki[node] :] + [node] - # else go on to get next successor - else: - seen.add(node) - itstack.append(iter(n2i[node].successors).__next__) - node2stacki[node] = len(stack) - stack.append(node) - - # Backtrack to the topmost stack entry with - # at least another successor. - while stack: - try: - node = itstack[-1]() - break - except StopIteration: - del node2stacki[stack.pop()] - itstack.pop() - else: - break - return None - - def static_order(self): - """Returns an iterable of nodes in a topological order. - - The particular order that is returned may depend on the specific - order in which the items were inserted in the graph. - - Using this method does not require to call "prepare" or "done". If any - cycle is detected, :exc:`CycleError` will be raised. - """ - self.prepare() - while self.is_active(): - node_group = self.get_ready() - yield from node_group - self.done(*node_group) - -# API - -def topsort(graph: Graph[T]) -> Iterable[T]: - """ - Wrapper for L{graphlib.TopologicalSorter.static_order}. - """ - return TopologicalSorter(graph).static_order() - -__all__ = ['CycleError', 'topsort'] \ No newline at end of file