rosetree 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.
rosetree/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Library implementing the "rose tree" data structure."""
2
+
3
+ from .draw import TreeDrawOptions, TreeLayoutOptions
4
+ from .tree import FrozenTree, MemoTree, Tree, zip_trees, zip_trees_with
5
+ from .trie import Trie
6
+
7
+
8
+ __version__ = '0.1.0'
9
+
10
+ __all__ = [
11
+ 'FrozenTree',
12
+ 'MemoTree',
13
+ 'Tree',
14
+ 'TreeDrawOptions',
15
+ 'TreeLayoutOptions',
16
+ 'Trie',
17
+ 'zip_trees',
18
+ 'zip_trees_with',
19
+ ]
rosetree/draw.py ADDED
@@ -0,0 +1,372 @@
1
+ """This module contains algorithms for drawing trees prettily."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass, field
7
+ from math import ceil
8
+ import re
9
+ from typing import TYPE_CHECKING, Callable, NamedTuple, Optional, TypeVar, Union
10
+
11
+ from typing_extensions import Self
12
+
13
+ from .utils import cumsums
14
+
15
+
16
+ if TYPE_CHECKING:
17
+ from .tree import BaseTree
18
+
19
+
20
+ T = TypeVar('T')
21
+
22
+
23
+ # CONSTANTS
24
+
25
+ _PARTITION_REGEX = re.compile(r'^(\s*)([^\s](.*[^\s])?)(\s*)$')
26
+
27
+
28
+ # TYPES
29
+
30
+ # color as string or RGB(A) tuple
31
+ Color = Union[str, tuple[float, ...]]
32
+
33
+
34
+ class Box(NamedTuple):
35
+ """Class representing a box (rectangle)."""
36
+ x: float
37
+ y: float
38
+ width: float
39
+ height: float
40
+
41
+ def shift(self, dx: float, dy: float) -> Self:
42
+ """Returns a new box, shifted by the given (dx, dy)."""
43
+ return type(self)(self.x + dx, self.y + dy, self.width, self.height)
44
+
45
+
46
+ BoxPair = tuple[Box, Box]
47
+
48
+
49
+ # LONG FORMAT
50
+
51
+ def pretty_tree_long(tree: BaseTree[T]) -> str:
52
+ """Given a tree whose nows can be converted to strings via `str`, produces a pretty rendering of that tree in "long format."
53
+ Each node is printed on its own line.
54
+ This format is analogous to the Linux `tree` command."""
55
+ def _pretty_lines(node: T, children: Sequence[list[str]]) -> list[str]:
56
+ lines = [str(node)]
57
+ num_children = len(children)
58
+ for (i, child_lines) in enumerate(children):
59
+ assert child_lines
60
+ if i < num_children - 1:
61
+ prefix1 = '├── '
62
+ prefix2 = '│ '
63
+ else:
64
+ prefix1 = '└── '
65
+ prefix2 = ' '
66
+ lines.append(prefix1 + child_lines[0])
67
+ lines.extend([prefix2 + line for line in child_lines[1:]])
68
+ return lines
69
+ lines = tree.fold(_pretty_lines)
70
+ return '\n'.join(lines)
71
+
72
+ # WIDE FORMAT
73
+
74
+ def _center_index(n: int) -> int:
75
+ """Gets the (integer) midpoint index for the given distance n."""
76
+ return max(0, n - 1) // 2
77
+
78
+ def _partition_line(line: str) -> tuple[str, str, str]:
79
+ """Giving a line with leading and/or trailing whitespace, splits it into these three parts, returning a tuple (leading whitespace, text, trailing whitespace)."""
80
+ (leading, text, _, trailing) = _PARTITION_REGEX.match(line).groups() # type: ignore[union-attr]
81
+ return (leading, text, trailing)
82
+
83
+ def _place_line(line: str, width: int, center: int) -> str:
84
+ """Given a text line, total width, and center index, pads the line on the left and right so that the total width and text center match the target quantities."""
85
+ line_length = len(line)
86
+ line_center = _center_index(line_length)
87
+ lpad = center - line_center
88
+ rpad = (width - center) - (line_length - line_center)
89
+ if (lpad < 0) or (rpad < 0):
90
+ raise ValueError(f'cannot center line of length {line_length} at index {center} in a width of {width}')
91
+ return (' ' * lpad) + line + (' ' * rpad)
92
+
93
+ def _pad_lines(lines: list[str]) -> list[str]:
94
+ # ensure the tree is padded on the left & right
95
+ if any(not line.startswith(' ') for line in lines):
96
+ lines = [' ' + line for line in lines]
97
+ if any(not line.endswith(' ') for line in lines):
98
+ lines = [line + ' ' for line in lines]
99
+ return lines
100
+
101
+ def _get_box_char(j: int, lcenter: int, midpoint: int, rcenter: int) -> str:
102
+ if j == midpoint:
103
+ return '┴'
104
+ if (j < lcenter) or (j > rcenter):
105
+ return ' '
106
+ if j == lcenter:
107
+ return '┌'
108
+ if j == rcenter:
109
+ return '┐'
110
+ return '─'
111
+
112
+ def _extend_box_char_down(c: str) -> str:
113
+ if c == '─':
114
+ return '┬'
115
+ if c == '┴':
116
+ return '┼'
117
+ return c
118
+
119
+
120
+ class Column(NamedTuple):
121
+ """Tuple of elements for a fixed-width column of text, which may consist of multiple rows."""
122
+ width: int # width of column
123
+ center: int # center index
124
+ rows: list[str] # rows of text
125
+
126
+ def pad_to(self, width: int) -> Self:
127
+ """Pads the column to the given width."""
128
+ n = width - self.width
129
+ if n <= 0:
130
+ return self
131
+ lpad = ' ' * (n // 2)
132
+ rpad = ' ' * (n - n // 2)
133
+ rows = [lpad + row + rpad for row in self.rows]
134
+ return type(self)(width, _center_index(width), rows)
135
+
136
+ @classmethod
137
+ def conjoin(cls, columns: Sequence[Self], spacing: int, top_down: bool) -> Self:
138
+ """Conjoint multiple columns horizontally into one.
139
+ An integer, spacing, specifies how many spaces to insert between each column.
140
+ If top_down = True, aligns conjoined columns from the top, otherwise from the bottom."""
141
+ assert len(columns) > 1
142
+ (widths, _, cols) = zip(*columns)
143
+ heights = [len(col) for col in cols]
144
+ max_height = max(heights)
145
+ empty_rows = [[' ' * width] * (max_height - height) for (width, height) in zip(widths, heights)]
146
+ if top_down:
147
+ cols = tuple(col + empty for (empty, col) in zip(empty_rows, cols))
148
+ else:
149
+ cols = tuple(empty + col for (empty, col) in zip(empty_rows, cols))
150
+ width = sum(widths) + (spacing * (len(cols) - 1))
151
+ delim = ' ' * spacing
152
+ col = [delim.join(row) for row in zip(*cols)]
153
+ return cls(width, _center_index(width), col)
154
+
155
+
156
+ def pretty_tree_wide(tree: BaseTree[T], *, top_down: bool = False, spacing: int = 2) -> str:
157
+ """Given a tree whose nows can be converted to strings via `str`, produces a pretty rendering of that tree in "wide format."
158
+ This presents the tree's root node at the top, with branches cascading down.
159
+ If top_down = True, positions nodes of the same depth on the same vertical level.
160
+ Otherwise, positions leaf nodes on the same vertical level.
161
+ spacing is an integer indicating the minimum number of spaces between each column."""
162
+ def conjoin_subtrees(node: T, children: Sequence[Column]) -> Column:
163
+ node_str = str(node)
164
+ node_lines = node_str.splitlines() if node_str else ['']
165
+ node_width = max(map(len, node_lines))
166
+ node_lines = [line.ljust(node_width) for line in node_lines]
167
+ if (num_children := len(children)) == 0: # leaf
168
+ return Column(node_width, _center_index(node_width), node_lines)
169
+ (child_widths, child_centers, _) = zip(*children)
170
+ if num_children == 1:
171
+ (width, center, rows) = children[0].pad_to(node_width)
172
+ spans = [(0, width)]
173
+ centers = [center]
174
+ midpoint = center
175
+ edges = ['│' if (j == center) else ' ' for j in range(width)]
176
+ else:
177
+ # calculate the smallest spacing required for the child width to exceed the parent width
178
+ num_spaces = max(spacing, ceil((node_width - sum(child_widths)) / (num_children - 1)))
179
+ (width, center, rows) = Column.conjoin(children, num_spaces, top_down)
180
+ # place parent at the midpoint of the leftmost and rightmost child's centers
181
+ spans = [(0, child_widths[0])]
182
+ for child_width in child_widths[1:]:
183
+ start = spans[-1][1]
184
+ spans.append((start + num_spaces, start + num_spaces + child_width))
185
+ centers = [start + child_center for ((start, _), child_center) in zip(spans, child_centers)]
186
+ (lcenter, rcenter) = (centers[0], centers[-1])
187
+ midpoint = lcenter + _center_index(rcenter - lcenter + 1)
188
+ edges = [_get_box_char(j, lcenter, midpoint, rcenter) for j in range(width)]
189
+ for j in centers:
190
+ edges[j] = _extend_box_char_down(edges[j])
191
+ node_lines = [_place_line(line, width, midpoint) for line in node_lines]
192
+ node_lines.append(''.join(edges))
193
+ # get mapping from center indices to column spans
194
+ column_spans = {center: (start, stop) for (center, (start, stop)) in zip(centers, spans) if (start <= center < stop)}
195
+ # insert '|' downward to each child
196
+ text_cols = [list(col) for col in zip(*rows)]
197
+ # extension_indices = set() # indices at which to branch downward
198
+ for (j, col) in enumerate(text_cols):
199
+ if (span := column_spans.get(j)) is None:
200
+ continue
201
+ for (i, row) in enumerate(rows):
202
+ if row[span[0]:span[1]].isspace():
203
+ col[i] = '│'
204
+ else:
205
+ break
206
+ rows = node_lines + [''.join(row) for row in zip(*text_cols)]
207
+ top_line = node_lines[0]
208
+ if top_line.isspace():
209
+ new_center = center
210
+ else:
211
+ (leading, top_text, _) = _partition_line(node_lines[0])
212
+ new_center = len(leading) + _center_index(len(top_text))
213
+ return Column(width, new_center, rows)
214
+ lines = tree.fold(conjoin_subtrees).rows
215
+ # ensure the tree is padded on the left & right
216
+ lines = _pad_lines(lines)
217
+ return '\n'.join(lines)
218
+
219
+ # PLANAR DRAWING
220
+
221
+ @dataclass
222
+ class TreeLayoutOptions:
223
+ """Options for laying out a tree diagram in 2D coordinates."""
224
+ xchar: float = 0.16 # x width of characters
225
+ ychar: float = 0.21 # y width of characters
226
+ xgap: float = 0.7 # horizontal gap dimension
227
+ ygap: float = 0.7 # vertical gap dimension
228
+ ylead: float = 0.06 # space between lines of text
229
+
230
+ def text_size(self, s: str) -> tuple[float, float]:
231
+ """Gets the width and height of a block of text."""
232
+ lines = s.splitlines()
233
+ num_lines = len(lines)
234
+ if num_lines == 0:
235
+ width = 0.0
236
+ else:
237
+ width = self.xchar * max(map(len, lines))
238
+ height = self.ychar * num_lines + self.ylead * max(0, num_lines - 1)
239
+ return (width, height)
240
+
241
+ def _shift_coord_node_pair(self, dx: float, dy: float) -> Callable[[tuple[BoxPair, T]], tuple[BoxPair, T]]:
242
+ def _shift(pair: tuple[BoxPair, T]) -> tuple[BoxPair, T]:
243
+ ((box1, box2), node) = pair
244
+ return ((box1.shift(dx, dy), box2.shift(dx, dy)), node)
245
+ return _shift
246
+
247
+ def tree_with_boxes(self, tree: BaseTree[T], *, top_down: bool = True) -> BaseTree[tuple[BoxPair, T]]:
248
+ """Computes bounding boxes for each node of the tree for the given drawing style.
249
+ Returns a new tree of ((parent box, full box), node) pairs."""
250
+ cls = type(tree)
251
+ # recursively compute (parent node box, full subtree box) for each node
252
+ def get_boxes(node: T, children: Sequence[BaseTree[tuple[BoxPair, T]]]) -> BaseTree[tuple[BoxPair, T]]:
253
+ # compute parent dimensions from text size
254
+ (parent_width, parent_height) = self.text_size(str(node))
255
+ num_children = len(children)
256
+ if num_children == 0:
257
+ full_box = parent_box = Box(0.0, 0.0, parent_width, parent_height)
258
+ else:
259
+ child_widths = [child.node[0][1].width for child in children]
260
+ # get x offsets for the child boxes, inserting gaps
261
+ dxs = cumsums([self.xgap + child_width for child_width in child_widths[:-1]] + [child_widths[-1]])
262
+ children_width = dxs[-1] # width of all children together
263
+ dy = -(parent_height + self.ygap)
264
+ child_heights = [child.node[0][1].height for child in children]
265
+ max_child_height = max(child_heights)
266
+ if top_down: # same vertical offset for each child
267
+ dys = [dy] * num_children
268
+ else: # adjust vertical offset for each child's height
269
+ dys = [dy - (max_child_height - height) for height in child_heights]
270
+ if parent_width > children_width: # shift children under parent
271
+ full_width = parent_width
272
+ diff = (parent_width - children_width) / 2.0
273
+ # shift each child's box to the right by the appropriate offset
274
+ children = [child.map(self._shift_coord_node_pair(dx + diff, dy)) for (child, dx, dy) in zip(children, dxs, dys)]
275
+ parent_x = 0.0
276
+ else: # center parent over its children
277
+ full_width = children_width
278
+ children = [child.map(self._shift_coord_node_pair(dx, dy)) for (child, dx, dy) in zip(children, dxs, dys)]
279
+ (lbox, rbox) = (children[0].node[0][0], children[-1].node[0][0])
280
+ (left, right) = (lbox.x, rbox.x + rbox.width)
281
+ parent_x = (left + right - parent_width) / 2.0
282
+ parent_box = Box(parent_x, 0.0, parent_width, parent_height)
283
+ full_height = parent_height + self.ygap + max_child_height
284
+ full_box = Box(0.0, 0.0, full_width, full_height)
285
+ return cls(((parent_box, full_box), node), children) # type: ignore
286
+ return tree.fold(get_boxes)
287
+
288
+
289
+ @dataclass
290
+ class TreeDrawOptions:
291
+ """Options for drawing a tree diagram on a 2D canvas."""
292
+ layout_options: TreeLayoutOptions = field(default_factory=TreeLayoutOptions)
293
+ text_color: Color = 'black' # node text color
294
+ leaf_text_color: Optional[Color] = None # leaf node text color (default: same as text_color)
295
+ edge_color: Color = 'black'
296
+ node_bgcolor: Color = 'white' # background color for node rectangles
297
+ fontsize: float = 22.0
298
+ fontweight: str = 'bold'
299
+ fontfamily: str = 'monospace'
300
+ linewidth: float = 1.0 # edge line thickness
301
+ ypad_top: float = 0.05 # space between node text and arrow above
302
+ ypad_bottom: float = 0.09 # space between node text and arrow below (may exceed ypad_top because some characters descend below baseline)
303
+ axis_scale: float = 1.3 # converts data coordinates to inches
304
+ margin: float = 0.05 # outer margin of figure
305
+ dpi: int = 100 # dots per inch of figure
306
+
307
+ def draw(self, tree: BaseTree[tuple[BoxPair, T]], filename: Optional[str] = None) -> None:
308
+ """Given a tree of ((parent box, full box), node) pairs, draws the tree with matplotlib using the given settings.
309
+ If a filename is provided, saves the plot to this file; otherwise, displays the plot."""
310
+ # TODO: 'bottom-up' mode can result in lines that cross, which is ugly.
311
+ # see: https://github.com/jeremander/rosetree/issues/2
312
+ from matplotlib.patches import Rectangle # type: ignore[import-not-found]
313
+ import matplotlib.pyplot as plt # type: ignore[import-not-found]
314
+ ((_, full_box), _) = tree.node
315
+ # convert box dimensions from characters to inches
316
+ (axis_width, axis_height) = (self.axis_scale * full_box.width, self.axis_scale * full_box.height)
317
+ (fig_width, fig_height) = (axis_width + 2 * self.margin, axis_height + 2 * self.margin)
318
+ plt.close(0)
319
+ fig = plt.figure(0, figsize=(fig_width, fig_height), dpi=self.dpi)
320
+ (xmargin, ymargin) = (self.margin / fig_width, self.margin / fig_height)
321
+ ax = fig.add_axes((xmargin, ymargin, 1.0 - 2 * xmargin, 1.0 - 2 * ymargin))
322
+ def _draw(node: tuple[BoxPair, T], children: Sequence[tuple[BoxPair, T]]) -> tuple[BoxPair, T]:
323
+ ((box, _), label) = node
324
+ # draw node bounding box
325
+ rect = Rectangle((box.x, box.y - box.height), box.width, box.height, facecolor=self.node_bgcolor)
326
+ ax.add_patch(rect)
327
+ # draw text
328
+ if (len(children) == 0) and (self.leaf_text_color is not None):
329
+ text_color = self.leaf_text_color
330
+ else:
331
+ text_color = self.text_color
332
+ ax.text(
333
+ box.x,
334
+ box.y - box.height,
335
+ str(label),
336
+ color=text_color,
337
+ fontsize=self.fontsize,
338
+ fontweight=self.fontweight,
339
+ fontfamily=self.fontfamily,
340
+ )
341
+ if self.linewidth > 0.0: # draw edges
342
+ (x1, y1) = (box.x + box.width / 2.0, box.y - box.height - self.ypad_bottom)
343
+ for ((child_box, _), _) in children:
344
+ (x2, y2) = (child_box.x + child_box.width / 2.0, child_box.y + self.ypad_top)
345
+ ax.arrow(
346
+ x1,
347
+ y1,
348
+ x2 - x1,
349
+ y2 - y1,
350
+ color=self.edge_color,
351
+ linewidth=self.linewidth,
352
+ head_width=0.0,
353
+ head_length=0.0,
354
+ )
355
+ return node
356
+ tree.fold(_draw)
357
+ xchar = self.layout_options.xchar
358
+ ychar = self.layout_options.ychar
359
+ ax.set_xlim((full_box.x - xchar, full_box.x + full_box.width + xchar))
360
+ ax.set_ylim((full_box.y - full_box.height - ychar, full_box.y + ychar))
361
+ # remove spines
362
+ for spine in ax.spines.values():
363
+ spine.set_visible(False)
364
+ # remove ticks
365
+ ax.set_xticks([])
366
+ ax.set_yticks([])
367
+ # set equal aspect ratio, adjust axis limits to fit the data tightly
368
+ ax.axis('image')
369
+ if filename is None: # display plot
370
+ plt.show()
371
+ else: # save plot to file
372
+ plt.savefig(filename)