predictygraph 0.1.0__tar.gz → 0.1.1__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.
Files changed (50) hide show
  1. {predictygraph-0.1.0 → predictygraph-0.1.1}/PKG-INFO +1 -1
  2. {predictygraph-0.1.0 → predictygraph-0.1.1}/pyproject.toml +1 -1
  3. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/route.py +49 -22
  4. predictygraph-0.1.1/tests/test_routing.py +60 -0
  5. {predictygraph-0.1.0 → predictygraph-0.1.1}/README.md +0 -0
  6. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/__init__.py +0 -0
  7. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/__main__.py +0 -0
  8. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/anim.py +0 -0
  9. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/cli.py +0 -0
  10. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/default.css +0 -0
  11. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
  12. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
  13. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
  14. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
  15. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
  16. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
  17. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
  18. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
  19. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
  20. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
  21. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/fonts.py +0 -0
  22. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/ir.py +0 -0
  23. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/layout.py +0 -0
  24. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/model.py +0 -0
  25. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/parser.py +0 -0
  26. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/render/__init__.py +0 -0
  27. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/render/apng.py +0 -0
  28. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/render/svg.py +0 -0
  29. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/safe.py +0 -0
  30. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/scene.py +0 -0
  31. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/style.py +0 -0
  32. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/text.py +0 -0
  33. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/theme.py +0 -0
  34. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
  35. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
  36. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
  37. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
  38. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/themes/default.toml +0 -0
  39. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/themes/dracula.toml +0 -0
  40. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/themes/github-light.toml +0 -0
  41. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
  42. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/themes/nord.toml +0 -0
  43. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/themes/solarized-dark.toml +0 -0
  44. {predictygraph-0.1.0 → predictygraph-0.1.1}/src/predictygraph/themes/solarized-light.toml +0 -0
  45. {predictygraph-0.1.0 → predictygraph-0.1.1}/tests/test_diagonal.py +0 -0
  46. {predictygraph-0.1.0 → predictygraph-0.1.1}/tests/test_fixes.py +0 -0
  47. {predictygraph-0.1.0 → predictygraph-0.1.1}/tests/test_hardening.py +0 -0
  48. {predictygraph-0.1.0 → predictygraph-0.1.1}/tests/test_predictygraph.py +0 -0
  49. {predictygraph-0.1.0 → predictygraph-0.1.1}/tests/test_scene.py +0 -0
  50. {predictygraph-0.1.0 → predictygraph-0.1.1}/tests/test_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: predictygraph
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Predictable graphs: a readable, CSS-styled diagram DSL with per-group layouts, rendered to SVG/APNG
5
5
  Author-Email: Reza Jelveh <git@jelveh.me>
6
6
  License: GPL-3.0-or-later
@@ -12,7 +12,7 @@ dependencies = [
12
12
  ]
13
13
  requires-python = ">=3.11"
14
14
  readme = "README.md"
15
- version = "0.1.0"
15
+ version = "0.1.1"
16
16
 
17
17
  [project.license]
18
18
  text = "GPL-3.0-or-later"
@@ -20,6 +20,7 @@ from .style import px
20
20
  LANE = 9.0 # distance between parallel lanes
21
21
  CLEAR = 12.0 # min distance between a horizontal run and the boxes above/below it
22
22
  RECT_SHAPES = {"box", "square", "round", "pill", "cylinder", "note", "image", "none"}
23
+ ROUND_SHAPES = {"circle", "ellipse", "diamond"}
23
24
 
24
25
 
25
26
  def lca(a, b, root):
@@ -62,6 +63,23 @@ def port(n, side):
62
63
  "left": (n.ax, n.cy), "right": (n.ax + n.w, n.cy)}[side]
63
64
 
64
65
 
66
+ def on_outline(n, p, vert):
67
+ """Move a port that was shifted along a side back onto the node's outline.
68
+ Boxes are flat, so only circles, ellipses and diamonds need it: the point slides
69
+ along the flow axis until it touches the curve (or slanted edge)."""
70
+ shape = n.attrs.get("shape", "box")
71
+ if isinstance(n, Group) or shape not in ROUND_SHAPES:
72
+ return p
73
+ hw, hh = n.w / 2, n.h / 2
74
+ if vert:
75
+ u = min(abs(p[0] - n.cx) / hw, 1.0)
76
+ reach = hh * (math.sqrt(1 - u * u) if shape != "diamond" else 1 - u)
77
+ return p[0], n.cy + math.copysign(reach, p[1] - n.cy)
78
+ u = min(abs(p[1] - n.cy) / hh, 1.0)
79
+ reach = hw * (math.sqrt(1 - u * u) if shape != "diamond" else 1 - u)
80
+ return n.cx + math.copysign(reach, p[0] - n.cx), p[1]
81
+
82
+
65
83
  @dataclass
