graph-easy 0.0.2__tar.gz → 0.0.4__tar.gz
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.
- {graph_easy-0.0.2/src/graph_easy.egg-info → graph_easy-0.0.4}/PKG-INFO +6 -1
- {graph_easy-0.0.2 → graph_easy-0.0.4}/README.md +5 -0
- {graph_easy-0.0.2 → graph_easy-0.0.4}/pyproject.toml +1 -1
- {graph_easy-0.0.2 → graph_easy-0.0.4}/src/graph_easy/cli.py +13 -6
- {graph_easy-0.0.2 → graph_easy-0.0.4}/src/graph_easy/render.py +56 -2
- {graph_easy-0.0.2 → graph_easy-0.0.4/src/graph_easy.egg-info}/PKG-INFO +6 -1
- {graph_easy-0.0.2 → graph_easy-0.0.4}/tests/test_graph_easy.py +35 -0
- {graph_easy-0.0.2 → graph_easy-0.0.4}/LICENSE +0 -0
- {graph_easy-0.0.2 → graph_easy-0.0.4}/setup.cfg +0 -0
- {graph_easy-0.0.2 → graph_easy-0.0.4}/src/graph_easy/__init__.py +0 -0
- {graph_easy-0.0.2 → graph_easy-0.0.4}/src/graph_easy/node.py +0 -0
- {graph_easy-0.0.2 → graph_easy-0.0.4}/src/graph_easy/parser.py +0 -0
- {graph_easy-0.0.2 → graph_easy-0.0.4}/src/graph_easy.egg-info/SOURCES.txt +0 -0
- {graph_easy-0.0.2 → graph_easy-0.0.4}/src/graph_easy.egg-info/dependency_links.txt +0 -0
- {graph_easy-0.0.2 → graph_easy-0.0.4}/src/graph_easy.egg-info/entry_points.txt +0 -0
- {graph_easy-0.0.2 → graph_easy-0.0.4}/src/graph_easy.egg-info/requires.txt +0 -0
- {graph_easy-0.0.2 → graph_easy-0.0.4}/src/graph_easy.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graph-easy
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: A Python port of Graph::Easy - lay out and render graphs as ASCII art
|
|
5
5
|
Author: Tels (original Graph::Easy author)
|
|
6
6
|
License: GPL-2.0-or-later
|
|
@@ -70,7 +70,12 @@ $ printf '[ Bonn ] --> [ Berlin ] --> [ Hamburg ]\n' | graph-easy
|
|
|
70
70
|
┌──────┐ ┌────────┐ ┌─────────┐
|
|
71
71
|
│ Bonn │ ───────>│ Berlin │ ───────>│ Hamburg │
|
|
72
72
|
└──────┘ └────────┘ └─────────┘
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Colours from `fill`/`color` attributes are **on by default**; use
|
|
76
|
+
`--no-color` to disable, or `--color auto` to enable only on a TTY.
|
|
73
77
|
|
|
78
|
+
```console
|
|
74
79
|
# plain-ASCII output
|
|
75
80
|
$ printf '[ A ] ..> [ B ]\n' | graph-easy --ascii
|
|
76
81
|
+---+ +---+
|
|
@@ -51,7 +51,12 @@ $ printf '[ Bonn ] --> [ Berlin ] --> [ Hamburg ]\n' | graph-easy
|
|
|
51
51
|
┌──────┐ ┌────────┐ ┌─────────┐
|
|
52
52
|
│ Bonn │ ───────>│ Berlin │ ───────>│ Hamburg │
|
|
53
53
|
└──────┘ └────────┘ └─────────┘
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Colours from `fill`/`color` attributes are **on by default**; use
|
|
57
|
+
`--no-color` to disable, or `--color auto` to enable only on a TTY.
|
|
54
58
|
|
|
59
|
+
```console
|
|
55
60
|
# plain-ASCII output
|
|
56
61
|
$ printf '[ A ] ..> [ B ]\n' | graph-easy --ascii
|
|
57
62
|
+---+ +---+
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graph-easy"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.4"
|
|
8
8
|
description = "A Python port of Graph::Easy - lay out and render graphs as ASCII art"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "GPL-2.0-or-later" }
|
|
@@ -26,16 +26,19 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
26
26
|
)
|
|
27
27
|
ap.add_argument(
|
|
28
28
|
"--color",
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
nargs="?",
|
|
30
|
+
const="on",
|
|
31
|
+
choices=["on", "off", "auto"],
|
|
32
|
+
help="apply ANSI colours (default: on; 'auto' = only when output is a TTY)",
|
|
31
33
|
)
|
|
32
34
|
ap.add_argument(
|
|
33
35
|
"--no-color",
|
|
34
36
|
dest="color",
|
|
35
|
-
action="
|
|
36
|
-
|
|
37
|
+
action="store_const",
|
|
38
|
+
const="off",
|
|
39
|
+
help="disable ANSI colours",
|
|
37
40
|
)
|
|
38
|
-
ap.set_defaults(color=
|
|
41
|
+
ap.set_defaults(color="on")
|
|
39
42
|
args = ap.parse_args(argv)
|
|
40
43
|
|
|
41
44
|
if args.file and args.file != "-":
|
|
@@ -44,8 +47,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
44
47
|
else:
|
|
45
48
|
text = sys.stdin.read()
|
|
46
49
|
|
|
47
|
-
if args.color
|
|
50
|
+
if args.color == "auto":
|
|
48
51
|
args.color = sys.stdout.isatty()
|
|
52
|
+
elif args.color == "on":
|
|
53
|
+
args.color = True
|
|
54
|
+
else:
|
|
55
|
+
args.color = False
|
|
49
56
|
graph = parse_graph(text)
|
|
50
57
|
output = render(graph, ascii_style=args.ascii, color=args.color)
|
|
51
58
|
sys.stdout.write(output + "\n")
|
|
@@ -213,7 +213,7 @@ def _draw_edge(
|
|
|
213
213
|
tx = tx0
|
|
214
214
|
sy = sy0 + sh // 2
|
|
215
215
|
ty = ty0 + th // 2
|
|
216
|
-
cx = sx + _CHANNEL // 2
|
|
216
|
+
cx = (sx + tx) // 2 if tx - sx > _CHANNEL else sx + _CHANNEL // 2
|
|
217
217
|
|
|
218
218
|
hch, vch, cch = styles.get(edge.style or _STYLE_DEFAULT)
|
|
219
219
|
|
|
@@ -272,8 +272,26 @@ def _draw_edge(
|
|
|
272
272
|
put_cross(cx, y, vch)
|
|
273
273
|
return
|
|
274
274
|
|
|
275
|
+
def put_corner(x: int, y: int, ch: str) -> None:
|
|
276
|
+
if 0 <= y < len(canvas) and 0 <= x < len(canvas[y]):
|
|
277
|
+
cur = canvas[y][x]
|
|
278
|
+
if cur in (" ", hch, vch):
|
|
279
|
+
canvas[y][x] = ch
|
|
280
|
+
elif cur != ch:
|
|
281
|
+
canvas[y][x] = cch
|
|
282
|
+
|
|
283
|
+
# source arm: horizontal sx..cx at row sy, then corner down at cx
|
|
275
284
|
for x in range(sx + 1, cx):
|
|
276
285
|
put(x, sy, hch)
|
|
286
|
+
if sy < ty:
|
|
287
|
+
put_corner(cx, sy, "┌")
|
|
288
|
+
elif sy > ty:
|
|
289
|
+
put_corner(cx, sy, "└")
|
|
290
|
+
# target arm: vertical cx..ty already drawn (vertical_only pass), corner right at cx,ty
|
|
291
|
+
if ty < sy:
|
|
292
|
+
put_corner(cx, ty, "┐")
|
|
293
|
+
elif ty > sy:
|
|
294
|
+
put_corner(cx, ty, "┘")
|
|
277
295
|
for x in range(cx, tx):
|
|
278
296
|
put(x, ty, hch)
|
|
279
297
|
if edge.directed_to_target:
|
|
@@ -286,6 +304,42 @@ def _draw_edge(
|
|
|
286
304
|
put(cx + i + 1, mid_y, ch)
|
|
287
305
|
|
|
288
306
|
|
|
307
|
+
def _barycenter_order(layers: list[list[str]], graph: Graph) -> list[list[str]]:
|
|
308
|
+
"""Reorder nodes within each layer to reduce edge crossings.
|
|
309
|
+
|
|
310
|
+
Sugiyama barycenter heuristic: each node's x position is the average of
|
|
311
|
+
its neighbours' positions in the previous layer; layers are then sorted
|
|
312
|
+
by that average. Iterates top-down then bottom-up until stable.
|
|
313
|
+
"""
|
|
314
|
+
order = [list(layer) for layer in layers]
|
|
315
|
+
n_layers = len(order)
|
|
316
|
+
for _ in range(n_layers + 1):
|
|
317
|
+
changed = False
|
|
318
|
+
for direction in (1, -1):
|
|
319
|
+
for li in range(n_layers):
|
|
320
|
+
prev_i = li - direction
|
|
321
|
+
if prev_i < 0 or prev_i >= n_layers:
|
|
322
|
+
continue
|
|
323
|
+
prev_order = order[prev_i]
|
|
324
|
+
prev_idx = {n: i for i, n in enumerate(prev_order)}
|
|
325
|
+
score: list[tuple[float, int, str]] = []
|
|
326
|
+
for j, n in enumerate(order[li]):
|
|
327
|
+
neigh = [e.target for e in graph.edges if e.source == n and e.target in prev_idx]
|
|
328
|
+
neigh += [e.source for e in graph.edges if e.target == n and e.source in prev_idx]
|
|
329
|
+
if neigh:
|
|
330
|
+
avg = sum(prev_idx[x] for x in neigh) / len(neigh)
|
|
331
|
+
else:
|
|
332
|
+
avg = j
|
|
333
|
+
score.append((avg, j, n))
|
|
334
|
+
new_order = [x[2] for x in sorted(score)]
|
|
335
|
+
if new_order != order[li]:
|
|
336
|
+
order[li] = new_order
|
|
337
|
+
changed = True
|
|
338
|
+
if not changed:
|
|
339
|
+
break
|
|
340
|
+
return order
|
|
341
|
+
|
|
342
|
+
|
|
289
343
|
def render(graph: Graph, *, ascii_style: bool = False, color: bool = False) -> str:
|
|
290
344
|
"""Render the graph to multi-line ASCII/Unicode text (no trailing newline)."""
|
|
291
345
|
if not graph.nodes:
|
|
@@ -294,7 +348,7 @@ def render(graph: Graph, *, ascii_style: bool = False, color: bool = False) -> s
|
|
|
294
348
|
styles = _STYLE_ASCII if ascii_style else _STYLE_UNICODE
|
|
295
349
|
bands: list[str] = []
|
|
296
350
|
for comp in _components(graph):
|
|
297
|
-
layers = _layers(comp, graph)
|
|
351
|
+
layers = _barycenter_order(_layers(comp, graph), graph)
|
|
298
352
|
layer_boxes = [[_box(graph.nodes[n], box_chars) for n in layer] for layer in layers]
|
|
299
353
|
col_w = [max(max(len(row) for row in b) for b in boxes) for boxes in layer_boxes]
|
|
300
354
|
col_h = [sum(len(b) for b in boxes) + (len(boxes) - 1) for boxes in layer_boxes]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graph-easy
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: A Python port of Graph::Easy - lay out and render graphs as ASCII art
|
|
5
5
|
Author: Tels (original Graph::Easy author)
|
|
6
6
|
License: GPL-2.0-or-later
|
|
@@ -70,7 +70,12 @@ $ printf '[ Bonn ] --> [ Berlin ] --> [ Hamburg ]\n' | graph-easy
|
|
|
70
70
|
┌──────┐ ┌────────┐ ┌─────────┐
|
|
71
71
|
│ Bonn │ ───────>│ Berlin │ ───────>│ Hamburg │
|
|
72
72
|
└──────┘ └────────┘ └─────────┘
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Colours from `fill`/`color` attributes are **on by default**; use
|
|
76
|
+
`--no-color` to disable, or `--color auto` to enable only on a TTY.
|
|
73
77
|
|
|
78
|
+
```console
|
|
74
79
|
# plain-ASCII output
|
|
75
80
|
$ printf '[ A ] ..> [ B ]\n' | graph-easy --ascii
|
|
76
81
|
+---+ +---+
|
|
@@ -161,6 +161,41 @@ def test_disjoint_groups_render_separately():
|
|
|
161
161
|
assert out.count("┌ G2") == 1
|
|
162
162
|
|
|
163
163
|
|
|
164
|
+
# ---- layout: fan-out / parallel branches ------------------------------------
|
|
165
|
+
|
|
166
|
+
def test_fan_out_keeps_all_edges():
|
|
167
|
+
g = parse_graph("[ S ] --> [ A ]\n[ S ] --> [ B ]\n[ S ] --> [ C ]\n")
|
|
168
|
+
assert len(g.edges) == 3
|
|
169
|
+
out = render(g)
|
|
170
|
+
# all three targets appear
|
|
171
|
+
assert "A" in out and "B" in out and "C" in out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_diamond_renders_all_four_edges():
|
|
175
|
+
g = parse_graph("[ A ] --> [ B ]\n[ A ] --> [ C ]\n[ B ] --> [ D ]\n[ C ] --> [ D ]\n")
|
|
176
|
+
assert len(g.edges) == 4
|
|
177
|
+
out = render(g)
|
|
178
|
+
assert "D" in out and "B" in out and "C" in out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_parallel_paths_merge_cleanly():
|
|
182
|
+
g = parse_graph(
|
|
183
|
+
"[ S ] --> [ P1 ]\n[ S ] --> [ P2 ]\n"
|
|
184
|
+
"[ P1 ] --> [ T ]\n[ P2 ] --> [ T ]\n"
|
|
185
|
+
)
|
|
186
|
+
out = render(g)
|
|
187
|
+
assert "P1" in out and "P2" in out and "T" in out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_edge_corners_use_box_chars():
|
|
191
|
+
# a downward branch must draw a ┌ corner where the arm turns vertical
|
|
192
|
+
out = render(parse_graph("[ A ] --> [ B ]\n[ A ] --> [ C ]"))
|
|
193
|
+
assert "┌" in out
|
|
194
|
+
# an upward merge must draw a └/┐ corner
|
|
195
|
+
out2 = render(parse_graph("[ A ] --> [ D ]\n[ B ] --> [ D ]"))
|
|
196
|
+
assert "└" in out2 or "┐" in out2
|
|
197
|
+
|
|
198
|
+
|
|
164
199
|
def test_filled_arrowhead():
|
|
165
200
|
g = parse_graph("[ A ] -- { arrowshape: filled; } --> [ B ]")
|
|
166
201
|
assert g.edges[0].attrs.get("arrowshape") == "filled"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|