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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graph-easy
3
- Version: 0.0.2
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.2"
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
- action="store_true",
30
- help="apply ANSI colours from fill/color attributes",
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="store_false",
36
- help="disable ANSI colours (default when output is piped)",
37
+ action="store_const",
38
+ const="off",
39
+ help="disable ANSI colours",
37
40
  )
38
- ap.set_defaults(color=None)
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 is None:
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.2
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