renderdag 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
renderdag/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ from ._render import (
2
+ Ancestor,
3
+ AncestorType,
4
+ GraphRow,
5
+ GraphRowRenderer,
6
+ LinkLine,
7
+ NodeLine,
8
+ PadLine,
9
+ )
10
+ from ._ascii import AsciiRenderer
11
+ from ._ascii_large import AsciiLargeRenderer
12
+ from ._box_drawing import BoxDrawingRenderer
13
+
14
+ __all__ = [
15
+ "Ancestor",
16
+ "AncestorType",
17
+ "GraphRow",
18
+ "GraphRowRenderer",
19
+ "LinkLine",
20
+ "NodeLine",
21
+ "PadLine",
22
+ "AsciiRenderer",
23
+ "AsciiLargeRenderer",
24
+ "BoxDrawingRenderer",
25
+ ]
renderdag/_ascii.py ADDED
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from ._output import OutputRendererOptions
4
+ from ._pad import pad_lines
5
+ from ._render import (
6
+ AncestorType,
7
+ GraphRow,
8
+ GraphRowRenderer,
9
+ LinkLine,
10
+ NodeLine,
11
+ PadLine,
12
+ )
13
+
14
+
15
+ class AsciiRenderer:
16
+ def __init__(self, inner: GraphRowRenderer, options: OutputRendererOptions) -> None:
17
+ self._inner = inner
18
+ self._options = options
19
+ self._extra_pad_line: str | None = None
20
+
21
+ def width(
22
+ self,
23
+ node: object | None = None,
24
+ parents: list[AncestorType] | None = None,
25
+ ) -> int:
26
+ return self._inner.width(node, parents) * 2 + 1
27
+
28
+ def reserve(self, node: object) -> None:
29
+ self._inner.reserve(node)
30
+
31
+ def next_row(
32
+ self,
33
+ node: object,
34
+ parents: list[AncestorType],
35
+ glyph: str,
36
+ message: str,
37
+ ) -> str:
38
+ line = self._inner.next_row(node, parents, glyph, message)
39
+ out: list[str] = []
40
+ message_lines = pad_lines(
41
+ line.message.splitlines(), self._options.min_row_height
42
+ )
43
+ message_iter = iter(message_lines)
44
+ need_extra_pad_line = False
45
+
46
+ if self._extra_pad_line is not None:
47
+ out.append(self._extra_pad_line.rstrip())
48
+ out.append("\n")
49
+ self._extra_pad_line = None
50
+
51
+ # Node line
52
+ node_line_str = ""
53
+ for entry in line.node_line:
54
+ if entry == NodeLine.NODE:
55
+ node_line_str += line.glyph + " "
56
+ elif entry == NodeLine.PARENT:
57
+ node_line_str += "| "
58
+ elif entry == NodeLine.ANCESTOR:
59
+ node_line_str += ". "
60
+ else:
61
+ node_line_str += " "
62
+ msg = next(message_iter, None)
63
+ if msg is not None:
64
+ node_line_str += " " + msg
65
+ out.append(node_line_str.rstrip())
66
+ out.append("\n")
67
+
68
+ # Link line
69
+ if line.link_line is not None:
70
+ link_row = line.link_line
71
+ any_horizontal = any(
72
+ cur.intersects(LinkLine.HORIZONTAL) for cur in link_row
73
+ )
74
+ link_line_str = ""
75
+ extended = list(link_row) + [LinkLine(0)]
76
+ for idx in range(len(link_row)):
77
+ cur = extended[idx]
78
+ nxt = extended[idx + 1]
79
+
80
+ # Column character
81
+ if cur.intersects(LinkLine.HORIZONTAL):
82
+ if cur.intersects(LinkLine.CHILD | LinkLine.ANY_FORK_OR_MERGE):
83
+ link_line_str += "+"
84
+ else:
85
+ link_line_str += "-"
86
+ elif cur.intersects(LinkLine.VERTICAL):
87
+ if cur.intersects(LinkLine.ANY_FORK_OR_MERGE) and any_horizontal:
88
+ link_line_str += "+"
89
+ elif cur.intersects(LinkLine.VERT_PARENT):
90
+ link_line_str += "|"
91
+ else:
92
+ link_line_str += "."
93
+ elif cur.intersects(LinkLine.ANY_MERGE) and any_horizontal:
94
+ link_line_str += "'"
95
+ elif cur.intersects(LinkLine.ANY_FORK) and any_horizontal:
96
+ link_line_str += "."
97
+ else:
98
+ link_line_str += " "
99
+
100
+ # Connecting character
101
+ if cur.intersects(LinkLine.HORIZONTAL):
102
+ link_line_str += "-"
103
+ elif cur.intersects(LinkLine.RIGHT_MERGE):
104
+ if nxt.intersects(LinkLine.LEFT_FORK) and not any_horizontal:
105
+ link_line_str += "\\"
106
+ else:
107
+ link_line_str += "-"
108
+ elif cur.intersects(LinkLine.RIGHT_FORK):
109
+ if nxt.intersects(LinkLine.LEFT_MERGE) and not any_horizontal:
110
+ link_line_str += "/"
111
+ else:
112
+ link_line_str += "-"
113
+ else:
114
+ link_line_str += " "
115
+
116
+ msg = next(message_iter, None)
117
+ if msg is not None:
118
+ link_line_str += " " + msg
119
+ out.append(link_line_str.rstrip())
120
+ out.append("\n")
121
+
122
+ # Term line
123
+ if line.term_line is not None:
124
+ term_row = line.term_line
125
+ term_strs = ["| ", "~ "]
126
+ for term_str in term_strs:
127
+ term_line_str = ""
128
+ for i, term in enumerate(term_row):
129
+ if term:
130
+ term_line_str += term_str
131
+ else:
132
+ if line.pad_lines[i] == PadLine.PARENT:
133
+ term_line_str += "| "
134
+ elif line.pad_lines[i] == PadLine.ANCESTOR:
135
+ term_line_str += ". "
136
+ else:
137
+ term_line_str += " "
138
+ msg = next(message_iter, None)
139
+ if msg is not None:
140
+ term_line_str += " " + msg
141
+ out.append(term_line_str.rstrip())
142
+ out.append("\n")
143
+ need_extra_pad_line = True
144
+
145
+ # Base pad line
146
+ base_pad_line = ""
147
+ for entry in line.pad_lines:
148
+ if entry == PadLine.PARENT:
149
+ base_pad_line += "| "
150
+ elif entry == PadLine.ANCESTOR:
151
+ base_pad_line += ". "
152
+ else:
153
+ base_pad_line += " "
154
+
155
+ # Remaining message lines
156
+ for msg in message_iter:
157
+ pad_line = base_pad_line + " " + msg
158
+ out.append(pad_line.rstrip())
159
+ out.append("\n")
160
+ need_extra_pad_line = False
161
+
162
+ if need_extra_pad_line:
163
+ self._extra_pad_line = base_pad_line
164
+
165
+ return "".join(out)
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ from ._output import OutputRendererOptions
4
+ from ._pad import pad_lines
5
+ from ._render import (
6
+ AncestorType,
7
+ GraphRow,
8
+ GraphRowRenderer,
9
+ LinkLine,
10
+ NodeLine,
11
+ PadLine,
12
+ )
13
+
14
+
15
+ class AsciiLargeRenderer:
16
+ def __init__(self, inner: GraphRowRenderer, options: OutputRendererOptions) -> None:
17
+ self._inner = inner
18
+ self._options = options
19
+ self._extra_pad_line: str | None = None
20
+
21
+ def width(
22
+ self,
23
+ node: object | None = None,
24
+ parents: list[AncestorType] | None = None,
25
+ ) -> int:
26
+ return self._inner.width(node, parents) * 3 - 1 + 1
27
+
28
+ def reserve(self, node: object) -> None:
29
+ self._inner.reserve(node)
30
+
31
+ def next_row(
32
+ self,
33
+ node: object,
34
+ parents: list[AncestorType],
35
+ glyph: str,
36
+ message: str,
37
+ ) -> str:
38
+ line = self._inner.next_row(node, parents, glyph, message)
39
+ out: list[str] = []
40
+ message_lines = pad_lines(
41
+ line.message.splitlines(), self._options.min_row_height
42
+ )
43
+ message_iter = iter(message_lines)
44
+ need_extra_pad_line = False
45
+
46
+ if self._extra_pad_line is not None:
47
+ out.append(self._extra_pad_line.rstrip())
48
+ out.append("\n")
49
+ self._extra_pad_line = None
50
+
51
+ # Node line
52
+ node_line_str = ""
53
+ for i, entry in enumerate(line.node_line):
54
+ if entry == NodeLine.NODE:
55
+ if i > 0:
56
+ node_line_str += " "
57
+ node_line_str += line.glyph + " "
58
+ elif entry == NodeLine.PARENT:
59
+ node_line_str += " | " if i > 0 else "| "
60
+ elif entry == NodeLine.ANCESTOR:
61
+ node_line_str += " . " if i > 0 else ". "
62
+ else:
63
+ node_line_str += " " if i > 0 else " "
64
+ msg = next(message_iter, None)
65
+ if msg is not None:
66
+ node_line_str += " " + msg
67
+ out.append(node_line_str.rstrip())
68
+ out.append("\n")
69
+
70
+ # Link line
71
+ if line.link_line is not None:
72
+ link_row = line.link_line
73
+ top_link = ""
74
+ bot_link = ""
75
+ for i, cur in enumerate(link_row):
76
+ # Top left
77
+ if i > 0:
78
+ if cur.intersects(LinkLine.LEFT_MERGE_PARENT):
79
+ top_link += "/"
80
+ elif cur.intersects(LinkLine.LEFT_MERGE_ANCESTOR):
81
+ top_link += "."
82
+ elif cur.intersects(LinkLine.HORIZ_PARENT):
83
+ top_link += "_"
84
+ elif cur.intersects(LinkLine.HORIZ_ANCESTOR):
85
+ top_link += "."
86
+ else:
87
+ top_link += " "
88
+
89
+ # Top center
90
+ if cur.intersects(LinkLine.VERT_PARENT):
91
+ top_link += "|"
92
+ elif cur.intersects(LinkLine.VERT_ANCESTOR):
93
+ top_link += "."
94
+ elif cur.intersects(LinkLine.ANY_MERGE):
95
+ top_link += " "
96
+ elif cur.intersects(LinkLine.HORIZ_PARENT):
97
+ top_link += "_"
98
+ elif cur.intersects(LinkLine.HORIZ_ANCESTOR):
99
+ top_link += "."
100
+ else:
101
+ top_link += " "
102
+
103
+ # Top right
104
+ if cur.intersects(LinkLine.RIGHT_MERGE_PARENT):
105
+ top_link += "\\"
106
+ elif cur.intersects(LinkLine.RIGHT_MERGE_ANCESTOR):
107
+ top_link += "."
108
+ elif cur.intersects(LinkLine.HORIZ_PARENT):
109
+ top_link += "_"
110
+ elif cur.intersects(LinkLine.HORIZ_ANCESTOR):
111
+ top_link += "."
112
+ else:
113
+ top_link += " "
114
+
115
+ # Bottom left
116
+ if i > 0:
117
+ if cur.intersects(LinkLine.LEFT_FORK_PARENT):
118
+ bot_link += "\\"
119
+ elif cur.intersects(LinkLine.LEFT_FORK_ANCESTOR):
120
+ bot_link += "."
121
+ else:
122
+ bot_link += " "
123
+
124
+ # Bottom center
125
+ if cur.intersects(LinkLine.VERT_PARENT):
126
+ bot_link += "|"
127
+ elif cur.intersects(LinkLine.VERT_ANCESTOR):
128
+ bot_link += "."
129
+ else:
130
+ bot_link += " "
131
+
132
+ # Bottom right
133
+ if cur.intersects(LinkLine.RIGHT_FORK_PARENT):
134
+ bot_link += "/"
135
+ elif cur.intersects(LinkLine.RIGHT_FORK_ANCESTOR):
136
+ bot_link += "."
137
+ else:
138
+ bot_link += " "
139
+
140
+ msg = next(message_iter, None)
141
+ if msg is not None:
142
+ top_link += " " + msg
143
+ msg = next(message_iter, None)
144
+ if msg is not None:
145
+ bot_link += " " + msg
146
+ out.append(top_link.rstrip())
147
+ out.append("\n")
148
+ out.append(bot_link.rstrip())
149
+ out.append("\n")
150
+
151
+ # Term line
152
+ if line.term_line is not None:
153
+ term_row = line.term_line
154
+ term_strs = ["| ", "~ "]
155
+ for term_str in term_strs:
156
+ term_line_str = ""
157
+ for i, term in enumerate(term_row):
158
+ if i > 0:
159
+ term_line_str += " "
160
+ if term:
161
+ term_line_str += term_str
162
+ else:
163
+ if line.pad_lines[i] == PadLine.PARENT:
164
+ term_line_str += "| "
165
+ elif line.pad_lines[i] == PadLine.ANCESTOR:
166
+ term_line_str += ". "
167
+ else:
168
+ term_line_str += " "
169
+ msg = next(message_iter, None)
170
+ if msg is not None:
171
+ term_line_str += " " + msg
172
+ out.append(term_line_str.rstrip())
173
+ out.append("\n")
174
+ need_extra_pad_line = True
175
+
176
+ # Base pad line
177
+ base_pad_line = ""
178
+ for i, entry in enumerate(line.pad_lines):
179
+ if entry == PadLine.PARENT:
180
+ base_pad_line += " | " if i > 0 else "| "
181
+ elif entry == PadLine.ANCESTOR:
182
+ base_pad_line += " . " if i > 0 else ". "
183
+ else:
184
+ base_pad_line += " " if i > 0 else " "
185
+
186
+ # Remaining message lines
187
+ for msg in message_iter:
188
+ pad_line = base_pad_line + " " + msg
189
+ out.append(pad_line.rstrip())
190
+ out.append("\n")
191
+ need_extra_pad_line = False
192
+
193
+ if need_extra_pad_line:
194
+ self._extra_pad_line = base_pad_line
195
+
196
+ return "".join(out)
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ from ._output import OutputRendererOptions
4
+ from ._pad import pad_lines
5
+ from ._render import (
6
+ AncestorType,
7
+ GraphRow,
8
+ GraphRowRenderer,
9
+ LinkLine,
10
+ NodeLine,
11
+ PadLine,
12
+ )
13
+
14
+ SPACE = 0
15
+ HORIZONTAL = 1
16
+ PARENT = 2
17
+ ANCESTOR = 3
18
+ MERGE_LEFT = 4
19
+ MERGE_RIGHT = 5
20
+ MERGE_BOTH = 6
21
+ FORK_LEFT = 7
22
+ FORK_RIGHT = 8
23
+ FORK_BOTH = 9
24
+ JOIN_LEFT = 10
25
+ JOIN_RIGHT = 11
26
+ JOIN_BOTH = 12
27
+ TERMINATION = 13
28
+
29
+ SQUARE_GLYPHS = [
30
+ " ", "──", "│ ", "· ", "┘ ", "└─", "┴─", "┐ ", "┌─", "┬─", "┤ ", "├─", "┼─", "~ ",
31
+ ]
32
+
33
+ CURVED_GLYPHS = [
34
+ " ", "──", "│ ", "╷ ", "╯ ", "╰─", "┴─", "╮ ", "╭─", "┬─", "┤ ", "├─", "┼─", "~ ",
35
+ ]
36
+
37
+ DEC_GLYPHS = [
38
+ " ",
39
+ "\x1b(0qq\x1b(B",
40
+ "\x1b(0x \x1b(B",
41
+ "\x1b(0~ \x1b(B",
42
+ "\x1b(0j \x1b(B",
43
+ "\x1b(0mq\x1b(B",
44
+ "\x1b(0vq\x1b(B",
45
+ "\x1b(0k \x1b(B",
46
+ "\x1b(0lq\x1b(B",
47
+ "\x1b(0wq\x1b(B",
48
+ "\x1b(0u \x1b(B",
49
+ "\x1b(0tq\x1b(B",
50
+ "\x1b(0nq\x1b(B",
51
+ "~ ",
52
+ ]
53
+
54
+
55
+ def _pad_line_to_glyph(pl: PadLine) -> int:
56
+ if pl == PadLine.PARENT:
57
+ return PARENT
58
+ elif pl == PadLine.ANCESTOR:
59
+ return ANCESTOR
60
+ else:
61
+ return SPACE
62
+
63
+
64
+ class BoxDrawingRenderer:
65
+ def __init__(self, inner: GraphRowRenderer, options: OutputRendererOptions) -> None:
66
+ self._inner = inner
67
+ self._options = options
68
+ self._extra_pad_line: str | None = None
69
+ self._glyphs: list[str] = CURVED_GLYPHS
70
+
71
+ def with_square_glyphs(self) -> BoxDrawingRenderer:
72
+ self._glyphs = SQUARE_GLYPHS
73
+ return self
74
+
75
+ def with_dec_graphics_glyphs(self) -> BoxDrawingRenderer:
76
+ self._glyphs = DEC_GLYPHS
77
+ return self
78
+
79
+ def width(
80
+ self,
81
+ node: object | None = None,
82
+ parents: list[AncestorType] | None = None,
83
+ ) -> int:
84
+ return self._inner.width(node, parents) * 2 + 1
85
+
86
+ def reserve(self, node: object) -> None:
87
+ self._inner.reserve(node)
88
+
89
+ def next_row(
90
+ self,
91
+ node: object,
92
+ parents: list[AncestorType],
93
+ glyph: str,
94
+ message: str,
95
+ ) -> str:
96
+ glyphs = self._glyphs
97
+ line = self._inner.next_row(node, parents, glyph, message)
98
+ out: list[str] = []
99
+ message_lines = pad_lines(
100
+ line.message.splitlines(), self._options.min_row_height
101
+ )
102
+ message_iter = iter(message_lines)
103
+ need_extra_pad_line = False
104
+
105
+ if self._extra_pad_line is not None:
106
+ out.append(self._extra_pad_line.rstrip())
107
+ out.append("\n")
108
+ self._extra_pad_line = None
109
+
110
+ # Node line
111
+ node_line_str = ""
112
+ for entry in line.node_line:
113
+ if entry == NodeLine.NODE:
114
+ node_line_str += line.glyph + " "
115
+ elif entry == NodeLine.PARENT:
116
+ node_line_str += glyphs[PARENT]
117
+ elif entry == NodeLine.ANCESTOR:
118
+ node_line_str += glyphs[ANCESTOR]
119
+ else:
120
+ node_line_str += glyphs[SPACE]
121
+ msg = next(message_iter, None)
122
+ if msg is not None:
123
+ node_line_str += " " + msg
124
+ out.append(node_line_str.rstrip())
125
+ out.append("\n")
126
+
127
+ # Link line
128
+ if line.link_line is not None:
129
+ link_row = line.link_line
130
+ link_line_str = ""
131
+ for cur in link_row:
132
+ if cur.intersects(LinkLine.HORIZONTAL):
133
+ if cur.intersects(LinkLine.CHILD):
134
+ link_line_str += glyphs[JOIN_BOTH]
135
+ elif cur.intersects(LinkLine.ANY_FORK) and cur.intersects(
136
+ LinkLine.ANY_MERGE
137
+ ):
138
+ link_line_str += glyphs[JOIN_BOTH]
139
+ elif (
140
+ cur.intersects(LinkLine.ANY_FORK)
141
+ and cur.intersects(LinkLine.VERT_PARENT)
142
+ and not line.merge
143
+ ):
144
+ link_line_str += glyphs[JOIN_BOTH]
145
+ elif cur.intersects(LinkLine.ANY_FORK):
146
+ link_line_str += glyphs[FORK_BOTH]
147
+ elif cur.intersects(LinkLine.ANY_MERGE):
148
+ link_line_str += glyphs[MERGE_BOTH]
149
+ else:
150
+ link_line_str += glyphs[HORIZONTAL]
151
+ elif cur.intersects(LinkLine.VERT_PARENT) and not line.merge:
152
+ left = cur.intersects(LinkLine.LEFT_MERGE | LinkLine.LEFT_FORK)
153
+ right = cur.intersects(
154
+ LinkLine.RIGHT_MERGE | LinkLine.RIGHT_FORK
155
+ )
156
+ if left and right:
157
+ link_line_str += glyphs[JOIN_BOTH]
158
+ elif left:
159
+ link_line_str += glyphs[JOIN_LEFT]
160
+ elif right:
161
+ link_line_str += glyphs[JOIN_RIGHT]
162
+ else:
163
+ link_line_str += glyphs[PARENT]
164
+ elif cur.intersects(
165
+ LinkLine.VERT_PARENT | LinkLine.VERT_ANCESTOR
166
+ ) and not cur.intersects(LinkLine.LEFT_FORK | LinkLine.RIGHT_FORK):
167
+ left = cur.intersects(LinkLine.LEFT_MERGE)
168
+ right = cur.intersects(LinkLine.RIGHT_MERGE)
169
+ if left and right:
170
+ link_line_str += glyphs[JOIN_BOTH]
171
+ elif left:
172
+ link_line_str += glyphs[JOIN_LEFT]
173
+ elif right:
174
+ link_line_str += glyphs[JOIN_RIGHT]
175
+ else:
176
+ if cur.intersects(LinkLine.VERT_ANCESTOR):
177
+ link_line_str += glyphs[ANCESTOR]
178
+ else:
179
+ link_line_str += glyphs[PARENT]
180
+ elif cur.intersects(LinkLine.LEFT_FORK) and cur.intersects(
181
+ LinkLine.LEFT_MERGE | LinkLine.CHILD
182
+ ):
183
+ link_line_str += glyphs[JOIN_LEFT]
184
+ elif cur.intersects(LinkLine.RIGHT_FORK) and cur.intersects(
185
+ LinkLine.RIGHT_MERGE | LinkLine.CHILD
186
+ ):
187
+ link_line_str += glyphs[JOIN_RIGHT]
188
+ elif cur.intersects(LinkLine.LEFT_MERGE) and cur.intersects(
189
+ LinkLine.RIGHT_MERGE
190
+ ):
191
+ link_line_str += glyphs[MERGE_BOTH]
192
+ elif cur.intersects(LinkLine.LEFT_FORK) and cur.intersects(
193
+ LinkLine.RIGHT_FORK
194
+ ):
195
+ link_line_str += glyphs[FORK_BOTH]
196
+ elif cur.intersects(LinkLine.LEFT_FORK):
197
+ link_line_str += glyphs[FORK_LEFT]
198
+ elif cur.intersects(LinkLine.LEFT_MERGE):
199
+ link_line_str += glyphs[MERGE_LEFT]
200
+ elif cur.intersects(LinkLine.RIGHT_FORK):
201
+ link_line_str += glyphs[FORK_RIGHT]
202
+ elif cur.intersects(LinkLine.RIGHT_MERGE):
203
+ link_line_str += glyphs[MERGE_RIGHT]
204
+ else:
205
+ link_line_str += glyphs[SPACE]
206
+
207
+ msg = next(message_iter, None)
208
+ if msg is not None:
209
+ link_line_str += " " + msg
210
+ out.append(link_line_str.rstrip())
211
+ out.append("\n")
212
+
213
+ # Term line
214
+ if line.term_line is not None:
215
+ term_row = line.term_line
216
+ term_strs = [glyphs[PARENT], glyphs[TERMINATION]]
217
+ for term_str in term_strs:
218
+ term_line_str = ""
219
+ for i, term in enumerate(term_row):
220
+ if term:
221
+ term_line_str += term_str
222
+ else:
223
+ term_line_str += glyphs[_pad_line_to_glyph(line.pad_lines[i])]
224
+ msg = next(message_iter, None)
225
+ if msg is not None:
226
+ term_line_str += " " + msg
227
+ out.append(term_line_str.rstrip())
228
+ out.append("\n")
229
+ need_extra_pad_line = True
230
+
231
+ # Base pad line
232
+ base_pad_line = ""
233
+ for entry in line.pad_lines:
234
+ base_pad_line += glyphs[_pad_line_to_glyph(entry)]
235
+
236
+ # Remaining message lines
237
+ for msg in message_iter:
238
+ pad_line = base_pad_line + " " + msg
239
+ out.append(pad_line.rstrip())
240
+ out.append("\n")
241
+ need_extra_pad_line = False
242
+
243
+ if need_extra_pad_line:
244
+ self._extra_pad_line = base_pad_line
245
+
246
+ return "".join(out)
renderdag/_column.py ADDED
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Hashable, TypeVar
5
+
6
+ N = TypeVar("N", bound=Hashable)
7
+
8
+
9
+ class Column:
10
+ __slots__ = ()
11
+
12
+ def matches(self, n: object) -> bool:
13
+ return False
14
+
15
+ def variant(self) -> int:
16
+ raise NotImplementedError
17
+
18
+ def merge(self, other: Column) -> Column:
19
+ if other.variant() > self.variant():
20
+ return other
21
+ return self
22
+
23
+ def reset(self) -> Column:
24
+ return self
25
+
26
+ def clone(self) -> Column:
27
+ return self
28
+
29
+
30
+ class _Empty(Column):
31
+ __slots__ = ()
32
+
33
+ def variant(self) -> int:
34
+ return 0
35
+
36
+ def __eq__(self, other: object) -> bool:
37
+ return isinstance(other, _Empty)
38
+
39
+ def __repr__(self) -> str:
40
+ return "EMPTY"
41
+
42
+
43
+ class _Blocked(Column):
44
+ __slots__ = ()
45
+
46
+ def variant(self) -> int:
47
+ return 1
48
+
49
+ def reset(self) -> Column:
50
+ return EMPTY
51
+
52
+ def __eq__(self, other: object) -> bool:
53
+ return isinstance(other, _Blocked)
54
+
55
+ def __repr__(self) -> str:
56
+ return "BLOCKED"
57
+
58
+
59
+ EMPTY = _Empty()
60
+ BLOCKED = _Blocked()
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class Reserved(Column):
65
+ node: object
66
+
67
+ def matches(self, n: object) -> bool:
68
+ return self.node == n
69
+
70
+ def variant(self) -> int:
71
+ return 2
72
+
73
+ def clone(self) -> Column:
74
+ return Reserved(self.node)
75
+
76
+
77
+ @dataclass(slots=True)
78
+ class Ancestor(Column):
79
+ node: object
80
+
81
+ def matches(self, n: object) -> bool:
82
+ return self.node == n
83
+
84
+ def variant(self) -> int:
85
+ return 3
86
+
87
+ def clone(self) -> Column:
88
+ return Ancestor(self.node)
89
+
90
+
91
+ @dataclass(slots=True)
92
+ class Parent(Column):
93
+ node: object
94
+
95
+ def matches(self, n: object) -> bool:
96
+ return self.node == n
97
+
98
+ def variant(self) -> int:
99
+ return 4
100
+
101
+ def clone(self) -> Column:
102
+ return Parent(self.node)
103
+
104
+
105
+ def columns_find(columns: list[Column], node: object) -> int | None:
106
+ for i, col in enumerate(columns):
107
+ if col.matches(node):
108
+ return i
109
+ return None
110
+
111
+
112
+ def columns_find_empty(columns: list[Column], index: int) -> int | None:
113
+ if index < len(columns) and columns[index] == EMPTY:
114
+ return index
115
+ return columns_first_empty(columns)
116
+
117
+
118
+ def columns_first_empty(columns: list[Column]) -> int | None:
119
+ for i, col in enumerate(columns):
120
+ if col == EMPTY:
121
+ return i
122
+ return None
123
+
124
+
125
+ def columns_new_empty(columns: list[Column]) -> int:
126
+ columns.append(EMPTY)
127
+ return len(columns) - 1
128
+
129
+
130
+ def columns_reset(columns: list[Column]) -> None:
131
+ for i in range(len(columns)):
132
+ columns[i] = columns[i].reset()
133
+ while columns and columns[-1] == EMPTY:
134
+ columns.pop()
renderdag/_output.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from ._render import GraphRowRenderer
6
+
7
+
8
+ @dataclass
9
+ class OutputRendererOptions:
10
+ min_row_height: int = 2
11
+
12
+
13
+ class OutputRendererBuilder:
14
+ def __init__(self, inner: GraphRowRenderer) -> None:
15
+ self._inner = inner
16
+ self._options = OutputRendererOptions()
17
+
18
+ def with_min_row_height(self, min_row_height: int) -> OutputRendererBuilder:
19
+ self._options.min_row_height = min_row_height
20
+ return self
21
+
22
+ def build_ascii(self) -> "AsciiRenderer":
23
+ from ._ascii import AsciiRenderer
24
+
25
+ return AsciiRenderer(self._inner, self._options)
26
+
27
+ def build_ascii_large(self) -> "AsciiLargeRenderer":
28
+ from ._ascii_large import AsciiLargeRenderer
29
+
30
+ return AsciiLargeRenderer(self._inner, self._options)
31
+
32
+ def build_box_drawing(self) -> "BoxDrawingRenderer":
33
+ from ._box_drawing import BoxDrawingRenderer
34
+
35
+ return BoxDrawingRenderer(self._inner, self._options)
renderdag/_pad.py ADDED
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterator
4
+
5
+
6
+ def pad_lines(lines: list[str], min_count: int) -> Iterator[str]:
7
+ index = 0
8
+ for line in lines:
9
+ index += 1
10
+ yield line
11
+ while index < min_count:
12
+ index += 1
13
+ yield ""
renderdag/_render.py ADDED
@@ -0,0 +1,411 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ from dataclasses import dataclass, field
5
+ from typing import Optional
6
+
7
+ from ._column import (
8
+ BLOCKED,
9
+ EMPTY,
10
+ Column,
11
+ columns_find,
12
+ columns_find_empty,
13
+ columns_first_empty,
14
+ columns_new_empty,
15
+ columns_reset,
16
+ )
17
+ from ._column import Ancestor as ColumnAncestor
18
+ from ._column import Parent as ColumnParent
19
+
20
+
21
+ class AncestorType:
22
+ __slots__ = ()
23
+
24
+ def id(self) -> object | None:
25
+ raise NotImplementedError
26
+
27
+ def is_direct(self) -> bool:
28
+ raise NotImplementedError
29
+
30
+ def to_link_line(self, direct: LinkLine, indirect: LinkLine) -> LinkLine:
31
+ return direct if self.is_direct() else indirect
32
+
33
+ def to_column(self) -> Column:
34
+ raise NotImplementedError
35
+
36
+
37
+ class AncestorAncestor(AncestorType):
38
+ __slots__ = ("node",)
39
+
40
+ def __init__(self, node: object) -> None:
41
+ self.node = node
42
+
43
+ def id(self) -> object | None:
44
+ return self.node
45
+
46
+ def is_direct(self) -> bool:
47
+ return False
48
+
49
+ def to_column(self) -> Column:
50
+ return ColumnAncestor(self.node)
51
+
52
+ def __repr__(self) -> str:
53
+ return f"Ancestor.ancestor({self.node!r})"
54
+
55
+
56
+ class AncestorParent(AncestorType):
57
+ __slots__ = ("node",)
58
+
59
+ def __init__(self, node: object) -> None:
60
+ self.node = node
61
+
62
+ def id(self) -> object | None:
63
+ return self.node
64
+
65
+ def is_direct(self) -> bool:
66
+ return True
67
+
68
+ def to_column(self) -> Column:
69
+ return ColumnParent(self.node)
70
+
71
+ def __repr__(self) -> str:
72
+ return f"Ancestor.parent({self.node!r})"
73
+
74
+
75
+ class AncestorAnonymous(AncestorType):
76
+ __slots__ = ()
77
+
78
+ def id(self) -> object | None:
79
+ return None
80
+
81
+ def is_direct(self) -> bool:
82
+ return True
83
+
84
+ def to_column(self) -> Column:
85
+ return BLOCKED
86
+
87
+ def __repr__(self) -> str:
88
+ return "Ancestor.anonymous()"
89
+
90
+
91
+ class Ancestor:
92
+ @staticmethod
93
+ def ancestor(node: object) -> AncestorType:
94
+ return AncestorAncestor(node)
95
+
96
+ @staticmethod
97
+ def parent(node: object) -> AncestorType:
98
+ return AncestorParent(node)
99
+
100
+ @staticmethod
101
+ def anonymous() -> AncestorType:
102
+ return AncestorAnonymous()
103
+
104
+
105
+ class NodeLine(enum.Enum):
106
+ BLANK = 0
107
+ ANCESTOR = 1
108
+ PARENT = 2
109
+ NODE = 3
110
+
111
+
112
+ class PadLine(enum.Enum):
113
+ BLANK = 0
114
+ ANCESTOR = 1
115
+ PARENT = 2
116
+
117
+
118
+ class LinkLine(enum.IntFlag):
119
+ HORIZ_PARENT = 0b0_0000_0000_0001
120
+ HORIZ_ANCESTOR = 0b0_0000_0000_0010
121
+ VERT_PARENT = 0b0_0000_0000_0100
122
+ VERT_ANCESTOR = 0b0_0000_0000_1000
123
+ LEFT_FORK_PARENT = 0b0_0000_0001_0000
124
+ LEFT_FORK_ANCESTOR = 0b0_0000_0010_0000
125
+ RIGHT_FORK_PARENT = 0b0_0000_0100_0000
126
+ RIGHT_FORK_ANCESTOR = 0b0_0000_1000_0000
127
+ LEFT_MERGE_PARENT = 0b0_0001_0000_0000
128
+ LEFT_MERGE_ANCESTOR = 0b0_0010_0000_0000
129
+ RIGHT_MERGE_PARENT = 0b0_0100_0000_0000
130
+ RIGHT_MERGE_ANCESTOR = 0b0_1000_0000_0000
131
+ CHILD = 0b1_0000_0000_0000
132
+
133
+ HORIZONTAL = HORIZ_PARENT | HORIZ_ANCESTOR
134
+ VERTICAL = VERT_PARENT | VERT_ANCESTOR
135
+ LEFT_FORK = LEFT_FORK_PARENT | LEFT_FORK_ANCESTOR
136
+ RIGHT_FORK = RIGHT_FORK_PARENT | RIGHT_FORK_ANCESTOR
137
+ LEFT_MERGE = LEFT_MERGE_PARENT | LEFT_MERGE_ANCESTOR
138
+ RIGHT_MERGE = RIGHT_MERGE_PARENT | RIGHT_MERGE_ANCESTOR
139
+ ANY_MERGE = LEFT_MERGE | RIGHT_MERGE
140
+ ANY_FORK = LEFT_FORK | RIGHT_FORK
141
+ ANY_FORK_OR_MERGE = ANY_MERGE | ANY_FORK
142
+
143
+ def intersects(self, other: LinkLine) -> bool:
144
+ return bool(self & other)
145
+
146
+
147
+ def _empty_link_line() -> LinkLine:
148
+ return LinkLine(0)
149
+
150
+
151
+ def _column_to_node_line(col: Column) -> NodeLine:
152
+ if isinstance(col, ColumnAncestor):
153
+ return NodeLine.ANCESTOR
154
+ if isinstance(col, ColumnParent):
155
+ return NodeLine.PARENT
156
+ return NodeLine.BLANK
157
+
158
+
159
+ def _column_to_link_line(col: Column) -> LinkLine:
160
+ if isinstance(col, ColumnAncestor):
161
+ return LinkLine.VERT_ANCESTOR
162
+ if isinstance(col, ColumnParent):
163
+ return LinkLine.VERT_PARENT
164
+ return _empty_link_line()
165
+
166
+
167
+ def _column_to_pad_line(col: Column) -> PadLine:
168
+ if isinstance(col, ColumnAncestor):
169
+ return PadLine.ANCESTOR
170
+ if isinstance(col, ColumnParent):
171
+ return PadLine.PARENT
172
+ return PadLine.BLANK
173
+
174
+
175
+ @dataclass
176
+ class _AncestorColumnBounds:
177
+ target: int
178
+ min_ancestor: int
179
+ min_parent: int
180
+ max_parent: int
181
+ max_ancestor: int
182
+
183
+ @staticmethod
184
+ def from_columns(
185
+ columns: dict[int, AncestorType], target: int
186
+ ) -> Optional[_AncestorColumnBounds]:
187
+ if not columns:
188
+ return None
189
+ sorted_keys = sorted(columns.keys())
190
+ min_ancestor = min(sorted_keys[0], target)
191
+ max_ancestor = max(sorted_keys[-1], target)
192
+
193
+ direct_keys = [k for k in sorted_keys if columns[k].is_direct()]
194
+ min_parent = min(direct_keys[0], target) if direct_keys else target
195
+ max_parent = max(direct_keys[-1], target) if direct_keys else target
196
+
197
+ return _AncestorColumnBounds(
198
+ target=target,
199
+ min_ancestor=min_ancestor,
200
+ min_parent=min_parent,
201
+ max_parent=max_parent,
202
+ max_ancestor=max_ancestor,
203
+ )
204
+
205
+ def range(self) -> range:
206
+ if self.min_ancestor < self.max_ancestor:
207
+ return range(self.min_ancestor + 1, self.max_ancestor)
208
+ return range(0, 0)
209
+
210
+ def horizontal_line(self, index: int) -> LinkLine:
211
+ if index == self.target:
212
+ return _empty_link_line()
213
+ elif index > self.min_parent and index < self.max_parent:
214
+ return LinkLine.HORIZ_PARENT
215
+ elif index > self.min_ancestor and index < self.max_ancestor:
216
+ return LinkLine.HORIZ_ANCESTOR
217
+ else:
218
+ return _empty_link_line()
219
+
220
+
221
+ @dataclass
222
+ class GraphRow:
223
+ node: object
224
+ glyph: str
225
+ message: str
226
+ merge: bool
227
+ node_line: list[NodeLine]
228
+ link_line: list[LinkLine] | None
229
+ term_line: list[bool] | None
230
+ pad_lines: list[PadLine]
231
+
232
+
233
+ class GraphRowRenderer:
234
+ def __init__(self) -> None:
235
+ self._columns: list[Column] = []
236
+
237
+ def width(
238
+ self,
239
+ node: object | None = None,
240
+ parents: list[AncestorType] | None = None,
241
+ ) -> int:
242
+ width = len(self._columns)
243
+ empty_columns = sum(1 for c in self._columns if c == EMPTY)
244
+ if node is not None:
245
+ if columns_find(self._columns, node) is None:
246
+ if empty_columns == 0:
247
+ width += 1
248
+ else:
249
+ empty_columns = max(0, empty_columns - 1)
250
+ if parents is not None:
251
+ unallocated = 0
252
+ for p in parents:
253
+ pid = p.id()
254
+ if pid is None or columns_find(self._columns, pid) is None:
255
+ unallocated += 1
256
+ unallocated = max(0, unallocated - empty_columns)
257
+ width += max(0, unallocated - 1)
258
+ return width
259
+
260
+ def reserve(self, node: object) -> None:
261
+ from ._column import Reserved
262
+
263
+ if columns_find(self._columns, node) is None:
264
+ idx = columns_first_empty(self._columns)
265
+ if idx is not None:
266
+ self._columns[idx] = Reserved(node)
267
+ else:
268
+ self._columns.append(Reserved(node))
269
+
270
+ def next_row(
271
+ self,
272
+ node: object,
273
+ parents: list[AncestorType],
274
+ glyph: str,
275
+ message: str,
276
+ ) -> GraphRow:
277
+ column = columns_find(self._columns, node)
278
+ if column is None:
279
+ column = columns_first_empty(self._columns)
280
+ if column is None:
281
+ column = columns_new_empty(self._columns)
282
+ self._columns[column] = EMPTY
283
+
284
+ merge = len(parents) > 1
285
+
286
+ node_line = [_column_to_node_line(c) for c in self._columns]
287
+ node_line[column] = NodeLine.NODE
288
+
289
+ link_line = [_column_to_link_line(c) for c in self._columns]
290
+ need_link_line = False
291
+
292
+ term_line = [False] * len(self._columns)
293
+ need_term_line = False
294
+
295
+ pad_lines_list = [_column_to_pad_line(c) for c in self._columns]
296
+
297
+ parent_columns: dict[int, AncestorType] = {}
298
+ for p in parents:
299
+ pid = p.id()
300
+ if pid is not None:
301
+ idx = columns_find(self._columns, pid)
302
+ if idx is not None:
303
+ self._columns[idx] = self._columns[idx].merge(p.to_column())
304
+ parent_columns[idx] = p
305
+ continue
306
+
307
+ idx = columns_find_empty(self._columns, column)
308
+ if idx is not None:
309
+ self._columns[idx] = self._columns[idx].merge(p.to_column())
310
+ parent_columns[idx] = p
311
+ continue
312
+
313
+ parent_columns[len(self._columns)] = p
314
+ node_line.append(NodeLine.BLANK)
315
+ pad_lines_list.append(PadLine.BLANK)
316
+ link_line.append(_empty_link_line())
317
+ term_line.append(False)
318
+ self._columns.append(p.to_column())
319
+
320
+ for i, p in parent_columns.items():
321
+ if p.id() is None:
322
+ term_line[i] = True
323
+ need_term_line = True
324
+
325
+ if len(parents) == 1:
326
+ sorted_pc = sorted(parent_columns.items())
327
+ if sorted_pc:
328
+ parent_column, _ = sorted_pc[0]
329
+ if parent_column > column:
330
+ self._columns[column], self._columns[parent_column] = (
331
+ self._columns[parent_column],
332
+ self._columns[column],
333
+ )
334
+ parent = parent_columns.pop(parent_column)
335
+ parent_columns[column] = parent
336
+
337
+ was_direct = bool(
338
+ link_line[parent_column] & LinkLine.VERT_PARENT
339
+ )
340
+ link_line[column] |= (
341
+ LinkLine.RIGHT_FORK_PARENT
342
+ if was_direct
343
+ else LinkLine.RIGHT_FORK_ANCESTOR
344
+ )
345
+ for i in range(column + 1, parent_column):
346
+ link_line[i] |= (
347
+ LinkLine.HORIZ_PARENT
348
+ if was_direct
349
+ else LinkLine.HORIZ_ANCESTOR
350
+ )
351
+ link_line[parent_column] = (
352
+ LinkLine.LEFT_MERGE_PARENT
353
+ if was_direct
354
+ else LinkLine.LEFT_MERGE_ANCESTOR
355
+ )
356
+ need_link_line = True
357
+ pad_lines_list[parent_column] = PadLine.BLANK
358
+
359
+ bounds = _AncestorColumnBounds.from_columns(parent_columns, column)
360
+ if bounds is not None:
361
+ for i in bounds.range():
362
+ link_line[i] |= bounds.horizontal_line(i)
363
+ need_link_line = True
364
+
365
+ if bounds.max_parent > column:
366
+ link_line[column] |= LinkLine.RIGHT_MERGE_PARENT
367
+ need_link_line = True
368
+ elif bounds.max_ancestor > column:
369
+ link_line[column] |= LinkLine.RIGHT_MERGE_ANCESTOR
370
+ need_link_line = True
371
+
372
+ if bounds.min_parent < column:
373
+ link_line[column] |= LinkLine.LEFT_MERGE_PARENT
374
+ need_link_line = True
375
+ elif bounds.min_ancestor < column:
376
+ link_line[column] |= LinkLine.LEFT_MERGE_ANCESTOR
377
+ need_link_line = True
378
+
379
+ for i in sorted(parent_columns.keys()):
380
+ p = parent_columns[i]
381
+ pad_lines_list[i] = _column_to_pad_line(self._columns[i])
382
+ if i < column:
383
+ link_line[i] |= p.to_link_line(
384
+ LinkLine.RIGHT_FORK_PARENT, LinkLine.RIGHT_FORK_ANCESTOR
385
+ )
386
+ elif i == column:
387
+ link_line[i] |= LinkLine.CHILD | p.to_link_line(
388
+ LinkLine.VERT_PARENT, LinkLine.VERT_ANCESTOR
389
+ )
390
+ else:
391
+ link_line[i] |= p.to_link_line(
392
+ LinkLine.LEFT_FORK_PARENT, LinkLine.LEFT_FORK_ANCESTOR
393
+ )
394
+
395
+ columns_reset(self._columns)
396
+
397
+ return GraphRow(
398
+ node=node,
399
+ glyph=glyph,
400
+ message=message,
401
+ merge=merge,
402
+ node_line=node_line,
403
+ link_line=link_line if need_link_line else None,
404
+ term_line=term_line if need_term_line else None,
405
+ pad_lines=pad_lines_list,
406
+ )
407
+
408
+ def output(self) -> "OutputRendererBuilder":
409
+ from ._output import OutputRendererBuilder
410
+
411
+ return OutputRendererBuilder(self)
renderdag/py.typed ADDED
File without changes
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.3
2
+ Name: renderdag
3
+ Version: 0.1.0
4
+ Summary: Render directed acyclic graphs (DAGs) as text, ported from sapling-renderdag
5
+ Keywords: dag,graph,visualization,git,terminal
6
+ Author: Sam Watson
7
+ Author-email: Sam Watson <sam.watson@aprioriinvestments.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) Meta Platforms, Inc. and affiliates.
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ Classifier: License :: OSI Approved :: MIT License
30
+ Classifier: Programming Language :: Python :: 3
31
+ Classifier: Programming Language :: Python :: 3.10
32
+ Classifier: Programming Language :: Python :: 3.11
33
+ Classifier: Programming Language :: Python :: 3.12
34
+ Classifier: Programming Language :: Python :: 3.13
35
+ Classifier: Topic :: Software Development :: Version Control :: Git
36
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
37
+ Requires-Python: >=3.10
38
+ Description-Content-Type: text/markdown
39
+
40
+ # renderdag
41
+
42
+ A Python port of Meta's [sapling-renderdag](https://github.com/facebook/sapling/tree/main/eden/scm/lib/renderdag) Rust library for rendering directed acyclic graphs (DAGs) as text.
43
+
44
+
45
+ ## Renderers
46
+
47
+ **ASCII** — basic characters, compact:
48
+
49
+ ```
50
+ o E merge commit
51
+ |\
52
+ o | D
53
+ | |
54
+ | o C branch
55
+ |/
56
+ o B
57
+ |
58
+ o A
59
+ ```
60
+
61
+ **ASCII Large** — wider spacing, easier to read:
62
+
63
+ ```
64
+ o E merge commit
65
+ |\
66
+ | \
67
+ o | D
68
+ | |
69
+ | o C branch
70
+ | /
71
+ |/
72
+ o B
73
+ |
74
+ o A
75
+ ```
76
+
77
+ **Box Drawing** — Unicode line-drawing characters:
78
+
79
+ ```
80
+ o E merge commit
81
+ ├─╮
82
+ o │ D
83
+ │ │
84
+ │ o C branch
85
+ ├─╯
86
+ o B
87
+
88
+ o A
89
+ ```
90
+
91
+ ## Install
92
+
93
+ ```
94
+ pip install renderdag
95
+ # or
96
+ uv add renderdag
97
+ ```
98
+
99
+ ## Usage
100
+
101
+ ```python
102
+ from renderdag import Ancestor, GraphRowRenderer
103
+
104
+ renderer = GraphRowRenderer().output().build_box_drawing()
105
+
106
+ # Render rows top-down (most recent commit first)
107
+ print(renderer.next_row("C", [Ancestor.parent("B")], "o", "C latest"))
108
+ print(renderer.next_row("B", [Ancestor.parent("A")], "o", "B"))
109
+ print(renderer.next_row("A", [], "o", "A root"))
110
+ ```
111
+
112
+ The API follows a row-at-a-time model: call `next_row()` for each node in topological order (heads first), passing the node identifier, its parents as `Ancestor` values, a glyph string, and a message. The renderer tracks column state and returns the formatted string for that row.
113
+
114
+ ### Ancestor types
115
+
116
+ - `Ancestor.parent(node)` — direct parent (solid line)
117
+ - `Ancestor.ancestor(node)` — indirect ancestor (dotted line)
118
+ - `Ancestor.anonymous()` — unknown/missing parent (terminator `~`)
119
+
120
+ ### Builder options
121
+
122
+ ```python
123
+ # ASCII renderer
124
+ renderer = GraphRowRenderer().output().build_ascii()
125
+
126
+ # ASCII Large with taller rows
127
+ renderer = GraphRowRenderer().output().with_min_row_height(3).build_ascii_large()
128
+
129
+ # Box Drawing with square corners instead of curved
130
+ renderer = GraphRowRenderer().output().build_box_drawing().with_square_glyphs()
131
+
132
+ # Reserve a column for a node before it appears
133
+ renderer.reserve("some-node")
134
+ ```
135
+
136
+ ## Development
137
+
138
+ Requires [uv](https://docs.astral.sh/uv/) and [just](https://just.systems/).
139
+
140
+ ```
141
+ just demo # see example output from all renderers
142
+ just test # run all tests
143
+ just test-fast # skip Rust cross-validation
144
+ just test-xval # run only Rust cross-validation
145
+ ```
146
+
147
+ ## License
148
+
149
+ MIT. This is a derivative work of [sapling-renderdag](https://github.com/facebook/sapling/tree/main/eden/scm/lib/renderdag) by Meta Platforms, Inc. See [LICENSE](LICENSE) for details.
150
+
151
+ *Note: this library was created using LLMs.*
152
+
@@ -0,0 +1,12 @@
1
+ renderdag/__init__.py,sha256=0R4VY2m7z4DYZEW5eGjNREbLfSxCsyGfwV3wxpIIv7E,469
2
+ renderdag/_ascii.py,sha256=xIp0Y-Ir7JHQWHDZ_Pjf9CnzOoIe_IRjsz0FbsjU0y0,5749
3
+ renderdag/_ascii_large.py,sha256=qTGpF3OJvGGbSjBmKaZF2Cg-EEaRFP6b7P6CsCRmbeI,6837
4
+ renderdag/_box_drawing.py,sha256=OflEyN-1JOfPBFAooRU9hG6IcgpphTHqPFLQi2Qr2xM,8593
5
+ renderdag/_column.py,sha256=vNQQTG6TxpDiy-b1YFIxqf3frXMNZPe6ErmEWpXi9yQ,2703
6
+ renderdag/_output.py,sha256=ugfJfljz4ZooNV7i1SA7Sra2qS_KsrBUfDU_kVMczf0,994
7
+ renderdag/_pad.py,sha256=1CqOER19n8_8lp3Fkky0qxbvItbRpy8iZq43mGZ--RA,272
8
+ renderdag/_render.py,sha256=lT32XnXhNZj4gem6-_LtYDndei-Lo7rA-YNSZsl0eU0,12597
9
+ renderdag/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ renderdag-0.1.0.dist-info/WHEEL,sha256=QnGI6l9Psotmz6XrseXTYT5jnZRJeTk4SwJP4aLtfdI,80
11
+ renderdag-0.1.0.dist-info/METADATA,sha256=v58ZRfok8cJyiLX2VH3npS291FpiPMcck6iJspXAQ10,4569
12
+ renderdag-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.10.2
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any