Add type hints to KDE Python build scripts #2

Merged
vokhactri merged 1 commits from refactor/add-python-type-hints into main 2026-11-02 15:18:44 +07:00
3 changed files with 49 additions and 41 deletions
Showing only changes of commit 3d1011e17c - Show all commits

No files matched your search

+2 -2
View File
@@ -30,7 +30,7 @@ COLS, ROWS = 20, 10
KONSOLE_CELL = 8, 17 KONSOLE_CELL = 8, 17
def encode(png, dst): def encode(png: bytes, dst: Path) -> None:
data = base64.b64encode(png) data = base64.b64encode(png)
chunks = [data[i : i + 4096] for i in range(0, len(data), 4096)] chunks = [data[i : i + 4096] for i in range(0, len(data), 4096)]
out = bytearray() out = bytearray()
@@ -44,7 +44,7 @@ def encode(png, dst):
dst.write_bytes(out) dst.write_bytes(out)
def resize(width, height): def resize(width: int, height: int) -> bytes:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "out.png" out = Path(tmp) / "out.png"
subprocess.run( subprocess.run(
+7 -6
View File
@@ -7,6 +7,7 @@ import os
from pathlib import Path from pathlib import Path
import re import re
import subprocess import subprocess
from typing import Any
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
@@ -14,11 +15,11 @@ COMPONENT = "org.kde.spectacle.desktop"
ACCEL = "/component/org_kde_spectacle_desktop" ACCEL = "/component/org_kde_spectacle_desktop"
def run(*args, **kwargs): def run(*args: str, **kwargs: Any) -> subprocess.CompletedProcess[str]:
return subprocess.run(args, check=True, text=True, **kwargs) return subprocess.run(args, check=True, text=True, **kwargs)
def accel(path, method, *args): def accel(path: str, method: str, *args: str) -> str:
return run( return run(
"gdbus", "call", "--session", "--dest", "org.kde.kglobalaccel", "gdbus", "call", "--session", "--dest", "org.kde.kglobalaccel",
"--object-path", path, "--method", method, *args, "--object-path", path, "--method", method, *args,
@@ -26,10 +27,10 @@ def accel(path, method, *args):
).stdout ).stdout
def main(): def main() -> None:
for name in ("spectaclerc", "klipperrc"): for name in ("spectaclerc", "klipperrc"):
config = configparser.ConfigParser(interpolation=None) config = configparser.ConfigParser(interpolation=None)
config.optionxform = str config.optionxform = str # type: ignore[method-assign,assignment]
config.read(HERE / "settings" / name) config.read(HERE / "settings" / name)
for group in config.sections(): for group in config.sections():
for key, value in config[group].items(): for key, value in config[group].items():
@@ -38,7 +39,7 @@ def main():
# T3's locale and XDG_DATA_DIRS can differ from the running KDE session. # T3's locale and XDG_DATA_DIRS can differ from the running KDE session.
# Build the cache KDE actually reads, rather than an app-specific cache. # Build the cache KDE actually reads, rather than an app-specific cache.
session = dict( session: dict[str, str] = dict(
line.split("=", 1) for line in line.split("=", 1) for line in
run("systemctl", "--user", "show-environment", capture_output=True) run("systemctl", "--user", "show-environment", capture_output=True)
.stdout.splitlines() if "=" in line .stdout.splitlines() if "=" in line
@@ -61,7 +62,7 @@ def main():
try: try:
output = accel(ACCEL, "org.kde.kglobalaccel.Component.allShortcutInfos") output = accel(ACCEL, "org.kde.kglobalaccel.Component.allShortcutInfos")
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
shortcuts = [] # Component has not been registered yet. shortcuts: list[Any] = [] # Component has not been registered yet.
else: else:
shortcuts = ast.literal_eval(re.sub(r"@a[is] ", "", output))[0] shortcuts = ast.literal_eval(re.sub(r"@a[is] ", "", output))[0]
accel(ACCEL, "org.kde.kglobalaccel.Component.cleanUp") accel(ACCEL, "org.kde.kglobalaccel.Component.cleanUp")
+40 -33
View File
@@ -18,6 +18,7 @@ import re
import struct import struct
import zipfile import zipfile
import zlib import zlib
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path from pathlib import Path
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
@@ -40,6 +41,11 @@ VSCODE_UI = VSCODE / "base/2026-dark.json"
# 0.1.4). The editor uses its token colors as they are. # 0.1.4). The editor uses its token colors as they are.
VSCODE_SYNTAX = VSCODE / "base/pierre-dark.json" VSCODE_SYNTAX = VSCODE / "base/pierre-dark.json"
# An RGB or RGBA color, 0-255 per channel.
type Color = tuple[int, ...]
# Looks up a color in the scheme by group and key.
type ColorFn = Callable[[str, str], Color]
WHITE = (255, 255, 255) WHITE = (255, 255, 255)
# Apple systemCyan; the KDE scheme has no cyan role. # Apple systemCyan; the KDE scheme has no cyan role.
@@ -53,27 +59,27 @@ YELLOW = (255, 255, 0)
INACTIVE_TAB_MIX = 0.12 INACTIVE_TAB_MIX = 0.12
def load(): def load() -> ColorFn:
cp = configparser.ConfigParser(interpolation=None, strict=False) cp = configparser.ConfigParser(interpolation=None, strict=False)
cp.optionxform = str cp.optionxform = str # type: ignore[method-assign,assignment]
cp.read(SCHEME) cp.read(SCHEME)
def color(group, key): def color(group: str, key: str) -> Color:
return tuple(int(v) for v in cp[f"Colors:{group}"][key].split(",")) return tuple(int(v) for v in cp[f"Colors:{group}"][key].split(","))
return color return color
def mix(a, b, t): def mix(a: Sequence[int], b: Sequence[int], t: float) -> Color:
return tuple(round(x + (y - x) * t) for x, y in zip(a, b)) return tuple(round(x + (y - x) * t) for x, y in zip(a, b))
def hexc(c): def hexc(c: Sequence[int]) -> str:
"""#rrggbb, or #rrggbbaa when c carries an alpha.""" """#rrggbb, or #rrggbbaa when c carries an alpha."""
return "#" + "".join(f"{v:02x}" for v in c) return "#" + "".join(f"{v:02x}" for v in c)
def kwin_compensate(c): def kwin_compensate(c: Sequence[int]) -> list[int]:
"""Pre-distort a color for Chromium on KWin. """Pre-distort a color for Chromium on KWin.
Chromium tags its surfaces as sRGB, KWin shows them with a gamma 2.2 Chromium tags its surfaces as sRGB, KWin shows them with a gamma 2.2
@@ -81,15 +87,15 @@ def kwin_compensate(c):
with the inverse makes Helium land on the same values as native apps. with the inverse makes Helium land on the same values as native apps.
""" """
def ch(v): def ch(v: int) -> int:
lin = (v / 255) ** 2.2 lin: float = (v / 255) ** 2.2
enc = 12.92 * lin if lin <= 0.0031308 else 1.055 * lin ** (1 / 2.4) - 0.055 enc = 12.92 * lin if lin <= 0.0031308 else 1.055 * lin ** (1 / 2.4) - 0.055
return round(enc * 255) return round(enc * 255)
return [ch(v) for v in c] return [ch(v) for v in c]
def helium(color): def helium(color: ColorFn) -> dict[str, list[int]]:
bar = color("Header", "BackgroundNormal") bar = color("Header", "BackgroundNormal")
text = color("Window", "ForegroundNormal") text = color("Window", "ForegroundNormal")
view = color("View", "BackgroundNormal") view = color("View", "BackgroundNormal")
@@ -123,7 +129,7 @@ def helium(color):
return {k: kwin_compensate(v) for k, v in colors.items()} return {k: kwin_compensate(v) for k, v in colors.items()}
def ansi(color): def ansi(color: ColorFn) -> list[Color]:
"""The 16 colors for the VS Code terminal.""" """The 16 colors for the VS Code terminal."""
bg = color("View", "BackgroundNormal") bg = color("View", "BackgroundNormal")
fg = color("View", "ForegroundNormal") fg = color("View", "ForegroundNormal")
@@ -145,7 +151,7 @@ def ansi(color):
return normal + bright return normal + bright
def t3(color): def t3(color: ColorFn) -> dict[str, str]:
"""Every color slot of a T3 Code theme, mapped onto a scheme role.""" """Every color slot of a T3 Code theme, mapped onto a scheme role."""
window, window_text = color("Window", "BackgroundNormal"), color("Window", "ForegroundNormal") window, window_text = color("Window", "BackgroundNormal"), color("Window", "ForegroundNormal")
view, view_text = color("View", "BackgroundNormal"), color("View", "ForegroundNormal") view, view_text = color("View", "BackgroundNormal"), color("View", "ForegroundNormal")
@@ -227,7 +233,7 @@ def t3(color):
return {k: hexc(kwin_compensate(v)) for k, v in colors.items()} return {k: hexc(kwin_compensate(v)) for k, v in colors.items()}
def telegram(color): def telegram(color: ColorFn) -> dict[str, Color]:
"""Telegram keys mapped onto a scheme role; the rest come from night.""" """Telegram keys mapped onto a scheme role; the rest come from night."""
window, window_text = color("Window", "BackgroundNormal"), color("Window", "ForegroundNormal") window, window_text = color("Window", "BackgroundNormal"), color("Window", "ForegroundNormal")
view, view_text = color("View", "BackgroundNormal"), color("View", "ForegroundNormal") view, view_text = color("View", "BackgroundNormal"), color("View", "ForegroundNormal")
@@ -360,25 +366,25 @@ def telegram(color):
} }
def luma(c): def luma(c: Sequence[int]) -> float:
return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
def interp(points, x): def interp(points: Sequence[tuple[float, float]], x: float) -> float:
for (x0, y0), (x1, y1) in zip(points, points[1:]): for (x0, y0), (x1, y1) in zip(points, points[1:]):
if x <= x1: if x <= x1:
return y0 + (y1 - y0) * (x - x0) / (x1 - x0) return y0 + (y1 - y0) * (x - x0) / (x1 - x0)
return points[-1][1] return points[-1][1]
def parse_hex(v): def parse_hex(v: str) -> Color:
h = v[1:] h = v[1:]
if len(h) in (3, 4): if len(h) in (3, 4):
h = "".join(ch * 2 for ch in h) h = "".join(ch * 2 for ch in h)
return tuple(int(h[i:i + 2], 16) for i in range(0, len(h), 2)) return tuple(int(h[i:i + 2], 16) for i in range(0, len(h), 2))
def telegram_theme(color): def telegram_theme(color: ColorFn) -> str:
"""The night theme, recolored onto the scheme. """The night theme, recolored onto the scheme.
Night tints everything blue. Its tinted grays become scheme grays of Night tints everything blue. Its tinted grays become scheme grays of
@@ -399,7 +405,7 @@ def telegram_theme(color):
}) })
accent_h, _, accent_s = colorsys.rgb_to_hls(*(v / 255 for v in color("Selection", "BackgroundNormal"))) accent_h, _, accent_s = colorsys.rgb_to_hls(*(v / 255 for v in color("Selection", "BackgroundNormal")))
def recolor(c): def recolor(c: Color) -> Color:
rgb, alpha = c[:3], c[3:] rgb, alpha = c[:3], c[3:]
chroma = max(rgb) - min(rgb) chroma = max(rgb) - min(rgb)
h, l, _ = colorsys.rgb_to_hls(*(v / 255 for v in rgb)) h, l, _ = colorsys.rgb_to_hls(*(v / 255 for v in rgb))
@@ -411,7 +417,7 @@ def telegram_theme(color):
return (*(round(v * 255) for v in colorsys.hls_to_rgb(accent_h, l, accent_s)), *alpha) return (*(round(v * 255) for v in colorsys.hls_to_rgb(accent_h, l, accent_s)), *alpha)
pinned = telegram(color) pinned = telegram(color)
seen = set() seen: set[str] = set()
out = [f"// Generated by kde/theme/build.py from {SCHEME.name}"] out = [f"// Generated by kde/theme/build.py from {SCHEME.name}"]
for line in base.splitlines(): for line in base.splitlines():
m = line_re.match(line) m = line_re.match(line)
@@ -429,20 +435,21 @@ def telegram_theme(color):
return "\n".join(out) + "\n" return "\n".join(out) + "\n"
def vscode_base_colors(): def vscode_base_colors() -> dict[str, Color]:
"""Dark 2026's colors with its include chain resolved.""" """Dark 2026's colors with its include chain resolved."""
chain, path = [], VSCODE_UI chain: list[dict[str, str]] = []
path: Path | None = VSCODE_UI
while path: while path:
theme = json.loads(path.read_text()) theme = json.loads(path.read_text())
chain.append(theme.get("colors", {})) chain.append(theme.get("colors", {}))
path = path.parent / theme["include"] if "include" in theme else None path = path.parent / theme["include"] if "include" in theme else None
colors = {} colors: dict[str, str] = {}
for layer in reversed(chain): for layer in reversed(chain):
colors.update(layer) colors.update(layer)
return {k: parse_hex(v) for k, v in colors.items()} return {k: parse_hex(v) for k, v in colors.items()}
def vscode(color): def vscode(color: ColorFn) -> dict[str, str]:
"""Workbench colors: Dark 2026, recolored onto the scheme. """Workbench colors: Dark 2026, recolored onto the scheme.
Dark 2026 lays out its grays the way the scheme does: the chrome lighter Dark 2026 lays out its grays the way the scheme does: the chrome lighter
@@ -475,11 +482,11 @@ def vscode(color):
}) })
assert all(a[1] <= b[1] for a, b in zip(grays, grays[1:])), grays assert all(a[1] <= b[1] for a, b in zip(grays, grays[1:])), grays
def hls(c): def hls(c: Sequence[int]) -> tuple[float, float, float]:
return colorsys.rgb_to_hls(*(v / 255 for v in c)) return colorsys.rgb_to_hls(*(v / 255 for v in c))
# (hue range in degrees, scheme color whose hue it takes) # (hue range in degrees, scheme color whose hue it takes)
families = [ families: list[tuple[tuple[int, int], Color]] = [
((0, 15), negative), ((0, 15), negative),
((15, 70), neutral), ((15, 70), neutral),
((70, 170), positive), ((70, 170), positive),
@@ -488,7 +495,7 @@ def vscode(color):
((330, 360), negative), ((330, 360), negative),
] ]
def recolor(c): def recolor(c: Color) -> Color:
rgb, alpha = c[:3], c[3:] rgb, alpha = c[:3], c[3:]
if max(rgb) - min(rgb) <= 10: if max(rgb) - min(rgb) <= 10:
g = round(interp(grays, luma(rgb))) g = round(interp(grays, luma(rgb)))
@@ -497,7 +504,7 @@ def vscode(color):
target = next(t for (lo, hi), t in families if lo <= h * 360 < hi) target = next(t for (lo, hi), t in families if lo <= h * 360 < hi)
return (*(round(v * 255) for v in colorsys.hls_to_rgb(hls(target)[0], l, sat)), *alpha) return (*(round(v * 255) for v in colorsys.hls_to_rgb(hls(target)[0], l, sat)), *alpha)
pins = { pins: dict[str, Color] = {
"editor.background": view, "editor.background": view,
"editor.foreground": view_text, "editor.foreground": view_text,
"titleBar.inactiveBackground": color("Header][Inactive", "BackgroundNormal"), "titleBar.inactiveBackground": color("Header][Inactive", "BackgroundNormal"),
@@ -527,7 +534,7 @@ def vscode(color):
return {k: hexc(kwin_compensate(c[:3]) + list(c[3:])) for k, c in sorted(colors.items())} return {k: hexc(kwin_compensate(c[:3]) + list(c[3:])) for k, c in sorted(colors.items())}
def vscode_files(color): def vscode_files(color: ColorFn) -> dict[str, str]:
"""The extension files, keyed by their path inside it.""" """The extension files, keyed by their path inside it."""
name = "Darwin Breeze Dark" name = "Darwin Breeze Dark"
# Syntax colors as T3 Code shows them. T3 Code doesn't compensate them # Syntax colors as T3 Code shows them. T3 Code doesn't compensate them
@@ -560,7 +567,7 @@ def vscode_files(color):
} }
def vsix(files): def vsix(files: Mapping[str, str]) -> bytes:
"""Pack the extension the way vsce does, for code --install-extension.""" """Pack the extension the way vsce does, for code --install-extension."""
manifest = """<?xml version="1.0" encoding="utf-8"?> manifest = """<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011"> <PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
@@ -592,10 +599,10 @@ def vsix(files):
}) })
def solid_png(c): def solid_png(c: Sequence[int]) -> bytes:
"""A 1x1 PNG; Telegram stretches it over the chat as a plain background.""" """A 1x1 PNG; Telegram stretches it over the chat as a plain background."""
def chunk(kind, data): def chunk(kind: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))
return ( return (
@@ -606,7 +613,7 @@ def solid_png(c):
) )
def zip_bytes(files): def zip_bytes(files: Mapping[str, str | bytes]) -> bytes:
"""A zip with fixed timestamps, so an unchanged theme rebuilds byte-identical.""" """A zip with fixed timestamps, so an unchanged theme rebuilds byte-identical."""
buf = io.BytesIO() buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
@@ -618,7 +625,7 @@ def zip_bytes(files):
return buf.getvalue() return buf.getvalue()
def write(path, data): def write(path: Path, data: str | bytes) -> bool:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(data, str): if isinstance(data, str):
data = data.encode() data = data.encode()
@@ -629,7 +636,7 @@ def write(path, data):
return True return True
def main(): def main() -> None:
color = load() color = load()
manifest = { manifest = {
"manifest_version": 3, "manifest_version": 3,