diff --git a/tui/pyproject.toml b/tui/pyproject.toml index 1e7dab8..fdd9ea7 100644 --- a/tui/pyproject.toml +++ b/tui/pyproject.toml @@ -14,6 +14,9 @@ dependencies = [ "textual>=1.0.0", ] +[project.optional-dependencies] +camera = ["Pillow>=10.0", "astropy>=6.0"] + [project.scripts] birdcage-tui = "birdcage_tui.app:main" diff --git a/tui/src/birdcage_tui/app.py b/tui/src/birdcage_tui/app.py index f8821d0..945391b 100644 --- a/tui/src/birdcage_tui/app.py +++ b/tui/src/birdcage_tui/app.py @@ -16,6 +16,7 @@ from textual.binding import Binding from textual.containers import Horizontal from textual.widgets import Button, ContentSwitcher, Footer, Header +from birdcage_tui.screens.camera import CameraOverlay from birdcage_tui.screens.console import ConsoleOverlay from birdcage_tui.screens.control import ControlScreen from birdcage_tui.screens.dashboard import DashboardScreen @@ -45,6 +46,7 @@ class BirdcageApp(App): Binding("f3", "switch_tab('signal')", "Signal"), Binding("f4", "switch_tab('system')", "System"), Binding("f5", "toggle_console", "Console"), + Binding("f6", "toggle_camera", "Camera"), Binding("q", "quit", "Quit"), Binding("d", "toggle_dark", "Dark"), ] @@ -55,6 +57,8 @@ class BirdcageApp(App): firmware_name: str = "g2" skip_init: bool = False craft_url: str = "https://space.warehack.ing" + capture_dir: str = "captures" + camera_device: str = "auto" device: object = None shutdown_event: threading.Event = threading.Event() @@ -64,12 +68,15 @@ class BirdcageApp(App): _prev_az: float = 0.0 _prev_el: float = 0.0 _console_visible: bool = False + _camera_visible: bool = False + _pass_detector: object = None # PassEventDetector, set by CameraOverlay @property def SUB_TITLE(self) -> str: # noqa: N802 + tag = "a generic AZ/EL positioner that doesn't care about wavelength" if self.demo_mode: - return "DEMO" - return self.serial_port + return f"{tag} · DEMO" + return f"{tag} · {self.serial_port}" def compose(self) -> ComposeResult: yield Header() @@ -114,6 +121,7 @@ class BirdcageApp(App): self._setup_craft_client() self._update_status_strip_connection() self._install_console() + self._install_camera() self._start_position_poll() async def _initialize_device(self) -> None: @@ -159,6 +167,10 @@ class BirdcageApp(App): """Pre-install the console overlay so it persists across open/close.""" self.install_screen(ConsoleOverlay(), name="console-overlay") + def _install_camera(self) -> None: + """Pre-install the camera overlay so it persists across open/close.""" + self.install_screen(CameraOverlay(), name="camera-overlay") + # ------------------------------------------------------------------ # App-level position poll # ------------------------------------------------------------------ @@ -246,6 +258,25 @@ class BirdcageApp(App): """Called when the console overlay is dismissed.""" self._console_visible = False + # ------------------------------------------------------------------ + # Camera overlay + # ------------------------------------------------------------------ + + def action_toggle_camera(self) -> None: + """Push or pop the camera capture overlay.""" + if self._camera_visible: + try: + self.pop_screen() + except Exception: + self._camera_visible = False + else: + self.push_screen("camera-overlay", callback=self._on_camera_dismissed) + self._camera_visible = True + + def _on_camera_dismissed(self, _result=None) -> None: + """Called when the camera overlay is dismissed.""" + self._camera_visible = False + # ------------------------------------------------------------------ # Tab bar button handling # ------------------------------------------------------------------ @@ -329,6 +360,16 @@ def main() -> None: default="https://space.warehack.ing", help="Craft API base URL", ) + parser.add_argument( + "--capture-dir", + default="captures", + help="Output directory for camera captures", + ) + parser.add_argument( + "--camera-device", + default="auto", + help="Camera device (e.g., /dev/video0) or 'auto'", + ) args = parser.parse_args() app = BirdcageApp() @@ -337,6 +378,8 @@ def main() -> None: app.firmware_name = args.firmware app.skip_init = args.skip_init app.craft_url = args.craft_url + app.capture_dir = args.capture_dir + app.camera_device = args.camera_device try: app.run() except KeyboardInterrupt: diff --git a/tui/src/birdcage_tui/camera.py b/tui/src/birdcage_tui/camera.py new file mode 100644 index 0000000..0b0c857 --- /dev/null +++ b/tui/src/birdcage_tui/camera.py @@ -0,0 +1,375 @@ +"""Camera backend abstraction — capture images from USB/CSI cameras. + +Wraps fswebcam/ffmpeg/libcamera-still via subprocess for zero-dependency +operation. DemoCamera generates placeholder frames without hardware. + +No TUI dependency. All methods are blocking — designed for +@work(thread=True) workers in the TUI. +""" + +import glob +import logging +import shutil +import struct +import subprocess +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path + +log = logging.getLogger(__name__) + + +@dataclass +class CameraConfig: + """Camera hardware and capture settings.""" + + device: str = "/dev/video0" + resolution: tuple[int, int] = (1280, 720) + backend_cmd: str = "fswebcam" # fswebcam | ffmpeg | libcamera-still + extra_args: list[str] = field(default_factory=list) + + +@dataclass +class CaptureResult: + """Outcome of a single capture attempt.""" + + path: Path + timestamp: str # ISO-8601 UTC + duration_ms: float + success: bool + error: str = "" + + +class CameraBackend(ABC): + """Abstract camera backend. Subclasses wrap specific capture tools.""" + + @abstractmethod + def capture(self, output_path: Path) -> CaptureResult: + """Capture a single frame to output_path. Blocking.""" + + @abstractmethod + def is_available(self) -> bool: + """Check if the backend tool and device are accessible.""" + + @property + @abstractmethod + def name(self) -> str: + """Human-readable backend identifier.""" + + +class SubprocessCamera(CameraBackend): + """Camera backend wrapping fswebcam/ffmpeg/libcamera-still. + + Each tool is invoked via subprocess.run() with a 10-second timeout. + The caller provides the output path; the backend builds the command. + """ + + _COMMANDS: dict[str, list[str]] = { + "fswebcam": [ + "fswebcam", + "-r", + "{width}x{height}", + "--no-banner", + "-d", + "{device}", + "{output}", + ], + "ffmpeg": [ + "ffmpeg", + "-y", + "-f", + "v4l2", + "-video_size", + "{width}x{height}", + "-i", + "{device}", + "-frames:v", + "1", + "{output}", + ], + "libcamera-still": [ + "libcamera-still", + "--width", + "{width}", + "--height", + "{height}", + "-t", + "1", + "-o", + "{output}", + ], + } + + def __init__(self, config: CameraConfig) -> None: + self._config = config + + def capture(self, output_path: Path) -> CaptureResult: + ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + t0 = time.monotonic() + + template = self._COMMANDS.get(self._config.backend_cmd) + if template is None: + return CaptureResult( + path=output_path, + timestamp=ts, + duration_ms=0.0, + success=False, + error=f"Unknown backend: {self._config.backend_cmd}", + ) + + w, h = self._config.resolution + cmd = [ + part.format( + width=w, + height=h, + device=self._config.device, + output=str(output_path), + ) + for part in template + ] + cmd.extend(self._config.extra_args) + + try: + result = subprocess.run( + cmd, + capture_output=True, + timeout=10, + check=False, + ) + elapsed = (time.monotonic() - t0) * 1000 + if result.returncode != 0: + stderr = result.stderr.decode(errors="replace")[:200] + return CaptureResult( + path=output_path, + timestamp=ts, + duration_ms=elapsed, + success=False, + error=f"Exit {result.returncode}: {stderr}", + ) + return CaptureResult( + path=output_path, + timestamp=ts, + duration_ms=elapsed, + success=True, + ) + except subprocess.TimeoutExpired: + elapsed = (time.monotonic() - t0) * 1000 + return CaptureResult( + path=output_path, + timestamp=ts, + duration_ms=elapsed, + success=False, + error="Capture timed out (10s)", + ) + except FileNotFoundError: + return CaptureResult( + path=output_path, + timestamp=ts, + duration_ms=0.0, + success=False, + error=f"{self._config.backend_cmd} not found", + ) + + def is_available(self) -> bool: + return shutil.which(self._config.backend_cmd) is not None + + @property + def name(self) -> str: + return f"{self._config.backend_cmd} ({self._config.device})" + + +class DemoCamera(CameraBackend): + """Generates a minimal valid JPEG without external dependencies. + + When Pillow is available, renders a placeholder frame with a timestamp + overlay. Otherwise creates a tiny valid JPEG (gray 1x1 pixel). Always + available — used in --demo mode. + """ + + def __init__(self, resolution: tuple[int, int] = (1280, 720)) -> None: + self._resolution = resolution + + def capture(self, output_path: Path) -> CaptureResult: + ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + t0 = time.monotonic() + + try: + self._write_frame(output_path, ts) + elapsed = (time.monotonic() - t0) * 1000 + return CaptureResult( + path=output_path, + timestamp=ts, + duration_ms=elapsed, + success=True, + ) + except Exception as exc: + elapsed = (time.monotonic() - t0) * 1000 + return CaptureResult( + path=output_path, + timestamp=ts, + duration_ms=elapsed, + success=False, + error=str(exc), + ) + + def _write_frame(self, output_path: Path, timestamp: str) -> None: + """Try Pillow first, fall back to minimal JPEG.""" + try: + from PIL import Image, ImageDraw + + w, h = self._resolution + img = Image.new("RGB", (w, h), color=(14, 20, 32)) + draw = ImageDraw.Draw(img) + draw.text((20, 20), "DEMO CAPTURE", fill=(0, 212, 170)) + draw.text((20, 50), timestamp, fill=(200, 208, 216)) + draw.text((20, 80), f"{w}x{h}", fill=(80, 104, 120)) + img.save(output_path, "JPEG", quality=85) + except ImportError: + self._write_minimal_jpeg(output_path) + + def _write_minimal_jpeg(self, output_path: Path) -> None: + """Write a valid 1x1 gray JPEG (smallest possible valid file).""" + # Minimal JFIF: SOI + APP0 + DQT + SOF0 + DHT + SOS + data + EOI + # This is a pre-computed 1x1 gray pixel JPEG. + data = bytes( + [ + 0xFF, + 0xD8, # SOI + 0xFF, + 0xE0, # APP0 (JFIF) + ] + ) + app0_payload = b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + data += struct.pack(">H", len(app0_payload) + 2) + app0_payload + + # Quantization table (all 1s for simplicity) + dqt = bytes([0xFF, 0xDB, 0x00, 0x43, 0x00]) + bytes([1] * 64) + data += dqt + + # SOF0 (baseline, 1x1, 1 component, Y only) + sof0 = bytes( + [ + 0xFF, + 0xC0, + 0x00, + 0x0B, + 0x08, + 0x00, + 0x01, + 0x00, + 0x01, # height=1, width=1 + 0x01, + 0x01, + 0x11, + 0x00, # 1 component, sampling 1x1, quant table 0 + ] + ) + data += sof0 + + # DHT (minimal Huffman table for DC) + dht = bytes( + [ + 0xFF, + 0xC4, + 0x00, + 0x1F, + 0x00, + 0x00, + 0x01, + 0x05, + 0x01, + 0x01, + 0x01, + 0x01, + 0x01, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x02, + 0x03, + 0x04, + 0x05, + 0x06, + 0x07, + 0x08, + 0x09, + 0x0A, + 0x0B, + ] + ) + data += dht + + # SOS + encoded data (gray pixel value 128) + sos = bytes( + [ + 0xFF, + 0xDA, + 0x00, + 0x08, + 0x01, + 0x01, + 0x00, + 0x00, + 0x3F, + 0x00, + 0x7F, + 0x49, # encoded pixel data + ] + ) + data += sos + + # EOI + data += bytes([0xFF, 0xD9]) + + output_path.write_bytes(data) + + def is_available(self) -> bool: + return True + + @property + def name(self) -> str: + return "demo" + + +def detect_cameras() -> list[str]: + """Discover available video capture devices.""" + devices = sorted(glob.glob("/dev/video*")) + return devices + + +def auto_select_backend(config: CameraConfig) -> CameraBackend: + """Pick the best available backend for the given config. + + Tries the configured backend first, then falls through alternatives. + Falls back to DemoCamera if nothing is available. + """ + # Try configured backend first + candidate = SubprocessCamera(config) + if candidate.is_available(): + log.info("Using %s backend", config.backend_cmd) + return candidate + + # Try alternatives in preference order + for alt_cmd in ("fswebcam", "ffmpeg", "libcamera-still"): + if alt_cmd == config.backend_cmd: + continue + alt_config = CameraConfig( + device=config.device, + resolution=config.resolution, + backend_cmd=alt_cmd, + ) + alt = SubprocessCamera(alt_config) + if alt.is_available(): + log.info("Falling back to %s backend", alt_cmd) + return alt + + log.info("No camera backend available, using demo") + return DemoCamera(config.resolution) diff --git a/tui/src/birdcage_tui/capture_manager.py b/tui/src/birdcage_tui/capture_manager.py new file mode 100644 index 0000000..98233bf --- /dev/null +++ b/tui/src/birdcage_tui/capture_manager.py @@ -0,0 +1,286 @@ +"""Capture orchestrator — coordinates camera backend + metadata + output files. + +Thread-safe (internal lock). All methods blocking — designed for +@work(thread=True) workers. Writes JPEG + JSON sidecar always; +optionally adds EXIF (Pillow) and FITS (astropy) when available. +""" + +import json +import logging +import re +import threading +import time +import uuid +from dataclasses import asdict, dataclass, field +from pathlib import Path + +from birdcage_tui.camera import CameraBackend, CaptureResult + +log = logging.getLogger(__name__) + +# Optional dependencies — graceful degradation +try: + from PIL import Image + + HAS_PILLOW = True +except ImportError: + HAS_PILLOW = False + +try: + from astropy.io import fits as astropy_fits + + HAS_ASTROPY = True +except ImportError: + HAS_ASTROPY = False + +# Filename-safe character filter +_SAFE_RE = re.compile(r"[^a-zA-Z0-9_\-]") + + +@dataclass +class CaptureMetadata: + """Everything we know about a capture at the moment the shutter fires.""" + + # Timing + timestamp: str = "" # ISO-8601 UTC + capture_duration_ms: float = 0.0 + + # Mount position (actual dish position at capture time) + mount_az: float = 0.0 + mount_el: float = 0.0 + + # Target info (from active tracking, if any) + target_name: str = "" + target_type: str = "" # satellite, planet, star, comet, "" + target_id: str = "" + target_az: float | None = None + target_el: float | None = None + target_distance_km: float | None = None + target_range_rate: float | None = None + + # Context + tracking_mode: str = "manual" # manual, craft, rotctld + tracking_status: str = "" # TRACKING, WAITING, IDLE + trigger: str = "manual" # manual, interval, aos, tca, los + + # Sequence + sequence_number: int = 0 + session_id: str = "" + + # Camera + camera_backend: str = "" + camera_device: str = "" + resolution: tuple[int, int] = (0, 0) + + # Device + firmware_version: str = "" + + +@dataclass +class CaptureSession: + """Tracks a capture session with sequential numbering.""" + + session_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12]) + start_time: str = field( + default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + ) + capture_count: int = 0 + output_dir: Path = Path("captures") + + def next_sequence(self) -> int: + self.capture_count += 1 + return self.capture_count + + +def _sanitize_name(name: str) -> str: + """Make a target name filesystem-safe.""" + if not name: + return "manual" + cleaned = name.replace(" ", "-") + cleaned = _SAFE_RE.sub("", cleaned) + return cleaned[:40] or "capture" + + +def _build_filename(metadata: CaptureMetadata, ext: str) -> str: + """Generate filename: {target}_{az:06.2f}_{el:05.2f}_{timestamp}.{ext}""" + name = _sanitize_name(metadata.target_name) + az = f"{metadata.mount_az:06.2f}" + el = f"{metadata.mount_el:05.2f}" + # Compact timestamp: 20260216T082500Z + ts = metadata.timestamp.replace("-", "").replace(":", "").replace(" ", "") + return f"{name}_{az}_{el}_{ts}.{ext}" + + +class CaptureManager: + """Orchestrates capture → metadata → output pipeline. + + Usage: + manager = CaptureManager(backend, output_dir=Path("captures")) + result = manager.capture(metadata) # blocking + + Output files per capture: + - JPEG (always) — from camera backend + - JSON sidecar (always) — full metadata + - FITS (when astropy available) — with WCS headers + """ + + def __init__( + self, backend: CameraBackend, output_dir: Path = Path("captures") + ) -> None: + self._backend = backend + self._output_dir = output_dir + self._session = CaptureSession(output_dir=output_dir) + self._lock = threading.Lock() + + def capture(self, metadata: CaptureMetadata) -> CaptureResult: + """Execute a full capture cycle. Blocking, thread-safe.""" + with self._lock: + seq = self._session.next_sequence() + + metadata.sequence_number = seq + metadata.session_id = self._session.session_id + metadata.camera_backend = self._backend.name + + # Date-based subdirectory + date_str = time.strftime("%Y-%m-%d", time.gmtime()) + capture_dir = self._output_dir / date_str + capture_dir.mkdir(parents=True, exist_ok=True) + + # Capture the frame + jpeg_name = _build_filename(metadata, "jpg") + jpeg_path = capture_dir / jpeg_name + + result = self._backend.capture(jpeg_path) + + # Populate timing metadata from result + metadata.timestamp = result.timestamp + metadata.capture_duration_ms = result.duration_ms + + # Always write JSON sidecar + self._write_json_sidecar(capture_dir, metadata) + + if result.success: + # EXIF embedding (optional) + if HAS_PILLOW: + self._add_jpeg_exif(jpeg_path, metadata) + + # FITS output (optional) + if HAS_ASTROPY: + fits_name = _build_filename(metadata, "fits") + fits_path = capture_dir / fits_name + self._write_fits(jpeg_path, fits_path, metadata) + + log.info( + "Capture #%d: %s (%.1fms)", + seq, + jpeg_name, + result.duration_ms, + ) + else: + log.warning("Capture #%d failed: %s", seq, result.error) + + return result + + @property + def session(self) -> CaptureSession: + return self._session + + @property + def has_pillow(self) -> bool: + return HAS_PILLOW + + @property + def has_astropy(self) -> bool: + return HAS_ASTROPY + + def _write_json_sidecar(self, capture_dir: Path, metadata: CaptureMetadata) -> None: + """Write metadata as a JSON sidecar file. Always succeeds or logs.""" + try: + json_name = _build_filename(metadata, "json") + json_path = capture_dir / json_name + data = asdict(metadata) + # Convert tuple to list for JSON serialization + data["resolution"] = list(data["resolution"]) + json_path.write_text(json.dumps(data, indent=2, default=str)) + except Exception: + log.exception("Failed to write JSON sidecar") + + def _add_jpeg_exif(self, jpeg_path: Path, metadata: CaptureMetadata) -> None: + """Embed metadata into JPEG EXIF. Requires Pillow.""" + try: + import piexif + + exif_dict = piexif.load(str(jpeg_path)) + # UserComment field for metadata + comment = json.dumps( + { + "mount_az": metadata.mount_az, + "mount_el": metadata.mount_el, + "target": metadata.target_name, + "trigger": metadata.trigger, + "session": metadata.session_id, + } + ) + exif_dict["Exif"][piexif.ExifIFD.UserComment] = ( + piexif.helper.UserComment.dump(comment, encoding="unicode") + ) + exif_bytes = piexif.dump(exif_dict) + piexif.insert(exif_bytes, str(jpeg_path)) + except Exception: + # piexif not available or EXIF write failed — non-fatal + log.debug("EXIF embedding skipped", exc_info=True) + + def _write_fits( + self, jpeg_path: Path, fits_path: Path, metadata: CaptureMetadata + ) -> None: + """Write FITS file with WCS headers. Requires astropy.""" + try: + if HAS_PILLOW: + img = Image.open(jpeg_path) + import numpy as np + + data = np.array(img) + else: + # Without Pillow, create a minimal FITS with no image data + data = None + + hdu = astropy_fits.PrimaryHDU(data=data) + hdr = hdu.header + + # Standard FITS keywords + hdr["DATE-OBS"] = metadata.timestamp + hdr["OBJECT"] = metadata.target_name or "MANUAL" + hdr["TELESCOP"] = "Birdcage/Winegard" + hdr["INSTRUME"] = metadata.camera_backend + + # WCS — AZ/EL tangent plane projection + hdr["CTYPE1"] = "AZ---TAN" + hdr["CTYPE2"] = "EL---TAN" + hdr["CRVAL1"] = metadata.mount_az + hdr["CRVAL2"] = metadata.mount_el + hdr["CRPIX1"] = metadata.resolution[0] / 2 if metadata.resolution[0] else 1 + hdr["CRPIX2"] = metadata.resolution[1] / 2 if metadata.resolution[1] else 1 + + # Custom headers + hdr["MOUNT-AZ"] = (metadata.mount_az, "Mount azimuth (deg)") + hdr["MOUNT-EL"] = (metadata.mount_el, "Mount elevation (deg)") + hdr["TRIGGER"] = (metadata.trigger, "Capture trigger type") + hdr["SEQNUM"] = (metadata.sequence_number, "Capture sequence number") + hdr["SESSID"] = (metadata.session_id, "Capture session ID") + + if metadata.target_name: + hdr["TGT-NAME"] = (metadata.target_name, "Target name") + hdr["TGT-TYPE"] = (metadata.target_type, "Target type") + hdr["TGT-ID"] = (metadata.target_id, "Target ID") + if metadata.target_distance_km is not None: + hdr["TGT-DIST"] = (metadata.target_distance_km, "Target distance (km)") + if metadata.target_range_rate is not None: + hdr["TGT-RATE"] = ( + metadata.target_range_rate, + "Target range rate (km/s)", + ) + + hdu.writeto(str(fits_path), overwrite=True) + log.debug("FITS written: %s", fits_path.name) + except Exception: + log.exception("Failed to write FITS file") diff --git a/tui/src/birdcage_tui/capture_triggers.py b/tui/src/birdcage_tui/capture_triggers.py new file mode 100644 index 0000000..0388e9c --- /dev/null +++ b/tui/src/birdcage_tui/capture_triggers.py @@ -0,0 +1,212 @@ +"""Capture trigger system — manual, interval, and pass-event triggers. + +Decoupled from TUI — operates on callbacks. The PassEventDetector is a +stateful edge detector: call update() from the tracking loop each iteration, +and it fires callbacks on AOS/TCA/LOS transitions. + +IntervalTimer runs a daemon thread that fires at configurable intervals. +""" + +import logging +import threading +import time +from enum import Enum, auto + +log = logging.getLogger(__name__) + + +class TriggerType(Enum): + """Types of capture triggers.""" + + MANUAL = auto() + INTERVAL = auto() + AOS = auto() # Acquisition of Signal: WAITING/IDLE -> TRACKING + TCA = auto() # Time of Closest Approach: elevation peak during TRACKING + LOS = auto() # Loss of Signal: TRACKING -> WAITING/IDLE + + +class TriggerEvent: + """A trigger event with type, timestamp, and optional detail.""" + + __slots__ = ("trigger_type", "timestamp", "detail") + + def __init__( + self, + trigger_type: TriggerType, + timestamp: str = "", + detail: str = "", + ) -> None: + self.trigger_type = trigger_type + self.timestamp = timestamp or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + self.detail = detail + + +class IntervalTimer: + """Background thread that fires a callback every N seconds. + + Thread-safe start/stop/pause. Daemon thread — doesn't block shutdown. + """ + + def __init__( + self, + callback: "callable", + interval_seconds: float = 10.0, + ) -> None: + self._callback = callback + self._interval = interval_seconds + self._running = False + self._thread: threading.Thread | None = None + self._stop_event = threading.Event() + + def start(self) -> None: + """Start firing at the configured interval.""" + if self._running: + return + self._running = True + self._stop_event.clear() + self._thread = threading.Thread( + target=self._loop, + name="capture-interval", + daemon=True, + ) + self._thread.start() + + def stop(self) -> None: + """Stop the timer. Safe to call multiple times.""" + self._running = False + self._stop_event.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + + def set_interval(self, seconds: float) -> None: + """Update the interval. Takes effect on the next cycle.""" + self._interval = max(1.0, seconds) + + @property + def is_running(self) -> bool: + return self._running + + @property + def interval(self) -> float: + return self._interval + + def _loop(self) -> None: + while self._running and not self._stop_event.is_set(): + self._stop_event.wait(self._interval) + if not self._running: + break + try: + event = TriggerEvent( + trigger_type=TriggerType.INTERVAL, + detail=f"interval={self._interval:.0f}s", + ) + self._callback(event) + except Exception: + log.exception("Interval trigger callback failed") + + +class PassEventDetector: + """Stateful edge detector for satellite pass events. + + Call update() each tracking iteration with the current status, target + name, and elevation. The detector fires callbacks on state transitions: + + - AOS: status transitions from WAITING/IDLE to TRACKING + - LOS: status transitions from TRACKING to WAITING/IDLE + - TCA: during TRACKING, elevation stops ascending and drops by >0.5 deg + from the observed peak (hysteresis prevents jitter false triggers) + + The detector tracks per-target state and resets on target change. + """ + + # Minimum elevation drop from peak before we fire TCA (hysteresis) + TCA_HYSTERESIS_DEG = 0.5 + + def __init__(self, on_event: "callable") -> None: + self._on_event = on_event + self._enabled: set[TriggerType] = set() + self._prev_status: str = "" + self._prev_target: str = "" + self._peak_el: float = -90.0 + self._tca_fired: bool = False + + def enable(self, *trigger_types: TriggerType) -> None: + """Enable one or more trigger types.""" + for tt in trigger_types: + self._enabled.add(tt) + + def disable(self, *trigger_types: TriggerType) -> None: + """Disable one or more trigger types.""" + for tt in trigger_types: + self._enabled.discard(tt) + + def is_enabled(self, trigger_type: TriggerType) -> bool: + return trigger_type in self._enabled + + def update(self, status: str, target_name: str, elevation: float) -> None: + """Feed current tracking state. Call each iteration (~1 Hz).""" + # Reset on target change + if target_name != self._prev_target: + self._peak_el = -90.0 + self._tca_fired = False + self._prev_status = "" + self._prev_target = target_name + + # AOS detection: non-tracking -> TRACKING + if ( + TriggerType.AOS in self._enabled + and status == "TRACKING" + and self._prev_status in ("WAITING", "IDLE", "") + ): + self._fire( + TriggerType.AOS, + f"AOS: {target_name} at EL {elevation:.1f}\u00b0", + ) + self._peak_el = elevation + self._tca_fired = False + + # LOS detection: TRACKING -> non-tracking + if ( + TriggerType.LOS in self._enabled + and self._prev_status == "TRACKING" + and status in ("WAITING", "IDLE") + ): + self._fire( + TriggerType.LOS, + f"LOS: {target_name} at EL {elevation:.1f}\u00b0", + ) + + # TCA detection: elevation peak with hysteresis + if status == "TRACKING": + if elevation > self._peak_el: + self._peak_el = elevation + self._tca_fired = False + elif ( + TriggerType.TCA in self._enabled + and not self._tca_fired + and (self._peak_el - elevation) > self.TCA_HYSTERESIS_DEG + ): + self._fire( + TriggerType.TCA, + f"TCA: {target_name} peak EL {self._peak_el:.1f}\u00b0", + ) + self._tca_fired = True + + self._prev_status = status + + def reset(self) -> None: + """Reset all state. Call when tracking stops.""" + self._prev_status = "" + self._prev_target = "" + self._peak_el = -90.0 + self._tca_fired = False + + def _fire(self, trigger_type: TriggerType, detail: str) -> None: + """Dispatch a trigger event.""" + try: + event = TriggerEvent(trigger_type=trigger_type, detail=detail) + self._on_event(event) + log.info("Pass event: %s — %s", trigger_type.name, detail) + except Exception: + log.exception("Pass event callback failed for %s", trigger_type.name) diff --git a/tui/src/birdcage_tui/screens/camera.py b/tui/src/birdcage_tui/screens/camera.py new file mode 100644 index 0000000..f826967 --- /dev/null +++ b/tui/src/birdcage_tui/screens/camera.py @@ -0,0 +1,426 @@ +"""Camera capture overlay — F6 slide-up modal for image acquisition. + +Follows the ConsoleOverlay pattern: ModalScreen pushed via F6, dismissed +via Escape or F6 again. Pre-installed via install_screen() for persistence. + +Provides manual capture, interval timer, and AOS/TCA/LOS pass-event +triggers. Each capture writes JPEG + JSON sidecar (+ optional FITS). +""" + +import logging +import time + +from textual import work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, Horizontal +from textual.screen import ModalScreen +from textual.widgets import Button, Input, RichLog, Static + +from birdcage_tui.camera import CaptureResult +from birdcage_tui.capture_manager import CaptureManager, CaptureMetadata +from birdcage_tui.capture_triggers import ( + IntervalTimer, + PassEventDetector, + TriggerEvent, + TriggerType, +) + +log = logging.getLogger(__name__) + +# Map trigger types to display colors (Rich markup) +_TRIGGER_COLORS: dict[TriggerType, str] = { + TriggerType.MANUAL: "#00d4aa", + TriggerType.INTERVAL: "#00b8c8", + TriggerType.AOS: "#00e060", + TriggerType.TCA: "#e8c020", + TriggerType.LOS: "#e04040", +} + + +class CaptureStatusPanel(Static): + """Top status bar showing camera state, capture count, and formats.""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._camera_name = "none" + self._capture_count = 0 + self._last_capture = "" + self._formats = "JPEG + JSON" + self._triggers_text = "none" + self._auto_interval = 0.0 + + def update_status( + self, + camera_name: str = "", + capture_count: int = 0, + last_capture: str = "", + formats: str = "", + triggers_text: str = "", + auto_interval: float = 0.0, + ) -> None: + if camera_name: + self._camera_name = camera_name + self._capture_count = capture_count + if last_capture: + self._last_capture = last_capture + if formats: + self._formats = formats + if triggers_text: + self._triggers_text = triggers_text + self._auto_interval = auto_interval + self._refresh_display() + + def _refresh_display(self) -> None: + last = self._last_capture or "---" + auto = f" Auto: {self._auto_interval:.0f}s" if self._auto_interval > 0 else "" + self.update( + f" Camera: {self._camera_name} | " + f"Captures: {self._capture_count} | " + f"Last: {last}\n" + f" Formats: {self._formats} | " + f"Triggers: {self._triggers_text}{auto}" + ) + + +class CameraOverlay(ModalScreen): + """F6: Camera capture overlay as a slide-up panel. + + Slides up from the bottom of the terminal, taking ~45% of the viewport. + Provides manual capture, interval timer, and pass-event trigger controls. + """ + + BINDINGS = [ + Binding("escape", "dismiss_overlay", "Close", priority=True), + Binding("f6", "dismiss_overlay", "Close", priority=True), + Binding("c", "manual_capture", "Capture", priority=True), + ] + + def __init__(self) -> None: + super().__init__() + self._capture_manager: CaptureManager | None = None + self._interval_timer: IntervalTimer | None = None + self._pass_detector: PassEventDetector | None = None + self._auto_running = False + + def compose(self) -> ComposeResult: + with Container(id="camera-overlay"): + yield CaptureStatusPanel(id="capture-status") + yield RichLog(id="capture-log", markup=True, wrap=True) + with Horizontal(classes="camera-controls"): + yield Button("Capture", id="btn-capture", variant="primary") + yield Static("Interval:", classes="label") + yield Input( + value="10", + id="camera-interval-input", + type="integer", + ) + yield Static("s", classes="label") + yield Button("Start", id="btn-auto-start") + yield Button("Stop", id="btn-auto-stop") + with Horizontal(classes="camera-trigger-bar"): + yield Static("Triggers:", classes="label") + yield Button("AOS", id="btn-trigger-aos", classes="trigger-btn") + yield Button("TCA", id="btn-trigger-tca", classes="trigger-btn") + yield Button("LOS", id="btn-trigger-los", classes="trigger-btn") + yield Static(" Cam:", classes="label") + yield Static("---", id="camera-backend-label") + + def on_mount(self) -> None: + """Initialize camera system on first mount.""" + self._setup_camera() + + def on_screen_resume(self) -> None: + """Refresh status when the overlay is re-opened.""" + self._refresh_status() + + def action_dismiss_overlay(self) -> None: + self.dismiss() + + # ------------------------------------------------------------------ + # Camera setup + # ------------------------------------------------------------------ + + def _setup_camera(self) -> None: + """Initialize camera backend and capture manager.""" + from pathlib import Path + + from birdcage_tui.camera import CameraConfig, auto_select_backend + + capture_dir = getattr(self.app, "capture_dir", "captures") + camera_device = getattr(self.app, "camera_device", "auto") + + config = CameraConfig() + if camera_device != "auto": + config.device = camera_device + + # In demo mode, always use the demo camera + is_demo = getattr(self.app, "demo_mode", False) + if is_demo: + from birdcage_tui.camera import DemoCamera + + backend = DemoCamera(config.resolution) + else: + backend = auto_select_backend(config) + + output_dir = Path(capture_dir) + self._capture_manager = CaptureManager(backend, output_dir) + + # Set up interval timer + self._interval_timer = IntervalTimer( + callback=self._on_interval_trigger, + interval_seconds=10.0, + ) + + # Set up pass event detector (shared at app level) + self._pass_detector = getattr(self.app, "_pass_detector", None) + if self._pass_detector is None: + self._pass_detector = PassEventDetector( + on_event=self._on_pass_trigger, + ) + # Store at app level so control.py can feed it + self.app._pass_detector = self._pass_detector + + self._refresh_status() + + # Update backend label + try: + label = self.query_one("#camera-backend-label", Static) + label.update(backend.name) + except Exception: + pass + + # Log welcome + capture_log = self.query_one("#capture-log", RichLog) + capture_log.write(f"[#506878]Camera ready: {backend.name}[/]") + formats = self._format_list() + capture_log.write(f"[#506878]Output: {formats}[/]") + + # ------------------------------------------------------------------ + # Manual capture + # ------------------------------------------------------------------ + + def action_manual_capture(self) -> None: + """Trigger a manual capture via keyboard shortcut.""" + self._do_capture(TriggerType.MANUAL) + + # ------------------------------------------------------------------ + # Capture execution + # ------------------------------------------------------------------ + + @work(thread=True) + def _do_capture(self, trigger_type: TriggerType) -> None: + """Execute a capture in a worker thread.""" + if self._capture_manager is None: + return + + metadata = self._collect_metadata(trigger_type) + result = self._capture_manager.capture(metadata) + self.app.call_from_thread(self._on_capture_complete, result, metadata) + + def _collect_metadata(self, trigger_type: TriggerType) -> CaptureMetadata: + """Snapshot current app state into capture metadata.""" + meta = CaptureMetadata( + mount_az=getattr(self.app, "_current_az", 0.0), + mount_el=getattr(self.app, "_current_el", 0.0), + trigger=trigger_type.name.lower(), + ) + + # Try to get tracking info from the Craft tracking state + try: + control = self.app.query_one("#control") + if hasattr(control, "_craft_state") and control._craft_tracking: + state = control._craft_state + meta.target_name = state.target_name + meta.target_az = state.azimuth + meta.target_el = state.elevation + meta.target_distance_km = state.distance_km + meta.target_range_rate = state.range_rate + meta.tracking_mode = "craft" + meta.tracking_status = state.status + meta.target_type = getattr(control, "_craft_target_type", "") + meta.target_id = getattr(control, "_craft_target_id", "") + except Exception: + pass + + # Camera info + if self._capture_manager: + meta.camera_backend = self._capture_manager._backend.name + mgr = self._capture_manager + meta.resolution = ( + getattr(mgr._backend, "_resolution", (0, 0)) + if hasattr(mgr._backend, "_resolution") + else (0, 0) + ) + + return meta + + def _on_capture_complete( + self, result: CaptureResult, metadata: CaptureMetadata + ) -> None: + """Update UI after capture completes (main thread).""" + capture_log = self.query_one("#capture-log", RichLog) + ts = time.strftime("%H:%M:%S", time.gmtime()) + trigger_name = metadata.trigger.upper() + color = ( + _TRIGGER_COLORS.get(TriggerType[trigger_name], "#506878") + if trigger_name in TriggerType.__members__ + else "#506878" + ) + + if result.success: + name = result.path.name + if len(name) > 45: + name = name[:42] + "..." + capture_log.write( + f"[{color}][{ts}] {trigger_name:<8}[/] " + f"[#c8d0d8]{name}[/] " + f"[#506878]{result.duration_ms:.0f}ms[/]" + ) + else: + capture_log.write( + f"[{color}][{ts}] {trigger_name:<8}[/] " + f"[#e04040]FAILED: {result.error}[/]" + ) + + self._refresh_status() + + # ------------------------------------------------------------------ + # Interval timer + # ------------------------------------------------------------------ + + def _on_interval_trigger(self, event: TriggerEvent) -> None: + """Called from the interval timer thread.""" + self.app.call_from_thread(self._do_capture, TriggerType.INTERVAL) + + def _start_auto(self) -> None: + """Start the interval timer.""" + if self._interval_timer is None or self._auto_running: + return + + try: + interval_input = self.query_one("#camera-interval-input", Input) + seconds = float(interval_input.value) + if seconds < 1: + seconds = 1 + self._interval_timer.set_interval(seconds) + except (ValueError, Exception): + self._interval_timer.set_interval(10.0) + + self._interval_timer.start() + self._auto_running = True + self._refresh_status() + + capture_log = self.query_one("#capture-log", RichLog) + interval = self._interval_timer.interval + capture_log.write(f"[#00b8c8]Auto-capture started ({interval:.0f}s)[/]") + + def _stop_auto(self) -> None: + """Stop the interval timer.""" + if self._interval_timer is None or not self._auto_running: + return + + self._interval_timer.stop() + self._auto_running = False + self._refresh_status() + + capture_log = self.query_one("#capture-log", RichLog) + capture_log.write("[#506878]Auto-capture stopped[/]") + + # ------------------------------------------------------------------ + # Pass event triggers + # ------------------------------------------------------------------ + + def _on_pass_trigger(self, event: TriggerEvent) -> None: + """Called from the tracking loop thread via PassEventDetector.""" + self.app.call_from_thread(self._do_capture, event.trigger_type) + + def _toggle_trigger(self, trigger_type: TriggerType, button: Button) -> None: + """Toggle a pass event trigger on/off.""" + if self._pass_detector is None: + return + + if self._pass_detector.is_enabled(trigger_type): + self._pass_detector.disable(trigger_type) + button.remove_class("active") + else: + self._pass_detector.enable(trigger_type) + button.add_class("active") + + self._refresh_status() + + # ------------------------------------------------------------------ + # Button handlers + # ------------------------------------------------------------------ + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id or "" + + if button_id == "btn-capture": + self._do_capture(TriggerType.MANUAL) + elif button_id == "btn-auto-start": + self._start_auto() + elif button_id == "btn-auto-stop": + self._stop_auto() + elif button_id == "btn-trigger-aos": + self._toggle_trigger(TriggerType.AOS, event.button) + elif button_id == "btn-trigger-tca": + self._toggle_trigger(TriggerType.TCA, event.button) + elif button_id == "btn-trigger-los": + self._toggle_trigger(TriggerType.LOS, event.button) + + # ------------------------------------------------------------------ + # Status helpers + # ------------------------------------------------------------------ + + def _refresh_status(self) -> None: + """Update the status panel with current state.""" + try: + panel = self.query_one("#capture-status", CaptureStatusPanel) + except Exception: + return + + if self._capture_manager is None: + return + + session = self._capture_manager.session + formats = self._format_list() + + triggers = [] + if self._pass_detector: + for tt in (TriggerType.AOS, TriggerType.TCA, TriggerType.LOS): + if self._pass_detector.is_enabled(tt): + triggers.append(tt.name) + triggers_text = " ".join(triggers) if triggers else "none" + + auto_interval = self._interval_timer.interval if self._auto_running else 0.0 + + panel.update_status( + camera_name=( + self._capture_manager._backend.name if self._capture_manager else "none" + ), + capture_count=session.capture_count, + last_capture="", + formats=formats, + triggers_text=triggers_text, + auto_interval=auto_interval, + ) + + def _format_list(self) -> str: + """Build a string describing available output formats.""" + parts = ["JPEG", "JSON"] + if self._capture_manager: + if self._capture_manager.has_pillow: + parts.append("EXIF") + if self._capture_manager.has_astropy: + parts.append("FITS") + return " + ".join(parts) + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + + def on_unmount(self) -> None: + """Stop timers on teardown.""" + if self._interval_timer: + self._interval_timer.stop() diff --git a/tui/src/birdcage_tui/screens/control.py b/tui/src/birdcage_tui/screens/control.py index 5f94c66..f7c8c44 100644 --- a/tui/src/birdcage_tui/screens/control.py +++ b/tui/src/birdcage_tui/screens/control.py @@ -687,6 +687,9 @@ class ControlScreen(Container): state.target_name = name state.error = "" + # Hook into pass event detector for camera triggers (if installed) + pass_detector = getattr(self.app, "_pass_detector", None) + self.app.call_from_thread(self._apply_craft_state, state) while self._craft_tracking and not shutdown.is_set(): @@ -739,6 +742,11 @@ class ControlScreen(Container): state.error = "Motor command failed" self.app.call_from_thread(self._apply_craft_state, state) + + # Feed pass event detector for camera triggers + if pass_detector is not None: + pass_detector.update(state.status, name, state.elevation) + shutdown.wait(1.0) # Clean exit @@ -746,6 +754,11 @@ class ControlScreen(Container): state.error = "" self.app.call_from_thread(self._apply_craft_state, state) + # Signal LOS on tracking stop + if pass_detector is not None: + pass_detector.update("IDLE", name, 0.0) + pass_detector.reset() + def _apply_craft_state(self, state: CraftTrackingState) -> None: try: panel = self.query_one("#ctrl-craft-panel", CraftPanel) diff --git a/tui/src/birdcage_tui/theme.tcss b/tui/src/birdcage_tui/theme.tcss index 99d1237..c229dea 100644 --- a/tui/src/birdcage_tui/theme.tcss +++ b/tui/src/birdcage_tui/theme.tcss @@ -774,6 +774,102 @@ ProgressBar PercentageStatus { margin-right: 1; } +/* ── Camera Overlay (ModalScreen) ─────────────────── */ + +CameraOverlay { + background: rgba(10, 10, 18, 0.6); +} + +#camera-overlay { + dock: bottom; + height: 45%; + width: 100%; + background: #0a0a12; + border-top: double #00b8c8; + padding: 0; +} + +#capture-status { + height: auto; + min-height: 2; + padding: 0 1; + background: #0e1420; + color: #c8d0d8; + border-bottom: solid #1a2a38; +} + +#capture-log { + height: 1fr; +} + +.camera-controls { + dock: bottom; + height: auto; + width: 100%; + layout: horizontal; + padding: 0 1; + background: #0e1420; + border-top: solid #1a2a38; +} + +.camera-controls .label { + width: auto; + padding: 1 1 0 0; +} + +#camera-interval-input { + width: 6; + margin-right: 0; +} + +.camera-controls Button { + margin-right: 1; +} + +.camera-trigger-bar { + dock: bottom; + height: auto; + width: 100%; + layout: horizontal; + padding: 0 1; + background: #0e1420; + border-top: solid #1a2a38; +} + +.camera-trigger-bar .label { + width: auto; + padding: 1 1 0 0; +} + +#camera-backend-label { + width: auto; + padding: 1 1 0 0; + color: #506878; +} + +.trigger-btn { + min-width: 8; + height: 3; + margin: 0 0 0 0; + background: #121c2a; + color: #7090a8; + border: round #1a3050; + text-align: center; +} + +.trigger-btn:hover { + background: #1a2a40; + color: #00b8c8; + border: round #00b8c8; +} + +.trigger-btn.active { + background: #0a2a3a; + color: #00b8c8; + border: round #00b8c8; + text-style: bold; +} + /* ── Scrollbar Styling ─────────────────────────────── */ * { diff --git a/tui/tests/test_camera_backend.py b/tui/tests/test_camera_backend.py new file mode 100644 index 0000000..e9f4939 --- /dev/null +++ b/tui/tests/test_camera_backend.py @@ -0,0 +1,113 @@ +"""Test camera backend abstraction — DemoCamera, SubprocessCamera, detection. + +DemoCamera is always available and produces valid output files. +SubprocessCamera reports unavailable when the tool doesn't exist. +""" + +from pathlib import Path + +import pytest + +from birdcage_tui.camera import ( + CameraConfig, + DemoCamera, + SubprocessCamera, + auto_select_backend, + detect_cameras, +) + + +def test_demo_camera_captures_file(tmp_path: Path): + """DemoCamera should produce a file at the requested path.""" + camera = DemoCamera() + output = tmp_path / "test.jpg" + + result = camera.capture(output) + + assert result.success + assert output.exists() + assert output.stat().st_size > 0 + assert result.timestamp # ISO-8601 UTC string + assert result.duration_ms >= 0 + + +def test_demo_camera_always_available(): + """DemoCamera.is_available() should always return True.""" + camera = DemoCamera() + assert camera.is_available() + + +def test_demo_camera_name(): + """DemoCamera.name should be 'demo'.""" + camera = DemoCamera() + assert camera.name == "demo" + + +def test_demo_camera_valid_jpeg(tmp_path: Path): + """DemoCamera should produce a file starting with JPEG SOI marker.""" + camera = DemoCamera() + output = tmp_path / "test.jpg" + + result = camera.capture(output) + assert result.success + + data = output.read_bytes() + # JPEG files start with SOI marker: FF D8 + assert data[:2] == b"\xff\xd8" + + +def test_demo_camera_custom_resolution(): + """DemoCamera should accept custom resolution.""" + camera = DemoCamera(resolution=(640, 480)) + assert camera._resolution == (640, 480) + + +def test_subprocess_camera_not_available(): + """SubprocessCamera with nonexistent tool should be unavailable.""" + config = CameraConfig(backend_cmd="nonexistent_camera_tool_xyz") + camera = SubprocessCamera(config) + assert not camera.is_available() + + +def test_subprocess_camera_capture_missing_tool(tmp_path: Path): + """Capture with missing tool should return failure.""" + config = CameraConfig(backend_cmd="nonexistent_camera_tool_xyz") + camera = SubprocessCamera(config) + output = tmp_path / "test.jpg" + + result = camera.capture(output) + assert not result.success + assert result.error # Has a meaningful error message + + +def test_subprocess_camera_name(): + """SubprocessCamera.name should include tool and device.""" + config = CameraConfig(backend_cmd="fswebcam", device="/dev/video0") + camera = SubprocessCamera(config) + assert "fswebcam" in camera.name + assert "/dev/video0" in camera.name + + +def test_detect_cameras_returns_list(): + """detect_cameras() should return a list (may be empty in CI).""" + devices = detect_cameras() + assert isinstance(devices, list) + + +def test_auto_select_returns_available_backend(): + """auto_select should return an available backend (demo or real).""" + config = CameraConfig(backend_cmd="nonexistent_tool_xyz") + backend = auto_select_backend(config) + # Should always return something available + assert backend.is_available() + # If no real backends exist, should be demo; otherwise a real one + assert backend.name # Has a name + + +@pytest.mark.parametrize("backend_cmd", ["fswebcam", "ffmpeg", "libcamera-still"]) +def test_subprocess_unknown_backend_fails(tmp_path: Path, backend_cmd: str): + """Unknown backend command pattern should be in the lookup table.""" + config = CameraConfig(backend_cmd=backend_cmd) + SubprocessCamera(config) + # The command template should exist even if the tool isn't installed + assert backend_cmd in SubprocessCamera._COMMANDS diff --git a/tui/tests/test_camera_overlay.py b/tui/tests/test_camera_overlay.py new file mode 100644 index 0000000..4198ff7 --- /dev/null +++ b/tui/tests/test_camera_overlay.py @@ -0,0 +1,273 @@ +"""Test camera overlay — F6 toggle, manual capture, trigger controls. + +Uses the same testing pattern as test_craft_mode.py: +- app.demo_mode = True for DemoDevice +- async with app.run_test() for headless rendering +- post_message() for button events (avoids coordinate issues) + +Note: ModalScreen widgets are on the screen stack, not the app's regular +DOM. Use app.screen to get the active overlay, then query within it. +""" + +import asyncio +from pathlib import Path + +import pytest + +from birdcage_tui.app import BirdcageApp +from birdcage_tui.screens.camera import CameraOverlay, CaptureStatusPanel + + +@pytest.mark.asyncio +async def test_f6_opens_camera_overlay(tmp_path: Path): + """F6 should push the camera overlay.""" + app = BirdcageApp() + app.demo_mode = True + app.capture_dir = str(tmp_path / "captures") + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + assert not app._camera_visible + await pilot.press("f6") + await pilot.pause() + + assert app._camera_visible + # The top screen should be the camera overlay + assert isinstance(app.screen, CameraOverlay) + + +@pytest.mark.asyncio +async def test_f6_closes_camera_overlay(tmp_path: Path): + """F6 again should dismiss the camera overlay.""" + app = BirdcageApp() + app.demo_mode = True + app.capture_dir = str(tmp_path / "captures") + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + await pilot.press("f6") + await pilot.pause() + assert app._camera_visible + + await pilot.press("f6") + await pilot.pause() + assert not app._camera_visible + + +@pytest.mark.asyncio +async def test_escape_closes_camera_overlay(tmp_path: Path): + """Escape should dismiss the camera overlay.""" + app = BirdcageApp() + app.demo_mode = True + app.capture_dir = str(tmp_path / "captures") + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + await pilot.press("f6") + await pilot.pause() + assert app._camera_visible + + await pilot.press("escape") + await pilot.pause() + assert not app._camera_visible + + +@pytest.mark.asyncio +async def test_manual_capture_creates_file(tmp_path: Path): + """Pressing 'c' should create a capture file.""" + app = BirdcageApp() + app.demo_mode = True + app.capture_dir = str(tmp_path / "captures") + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + await pilot.press("f6") + await pilot.pause() + + # Trigger manual capture + await pilot.press("c") + await asyncio.sleep(0.5) + + # Check captures directory + capture_dir = tmp_path / "captures" + jpg_files = list(capture_dir.rglob("*.jpg")) + json_files = list(capture_dir.rglob("*.json")) + + assert len(jpg_files) >= 1 + assert len(json_files) >= 1 + + +@pytest.mark.asyncio +async def test_capture_button(tmp_path: Path): + """Capture button should trigger a capture.""" + app = BirdcageApp() + app.demo_mode = True + app.capture_dir = str(tmp_path / "captures") + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + await pilot.press("f6") + await pilot.pause() + + # Get the overlay from the screen stack + overlay = app.screen + assert isinstance(overlay, CameraOverlay) + + from textual.widgets import Button + + btn = overlay.query_one("#btn-capture", Button) + btn.post_message(Button.Pressed(btn)) + await asyncio.sleep(0.5) + + capture_dir = tmp_path / "captures" + jpg_files = list(capture_dir.rglob("*.jpg")) + assert len(jpg_files) >= 1 + + +@pytest.mark.asyncio +async def test_capture_log_updates(tmp_path: Path): + """Capture log should show entries after captures.""" + app = BirdcageApp() + app.demo_mode = True + app.capture_dir = str(tmp_path / "captures") + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + await pilot.press("f6") + await pilot.pause() + + # Two captures + await pilot.press("c") + await asyncio.sleep(0.3) + await pilot.press("c") + await asyncio.sleep(0.3) + + # Status panel should show count + overlay = app.screen + assert isinstance(overlay, CameraOverlay) + panel = overlay.query_one("#capture-status", CaptureStatusPanel) + assert panel._capture_count >= 2 + + +@pytest.mark.asyncio +async def test_trigger_toggles(tmp_path: Path): + """AOS/TCA/LOS trigger buttons should toggle active state.""" + app = BirdcageApp() + app.demo_mode = True + app.capture_dir = str(tmp_path / "captures") + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + await pilot.press("f6") + await pilot.pause() + + overlay = app.screen + assert isinstance(overlay, CameraOverlay) + + from textual.widgets import Button + + from birdcage_tui.capture_triggers import TriggerType + + # Toggle AOS on + aos_btn = overlay.query_one("#btn-trigger-aos", Button) + aos_btn.post_message(Button.Pressed(aos_btn)) + await pilot.pause() + assert overlay._pass_detector.is_enabled(TriggerType.AOS) + + # Toggle AOS off + aos_btn.post_message(Button.Pressed(aos_btn)) + await pilot.pause() + assert not overlay._pass_detector.is_enabled(TriggerType.AOS) + + +@pytest.mark.asyncio +async def test_auto_interval_start_stop(tmp_path: Path): + """Auto interval start/stop should control the timer.""" + app = BirdcageApp() + app.demo_mode = True + app.capture_dir = str(tmp_path / "captures") + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + await pilot.press("f6") + await pilot.pause() + + overlay = app.screen + assert isinstance(overlay, CameraOverlay) + + from textual.widgets import Button + + # Start auto + start_btn = overlay.query_one("#btn-auto-start", Button) + start_btn.post_message(Button.Pressed(start_btn)) + await pilot.pause() + assert overlay._auto_running + + # Stop auto + stop_btn = overlay.query_one("#btn-auto-stop", Button) + stop_btn.post_message(Button.Pressed(stop_btn)) + await pilot.pause() + assert not overlay._auto_running + + +@pytest.mark.asyncio +async def test_camera_overlay_with_console(tmp_path: Path): + """F5 and F6 should open independent overlays.""" + app = BirdcageApp() + app.demo_mode = True + app.capture_dir = str(tmp_path / "captures") + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + # Open camera first + await pilot.press("f6") + await pilot.pause() + assert app._camera_visible + assert not app._console_visible + + # Close camera + await pilot.press("f6") + await pilot.pause() + assert not app._camera_visible + + # Open console + await pilot.press("f5") + await pilot.pause() + assert app._console_visible + assert not app._camera_visible + + # Close console + await pilot.press("f5") + await pilot.pause() + assert not app._console_visible + + +@pytest.mark.asyncio +async def test_status_panel_shows_formats(tmp_path: Path): + """Status panel should list available output formats.""" + app = BirdcageApp() + app.demo_mode = True + app.capture_dir = str(tmp_path / "captures") + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + await pilot.press("f6") + await pilot.pause() + + overlay = app.screen + assert isinstance(overlay, CameraOverlay) + + panel = overlay.query_one("#capture-status", CaptureStatusPanel) + # Should always have JPEG + JSON at minimum + assert "JPEG" in panel._formats + assert "JSON" in panel._formats diff --git a/tui/tests/test_capture_manager.py b/tui/tests/test_capture_manager.py new file mode 100644 index 0000000..cc1d243 --- /dev/null +++ b/tui/tests/test_capture_manager.py @@ -0,0 +1,171 @@ +"""Test capture manager — file naming, JSON sidecars, session tracking. + +All tests use DemoCamera for zero-hardware-dependency captures. +""" + +import json +from pathlib import Path + +import pytest + +from birdcage_tui.camera import DemoCamera +from birdcage_tui.capture_manager import ( + CaptureManager, + CaptureMetadata, + CaptureSession, + _build_filename, + _sanitize_name, +) + + +@pytest.fixture +def manager(tmp_path: Path) -> CaptureManager: + """CaptureManager with DemoCamera in a temp directory.""" + backend = DemoCamera() + return CaptureManager(backend, output_dir=tmp_path) + + +@pytest.fixture +def tracking_metadata() -> CaptureMetadata: + """Metadata simulating an active tracking capture.""" + return CaptureMetadata( + mount_az=245.30, + mount_el=34.20, + target_name="ISS (ZARYA)", + target_type="satellite", + target_id="25544", + target_az=245.30, + target_el=34.20, + target_distance_km=420.0, + target_range_rate=-2.1, + tracking_mode="craft", + tracking_status="TRACKING", + trigger="manual", + ) + + +def test_capture_writes_jpeg(manager: CaptureManager, tracking_metadata): + """Capture should produce a JPEG file.""" + result = manager.capture(tracking_metadata) + assert result.success + assert result.path.exists() + assert result.path.suffix == ".jpg" + + +def test_capture_writes_json_sidecar(manager: CaptureManager, tracking_metadata): + """JSON sidecar should always be written alongside JPEG.""" + result = manager.capture(tracking_metadata) + assert result.success + + # Find the JSON sidecar + json_files = list(result.path.parent.glob("*.json")) + assert len(json_files) == 1 + + data = json.loads(json_files[0].read_text()) + assert data["mount_az"] == 245.30 + assert data["mount_el"] == 34.20 + assert data["target_name"] == "ISS (ZARYA)" + assert data["trigger"] == "manual" + + +def test_filename_generation(): + """Filenames should follow the naming convention.""" + meta = CaptureMetadata( + mount_az=245.30, + mount_el=34.20, + target_name="ISS (ZARYA)", + timestamp="2026-02-16T08:25:00Z", + ) + name = _build_filename(meta, "jpg") + assert name.startswith("ISS-ZARYA_") + assert "245.30" in name + assert "34.20" in name + assert name.endswith(".jpg") + + +def test_filename_sanitization(): + """Special characters should be stripped from target names.""" + assert _sanitize_name("ISS (ZARYA)") == "ISS-ZARYA" + assert _sanitize_name("NOAA 19") == "NOAA-19" + assert _sanitize_name("") == "manual" + assert _sanitize_name("a/b\\c:d") == "abcd" + + +def test_filename_no_target(): + """Without a target name, filename should use 'manual'.""" + meta = CaptureMetadata( + mount_az=180.0, + mount_el=45.0, + timestamp="2026-02-16T12:00:00Z", + ) + name = _build_filename(meta, "jpg") + assert name.startswith("manual_") + + +def test_session_counter(): + """Session sequence should increment monotonically.""" + session = CaptureSession() + assert session.next_sequence() == 1 + assert session.next_sequence() == 2 + assert session.next_sequence() == 3 + assert session.capture_count == 3 + + +def test_session_id_unique(): + """Each session should have a unique ID.""" + s1 = CaptureSession() + s2 = CaptureSession() + assert s1.session_id != s2.session_id + + +def test_subdirectory_creation(manager: CaptureManager, tracking_metadata): + """Captures should go into date-based subdirectories.""" + result = manager.capture(tracking_metadata) + assert result.success + # The parent should be a date directory like "2026-02-16" + parent = result.path.parent.name + assert len(parent) == 10 # YYYY-MM-DD + assert parent.count("-") == 2 + + +def test_multiple_captures_increment(manager: CaptureManager, tracking_metadata): + """Multiple captures should increment the session counter.""" + r1 = manager.capture(tracking_metadata) + r2 = manager.capture(tracking_metadata) + assert r1.success and r2.success + assert manager.session.capture_count == 2 + # Filenames should differ + assert r1.path.name != r2.path.name + + +def test_pillow_fallback(manager: CaptureManager): + """Manager should report Pillow status without crashing.""" + # This test just verifies the property doesn't throw + _ = manager.has_pillow # True if installed, False if not + + +def test_astropy_fallback(manager: CaptureManager): + """Manager should report astropy status without crashing.""" + _ = manager.has_astropy # True if installed, False if not + + +def test_metadata_session_populated(manager: CaptureManager, tracking_metadata): + """After capture, metadata should have session info populated.""" + manager.capture(tracking_metadata) + assert tracking_metadata.session_id == manager.session.session_id + assert tracking_metadata.sequence_number == 1 + + +def test_manual_capture_no_target(manager: CaptureManager): + """Capture without tracking should work with minimal metadata.""" + meta = CaptureMetadata( + mount_az=180.0, + mount_el=45.0, + trigger="manual", + ) + result = manager.capture(meta) + assert result.success + + # JSON sidecar should exist + json_files = list(result.path.parent.glob("*.json")) + assert len(json_files) >= 1 diff --git a/tui/tests/test_capture_triggers.py b/tui/tests/test_capture_triggers.py new file mode 100644 index 0000000..b75d423 --- /dev/null +++ b/tui/tests/test_capture_triggers.py @@ -0,0 +1,283 @@ +"""Test capture triggers — interval timer and pass event detection. + +IntervalTimer fires at configurable intervals. +PassEventDetector detects AOS/TCA/LOS transitions with hysteresis. +""" + +import threading +import time + +from birdcage_tui.capture_triggers import ( + IntervalTimer, + PassEventDetector, + TriggerEvent, + TriggerType, +) + + +class _EventCollector: + """Collects trigger events for test assertions.""" + + def __init__(self): + self.events: list[TriggerEvent] = [] + self._lock = threading.Lock() + + def __call__(self, event: TriggerEvent) -> None: + with self._lock: + self.events.append(event) + + @property + def count(self) -> int: + with self._lock: + return len(self.events) + + def types(self) -> list[TriggerType]: + with self._lock: + return [e.trigger_type for e in self.events] + + +# ------------------------------------------------------------------ +# IntervalTimer tests +# ------------------------------------------------------------------ + + +def test_interval_timer_fires(): + """Timer should fire at least once within 2x the interval.""" + collector = _EventCollector() + timer = IntervalTimer(callback=collector, interval_seconds=0.2) + timer.start() + time.sleep(0.5) + timer.stop() + + assert collector.count >= 1 + assert all(e.trigger_type == TriggerType.INTERVAL for e in collector.events) + + +def test_interval_timer_stop(): + """Timer should stop cleanly and not fire after stop().""" + collector = _EventCollector() + timer = IntervalTimer(callback=collector, interval_seconds=0.1) + timer.start() + time.sleep(0.25) + timer.stop() + + count_at_stop = collector.count + time.sleep(0.3) + assert collector.count == count_at_stop # No new events + + +def test_interval_timer_set_interval(): + """set_interval should update the interval property.""" + collector = _EventCollector() + timer = IntervalTimer(callback=collector, interval_seconds=10.0) + assert timer.interval == 10.0 + + timer.set_interval(5.0) + assert timer.interval == 5.0 + + # Minimum clamp + timer.set_interval(0.5) + assert timer.interval >= 0.5 + + +def test_interval_timer_double_start(): + """Starting twice should be safe (no duplicate threads).""" + collector = _EventCollector() + timer = IntervalTimer(callback=collector, interval_seconds=0.2) + timer.start() + timer.start() # Should be no-op + time.sleep(0.5) + timer.stop() + + assert collector.count >= 1 + + +def test_interval_timer_is_running(): + """is_running property should reflect timer state.""" + collector = _EventCollector() + timer = IntervalTimer(callback=collector, interval_seconds=1.0) + assert not timer.is_running + + timer.start() + assert timer.is_running + + timer.stop() + assert not timer.is_running + + +# ------------------------------------------------------------------ +# PassEventDetector tests +# ------------------------------------------------------------------ + + +def test_pass_detector_aos(): + """WAITING -> TRACKING should fire AOS.""" + collector = _EventCollector() + detector = PassEventDetector(on_event=collector) + detector.enable(TriggerType.AOS) + + detector.update("WAITING", "ISS", 0.0) + detector.update("TRACKING", "ISS", 20.0) + + assert collector.count == 1 + assert collector.events[0].trigger_type == TriggerType.AOS + assert "ISS" in collector.events[0].detail + + +def test_pass_detector_los(): + """TRACKING -> WAITING should fire LOS.""" + collector = _EventCollector() + detector = PassEventDetector(on_event=collector) + detector.enable(TriggerType.LOS) + + detector.update("TRACKING", "ISS", 45.0) + detector.update("WAITING", "ISS", 5.0) + + assert collector.count == 1 + assert collector.events[0].trigger_type == TriggerType.LOS + + +def test_pass_detector_tca(): + """Elevation peak detection with hysteresis.""" + collector = _EventCollector() + detector = PassEventDetector(on_event=collector) + detector.enable(TriggerType.TCA) + + # Simulate ascending pass + detector.update("TRACKING", "ISS", 20.0) + detector.update("TRACKING", "ISS", 30.0) + detector.update("TRACKING", "ISS", 40.0) + detector.update("TRACKING", "ISS", 45.0) # Peak + detector.update("TRACKING", "ISS", 44.8) # Small jitter, no trigger + detector.update("TRACKING", "ISS", 44.4) # Below threshold + detector.update("TRACKING", "ISS", 43.0) # Well below peak + + assert collector.count == 1 + assert collector.events[0].trigger_type == TriggerType.TCA + assert "45.0" in collector.events[0].detail + + +def test_pass_detector_hysteresis(): + """Small jitter around peak should NOT trigger TCA.""" + collector = _EventCollector() + detector = PassEventDetector(on_event=collector) + detector.enable(TriggerType.TCA) + + # Simulate noisy plateau at peak + detector.update("TRACKING", "ISS", 44.8) + detector.update("TRACKING", "ISS", 45.0) + detector.update("TRACKING", "ISS", 44.9) # -0.1 from peak + detector.update("TRACKING", "ISS", 44.8) # -0.2 from peak + detector.update("TRACKING", "ISS", 44.7) # -0.3 from peak + + # Still within hysteresis threshold (0.5 deg) + assert collector.count == 0 + + +def test_pass_detector_tca_fires_once(): + """TCA should fire only once per pass.""" + collector = _EventCollector() + detector = PassEventDetector(on_event=collector) + detector.enable(TriggerType.TCA) + + detector.update("TRACKING", "ISS", 45.0) # Peak + detector.update("TRACKING", "ISS", 44.0) # -1.0, triggers TCA + detector.update("TRACKING", "ISS", 43.0) # Should NOT trigger again + detector.update("TRACKING", "ISS", 42.0) + + assert collector.count == 1 + + +def test_pass_detector_full_pass(): + """Full pass should generate AOS + TCA + LOS in order.""" + collector = _EventCollector() + detector = PassEventDetector(on_event=collector) + detector.enable(TriggerType.AOS, TriggerType.TCA, TriggerType.LOS) + + # Pre-pass + detector.update("WAITING", "ISS", 0.0) + + # AOS + detector.update("TRACKING", "ISS", 5.0) + + # Ascending + detector.update("TRACKING", "ISS", 20.0) + detector.update("TRACKING", "ISS", 35.0) + detector.update("TRACKING", "ISS", 45.0) # Peak + + # Descending (triggers TCA) + detector.update("TRACKING", "ISS", 44.0) + detector.update("TRACKING", "ISS", 30.0) + detector.update("TRACKING", "ISS", 10.0) + + # LOS + detector.update("WAITING", "ISS", 2.0) + + types = collector.types() + assert types == [TriggerType.AOS, TriggerType.TCA, TriggerType.LOS] + + +def test_pass_detector_disabled_triggers(): + """Disabled triggers should not fire.""" + collector = _EventCollector() + detector = PassEventDetector(on_event=collector) + # Enable only AOS, not TCA or LOS + detector.enable(TriggerType.AOS) + + detector.update("WAITING", "ISS", 0.0) + detector.update("TRACKING", "ISS", 45.0) + detector.update("TRACKING", "ISS", 30.0) + detector.update("WAITING", "ISS", 0.0) + + # Only AOS should fire + assert collector.count == 1 + assert collector.events[0].trigger_type == TriggerType.AOS + + +def test_pass_detector_target_change_resets(): + """Changing target should reset peak tracking.""" + collector = _EventCollector() + detector = PassEventDetector(on_event=collector) + detector.enable(TriggerType.TCA) + + # First target peaks at 45 + detector.update("TRACKING", "ISS", 45.0) + + # Switch to new target — peak resets + detector.update("TRACKING", "NOAA 19", 30.0) + detector.update("TRACKING", "NOAA 19", 35.0) # New peak + detector.update("TRACKING", "NOAA 19", 34.0) # -1.0, triggers TCA + + assert collector.count == 1 + assert "NOAA 19" in collector.events[0].detail + + +def test_pass_detector_reset(): + """reset() should clear all internal state.""" + collector = _EventCollector() + detector = PassEventDetector(on_event=collector) + detector.enable(TriggerType.AOS) + + detector.update("TRACKING", "ISS", 45.0) + detector.reset() + + # After reset, a new TRACKING should fire AOS again + detector.update("WAITING", "ISS", 0.0) + detector.update("TRACKING", "ISS", 20.0) + + assert collector.count == 2 # Initial + after reset + + +def test_pass_detector_enable_disable(): + """enable/disable should toggle trigger states.""" + collector = _EventCollector() + detector = PassEventDetector(on_event=collector) + + detector.enable(TriggerType.AOS, TriggerType.LOS) + assert detector.is_enabled(TriggerType.AOS) + assert detector.is_enabled(TriggerType.LOS) + assert not detector.is_enabled(TriggerType.TCA) + + detector.disable(TriggerType.AOS) + assert not detector.is_enabled(TriggerType.AOS) + assert detector.is_enabled(TriggerType.LOS) diff --git a/tui/uv.lock b/tui/uv.lock index d6918f4..07782cb 100644 --- a/tui/uv.lock +++ b/tui/uv.lock @@ -2,6 +2,38 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "astropy" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astropy-iers-data" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyerfa" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/92/2dce2d48347efc3346d08ca7995b152d242ebd170c571f7c9346468d8427/astropy-7.2.0.tar.gz", hash = "sha256:ae48bc26b1feaeb603cd94bd1fa1aa39137a115fe931b7f13787ab420e8c3070", size = 7057774, upload-time = "2025-11-25T22:36:41.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/6d/6330a844bad8dfc4875e0f2fa1db1fee87837ba9805aa8a8d048c071363a/astropy-7.2.0-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:efac04df4cc488efe630c2fff1992d6516dfb16a06e197fb68bc9e8e3b85def1", size = 6442332, upload-time = "2025-11-25T22:36:23.6Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ba/3418133ba144dfcd1530bca5a6b695f4cdd21a8abaaa2ac4e5450d11b028/astropy-7.2.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:52e9a7d9c86b21f1af911a2930cd0c4a275fb302d455c89e11eedaffef6f2ad0", size = 6413656, upload-time = "2025-11-25T22:36:26.548Z" }, + { url = "https://files.pythonhosted.org/packages/be/ba/05e43b5a7d738316a097fa78524d3eaaff5986294b4a052d4adb3c45e7c0/astropy-7.2.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97c370421b9bb13d4c762c7af06d172bad7c01bd5bcf88314f6913c3c235b770", size = 9758867, upload-time = "2025-11-25T22:36:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/f06ad85180e7dd9855aa5ede901bfc2be858d7bee17d4e978a14c0ecec14/astropy-7.2.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f39ce2c80211fbceb005d377a5478cd0d66c42aa1498d252f2239fe5a025c24", size = 9789007, upload-time = "2025-11-25T22:36:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fb/e4d35194a5009d7a73333079481a4ef1380a255d67b9c1db578151a5fb50/astropy-7.2.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ad4d71db994d45f046a1a5449000cf0f88ab6367cb67658500654a0586d6ab19", size = 9748547, upload-time = "2025-11-25T22:36:33.154Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/f990730978ae0a7a34705f885d2f3806928c5f0bc22eefd6a1a23539cc32/astropy-7.2.0-cp311-abi3-win32.whl", hash = "sha256:95161f26602433176483e8bde8ab1a8ca09148f5b4bf5190569a26d381091598", size = 6237228, upload-time = "2025-11-25T22:36:35.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bc/f4378f586dd63902c37d16f68f35f7d555b3b32e08ac6b1d633eb0a48805/astropy-7.2.0-cp311-abi3-win_amd64.whl", hash = "sha256:dc7c340ba1713e55c93071b32033f3153470a0f663a4d539c03a7c9b44020790", size = 6362868, upload-time = "2025-11-25T22:36:37.784Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/b6d4bf01913cfd4ce0cd4c1be5916beccdb92b2970bab8c827984231eae6/astropy-7.2.0-cp311-abi3-win_arm64.whl", hash = "sha256:0c428735a3f15b05c2095bc6ccb5f98a64bc99fb7015866af19ff8492420ddaf", size = 6221756, upload-time = "2025-11-25T22:36:39.852Z" }, +] + +[[package]] +name = "astropy-iers-data" +version = "0.2026.2.16.0.48.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/ce/9aed8765e4dcb2e12afd545c7ce63f2b9bcad592afcb9a0d5437d872060d/astropy_iers_data-0.2026.2.16.0.48.25.tar.gz", hash = "sha256:be14512844e71536a15e165d729385f3cb4865d7822172509e68c4ac79322067", size = 1926145, upload-time = "2026-02-16T00:49:11.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/94/dcdac330d2c26776956555610ef584147584acc6b060e451aa65dd9142d9/astropy_iers_data-0.2026.2.16.0.48.25-py3-none-any.whl", hash = "sha256:180d1c3f59d18aa616345560799c2d88ec6e5164b8c45c746380acf892946136", size = 1982562, upload-time = "2026-02-16T00:49:09.602Z" }, +] + [[package]] name = "birdcage" version = "2026.2.12.1" @@ -26,6 +58,12 @@ dependencies = [ { name = "textual" }, ] +[package.optional-dependencies] +camera = [ + { name = "astropy" }, + { name = "pillow" }, +] + [package.dev-dependencies] dev = [ { name = "pytest" }, @@ -34,9 +72,12 @@ dev = [ [package.metadata] requires-dist = [ + { name = "astropy", marker = "extra == 'camera'", specifier = ">=6.0" }, { name = "birdcage", editable = "../" }, + { name = "pillow", marker = "extra == 'camera'", specifier = ">=10.0" }, { name = "textual", specifier = ">=1.0.0" }, ] +provides-extras = ["camera"] [package.metadata.requires-dev] dev = [ @@ -124,6 +165,85 @@ 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 = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, + { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, + { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, + { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, + { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -133,6 +253,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + [[package]] name = "platformdirs" version = "4.7.0" @@ -151,6 +358,24 @@ 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 = "pyerfa" +version = "2.0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/39/63cc8291b0cf324ae710df41527faf7d331bce573899199d926b3e492260/pyerfa-2.0.1.5.tar.gz", hash = "sha256:17d6b24fe4846c65d5e7d8c362dcb08199dc63b30a236aedd73875cc83e1f6c0", size = 818430, upload-time = "2024-11-11T15:22:30.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/d9/3448a57cb5bd19950de6d6ab08bd8fbb3df60baa71726de91d73d76c481b/pyerfa-2.0.1.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b282d7c60c4c47cf629c484c17ac504fcb04abd7b3f4dfcf53ee042afc3a5944", size = 341818, upload-time = "2024-11-11T15:22:16.467Z" }, + { url = "https://files.pythonhosted.org/packages/11/4a/31a363370478b63c6289a34743f2ba2d3ae1bd8223e004d18ab28fb92385/pyerfa-2.0.1.5-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:be1aeb70390dd03a34faf96749d5cabc58437410b4aab7213c512323932427df", size = 329370, upload-time = "2024-11-11T15:22:17.829Z" }, + { url = "https://files.pythonhosted.org/packages/cb/96/b6210fc624123c8ae13e1eecb68fb75e3f3adff216d95eee1c7b05843e3e/pyerfa-2.0.1.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0603e8e1b839327d586c8a627cdc634b795e18b007d84f0cda5500a0908254e", size = 692794, upload-time = "2024-11-11T15:22:19.429Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e0/050018d855d26d3c0b4a7d1b2ed692be758ce276d8289e2a2b44ba1014a5/pyerfa-2.0.1.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e43c7194e3242083f2350b46c09fd4bf8ba1bcc0ebd1460b98fc47fe2389906", size = 738711, upload-time = "2024-11-11T15:22:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f5/ff91ee77308793ae32fa1e1de95e9edd4551456dd888b4e87c5938657ca5/pyerfa-2.0.1.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:07b80cd70701f5d066b1ac8cce406682cfcd667a1186ec7d7ade597239a6021d", size = 722966, upload-time = "2024-11-11T15:22:21.905Z" }, + { url = "https://files.pythonhosted.org/packages/2c/56/b22b35c8551d2228ff8d445e63787112927ca13f6dc9e2c04f69d742c95b/pyerfa-2.0.1.5-cp39-abi3-win32.whl", hash = "sha256:d30b9b0df588ed5467e529d851ea324a67239096dd44703125072fd11b351ea2", size = 339955, upload-time = "2024-11-11T15:22:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/b4/11/97233cf23ad5411ac6f13b1d6ee3888f90ace4f974d9bf9db887aa428912/pyerfa-2.0.1.5-cp39-abi3-win_amd64.whl", hash = "sha256:66292d437dcf75925b694977aa06eb697126e7b86553e620371ed3e48b5e0ad0", size = 349410, upload-time = "2024-11-11T15:22:24.817Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -198,6 +423,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "rich" version = "14.3.2"