Add Camera Capture overlay (F6) with multi-trigger capture pipeline

Camera backend abstraction wrapping fswebcam/ffmpeg/libcamera-still via
subprocess, with DemoCamera fallback generating valid JPEG from raw bytes.

Capture pipeline writes JPEG + JSON sidecar always, optional FITS (astropy)
and EXIF (Pillow) when available. Thread-safe orchestrator with session
tracking, sequence numbering, and date-based output directories.

Trigger system: manual capture, configurable interval timer, and pass event
detection (AOS/TCA/LOS) with 0.5-degree TCA hysteresis. PassEventDetector
runs in the Craft tracking loop, fires callbacks to the camera overlay.

F6 overlay follows the F5 ConsoleOverlay pattern — ModalScreen with
install_screen persistence. Status panel, scrollable capture log, interval
controls, and AOS/TCA/LOS toggle buttons.

Tagline: "a generic AZ/EL positioner that doesn't care about wavelength"
added to TUI header subtitle.

51 new tests (77 total passing).
This commit is contained in:
Ryan Malloy 2026-02-16 05:08:18 -07:00
parent 6c1e9da773
commit 7035d814a1
13 changed files with 2576 additions and 2 deletions

View File

@ -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"

View File

@ -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:

View File

@ -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)

View File

@ -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")

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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 ─────────────────────────────── */
* {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

280
tui/uv.lock generated
View File

@ -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"