codefig 0.17.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.
codefig/notices.py ADDED
@@ -0,0 +1,34 @@
1
+ """v0.12.0: draw-time notices (labels the engine shortened or dropped).
2
+
3
+ Draw code calls :func:`note` when it ellipsizes a label or skips one for lack of room;
4
+ the linter resets the list before its instrumented render and reports what was noted
5
+ as CF-TXT-011, so nothing disappears silently.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, List, Tuple
10
+
11
+ _NOTES: List[Tuple[Any, str, str, str]] = []
12
+
13
+
14
+ def note(owner: Any, text: str, why: str, level: str = "warn") -> None:
15
+ """Record that ``owner`` shortened, moved or dropped ``text``.
16
+
17
+ ``why``: short reason; ``level``: ``"warn"`` (shortened / not drawn) or ``"info"``
18
+ (drawn, but at a fallback position).
19
+ """
20
+ key = (id(owner), str(text), why)
21
+ if any((id(o), t, w) == key for o, t, w, _l in _NOTES):
22
+ return
23
+ _NOTES.append((owner, str(text), why, level))
24
+
25
+
26
+ def reset() -> None:
27
+ _NOTES.clear()
28
+
29
+
30
+ def drained() -> List[Tuple[Any, str, str, str]]:
31
+ """Notes recorded since the last :func:`reset` (cleared on read)."""
32
+ out = list(_NOTES)
33
+ _NOTES.clear()
34
+ return out
codefig/overlaps.py ADDED
@@ -0,0 +1,392 @@
1
+ """Global overlap detection and resolution inside one panel.
2
+
3
+ Any two *visible* nodes that are not ancestor/descendant of each other (boxes,
4
+ texts, matrices, strips, dot grids, braces, framed groups, and place()
5
+ annotations) may not overlap by more than ``tol`` px on both axes. Layout-only
6
+ containers (Row/Col/Stack/Grid/Diagram), Spacers, arrows and CircleHighlight /
7
+ Callout rings (intentional overlays) are ignored, as are nodes listed in an
8
+ ``allow_overlap(...)`` constraint.
9
+
10
+ Policies (``Figure(overlap=...)`` / ``Panel(overlap=...)``):
11
+
12
+ * ``"resolve"`` (default): each overlapping pair gets a ``no_overlap`` constraint
13
+ along the axis of least penetration and the panel is re-solved (≤ 6 rounds);
14
+ anything still overlapping emits an :class:`OverlapWarning`.
15
+ * ``"warn"``: report only (OverlapWarning).
16
+ * ``"error"``: raise :class:`OverlapError`.
17
+ * ``"ignore"``: skip the check.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
24
+
25
+ from .constraints import Constraint, node_name, no_overlap, walk
26
+
27
+ POLICIES = ("resolve", "warn", "error", "ignore")
28
+
29
+ _VISIBLE = {"Box", "Text", "Ellipsis", "Matrix", "DotGrid", "Strip", "Brace", "Group",
30
+ "Badge", "BarChart", "VectorFan", "Swatch", "Legend"}
31
+ # intentional overlays: rings / callouts sit on top of what they highlight
32
+ _OVERLAY_TYPES = {"CircleHighlight", "Callout"}
33
+
34
+
35
+
36
+ def _unit() -> str:
37
+ from .styles import length_unit
38
+
39
+ return length_unit()
40
+
41
+ class OverlapError(ValueError):
42
+ """Raised with overlap='error' when visible nodes overlap."""
43
+
44
+
45
+ class OverlapWarning(UserWarning):
46
+ """Emitted when visible nodes overlap (overlap='warn', or unresolved)."""
47
+
48
+
49
+ def _node_context(node: Any) -> str:
50
+ """v0.13.0: where a crossed node is — its nearest named container and its box —
51
+ so ``Task('…')`` or an unnamed Box can be found without guessing."""
52
+ parts = []
53
+ p = getattr(node, "parent", None)
54
+ while p is not None:
55
+ nm = node_name(p)
56
+ if "@" not in nm:
57
+ parts.append(f"inside {nm}")
58
+ break
59
+ p = getattr(p, "parent", None)
60
+ g = getattr(node, "geom", None)
61
+ if g is not None:
62
+ parts.append(f"at x {g.x:.0f}–{g.x + g.w:.0f}, y {g.y:.0f}–{g.y + g.h:.0f}{_unit()}")
63
+ return f" ({', '.join(parts)})" if parts else ""
64
+
65
+
66
+ @dataclass
67
+ class Overlap:
68
+ a: Any
69
+ b: Any
70
+ dx: float # penetration along x (px, pre-scale panel units)
71
+ dy: float
72
+ panel: str = ""
73
+ kind: str = "node" # node | edge-node | head-text | label-node | label-text | label-label
74
+ # | step-node | step-text | step-label (edge step badges, v0.5.0)
75
+
76
+
77
+ @property
78
+ def axis(self) -> str:
79
+ """Axis of least penetration (the cheap way out)."""
80
+ return "x" if self.dx <= self.dy else "y"
81
+
82
+ def __str__(self) -> str:
83
+ where = f"panel ({self.panel}): " if self.panel else ""
84
+ if self.kind == "edge-node":
85
+ return (f"{where}{edge_name(self.a)} crosses hard node {node_name(self.b)}"
86
+ f"{_node_context(self.b)}")
87
+ if self.kind == "head-text":
88
+ return (f"{where}arrowhead of {edge_name(self.a)} lands on text {node_name(self.b)} "
89
+ f"by {self.dx:.1f}×{self.dy:.1f}{_unit()}")
90
+ if self.kind.startswith("step-"):
91
+ other = (f"label/step of {edge_name(self.b)}" if self.kind == "step-label"
92
+ else node_name(self.b))
93
+ return (f"{where}step badge {self.a.step.text!r} on {edge_name(self.a)} overlaps {other} "
94
+ f"by {self.dx:.1f}×{self.dy:.1f}{_unit()}")
95
+ if self.kind.startswith("label-"):
96
+ other = (f"label {self.b.label!r}" if self.kind == "label-label"
97
+ else node_name(self.b))
98
+ return (f"{where}label {self.a.label!r} of {edge_name(self.a)} overlaps {other} "
99
+ f"by {self.dx:.1f}×{self.dy:.1f}{_unit()}")
100
+ return (f"{where}{node_name(self.a)} overlaps {node_name(self.b)} "
101
+ f"by {self.dx:.1f}×{self.dy:.1f}{_unit()}")
102
+
103
+
104
+ def edge_name(e: Any) -> str:
105
+ try:
106
+ return f"edge {node_name(e.src.node)}→{node_name(e.dst.node)}"
107
+ except Exception: # pragma: no cover
108
+ return "edge"
109
+
110
+
111
+ def check_policy(p: Optional[str]) -> Optional[str]:
112
+ if p is None:
113
+ return None
114
+ p = str(p).lower()
115
+ if p not in POLICIES:
116
+ raise ValueError(f"overlap must be one of {POLICIES}, got {p!r}")
117
+ return p
118
+
119
+
120
+ def _visible(n: Any, overlay_ids: set) -> bool:
121
+ """Takes part in node-vs-node checks: any role except none (see codefig.policy)."""
122
+ from .policy import role_of
123
+
124
+ if getattr(n, "allow_overlap", False):
125
+ return False
126
+ if getattr(n, "_cf_guide", False): # timeline lane bands / marker lines / axis
127
+ return False
128
+ role = role_of(n)
129
+ if id(n) in overlay_ids:
130
+ return getattr(n, "_cf_obstacle", True) and role != "none"
131
+ if type(n).__name__ in _OVERLAY_TYPES:
132
+ return False
133
+ return role in ("hard", "soft", "background")
134
+
135
+
136
+ def _contains(outer: Any, inner: Any, eps: float = 0.5) -> bool:
137
+ """A framed Group (or any background-role node) fully containing another node
138
+ (e.g. an annotation hosted inside it) is nesting, not overlap."""
139
+ from .policy import role_of
140
+
141
+ if type(outer).__name__ != "Group" and role_of(outer) != "background":
142
+ return False
143
+ go, gi = outer.geom, inner.geom
144
+ return (gi.x >= go.x - eps and gi.y >= go.y - eps and gi.x + gi.w <= go.x + go.w + eps
145
+ and gi.y + gi.h <= go.y + go.h + eps)
146
+
147
+
148
+ def find_overlaps(root: Any, overlays: Sequence[Any] = (), constraints: Sequence[Constraint] = (),
149
+ tol: float = 0.5) -> List[Overlap]:
150
+ """All topmost overlapping pairs of visible nodes under ``root`` (+ overlays)."""
151
+ parent: Dict[int, Any] = {}
152
+ nodes: List[Any] = []
153
+ for top in [root] + list(overlays):
154
+ for n in walk(top):
155
+ nodes.append(n)
156
+ for k in n.children():
157
+ parent[id(k)] = n
158
+ overlay_ids = {id(o) for o in overlays}
159
+
160
+ free_all: set = set()
161
+ free_pairs: List[set] = []
162
+ for c in constraints:
163
+ if c.kind == "allow_overlap":
164
+ ids = {id(n) for n in c.subjects}
165
+ if len(ids) == 1:
166
+ free_all |= ids
167
+ else:
168
+ free_pairs.append(ids)
169
+
170
+ def chain(n: Any) -> List[int]:
171
+ out = []
172
+ while n is not None:
173
+ out.append(id(n))
174
+ n = parent.get(id(n))
175
+ return out
176
+
177
+ chains = {id(n): chain(n) for n in nodes}
178
+ cand = [n for n in nodes if _visible(n, overlay_ids)]
179
+
180
+ def freed(a: Any, b: Any) -> bool:
181
+ ca, cb = set(chains[id(a)]), set(chains[id(b)])
182
+ if ca & free_all or cb & free_all:
183
+ return True
184
+ return any((ca & ids) and (cb & ids) for ids in free_pairs)
185
+
186
+ raw: List[Overlap] = []
187
+ for i, a in enumerate(cand):
188
+ ga = a.geom
189
+ if ga.w <= 1 or ga.h <= 1:
190
+ continue
191
+ for b in cand[i + 1:]:
192
+ gb = b.geom
193
+ if gb.w <= 1 or gb.h <= 1:
194
+ continue
195
+ if id(a) in chains[id(b)] or id(b) in chains[id(a)]:
196
+ continue
197
+ if _contains(a, b) or _contains(b, a):
198
+ continue
199
+ dx = min(ga.x + ga.w, gb.x + gb.w) - max(ga.x, gb.x)
200
+ dy = min(ga.y + ga.h, gb.y + gb.h) - max(ga.y, gb.y)
201
+ if dx > tol and dy > tol and not freed(a, b):
202
+ raw.append(Overlap(a, b, dx, dy))
203
+ # keep only topmost pairs: drop (a, b) if an ancestor pair also overlaps
204
+ keys = {(id(o.a), id(o.b)) for o in raw} | {(id(o.b), id(o.a)) for o in raw}
205
+ out = []
206
+ for o in raw:
207
+ ca, cb = chains[id(o.a)], chains[id(o.b)]
208
+ shadowed = any((x, y) in keys and (x, y) != (id(o.a), id(o.b))
209
+ for x in ca for y in cb)
210
+ if not shadowed:
211
+ out.append(o)
212
+ return out
213
+
214
+
215
+ def _isect(a: Tuple[float, float, float, float], b: Tuple[float, float, float, float]
216
+ ) -> Tuple[float, float]:
217
+ dx = min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0])
218
+ dy = min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1])
219
+ return dx, dy
220
+
221
+
222
+ def _head_box(tip: Tuple[float, float], prev: Tuple[float, float], length: float,
223
+ width: float) -> Tuple[float, float, float, float]:
224
+ """Axis-aligned box of an arrowhead whose tip is ``tip`` (coming from ``prev``)."""
225
+ import math
226
+
227
+ dx, dy = tip[0] - prev[0], tip[1] - prev[1]
228
+ L = math.hypot(dx, dy) or 1.0
229
+ ux, uy = dx / L, dy / L
230
+ bx, by = tip[0] - ux * length, tip[1] - uy * length
231
+ hw = width / 2
232
+ xs = [tip[0], bx - uy * hw, bx + uy * hw]
233
+ ys = [tip[1], by + ux * hw, by - ux * hw]
234
+ return min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys)
235
+
236
+
237
+ def find_edge_issues(diagram: Any, overlays: Sequence[Any] = (), constraints: Sequence[Any] = (),
238
+ tol: float = 0.5) -> List[Overlap]:
239
+ """Layering errors for a Diagram's routed edges and labels (codefig.policy):
240
+
241
+ * an edge path crossing a **hard** node that is not one of its endpoints (nor an
242
+ ancestor / descendant of one) → ``edge-node``;
243
+ * an edge label overlapping a hard node / soft text / another label → ``label-*``.
244
+
245
+ Edges passing under soft text or labels, and across background groups, are fine.
246
+ """
247
+ from .edges import _poly_hits_rect
248
+ from .policy import role_of
249
+
250
+ lay = diagram.layout_edges()
251
+ parent: Dict[int, Any] = {}
252
+ hard: List[Any] = []
253
+ soft: List[Any] = []
254
+ free = set()
255
+ for c in constraints:
256
+ if getattr(c, "kind", "") == "allow_overlap":
257
+ free |= {id(n) for n in c.subjects}
258
+ for top in [diagram.content] + list(overlays):
259
+ for n in walk(top):
260
+ for k in n.children():
261
+ parent[id(k)] = n
262
+ if getattr(n, "allow_overlap", False) or id(n) in free:
263
+ continue
264
+ if id(n) in {id(o) for o in overlays} and not getattr(n, "_cf_obstacle", True):
265
+ continue
266
+ r = role_of(n)
267
+ g = n.geom
268
+ if g.w <= 1 or g.h <= 1:
269
+ continue
270
+ if r == "hard":
271
+ hard.append(n)
272
+ elif r == "soft":
273
+ soft.append(n)
274
+
275
+ def family(nodes: Iterable[Any]) -> set:
276
+ out = set()
277
+ for n in nodes:
278
+ m = n
279
+ while m is not None: # ancestors
280
+ out.add(id(m))
281
+ m = parent.get(id(m))
282
+ for d in walk(n): # descendants
283
+ out.add(id(d))
284
+ return out
285
+
286
+ issues: List[Overlap] = []
287
+ shrink = 1.5
288
+ for e, pts in lay["routed"]:
289
+ fk = getattr(e, "_fork", None)
290
+ ends = [e.src.node, e.dst.node]
291
+ if fk is not None: # a fork piece is exempt only for the hub and its own leaf
292
+ ends = [fk._node(fk.hub), e.src.node, e.dst.node]
293
+ if getattr(e, "around", None) is not None:
294
+ ends.append(e.around)
295
+ fam = family(ends)
296
+ # v0.13.0: a Guide is meant to pass over things — no runtime "crosses hard node"
297
+ for n in (() if getattr(e, "_cf_guide_edge", False) else hard):
298
+ if id(n) in fam:
299
+ continue
300
+ g = n.geom
301
+ r = (g.x + shrink, g.y + shrink, g.w - 2 * shrink, g.h - 2 * shrink)
302
+ if r[2] > 0 and r[3] > 0 and _poly_hits_rect(pts, r):
303
+ issues.append(Overlap(e, n, 0.0, 0.0, kind="edge-node"))
304
+ # arrowheads are never knocked out, so a head on soft text is an error
305
+ st = e.resolved(diagram.theme)
306
+ for hd, tip, prev in ((st.end_head, pts[-1], pts[-2]), (st.start_head, pts[0], pts[1])):
307
+ if hd is None or not hd.visible:
308
+ continue
309
+ hb = _head_box(tip, prev, hd.length, hd.width)
310
+ for n in soft:
311
+ if id(n) in fam:
312
+ continue
313
+ ib = n.ink_box() if hasattr(n, "ink_box") else (n.geom.x, n.geom.y, n.geom.w, n.geom.h)
314
+ dx, dy = _isect(hb, ib)
315
+ if dx > tol and dy > tol:
316
+ issues.append(Overlap(e, n, dx, dy, kind="head-text"))
317
+
318
+ labels = lay["labels"]
319
+ for i, (e, _pts, geo) in enumerate(labels):
320
+ box = geo[0]
321
+ for n in hard:
322
+ g = n.geom
323
+ dx, dy = _isect(box, (g.x, g.y, g.w, g.h))
324
+ if dx > tol and dy > tol:
325
+ issues.append(Overlap(e, n, dx, dy, kind="label-node"))
326
+ for n in soft:
327
+ ib = n.ink_box() if hasattr(n, "ink_box") else (n.geom.x, n.geom.y, n.geom.w, n.geom.h)
328
+ dx, dy = _isect(box, ib)
329
+ if dx > tol and dy > tol:
330
+ issues.append(Overlap(e, n, dx, dy, kind="label-text"))
331
+ for e2, _p2, geo2 in labels[i + 1:]:
332
+ dx, dy = _isect(box, geo2[0])
333
+ if dx > tol and dy > tol:
334
+ issues.append(Overlap(e, e2, dx, dy, kind="label-label"))
335
+ # step badges on edges (①): like labels — no hard node, soft text, label or badge
336
+ steps = lay.get("steps", [])
337
+ for i, (e, box) in enumerate(steps):
338
+ for n in hard:
339
+ g = n.geom
340
+ dx, dy = _isect(box, (g.x, g.y, g.w, g.h))
341
+ if dx > tol and dy > tol:
342
+ issues.append(Overlap(e, n, dx, dy, kind="step-node"))
343
+ for n in soft:
344
+ ib = n.ink_box() if hasattr(n, "ink_box") else (n.geom.x, n.geom.y, n.geom.w, n.geom.h)
345
+ dx, dy = _isect(box, ib)
346
+ if dx > tol and dy > tol:
347
+ issues.append(Overlap(e, n, dx, dy, kind="step-text"))
348
+ for e2, _p2, geo2 in labels:
349
+ dx, dy = _isect(box, geo2[0])
350
+ if dx > tol and dy > tol:
351
+ issues.append(Overlap(e, e2, dx, dy, kind="step-label"))
352
+ for e2, box2 in steps[i + 1:]:
353
+ dx, dy = _isect(box, box2)
354
+ if dx > tol and dy > tol:
355
+ issues.append(Overlap(e, e2, dx, dy, kind="step-label"))
356
+ return issues
357
+
358
+
359
+ def resolution_for(o: Overlap, overlays: Sequence[Any] = (), margin: float = 2.0,
360
+ strength: str = "required") -> Constraint:
361
+ """A no_overlap constraint that separates the pair along its cheapest axis.
362
+ The annotation (if any) stays; the other node is pushed."""
363
+ ax = o.axis
364
+ a, b = o.a, o.b
365
+ ga, gb = a.geom, b.geom
366
+ ca = ga.x + ga.w / 2 if ax == "x" else ga.y + ga.h / 2
367
+ cb = gb.x + gb.w / 2 if ax == "x" else gb.y + gb.h / 2
368
+ first, second = (a, b) if ca <= cb else (b, a)
369
+ c = no_overlap(first, second, ax, margin=margin, strength=strength)
370
+ ov = {id(x) for x in overlays}
371
+ # subject = the mover; prefer moving the non-annotation
372
+ if id(second) in ov and id(first) not in ov:
373
+ c.subjects, c.refs = [first], [second]
374
+ return c
375
+
376
+
377
+ def emit(found: Iterable[Overlap], policy: str) -> None:
378
+ import warnings
379
+
380
+ found = list(found)
381
+ if not found or policy == "ignore":
382
+ return
383
+ msg = "; ".join(str(o) for o in found)
384
+ what = ("overlapping nodes" if all(o.kind == "node" for o in found)
385
+ else "layering violations")
386
+ if policy == "error":
387
+ raise OverlapError(f"{what}: {msg}")
388
+ warnings.warn(f"{what}: {msg}", OverlapWarning, stacklevel=4)
389
+
390
+
391
+ __all__ = ["Overlap", "OverlapError", "OverlapWarning", "find_overlaps", "find_edge_issues",
392
+ "resolution_for", "POLICIES"]
codefig/policy.py ADDED
@@ -0,0 +1,137 @@
1
+ """Layering / overlap policy: who may cover whom.
2
+
3
+ Every element has one *role* (``obstacle=``). Authors rarely set it — the class
4
+ default is right almost always:
5
+
6
+ ============= ===================================================== =====================
7
+ role routing (edges …) check_overlaps()
8
+ ============= ===================================================== =====================
9
+ ``"hard"`` route *around* it; never drawn across it edge across it → error
10
+ ``"soft"`` may pass *under* it (mild preference to avoid); edge under it: fine;
11
+ the text knocks the line out (clean gap) text-on-text → error
12
+ ``"background"`` cross freely (a small preference to stay outside) edges: fine; nesting fine
13
+ ``"none"`` invisible to routing and overlap checks ignored
14
+ ============= ===================================================== =====================
15
+
16
+ Defaults — hard: Box, Matrix (TokenStrip / Table / heatmaps), Badge, BarChart,
17
+ VectorFan, Swatch, DotGrid, Strip, Brace, Legend, CircleHighlight/Callout, and (v0.4.0)
18
+ every shape (Ellipse, Circle, OpCircle, Cylinder, Document, Trapezoid, Parallelogram,
19
+ Hexagon, Diamond, ChatBubble, BlockArrow — Box subclasses), Icon, Image. Stacked
20
+ inherits its front card's role (hard for a Box, background for a Group).
21
+ v0.5.0 hard: Step badges (on boxes and edges), CodeBlock, Timeline Task bars and
22
+ TimeSpan braces / brackets / dims.
23
+ Soft: Text, Ellipsis, edge labels, place() text annotations, Repeat's "× N" label,
24
+ Dim / Timeline labels (lane headers, tick / marker / span labels).
25
+ Background: Group, Repeat's frame (and the panel), Lanes / Lane. A background node
26
+ that fully contains another is nesting, not overlap. Timeline lane bands, marker
27
+ lines and the axis line are *guides* (``_cf_guide``): background for routing and
28
+ skipped by check_overlaps(). Layout-only containers (Row/Col/Series/Tree/Timeline
29
+ body/…) take no part.
30
+
31
+ Override per element (``Text(..., obstacle="hard")``, ``Group(..., obstacle="none")``,
32
+ ``Text(..., knockout=False)``) or per figure (``Figure(roles={"Text": "hard"},
33
+ knockout=False)``). ``obstacle=True`` means "hard", ``False`` means "none".
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import contextlib
39
+ from typing import Any, Dict, Iterator, Mapping, Optional
40
+
41
+ ROLES = ("hard", "soft", "background", "none")
42
+
43
+ _DEFAULT_ROLES: Dict[str, str] = {
44
+ "Box": "hard",
45
+ "Matrix": "hard",
46
+ "Badge": "hard",
47
+ "BarChart": "hard",
48
+ "VectorFan": "hard",
49
+ "Swatch": "hard",
50
+ "DotGrid": "hard",
51
+ "Strip": "hard",
52
+ "Brace": "hard",
53
+ "Legend": "hard",
54
+ "CircleHighlight": "hard",
55
+ "Callout": "hard",
56
+ "Icon": "hard",
57
+ "Image": "hard",
58
+ "Stacked": "hard", # overridden per instance: follows the front card's role
59
+ "Repeat": "background",
60
+ "Text": "soft",
61
+ "Ellipsis": "soft",
62
+ "Group": "background",
63
+ # v0.5.0
64
+ "Step": "hard",
65
+ "CodeBlock": "hard",
66
+ "TimeSpan": "hard",
67
+ "Lanes": "background",
68
+ "Lane": "background",
69
+ }
70
+
71
+ _ALIASES = {True: "hard", False: "none", "solid": "hard", "coverable": "soft",
72
+ "transparent": "none", "free": "none", "bg": "background"}
73
+
74
+ _overrides: Dict[str, str] = {}
75
+ _knockout_default: bool = True
76
+
77
+
78
+ def normalize_role(v: Any) -> Optional[str]:
79
+ if v is None:
80
+ return None
81
+ if isinstance(v, bool):
82
+ return _ALIASES[v]
83
+ s = str(v).lower()
84
+ s = _ALIASES.get(s, s)
85
+ if s not in ROLES:
86
+ raise ValueError(f"obstacle must be one of {ROLES} (or True/False), got {v!r}")
87
+ return s
88
+
89
+
90
+ def default_role(node: Any) -> Optional[str]:
91
+ """Class default (MRO aware, so subclasses inherit), after figure overrides."""
92
+ for cls in type(node).__mro__:
93
+ name = cls.__name__
94
+ if name in _overrides:
95
+ return _overrides[name]
96
+ if name in _DEFAULT_ROLES:
97
+ return _DEFAULT_ROLES[name]
98
+ return None
99
+
100
+
101
+ def role_of(node: Any) -> Optional[str]:
102
+ """Effective role of ``node`` (None → layout-only, takes no part)."""
103
+ explicit = getattr(node, "_cf_obstacle_role", None) # set by place(obstacle=…)
104
+ if explicit is None:
105
+ explicit = normalize_role(getattr(node, "obstacle", None))
106
+ return explicit if explicit is not None else default_role(node)
107
+
108
+
109
+ def knockout_of(node: Any) -> bool:
110
+ k = getattr(node, "knockout", None)
111
+ return _knockout_default if k is None else bool(k)
112
+
113
+
114
+ def knockout_enabled(theme: Any = None) -> bool:
115
+ """Figure-level knockout default (``use_policy``), further gated by ``theme.knockout``."""
116
+ if not _knockout_default:
117
+ return False
118
+ return bool(getattr(theme, "knockout", True)) if theme is not None else True
119
+
120
+
121
+ @contextlib.contextmanager
122
+ def use_policy(roles: Optional[Mapping[str, Any]] = None,
123
+ knockout: Optional[bool] = None) -> Iterator[None]:
124
+ """Figure-level defaults (applied while a Figure lays out / renders)."""
125
+ global _overrides, _knockout_default
126
+ old, old_k = dict(_overrides), _knockout_default
127
+ if roles:
128
+ _overrides.update({k: normalize_role(v) for k, v in roles.items()}) # type: ignore[misc]
129
+ if knockout is not None:
130
+ _knockout_default = bool(knockout)
131
+ try:
132
+ yield
133
+ finally:
134
+ _overrides, _knockout_default = old, old_k
135
+
136
+
137
+ POLICY_TABLE = __doc__