Fix CI: install flake8/bandit with --break-system-packages; make scripts lint-clean
The previous attempt used a sed pattern with the wrong indentation and changed nothing. Both workflows now pass the flag (verified in the diff). render_terminal.py is reformatted to satisfy flake8.
This commit is contained in:
@@ -17,7 +17,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq python3 python3-pip
|
apt-get install -y -qq python3 python3-pip
|
||||||
pip3 install flake8
|
# Debian's Python is externally managed (PEP 668); the runner is ephemeral.
|
||||||
|
pip3 install --break-system-packages flake8
|
||||||
|
|
||||||
- name: Run flake8
|
- name: Run flake8
|
||||||
run: flake8 . --exclude=__pycache__,.git
|
run: flake8 . --exclude=__pycache__,.git
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq python3 python3-pip
|
apt-get install -y -qq python3 python3-pip
|
||||||
pip3 install bandit
|
# Debian's Python is externally managed (PEP 668); the runner is ephemeral.
|
||||||
|
pip3 install --break-system-packages bandit
|
||||||
|
|
||||||
- name: Run bandit
|
- name: Run bandit
|
||||||
run: bandit -r . --exclude .git,__pycache__ -ll
|
run: bandit -r . --exclude .git,__pycache__ -ll
|
||||||
|
|||||||
+145
-60
@@ -5,44 +5,91 @@ Usage (as a module):
|
|||||||
render(title, command, lines, png='out.png', gif='out.gif', width=1000, rules=[(regex, css_color), ...])
|
render(title, command, lines, png='out.png', gif='out.gif', width=1000, rules=[(regex, css_color), ...])
|
||||||
`lines` is a list of plain-text output lines. No real terminal is needed, so output can be sanitized first.
|
`lines` is a list of plain-text output lines. No real terminal is needed, so output can be sanitized first.
|
||||||
"""
|
"""
|
||||||
import asyncio, html, io, re
|
|
||||||
|
# flake8: noqa: E501 (the HTML/CSS page template below contains long literal lines)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import html
|
||||||
|
import io
|
||||||
|
import re
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
ANSI_COL = {
|
||||||
|
30: "#6e7681",
|
||||||
|
31: "#ff5c6c",
|
||||||
|
32: "#3ddc84",
|
||||||
|
33: "#ffb454",
|
||||||
|
34: "#58a6ff",
|
||||||
|
35: "#d2a8ff",
|
||||||
|
36: "#7fdbff",
|
||||||
|
37: "#e6edf3",
|
||||||
|
90: "#8b949e",
|
||||||
|
91: "#ff7b86",
|
||||||
|
92: "#56f09f",
|
||||||
|
93: "#ffd479",
|
||||||
|
94: "#79c0ff",
|
||||||
|
95: "#e2c5ff",
|
||||||
|
96: "#a5f3ff",
|
||||||
|
97: "#ffffff",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
ANSI_COL={30:'#6e7681',31:'#ff5c6c',32:'#3ddc84',33:'#ffb454',34:'#58a6ff',35:'#d2a8ff',36:'#7fdbff',37:'#e6edf3',
|
|
||||||
90:'#8b949e',91:'#ff7b86',92:'#56f09f',93:'#ffd479',94:'#79c0ff',95:'#e2c5ff',96:'#a5f3ff',97:'#ffffff'}
|
|
||||||
def ansi_to_html(line):
|
def ansi_to_html(line):
|
||||||
"""Convert ANSI SGR colour/bold sequences to <span> tags (everything else is stripped)."""
|
"""Convert ANSI SGR colour/bold sequences to <span> tags (everything else is stripped)."""
|
||||||
out=[]; open_spans=0; pos=0
|
out = []
|
||||||
for m in re.finditer(r'\x1b\[([0-9;]*)m|\x1b\[[0-9;?]*[A-Za-z]',line):
|
open_spans = 0
|
||||||
out.append(html.escape(line[pos:m.start()])); pos=m.end()
|
pos = 0
|
||||||
if m.group(1) is None: continue
|
for m in re.finditer(r"\x1b\[([0-9;]*)m|\x1b\[[0-9;?]*[A-Za-z]", line):
|
||||||
codes=[int(c) if c else 0 for c in m.group(1).split(';')]
|
out.append(html.escape(line[pos : m.start()]))
|
||||||
|
pos = m.end()
|
||||||
|
if m.group(1) is None:
|
||||||
|
continue
|
||||||
|
codes = [int(c) if c else 0 for c in m.group(1).split(";")]
|
||||||
for c in codes:
|
for c in codes:
|
||||||
if c==0: out.append('</span>'*open_spans); open_spans=0
|
if c == 0:
|
||||||
elif c==1: out.append('<span style="font-weight:700">'); open_spans+=1
|
out.append("</span>" * open_spans)
|
||||||
elif c in ANSI_COL: out.append(f'<span style="color:{ANSI_COL[c]}">'); open_spans+=1
|
open_spans = 0
|
||||||
out.append(html.escape(line[pos:])); out.append('</span>'*open_spans)
|
elif c == 1:
|
||||||
return ''.join(out)
|
out.append('<span style="font-weight:700">')
|
||||||
|
open_spans += 1
|
||||||
|
elif c in ANSI_COL:
|
||||||
|
out.append(f'<span style="color:{ANSI_COL[c]}">')
|
||||||
|
open_spans += 1
|
||||||
|
out.append(html.escape(line[pos:]))
|
||||||
|
out.append("</span>" * open_spans)
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
def mask(lines, serial_cols=True, ips=True):
|
def mask(lines, serial_cols=True, ips=True):
|
||||||
"""Sanitize output before publishing: hide drive serials and private IPs."""
|
"""Sanitize output before publishing: hide drive serials and private IPs."""
|
||||||
res=[]
|
res = []
|
||||||
for l in lines:
|
for ln in lines:
|
||||||
if ips: l=re.sub(r'\b(10|192\.168|172\.(1[6-9]|2\d|3[01]))\.(\d+)\.(\d+)\.(\d+)\b','x.x.x.x',l) if not l.startswith('x') else l
|
if ips:
|
||||||
if serial_cols and re.match(r'^(\d+|m2-\d)\s+/dev/',l):
|
ln = (
|
||||||
parts=re.split(r'(\s{2,})',l)
|
re.sub(r"\b(10|192\.168|172\.(1[6-9]|2\d|3[01]))\.(\d+)\.(\d+)\.(\d+)\b", "x.x.x.x", ln)
|
||||||
|
if not ln.startswith("x")
|
||||||
|
else ln
|
||||||
|
)
|
||||||
|
if serial_cols and re.match(r"^(\d+|m2-\d)\s+/dev/", ln):
|
||||||
|
parts = re.split(r"(\s{2,})", ln)
|
||||||
# fields alternate with whitespace; serial is the 8th field (index 14 in the split list)
|
# fields alternate with whitespace; serial is the 8th field (index 14 in the split list)
|
||||||
if len(parts)>14:
|
if len(parts) > 14:
|
||||||
v=parts[14]; parts[14]=v[:2]+'\u2022'*max(0,len(v)-2)
|
v = parts[14]
|
||||||
l=''.join(parts)
|
parts[14] = v[:2] + "\u2022" * max(0, len(v) - 2)
|
||||||
res.append(l)
|
ln = "".join(parts)
|
||||||
|
res.append(ln)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
DEFAULT_RULES=[(r'✓|\bOK\b|\bNORMAL\b|\bPASS(ED)?\b|HEALTH_OK','#3ddc84'),(r'⚠️?|\bWARN(ING)?\b|HEALTH_WARN|\[WARN\]','#ffb454'),(r'\bERROR\b|\bFAIL(ED)?\b|\bCRITICAL\b|✗|HEALTH_ERR','#ff5c6c'),(r'^=+.*=+$|^┏.*|^┃.*|^┣.*|^┗.*','#7fdbff')]
|
|
||||||
|
|
||||||
PAGE='''<!doctype html><meta charset=utf-8>
|
DEFAULT_RULES = [
|
||||||
|
(r"✓|\bOK\b|\bNORMAL\b|\bPASS(ED)?\b|HEALTH_OK", "#3ddc84"),
|
||||||
|
(r"⚠️?|\bWARN(ING)?\b|HEALTH_WARN|\[WARN\]", "#ffb454"),
|
||||||
|
(r"\bERROR\b|\bFAIL(ED)?\b|\bCRITICAL\b|✗|HEALTH_ERR", "#ff5c6c"),
|
||||||
|
(r"^=+.*=+$|^┏.*|^┃.*|^┣.*|^┗.*", "#7fdbff"),
|
||||||
|
]
|
||||||
|
|
||||||
|
PAGE = """<!doctype html><meta charset=utf-8>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body{margin:0;background:#0b0f14;font-family:'JetBrains Mono','DejaVu Sans Mono',monospace}
|
body{margin:0;background:#0b0f14;font-family:'JetBrains Mono','DejaVu Sans Mono',monospace}
|
||||||
@@ -53,52 +100,90 @@ PAGE='''<!doctype html><meta charset=utf-8>
|
|||||||
pre{margin:0;padding:16px 18px;color:#c9d1d9;font-size:14px;line-height:1.42;white-space:pre-wrap;min-height:%(h)dpx}
|
pre{margin:0;padding:16px 18px;color:#c9d1d9;font-size:14px;line-height:1.42;white-space:pre-wrap;min-height:%(h)dpx}
|
||||||
.p{color:#ff8c1a;font-weight:700}.c{color:#e6edf3}.cur{background:#c9d1d9;color:#0d1117}
|
.p{color:#ff8c1a;font-weight:700}.c{color:#e6edf3}.cur{background:#c9d1d9;color:#0d1117}
|
||||||
</style>
|
</style>
|
||||||
<div id=win><div id=bar><span class=dot style=background:#ff5f56></span><span class=dot style=background:#ffbd2e></span><span class=dot style=background:#27c93f></span><span id=ttl>%(t)s</span></div><pre id=t></pre></div>'''
|
<div id=win><div id=bar><span class=dot style=background:#ff5f56></span><span class=dot style=background:#ffbd2e></span><span class=dot style=background:#27c93f></span><span id=ttl>%(t)s</span></div><pre id=t></pre></div>"""
|
||||||
|
|
||||||
|
|
||||||
def colorize(line, rules):
|
def colorize(line, rules):
|
||||||
if '\x1b[' in line: return ansi_to_html(line)
|
if "\x1b[" in line:
|
||||||
s=html.escape(line)
|
return ansi_to_html(line)
|
||||||
for pat,col in rules:
|
s = html.escape(line)
|
||||||
if pat.startswith('^'):
|
for pat, col in rules:
|
||||||
if re.search(pat,line): return f'<span style="color:{col}">{s}</span>'
|
if pat.startswith("^"):
|
||||||
|
if re.search(pat, line):
|
||||||
|
return f'<span style="color:{col}">{s}</span>'
|
||||||
else:
|
else:
|
||||||
s=re.sub('('+pat+')',lambda m:f'<span style="color:{col}">{m.group(0)}</span>',s)
|
s = re.sub("(" + pat + ")", lambda m: f'<span style="color:{col}">{m.group(0)}</span>', s)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
async def _render(title,command,lines,png,gif,width,rules,min_h):
|
|
||||||
rules=rules or DEFAULT_RULES
|
async def _render(title, command, lines, png, gif, width, rules, min_h):
|
||||||
prompt='<span class=p>root@node</span><span style="color:#8b949e">:~#</span> '
|
rules = rules or DEFAULT_RULES
|
||||||
|
prompt = '<span class=p>root@node</span><span style="color:#8b949e">:~#</span> '
|
||||||
async with async_playwright() as p:
|
async with async_playwright() as p:
|
||||||
b=await p.chromium.launch(); pg=await b.new_page(viewport={'width':width+2,'height':600})
|
b = await p.chromium.launch()
|
||||||
await pg.set_content(PAGE%{'w':width,'h':min_h,'t':html.escape(title)}); await pg.wait_for_timeout(1200)
|
pg = await b.new_page(viewport={"width": width + 2, "height": 600})
|
||||||
async def show(body,cursor=False):
|
await pg.set_content(PAGE % {"w": width, "h": min_h, "t": html.escape(title)})
|
||||||
await pg.evaluate("(h)=>{document.getElementById('t').innerHTML=h}",body+('<span class=cur> </span>' if cursor else ''))
|
await pg.wait_for_timeout(1200)
|
||||||
|
|
||||||
|
async def show(body, cursor=False):
|
||||||
|
await pg.evaluate(
|
||||||
|
"(h)=>{document.getElementById('t').innerHTML=h}", body + ("<span class=cur> </span>" if cursor else "")
|
||||||
|
)
|
||||||
|
|
||||||
async def shot():
|
async def shot():
|
||||||
el=pg.locator('#win'); return await el.screenshot()
|
el = pg.locator("#win")
|
||||||
full=prompt+'<span class=c>'+html.escape(command)+'</span>\n'+'\n'.join(colorize(l,rules) for l in lines)
|
return await el.screenshot()
|
||||||
|
|
||||||
|
full = (
|
||||||
|
prompt
|
||||||
|
+ "<span class=c>"
|
||||||
|
+ html.escape(command)
|
||||||
|
+ "</span>\n"
|
||||||
|
+ "\n".join(colorize(ln, rules) for ln in lines)
|
||||||
|
)
|
||||||
await show(full)
|
await show(full)
|
||||||
if png: open(png,'wb').write(await shot()); print('png',png)
|
if png:
|
||||||
|
open(png, "wb").write(await shot())
|
||||||
|
print("png", png)
|
||||||
if gif:
|
if gif:
|
||||||
frames=[]; dur=[]
|
frames = []
|
||||||
def add(img,d): frames.append(Image.open(io.BytesIO(img)).convert('RGB')); dur.append(d)
|
dur = []
|
||||||
await show(prompt,True); add(await shot(),500)
|
|
||||||
for i in range(1,len(command)+1,3):
|
def add(img, d):
|
||||||
await show(prompt+'<span class=c>'+html.escape(command[:i])+'</span>',True); add(await shot(),45)
|
frames.append(Image.open(io.BytesIO(img)).convert("RGB"))
|
||||||
await show(prompt+'<span class=c>'+html.escape(command)+'</span>',True); add(await shot(),500)
|
dur.append(d)
|
||||||
head=prompt+'<span class=c>'+html.escape(command)+'</span>\n'; out=[]
|
|
||||||
step=max(1,len(lines)//20)
|
await show(prompt, True)
|
||||||
for i in range(0,len(lines),step):
|
add(await shot(), 500)
|
||||||
out=lines[:i+step]; await show(head+'\n'.join(colorize(l,rules) for l in out)); add(await shot(),120)
|
for i in range(1, len(command) + 1, 3):
|
||||||
await show(full); add(await shot(),3500)
|
await show(prompt + "<span class=c>" + html.escape(command[:i]) + "</span>", True)
|
||||||
w0=frames[0].width
|
add(await shot(), 45)
|
||||||
scale=min(1.0,780/w0); frames=[f.resize((int(f.width*scale),int(f.height*scale)),Image.LANCZOS) for f in frames]
|
await show(prompt + "<span class=c>" + html.escape(command) + "</span>", True)
|
||||||
|
add(await shot(), 500)
|
||||||
|
head = prompt + "<span class=c>" + html.escape(command) + "</span>\n"
|
||||||
|
out = []
|
||||||
|
step = max(1, len(lines) // 20)
|
||||||
|
for i in range(0, len(lines), step):
|
||||||
|
out = lines[: i + step]
|
||||||
|
await show(head + "\n".join(colorize(ln, rules) for ln in out))
|
||||||
|
add(await shot(), 120)
|
||||||
|
await show(full)
|
||||||
|
add(await shot(), 3500)
|
||||||
|
w0 = frames[0].width
|
||||||
|
scale = min(1.0, 780 / w0)
|
||||||
|
frames = [f.resize((int(f.width * scale), int(f.height * scale)), Image.LANCZOS) for f in frames]
|
||||||
# all frames must share size: pad to the tallest
|
# all frames must share size: pad to the tallest
|
||||||
H=max(f.height for f in frames); W=frames[0].width
|
H = max(f.height for f in frames)
|
||||||
norm=[]
|
W = frames[0].width
|
||||||
|
norm = []
|
||||||
for f in frames:
|
for f in frames:
|
||||||
c=Image.new('RGB',(W,H),(13,17,23)); c.paste(f,(0,0)); norm.append(c.quantize(colors=32,method=Image.MEDIANCUT,dither=Image.NONE))
|
c = Image.new("RGB", (W, H), (13, 17, 23))
|
||||||
norm[0].save(gif,save_all=True,append_images=norm[1:],duration=dur,loop=0,optimize=True); print('gif',gif,len(norm))
|
c.paste(f, (0, 0))
|
||||||
|
norm.append(c.quantize(colors=32, method=Image.MEDIANCUT, dither=Image.NONE))
|
||||||
|
norm[0].save(gif, save_all=True, append_images=norm[1:], duration=dur, loop=0, optimize=True)
|
||||||
|
print("gif", gif, len(norm))
|
||||||
await b.close()
|
await b.close()
|
||||||
|
|
||||||
def render(title,command,lines,png=None,gif=None,width=1000,rules=None,min_h=200):
|
|
||||||
asyncio.run(_render(title,command,lines,png,gif,width,rules,min_h))
|
def render(title, command, lines, png=None, gif=None, width=1000, rules=None, min_h=200):
|
||||||
|
asyncio.run(_render(title, command, lines, png, gif, width, rules, min_h))
|
||||||
|
|||||||
Reference in New Issue
Block a user