shapelens 0.1.0__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.
- shapelens-0.1.0/.gitignore +5 -0
- shapelens-0.1.0/LICENSE +21 -0
- shapelens-0.1.0/PKG-INFO +75 -0
- shapelens-0.1.0/README.md +56 -0
- shapelens-0.1.0/pyproject.toml +42 -0
- shapelens-0.1.0/shapelens.py +192 -0
shapelens-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nhanncv
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
shapelens-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: shapelens
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: See tensor shapes as boxes: trace PyTorch ops and draw them in Jupyter notebooks.
|
|
5
|
+
Project-URL: Homepage, https://github.com/hiimnhan/shapelens
|
|
6
|
+
Project-URL: Repository, https://github.com/hiimnhan/shapelens
|
|
7
|
+
Author: nhanncv
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: debugging,pytorch,shape,tensor,visualization
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: torch>=2.0.1
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# shapelens
|
|
21
|
+
|
|
22
|
+
See tensor shapes as boxes. Wrap a block of PyTorch code and get, per source line, the input
|
|
23
|
+
tensors, the op, and the output tensor drawn as rectangles, with your variable names and
|
|
24
|
+
dimension labels (`B`, `T`, `D`, ...).
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
*A ViT patch embedding, traced: image `(B, C, H, W)` becomes tokens `(B, N+1, D)`.*
|
|
29
|
+
|
|
30
|
+
Built for Jupyter notebooks. In a cell:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import shapelens as sl
|
|
34
|
+
|
|
35
|
+
with sl.Trace() as t:
|
|
36
|
+
out = attention(x, Wq, Wk, Wv, H=4)
|
|
37
|
+
t # last expression in the cell renders the boxes inline
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- **Shape errors:** if an op fails inside the block, the trace is displayed immediately with the
|
|
41
|
+
failing line highlighted in red, then the original error still propagates.
|
|
42
|
+
- `print(t)` gives a plain-text version of the same trace.
|
|
43
|
+
|
|
44
|
+

