Skip to content

Commit 66cf276

Browse files
committed
Add a conditionally deterministic topological sort
1 parent abdcc29 commit 66cf276

File tree

3 files changed

+189
-2
lines changed

3 files changed

+189
-2
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ line-ending = "lf"
147147

148148
[tool.ruff.lint]
149149
select = [
150-
"A", "ANN", "ASYNC", "B", "BLE", "C4", "COM", "D", "DOC", "DTZ", "E",
150+
"A", "ANN", "ASYNC", "B", "BLE", "C4", "COM", "DTZ", "E",
151151
"EM", "ERA", "F", "FA", "FBT", "FURB", "G", "I", "ICN", "INP", "ISC", "LOG", "NPY",
152152
"PD", "PERF", "PGH", "PIE", "PLC", "PLE", "PLR", "PLW", "PTH", "PYI",
153153
"Q", "Q003", "RET", "RSE", "RUF", "S", "SIM", "SLOT", "T20", "TC", "TID",

src/async_utils/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
__author__ = "Michael Hall"
1010
__license__ = "Apache-2.0"
1111
__copyright__ = "Copyright 2020-Present Michael Hall"
12-
__version__ = "2025.08.03b"
12+
__version__ = "2025.11.19b"
1313

1414
import os
1515
import sys

src/async_utils/_graphs.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# Copyright 2020-present Michael Hall
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
from collections.abc import Generator, Iterator
18+
19+
from . import _typings as t
20+
21+
TYPE_CHECKING = False
22+
if TYPE_CHECKING:
23+
import typing
24+
25+
class CanHashAndCompareLT(typing.Protocol):
26+
def __hash__(self) -> int: ...
27+
28+
def __lt__(self, other: typing.Self, /) -> bool: ...
29+
30+
class CanHashAndCompareGT(typing.Protocol):
31+
def __hash__(self) -> int: ...
32+
33+
def __gt__(self, other: typing.Self, /) -> bool: ...
34+
35+
else:
36+
37+
def f__hash__(self: t.Self) -> int: ...
38+
def f_binop_bool(self: t.Self, other: typing.Self, /) -> bool: ...
39+
40+
class ExprWrapper:
41+
"""Wrapper since call expressions aren't allowed in type statements."""
42+
43+
def __class_getitem__(cls, key: int) -> t.Any:
44+
n = "__lt__" if key == 1 else "__gt__"
45+
data = {"__hash__": f__hash__, n: f_binop_bool}
46+
return type(
47+
"CoroCacheDeco",
48+
(__import__("typing").Protocol,),
49+
data,
50+
)
51+
52+
type CanHashAndCompareLT = ExprWrapper[1]
53+
type CanHashAndCompareGT = ExprWrapper[2]
54+
55+
56+
type CanHashAndCompare = CanHashAndCompareLT | CanHashAndCompareGT
57+
58+
59+
class CycleDetected[T: CanHashAndCompare](Exception):
60+
@property
61+
def cycle(self) -> list[T]:
62+
return self.args[0]
63+
64+
65+
class NodeData[T]:
66+
__slots__ = ("dependants", "ndependencies", "node")
67+
68+
def __init__(self, node: T) -> None:
69+
self.node: T = node
70+
self.ndependencies: int = 0
71+
self.dependants: list[T] = []
72+
73+
def __init_subclass__(cls) -> t.Never:
74+
msg = "Don't subclass this"
75+
raise RuntimeError(msg)
76+
77+
__final__ = True
78+
79+
80+
#: TODO: document the 3 uses I have for the below as examples
81+
class DepSorter[T: CanHashAndCompare]:
82+
"""Provides a topological sort that attempts to preserve logical priority
83+
(provided by comparison)
84+
85+
If the set of nodes in a given graph has a strict total order
86+
via direct comparison (<), the resulting
87+
topological order for that graph is deterministic.
88+
89+
Nodes may be added multiple times.
90+
Directed Edges are accumulated from all input information.
91+
"""
92+
93+
def __init_subclass__(cls) -> t.Never:
94+
msg = (
95+
"Don't subclass this. "
96+
"If you need anything more complex than this, "
97+
"pull a dedicated graph library."
98+
)
99+
raise RuntimeError(msg)
100+
101+
__final__ = True
102+
103+
def __init__(self, *edges: tuple[T, T]) -> None:
104+
self._nodemap: dict[T, NodeData[T]] = {}
105+
self.__iterating: bool = False
106+
107+
for edge in edges:
108+
self.add_dependants(*edge)
109+
110+
def add_dependencies(self, node: T, *dependencies: T) -> None:
111+
if self.__iterating:
112+
raise RuntimeError
113+
114+
node_data = self._nodemap.setdefault(node, NodeData(node))
115+
116+
for dep in dependencies:
117+
dep_node_data = self._nodemap.setdefault(dep, NodeData(dep))
118+
node_data.ndependencies += 1
119+
dep_node_data.dependants.append(node)
120+
121+
def add_dependants(self, node: T, *dependants: T) -> None:
122+
if self.__iterating:
123+
raise RuntimeError
124+
125+
node_data = self._nodemap.setdefault(node, NodeData(node))
126+
127+
for dep in dependants:
128+
dep_node_data = self._nodemap.setdefault(dep, NodeData(dep))
129+
dep_node_data.ndependencies += 1
130+
node_data.dependants.append(dep)
131+
132+
def _find_cycle(self) -> list[T] | None:
133+
graph = self._nodemap
134+
# Cheaper than a queue since we need to iterate anyhow
135+
queued: list[Iterator[T]] = []
136+
seen: set[T] = set()
137+
# Let's not recurse without TCO
138+
stack: list[T] = []
139+
node_depth: dict[T, int] = {}
140+
141+
for node in graph:
142+
if node in seen:
143+
continue
144+
145+
while True:
146+
if node in seen:
147+
if node in node_depth:
148+
return [*stack[node_depth[node] :], node]
149+
else:
150+
seen.add(node)
151+
iterator = iter(graph[node].dependants)
152+
queued.append(iterator)
153+
node_depth[node] = len(stack)
154+
stack.append(node)
155+
156+
while stack:
157+
if (node := next(queued[-1], None)) is not None:
158+
break
159+
del node_depth[stack.pop()]
160+
queued.pop()
161+
else:
162+
break
163+
return None
164+
165+
def __iter__(self) -> Generator[T, None, None]:
166+
if self.__iterating:
167+
raise RuntimeError
168+
169+
self.__iterating = True
170+
171+
if cycle := self._find_cycle():
172+
raise CycleDetected(cycle)
173+
174+
return self.__iter()
175+
176+
def __iter(self) -> Generator[T, None, None]:
177+
while ready := [
178+
i.node for i in self._nodemap.values() if not i.ndependencies
179+
]:
180+
next_node = min(ready)
181+
self._nodemap[next_node].ndependencies = -1
182+
183+
yield next_node
184+
185+
for dep in self._nodemap[next_node].dependants:
186+
dep_info = self._nodemap[dep]
187+
dep_info.ndependencies -= 1

0 commit comments

Comments
 (0)