diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e70c2ca..0c51e7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,3 +17,4 @@ jobs: - run: uv run ruff format --check . - run: uv run ruff check . - run: uv run ty check . + - run: uv run pytest diff --git a/README.md b/README.md index 0fb2e04..e19f166 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,31 @@ full brightness would be blinding in person; a color that's already it. Start around 5.0 for a similarly low "should read as white" value and adjust live with `[`/`]` for anything not listed. +## Screenshots + +For CI, PR previews, or anywhere else a terminal isn't available: + +```sh +uv run matrixbox screenshot clock -o clock.png +``` + +Boots one app headlessly (no terminal, no button listener), waits for it +to draw, writes the result to a PNG, and exits — no second terminal or +`--connect` needed. `--settings ` seeds it with a settings file +before boot, resolved inside the app's own directory (e.g. `--settings +ci.json` for `apps/clock/ci.json`); omit it to boot with plain defaults. +Always starts from a clean, reset state, regardless of whatever an +earlier `uv run matrixbox app` run against the same app may have saved. + +By default it captures as soon as one frame is drawn, waiting up to 5 +seconds; `--after-frames ` and `--timeout ` adjust both, +whichever is reached first. An app that never draws in time, or raises +while starting up, exits non-zero with the error printed, so a CI job +fails loudly instead of shipping a blank or stale image. `--scale ` +sets the output PNG's pixel scale factor (default 8, so a 128x32 panel +becomes a 1024x256 image). `--size` / `--width` / `--height` pick the +panel size, same as `matrixbox app` (see "Panel sizes" above). + ## Useful flags `uv run matrixbox app`: `--size` or `--width` / `--height` for panel @@ -248,6 +273,8 @@ and start fresh, `--refresh-fps` / `--gamma` (see above). `--device` or `--width` / `--height` for the demo/placeholder size (see "Panel sizes" above), `--fps` (demo mode only). +`uv run matrixbox screenshot`: see "Screenshots" above. + ## Limitations - `Group(scale=...)` is accepted but not honored: nothing renders diff --git a/matrixbox_simulator/cli.py b/matrixbox_simulator/cli.py index d5db4c4..5440725 100644 --- a/matrixbox_simulator/cli.py +++ b/matrixbox_simulator/cli.py @@ -1,8 +1,9 @@ -"""Single `matrixbox` entrypoint, with `app` and `simulator` as subcommands.""" +"""Single `matrixbox` entrypoint, with `app`, `simulator`, and `screenshot` +as subcommands.""" import argparse -from matrixbox_simulator.device import run_app +from matrixbox_simulator.device import run_app, run_screenshot from matrixbox_simulator.term import run_simulator @@ -24,12 +25,23 @@ def main() -> None: description=run_simulator.__doc__, ) ) + run_screenshot.build_parser( + subparsers.add_parser( + "screenshot", + help="boot an app headlessly and save one rendered frame to a PNG", + description=run_screenshot.__doc__, + ) + ) args = parser.parse_args() if args.command == "app": run_app.run(args) - else: + elif args.command == "screenshot": + run_screenshot.run(args) + elif args.command == "simulator": run_simulator.run(args) + else: + raise AssertionError(f"unhandled command: {args.command!r}") if __name__ == "__main__": diff --git a/matrixbox_simulator/device/frame_bridge.py b/matrixbox_simulator/device/frame_bridge.py index 9044e96..646a0c9 100644 --- a/matrixbox_simulator/device/frame_bridge.py +++ b/matrixbox_simulator/device/frame_bridge.py @@ -9,6 +9,7 @@ import struct import sys import time +from collections.abc import Callable try: import resource @@ -40,6 +41,11 @@ def __init__(self) -> None: self._last_rgb: bytes | None = None self._smoothed_interval: float | None = None + # Set by headless callers (screenshot mode) that need a composited + # frame directly, without standing up a real renderer to decode it + # back off the wire. + self.on_publish: Callable[[int, int, bytes], None] | None = None + def start(self, host: str = "127.0.0.1", port: int = 9191) -> wsserver.FrameServer: if self._server is None: self._server = wsserver.FrameServer(host, port) @@ -97,6 +103,9 @@ def publish(self, width: int, height: int, rgb: bytes) -> None: header = struct.pack(" None: diff --git a/matrixbox_simulator/device/run_screenshot.py b/matrixbox_simulator/device/run_screenshot.py new file mode 100644 index 0000000..498f002 --- /dev/null +++ b/matrixbox_simulator/device/run_screenshot.py @@ -0,0 +1,342 @@ +"""Boots a single matrixbox app headlessly and saves one rendered frame to +a PNG, for CI smoke tests and PR previews rather than interactive use. + +Usage: + + matrixbox screenshot clock --settings ci.json -o clock.png + +Reuses `run_app`'s own staging (kernel detection, path sandboxing, +settings seeding) — screenshot mode differs only in what happens after +staging: no terminal, no button listener, no web UI, just wait for a +frame and write it out. +""" + +import argparse +import io +import json +import os +import sys +import threading +import time +import traceback +from pathlib import Path +from typing import Any + +from PIL import Image + +from matrixbox_simulator.device import frame_bridge, run_app +from matrixbox_simulator.sizes import ROTATION_OVERRIDES, SIZE_PRESETS + + +def build_parser( + parser: argparse.ArgumentParser | None = None, +) -> argparse.ArgumentParser: + if parser is None: + parser = argparse.ArgumentParser(description=__doc__) + + parser.add_argument( + "app", + help=( + "app to screenshot: a directory name under matrixbox/apps (e.g. " + "clock), or an absolute/relative path to any app directory" + ), + ) + parser.add_argument( + "--settings", + default=None, + help=( + "name of a settings file to seed with, resolved inside the " + "app's own directory (e.g. --settings ci.json for " + "/ci.json). Optional: omitted, the app boots with plain " + "defaults" + ), + ) + parser.add_argument( + "--size", choices=sorted(SIZE_PRESETS), default=None, help="see `matrixbox app`" + ) + parser.add_argument("--width", type=int, default=None, help="see `matrixbox app`") + parser.add_argument("--height", type=int, default=None, help="see `matrixbox app`") + parser.add_argument( + "--after-frames", + type=int, + default=1, + help="capture as soon as this many distinct frames have been drawn (default 1)", + ) + parser.add_argument( + "--timeout", + type=float, + default=5.0, + help="give up waiting for --after-frames after this many seconds, " + "capturing whatever the last drawn frame was instead (default 5.0)", + ) + parser.add_argument( + "--scale", + type=int, + default=8, + help="pixel scale factor for the output PNG: each device pixel is " + "drawn as an NxN block (default 8)", + ) + parser.add_argument( + "-o", + "--output", + default=None, + help="output PNG path (default: .png in the current directory)", + ) + parser.add_argument( + "--refresh-fps", type=float, default=0.0, help="see `matrixbox app`" + ) + parser.add_argument("--gamma", type=float, default=1.0, help="see `matrixbox app`") + + return parser + + +def _stage_for_screenshot( + app_dir: Path, + framework_root: Path, + settings_src: Path | None, + args: argparse.Namespace, +) -> tuple[str, Path]: + """Stages `app_dir` fresh (whichever kernel style it uses) and seeds its + settings.txt, either from `settings_src` or plain defaults. Returns the + exec-ready (source, path) for the app's own entry point, ready for + `run_app._exec_as_main`. Mirrors run_app's own + _run_main_kernel/_run_package_kernel split, minus everything that's + interactive-only or web-UI-only.""" + if run_app._is_monolithic_kernel(framework_root): + return _stage_monolithic_app_for_screenshot( + app_dir, framework_root, settings_src, args + ) + + return _stage_package_app_for_screenshot( + app_dir, framework_root, settings_src, args + ) + + +def _stage_monolithic_app_for_screenshot( + app_dir: Path, + framework_root: Path, + settings_src: Path | None, + args: argparse.Namespace, +) -> tuple[str, Path]: + staged_root = run_app._stage_checkout(framework_root, reset=True) + run_app._install_path_sandbox(staged_root) + run_app._install_chdir_path_tracking() + run_app._install_lenient_bytes_import_hook(staged_root) + + settings_path = staged_root / "settings.txt" + if settings_src is not None: + settings_path.write_text(settings_src.read_text()) + + run_app._seed_monolithic_settings( + staged_root, + args.width, + args.height, + app_name=app_dir.name, + overwrite=args.geometry_explicit, + rotation=args.rotation_override, + ) + + sys.path.insert(0, str(run_app.REPO_ROOT)) + sys.path.insert(0, str(staged_root)) + sys.path.insert(0, str(staged_root / "lib")) + sys.path.insert(0, str(run_app.STUB_DIR)) + + entry_path = staged_root / "main.py" + os.chdir(staged_root) # goes through tracked_chdir, seeds sys.path[0] + + return entry_path.read_text(), entry_path + + +def _stage_package_app_for_screenshot( + app_dir: Path, + framework_root: Path, + settings_src: Path | None, + args: argparse.Namespace, +) -> tuple[str, Path]: + if not (app_dir / "code.py").exists(): + raise SystemExit(f"{app_dir} doesn't look like an app (no code.py)") + + staged_app_dir = run_app._stage_app(app_dir, reset=True) + run_app._install_path_sandbox(run_app.SANDBOX_ROOT) + + # SANDBOX_ROOT (not staged_app_dir) is where a package-kernel app's + # settings.txt actually lives, matching real hardware's single + # flash-root settings file — see run_app._seed_settings. reset=True on + # _stage_app above only wipes this app's own staged code, so drop any + # leftover settings.txt from an earlier, unrelated run by hand: + # screenshot mode always starts from a clean, known state. + settings_path = run_app.SANDBOX_ROOT / "settings.txt" + settings_path.unlink(missing_ok=True) + if settings_src is not None: + settings_path.write_text(settings_src.read_text()) + + run_app._seed_settings( + args.width, + args.height, + overwrite=args.geometry_explicit, + rotation=args.rotation_override, + ) + + sys.path.insert(0, str(run_app.REPO_ROOT)) + sys.path.insert(0, str(framework_root)) + sys.path.insert(0, str(framework_root / "lib")) + sys.path.insert(0, str(run_app.STUB_DIR)) + + entry_path = staged_app_dir / "code.py" + os.chdir(staged_app_dir) + sys.path.insert(0, str(staged_app_dir)) + + return entry_path.read_text(), entry_path + + +def _write_screenshot( + path: Path, width: int, height: int, rgb: bytes, *, scale: int +) -> None: + image = Image.frombytes("RGB", (width, height), rgb) + if scale > 1: + image = image.resize((width * scale, height * scale), Image.Resampling.NEAREST) + + # Image.save() reaches for builtins.open directly, which + # _install_path_sandbox has redirected into the app's own sandboxed + # filesystem — but this output path is a real one on the host, not + # something the app itself wrote. Route around that the same way + # settings.txt writes elsewhere do: through pathlib's own io.open, + # which the sandbox patch never touches. + buffer = io.BytesIO() + image.save(buffer, format="PNG") + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(buffer.getvalue()) + + +def run(args: argparse.Namespace) -> None: + args.geometry_explicit = ( + args.size is not None or args.width is not None or args.height is not None + ) + args.rotation_override = ( + ROTATION_OVERRIDES.get(args.size, 0) if args.size is not None else None + ) + + if args.size is not None: + args.width, args.height, _panels = SIZE_PRESETS[args.size] + else: + args.width = args.width if args.width is not None else 128 + args.height = args.height if args.height is not None else 32 + + os.environ["MATRIXBOX_SIMULATOR_REFRESH_FPS"] = str(args.refresh_fps) + os.environ["MATRIXBOX_SIMULATOR_GAMMA"] = str(args.gamma) + + app_dir = run_app._resolve_app_dir(args.app) + framework_root = run_app._framework_root_for(app_dir) + if not framework_root.exists(): + raise SystemExit(f"expected a matrixbox-style checkout at {framework_root}") + + settings_src = None + if args.settings is not None: + settings_src = app_dir / args.settings + if not settings_src.is_file(): + raise SystemExit(f"no such settings file: {settings_src}") + + # Validated up front rather than left to run_app._seed_settings' + # own _read_json: that swallows a parse error and falls back to + # {}, reasonable for on-disk runtime state that might get + # corrupted, but not for a file the caller explicitly asked to + # seed with — a typo here should fail the CI job, not silently + # boot with plain defaults instead. + try: + json.loads(settings_src.read_text()) + except (OSError, ValueError) as exc: + raise SystemExit(f"invalid settings file {settings_src}: {exc}") from exc + + # Resolved against the real launch directory, before staging below + # os.chdir()s into the sandbox — a relative --output would otherwise + # land inside it instead of where the caller actually meant. + output = ( + Path(args.output) if args.output is not None else Path(f"{app_dir.name}.png") + ).resolve() + + run_app._patch_stdlib() + source, entry_path = _stage_for_screenshot( + app_dir, framework_root, settings_src, args + ) + + # Port 0: nothing ever connects to this server, it's only running so + # framebufferio's refresh() (which bails out early with no server + # started) has somewhere to publish to — the callback below is what + # actually captures the frame. Letting the OS pick a free port avoids + # colliding with a real `matrixbox app` or another screenshot run + # already using the default 9191. + frame_bridge.bridge.start("127.0.0.1", 0) + + capture_lock = threading.Lock() + frame_ready = threading.Event() + captured: dict[str, Any] = {"count": 0, "width": 0, "height": 0, "rgb": None} + + def on_publish(width: int, height: int, rgb: bytes) -> None: + with capture_lock: + captured["count"] += 1 + captured["width"] = width + captured["height"] = height + captured["rgb"] = rgb + + frame_ready.set() + + frame_bridge.bridge.on_publish = on_publish + + crashed: dict[str, BaseException | None] = {"error": None} + + def run_app_code() -> None: + try: + run_app._exec_as_main(source, entry_path) + except SystemExit: + pass + except BaseException as exc: # noqa: BLE001 - surfaced to the CI caller below + crashed["error"] = exc + + app_thread = threading.Thread(target=run_app_code, daemon=True) + app_thread.start() + + deadline = time.monotonic() + args.timeout + while time.monotonic() < deadline: + if crashed["error"] is not None: + break + + with capture_lock: + if captured["count"] >= args.after_frames: + break + + frame_ready.wait(timeout=0.05) + frame_ready.clear() + + frame_bridge.bridge.on_publish = None + + if crashed["error"] is not None: + traceback.print_exception(crashed["error"]) + raise SystemExit(f"{app_dir.name} raised while rendering: {crashed['error']!r}") + + with capture_lock: + count, width, height, rgb = ( + captured["count"], + captured["width"], + captured["height"], + captured["rgb"], + ) + + if rgb is None: + raise SystemExit( + f"{app_dir.name} never drew a frame within {args.timeout:.1f}s" + ) + + _write_screenshot(output, width, height, rgb, scale=args.scale) + print( + f"matrixbox-simulator: wrote {output} ({width}x{height}, " + f"{count} frame{'s' if count != 1 else ''} drawn, {args.scale}x scale)" + ) + + +def main() -> None: + run(build_parser().parse_args()) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 9435ef2..b0f6230 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,10 +20,13 @@ build-backend = "hatchling.build" packages = ["matrixbox_simulator"] [dependency-groups] -dev = ["ruff>=0.14", "ty>=0.0.1a0", "uv>=0.12"] +dev = ["pytest>=8", "ruff>=0.14", "ty>=0.0.1a0", "uv>=0.12"] [tool.ruff] line-length = 88 [tool.ruff.lint] select = ["E", "F", "I"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py new file mode 100644 index 0000000..a59caf7 --- /dev/null +++ b/tests/test_screenshot.py @@ -0,0 +1,202 @@ +"""Integration tests for `matrixbox screenshot`, run as real subprocesses +against small fixture apps under the package-kernel layout (no main.py). +The command's own staging does enough process-global monkeypatching +(sys.modules, builtins.open, os.chdir) that driving it in-process would +mean fighting that instead of testing it, so a subprocess is the only +way to see it the way a CI job actually would. +""" + +import json +import subprocess +import sys +from pathlib import Path + +from PIL import Image + +_SOLID_FRAME_APP = """ +import json + +import displayio +import framebufferio +import rgbmatrix + +try: + with open("/settings.txt") as f: + settings = json.loads(f.read()) +except OSError: + settings = {} + +width = settings.get("width", 64) +height = settings.get("height", 32) +color = 0x00FF00 if settings.get("theme") == "green" else 0xFF0000 + +matrix = rgbmatrix.RGBMatrix(width=width, height=height) +display = framebufferio.FramebufferDisplay(matrix) + +bitmap = displayio.Bitmap(width, height, 1) +palette = displayio.Palette(1) +palette[0] = color +tile_grid = displayio.TileGrid(bitmap, pixel_shader=palette) +group = displayio.Group() +group.append(tile_grid) +display.root_group = group +display.refresh() + +while True: + pass +""" + +_MULTI_FRAME_APP = """ +import time + +import displayio +import framebufferio +import rgbmatrix + +matrix = rgbmatrix.RGBMatrix(width=64, height=32) +display = framebufferio.FramebufferDisplay(matrix) + +bitmap = displayio.Bitmap(64, 32, 2) +palette = displayio.Palette(2) +palette[0] = 0x000000 +palette[1] = 0x0000FF +tile_grid = displayio.TileGrid(bitmap, pixel_shader=palette) +group = displayio.Group() +group.append(tile_grid) +display.root_group = group + +for i in range(5): + bitmap[0, 0] = i % 2 + display.refresh() + time.sleep(0.05) + +while True: + time.sleep(0.1) +""" + +_NEVER_DRAWS_APP = """ +import time + +while True: + time.sleep(0.1) +""" + +_CRASHES_APP = 'raise RuntimeError("boom")\n' + + +def _make_app(tmp_path: Path, name: str, code: str) -> Path: + # /apps/ is the layout _framework_root_for() recognizes; + # anything else falls back to the real ../matrixbox sibling checkout, + # which won't exist in CI. + app_dir = tmp_path / "fakefw" / "apps" / name + app_dir.mkdir(parents=True) + (app_dir / "code.py").write_text(code) + + return app_dir + + +def _run_screenshot( + *args: str, cwd: Path | None = None, timeout: float = 15.0 +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "matrixbox_simulator.cli", "screenshot", *args], + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + + +def test_captures_a_drawn_frame_with_default_settings(tmp_path: Path) -> None: + app_dir = _make_app(tmp_path, "solid", _SOLID_FRAME_APP) + output = tmp_path / "out.png" + + result = _run_screenshot(str(app_dir), "-o", str(output)) + + assert result.returncode == 0, result.stderr + image = Image.open(output) + assert image.size == (128 * 8, 32 * 8) # default panel size, default 8x scale + assert image.getpixel((0, 0)) == (255, 0, 0) # no --settings: app's own default + + +def test_settings_file_is_resolved_inside_the_app_directory(tmp_path: Path) -> None: + app_dir = _make_app(tmp_path, "solid", _SOLID_FRAME_APP) + (app_dir / "ci.json").write_text(json.dumps({"theme": "green"})) + output = tmp_path / "out.png" + + result = _run_screenshot(str(app_dir), "--settings", "ci.json", "-o", str(output)) + + assert result.returncode == 0, result.stderr + assert Image.open(output).getpixel((0, 0)) == (0, 255, 0) + + +def test_missing_settings_file_fails_fast(tmp_path: Path) -> None: + app_dir = _make_app(tmp_path, "solid", _SOLID_FRAME_APP) + + result = _run_screenshot( + str(app_dir), "--settings", "nope.json", "-o", str(tmp_path / "out.png") + ) + + assert result.returncode != 0 + assert "no such settings file" in result.stderr + + +def test_invalid_settings_json_fails_fast(tmp_path: Path) -> None: + app_dir = _make_app(tmp_path, "solid", _SOLID_FRAME_APP) + (app_dir / "ci.json").write_text("{not json") + + result = _run_screenshot( + str(app_dir), "--settings", "ci.json", "-o", str(tmp_path / "out.png") + ) + + assert result.returncode != 0 + assert "invalid settings file" in result.stderr + + +def test_relative_output_path_resolves_against_the_launch_directory( + tmp_path: Path, +) -> None: + # Regression test: staging os.chdir()s into the app's own sandbox + # before the frame is written, so a relative --output must be + # resolved against the caller's cwd *before* that happens, not + # whatever the sandbox's cwd is by the time the file gets written. + app_dir = _make_app(tmp_path, "solid", _SOLID_FRAME_APP) + workdir = tmp_path / "workdir" + workdir.mkdir() + + result = _run_screenshot(str(app_dir), "-o", "out.png", cwd=workdir) + + assert result.returncode == 0, result.stderr + assert (workdir / "out.png").is_file() + + +def test_after_frames_caps_how_many_frames_are_waited_for(tmp_path: Path) -> None: + app_dir = _make_app(tmp_path, "multi", _MULTI_FRAME_APP) + + result = _run_screenshot( + str(app_dir), "--after-frames", "3", "-o", str(tmp_path / "out.png") + ) + + assert result.returncode == 0, result.stderr + assert "3 frames drawn" in result.stdout + + +def test_an_app_that_never_draws_times_out_with_a_nonzero_exit(tmp_path: Path) -> None: + app_dir = _make_app(tmp_path, "never", _NEVER_DRAWS_APP) + + result = _run_screenshot( + str(app_dir), "--timeout", "1", "-o", str(tmp_path / "out.png") + ) + + assert result.returncode != 0 + assert "never drew a frame" in result.stderr + + +def test_a_crashing_app_exits_nonzero_with_the_error_surfaced(tmp_path: Path) -> None: + app_dir = _make_app(tmp_path, "crashy", _CRASHES_APP) + + result = _run_screenshot(str(app_dir), "-o", str(tmp_path / "out.png")) + + assert result.returncode != 0 + assert "raised while rendering" in result.stderr + assert "boom" in result.stderr diff --git a/uv.lock b/uv.lock index c3ec431..a858f0b 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,24 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -26,6 +44,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pytest" }, { name = "ruff" }, { name = "ty" }, { name = "uv" }, @@ -40,6 +59,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "pytest", specifier = ">=8" }, { name = "ruff", specifier = ">=0.14" }, { name = "ty", specifier = ">=0.0.1a0" }, { name = "uv", specifier = ">=0.12" }, @@ -54,6 +74,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + [[package]] name = "pillow" version = "12.3.0" @@ -139,6 +168,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -148,6 +186,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "rich" version = "15.0.0"