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:
parent
6c1e9da773
commit
7035d814a1
@ -14,6 +14,9 @@ dependencies = [
|
|||||||
"textual>=1.0.0",
|
"textual>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
camera = ["Pillow>=10.0", "astropy>=6.0"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
birdcage-tui = "birdcage_tui.app:main"
|
birdcage-tui = "birdcage_tui.app:main"
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ from textual.binding import Binding
|
|||||||
from textual.containers import Horizontal
|
from textual.containers import Horizontal
|
||||||
from textual.widgets import Button, ContentSwitcher, Footer, Header
|
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.console import ConsoleOverlay
|
||||||
from birdcage_tui.screens.control import ControlScreen
|
from birdcage_tui.screens.control import ControlScreen
|
||||||
from birdcage_tui.screens.dashboard import DashboardScreen
|
from birdcage_tui.screens.dashboard import DashboardScreen
|
||||||
@ -45,6 +46,7 @@ class BirdcageApp(App):
|
|||||||
Binding("f3", "switch_tab('signal')", "Signal"),
|
Binding("f3", "switch_tab('signal')", "Signal"),
|
||||||
Binding("f4", "switch_tab('system')", "System"),
|
Binding("f4", "switch_tab('system')", "System"),
|
||||||
Binding("f5", "toggle_console", "Console"),
|
Binding("f5", "toggle_console", "Console"),
|
||||||
|
Binding("f6", "toggle_camera", "Camera"),
|
||||||
Binding("q", "quit", "Quit"),
|
Binding("q", "quit", "Quit"),
|
||||||
Binding("d", "toggle_dark", "Dark"),
|
Binding("d", "toggle_dark", "Dark"),
|
||||||
]
|
]
|
||||||
@ -55,6 +57,8 @@ class BirdcageApp(App):
|
|||||||
firmware_name: str = "g2"
|
firmware_name: str = "g2"
|
||||||
skip_init: bool = False
|
skip_init: bool = False
|
||||||
craft_url: str = "https://space.warehack.ing"
|
craft_url: str = "https://space.warehack.ing"
|
||||||
|
capture_dir: str = "captures"
|
||||||
|
camera_device: str = "auto"
|
||||||
device: object = None
|
device: object = None
|
||||||
shutdown_event: threading.Event = threading.Event()
|
shutdown_event: threading.Event = threading.Event()
|
||||||
|
|
||||||
@ -64,12 +68,15 @@ class BirdcageApp(App):
|
|||||||
_prev_az: float = 0.0
|
_prev_az: float = 0.0
|
||||||
_prev_el: float = 0.0
|
_prev_el: float = 0.0
|
||||||
_console_visible: bool = False
|
_console_visible: bool = False
|
||||||
|
_camera_visible: bool = False
|
||||||
|
_pass_detector: object = None # PassEventDetector, set by CameraOverlay
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def SUB_TITLE(self) -> str: # noqa: N802
|
def SUB_TITLE(self) -> str: # noqa: N802
|
||||||
|
tag = "a generic AZ/EL positioner that doesn't care about wavelength"
|
||||||
if self.demo_mode:
|
if self.demo_mode:
|
||||||
return "DEMO"
|
return f"{tag} · DEMO"
|
||||||
return self.serial_port
|
return f"{tag} · {self.serial_port}"
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header()
|
yield Header()
|
||||||
@ -114,6 +121,7 @@ class BirdcageApp(App):
|
|||||||
self._setup_craft_client()
|
self._setup_craft_client()
|
||||||
self._update_status_strip_connection()
|
self._update_status_strip_connection()
|
||||||
self._install_console()
|
self._install_console()
|
||||||
|
self._install_camera()
|
||||||
self._start_position_poll()
|
self._start_position_poll()
|
||||||
|
|
||||||
async def _initialize_device(self) -> None:
|
async def _initialize_device(self) -> None:
|
||||||
@ -159,6 +167,10 @@ class BirdcageApp(App):
|
|||||||
"""Pre-install the console overlay so it persists across open/close."""
|
"""Pre-install the console overlay so it persists across open/close."""
|
||||||
self.install_screen(ConsoleOverlay(), name="console-overlay")
|
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
|
# App-level position poll
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -246,6 +258,25 @@ class BirdcageApp(App):
|
|||||||
"""Called when the console overlay is dismissed."""
|
"""Called when the console overlay is dismissed."""
|
||||||
self._console_visible = False
|
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
|
# Tab bar button handling
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -329,6 +360,16 @@ def main() -> None:
|
|||||||
default="https://space.warehack.ing",
|
default="https://space.warehack.ing",
|
||||||
help="Craft API base URL",
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
app = BirdcageApp()
|
app = BirdcageApp()
|
||||||
@ -337,6 +378,8 @@ def main() -> None:
|
|||||||
app.firmware_name = args.firmware
|
app.firmware_name = args.firmware
|
||||||
app.skip_init = args.skip_init
|
app.skip_init = args.skip_init
|
||||||
app.craft_url = args.craft_url
|
app.craft_url = args.craft_url
|
||||||
|
app.capture_dir = args.capture_dir
|
||||||
|
app.camera_device = args.camera_device
|
||||||
try:
|
try:
|
||||||
app.run()
|
app.run()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
375
tui/src/birdcage_tui/camera.py
Normal file
375
tui/src/birdcage_tui/camera.py
Normal 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)
|
||||||
286
tui/src/birdcage_tui/capture_manager.py
Normal file
286
tui/src/birdcage_tui/capture_manager.py
Normal 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")
|
||||||
212
tui/src/birdcage_tui/capture_triggers.py
Normal file
212
tui/src/birdcage_tui/capture_triggers.py
Normal 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)
|
||||||
426
tui/src/birdcage_tui/screens/camera.py
Normal file
426
tui/src/birdcage_tui/screens/camera.py
Normal 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()
|
||||||
@ -687,6 +687,9 @@ class ControlScreen(Container):
|
|||||||
state.target_name = name
|
state.target_name = name
|
||||||
state.error = ""
|
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)
|
self.app.call_from_thread(self._apply_craft_state, state)
|
||||||
|
|
||||||
while self._craft_tracking and not shutdown.is_set():
|
while self._craft_tracking and not shutdown.is_set():
|
||||||
@ -739,6 +742,11 @@ class ControlScreen(Container):
|
|||||||
state.error = "Motor command failed"
|
state.error = "Motor command failed"
|
||||||
|
|
||||||
self.app.call_from_thread(self._apply_craft_state, state)
|
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)
|
shutdown.wait(1.0)
|
||||||
|
|
||||||
# Clean exit
|
# Clean exit
|
||||||
@ -746,6 +754,11 @@ class ControlScreen(Container):
|
|||||||
state.error = ""
|
state.error = ""
|
||||||
self.app.call_from_thread(self._apply_craft_state, state)
|
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:
|
def _apply_craft_state(self, state: CraftTrackingState) -> None:
|
||||||
try:
|
try:
|
||||||
panel = self.query_one("#ctrl-craft-panel", CraftPanel)
|
panel = self.query_one("#ctrl-craft-panel", CraftPanel)
|
||||||
|
|||||||
@ -774,6 +774,102 @@ ProgressBar PercentageStatus {
|
|||||||
margin-right: 1;
|
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 ─────────────────────────────── */
|
/* ── Scrollbar Styling ─────────────────────────────── */
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
113
tui/tests/test_camera_backend.py
Normal file
113
tui/tests/test_camera_backend.py
Normal 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
|
||||||
273
tui/tests/test_camera_overlay.py
Normal file
273
tui/tests/test_camera_overlay.py
Normal 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
|
||||||
171
tui/tests/test_capture_manager.py
Normal file
171
tui/tests/test_capture_manager.py
Normal 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
|
||||||
283
tui/tests/test_capture_triggers.py
Normal file
283
tui/tests/test_capture_triggers.py
Normal 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
280
tui/uv.lock
generated
@ -2,6 +2,38 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.11"
|
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]]
|
[[package]]
|
||||||
name = "birdcage"
|
name = "birdcage"
|
||||||
version = "2026.2.12.1"
|
version = "2026.2.12.1"
|
||||||
@ -26,6 +58,12 @@ dependencies = [
|
|||||||
{ name = "textual" },
|
{ name = "textual" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
camera = [
|
||||||
|
{ name = "astropy" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@ -34,9 +72,12 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "astropy", marker = "extra == 'camera'", specifier = ">=6.0" },
|
||||||
{ name = "birdcage", editable = "../" },
|
{ name = "birdcage", editable = "../" },
|
||||||
|
{ name = "pillow", marker = "extra == 'camera'", specifier = ">=10.0" },
|
||||||
{ name = "textual", specifier = ">=1.0.0" },
|
{ name = "textual", specifier = ">=1.0.0" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["camera"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.7.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.19.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "rich"
|
name = "rich"
|
||||||
version = "14.3.2"
|
version = "14.3.2"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user