66
84
  class Diagonal:
67
85
  """When may a bend/ortho jog be a slant instead of a right-angle step?"""
@@ -166,42 +184,48 @@ class Router:
166
184
 
167
185
  # ------------------------------------------------------------ passes
168
186
  def _parallel_offsets(self):
187
+ """Edges between the same two nodes get side-by-side lanes. The offset is in
188
+ absolute coordinates, so an edge and its reverse simply take different lanes."""
169
189
  pairs: dict[tuple[str, str], list[Edge]] = {}
170
190
  for e in self.doc.edges:
171
191
  pairs.setdefault(tuple(sorted((e.src, e.dst))), []).append(e)
172
192
  for es in pairs.values():
173
193
  n = len(es)
174
194
  for i, e in enumerate(es):
175
- k = (i - (n - 1) / 2) * 12
176
- # opposite-direction edges get mirrored sign so they stay apart
177
- self.offset[e.id] = k if e.src <= e.dst else -k
195
+ self.offset[e.id] = (i - (n - 1) / 2) * 12
178
196
 
179
197
  def _spread_ports(self):
180
198
  """Several curved/bent edges on one side of a node: spread them symmetrically
181
- around the side's middle (ordered by where they go)."""
199
+ around the side's middle, ordered by where they go, so they don't cross.
200
+ Parallel edges (same two nodes) take part too, which keeps them apart and in
201
+ the same order at both ends."""
182
202
  sides: dict[tuple[str, str], list] = {}
183
- for e in self.doc.edges:
184
- if e.attrs.get("exit") or e.attrs.get("enter") or self.offset.get(e.id):
185
- continue
186
- pts, axis = self.geometry(e)
187
- if axis not in ("v", "h"):
188
- continue
189
- a, b = self.doc.items[e.src], self.doc.items[e.dst]
190
- if flow_dir(lca(a, b, self.doc.root)) == "auto" or e.style.get("route", "curve") in ("straight", "ortho"):
191
- continue # ortho fan-in/out merges into a bus instead
192
- i = 0 if axis == "v" else 1
193
- for end, node, here, there in ((0, a, pts[0], pts[1] if len(pts) > 2 else pts[-1]),
194
- (1, b, pts[-1], pts[-2] if len(pts) > 2 else pts[0])):
195
- side = ("bottom" if there[1] > here[1] else "top") if axis == "v" else \
196
- ("right" if there[0] > here[0] else "left")
197
- sides.setdefault((node.id, side), []).append((there[i], e.id, end))
203
+ saved, self.offset = self.offset, {} # order by the unshifted geometry
204
+ try:
205
+ for e in self.doc.edges:
206
+ if e.attrs.get("exit") or e.attrs.get("enter"):
207
+ continue
208
+ pts, axis = self.geometry(e)
209
+ if axis not in ("v", "h"):
210
+ continue
211
+ a, b = self.doc.items[e.src], self.doc.items[e.dst]
212
+ if flow_dir(lca(a, b, self.doc.root)) == "auto" or e.style.get("route", "curve") in ("straight", "ortho"):
213
+ continue # ortho fan-in/out merges into a bus instead
214
+ i = 0 if axis == "v" else 1
215
+ for end, node, here, there in ((0, a, pts[0], pts[1] if len(pts) > 2 else pts[-1]),
216
+ (1, b, pts[-1], pts[-2] if len(pts) > 2 else pts[0])):
217
+ side = ("bottom" if there[1] > here[1] else "top") if axis == "v" else \
218
+ ("right" if there[0] > here[0] else "left")
219
+ sides.setdefault((node.id, side), []).append((there[i], e.id, end))
220
+ finally:
221
+ self.offset = saved
198
222
  for (nid, side), lst in sides.items():
199
223
  if len(lst) < 2:
200
224
  continue
201
225
  n = self.doc.items[nid]
202
- if n.attrs.get("shape", "box") in ("diamond", "circle", "ellipse") and not isinstance(n, Group):
203
- continue
204
226
  span = n.w if side in ("top", "bottom") else n.h
227
+ if n.attrs.get("shape", "box") in ROUND_SHAPES and not isinstance(n, Group):
228
+ span *= 0.6 # stay on the part of the outline that faces the side
205
229
  step = min(18.0, (span - 12) / (len(lst) - 1)) if span > 12 else 0.0
206
230
  lst.sort()
207
231
  for k, (_, eid, end) in enumerate(lst):
@@ -371,18 +395,21 @@ class Router:
371
395
  elif not wps:
372
396
  self._record_channel(e, owner, da, db, vert)
373
397
  p0, p1 = self._ports(a, b, vert)
398
+ spread = (e.id, 0) in self.port_shift or (e.id, 1) in self.port_shift
374
399
  s0, s1 = self.port_shift.get((e.id, 0), 0.0), self.port_shift.get((e.id, 1), 0.0)
375
400
  if vert:
376
401
  p0, p1 = (p0[0] + s0, p0[1]), (p1[0] + s1, p1[1])
377
402
  else:
378
403
  p0, p1 = (p0[0], p0[1] + s0), (p1[0], p1[1] + s1)
379
- if s0 or s1:
404
+ if spread:
405
+ p0, p1 = on_outline(a, p0, vert), on_outline(b, p1, vert)
380
406
  return [p0] + wps + [p1], ("v" if vert else "h")
381
407
  if off:
382
408
  if vert:
383
409
  p0, p1 = (p0[0] + off, p0[1]), (p1[0] + off, p1[1])
384
410
  else:
385
411
  p0, p1 = (p0[0], p0[1] + off), (p1[0], p1[1] + off)
412
+ p0, p1 = on_outline(a, p0, vert), on_outline(b, p1, vert)
386
413
  # boxes overlap on the cross axis: one straight line, no jog / double trace
387
414
  boxy = all(x.attrs.get("shape", "box") in RECT_SHAPES or isinstance(x, Group) for x in (a, b))
388
415
  if not wps and not off and boxy:
@@ -0,0 +1,60 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Routing regressions: edges between the same nodes, and edges fanning out of a
6
+ round node, must stay apart and must not cross."""
7
+ from predictygraph import _style, parse
8
+ from predictygraph.layout import layout
9
+ from predictygraph.route import Router
10
+
11
+ STATES = """graph dir=LR
12
+ default node shape=circle
13
+ idle "Idle"
14
+ running "Running"
15
+ error "Error"
16
+ done "Done"
17
+ idle -> running "Start"
18
+ running -> error "Fail"
19
+ running -> done "OK"
20
+ error -> running "Retry"
21
+ done -> idle "Reset"
22
+ """
23
+
24
+
25
+ def _ends():
26
+ doc = _style(parse(STATES), None, None, None)
27
+ layout(doc)
28
+ r = Router(doc).route()
29
+ ends = {}
30
+ for e in doc.edges:
31
+ pts, _ = r.geometry(e)
32
+ ends[(e.src, e.dst)] = (pts[0], pts[-1])
33
+ return ends
34
+
35
+
36
+ def test_edge_and_its_reverse_do_not_overlap():
37
+ ends = _ends()
38
+ fail = ends[("running", "error")]
39
+ retry = ends[("error", "running")]
40
+ # Fail leaves Running at p0, Retry arrives at Running at p1 (and vice versa at Error)
41
+ assert abs(fail[0][1] - retry[1][1]) >= 8, "Fail and Retry share a port on Running"
42
+ assert abs(fail[1][1] - retry[0][1]) >= 8, "Fail and Retry share a port on Error"
43
+
44
+
45
+ def test_edges_leaving_one_side_keep_their_order():
46
+ ends = _ends()
47
+ # on Running's right side: Fail (to Error, top) above Retry above OK (to Done, bottom)
48
+ ys = [ends[("running", "error")][0][1], ends[("error", "running")][1][1], ends[("running", "done")][0][1]]
49
+ assert ys == sorted(ys), f"edges cross on Running's right side: {ys}"
50
+
51
+
52
+ def test_shifted_ports_sit_on_the_circle():
53
+ doc = _style(parse(STATES), None, None, None)
54
+ layout(doc)
55
+ r = Router(doc).route()
56
+ for e in doc.edges:
57
+ pts, _ = r.geometry(e)
58
+ for n, p in ((doc.items[e.src], pts[0]), (doc.items[e.dst], pts[-1])):
59
+ d = ((p[0] - n.cx) / (n.w / 2)) ** 2 + ((p[1] - n.cy) / (n.h / 2)) ** 2
60
+ assert abs(d - 1) < 0.02, f"{e.src}->{e.dst} end is off the outline of {n.id}"
File without changes