GitHub Viewer
#!/usr/bin/env python3
"""Regenerate the bundled Lucide icon data and the ``IconName`` literal.
Usage:
python scripts/generate-icons.py --tag 1.47.0
python scripts/generate-icons.py --source /path/to/lucide/icons --version 1.47.0
With ``--tag`` the script downloads the release archive from GitHub into a
temporary directory. With ``--source`` it reads an already extracted
``icons/`` directory (the folder holding ``heart.svg``, ``heart.json``,
and so on).
Outputs (both checked in):
- ``src/pythonnative/icons/_lucide.json``: ``{"version", "license",
"icons": {name: [shape, ...]}}`` where each shape is either a path
string or a small dict for the other SVG basic shapes, all in the
24 by 24 Lucide grid.
- ``src/pythonnative/icons/_names.py``: the ``IconName`` ``Literal`` and
``ICON_NAMES`` tuple used for editor completion and validation.
Lucide is ISC licensed; the license text is copied into
``src/pythonnative/icons/LICENSE-lucide.txt``.
"""
from __future__ import annotations
import argparse
import io
import json
import re
import sys
import tarfile
import tempfile
import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any, Dict, List
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "src" / "pythonnative" / "icons"
SVG_NS = "{http://www.w3.org/2000/svg}"
NUMERIC = ("cx", "cy", "r", "rx", "ry", "x", "y", "width", "height", "x1", "y1", "x2", "y2")
def _number(text: str) -> float:
value = float(text)
return int(value) if value.is_integer() else value
def convert(svg_text: str) -> List[Any]:
"""Return the shape list for one Lucide SVG document."""
root = ET.fromstring(svg_text)
shapes: List[Any] = []
for element in root:
tag = element.tag.replace(SVG_NS, "")
if tag == "path":
d = element.attrib.get("d", "").strip()
if d:
shapes.append(re.sub(r"\s+", " ", d))
elif tag in ("circle", "ellipse", "rect", "line"):
shape: Dict[str, Any] = {"kind": tag}
for key in NUMERIC:
if key in element.attrib:
shape[key] = _number(element.attrib[key])
shapes.append(shape)
elif tag in ("polyline", "polygon"):
shapes.append({"kind": tag, "points": re.sub(r"\s+", " ", element.attrib.get("points", "").strip())})
else:
raise ValueError(f"unsupported element ")
return shapes
def load_icons(source: Path) -> Dict[str, List[Any]]:
"""Read every ``*.svg`` under ``source`` into a name -> shapes mapping."""
icons: Dict[str, List[Any]] = {}
for path in sorted(source.glob("*.svg")):
name = path.stem
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
print(f"skipping oddly named icon {name!r}", file=sys.stderr)
continue
icons[name] = convert(path.read_text(encoding="utf-8"))
if not icons:
raise SystemExit(f"no icons found under {source}")
return icons
def download(tag: str, destination: Path) -> tuple[Path, str]:
"""Fetch the Lucide release archive for ``tag``; return (icons dir, license)."""
url = f"https://github.com/lucide-icons/lucide/archive/refs/tags/{tag}.tar.gz"
print(f"downloading {url}", file=sys.stderr)
with urllib.request.urlopen(url) as response: # noqa: S310 - fixed GitHub host
data = response.read()
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
members = [m for m in archive.getmembers() if "/icons/" in m.name or m.name.endswith("/LICENSE")]
archive.extractall(destination, members=members) # noqa: S202 - trusted release archive
top = next(destination.iterdir())
return top / "icons", (top / "LICENSE").read_text(encoding="utf-8")
def write_outputs(icons: Dict[str, List[Any]], version: str, license_text: str | None) -> None:
"""Write the JSON data, the names module, and the license."""
OUT_DIR.mkdir(parents=True, exist_ok=True)
payload = {"version": version, "license": "ISC", "source": "https://lucide.dev", "icons": icons}
(OUT_DIR / "_lucide.json").write_text(
json.dumps(payload, separators=(",", ":"), sort_keys=True, ensure_ascii=True) + "\n", encoding="utf-8"
)
names = sorted(icons)
lines = [
'"""Generated by scripts/generate-icons.py. Do not edit by hand."""',
"",
"# ruff: noqa: E501",
"# fmt: off",
"from typing import Literal, Tuple",
"",
f'LUCIDE_VERSION = "{version}"',
"",
"IconName = Literal[",
*(f' "{name}",' for name in names),
"]",
'"""Every icon name bundled with PythonNative (Lucide).',
"",
"Used for editor completion and type checking of ``pn.Icon(name)``.",
'"""',
"",
"ICON_NAMES: Tuple[str, ...] = (",
*(f' "{name}",' for name in names),
")",
"",
]
(OUT_DIR / "_names.py").write_text("\n".join(lines), encoding="utf-8")
if license_text:
(OUT_DIR / "LICENSE-lucide.txt").write_text(license_text, encoding="utf-8")
print(f"wrote {len(names)} icons (Lucide {version}) to {OUT_DIR}", file=sys.stderr)
def main(argv: List[str]) -> int:
"""Download the icon set and regenerate the packaged data files."""
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--tag", help="Lucide release tag to download (e.g. 1.47.0)")
parser.add_argument("--source", type=Path, help="Extracted Lucide icons/ directory")
parser.add_argument("--version", help="Version string recorded in the output (defaults to --tag)")
parser.add_argument("--license", type=Path, help="Path to the Lucide LICENSE file when using --source")
args = parser.parse_args(argv)
if not args.tag and not args.source:
parser.error("one of --tag or --source is required")
if args.source:
icons = load_icons(args.source)
version = args.version or args.tag or "unknown"
license_text = args.license.read_text(encoding="utf-8") if args.license else None
write_outputs(icons, version, license_text)
return 0
with tempfile.TemporaryDirectory(prefix="lucide-") as folder:
icons_dir, license_text = download(args.tag, Path(folder))
write_outputs(load_icons(icons_dir), args.version or args.tag, license_text)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))