|
|
45
|
+
|
|
46
|
+
## Drawing rules
|
|
47
|
+
|
|
48
|
+
- Last two dims are the rectangle (rows x cols); the third-from-last is the cuboid depth;
|
|
49
|
+
any leading dims are a label plus ghost copies behind.
|
|
50
|
+
- Sides are log-scaled, so relative sizes are not to scale.
|
|
51
|
+
- Dims of equal size share a color.
|
|
52
|
+
|
|
53
|
+
## Dimension labels
|
|
54
|
+
|
|
55
|
+
Any UPPERCASE `int` variable in your code (`B = 2`, `D = 32`) labels every dim of that size.
|
|
56
|
+
Add more by hand: `sl.Trace(S=197)`. Labels go by size, so equal sizes show every name (`H/W`).
|
|
57
|
+
|
|
58
|
+
## Tip
|
|
59
|
+
|
|
60
|
+
Use `device="meta"` tensors to see shapes without any compute or memory.
|
|
61
|
+
|
|
62
|
+
## Limits
|
|
63
|
+
|
|
64
|
+
PyTorch only. Smoke-tested on torch 2.0.1 to 2.8.0 and 2.14.1 (Python 3.10, macOS arm64); older
|
|
65
|
+
versions are untested. The tracer keeps references to op outputs while tracing, so prefer `meta`
|
|
66
|
+
tensors for big models. Only errors raised inside a torch op are highlighted.
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
uv sync # installs the project plus jupyter/nbconvert (dev group)
|
|
72
|
+
uv run python test_shapelens.py # smoke test (no pytest needed)
|
|
73
|
+
uv run jupyter lab # open demo.ipynb: attention, ViT patchify, and a shape-bug cell
|
|
74
|
+
uv build # wheel + sdist into dist/
|
|
75
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# shapelens
|
|
2
|
+
|
|
3
|
+
See tensor shapes as boxes. Wrap a block of PyTorch code and get, per source line, the input
|
|
4
|
+
tensors, the op, and the output tensor drawn as rectangles, with your variable names and
|
|
5
|
+
dimension labels (`B`, `T`, `D`, ...).
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
*A ViT patch embedding, traced: image `(B, C, H, W)` becomes tokens `(B, N+1, D)`.*
|
|
10
|
+
|
|
11
|
+
Built for Jupyter notebooks. In a cell:
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import shapelens as sl
|
|
15
|
+
|
|
16
|
+
with sl.Trace() as t:
|
|
17
|
+
out = attention(x, Wq, Wk, Wv, H=4)
|
|
18
|
+
t # last expression in the cell renders the boxes inline
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **Shape errors:** if an op fails inside the block, the trace is displayed immediately with the
|
|
22
|
+
failing line highlighted in red, then the original error still propagates.
|
|
23
|
+
- `print(t)` gives a plain-text version of the same trace.
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
## Drawing rules
|
|
28
|
+
|
|
29
|
+
- Last two dims are the rectangle (rows x cols); the third-from-last is the cuboid depth;
|
|
30
|
+
any leading dims are a label plus ghost copies behind.
|
|
31
|
+
- Sides are log-scaled, so relative sizes are not to scale.
|
|
32
|
+
- Dims of equal size share a color.
|
|
33
|
+
|
|
34
|
+
## Dimension labels
|
|
35
|
+
|
|
36
|
+
Any UPPERCASE `int` variable in your code (`B = 2`, `D = 32`) labels every dim of that size.
|
|
37
|
+
Add more by hand: `sl.Trace(S=197)`. Labels go by size, so equal sizes show every name (`H/W`).
|
|
38
|
+
|
|
39
|
+
## Tip
|
|
40
|
+
|
|
41
|
+
Use `device="meta"` tensors to see shapes without any compute or memory.
|
|
42
|
+
|
|
43
|
+
## Limits
|
|
44
|
+
|
|
45
|
+
PyTorch only. Smoke-tested on torch 2.0.1 to 2.8.0 and 2.14.1 (Python 3.10, macOS arm64); older
|
|
46
|
+
versions are untested. The tracer keeps references to op outputs while tracing, so prefer `meta`
|
|
47
|
+
tensors for big models. Only errors raised inside a torch op are highlighted.
|
|
48
|
+
|
|
49
|
+
## Development
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
uv sync # installs the project plus jupyter/nbconvert (dev group)
|
|
53
|
+
uv run python test_shapelens.py # smoke test (no pytest needed)
|
|
54
|
+
uv run jupyter lab # open demo.ipynb: attention, ViT patchify, and a shape-bug cell
|
|
55
|
+
uv build # wheel + sdist into dist/
|
|
56
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "shapelens"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "See tensor shapes as boxes: trace PyTorch ops and draw them in Jupyter notebooks."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "nhanncv" }]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
keywords = ["pytorch", "tensor", "shape", "visualization", "debugging"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
16
|
+
"Topic :: Software Development :: Debuggers",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"torch>=2.0.1",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/hiimnhan/shapelens"
|
|
24
|
+
Repository = "https://github.com/hiimnhan/shapelens"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"jupyter>=1.1.1",
|
|
29
|
+
"nbconvert>=7.17.2",
|
|
30
|
+
"numpy>=2.2.6",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
# single-file module: shapelens.py at the project root
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
include = ["shapelens.py"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.sdist]
|
|
42
|
+
include = ["shapelens.py", "README.md", "LICENSE", "pyproject.toml"]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""shapelens: trace tensor shapes in a code block, render in Jupyter.
|
|
2
|
+
|
|
3
|
+
import shapelens as sl
|
|
4
|
+
with sl.Trace() as t:
|
|
5
|
+
out = attention(x)
|
|
6
|
+
t # last expression in a cell -> HTML table
|
|
7
|
+
"""
|
|
8
|
+
import html
|
|
9
|
+
import linecache
|
|
10
|
+
import math
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
import torch
|
|
14
|
+
from torch.utils._python_dispatch import TorchDispatchMode
|
|
15
|
+
|
|
16
|
+
_SKIP = ("torch/", "contextlib.py", "/IPython/", "/ipykernel/")
|
|
17
|
+
_NOISE = {"view", "_unsafe_view", "expand", "clone", "reshape", "_reshape_alias", "detach", "alias"}
|
|
18
|
+
_PALETTE =["#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f", "#edc948", "#b07aa1", "#ff9da7", "#9c755f"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _side(d):
|
|
22
|
+
return int(8 + 12 * math.log2(max(d, 1))) # log scale so 224 vs 3 stays drawable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _user_frame(f):
|
|
26
|
+
while f and (f.f_code.co_filename == __file__ or any(s in f.f_code.co_filename for s in _SKIP)):
|
|
27
|
+
f = f.f_back
|
|
28
|
+
return f
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Trace(TorchDispatchMode):
|
|
32
|
+
def __init__(self, **dims):
|
|
33
|
+
"""dims: extra labels, e.g. Trace(N=196). UPPERCASE int variables in your code are picked up automatically."""
|
|
34
|
+
super().__init__()
|
|
35
|
+
self.dims = {} # size -> [labels]; ponytail: label by value, so equal sizes share names (H/W)
|
|
36
|
+
for k, v in dims.items():
|
|
37
|
+
self._label(v, k)
|
|
38
|
+
self.rows = [] # (lineno, code, op, [(id, shape)], [(id, shape)])
|
|
39
|
+
self.names = {} # id(tensor) -> variable name, last seen
|
|
40
|
+
self._keep = [] # keep outputs alive so ids stay unique
|
|
41
|
+
|
|
42
|
+
def _label(self, size, name):
|
|
43
|
+
labels = self.dims.setdefault(size, [])
|
|
44
|
+
if name not in labels:
|
|
45
|
+
labels.append(name)
|
|
46
|
+
|
|
47
|
+
def _scan(self, frame):
|
|
48
|
+
for k, v in frame.f_locals.items():
|
|
49
|
+
if isinstance(v, torch.Tensor) and not k.startswith("_"):
|
|
50
|
+
self.names[id(v)] = k
|
|
51
|
+
elif type(v) is int and v > 1 and k.isupper(): # B, T, D, H2 ... but not loop vars like i
|
|
52
|
+
self._label(v, k)
|
|
53
|
+
|
|
54
|
+
def __torch_dispatch__(self, func, types, args=(), kwargs=None):
|
|
55
|
+
try:
|
|
56
|
+
out = func(*args, **(kwargs or {}))
|
|
57
|
+
except Exception as e: # record the failing op, then let the error propagate
|
|
58
|
+
self._rec(func, args, [], (str(e).strip().splitlines() or [type(e).__name__])[0][:300])
|
|
59
|
+
raise
|
|
60
|
+
self._rec(func, args, out, None)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
def _rec(self, func, args, out, err):
|
|
64
|
+
fr = _user_frame(sys._getframe(1))
|
|
65
|
+
if fr is not None:
|
|
66
|
+
self._scan(fr)
|
|
67
|
+
code = linecache.getline(fr.f_code.co_filename, fr.f_lineno).strip()
|
|
68
|
+
line = fr.f_lineno
|
|
69
|
+
else:
|
|
70
|
+
code, line = "", 0
|
|
71
|
+
outs = [o for o in (out if isinstance(out, (tuple, list)) else [out]) if isinstance(o, torch.Tensor)]
|
|
72
|
+
flat = [x for a in args for x in (a if isinstance(a, (list, tuple)) else [a])] # cat/stack take lists
|
|
73
|
+
ins = [(id(a), tuple(a.shape)) for a in flat if isinstance(a, torch.Tensor)]
|
|
74
|
+
self._keep += outs
|
|
75
|
+
self.rows.append((line, code, func._schema.name.split("::")[-1], ins,
|
|
76
|
+
[(id(o), tuple(o.shape)) for o in outs], err))
|
|
77
|
+
|
|
78
|
+
def __exit__(self, *a):
|
|
79
|
+
fr = _user_frame(sys._getframe(1)) # block's frame holds the final variable names
|
|
80
|
+
if fr is not None:
|
|
81
|
+
self._scan(fr)
|
|
82
|
+
r = super().__exit__(*a)
|
|
83
|
+
if a[0] is not None: # block raised: the cell's trailing `t` never runs, so show the trace now
|
|
84
|
+
try:
|
|
85
|
+
from IPython.display import display
|
|
86
|
+
display(self)
|
|
87
|
+
except ImportError: # never mask the user's real error
|
|
88
|
+
pass
|
|
89
|
+
return r
|
|
90
|
+
|
|
91
|
+
def _lab(self, d):
|
|
92
|
+
return f'{"/".join(self.dims.get(d, []))} {d}'.strip()
|
|
93
|
+
|
|
94
|
+
def text(self):
|
|
95
|
+
fmt = lambda i, s: f'{self.names.get(i, "?")}[{", ".join(self._lab(d) for d in s)}]'
|
|
96
|
+
return "\n".join(
|
|
97
|
+
f"{'✗ ' if err else ''}{code}\n {op}: {', '.join(fmt(i, s) for i, s in ins)} -> "
|
|
98
|
+
+ (f"ERROR: {err}" if err else ", ".join(fmt(i, s) for i, s in outs))
|
|
99
|
+
for code, op, ins, outs, err in self.lines()
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
__repr__ = text
|
|
103
|
+
|
|
104
|
+
# --- rendering ---
|
|
105
|
+
def _box(self, tid, shape, colors):
|
|
106
|
+
"""Draw a tensor: last 2 dims = rectangle, 3rd-from-last = cuboid depth, leading dims = ghost stack + label."""
|
|
107
|
+
col = lambda d: colors.setdefault(d, _PALETTE[len(colors) % len(_PALETTE)])
|
|
108
|
+
lab = lambda d: html.escape(self._lab(d))
|
|
109
|
+
n = len(shape)
|
|
110
|
+
W = _side(shape[-1]) if n else 12
|
|
111
|
+
Hh = _side(shape[-2]) if n >= 2 else (16 if n else 12)
|
|
112
|
+
dep = shape[-3] if n >= 3 else None
|
|
113
|
+
D = min(40, _side(dep) // 2) if dep else 0
|
|
114
|
+
lead = shape[:-3]
|
|
115
|
+
g = min(math.prod(lead), 3) - 1 if lead else 0 # ghost copies behind
|
|
116
|
+
rl, dl = (lab(shape[-2]) if n >= 2 else ""), (lab(dep) if dep else "")
|
|
117
|
+
lt = " × ".join(lab(d) for d in lead)
|
|
118
|
+
x0, y0 = 7 * len(rl) + 8, D + (16 if lead else 4)
|
|
119
|
+
|
|
120
|
+
def cube(ox, oy, op):
|
|
121
|
+
x, y = x0 + ox, y0 + oy
|
|
122
|
+
s = (f'<g opacity="{op}"><rect x="{x}" y="{y}" width="{W}" height="{Hh}" '
|
|
123
|
+
f'fill="#4e79a7" fill-opacity=".18" stroke="#4e79a7"/>')
|
|
124
|
+
if D:
|
|
125
|
+
c = col(dep)
|
|
126
|
+
s += (f'<polygon points="{x},{y} {x+D},{y-D} {x+W+D},{y-D} {x+W},{y}" fill="{c}" fill-opacity=".45" stroke="{c}"/>'
|
|
127
|
+
f'<polygon points="{x+W},{y} {x+W+D},{y-D} {x+W+D},{y+Hh-D} {x+W},{y+Hh}" fill="{c}" fill-opacity=".3" stroke="{c}"/>')
|
|
128
|
+
return s + "</g>"
|
|
129
|
+
|
|
130
|
+
def text(x, y, d, anchor, txt):
|
|
131
|
+
return (f'<text x="{x}" y="{y}" text-anchor="{anchor}" font-size="11" font-family="monospace" '
|
|
132
|
+
f'fill="{col(d)}">{txt}</text>')
|
|
133
|
+
|
|
134
|
+
body = "".join(cube(8 * i, 8 * i, 0.5) for i in range(g, 0, -1)) + cube(0, 0, 1)
|
|
135
|
+
if n:
|
|
136
|
+
body += text(x0 + W / 2, y0 + Hh + 8 * g + 13, shape[-1], "middle", lab(shape[-1]))
|
|
137
|
+
if n >= 2:
|
|
138
|
+
body += text(x0 - 4, y0 + Hh / 2 + 4, shape[-2], "end", rl)
|
|
139
|
+
if dep:
|
|
140
|
+
body += text(x0 + W + D + 5, y0 - D / 2 + 4, dep, "start", dl)
|
|
141
|
+
if lead:
|
|
142
|
+
body += f'<text x="{x0}" y="11" font-size="11" font-family="monospace" fill="currentColor">{lt}</text>'
|
|
143
|
+
wd = max(x0 + W + D + 8 * g + (7 * len(dl) + 8 if dl else 6), x0 + 7 * len(lt) if lead else 0)
|
|
144
|
+
ht = y0 + Hh + 8 * g + 18
|
|
145
|
+
name = html.escape(self.names.get(tid, ""))
|
|
146
|
+
return (f'<div style="text-align:center;margin:0 6px"><svg width="{int(wd)}" height="{int(ht)}">{body}</svg>'
|
|
147
|
+
f'<div style="font:bold 12px monospace">{name}</div></div>')
|
|
148
|
+
|
|
149
|
+
def lines(self):
|
|
150
|
+
"""Collapse raw aten ops to one row per source line: named inputs -> named outputs."""
|
|
151
|
+
groups = [] # [code, ops, ins, outs, last_out, all_ops, err, failing_op_ins]
|
|
152
|
+
for line, code, op, ins, outs, err in self.rows:
|
|
153
|
+
if not groups or groups[-1][0] != (line, code):
|
|
154
|
+
groups.append([(line, code), [], {}, {}, None, [], None, []])
|
|
155
|
+
g = groups[-1]
|
|
156
|
+
if err:
|
|
157
|
+
g[6], g[7] = err, ins
|
|
158
|
+
if op not in g[5]:
|
|
159
|
+
g[5].append(op)
|
|
160
|
+
if op not in _NOISE and op not in g[1]:
|
|
161
|
+
g[1].append(op)
|
|
162
|
+
g[2].update({i: s for i, s in ins if i in self.names})
|
|
163
|
+
g[3].update({i: s for i, s in outs if i in self.names})
|
|
164
|
+
g[4] = outs
|
|
165
|
+
for g in groups:
|
|
166
|
+
if g[6]: # failing op: your named inputs, else the raw shapes that clash
|
|
167
|
+
g[2], g[3] = g[2] or dict(g[7]), {}
|
|
168
|
+
continue
|
|
169
|
+
if not g[3] and g[4]: # statement result never bound to a name
|
|
170
|
+
g[3] = dict(g[4])
|
|
171
|
+
g[2] = {i: s for i, s in g[2].items() if i not in g[3]} # drop in-place/aliased echoes
|
|
172
|
+
return [(g[0][1], ", ".join(g[1] or g[5]), list(g[2].items()), list(g[3].items()), g[6]) for g in groups]
|
|
173
|
+
|
|
174
|
+
def _repr_html_(self):
|
|
175
|
+
colors = {} # same dim size -> same color, so equal dims are visible at a glance
|
|
176
|
+
|
|
177
|
+
def row(code, op, ins, outs, err):
|
|
178
|
+
bad = "background:rgba(225,87,89,.12);border-left:4px solid #e15759;padding-left:8px;" if err else ""
|
|
179
|
+
res = (f"<div style='color:#e15759;font:12px monospace;max-width:340px;white-space:pre-wrap'>"
|
|
180
|
+
f"<b>✗ {html.escape(op)} failed</b><br>{html.escape(err)}</div>"
|
|
181
|
+
if err else "".join(self._box(i, s, colors) for i, s in outs))
|
|
182
|
+
return (
|
|
183
|
+
f"<div style='display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid #8884;{bad}'>"
|
|
184
|
+
f"<div style='width:240px;flex:none;font:12px monospace;white-space:pre-wrap'>{html.escape(code)}"
|
|
185
|
+
f"<div style='opacity:.6'>{html.escape(op)}</div></div>"
|
|
186
|
+
+ "".join(self._box(i, s, colors) for i, s in ins)
|
|
187
|
+
+ f"<div style='font-size:24px;padding:0 6px;{'color:#e15759' if err else ''}'>{'✗' if err else '→'}</div>"
|
|
188
|
+
+ res
|
|
189
|
+
+ "</div>"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return "".join(row(*r) for r in self.lines())
|