Compare commits
10 Commits
f40651b34d
...
feat/displ
| Author | SHA1 | Date | |
|---|---|---|---|
| a638fea610 | |||
| efd010b1b5 | |||
| 1af30d86bf | |||
| 3ad280a65b | |||
| e09bddb724 | |||
| 0a16e3e564 | |||
| ccbdb84888 | |||
| dbb9743640 | |||
| e2e5b42212 | |||
| 2d0e946a8d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,4 +9,3 @@ htmlcov/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
.DS_Store
|
||||
|
||||
110
AGENTS.md
110
AGENTS.md
@@ -1,110 +0,0 @@
|
||||
# Agent Development Guide
|
||||
|
||||
## Development Environment
|
||||
|
||||
This project uses:
|
||||
- **mise** (mise.jdx.dev) - tool version manager and task runner
|
||||
- **hk** (hk.jdx.dev) - git hook manager
|
||||
- **uv** - fast Python package installer
|
||||
- **ruff** - linter and formatter
|
||||
- **pytest** - test runner
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
mise run install
|
||||
|
||||
# Or equivalently:
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Available Commands
|
||||
|
||||
```bash
|
||||
mise run test # Run tests
|
||||
mise run test-v # Run tests verbose
|
||||
mise run test-cov # Run tests with coverage report
|
||||
mise run lint # Run ruff linter
|
||||
mise run lint-fix # Run ruff with auto-fix
|
||||
mise run format # Run ruff formatter
|
||||
mise run ci # Full CI pipeline (sync + test + coverage)
|
||||
```
|
||||
|
||||
## Git Hooks
|
||||
|
||||
**At the start of every agent session**, verify hooks are installed:
|
||||
|
||||
```bash
|
||||
ls -la .git/hooks/pre-commit
|
||||
```
|
||||
|
||||
If hooks are not installed, install them with:
|
||||
|
||||
```bash
|
||||
hk init --mise
|
||||
mise run pre-commit
|
||||
```
|
||||
|
||||
The project uses hk configured in `hk.pkl`:
|
||||
- **pre-commit**: runs ruff-format and ruff (with auto-fix)
|
||||
- **pre-push**: runs ruff check
|
||||
|
||||
## Workflow Rules
|
||||
|
||||
### Before Committing
|
||||
|
||||
1. **Always run the test suite** - never commit code that fails tests:
|
||||
```bash
|
||||
mise run test
|
||||
```
|
||||
|
||||
2. **Always run the linter**:
|
||||
```bash
|
||||
mise run lint
|
||||
```
|
||||
|
||||
3. **Fix any lint errors** before committing (or let the pre-commit hook handle it).
|
||||
|
||||
4. **Review your changes** using `git diff` to understand what will be committed.
|
||||
|
||||
### On Failing Tests
|
||||
|
||||
When tests fail, **determine whether it's an out-of-date test or a correctly failing test**:
|
||||
|
||||
- **Out-of-date test**: The test was written for old behavior that has legitimately changed. Update the test to match the new expected behavior.
|
||||
|
||||
- **Correctly failing test**: The test correctly identifies a broken contract. Fix the implementation, not the test.
|
||||
|
||||
**Never** modify a test to make it pass without understanding why it failed.
|
||||
|
||||
### Code Review
|
||||
|
||||
Before committing significant changes:
|
||||
- Run `git diff` to review all changes
|
||||
- Ensure new code follows existing patterns in the codebase
|
||||
- Check that type hints are added for new functions
|
||||
- Verify that tests exist for new functionality
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `tests/` and follow the pattern `test_*.py`.
|
||||
|
||||
Run all tests:
|
||||
```bash
|
||||
mise run test
|
||||
```
|
||||
|
||||
Run with coverage:
|
||||
```bash
|
||||
mise run test-cov
|
||||
```
|
||||
|
||||
The project uses pytest with strict marker enforcement. Test configuration is in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **ntfy.py** and **mic.py** are standalone modules with zero internal dependencies
|
||||
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
||||
- **controller.py** coordinates ntfy/mic monitoring
|
||||
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||
148
README.md
148
README.md
@@ -2,37 +2,7 @@
|
||||
|
||||
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
||||
|
||||
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh). **Figment mode** overlays flickery, theme-colored SVG glyphs on the running stream at timed intervals — controllable from any input source via an extensible trigger protocol.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [Using](#using)
|
||||
- [Run](#run)
|
||||
- [Config](#config)
|
||||
- [Feeds](#feeds)
|
||||
- [Fonts](#fonts)
|
||||
- [Color Schemes](#color-schemes)
|
||||
- [ntfy.sh](#ntfysh)
|
||||
- [Figment Mode](#figment-mode)
|
||||
- [Internals](#internals)
|
||||
- [How it works](#how-it-works)
|
||||
- [Architecture](#architecture)
|
||||
- [Extending](#extending)
|
||||
- [NtfyPoller](#ntfypoller)
|
||||
- [MicMonitor](#micmonitor)
|
||||
- [Render pipeline](#render-pipeline)
|
||||
- [Development](#development)
|
||||
- [Setup](#setup)
|
||||
- [Tasks](#tasks)
|
||||
- [Testing](#testing)
|
||||
- [Linting](#linting)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Performance](#performance)
|
||||
- [Graphics](#graphics)
|
||||
- [Cyberpunk Vibes](#cyberpunk-vibes)
|
||||
- [Extensibility](#extensibility)
|
||||
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
|
||||
|
||||
---
|
||||
|
||||
@@ -45,8 +15,6 @@ python3 mainline.py # news stream
|
||||
python3 mainline.py --poetry # literary consciousness mode
|
||||
python3 mainline.py -p # same
|
||||
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||
python3 mainline.py --figment # enable periodic SVG glyph overlays
|
||||
python3 mainline.py --figment-interval 30 # figment every 30 seconds (default: 60)
|
||||
python3 mainline.py --refresh # force re-fetch (bypass cache)
|
||||
python3 mainline.py --no-font-picker # skip interactive font picker
|
||||
python3 mainline.py --font-file path.otf # use a specific font file
|
||||
@@ -82,7 +50,6 @@ All constants live in `engine/config.py`:
|
||||
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
|
||||
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
||||
| `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) |
|
||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
|
||||
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
|
||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||
@@ -101,21 +68,6 @@ Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The select
|
||||
|
||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
|
||||
|
||||
### Color Schemes
|
||||
|
||||
Mainline supports three color themes for the scrolling gradient: **Verdant Green**, **Molten Orange**, and **Violet Purple**. Each theme uses a precise color-opposite palette for ntfy message queue rendering (magenta, blue, and yellow respectively).
|
||||
|
||||
On startup, an interactive picker presents all available color schemes:
|
||||
```
|
||||
[1] Verdant Green (white-hot → deep green)
|
||||
[2] Molten Orange (white-hot → deep orange)
|
||||
[3] Violet Purple (white-hot → deep purple)
|
||||
```
|
||||
|
||||
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selection applies only to the current session; you'll pick a fresh theme each run.
|
||||
|
||||
**Note:** The boot UI (title, status lines, font picker menu) uses a hardcoded green accent color for visual continuity. Only the scrolling headlines and incoming messages render in the selected theme gradient.
|
||||
|
||||
### ntfy.sh
|
||||
|
||||
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
||||
@@ -128,39 +80,6 @@ curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
|
||||
|
||||
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
|
||||
|
||||
### Figment Mode
|
||||
|
||||
Figment mode periodically overlays a full-screen SVG glyph on the running ticker — flickering through a reveal → hold (strobe) → dissolve cycle, colored with a randomly selected theme gradient.
|
||||
|
||||
**Enable it** with the `--figment` flag:
|
||||
|
||||
```bash
|
||||
uv run mainline.py --figment # glyph every 60 seconds (default)
|
||||
uv run mainline.py --figment --figment-interval 30 # every 30 seconds
|
||||
```
|
||||
|
||||
**Figment assets** live in `figments/` — drop any `.svg` file there and it will be picked up automatically. The bundled set contains Mayan and Aztec glyphs. Figments are selected randomly, avoiding immediate repeats, and rasterized into half-block terminal art at display time.
|
||||
|
||||
**Triggering manually** — any object with a `poll() -> FigmentCommand | None` method satisfies the `FigmentTrigger` protocol and can be passed to the plugin:
|
||||
|
||||
```python
|
||||
from engine.figment_trigger import FigmentAction, FigmentCommand
|
||||
|
||||
class MyTrigger:
|
||||
def poll(self):
|
||||
if some_condition:
|
||||
return FigmentCommand(action=FigmentAction.TRIGGER)
|
||||
return None
|
||||
```
|
||||
|
||||
Built-in commands: `TRIGGER`, `SET_INTENSITY`, `SET_INTERVAL`, `SET_COLOR`, `STOP`.
|
||||
|
||||
**System dependency:** Figment mode requires the Cairo C library (`brew install cairo` on macOS) in addition to the `figment` extras group:
|
||||
|
||||
```bash
|
||||
uv sync --extra figment # adds cairosvg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Internals
|
||||
@@ -175,7 +94,6 @@ uv sync --extra figment # adds cairosvg
|
||||
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
||||
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
||||
- An ntfy.sh SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
||||
- Figment mode rasterizes SVGs via cairosvg → PIL → greyscale → half-block encode, then overlays them with ANSI cursor-positioning commands between the effect chain and the ntfy message layer
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -183,40 +101,26 @@ uv sync --extra figment # adds cairosvg
|
||||
|
||||
```
|
||||
engine/
|
||||
__init__.py package marker
|
||||
app.py main(), font picker TUI, boot sequence, signal handler
|
||||
config.py constants, CLI flags, glyph tables
|
||||
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
||||
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
||||
filter.py HTML stripping, content filter
|
||||
translate.py Google Translate wrapper + region detection
|
||||
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||
effects.py noise, glitch_bar, fade, firehose
|
||||
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||
mic.py MicMonitor — standalone, graceful fallback
|
||||
scroll.py stream() frame loop + message rendering
|
||||
viewport.py terminal dimension tracking (tw/th)
|
||||
frame.py scroll step calculation, timing
|
||||
layers.py ticker zone, firehose, message + figment overlay rendering
|
||||
figment_render.py SVG → cairosvg → PIL → half-block rasterizer with cache
|
||||
figment_trigger.py FigmentTrigger protocol, FigmentAction enum, FigmentCommand
|
||||
eventbus.py thread-safe event publishing for decoupled communication
|
||||
events.py event types and definitions
|
||||
controller.py coordinates ntfy/mic monitoring and event publishing
|
||||
emitters.py background emitters for ntfy and mic
|
||||
types.py type definitions and dataclasses
|
||||
themes.py THEME_REGISTRY — gradient color definitions
|
||||
config.py constants, CLI flags, glyph tables
|
||||
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
||||
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
||||
filter.py HTML stripping, content filter
|
||||
translate.py Google Translate wrapper + region detection
|
||||
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||
effects.py noise, glitch_bar, fade, firehose
|
||||
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||
mic.py MicMonitor — standalone, graceful fallback
|
||||
scroll.py stream() frame loop + message rendering
|
||||
app.py main(), font picker TUI, boot sequence, signal handler
|
||||
|
||||
effects_plugins/
|
||||
__init__.py plugin discovery (ABC issubclass scan)
|
||||
noise.py NoiseEffect — random character noise
|
||||
glitch.py GlitchEffect — horizontal glitch bars
|
||||
fade.py FadeEffect — edge fade zones
|
||||
firehose.py FirehoseEffect — dense bottom ticker strip
|
||||
figment.py FigmentEffect — periodic SVG glyph overlay (state machine)
|
||||
|
||||
figments/ SVG assets for figment mode
|
||||
tests/
|
||||
test_config.py
|
||||
test_filter.py
|
||||
test_mic.py
|
||||
test_ntfy.py
|
||||
test_sources.py
|
||||
test_terminal.py
|
||||
```
|
||||
|
||||
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
||||
@@ -284,15 +188,11 @@ See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server +
|
||||
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
```bash
|
||||
uv sync # minimal (no mic, no figment)
|
||||
uv sync --extras mic # with mic support (sounddevice + numpy)
|
||||
uv sync --extra figment # with figment mode (cairosvg + system Cairo)
|
||||
uv sync --all-extras # all optional features
|
||||
uv sync # minimal (no mic)
|
||||
uv sync --all-extras # with mic support (sounddevice + numpy)
|
||||
uv sync --all-extras --group dev # full dev environment
|
||||
```
|
||||
|
||||
Figment mode also requires the Cairo C library: `brew install cairo` (macOS).
|
||||
|
||||
### Tasks
|
||||
|
||||
With [mise](https://mise.jdx.dev/):
|
||||
@@ -310,7 +210,7 @@ mise run run-firehose # uv run mainline.py --firehose
|
||||
|
||||
### Testing
|
||||
|
||||
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, `terminal`, and the full figment pipeline (`figment_render`, `figment_trigger`, `figment`, `figment_overlay`). Figment tests are automatically skipped if Cairo is not installed.
|
||||
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, and `terminal`.
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
@@ -342,8 +242,6 @@ Pre-commit hooks run lint automatically via `hk`.
|
||||
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
||||
|
||||
### Cyberpunk Vibes
|
||||
- **Figment intensity wiring** — `config.intensity` currently stored but not yet applied to reveal/dissolve speed or strobe frequency
|
||||
- **ntfy figment trigger** — built-in `NtfyFigmentTrigger` that listens on a dedicated topic to fire figments on demand
|
||||
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
|
||||
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
|
||||
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
||||
|
||||
250
cmdline.py
250
cmdline.py
@@ -1,250 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Command-line utility for interacting with mainline via ntfy.
|
||||
|
||||
Usage:
|
||||
python cmdline.py # Interactive TUI mode
|
||||
python cmdline.py --help # Show help
|
||||
python cmdline.py /effects list # Send single command via ntfy
|
||||
python cmdline.py /effects stats # Get performance stats via ntfy
|
||||
python cmdline.py -w /effects stats # Watch mode (polls for stats)
|
||||
|
||||
The TUI mode provides:
|
||||
- Arrow keys to navigate command history
|
||||
- Tab completion for commands
|
||||
- Auto-refresh for performance stats
|
||||
|
||||
C&C works like a serial port:
|
||||
1. Send command to ntfy_cc_topic
|
||||
2. Mainline receives, processes, responds to same topic
|
||||
3. Cmdline polls for response
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from engine import config
|
||||
from engine.terminal import CLR, CURSOR_OFF, CURSOR_ON, G_DIM, G_HI, RST, W_GHOST
|
||||
|
||||
try:
|
||||
CC_CMD_TOPIC = config.NTFY_CC_CMD_TOPIC
|
||||
CC_RESP_TOPIC = config.NTFY_CC_RESP_TOPIC
|
||||
except AttributeError:
|
||||
CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||
CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||
|
||||
|
||||
class NtfyResponsePoller:
|
||||
"""Polls ntfy for command responses."""
|
||||
|
||||
def __init__(self, cmd_topic: str, resp_topic: str, timeout: float = 10.0):
|
||||
self.cmd_topic = cmd_topic
|
||||
self.resp_topic = resp_topic
|
||||
self.timeout = timeout
|
||||
self._last_id = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _build_url(self) -> str:
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
parsed = urlparse(self.resp_topic)
|
||||
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||
params["since"] = [self._last_id if self._last_id else "20s"]
|
||||
new_query = urlencode({k: v[0] for k, v in params.items()})
|
||||
return urlunparse(parsed._replace(query=new_query))
|
||||
|
||||
def send_and_wait(self, cmd: str) -> str:
|
||||
"""Send command and wait for response."""
|
||||
url = self.cmd_topic.replace("/json", "")
|
||||
data = cmd.encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"User-Agent": "mainline-cmdline/0.1",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception as e:
|
||||
return f"Error sending command: {e}"
|
||||
|
||||
return self._wait_for_response(cmd)
|
||||
|
||||
def _wait_for_response(self, expected_cmd: str = "") -> str:
|
||||
"""Poll for response message."""
|
||||
start = time.time()
|
||||
while time.time() - start < self.timeout:
|
||||
try:
|
||||
url = self._build_url()
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "mainline-cmdline/0.1"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
for line in resp:
|
||||
try:
|
||||
data = json.loads(line.decode("utf-8", errors="replace"))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if data.get("event") == "message":
|
||||
self._last_id = data.get("id")
|
||||
msg = data.get("message", "")
|
||||
if msg:
|
||||
return msg
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
return "Timeout waiting for response"
|
||||
|
||||
|
||||
AVAILABLE_COMMANDS = """Available commands:
|
||||
/effects list - List all effects and status
|
||||
/effects <name> on - Enable an effect
|
||||
/effects <name> off - Disable an effect
|
||||
/effects <name> intensity <0.0-1.0> - Set effect intensity
|
||||
/effects reorder <name1>,<name2>,... - Reorder pipeline
|
||||
/effects stats - Show performance statistics
|
||||
/help - Show this help
|
||||
/quit - Exit
|
||||
"""
|
||||
|
||||
|
||||
def print_header():
|
||||
w = 60
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print(f"\033[1;1H", end="")
|
||||
print(f" \033[1;38;5;231m╔{'═' * (w - 6)}╗\033[0m")
|
||||
print(
|
||||
f" \033[1;38;5;231m║\033[0m \033[1;38;5;82mMAINLINE\033[0m \033[3;38;5;245mCommand Center\033[0m \033[1;38;5;231m ║\033[0m"
|
||||
)
|
||||
print(f" \033[1;38;5;231m╚{'═' * (w - 6)}╝\033[0m")
|
||||
print(f" \033[2;38;5;37mCMD: {CC_CMD_TOPIC.split('/')[-2]}\033[0m")
|
||||
print(f" \033[2;38;5;37mRESP: {CC_RESP_TOPIC.split('/')[-2]}\033[0m")
|
||||
print()
|
||||
|
||||
|
||||
def print_response(response: str, is_error: bool = False) -> None:
|
||||
"""Print response with nice formatting."""
|
||||
print()
|
||||
if is_error:
|
||||
print(f" \033[1;38;5;196m✗ Error\033[0m")
|
||||
print(f" \033[38;5;196m{'─' * 40}\033[0m")
|
||||
else:
|
||||
print(f" \033[1;38;5;82m✓ Response\033[0m")
|
||||
print(f" \033[38;5;37m{'─' * 40}\033[0m")
|
||||
|
||||
for line in response.split("\n"):
|
||||
print(f" {line}")
|
||||
print()
|
||||
|
||||
|
||||
def interactive_mode():
|
||||
"""Interactive TUI for sending commands."""
|
||||
import readline
|
||||
|
||||
print_header()
|
||||
poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
|
||||
|
||||
print(f" \033[38;5;245mType /help for commands, /quit to exit\033[0m")
|
||||
print()
|
||||
|
||||
while True:
|
||||
try:
|
||||
cmd = input(f" \033[1;38;5;82m❯\033[0m {G_HI}").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
|
||||
if not cmd:
|
||||
continue
|
||||
|
||||
if cmd.startswith("/"):
|
||||
if cmd == "/quit" or cmd == "/exit":
|
||||
print(f"\n \033[1;38;5;245mGoodbye!{RST}\n")
|
||||
break
|
||||
|
||||
if cmd == "/help":
|
||||
print(f"\n{AVAILABLE_COMMANDS}\n")
|
||||
continue
|
||||
|
||||
print(f" \033[38;5;245m⟳ Sending to mainline...{RST}")
|
||||
result = poller.send_and_wait(cmd)
|
||||
print_response(result, is_error=result.startswith("Error"))
|
||||
else:
|
||||
print(f"\n \033[1;38;5;196m⚠ Commands must start with /{RST}\n")
|
||||
|
||||
print(CURSOR_ON, end="")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Mainline command-line interface",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=AVAILABLE_COMMANDS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"command",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Command to send (e.g., /effects list)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--watch",
|
||||
"-w",
|
||||
action="store_true",
|
||||
help="Watch mode: continuously poll for stats (Ctrl+C to exit)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command is None:
|
||||
return interactive_mode()
|
||||
|
||||
poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
|
||||
|
||||
if args.watch and "/effects stats" in args.command:
|
||||
import signal
|
||||
|
||||
def handle_sigterm(*_):
|
||||
print(f"\n \033[1;38;5;245mStopped watching{RST}")
|
||||
print(CURSOR_ON, end="")
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_sigterm)
|
||||
|
||||
print_header()
|
||||
print(f" \033[38;5;245mWatching /effects stats (Ctrl+C to exit)...{RST}\n")
|
||||
try:
|
||||
while True:
|
||||
result = poller.send_and_wait(args.command)
|
||||
print(f"\033[2J\033[1;1H", end="")
|
||||
print(
|
||||
f" \033[1;38;5;82m❯\033[0m Performance Stats - \033[1;38;5;245m{time.strftime('%H:%M:%S')}{RST}"
|
||||
)
|
||||
print(f" \033[38;5;37m{'─' * 44}{RST}")
|
||||
for line in result.split("\n"):
|
||||
print(f" {line}")
|
||||
time.sleep(2)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n \033[1;38;5;245mStopped watching{RST}")
|
||||
return 0
|
||||
return 0
|
||||
|
||||
result = poller.send_and_wait(args.command)
|
||||
print(result)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,894 +0,0 @@
|
||||
# Color Scheme Switcher Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement interactive color theme picker at startup that lets users choose between green, orange, or purple gradients with complementary message queue colors.
|
||||
|
||||
**Architecture:** New `themes.py` data module defines Theme class and THEME_REGISTRY. Config adds `ACTIVE_THEME` global set by picker. Render functions read from active theme instead of hardcoded constants. App adds picker UI that mirrors font picker pattern.
|
||||
|
||||
**Tech Stack:** Python 3.10+, ANSI 256-color codes, existing terminal I/O utilities
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Purpose | Change Type |
|
||||
|------|---------|------------|
|
||||
| `engine/themes.py` | Theme class, THEME_REGISTRY, color codes | Create |
|
||||
| `engine/config.py` | ACTIVE_THEME global, set_active_theme() | Modify |
|
||||
| `engine/render.py` | Replace GRAD_COLS/MSG_GRAD_COLS with config lookup | Modify |
|
||||
| `engine/scroll.py` | Update message gradient call | Modify |
|
||||
| `engine/app.py` | pick_color_theme(), call in main() | Modify |
|
||||
| `tests/test_themes.py` | Theme class and registry unit tests | Create |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Theme Data Module
|
||||
|
||||
### Task 1: Create themes.py with Theme class and registry
|
||||
|
||||
**Files:**
|
||||
- Create: `engine/themes.py`
|
||||
- Test: `tests/test_themes.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test for Theme class**
|
||||
|
||||
Create `tests/test_themes.py`:
|
||||
|
||||
```python
|
||||
"""Test color themes and registry."""
|
||||
from engine.themes import Theme, THEME_REGISTRY, get_theme
|
||||
|
||||
|
||||
def test_theme_construction():
|
||||
"""Theme stores name and gradient lists."""
|
||||
main = ["\033[1;38;5;231m"] * 12
|
||||
msg = ["\033[1;38;5;225m"] * 12
|
||||
theme = Theme(name="Test Green", main_gradient=main, message_gradient=msg)
|
||||
|
||||
assert theme.name == "Test Green"
|
||||
assert theme.main_gradient == main
|
||||
assert theme.message_gradient == msg
|
||||
|
||||
|
||||
def test_gradient_length():
|
||||
"""Each gradient must have exactly 12 ANSI codes."""
|
||||
for theme_id, theme in THEME_REGISTRY.items():
|
||||
assert len(theme.main_gradient) == 12, f"{theme_id} main gradient wrong length"
|
||||
assert len(theme.message_gradient) == 12, f"{theme_id} message gradient wrong length"
|
||||
|
||||
|
||||
def test_theme_registry_has_three_themes():
|
||||
"""Registry contains green, orange, purple."""
|
||||
assert len(THEME_REGISTRY) == 3
|
||||
assert "green" in THEME_REGISTRY
|
||||
assert "orange" in THEME_REGISTRY
|
||||
assert "purple" in THEME_REGISTRY
|
||||
|
||||
|
||||
def test_get_theme_valid():
|
||||
"""get_theme returns Theme object for valid ID."""
|
||||
theme = get_theme("green")
|
||||
assert isinstance(theme, Theme)
|
||||
assert theme.name == "Verdant Green"
|
||||
|
||||
|
||||
def test_get_theme_invalid():
|
||||
"""get_theme raises KeyError for invalid ID."""
|
||||
with pytest.raises(KeyError):
|
||||
get_theme("invalid_theme")
|
||||
|
||||
|
||||
def test_green_theme_unchanged():
|
||||
"""Green theme uses original green → magenta colors."""
|
||||
green_theme = get_theme("green")
|
||||
# First color should be white (bold)
|
||||
assert green_theme.main_gradient[0] == "\033[1;38;5;231m"
|
||||
# Last deep green
|
||||
assert green_theme.main_gradient[9] == "\033[38;5;22m"
|
||||
# Message gradient is magenta
|
||||
assert green_theme.message_gradient[9] == "\033[38;5;89m"
|
||||
```
|
||||
|
||||
Run: `pytest tests/test_themes.py -v`
|
||||
Expected: FAIL (module doesn't exist)
|
||||
|
||||
- [ ] **Step 2: Create themes.py with Theme class and finalized gradients**
|
||||
|
||||
Create `engine/themes.py`:
|
||||
|
||||
```python
|
||||
"""Color theme definitions and registry."""
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Theme:
|
||||
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
||||
|
||||
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
||||
"""Initialize theme with display name and gradient lists.
|
||||
|
||||
Args:
|
||||
name: Display name (e.g., "Verdant Green")
|
||||
main_gradient: List of 12 ANSI 256-color codes (white → primary color)
|
||||
message_gradient: List of 12 ANSI codes (white → complementary color)
|
||||
"""
|
||||
self.name = name
|
||||
self.main_gradient = main_gradient
|
||||
self.message_gradient = message_gradient
|
||||
|
||||
|
||||
# ─── FINALIZED GRADIENTS ──────────────────────────────────────────────────
|
||||
# Each gradient: white → primary/complementary, 12 steps total
|
||||
# Format: "\033[<brightness>;<color>m" where color is 38;5;<colorcode>
|
||||
|
||||
_GREEN_MAIN = [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;195m", # pale white-tint
|
||||
"\033[38;5;123m", # bright cyan
|
||||
"\033[38;5;118m", # bright lime
|
||||
"\033[38;5;82m", # lime
|
||||
"\033[38;5;46m", # bright green
|
||||
"\033[38;5;40m", # green
|
||||
"\033[38;5;34m", # medium green
|
||||
"\033[38;5;28m", # dark green
|
||||
"\033[38;5;22m", # deep green
|
||||
"\033[2;38;5;22m", # dim deep green
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
_GREEN_MESSAGE = [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;225m", # pale pink-white
|
||||
"\033[38;5;219m", # bright pink
|
||||
"\033[38;5;213m", # hot pink
|
||||
"\033[38;5;207m", # magenta
|
||||
"\033[38;5;201m", # bright magenta
|
||||
"\033[38;5;165m", # orchid-red
|
||||
"\033[38;5;161m", # ruby-magenta
|
||||
"\033[38;5;125m", # dark magenta
|
||||
"\033[38;5;89m", # deep maroon-magenta
|
||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
_ORANGE_MAIN = [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;215m", # pale orange-white
|
||||
"\033[38;5;209m", # bright orange
|
||||
"\033[38;5;208m", # vibrant orange
|
||||
"\033[38;5;202m", # orange
|
||||
"\033[38;5;166m", # dark orange
|
||||
"\033[38;5;130m", # burnt orange
|
||||
"\033[38;5;94m", # rust
|
||||
"\033[38;5;58m", # dark rust
|
||||
"\033[38;5;94m", # rust (hold)
|
||||
"\033[2;38;5;94m", # dim rust
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
_ORANGE_MESSAGE = [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;195m", # pale cyan-white
|
||||
"\033[38;5;33m", # bright blue
|
||||
"\033[38;5;27m", # blue
|
||||
"\033[38;5;21m", # deep blue
|
||||
"\033[38;5;21m", # deep blue (hold)
|
||||
"\033[38;5;21m", # deep blue (hold)
|
||||
"\033[38;5;18m", # navy
|
||||
"\033[38;5;18m", # navy (hold)
|
||||
"\033[38;5;18m", # navy (hold)
|
||||
"\033[2;38;5;18m", # dim navy
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
_PURPLE_MAIN = [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;225m", # pale purple-white
|
||||
"\033[38;5;177m", # bright purple
|
||||
"\033[38;5;171m", # vibrant purple
|
||||
"\033[38;5;165m", # purple
|
||||
"\033[38;5;135m", # medium purple
|
||||
"\033[38;5;129m", # purple
|
||||
"\033[38;5;93m", # dark purple
|
||||
"\033[38;5;57m", # deep purple
|
||||
"\033[38;5;57m", # deep purple (hold)
|
||||
"\033[2;38;5;57m", # dim deep purple
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
_PURPLE_MESSAGE = [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;226m", # pale yellow-white
|
||||
"\033[38;5;226m", # bright yellow
|
||||
"\033[38;5;220m", # yellow
|
||||
"\033[38;5;220m", # yellow (hold)
|
||||
"\033[38;5;184m", # dark yellow
|
||||
"\033[38;5;184m", # dark yellow (hold)
|
||||
"\033[38;5;178m", # olive-yellow
|
||||
"\033[38;5;178m", # olive-yellow (hold)
|
||||
"\033[38;5;172m", # golden
|
||||
"\033[2;38;5;172m", # dim golden
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||
|
||||
THEME_REGISTRY = {
|
||||
"green": Theme(
|
||||
name="Verdant Green",
|
||||
main_gradient=_GREEN_MAIN,
|
||||
message_gradient=_GREEN_MESSAGE,
|
||||
),
|
||||
"orange": Theme(
|
||||
name="Molten Orange",
|
||||
main_gradient=_ORANGE_MAIN,
|
||||
message_gradient=_ORANGE_MESSAGE,
|
||||
),
|
||||
"purple": Theme(
|
||||
name="Violet Purple",
|
||||
main_gradient=_PURPLE_MAIN,
|
||||
message_gradient=_PURPLE_MESSAGE,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_theme(theme_id: str) -> Theme:
|
||||
"""Retrieve a theme by ID.
|
||||
|
||||
Args:
|
||||
theme_id: One of "green", "orange", "purple"
|
||||
|
||||
Returns:
|
||||
Theme object
|
||||
|
||||
Raises:
|
||||
KeyError: If theme_id not found in registry
|
||||
"""
|
||||
if theme_id not in THEME_REGISTRY:
|
||||
raise KeyError(f"Unknown theme: {theme_id}. Available: {list(THEME_REGISTRY.keys())}")
|
||||
return THEME_REGISTRY[theme_id]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_themes.py -v`
|
||||
Expected: PASS (all 6 tests)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add engine/themes.py tests/test_themes.py
|
||||
git commit -m "feat: create Theme class and registry with finalized color gradients
|
||||
|
||||
- Define Theme class to encapsulate name and main/message gradients
|
||||
- Create THEME_REGISTRY with green, orange, purple themes
|
||||
- Each gradient has 12 ANSI 256-color codes finalized
|
||||
- Complementary color pairs: green/magenta, orange/blue, purple/yellow
|
||||
- Add get_theme() lookup with error handling
|
||||
- Add comprehensive unit tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Config Integration
|
||||
|
||||
### Task 2: Add ACTIVE_THEME global and set_active_theme() to config.py
|
||||
|
||||
**Files:**
|
||||
- Modify: `engine/config.py:1-30`
|
||||
- Test: `tests/test_config.py` (expand existing)
|
||||
|
||||
- [ ] **Step 1: Write failing tests for config changes**
|
||||
|
||||
Add to `tests/test_config.py`:
|
||||
|
||||
```python
|
||||
def test_active_theme_initially_none():
|
||||
"""ACTIVE_THEME is None before initialization."""
|
||||
# This test may fail if config is already initialized
|
||||
# We'll set it to None first for testing
|
||||
import engine.config
|
||||
engine.config.ACTIVE_THEME = None
|
||||
assert engine.config.ACTIVE_THEME is None
|
||||
|
||||
|
||||
def test_set_active_theme_green():
|
||||
"""set_active_theme('green') sets ACTIVE_THEME to green theme."""
|
||||
from engine.config import set_active_theme
|
||||
from engine.themes import get_theme
|
||||
|
||||
set_active_theme("green")
|
||||
|
||||
assert config.ACTIVE_THEME is not None
|
||||
assert config.ACTIVE_THEME.name == "Verdant Green"
|
||||
assert config.ACTIVE_THEME == get_theme("green")
|
||||
|
||||
|
||||
def test_set_active_theme_default():
|
||||
"""set_active_theme() with no args defaults to green."""
|
||||
from engine.config import set_active_theme
|
||||
|
||||
set_active_theme()
|
||||
|
||||
assert config.ACTIVE_THEME.name == "Verdant Green"
|
||||
|
||||
|
||||
def test_set_active_theme_invalid():
|
||||
"""set_active_theme() with invalid ID raises KeyError."""
|
||||
from engine.config import set_active_theme
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
set_active_theme("invalid")
|
||||
```
|
||||
|
||||
Run: `pytest tests/test_config.py -v`
|
||||
Expected: FAIL (functions don't exist yet)
|
||||
|
||||
- [ ] **Step 2: Add ACTIVE_THEME global and set_active_theme() to config.py**
|
||||
|
||||
Edit `engine/config.py`, add after line 30 (after `_resolve_font_path` function):
|
||||
|
||||
```python
|
||||
# ─── COLOR THEME ──────────────────────────────────────────────────────────
|
||||
ACTIVE_THEME = None # set by set_active_theme() after picker
|
||||
|
||||
|
||||
def set_active_theme(theme_id: str = "green"):
|
||||
"""Set the active color theme. Defaults to 'green' if not specified.
|
||||
|
||||
Args:
|
||||
theme_id: One of "green", "orange", "purple"
|
||||
|
||||
Raises:
|
||||
KeyError: If theme_id is invalid
|
||||
"""
|
||||
global ACTIVE_THEME
|
||||
from engine import themes
|
||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove hardcoded GRAD_COLS and MSG_GRAD_COLS from render.py**
|
||||
|
||||
Edit `engine/render.py`, find and delete lines 20-49 (the hardcoded gradient arrays):
|
||||
|
||||
```python
|
||||
# DELETED:
|
||||
# GRAD_COLS = [...]
|
||||
# MSG_GRAD_COLS = [...]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_config.py::test_active_theme_initially_none -v`
|
||||
Run: `pytest tests/test_config.py::test_set_active_theme_green -v`
|
||||
Run: `pytest tests/test_config.py::test_set_active_theme_default -v`
|
||||
Run: `pytest tests/test_config.py::test_set_active_theme_invalid -v`
|
||||
|
||||
Expected: PASS (all 4 new tests)
|
||||
|
||||
- [ ] **Step 5: Verify existing config tests still pass**
|
||||
|
||||
Run: `pytest tests/test_config.py -v`
|
||||
|
||||
Expected: PASS (all existing + new tests)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add engine/config.py tests/test_config.py
|
||||
git commit -m "feat: add ACTIVE_THEME global and set_active_theme() to config
|
||||
|
||||
- Add ACTIVE_THEME global (initialized to None)
|
||||
- Add set_active_theme(theme_id) function with green default
|
||||
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS (move to themes.py)
|
||||
- Add comprehensive tests for theme setting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Render Pipeline Integration
|
||||
|
||||
### Task 3: Update render.py to use config.ACTIVE_THEME
|
||||
|
||||
**Files:**
|
||||
- Modify: `engine/render.py:15-220`
|
||||
- Test: `tests/test_render.py` (expand existing)
|
||||
|
||||
- [ ] **Step 1: Write failing test for lr_gradient with theme**
|
||||
|
||||
Add to `tests/test_render.py`:
|
||||
|
||||
```python
|
||||
def test_lr_gradient_uses_active_theme(monkeypatch):
|
||||
"""lr_gradient uses config.ACTIVE_THEME when cols=None."""
|
||||
from engine import config, render
|
||||
from engine.themes import get_theme
|
||||
|
||||
# Set orange theme
|
||||
config.set_active_theme("orange")
|
||||
|
||||
# Create simple rows
|
||||
rows = ["test row"]
|
||||
result = render.lr_gradient(rows, offset=0, cols=None)
|
||||
|
||||
# Result should start with first color from orange main gradient
|
||||
assert result[0].startswith("\033[1;38;5;231m") # white (same for all)
|
||||
|
||||
|
||||
def test_lr_gradient_fallback_when_no_theme(monkeypatch):
|
||||
"""lr_gradient uses fallback when ACTIVE_THEME is None."""
|
||||
from engine import config, render
|
||||
|
||||
# Clear active theme
|
||||
config.ACTIVE_THEME = None
|
||||
|
||||
rows = ["test row"]
|
||||
result = render.lr_gradient(rows, offset=0, cols=None)
|
||||
|
||||
# Should not crash and should return something
|
||||
assert result is not None
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_default_green_gradient_length():
|
||||
"""_default_green_gradient returns 12 colors."""
|
||||
from engine import render
|
||||
|
||||
colors = render._default_green_gradient()
|
||||
assert len(colors) == 12
|
||||
```
|
||||
|
||||
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
||||
Expected: FAIL (function signature doesn't match)
|
||||
|
||||
- [ ] **Step 2: Update lr_gradient() to use config.ACTIVE_THEME**
|
||||
|
||||
Edit `engine/render.py`, find the `lr_gradient()` function (around line 194) and update it:
|
||||
|
||||
```python
|
||||
def lr_gradient(rows, offset, cols=None):
|
||||
"""
|
||||
Render rows through a left-to-right color sweep.
|
||||
|
||||
Args:
|
||||
rows: List of text rows to colorize
|
||||
offset: Gradient position offset (for animation)
|
||||
cols: Optional list of color codes. If None, uses active theme.
|
||||
|
||||
Returns:
|
||||
List of colorized rows
|
||||
"""
|
||||
if cols is None:
|
||||
from engine import config
|
||||
cols = (
|
||||
config.ACTIVE_THEME.main_gradient
|
||||
if config.ACTIVE_THEME
|
||||
else _default_green_gradient()
|
||||
)
|
||||
|
||||
# ... rest of function unchanged ...
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add _default_green_gradient() fallback function**
|
||||
|
||||
Add to `engine/render.py` before `lr_gradient()`:
|
||||
|
||||
```python
|
||||
def _default_green_gradient():
|
||||
"""Fallback green gradient (original colors) for initialization."""
|
||||
return [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;195m", # pale white-tint
|
||||
"\033[38;5;123m", # bright cyan
|
||||
"\033[38;5;118m", # bright lime
|
||||
"\033[38;5;82m", # lime
|
||||
"\033[38;5;46m", # bright green
|
||||
"\033[38;5;40m", # green
|
||||
"\033[38;5;34m", # medium green
|
||||
"\033[38;5;28m", # dark green
|
||||
"\033[38;5;22m", # deep green
|
||||
"\033[2;38;5;22m", # dim deep green
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
|
||||
def _default_magenta_gradient():
|
||||
"""Fallback magenta gradient (original message colors) for initialization."""
|
||||
return [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;225m", # pale pink-white
|
||||
"\033[38;5;219m", # bright pink
|
||||
"\033[38;5;213m", # hot pink
|
||||
"\033[38;5;207m", # magenta
|
||||
"\033[38;5;201m", # bright magenta
|
||||
"\033[38;5;165m", # orchid-red
|
||||
"\033[38;5;161m", # ruby-magenta
|
||||
"\033[38;5;125m", # dark magenta
|
||||
"\033[38;5;89m", # deep maroon-magenta
|
||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
||||
Run: `pytest tests/test_render.py::test_lr_gradient_fallback_when_no_theme -v`
|
||||
Run: `pytest tests/test_render.py::test_default_green_gradient_length -v`
|
||||
|
||||
Expected: PASS (all 3 new tests)
|
||||
|
||||
- [ ] **Step 5: Run full render test suite**
|
||||
|
||||
Run: `pytest tests/test_render.py -v`
|
||||
|
||||
Expected: PASS (existing tests may need adjustment for mocking)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add engine/render.py tests/test_render.py
|
||||
git commit -m "feat: update lr_gradient to use config.ACTIVE_THEME
|
||||
|
||||
- Update lr_gradient(cols=None) to check config.ACTIVE_THEME
|
||||
- Add _default_green_gradient() and _default_magenta_gradient() fallbacks
|
||||
- Fallback used when ACTIVE_THEME is None (non-interactive init)
|
||||
- Add tests for theme-aware and fallback gradient rendering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Message Gradient Integration
|
||||
|
||||
### Task 4: Update scroll.py to use message gradient from config
|
||||
|
||||
**Files:**
|
||||
- Modify: `engine/scroll.py:85-95`
|
||||
- Test: existing `tests/test_scroll.py`
|
||||
|
||||
- [ ] **Step 1: Locate message gradient calls in scroll.py**
|
||||
|
||||
Run: `grep -n "MSG_GRAD_COLS\|lr_gradient_opposite" /Users/genejohnson/Dev/mainline/engine/scroll.py`
|
||||
|
||||
Expected: Should find line(s) where `MSG_GRAD_COLS` or similar is used
|
||||
|
||||
- [ ] **Step 2: Update scroll.py to use theme message gradient**
|
||||
|
||||
Edit `engine/scroll.py`, find the line that uses message gradients (around line 89 based on spec) and update:
|
||||
|
||||
Old code:
|
||||
```python
|
||||
# Some variation of:
|
||||
rows = lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||
```
|
||||
|
||||
New code:
|
||||
```python
|
||||
from engine import config
|
||||
msg_cols = (
|
||||
config.ACTIVE_THEME.message_gradient
|
||||
if config.ACTIVE_THEME
|
||||
else render._default_magenta_gradient()
|
||||
)
|
||||
rows = lr_gradient(rows, offset, msg_cols)
|
||||
```
|
||||
|
||||
Or use the helper approach (create `msg_gradient()` in render.py):
|
||||
|
||||
```python
|
||||
def msg_gradient(rows, offset):
|
||||
"""Apply message (ntfy) gradient using theme complementary colors."""
|
||||
from engine import config
|
||||
cols = (
|
||||
config.ACTIVE_THEME.message_gradient
|
||||
if config.ACTIVE_THEME
|
||||
else _default_magenta_gradient()
|
||||
)
|
||||
return lr_gradient(rows, offset, cols)
|
||||
```
|
||||
|
||||
Then in scroll.py:
|
||||
```python
|
||||
rows = render.msg_gradient(rows, offset)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run existing scroll tests**
|
||||
|
||||
Run: `pytest tests/test_scroll.py -v`
|
||||
|
||||
Expected: PASS (existing functionality unchanged)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add engine/scroll.py engine/render.py
|
||||
git commit -m "feat: update scroll.py to use theme message gradient
|
||||
|
||||
- Replace MSG_GRAD_COLS reference with config.ACTIVE_THEME.message_gradient
|
||||
- Use fallback magenta gradient when theme not initialized
|
||||
- Ensure ntfy messages render in complementary color from selected theme"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: Color Picker UI
|
||||
|
||||
### Task 5: Create pick_color_theme() function in app.py
|
||||
|
||||
**Files:**
|
||||
- Modify: `engine/app.py:1-300`
|
||||
- Test: manual/integration (interactive)
|
||||
|
||||
- [ ] **Step 1: Write helper functions for color picker UI**
|
||||
|
||||
Edit `engine/app.py`, add before `pick_font_face()` function:
|
||||
|
||||
```python
|
||||
def _draw_color_picker(themes_list, selected):
|
||||
"""Draw the color theme picker menu."""
|
||||
import sys
|
||||
from engine.terminal import CLR, W_GHOST, G_HI, G_DIM, tw
|
||||
|
||||
print(CLR, end="")
|
||||
print()
|
||||
print(f" {G_HI}▼ COLOR THEME{W_GHOST} ─ ↑/↓ or j/k to move, Enter/q to select{G_DIM}")
|
||||
print(f" {W_GHOST}{'─' * (tw() - 4)}\n")
|
||||
|
||||
for i, (theme_id, theme) in enumerate(themes_list):
|
||||
prefix = " ▶ " if i == selected else " "
|
||||
color = G_HI if i == selected else ""
|
||||
reset = "" if i == selected else W_GHOST
|
||||
print(f"{prefix}{color}{theme.name}{reset}")
|
||||
|
||||
print()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create pick_color_theme() function**
|
||||
|
||||
Edit `engine/app.py`, add after helper function:
|
||||
|
||||
```python
|
||||
def pick_color_theme():
|
||||
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
||||
import sys
|
||||
import termios
|
||||
import tty
|
||||
from engine import config, themes
|
||||
|
||||
# Non-interactive fallback: use green
|
||||
if not sys.stdin.isatty():
|
||||
config.set_active_theme("green")
|
||||
return
|
||||
|
||||
themes_list = list(themes.THEME_REGISTRY.items())
|
||||
selected = 0
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
while True:
|
||||
_draw_color_picker(themes_list, selected)
|
||||
key = _read_picker_key()
|
||||
if key == "up":
|
||||
selected = max(0, selected - 1)
|
||||
elif key == "down":
|
||||
selected = min(len(themes_list) - 1, selected + 1)
|
||||
elif key == "enter":
|
||||
break
|
||||
elif key == "interrupt":
|
||||
raise KeyboardInterrupt
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
selected_theme_id = themes_list[selected][0]
|
||||
config.set_active_theme(selected_theme_id)
|
||||
|
||||
theme_name = themes_list[selected][1].name
|
||||
print(f" {G_DIM}> using {theme_name}{RST}")
|
||||
time.sleep(0.8)
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update main() to call pick_color_theme() before pick_font_face()**
|
||||
|
||||
Edit `engine/app.py`, find the `main()` function and locate where `pick_font_face()` is called (around line 265). Add before it:
|
||||
|
||||
```python
|
||||
def main():
|
||||
# ... existing signal handler setup ...
|
||||
|
||||
pick_color_theme() # NEW LINE - before font picker
|
||||
pick_font_face()
|
||||
|
||||
# ... rest of main unchanged ...
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Manual test - run in interactive terminal**
|
||||
|
||||
Run: `python3 mainline.py`
|
||||
|
||||
Expected:
|
||||
- See color theme picker menu before font picker
|
||||
- Can navigate with ↑/↓ or j/k
|
||||
- Can select with Enter or q
|
||||
- Selected theme applies to scrolling headlines
|
||||
- Can select different themes and see colors change
|
||||
|
||||
- [ ] **Step 5: Manual test - run in non-interactive environment**
|
||||
|
||||
Run: `echo "" | python3 mainline.py`
|
||||
|
||||
Expected:
|
||||
- No color picker menu shown
|
||||
- Defaults to green theme
|
||||
- App runs without error
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add engine/app.py
|
||||
git commit -m "feat: add pick_color_theme() UI and integration
|
||||
|
||||
- Create _draw_color_picker() to render menu
|
||||
- Create pick_color_theme() function mirroring font picker pattern
|
||||
- Integrate into main() before font picker
|
||||
- Fallback to green theme in non-interactive environments
|
||||
- Support arrow keys and j/k navigation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6: Integration & Validation
|
||||
|
||||
### Task 6: End-to-end testing and cleanup
|
||||
|
||||
**Files:**
|
||||
- Test: All modified files
|
||||
- Verify: App functionality
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
|
||||
Expected: PASS (all tests, including new ones)
|
||||
|
||||
- [ ] **Step 2: Run linter**
|
||||
|
||||
Run: `ruff check engine/ mainline.py`
|
||||
|
||||
Expected: No errors (fix any style issues)
|
||||
|
||||
- [ ] **Step 3: Manual integration test - green theme**
|
||||
|
||||
Run: `python3 mainline.py`
|
||||
|
||||
Then select "Verdant Green" from picker.
|
||||
|
||||
Expected:
|
||||
- Headlines render in green → deep green
|
||||
- ntfy messages render in magenta gradient
|
||||
- Both work correctly during streaming
|
||||
|
||||
- [ ] **Step 4: Manual integration test - orange theme**
|
||||
|
||||
Run: `python3 mainline.py`
|
||||
|
||||
Then select "Molten Orange" from picker.
|
||||
|
||||
Expected:
|
||||
- Headlines render in orange → deep orange
|
||||
- ntfy messages render in blue gradient
|
||||
- Colors are visually distinct from green
|
||||
|
||||
- [ ] **Step 5: Manual integration test - purple theme**
|
||||
|
||||
Run: `python3 mainline.py`
|
||||
|
||||
Then select "Violet Purple" from picker.
|
||||
|
||||
Expected:
|
||||
- Headlines render in purple → deep purple
|
||||
- ntfy messages render in yellow gradient
|
||||
- Colors are visually distinct from green and orange
|
||||
|
||||
- [ ] **Step 6: Test poetry mode with color picker**
|
||||
|
||||
Run: `python3 mainline.py --poetry`
|
||||
|
||||
Then select "orange" from picker.
|
||||
|
||||
Expected:
|
||||
- Poetry mode works with color picker
|
||||
- Colors apply to poetry rendering
|
||||
|
||||
- [ ] **Step 7: Test code mode with color picker**
|
||||
|
||||
Run: `python3 mainline.py --code`
|
||||
|
||||
Then select "purple" from picker.
|
||||
|
||||
Expected:
|
||||
- Code mode works with color picker
|
||||
- Colors apply to code rendering
|
||||
|
||||
- [ ] **Step 8: Verify acceptance criteria**
|
||||
|
||||
✓ Color picker displays 3 theme options at startup
|
||||
✓ Selection applies to all headline and message gradients
|
||||
✓ Boot UI (title, status) uses hardcoded green (not theme)
|
||||
✓ Scrolling headlines and ntfy messages use theme gradients
|
||||
✓ No persistence between runs (each run picks fresh)
|
||||
✓ Non-TTY environments default to green without error
|
||||
✓ Architecture supports future random/animation modes
|
||||
✓ All gradient color codes finalized with no TBD values
|
||||
|
||||
- [ ] **Step 9: Final commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: color scheme switcher implementation complete
|
||||
|
||||
Closes color-pick feature with:
|
||||
- Three selectable color themes (green, orange, purple)
|
||||
- Interactive menu at startup (mirrors font picker UI)
|
||||
- Complementary colors for ntfy message queue
|
||||
- Fallback to green in non-interactive environments
|
||||
- All tests passing, manual validation complete"
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Create feature branch PR summary**
|
||||
|
||||
```
|
||||
## Color Scheme Switcher
|
||||
|
||||
Implements interactive color theme selection for Mainline news ticker.
|
||||
|
||||
### What's New
|
||||
- 3 color themes: Verdant Green, Molten Orange, Violet Purple
|
||||
- Interactive picker at startup (↑/↓ or j/k, Enter to select)
|
||||
- Complementary gradients for ntfy messages (magenta, blue, yellow)
|
||||
- Fresh theme selection each run (no persistence)
|
||||
|
||||
### Files Changed
|
||||
- `engine/themes.py` (new)
|
||||
- `engine/config.py` (ACTIVE_THEME, set_active_theme)
|
||||
- `engine/render.py` (theme-aware gradients)
|
||||
- `engine/scroll.py` (message gradient integration)
|
||||
- `engine/app.py` (pick_color_theme UI)
|
||||
- `tests/test_themes.py` (new theme tests)
|
||||
- `README.md` (documentation)
|
||||
|
||||
### Acceptance Criteria
|
||||
All met. App fully tested and ready for merge.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Unit tests: `pytest tests/test_themes.py -v`
|
||||
- [ ] Unit tests: `pytest tests/test_config.py -v`
|
||||
- [ ] Unit tests: `pytest tests/test_render.py -v`
|
||||
- [ ] Full suite: `pytest tests/ -v`
|
||||
- [ ] Linting: `ruff check engine/ mainline.py`
|
||||
- [ ] Manual: Green theme selection
|
||||
- [ ] Manual: Orange theme selection
|
||||
- [ ] Manual: Purple theme selection
|
||||
- [ ] Manual: Poetry mode with colors
|
||||
- [ ] Manual: Code mode with colors
|
||||
- [ ] Manual: Non-TTY fallback
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `themes.py` is data-only; never import config or render to prevent cycles
|
||||
- `ACTIVE_THEME` initialized to None; guaranteed non-None before stream() via pick_color_theme()
|
||||
- Font picker UI remains hardcoded green; title/subtitle use G_HI/G_DIM constants (not theme)
|
||||
- Message gradients use complementary colors; lookup in scroll.py
|
||||
- Each gradient has 12 colors; verify length in tests
|
||||
- No persistence; fresh picker each run
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,154 +0,0 @@
|
||||
# Code Scroll Mode — Design Spec
|
||||
|
||||
**Date:** 2026-03-16
|
||||
**Branch:** feat/code-scroll
|
||||
**Status:** Approved
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Add a `--code` CLI flag that puts MAINLINE into "source consciousness" mode. Instead of RSS headlines or poetry stanzas, the program's own source code scrolls upward as large OTF half-block characters with the standard white-hot → deep green gradient. Each scroll item is one non-blank, non-comment line from `engine/*.py`, attributed to its enclosing function/class scope and dotted module path.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- Mirror the existing `--poetry` mode pattern as closely as possible
|
||||
- Zero new runtime dependencies (stdlib `ast` and `pathlib` only)
|
||||
- No changes to `scroll.py` or the render pipeline
|
||||
- The item tuple shape `(text, src, ts)` is unchanged
|
||||
|
||||
---
|
||||
|
||||
## New Files
|
||||
|
||||
### `engine/fetch_code.py`
|
||||
|
||||
Single public function `fetch_code()` that returns `(items, line_count, 0)`.
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
1. Glob `engine/*.py` in sorted order
|
||||
2. For each file:
|
||||
a. Read source text
|
||||
b. `ast.parse(source)` → build a `{line_number: scope_label}` map by walking all `FunctionDef`, `AsyncFunctionDef`, and `ClassDef` nodes. Each node covers its full line range. Inner scopes override outer ones.
|
||||
c. Iterate source lines (1-indexed). Skip if:
|
||||
- The stripped line is empty
|
||||
- The stripped line starts with `#`
|
||||
d. For each kept line emit:
|
||||
- `text` = `line.rstrip()` (preserve indentation for readability in the big render)
|
||||
- `src` = scope label from the AST map, e.g. `stream()` for functions, `MicMonitor` for classes, `<module>` for top-level lines
|
||||
- `ts` = dotted module path derived from filename, e.g. `engine/scroll.py` → `engine.scroll`
|
||||
3. Return `(items, len(items), 0)`
|
||||
|
||||
**Scope label rules:**
|
||||
- `FunctionDef` / `AsyncFunctionDef` → `name()`
|
||||
- `ClassDef` → `name` (no parens)
|
||||
- No enclosing node → `<module>`
|
||||
|
||||
**Dependencies:** `ast`, `pathlib` — stdlib only.
|
||||
|
||||
---
|
||||
|
||||
## Modified Files
|
||||
|
||||
### `engine/config.py`
|
||||
|
||||
Extend `MODE` detection to recognise `--code`:
|
||||
|
||||
```python
|
||||
MODE = (
|
||||
"poetry" if "--poetry" in sys.argv or "-p" in sys.argv
|
||||
else "code" if "--code" in sys.argv
|
||||
else "news"
|
||||
)
|
||||
```
|
||||
|
||||
### `engine/app.py`
|
||||
|
||||
**Subtitle line** — extend the subtitle dict:
|
||||
|
||||
```python
|
||||
_subtitle = {
|
||||
"poetry": "literary consciousness stream",
|
||||
"code": "source consciousness stream",
|
||||
}.get(config.MODE, "digital consciousness stream")
|
||||
```
|
||||
|
||||
**Boot sequence** — add `elif config.MODE == "code":` branch after the poetry branch:
|
||||
|
||||
```python
|
||||
elif config.MODE == "code":
|
||||
from engine.fetch_code import fetch_code
|
||||
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
||||
time.sleep(0.2)
|
||||
print()
|
||||
items, line_count, _ = fetch_code()
|
||||
print()
|
||||
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
||||
```
|
||||
|
||||
No cache save/load — local source files are read instantly and change only on disk writes.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
engine/*.py (sorted)
|
||||
│
|
||||
▼
|
||||
fetch_code()
|
||||
│ ast.parse → scope map
|
||||
│ filter blank + comment lines
|
||||
│ emit (line, scope(), engine.module)
|
||||
▼
|
||||
items: List[Tuple[str, str, str]]
|
||||
│
|
||||
▼
|
||||
stream(items, ntfy, mic) ← unchanged
|
||||
│
|
||||
▼
|
||||
next_headline() shuffles + recycles automatically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If a file fails to `ast.parse` (malformed source), fall back to `<module>` scope for all lines in that file — do not crash.
|
||||
- If `engine/` contains no `.py` files (shouldn't happen in practice), `fetch_code()` returns an empty list; `app.py`'s existing `if not items:` guard handles this.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
New file: `tests/test_fetch_code.py`
|
||||
|
||||
| Test | Assertion |
|
||||
|------|-----------|
|
||||
| `test_items_are_tuples` | Every item from `fetch_code()` is a 3-tuple of strings |
|
||||
| `test_blank_and_comment_lines_excluded` | No item text is empty; no item text (stripped) starts with `#` |
|
||||
| `test_module_path_format` | Every `ts` field matches pattern `engine\.\w+` |
|
||||
|
||||
No mocking — tests read the real engine source files, keeping them honest against actual content.
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
python3 mainline.py --code # source consciousness mode
|
||||
uv run mainline.py --code
|
||||
```
|
||||
|
||||
Compatible with all existing flags (`--no-font-picker`, `--font-file`, `--firehose`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Syntax highlighting / token-aware coloring (can be added later)
|
||||
- `--code-dir` flag for pointing at arbitrary directories (YAGNI)
|
||||
- Caching code items to disk
|
||||
@@ -1,299 +0,0 @@
|
||||
# Color Scheme Switcher Design
|
||||
|
||||
**Date:** 2026-03-16
|
||||
**Status:** Revised after review
|
||||
**Scope:** Interactive color theme selection for Mainline news ticker
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Mainline currently renders news headlines with a fixed white-hot → deep green gradient. This feature adds an interactive theme picker at startup that lets users choose between three precise color schemes (green, orange, purple), each with complementary message queue colors.
|
||||
|
||||
The implementation uses a dedicated `Theme` class to encapsulate gradients and metadata, enabling future extensions like random rotation, animation, or additional themes without architectural changes.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional:**
|
||||
1. User selects a color theme from an interactive menu at startup (green, orange, or purple)
|
||||
2. Main headline gradient uses the selected primary color (white → color)
|
||||
3. Message queue (ntfy) gradient uses the precise complementary color (white → opposite)
|
||||
4. Selection is fresh each run (no persistence)
|
||||
5. Design supports future "random rotation" mode without refactoring
|
||||
|
||||
**Complementary colors (precise opposites):**
|
||||
- Green (38;5;22) → Magenta (38;5;89) *(current, unchanged)*
|
||||
- Orange (38;5;208) → Blue (38;5;21)
|
||||
- Purple (38;5;129) → Yellow (38;5;226)
|
||||
|
||||
**Non-functional:**
|
||||
- Reuse the existing font picker pattern for UI consistency
|
||||
- Zero runtime overhead during streaming (theme lookup happens once at startup)
|
||||
- **Boot UI (title, subtitle, status lines) use hardcoded green color constants (G_HI, G_DIM, G_MID); only scrolling headlines and ntfy messages use theme gradients**
|
||||
- Font picker UI remains hardcoded green for visual continuity
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### New Module: `engine/themes.py`
|
||||
|
||||
**Data-only module:** Contains Theme class, THEME_REGISTRY, and get_theme() function. **Imports only typing; does NOT import config or render** to prevent circular dependencies.
|
||||
|
||||
```python
|
||||
class Theme:
|
||||
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
||||
|
||||
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
||||
self.name = name
|
||||
self.main_gradient = main_gradient # white → primary color
|
||||
self.message_gradient = message_gradient # white → complementary
|
||||
```
|
||||
|
||||
**Theme Registry:**
|
||||
Three instances registered by ID: `"green"`, `"orange"`, `"purple"` (IDs match menu labels for clarity).
|
||||
|
||||
Each gradient is a list of 12 ANSI 256-color codes matching the current green gradient:
|
||||
```
|
||||
[
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;195m", # pale white-tint
|
||||
"\033[38;5;123m", # bright cyan
|
||||
"\033[38;5;118m", # bright lime
|
||||
"\033[38;5;82m", # lime
|
||||
"\033[38;5;46m", # bright color
|
||||
"\033[38;5;40m", # color
|
||||
"\033[38;5;34m", # medium color
|
||||
"\033[38;5;28m", # dark color
|
||||
"\033[38;5;22m", # deep color
|
||||
"\033[2;38;5;22m", # dim deep color
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
```
|
||||
|
||||
**Finalized color codes:**
|
||||
|
||||
**Green (primary: 22, complementary: 89)** — unchanged from current
|
||||
- Main: `[231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22(dim), 235]`
|
||||
- Messages: `[231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89(dim), 235]`
|
||||
|
||||
**Orange (primary: 208, complementary: 21)**
|
||||
- Main: `[231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94(dim), 235]`
|
||||
- Messages: `[231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18(dim), 235]`
|
||||
|
||||
**Purple (primary: 129, complementary: 226)**
|
||||
- Main: `[231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57(dim), 235]`
|
||||
- Messages: `[231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172(dim), 235]`
|
||||
|
||||
**Public API:**
|
||||
- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises KeyError if not found
|
||||
- `THEME_REGISTRY` — dict of all available themes (for picker)
|
||||
|
||||
---
|
||||
|
||||
### Modified: `engine/config.py`
|
||||
|
||||
**New globals:**
|
||||
```python
|
||||
ACTIVE_THEME = None # set by set_active_theme() after picker; guaranteed non-None during stream()
|
||||
```
|
||||
|
||||
**New function:**
|
||||
```python
|
||||
def set_active_theme(theme_id: str = "green"):
|
||||
"""Set the active theme. Defaults to 'green' if not specified."""
|
||||
global ACTIVE_THEME
|
||||
from engine import themes
|
||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Called by `app.pick_color_theme()` with user selection
|
||||
- Has default fallback to "green" for non-interactive environments (CI, testing, piped stdin)
|
||||
- Guarantees `ACTIVE_THEME` is set before any render functions are called
|
||||
|
||||
**Removal:**
|
||||
- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` constants
|
||||
|
||||
---
|
||||
|
||||
### Modified: `engine/render.py`
|
||||
|
||||
**Updated gradient access in existing functions:**
|
||||
|
||||
Current pattern (will be removed):
|
||||
```python
|
||||
GRAD_COLS = [...] # hardcoded green
|
||||
MSG_GRAD_COLS = [...] # hardcoded magenta
|
||||
```
|
||||
|
||||
New pattern — update `lr_gradient()` function:
|
||||
```python
|
||||
def lr_gradient(rows, offset, cols=None):
|
||||
if cols is None:
|
||||
from engine import config
|
||||
cols = (config.ACTIVE_THEME.main_gradient
|
||||
if config.ACTIVE_THEME
|
||||
else _default_green_gradient())
|
||||
# ... rest of function unchanged
|
||||
```
|
||||
|
||||
**Define fallback:**
|
||||
```python
|
||||
def _default_green_gradient():
|
||||
"""Fallback green gradient (current colors)."""
|
||||
return [
|
||||
"\033[1;38;5;231m", "\033[1;38;5;195m", "\033[38;5;123m",
|
||||
"\033[38;5;118m", "\033[38;5;82m", "\033[38;5;46m",
|
||||
"\033[38;5;40m", "\033[38;5;34m", "\033[38;5;28m",
|
||||
"\033[38;5;22m", "\033[2;38;5;22m", "\033[2;38;5;235m",
|
||||
]
|
||||
```
|
||||
|
||||
**Message gradient handling:**
|
||||
|
||||
The existing code (scroll.py line 89) calls `lr_gradient()` with `MSG_GRAD_COLS`. Change this call to:
|
||||
```python
|
||||
# Instead of: lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||
# Use:
|
||||
from engine import config
|
||||
cols = (config.ACTIVE_THEME.message_gradient
|
||||
if config.ACTIVE_THEME
|
||||
else _default_magenta_gradient())
|
||||
lr_gradient(rows, offset, cols)
|
||||
```
|
||||
|
||||
or define a helper:
|
||||
```python
|
||||
def msg_gradient(rows, offset):
|
||||
"""Apply message (ntfy) gradient using theme complementary colors."""
|
||||
from engine import config
|
||||
cols = (config.ACTIVE_THEME.message_gradient
|
||||
if config.ACTIVE_THEME
|
||||
else _default_magenta_gradient())
|
||||
return lr_gradient(rows, offset, cols)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Modified: `engine/app.py`
|
||||
|
||||
**New function: `pick_color_theme()`**
|
||||
|
||||
Mirrors `pick_font_face()` pattern:
|
||||
|
||||
```python
|
||||
def pick_color_theme():
|
||||
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
||||
import sys
|
||||
from engine import config, themes
|
||||
|
||||
# Non-interactive fallback: use default
|
||||
if not sys.stdin.isatty():
|
||||
config.set_active_theme("green")
|
||||
return
|
||||
|
||||
# Interactive picker (similar to font picker)
|
||||
themes_list = list(themes.THEME_REGISTRY.items())
|
||||
selected = 0
|
||||
|
||||
# ... render menu, handle arrow keys j/k, ↑/↓ ...
|
||||
# ... on Enter, call config.set_active_theme(themes_list[selected][0]) ...
|
||||
```
|
||||
|
||||
**Placement in `main()`:**
|
||||
```python
|
||||
def main():
|
||||
# ... signal handler setup ...
|
||||
pick_color_theme() # NEW — before title/subtitle
|
||||
pick_font_face()
|
||||
# ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ...
|
||||
```
|
||||
|
||||
**Important:** The title and subtitle render with hardcoded `G_HI`/`G_DIM` constants, not theme gradients. This is intentional for visual consistency with the font picker menu.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User starts: mainline.py
|
||||
↓
|
||||
main() called
|
||||
↓
|
||||
pick_color_theme()
|
||||
→ If TTY: display menu, read input, call config.set_active_theme(user_choice)
|
||||
→ If not TTY: silently call config.set_active_theme("green")
|
||||
↓
|
||||
pick_font_face() — renders in hardcoded green UI colors
|
||||
↓
|
||||
Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients)
|
||||
↓
|
||||
stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients
|
||||
↓
|
||||
On exit: no persistence
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Initialization Guarantee
|
||||
`config.ACTIVE_THEME` is guaranteed to be non-None before `stream()` is called because:
|
||||
1. `pick_color_theme()` always sets it (either interactively or via fallback)
|
||||
2. It's called before any rendering happens
|
||||
3. Default fallback ensures non-TTY environments don't crash
|
||||
|
||||
### Module Independence
|
||||
`themes.py` is a pure data module with no imports of `config` or `render`. This prevents circular dependencies and allows it to be imported by multiple consumers without side effects.
|
||||
|
||||
### Color Code Finalization
|
||||
All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain.
|
||||
|
||||
### Theme ID Naming
|
||||
IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity.
|
||||
|
||||
### Terminal Resize Handling
|
||||
The `pick_color_theme()` function mirrors `pick_font_face()`, which does not handle terminal resizing during the picker display. If the terminal is resized while the picker menu is shown, the menu redraw may be incomplete; pressing any key (arrow, j/k, q) continues normally. This is acceptable because:
|
||||
1. The picker completes quickly (< 5 seconds typical interaction)
|
||||
2. Once a theme is selected, the menu closes and rendering begins
|
||||
3. The streaming phase (`stream()`) is resilient to terminal resizing and auto-reflows to new dimensions
|
||||
|
||||
No special resize handling is needed for the color picker beyond what exists for the font picker.
|
||||
|
||||
### Testing Strategy
|
||||
1. **Unit tests** (`tests/test_themes.py`):
|
||||
- Verify Theme class construction
|
||||
- Test THEME_REGISTRY lookup (valid and invalid IDs)
|
||||
- Confirm gradient lists have correct length (12)
|
||||
|
||||
2. **Integration tests** (`tests/test_render.py`):
|
||||
- Mock `config.ACTIVE_THEME` to each theme
|
||||
- Verify `lr_gradient()` uses correct colors
|
||||
- Verify fallback works when `ACTIVE_THEME` is None
|
||||
|
||||
3. **Existing tests:**
|
||||
- Render tests that check gradient output will need to mock `config.ACTIVE_THEME`
|
||||
- Use pytest fixtures to set theme per test case
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
- `engine/themes.py` (new)
|
||||
- `engine/config.py` (add `ACTIVE_THEME`, `set_active_theme()`)
|
||||
- `engine/render.py` (replace GRAD_COLS/MSG_GRAD_COLS references with config lookups)
|
||||
- `engine/app.py` (add `pick_color_theme()`, call in main)
|
||||
- `tests/test_themes.py` (new unit tests)
|
||||
- `tests/test_render.py` (update mocking strategy)
|
||||
|
||||
## Acceptance Criteria
|
||||
1. ✓ Color picker displays 3 theme options at startup
|
||||
2. ✓ Selection applies to all headline and message gradients
|
||||
3. ✓ Boot UI (title, status) uses hardcoded green (not theme)
|
||||
4. ✓ Scrolling headlines and ntfy messages use theme gradients
|
||||
5. ✓ No persistence between runs
|
||||
6. ✓ Non-TTY environments default to green without error
|
||||
7. ✓ Architecture supports future random/animation modes
|
||||
8. ✓ All gradient color codes finalized with no TBD values
|
||||
@@ -1,308 +0,0 @@
|
||||
# Figment Mode Design Spec
|
||||
|
||||
> Periodic full-screen SVG glyph overlay with flickery animation, theme-aware coloring, and extensible physical device control.
|
||||
|
||||
## Overview
|
||||
|
||||
Figment mode displays a randomly selected SVG from the `figments/` directory as a flickery, glitchy half-block terminal overlay on top of the running ticker. It appears once per minute (configurable), holds for ~4.5 seconds with a three-phase animation (progressive reveal, strobing hold, dissolve), then fades back to the ticker. Colors are randomly chosen from the existing theme gradients.
|
||||
|
||||
The feature is designed for extensibility: a generic input protocol allows MQTT, ntfy, serial, or any other control surface to trigger figments and adjust parameters in real time.
|
||||
|
||||
## Goals
|
||||
|
||||
- Display SVG figments as half-block terminal art overlaid on the running ticker
|
||||
- Three-phase animation: progressive reveal, strobing hold, dissolve
|
||||
- Random color from existing theme gradients (green, orange, purple)
|
||||
- Configurable interval and duration via C&C
|
||||
- Extensible input abstraction for physical device control (MQTT, serial, etc.)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Multi-figment simultaneous display (one at a time)
|
||||
- SVG animation support (static SVGs only; animation comes from the overlay phases)
|
||||
- Custom color palettes beyond existing themes
|
||||
- MQTT and serial adapters (v1 ships with ntfy C&C only; protocol is ready for future adapters)
|
||||
|
||||
## Architecture: Hybrid Plugin + Overlay
|
||||
|
||||
The figment is an **EffectPlugin** for lifecycle, discovery, and configuration, but delegates rendering to a **layers-style overlay helper**. This avoids stretching the `EffectPlugin.process()` contract (which transforms line buffers) while still benefiting from the plugin system for C&C, auto-discovery, and config management.
|
||||
|
||||
**Important**: The plugin class is named `FigmentEffect` (not `FigmentPlugin`) to match the `*Effect` naming convention required by `discover_plugins()` in `effects_plugins/__init__.py`. The plugin is **not** added to the `EffectChain` order list — its `process()` is a no-op that returns the buffer unchanged. The chain only processes effects that transform buffers (noise, fade, glitch, firehose). Figment's rendering happens via the overlay path in `scroll.py`, outside the chain.
|
||||
|
||||
### Component Diagram
|
||||
|
||||
```
|
||||
+-------------------+
|
||||
| FigmentTrigger | (Protocol)
|
||||
| - NtfyTrigger | (v1)
|
||||
| - MqttTrigger | (future)
|
||||
| - SerialTrigger | (future)
|
||||
+--------+----------+
|
||||
|
|
||||
| FigmentCommand
|
||||
v
|
||||
+------------------+ +-----------------+ +----------------------+
|
||||
| figment_render |<---| FigmentEffect |--->| render_figment_ |
|
||||
| .py | | (EffectPlugin) | | overlay() in |
|
||||
| | | | | layers.py |
|
||||
| SVG -> PIL -> | | Timer, state | | |
|
||||
| half-block cache | | machine, SVG | | ANSI cursor-position |
|
||||
| | | selection | | commands for overlay |
|
||||
+------------------+ +-----------------+ +----------------------+
|
||||
|
|
||||
| get_figment_state()
|
||||
v
|
||||
+-------------------+
|
||||
| scroll.py |
|
||||
+-------------------+
|
||||
```
|
||||
|
||||
## Section 1: SVG Rasterization
|
||||
|
||||
**File: `engine/figment_render.py`**
|
||||
|
||||
Reuses the same PIL-based half-block encoding that `engine/render.py` uses for OTF fonts.
|
||||
|
||||
### Pipeline
|
||||
|
||||
1. **Load**: `cairosvg.svg2png()` converts SVG to PNG bytes in memory (no temp files)
|
||||
2. **Resize**: PIL scales to fit terminal — width = `tw()`, height = `th() * 2` pixels (each terminal row encodes 2 pixel rows via half-blocks)
|
||||
3. **Threshold**: Convert to greyscale ("L" mode), apply binary threshold to get visible/not-visible
|
||||
4. **Half-block encode**: Walk pixel pairs top-to-bottom. For each 2-row pair, emit `█` (both lit), `▀` (top only), `▄` (bottom only), or space (neither)
|
||||
5. **Cache**: Results cached per `(svg_path, terminal_width, terminal_height)` — invalidated on terminal resize
|
||||
|
||||
### Dependency
|
||||
|
||||
`cairosvg` added as an optional dependency in `pyproject.toml` (like `sounddevice`). If `cairosvg` is not installed, the `FigmentEffect` class will fail to import, and `discover_plugins()` will silently skip it (the existing `except Exception: pass` in discovery handles this). The plugin simply won't appear in the registry.
|
||||
|
||||
### Key Function
|
||||
|
||||
```python
|
||||
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
|
||||
"""Convert SVG file to list of half-block terminal rows (uncolored)."""
|
||||
```
|
||||
|
||||
## Section 2: Figment Overlay Rendering
|
||||
|
||||
**Integration point: `engine/layers.py`**
|
||||
|
||||
New function following the `render_message_overlay()` pattern.
|
||||
|
||||
### FigmentState Dataclass
|
||||
|
||||
Defined in `effects_plugins/figment.py`, passed between the plugin and the overlay renderer:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class FigmentState:
|
||||
phase: FigmentPhase # enum: REVEAL, HOLD, DISSOLVE
|
||||
progress: float # 0.0 to 1.0 within current phase
|
||||
rows: list[str] # rasterized half-block rows (uncolored)
|
||||
gradient: list[int] # 12-color ANSI 256 gradient from chosen theme
|
||||
center_row: int # top row for centering in viewport
|
||||
center_col: int # left column for centering in viewport
|
||||
```
|
||||
|
||||
### Function Signature
|
||||
|
||||
```python
|
||||
def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[str]:
|
||||
"""Return ANSI cursor-positioning commands for the current figment frame."""
|
||||
```
|
||||
|
||||
### Animation Phases (~4.5 seconds total)
|
||||
|
||||
Progress advances each frame as: `progress += config.FRAME_DT / phase_duration`. At 20 FPS (FRAME_DT=0.05s), a 1.5s phase takes 30 frames to complete.
|
||||
|
||||
| Phase | Duration | Behavior |
|
||||
|-------|----------|----------|
|
||||
| **Reveal** | ~1.5s | Progressive scanline fill. Each frame, a percentage of the figment's non-empty cells become visible in random block order. Intensity scales reveal speed. |
|
||||
| **Hold** | ~1.5s | Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. Intensity scales strobe frequency. |
|
||||
| **Dissolve** | ~1.5s | Inverse of reveal. Cells randomly drop out, replaced by spaces. Intensity scales dissolve speed. |
|
||||
|
||||
### Color
|
||||
|
||||
A random theme gradient is selected from `THEME_REGISTRY` at trigger time. Applied via `lr_gradient()` — the same function that colors headlines and messages.
|
||||
|
||||
### Positioning
|
||||
|
||||
Figment is centered in the viewport. Each visible row is an ANSI `\033[row;colH` command appended to the buffer, identical to how the message overlay works.
|
||||
|
||||
## Section 3: FigmentEffect (Effect Plugin)
|
||||
|
||||
**File: `effects_plugins/figment.py`**
|
||||
|
||||
An `EffectPlugin(ABC)` subclass named `FigmentEffect` to match the `*Effect` discovery convention.
|
||||
|
||||
### Chain Exclusion
|
||||
|
||||
`FigmentEffect` is registered in the `EffectRegistry` (for C&C access and config management) but is **not** added to the `EffectChain` order list. Its `process()` returns the buffer unchanged. The `enabled` flag is checked directly by `scroll.py` when deciding whether to call `get_figment_state()`, not by the chain.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- **Timer**: Tracks elapsed time via `config.FRAME_DT` accumulation. At the configured interval (default 60s), triggers a new figment.
|
||||
- **SVG selection**: Randomly picks from `figments/*.svg`. Avoids repeating the last shown.
|
||||
- **State machine**: `idle -> reveal -> hold -> dissolve -> idle`. Tracks phase progress (0.0 to 1.0).
|
||||
- **Color selection**: Picks a random theme key (`"green"`, `"orange"`, `"purple"`) at trigger time.
|
||||
- **Rasterization**: Calls `rasterize_svg()` on trigger, caches result for the display duration.
|
||||
|
||||
### State Machine
|
||||
|
||||
```
|
||||
idle ──(timer fires or trigger received)──> reveal
|
||||
reveal ──(progress >= 1.0)──> hold
|
||||
hold ──(progress >= 1.0)──> dissolve
|
||||
dissolve ──(progress >= 1.0)──> idle
|
||||
```
|
||||
|
||||
### Interface
|
||||
|
||||
The `process()` method returns the buffer unchanged (no-op). The plugin exposes state via:
|
||||
|
||||
```python
|
||||
def get_figment_state(self, frame_number: int) -> FigmentState | None:
|
||||
"""Tick the state machine and return current state, or None if idle."""
|
||||
```
|
||||
|
||||
This mirrors the `ntfy_poller.get_active_message()` pattern.
|
||||
|
||||
### Scroll Loop Access
|
||||
|
||||
`scroll.py` imports `FigmentEffect` directly and uses `isinstance()` to safely downcast from the registry:
|
||||
|
||||
```python
|
||||
from effects_plugins.figment import FigmentEffect
|
||||
|
||||
plugin = registry.get("figment")
|
||||
figment = plugin if isinstance(plugin, FigmentEffect) else None
|
||||
```
|
||||
|
||||
This is a one-time setup check, not per-frame. If `cairosvg` is missing, the import is wrapped in a try/except and `figment` stays `None`.
|
||||
|
||||
### EffectConfig
|
||||
|
||||
- `enabled`: bool (default `False` — opt-in)
|
||||
- `intensity`: float — scales strobe frequency and reveal/dissolve speed
|
||||
- `params`:
|
||||
- `interval_secs`: 60 (time between figments)
|
||||
- `display_secs`: 4.5 (total animation duration)
|
||||
- `figment_dir`: "figments" (SVG source directory)
|
||||
|
||||
Controllable via C&C: `/effects figment on`, `/effects figment intensity 0.7`.
|
||||
|
||||
## Section 4: Input Abstraction (FigmentTrigger)
|
||||
|
||||
**File: `engine/figment_trigger.py`**
|
||||
|
||||
### Protocol
|
||||
|
||||
```python
|
||||
class FigmentTrigger(Protocol):
|
||||
def poll(self) -> FigmentCommand | None: ...
|
||||
```
|
||||
|
||||
### FigmentCommand
|
||||
|
||||
```python
|
||||
class FigmentAction(Enum):
|
||||
TRIGGER = "trigger"
|
||||
SET_INTENSITY = "set_intensity"
|
||||
SET_INTERVAL = "set_interval"
|
||||
SET_COLOR = "set_color"
|
||||
STOP = "stop"
|
||||
|
||||
@dataclass
|
||||
class FigmentCommand:
|
||||
action: FigmentAction
|
||||
value: float | str | None = None
|
||||
```
|
||||
|
||||
Uses an enum for consistency with `EventType` in `engine/events.py`.
|
||||
|
||||
### Adapters
|
||||
|
||||
| Adapter | Transport | Dependency | Status |
|
||||
|---------|-----------|------------|--------|
|
||||
| `NtfyTrigger` | Existing C&C ntfy topic | None (reuses ntfy) | v1 |
|
||||
| `MqttTrigger` | MQTT broker | `paho-mqtt` (optional) | Future |
|
||||
| `SerialTrigger` | USB serial | `pyserial` (optional) | Future |
|
||||
|
||||
**NtfyTrigger v1**: Subscribes as a callback on the existing `NtfyPoller`. Parses messages with a `/figment` prefix (e.g., `/figment trigger`, `/figment intensity 0.8`). This is separate from the `/effects figment on` C&C path — the trigger protocol allows external devices to send commands without knowing the effects controller API.
|
||||
|
||||
### Integration
|
||||
|
||||
The `FigmentEffect` accepts a list of triggers. Each frame, it polls all triggers and acts on commands. Triggers are optional — if none are configured, the plugin runs on its internal timer alone.
|
||||
|
||||
### EventBus Bridge
|
||||
|
||||
A new `FIGMENT_TRIGGER` variant is added to the `EventType` enum in `engine/events.py`, with a corresponding `FigmentTriggerEvent` dataclass. Triggers publish to the EventBus for other components to react (logging, multi-display sync).
|
||||
|
||||
## Section 5: Scroll Loop Integration
|
||||
|
||||
Minimal change to `engine/scroll.py`:
|
||||
|
||||
```python
|
||||
# In stream() setup (with safe import):
|
||||
try:
|
||||
from effects_plugins.figment import FigmentEffect
|
||||
_plugin = registry.get("figment")
|
||||
figment = _plugin if isinstance(_plugin, FigmentEffect) else None
|
||||
except ImportError:
|
||||
figment = None
|
||||
|
||||
# In frame loop, after effects processing, before ntfy message overlay:
|
||||
if figment and figment.config.enabled:
|
||||
figment_state = figment.get_figment_state(frame_number)
|
||||
if figment_state is not None:
|
||||
figment_overlay = render_figment_overlay(figment_state, w, h)
|
||||
buf.extend(figment_overlay)
|
||||
```
|
||||
|
||||
### Overlay Priority
|
||||
|
||||
Figment overlay appends **after** effects processing but **before** the ntfy message overlay. This means:
|
||||
- Ntfy messages always appear on top of figments (higher priority)
|
||||
- Existing glitch/noise effects run over the ticker underneath the figment
|
||||
|
||||
Note: If more overlay types are added in the future, a priority-based overlay system should replace the current positional ordering.
|
||||
|
||||
## Section 6: Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| `cairosvg` not installed | `FigmentEffect` fails to import; `discover_plugins()` silently skips it; `scroll.py` import guard sets `figment = None` |
|
||||
| `figments/` directory missing | Plugin logs warning at startup, stays in permanent `idle` state |
|
||||
| `figments/` contains zero `.svg` files | Same as above: warning, permanent `idle` |
|
||||
| Malformed SVG | `cairosvg` raises exception; plugin catches it, skips that SVG, picks another. If all SVGs fail, enters permanent `idle` with warning |
|
||||
| Terminal resize during animation | Re-rasterize on next frame using new dimensions. Cache miss triggers fresh rasterization. Animation phase/progress are preserved; only the rendered rows update |
|
||||
|
||||
## Section 7: File Summary
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `effects_plugins/figment.py` | FigmentEffect — lifecycle, timer, state machine, SVG selection, FigmentState/FigmentPhase |
|
||||
| `engine/figment_render.py` | SVG to half-block rasterization pipeline |
|
||||
| `engine/figment_trigger.py` | FigmentTrigger protocol, FigmentAction enum, FigmentCommand, NtfyTrigger adapter |
|
||||
| `figments/` | SVG source directory (ships with sample SVGs) |
|
||||
| `tests/test_figment.py` | FigmentEffect lifecycle, state machine transitions, timer |
|
||||
| `tests/test_figment_render.py` | SVG rasterization, caching, edge cases |
|
||||
| `tests/test_figment_trigger.py` | FigmentCommand parsing, NtfyTrigger adapter |
|
||||
| `tests/fixtures/test.svg` | Minimal SVG for deterministic rasterization tests |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `engine/scroll.py` | Figment overlay integration (setup + per-frame block) |
|
||||
| `engine/layers.py` | Add `render_figment_overlay()` function |
|
||||
| `engine/events.py` | Add `FIGMENT_TRIGGER` to `EventType` enum, add `FigmentTriggerEvent` dataclass |
|
||||
| `pyproject.toml` | Add `cairosvg` as optional dependency |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit**: State machine transitions (idle→reveal→hold→dissolve→idle), timer accuracy (fires at interval_secs), SVG rasterization output dimensions, FigmentCommand parsing, FigmentAction enum coverage
|
||||
- **Integration**: Plugin discovery (verify `FigmentEffect` is found by `discover_plugins()`), overlay rendering with mock terminal dimensions, C&C command handling via `/effects figment on`
|
||||
- **Edge cases**: Missing figments dir, empty dir, malformed SVG, cairosvg unavailable, terminal resize mid-animation
|
||||
- **Fixture**: Minimal `test.svg` (simple rectangle) for deterministic rasterization tests
|
||||
@@ -1,36 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def discover_plugins():
|
||||
from engine.effects.registry import get_registry
|
||||
from engine.effects.types import EffectPlugin
|
||||
|
||||
registry = get_registry()
|
||||
imported = {}
|
||||
|
||||
for file_path in PLUGIN_DIR.glob("*.py"):
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
module_name = file_path.stem
|
||||
if module_name in ("base", "types"):
|
||||
continue
|
||||
|
||||
try:
|
||||
module = __import__(f"effects_plugins.{module_name}", fromlist=[""])
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and issubclass(attr, EffectPlugin)
|
||||
and attr is not EffectPlugin
|
||||
and attr_name.endswith("Effect")
|
||||
):
|
||||
plugin = attr()
|
||||
registry.register(plugin)
|
||||
imported[plugin.name] = plugin
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return imported
|
||||
@@ -1,58 +0,0 @@
|
||||
import random
|
||||
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class FadeEffect(EffectPlugin):
|
||||
name = "fade"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not ctx.ticker_height:
|
||||
return buf
|
||||
result = list(buf)
|
||||
intensity = self.config.intensity
|
||||
|
||||
top_zone = max(1, int(ctx.ticker_height * 0.25))
|
||||
bot_zone = max(1, int(ctx.ticker_height * 0.10))
|
||||
|
||||
for r in range(len(result)):
|
||||
if r >= ctx.ticker_height:
|
||||
continue
|
||||
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||
bot_f = (
|
||||
min(1.0, (ctx.ticker_height - 1 - r) / bot_zone)
|
||||
if bot_zone > 0
|
||||
else 1.0
|
||||
)
|
||||
row_fade = min(top_f, bot_f) * intensity
|
||||
|
||||
if row_fade < 1.0 and result[r].strip():
|
||||
result[r] = self._fade_line(result[r], row_fade)
|
||||
|
||||
return result
|
||||
|
||||
def _fade_line(self, s: str, fade: float) -> str:
|
||||
if fade >= 1.0:
|
||||
return s
|
||||
if fade <= 0.0:
|
||||
return ""
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||
j = i + 2
|
||||
while j < len(s) and not s[j].isalpha():
|
||||
j += 1
|
||||
result.append(s[i : j + 1])
|
||||
i = j + 1
|
||||
elif s[i] == " ":
|
||||
result.append(" ")
|
||||
i += 1
|
||||
else:
|
||||
result.append(s[i] if random.random() < fade else " ")
|
||||
i += 1
|
||||
return "".join(result)
|
||||
|
||||
def configure(self, cfg: EffectConfig) -> None:
|
||||
self.config = cfg
|
||||
@@ -1,200 +0,0 @@
|
||||
"""
|
||||
Figment effect plugin — periodic SVG glyph overlay.
|
||||
|
||||
Owns the figment lifecycle: timer, SVG selection, state machine.
|
||||
Delegates rendering to render_figment_overlay() in engine/layers.py.
|
||||
|
||||
Named FigmentEffect (not FigmentPlugin) to match the *Effect discovery
|
||||
convention in effects_plugins/__init__.py.
|
||||
|
||||
NOT added to the EffectChain order — process() is a no-op. The overlay
|
||||
rendering is handled by scroll.py calling get_figment_state().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
|
||||
from engine import config
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.figment_render import rasterize_svg
|
||||
from engine.figment_trigger import FigmentAction, FigmentCommand, FigmentTrigger
|
||||
from engine.themes import THEME_REGISTRY
|
||||
|
||||
|
||||
class FigmentPhase(Enum):
|
||||
REVEAL = auto()
|
||||
HOLD = auto()
|
||||
DISSOLVE = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class FigmentState:
|
||||
phase: FigmentPhase
|
||||
progress: float
|
||||
rows: list[str]
|
||||
gradient: list[int]
|
||||
center_row: int
|
||||
center_col: int
|
||||
|
||||
|
||||
class FigmentEffect(EffectPlugin):
|
||||
name = "figment"
|
||||
config = EffectConfig(
|
||||
enabled=False,
|
||||
intensity=1.0,
|
||||
params={
|
||||
"interval_secs": 60,
|
||||
"display_secs": 4.5,
|
||||
"figment_dir": "figments",
|
||||
},
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
figment_dir: str | None = None,
|
||||
triggers: list[FigmentTrigger] | None = None,
|
||||
):
|
||||
self.config = EffectConfig(
|
||||
enabled=False,
|
||||
intensity=1.0,
|
||||
params={
|
||||
"interval_secs": 60,
|
||||
"display_secs": 4.5,
|
||||
"figment_dir": figment_dir or "figments",
|
||||
},
|
||||
)
|
||||
self._triggers = triggers or []
|
||||
self._phase: FigmentPhase | None = None
|
||||
self._progress: float = 0.0
|
||||
self._rows: list[str] = []
|
||||
self._gradient: list[int] = []
|
||||
self._center_row: int = 0
|
||||
self._center_col: int = 0
|
||||
self._timer: float = 0.0
|
||||
self._last_svg: str | None = None
|
||||
self._svg_files: list[str] = []
|
||||
self._scan_svgs()
|
||||
|
||||
def _scan_svgs(self) -> None:
|
||||
figment_dir = Path(self.config.params["figment_dir"])
|
||||
if figment_dir.is_dir():
|
||||
self._svg_files = sorted(str(p) for p in figment_dir.glob("*.svg"))
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
return buf
|
||||
|
||||
def configure(self, cfg: EffectConfig) -> None:
|
||||
# Preserve figment_dir if the new config doesn't supply one
|
||||
figment_dir = cfg.params.get(
|
||||
"figment_dir", self.config.params.get("figment_dir", "figments")
|
||||
)
|
||||
self.config = cfg
|
||||
if "figment_dir" not in self.config.params:
|
||||
self.config.params["figment_dir"] = figment_dir
|
||||
self._scan_svgs()
|
||||
|
||||
def trigger(self, w: int, h: int) -> None:
|
||||
"""Manually trigger a figment display."""
|
||||
if not self._svg_files:
|
||||
return
|
||||
|
||||
# Pick a random SVG, avoid repeating
|
||||
candidates = [s for s in self._svg_files if s != self._last_svg]
|
||||
if not candidates:
|
||||
candidates = self._svg_files
|
||||
svg_path = random.choice(candidates)
|
||||
self._last_svg = svg_path
|
||||
|
||||
# Rasterize
|
||||
try:
|
||||
self._rows = rasterize_svg(svg_path, w, h)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Pick random theme gradient
|
||||
theme_key = random.choice(list(THEME_REGISTRY.keys()))
|
||||
self._gradient = THEME_REGISTRY[theme_key].main_gradient
|
||||
|
||||
# Center in viewport
|
||||
figment_h = len(self._rows)
|
||||
figment_w = max((len(r) for r in self._rows), default=0)
|
||||
self._center_row = max(0, (h - figment_h) // 2)
|
||||
self._center_col = max(0, (w - figment_w) // 2)
|
||||
|
||||
# Start reveal phase
|
||||
self._phase = FigmentPhase.REVEAL
|
||||
self._progress = 0.0
|
||||
|
||||
def get_figment_state(
|
||||
self, frame_number: int, w: int, h: int
|
||||
) -> FigmentState | None:
|
||||
"""Tick the state machine and return current state, or None if idle."""
|
||||
if not self.config.enabled:
|
||||
return None
|
||||
|
||||
# Poll triggers
|
||||
for trig in self._triggers:
|
||||
cmd = trig.poll()
|
||||
if cmd is not None:
|
||||
self._handle_command(cmd, w, h)
|
||||
|
||||
# Tick timer when idle
|
||||
if self._phase is None:
|
||||
self._timer += config.FRAME_DT
|
||||
interval = self.config.params.get("interval_secs", 60)
|
||||
if self._timer >= interval:
|
||||
self._timer = 0.0
|
||||
self.trigger(w, h)
|
||||
|
||||
# Tick animation — snapshot current phase/progress, then advance
|
||||
if self._phase is not None:
|
||||
# Capture the state at the start of this frame
|
||||
current_phase = self._phase
|
||||
current_progress = self._progress
|
||||
|
||||
# Advance for next frame
|
||||
display_secs = self.config.params.get("display_secs", 4.5)
|
||||
phase_duration = display_secs / 3.0
|
||||
self._progress += config.FRAME_DT / phase_duration
|
||||
|
||||
if self._progress >= 1.0:
|
||||
self._progress = 0.0
|
||||
if self._phase == FigmentPhase.REVEAL:
|
||||
self._phase = FigmentPhase.HOLD
|
||||
elif self._phase == FigmentPhase.HOLD:
|
||||
self._phase = FigmentPhase.DISSOLVE
|
||||
elif self._phase == FigmentPhase.DISSOLVE:
|
||||
self._phase = None
|
||||
|
||||
return FigmentState(
|
||||
phase=current_phase,
|
||||
progress=current_progress,
|
||||
rows=self._rows,
|
||||
gradient=self._gradient,
|
||||
center_row=self._center_row,
|
||||
center_col=self._center_col,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _handle_command(self, cmd: FigmentCommand, w: int, h: int) -> None:
|
||||
if cmd.action == FigmentAction.TRIGGER:
|
||||
self.trigger(w, h)
|
||||
elif cmd.action == FigmentAction.SET_INTENSITY and isinstance(
|
||||
cmd.value, (int, float)
|
||||
):
|
||||
self.config.intensity = float(cmd.value)
|
||||
elif cmd.action == FigmentAction.SET_INTERVAL and isinstance(
|
||||
cmd.value, (int, float)
|
||||
):
|
||||
self.config.params["interval_secs"] = float(cmd.value)
|
||||
elif cmd.action == FigmentAction.SET_COLOR and isinstance(cmd.value, str):
|
||||
if cmd.value in THEME_REGISTRY:
|
||||
self._gradient = THEME_REGISTRY[cmd.value].main_gradient
|
||||
elif cmd.action == FigmentAction.STOP:
|
||||
self._phase = None
|
||||
self._progress = 0.0
|
||||
@@ -1,72 +0,0 @@
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.sources import FEEDS, POETRY_SOURCES
|
||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||
|
||||
|
||||
class FirehoseEffect(EffectPlugin):
|
||||
name = "firehose"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
firehose_h = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
if firehose_h <= 0 or not ctx.items:
|
||||
return buf
|
||||
|
||||
result = list(buf)
|
||||
intensity = self.config.intensity
|
||||
h = ctx.terminal_height
|
||||
|
||||
for fr in range(firehose_h):
|
||||
scr_row = h - firehose_h + fr + 1
|
||||
fline = self._firehose_line(ctx.items, ctx.terminal_width, intensity)
|
||||
result.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||
return result
|
||||
|
||||
def _firehose_line(self, items: list, w: int, intensity: float) -> str:
|
||||
r = random.random()
|
||||
if r < 0.35 * intensity:
|
||||
title, src, ts = random.choice(items)
|
||||
text = title[: w - 1]
|
||||
color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
|
||||
return f"{color}{text}{RST}"
|
||||
elif r < 0.55 * intensity:
|
||||
d = random.choice([0.45, 0.55, 0.65, 0.75])
|
||||
return "".join(
|
||||
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||
if random.random() < d
|
||||
else " "
|
||||
for _ in range(w)
|
||||
)
|
||||
elif r < 0.78 * intensity:
|
||||
sources = FEEDS if config.MODE == "news" else POETRY_SOURCES
|
||||
src = random.choice(list(sources.keys()))
|
||||
msgs = [
|
||||
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
|
||||
f" ░░ FEED ACTIVE :: {src}",
|
||||
f" >> DECODE 0x{random.randint(0x1000, 0xFFFF):04X} :: {src[:24]}",
|
||||
f" ▒▒ ACQUIRE :: {random.choice(['TCP', 'UDP', 'RSS', 'ATOM', 'XML'])} :: {src}",
|
||||
f" {''.join(random.choice(config.KATA) for _ in range(3))} STRM "
|
||||
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}",
|
||||
]
|
||||
text = random.choice(msgs)[: w - 1]
|
||||
color = random.choice([G_LO, G_DIM, W_GHOST])
|
||||
return f"{color}{text}{RST}"
|
||||
else:
|
||||
title, _, _ = random.choice(items)
|
||||
start = random.randint(0, max(0, len(title) - 20))
|
||||
frag = title[start : start + random.randint(10, 35)]
|
||||
pad = random.randint(0, max(0, w - len(frag) - 8))
|
||||
gp = "".join(
|
||||
random.choice(config.GLITCH) for _ in range(random.randint(1, 3))
|
||||
)
|
||||
text = (" " * pad + gp + " " + frag)[: w - 1]
|
||||
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||
return f"{color}{text}{RST}"
|
||||
|
||||
def configure(self, cfg: EffectConfig) -> None:
|
||||
self.config = cfg
|
||||
@@ -1,37 +0,0 @@
|
||||
import random
|
||||
|
||||
from engine import config
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
||||
|
||||
|
||||
class GlitchEffect(EffectPlugin):
|
||||
name = "glitch"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not buf:
|
||||
return buf
|
||||
result = list(buf)
|
||||
intensity = self.config.intensity
|
||||
|
||||
glitch_prob = 0.32 + min(0.9, ctx.mic_excess * 0.16)
|
||||
glitch_prob = glitch_prob * intensity
|
||||
n_hits = 4 + int(ctx.mic_excess / 2)
|
||||
n_hits = int(n_hits * intensity)
|
||||
|
||||
if random.random() < glitch_prob:
|
||||
for _ in range(min(n_hits, len(result))):
|
||||
gi = random.randint(0, len(result) - 1)
|
||||
scr_row = gi + 1
|
||||
result[gi] = f"\033[{scr_row};1H{self._glitch_bar(ctx.terminal_width)}"
|
||||
return result
|
||||
|
||||
def _glitch_bar(self, w: int) -> str:
|
||||
c = random.choice(["░", "▒", "─", "\xc2"])
|
||||
n = random.randint(3, w // 2)
|
||||
o = random.randint(0, w - n)
|
||||
return " " * o + f"{G_LO}{DIM}" + c * n + RST
|
||||
|
||||
def configure(self, cfg: EffectConfig) -> None:
|
||||
self.config = cfg
|
||||
@@ -1,36 +0,0 @@
|
||||
import random
|
||||
|
||||
from engine import config
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||
|
||||
|
||||
class NoiseEffect(EffectPlugin):
|
||||
name = "noise"
|
||||
config = EffectConfig(enabled=True, intensity=0.15)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not ctx.ticker_height:
|
||||
return buf
|
||||
result = list(buf)
|
||||
intensity = self.config.intensity
|
||||
probability = intensity * 0.15
|
||||
|
||||
for r in range(len(result)):
|
||||
cy = ctx.scroll_cam + r
|
||||
if random.random() < probability:
|
||||
result[r] = self._generate_noise(ctx.terminal_width, cy)
|
||||
return result
|
||||
|
||||
def _generate_noise(self, w: int, cy: int) -> str:
|
||||
d = random.choice([0.15, 0.25, 0.35, 0.12])
|
||||
return "".join(
|
||||
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||
if random.random() < d
|
||||
else " "
|
||||
for _ in range(w)
|
||||
)
|
||||
|
||||
def configure(self, cfg: EffectConfig) -> None:
|
||||
self.config = cfg
|
||||
@@ -10,7 +10,7 @@ import termios
|
||||
import time
|
||||
import tty
|
||||
|
||||
from engine import config, render, themes
|
||||
from engine import config, render
|
||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||
from engine.mic import MicMonitor
|
||||
from engine.ntfy import NtfyPoller
|
||||
@@ -65,30 +65,6 @@ def _read_picker_key():
|
||||
return None
|
||||
|
||||
|
||||
def _draw_color_picker(themes_list, selected):
|
||||
"""Draw the color theme picker menu.
|
||||
|
||||
Args:
|
||||
themes_list: List of (theme_id, Theme) tuples from THEME_REGISTRY.items()
|
||||
selected: Index of currently selected theme (0-2)
|
||||
"""
|
||||
print(CLR, end="")
|
||||
print()
|
||||
|
||||
print(
|
||||
f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}"
|
||||
)
|
||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}\n")
|
||||
|
||||
for i, (theme_id, theme) in enumerate(themes_list):
|
||||
prefix = " ▶ " if i == selected else " "
|
||||
color = G_HI if i == selected else ""
|
||||
reset = "" if i == selected else W_GHOST
|
||||
print(f"{prefix}{color}{theme.name}{reset}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def _normalize_preview_rows(rows):
|
||||
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
||||
non_empty = [r for r in rows if r.strip()]
|
||||
@@ -155,50 +131,6 @@ def _draw_font_picker(faces, selected):
|
||||
print(f" {shown}")
|
||||
|
||||
|
||||
def pick_color_theme():
|
||||
"""Interactive color theme picker. Defaults to 'green' if not TTY.
|
||||
|
||||
Displays a menu of available themes and lets user select with arrow keys.
|
||||
Non-interactive environments (piped stdin, CI) silently default to green.
|
||||
"""
|
||||
# Non-interactive fallback
|
||||
if not sys.stdin.isatty():
|
||||
config.set_active_theme("green")
|
||||
return
|
||||
|
||||
# Interactive picker
|
||||
themes_list = list(themes.THEME_REGISTRY.items())
|
||||
selected = 0
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
while True:
|
||||
_draw_color_picker(themes_list, selected)
|
||||
key = _read_picker_key()
|
||||
if key == "up":
|
||||
selected = max(0, selected - 1)
|
||||
elif key == "down":
|
||||
selected = min(len(themes_list) - 1, selected + 1)
|
||||
elif key == "enter":
|
||||
break
|
||||
elif key == "interrupt":
|
||||
raise KeyboardInterrupt
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
selected_theme_id = themes_list[selected][0]
|
||||
config.set_active_theme(selected_theme_id)
|
||||
|
||||
theme_name = themes_list[selected][1].name
|
||||
print(f" {G_DIM}> using {theme_name}{RST}")
|
||||
time.sleep(0.8)
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
|
||||
|
||||
def pick_font_face():
|
||||
"""Interactive startup picker for selecting a face from repo OTF files."""
|
||||
if not config.FONT_PICKER:
|
||||
@@ -330,7 +262,6 @@ def main():
|
||||
w = tw()
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
pick_color_theme()
|
||||
pick_font_face()
|
||||
w = tw()
|
||||
print()
|
||||
@@ -341,10 +272,11 @@ def main():
|
||||
time.sleep(0.07)
|
||||
|
||||
print()
|
||||
_subtitle = {
|
||||
"poetry": "literary consciousness stream",
|
||||
"code": "source consciousness stream",
|
||||
}.get(config.MODE, "digital consciousness stream")
|
||||
_subtitle = (
|
||||
"literary consciousness stream"
|
||||
if config.MODE == "poetry"
|
||||
else "digital consciousness stream"
|
||||
)
|
||||
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||
print()
|
||||
@@ -365,15 +297,6 @@ def main():
|
||||
)
|
||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
||||
save_cache(items)
|
||||
elif config.MODE == "code":
|
||||
from engine.fetch_code import fetch_code
|
||||
|
||||
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
||||
time.sleep(0.2)
|
||||
print()
|
||||
items, line_count, _ = fetch_code()
|
||||
print()
|
||||
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
||||
else:
|
||||
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
||||
time.sleep(0.2)
|
||||
@@ -413,14 +336,6 @@ def main():
|
||||
if config.FIREHOSE:
|
||||
boot_ln("Firehose", "ENGAGED", True)
|
||||
|
||||
if config.FIGMENT:
|
||||
try:
|
||||
from effects_plugins.figment import FigmentEffect # noqa: F401
|
||||
|
||||
boot_ln("Figment", f"ARMED [{config.FIGMENT_INTERVAL}s interval]", True)
|
||||
except (ImportError, OSError):
|
||||
boot_ln("Figment", "UNAVAILABLE — run: brew install cairo", False)
|
||||
|
||||
time.sleep(0.4)
|
||||
slow_print(" > STREAMING...\n")
|
||||
time.sleep(0.2)
|
||||
|
||||
176
engine/config.py
176
engine/config.py
@@ -1,28 +1,25 @@
|
||||
"""
|
||||
Configuration constants, CLI flags, and glyph tables.
|
||||
Supports both global constants (backward compatible) and injected config for testing.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"}
|
||||
|
||||
|
||||
def _arg_value(flag, argv: list[str] | None = None):
|
||||
def _arg_value(flag):
|
||||
"""Get value following a CLI flag, if present."""
|
||||
argv = argv or sys.argv
|
||||
if flag not in argv:
|
||||
if flag not in sys.argv:
|
||||
return None
|
||||
i = argv.index(flag)
|
||||
return argv[i + 1] if i + 1 < len(argv) else None
|
||||
i = sys.argv.index(flag)
|
||||
return sys.argv[i + 1] if i + 1 < len(sys.argv) else None
|
||||
|
||||
|
||||
def _arg_int(flag, default, argv: list[str] | None = None):
|
||||
def _arg_int(flag, default):
|
||||
"""Get int CLI argument with safe fallback."""
|
||||
raw = _arg_value(flag, argv)
|
||||
raw = _arg_value(flag)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
@@ -56,148 +53,12 @@ def list_repo_font_files():
|
||||
return _list_font_files(FONT_DIR)
|
||||
|
||||
|
||||
def _get_platform_font_paths() -> dict[str, str]:
|
||||
"""Get platform-appropriate font paths for non-Latin scripts."""
|
||||
import platform
|
||||
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
return {
|
||||
"zh-cn": "/System/Library/Fonts/STHeiti Medium.ttc",
|
||||
"ja": "/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc",
|
||||
"ko": "/System/Library/Fonts/AppleSDGothicNeo.ttc",
|
||||
"ru": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"uk": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"el": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"he": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"ar": "/System/Library/Fonts/GeezaPro.ttc",
|
||||
"fa": "/System/Library/Fonts/GeezaPro.ttc",
|
||||
"hi": "/System/Library/Fonts/Kohinoor.ttc",
|
||||
"th": "/System/Library/Fonts/ThonburiUI.ttc",
|
||||
}
|
||||
elif system == "Linux":
|
||||
return {
|
||||
"zh-cn": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"ja": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"ko": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"ru": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"uk": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"el": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"he": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"ar": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"fa": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"hi": "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf",
|
||||
"th": "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
|
||||
}
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
"""Immutable configuration container for injected config."""
|
||||
|
||||
headline_limit: int = 1000
|
||||
feed_timeout: int = 10
|
||||
mic_threshold_db: int = 50
|
||||
mode: str = "news"
|
||||
firehose: bool = False
|
||||
|
||||
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||
ntfy_reconnect_delay: int = 5
|
||||
message_display_secs: int = 30
|
||||
|
||||
font_dir: str = "fonts"
|
||||
font_path: str = ""
|
||||
font_index: int = 0
|
||||
font_picker: bool = True
|
||||
font_sz: int = 60
|
||||
render_h: int = 8
|
||||
|
||||
ssaa: int = 4
|
||||
|
||||
scroll_dur: float = 5.625
|
||||
frame_dt: float = 0.05
|
||||
firehose_h: int = 12
|
||||
grad_speed: float = 0.08
|
||||
|
||||
glitch_glyphs: str = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||
kata_glyphs: str = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||
|
||||
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||
|
||||
@classmethod
|
||||
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
||||
"""Create Config from CLI arguments (or custom argv for testing)."""
|
||||
argv = argv or sys.argv
|
||||
|
||||
font_dir = _resolve_font_path(_arg_value("--font-dir", argv) or "fonts")
|
||||
font_file_arg = _arg_value("--font-file", argv)
|
||||
font_files = _list_font_files(font_dir)
|
||||
font_path = (
|
||||
_resolve_font_path(font_file_arg)
|
||||
if font_file_arg
|
||||
else (font_files[0] if font_files else "")
|
||||
)
|
||||
|
||||
return cls(
|
||||
headline_limit=1000,
|
||||
feed_timeout=10,
|
||||
mic_threshold_db=50,
|
||||
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
||||
firehose="--firehose" in argv,
|
||||
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
||||
ntfy_reconnect_delay=5,
|
||||
message_display_secs=30,
|
||||
font_dir=font_dir,
|
||||
font_path=font_path,
|
||||
font_index=max(0, _arg_int("--font-index", 0, argv)),
|
||||
font_picker="--no-font-picker" not in argv,
|
||||
font_sz=60,
|
||||
render_h=8,
|
||||
ssaa=4,
|
||||
scroll_dur=5.625,
|
||||
frame_dt=0.05,
|
||||
firehose_h=12,
|
||||
grad_speed=0.08,
|
||||
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
||||
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
||||
script_fonts=_get_platform_font_paths(),
|
||||
)
|
||||
|
||||
|
||||
_config: Config | None = None
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
"""Get the global config instance (lazy-loaded)."""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = Config.from_args()
|
||||
return _config
|
||||
|
||||
|
||||
def set_config(config: Config) -> None:
|
||||
"""Set the global config instance (for testing)."""
|
||||
global _config
|
||||
_config = config
|
||||
|
||||
|
||||
# ─── RUNTIME ──────────────────────────────────────────────
|
||||
HEADLINE_LIMIT = 1000
|
||||
FEED_TIMEOUT = 10
|
||||
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
||||
MODE = (
|
||||
"poetry"
|
||||
if "--poetry" in sys.argv or "-p" in sys.argv
|
||||
else "code"
|
||||
if "--code" in sys.argv
|
||||
else "news"
|
||||
)
|
||||
MODE = "poetry" if "--poetry" in sys.argv or "-p" in sys.argv else "news"
|
||||
FIREHOSE = "--firehose" in sys.argv
|
||||
FIGMENT = "--figment" in sys.argv
|
||||
FIGMENT_INTERVAL = _arg_int("--figment-interval", 60) # seconds between appearances
|
||||
|
||||
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
||||
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||
@@ -239,26 +100,3 @@ def set_font_selection(font_path=None, font_index=None):
|
||||
FONT_PATH = _resolve_font_path(font_path)
|
||||
if font_index is not None:
|
||||
FONT_INDEX = max(0, int(font_index))
|
||||
|
||||
|
||||
# ─── THEME MANAGEMENT ─────────────────────────────────────────
|
||||
ACTIVE_THEME = None
|
||||
|
||||
|
||||
def set_active_theme(theme_id: str = "green"):
|
||||
"""Set the active theme by ID.
|
||||
|
||||
Args:
|
||||
theme_id: Theme identifier ("green", "orange", or "purple")
|
||||
Defaults to "green"
|
||||
|
||||
Raises:
|
||||
KeyError: If theme_id is not in the theme registry
|
||||
|
||||
Side Effects:
|
||||
Sets the ACTIVE_THEME global variable
|
||||
"""
|
||||
global ACTIVE_THEME
|
||||
from engine import themes
|
||||
|
||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""
|
||||
Stream controller - manages input sources and orchestrates the render stream.
|
||||
"""
|
||||
|
||||
from engine.config import Config, get_config
|
||||
from engine.eventbus import EventBus
|
||||
from engine.events import EventType, StreamEvent
|
||||
from engine.mic import MicMonitor
|
||||
from engine.ntfy import NtfyPoller
|
||||
from engine.scroll import stream
|
||||
|
||||
|
||||
class StreamController:
|
||||
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
||||
|
||||
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
|
||||
self.config = config or get_config()
|
||||
self.event_bus = event_bus
|
||||
self.mic: MicMonitor | None = None
|
||||
self.ntfy: NtfyPoller | None = None
|
||||
|
||||
def initialize_sources(self) -> tuple[bool, bool]:
|
||||
"""Initialize microphone and ntfy sources.
|
||||
|
||||
Returns:
|
||||
(mic_ok, ntfy_ok) - success status for each source
|
||||
"""
|
||||
self.mic = MicMonitor(threshold_db=self.config.mic_threshold_db)
|
||||
mic_ok = self.mic.start() if self.mic.available else False
|
||||
|
||||
self.ntfy = NtfyPoller(
|
||||
self.config.ntfy_topic,
|
||||
reconnect_delay=self.config.ntfy_reconnect_delay,
|
||||
display_secs=self.config.message_display_secs,
|
||||
)
|
||||
ntfy_ok = self.ntfy.start()
|
||||
|
||||
return bool(mic_ok), ntfy_ok
|
||||
|
||||
def run(self, items: list) -> None:
|
||||
"""Run the stream with initialized sources."""
|
||||
if self.mic is None or self.ntfy is None:
|
||||
self.initialize_sources()
|
||||
|
||||
if self.event_bus:
|
||||
self.event_bus.publish(
|
||||
EventType.STREAM_START,
|
||||
StreamEvent(
|
||||
event_type=EventType.STREAM_START,
|
||||
headline_count=len(items),
|
||||
),
|
||||
)
|
||||
|
||||
stream(items, self.ntfy, self.mic)
|
||||
|
||||
if self.event_bus:
|
||||
self.event_bus.publish(
|
||||
EventType.STREAM_END,
|
||||
StreamEvent(
|
||||
event_type=EventType.STREAM_END,
|
||||
headline_count=len(items),
|
||||
),
|
||||
)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self.mic:
|
||||
self.mic.stop()
|
||||
@@ -1,102 +0,0 @@
|
||||
"""
|
||||
Display output abstraction - allows swapping output backends.
|
||||
|
||||
Protocol:
|
||||
- init(width, height): Initialize display with terminal dimensions
|
||||
- show(buffer): Render buffer (list of strings) to display
|
||||
- clear(): Clear the display
|
||||
- cleanup(): Shutdown display
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class Display(Protocol):
|
||||
"""Protocol for display backends."""
|
||||
|
||||
def init(self, width: int, height: int) -> None:
|
||||
"""Initialize display with dimensions."""
|
||||
...
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
"""Show buffer on display."""
|
||||
...
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear display."""
|
||||
...
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Shutdown display."""
|
||||
...
|
||||
|
||||
|
||||
def get_monitor():
|
||||
"""Get the performance monitor."""
|
||||
try:
|
||||
from engine.effects.performance import get_monitor as _get_monitor
|
||||
|
||||
return _get_monitor()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class TerminalDisplay:
|
||||
"""ANSI terminal display backend."""
|
||||
|
||||
def __init__(self):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
|
||||
def init(self, width: int, height: int) -> None:
|
||||
from engine.terminal import CURSOR_OFF
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
print(CURSOR_OFF, end="", flush=True)
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
import sys
|
||||
|
||||
t0 = time.perf_counter()
|
||||
sys.stdout.buffer.write("".join(buffer).encode())
|
||||
sys.stdout.flush()
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
from engine.terminal import CLR
|
||||
|
||||
print(CLR, end="", flush=True)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
from engine.terminal import CURSOR_ON
|
||||
|
||||
print(CURSOR_ON, end="", flush=True)
|
||||
|
||||
|
||||
class NullDisplay:
|
||||
"""Headless/null display - discards all output."""
|
||||
|
||||
def init(self, width: int, height: int) -> None:
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
t0 = time.perf_counter()
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
pass
|
||||
@@ -1,42 +0,0 @@
|
||||
from engine.effects.chain import EffectChain
|
||||
from engine.effects.controller import handle_effects_command, show_effects_menu
|
||||
from engine.effects.legacy import (
|
||||
fade_line,
|
||||
firehose_line,
|
||||
glitch_bar,
|
||||
next_headline,
|
||||
noise,
|
||||
vis_trunc,
|
||||
)
|
||||
from engine.effects.performance import PerformanceMonitor, get_monitor, set_monitor
|
||||
from engine.effects.registry import EffectRegistry, get_registry, set_registry
|
||||
from engine.effects.types import EffectConfig, EffectContext, PipelineConfig
|
||||
|
||||
|
||||
def get_effect_chain():
|
||||
from engine.layers import get_effect_chain as _chain
|
||||
|
||||
return _chain()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EffectChain",
|
||||
"EffectRegistry",
|
||||
"EffectConfig",
|
||||
"EffectContext",
|
||||
"PipelineConfig",
|
||||
"get_registry",
|
||||
"set_registry",
|
||||
"get_effect_chain",
|
||||
"get_monitor",
|
||||
"set_monitor",
|
||||
"PerformanceMonitor",
|
||||
"handle_effects_command",
|
||||
"show_effects_menu",
|
||||
"fade_line",
|
||||
"firehose_line",
|
||||
"glitch_bar",
|
||||
"noise",
|
||||
"next_headline",
|
||||
"vis_trunc",
|
||||
]
|
||||
@@ -1,71 +0,0 @@
|
||||
import time
|
||||
|
||||
from engine.effects.performance import PerformanceMonitor, get_monitor
|
||||
from engine.effects.registry import EffectRegistry
|
||||
from engine.effects.types import EffectContext
|
||||
|
||||
|
||||
class EffectChain:
|
||||
def __init__(
|
||||
self, registry: EffectRegistry, monitor: PerformanceMonitor | None = None
|
||||
):
|
||||
self._registry = registry
|
||||
self._order: list[str] = []
|
||||
self._monitor = monitor
|
||||
|
||||
def _get_monitor(self) -> PerformanceMonitor:
|
||||
if self._monitor is not None:
|
||||
return self._monitor
|
||||
return get_monitor()
|
||||
|
||||
def set_order(self, names: list[str]) -> None:
|
||||
self._order = list(names)
|
||||
|
||||
def get_order(self) -> list[str]:
|
||||
return self._order.copy()
|
||||
|
||||
def add_effect(self, name: str, position: int | None = None) -> bool:
|
||||
if name not in self._registry.list_all():
|
||||
return False
|
||||
if position is None:
|
||||
self._order.append(name)
|
||||
else:
|
||||
self._order.insert(position, name)
|
||||
return True
|
||||
|
||||
def remove_effect(self, name: str) -> bool:
|
||||
if name in self._order:
|
||||
self._order.remove(name)
|
||||
return True
|
||||
return False
|
||||
|
||||
def reorder(self, new_order: list[str]) -> bool:
|
||||
all_plugins = set(self._registry.list_all().keys())
|
||||
if not all(name in all_plugins for name in new_order):
|
||||
return False
|
||||
self._order = list(new_order)
|
||||
return True
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
monitor = self._get_monitor()
|
||||
frame_number = ctx.frame_number
|
||||
monitor.start_frame(frame_number)
|
||||
|
||||
frame_start = time.perf_counter()
|
||||
result = list(buf)
|
||||
for name in self._order:
|
||||
plugin = self._registry.get(name)
|
||||
if plugin and plugin.config.enabled:
|
||||
chars_in = sum(len(line) for line in result)
|
||||
effect_start = time.perf_counter()
|
||||
try:
|
||||
result = plugin.process(result, ctx)
|
||||
except Exception:
|
||||
plugin.config.enabled = False
|
||||
elapsed = time.perf_counter() - effect_start
|
||||
chars_out = sum(len(line) for line in result)
|
||||
monitor.record_effect(name, elapsed * 1000, chars_in, chars_out)
|
||||
|
||||
total_elapsed = time.perf_counter() - frame_start
|
||||
monitor.end_frame(frame_number, total_elapsed * 1000)
|
||||
return result
|
||||
@@ -1,144 +0,0 @@
|
||||
from engine.effects.performance import get_monitor
|
||||
from engine.effects.registry import get_registry
|
||||
|
||||
_effect_chain_ref = None
|
||||
|
||||
|
||||
def _get_effect_chain():
|
||||
global _effect_chain_ref
|
||||
if _effect_chain_ref is not None:
|
||||
return _effect_chain_ref
|
||||
try:
|
||||
from engine.layers import get_effect_chain as _chain
|
||||
|
||||
return _chain()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def set_effect_chain_ref(chain) -> None:
|
||||
global _effect_chain_ref
|
||||
_effect_chain_ref = chain
|
||||
|
||||
|
||||
def handle_effects_command(cmd: str) -> str:
|
||||
"""Handle /effects command from NTFY message.
|
||||
|
||||
Commands:
|
||||
/effects list - list all effects and their status
|
||||
/effects <name> on - enable an effect
|
||||
/effects <name> off - disable an effect
|
||||
/effects <name> intensity <0.0-1.0> - set intensity
|
||||
/effects reorder <name1>,<name2>,... - reorder pipeline
|
||||
/effects stats - show performance statistics
|
||||
"""
|
||||
parts = cmd.strip().split()
|
||||
if not parts or parts[0] != "/effects":
|
||||
return "Unknown command"
|
||||
|
||||
registry = get_registry()
|
||||
chain = _get_effect_chain()
|
||||
|
||||
if len(parts) == 1 or parts[1] == "list":
|
||||
result = ["Effects:"]
|
||||
for name, plugin in registry.list_all().items():
|
||||
status = "ON" if plugin.config.enabled else "OFF"
|
||||
intensity = plugin.config.intensity
|
||||
result.append(f" {name}: {status} (intensity={intensity})")
|
||||
if chain:
|
||||
result.append(f"Order: {chain.get_order()}")
|
||||
return "\n".join(result)
|
||||
|
||||
if parts[1] == "stats":
|
||||
return _format_stats()
|
||||
|
||||
if parts[1] == "reorder" and len(parts) >= 3:
|
||||
new_order = parts[2].split(",")
|
||||
if chain and chain.reorder(new_order):
|
||||
return f"Reordered pipeline: {new_order}"
|
||||
return "Failed to reorder pipeline"
|
||||
|
||||
if len(parts) < 3:
|
||||
return "Usage: /effects <name> on|off|intensity <value>"
|
||||
|
||||
effect_name = parts[1]
|
||||
action = parts[2]
|
||||
|
||||
if effect_name not in registry.list_all():
|
||||
return f"Unknown effect: {effect_name}"
|
||||
|
||||
if action == "on":
|
||||
registry.enable(effect_name)
|
||||
return f"Enabled: {effect_name}"
|
||||
|
||||
if action == "off":
|
||||
registry.disable(effect_name)
|
||||
return f"Disabled: {effect_name}"
|
||||
|
||||
if action == "intensity" and len(parts) >= 4:
|
||||
try:
|
||||
value = float(parts[3])
|
||||
if not 0.0 <= value <= 1.0:
|
||||
return "Intensity must be between 0.0 and 1.0"
|
||||
plugin = registry.get(effect_name)
|
||||
if plugin:
|
||||
plugin.config.intensity = value
|
||||
return f"Set {effect_name} intensity to {value}"
|
||||
except ValueError:
|
||||
return "Invalid intensity value"
|
||||
|
||||
return f"Unknown action: {action}"
|
||||
|
||||
|
||||
def _format_stats() -> str:
|
||||
monitor = get_monitor()
|
||||
stats = monitor.get_stats()
|
||||
|
||||
if "error" in stats:
|
||||
return stats["error"]
|
||||
|
||||
lines = ["Performance Stats:"]
|
||||
|
||||
pipeline = stats["pipeline"]
|
||||
lines.append(
|
||||
f" Pipeline: avg={pipeline['avg_ms']:.2f}ms min={pipeline['min_ms']:.2f}ms max={pipeline['max_ms']:.2f}ms (over {stats['frame_count']} frames)"
|
||||
)
|
||||
|
||||
if stats["effects"]:
|
||||
lines.append(" Per-effect (avg ms):")
|
||||
for name, effect_stats in stats["effects"].items():
|
||||
lines.append(
|
||||
f" {name}: avg={effect_stats['avg_ms']:.2f}ms min={effect_stats['min_ms']:.2f}ms max={effect_stats['max_ms']:.2f}ms"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def show_effects_menu() -> str:
|
||||
"""Generate effects menu text for display."""
|
||||
registry = get_registry()
|
||||
chain = _get_effect_chain()
|
||||
|
||||
lines = [
|
||||
"\033[1;38;5;231m=== EFFECTS MENU ===\033[0m",
|
||||
"",
|
||||
"Effects:",
|
||||
]
|
||||
|
||||
for name, plugin in registry.list_all().items():
|
||||
status = "ON" if plugin.config.enabled else "OFF"
|
||||
intensity = plugin.config.intensity
|
||||
lines.append(f" [{status:3}] {name}: intensity={intensity:.2f}")
|
||||
|
||||
if chain:
|
||||
lines.append("")
|
||||
lines.append(f"Pipeline order: {' -> '.join(chain.get_order())}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Controls:")
|
||||
lines.append(" /effects <name> on|off")
|
||||
lines.append(" /effects <name> intensity <0.0-1.0>")
|
||||
lines.append(" /effects reorder name1,name2,...")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -1,103 +0,0 @@
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectTiming:
|
||||
name: str
|
||||
duration_ms: float
|
||||
buffer_chars_in: int
|
||||
buffer_chars_out: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameTiming:
|
||||
frame_number: int
|
||||
total_ms: float
|
||||
effects: list[EffectTiming]
|
||||
|
||||
|
||||
class PerformanceMonitor:
|
||||
"""Collects and stores performance metrics for effect pipeline."""
|
||||
|
||||
def __init__(self, max_frames: int = 60):
|
||||
self._max_frames = max_frames
|
||||
self._frames: deque[FrameTiming] = deque(maxlen=max_frames)
|
||||
self._current_frame: list[EffectTiming] = []
|
||||
|
||||
def start_frame(self, frame_number: int) -> None:
|
||||
self._current_frame = []
|
||||
|
||||
def record_effect(
|
||||
self, name: str, duration_ms: float, chars_in: int, chars_out: int
|
||||
) -> None:
|
||||
self._current_frame.append(
|
||||
EffectTiming(
|
||||
name=name,
|
||||
duration_ms=duration_ms,
|
||||
buffer_chars_in=chars_in,
|
||||
buffer_chars_out=chars_out,
|
||||
)
|
||||
)
|
||||
|
||||
def end_frame(self, frame_number: int, total_ms: float) -> None:
|
||||
self._frames.append(
|
||||
FrameTiming(
|
||||
frame_number=frame_number,
|
||||
total_ms=total_ms,
|
||||
effects=self._current_frame,
|
||||
)
|
||||
)
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
if not self._frames:
|
||||
return {"error": "No timing data available"}
|
||||
|
||||
total_times = [f.total_ms for f in self._frames]
|
||||
avg_total = sum(total_times) / len(total_times)
|
||||
min_total = min(total_times)
|
||||
max_total = max(total_times)
|
||||
|
||||
effect_stats: dict[str, dict] = {}
|
||||
for frame in self._frames:
|
||||
for effect in frame.effects:
|
||||
if effect.name not in effect_stats:
|
||||
effect_stats[effect.name] = {"times": [], "total_chars": 0}
|
||||
effect_stats[effect.name]["times"].append(effect.duration_ms)
|
||||
effect_stats[effect.name]["total_chars"] += effect.buffer_chars_out
|
||||
|
||||
for name, stats in effect_stats.items():
|
||||
times = stats["times"]
|
||||
stats["avg_ms"] = sum(times) / len(times)
|
||||
stats["min_ms"] = min(times)
|
||||
stats["max_ms"] = max(times)
|
||||
del stats["times"]
|
||||
|
||||
return {
|
||||
"frame_count": len(self._frames),
|
||||
"pipeline": {
|
||||
"avg_ms": avg_total,
|
||||
"min_ms": min_total,
|
||||
"max_ms": max_total,
|
||||
},
|
||||
"effects": effect_stats,
|
||||
}
|
||||
|
||||
def reset(self) -> None:
|
||||
self._frames.clear()
|
||||
self._current_frame = []
|
||||
|
||||
|
||||
_monitor: PerformanceMonitor | None = None
|
||||
|
||||
|
||||
def get_monitor() -> PerformanceMonitor:
|
||||
global _monitor
|
||||
if _monitor is None:
|
||||
_monitor = PerformanceMonitor()
|
||||
return _monitor
|
||||
|
||||
|
||||
def set_monitor(monitor: PerformanceMonitor) -> None:
|
||||
global _monitor
|
||||
_monitor = monitor
|
||||
@@ -1,59 +0,0 @@
|
||||
from engine.effects.types import EffectConfig, EffectPlugin
|
||||
|
||||
|
||||
class EffectRegistry:
|
||||
def __init__(self):
|
||||
self._plugins: dict[str, EffectPlugin] = {}
|
||||
self._discovered: bool = False
|
||||
|
||||
def register(self, plugin: EffectPlugin) -> None:
|
||||
self._plugins[plugin.name] = plugin
|
||||
|
||||
def get(self, name: str) -> EffectPlugin | None:
|
||||
return self._plugins.get(name)
|
||||
|
||||
def list_all(self) -> dict[str, EffectPlugin]:
|
||||
return self._plugins.copy()
|
||||
|
||||
def list_enabled(self) -> list[EffectPlugin]:
|
||||
return [p for p in self._plugins.values() if p.config.enabled]
|
||||
|
||||
def enable(self, name: str) -> bool:
|
||||
plugin = self._plugins.get(name)
|
||||
if plugin:
|
||||
plugin.config.enabled = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable(self, name: str) -> bool:
|
||||
plugin = self._plugins.get(name)
|
||||
if plugin:
|
||||
plugin.config.enabled = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def configure(self, name: str, config: EffectConfig) -> bool:
|
||||
plugin = self._plugins.get(name)
|
||||
if plugin:
|
||||
plugin.configure(config)
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_enabled(self, name: str) -> bool:
|
||||
plugin = self._plugins.get(name)
|
||||
return plugin.config.enabled if plugin else False
|
||||
|
||||
|
||||
_registry: EffectRegistry | None = None
|
||||
|
||||
|
||||
def get_registry() -> EffectRegistry:
|
||||
global _registry
|
||||
if _registry is None:
|
||||
_registry = EffectRegistry()
|
||||
return _registry
|
||||
|
||||
|
||||
def set_registry(registry: EffectRegistry) -> None:
|
||||
global _registry
|
||||
_registry = registry
|
||||
@@ -1,68 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectContext:
|
||||
terminal_width: int
|
||||
terminal_height: int
|
||||
scroll_cam: int
|
||||
ticker_height: int
|
||||
camera_x: int = 0
|
||||
mic_excess: float = 0.0
|
||||
grad_offset: float = 0.0
|
||||
frame_number: int = 0
|
||||
has_message: bool = False
|
||||
items: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectConfig:
|
||||
enabled: bool = True
|
||||
intensity: float = 1.0
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class EffectPlugin(ABC):
|
||||
name: str
|
||||
config: EffectConfig
|
||||
|
||||
@abstractmethod
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]: ...
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, config: EffectConfig) -> None: ...
|
||||
|
||||
|
||||
def create_effect_context(
|
||||
terminal_width: int = 80,
|
||||
terminal_height: int = 24,
|
||||
scroll_cam: int = 0,
|
||||
ticker_height: int = 0,
|
||||
camera_x: int = 0,
|
||||
mic_excess: float = 0.0,
|
||||
grad_offset: float = 0.0,
|
||||
frame_number: int = 0,
|
||||
has_message: bool = False,
|
||||
items: list | None = None,
|
||||
) -> EffectContext:
|
||||
"""Factory function to create EffectContext with sensible defaults."""
|
||||
return EffectContext(
|
||||
terminal_width=terminal_width,
|
||||
terminal_height=terminal_height,
|
||||
scroll_cam=scroll_cam,
|
||||
ticker_height=ticker_height,
|
||||
camera_x=camera_x,
|
||||
mic_excess=mic_excess,
|
||||
grad_offset=grad_offset,
|
||||
frame_number=frame_number,
|
||||
has_message=has_message,
|
||||
items=items or [],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineConfig:
|
||||
order: list[str] = field(default_factory=list)
|
||||
effects: dict[str, EffectConfig] = field(default_factory=dict)
|
||||
@@ -1,25 +0,0 @@
|
||||
"""
|
||||
Event emitter protocols - abstract interfaces for event-producing components.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class EventEmitter(Protocol):
|
||||
"""Protocol for components that emit events."""
|
||||
|
||||
def subscribe(self, callback: Callable[[Any], None]) -> None: ...
|
||||
def unsubscribe(self, callback: Callable[[Any], None]) -> None: ...
|
||||
|
||||
|
||||
class Startable(Protocol):
|
||||
"""Protocol for components that can be started."""
|
||||
|
||||
def start(self) -> Any: ...
|
||||
|
||||
|
||||
class Stoppable(Protocol):
|
||||
"""Protocol for components that can be stopped."""
|
||||
|
||||
def stop(self) -> None: ...
|
||||
@@ -1,72 +0,0 @@
|
||||
"""
|
||||
Event bus - pub/sub messaging for decoupled component communication.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from engine.events import EventType
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Thread-safe event bus for publish-subscribe messaging."""
|
||||
|
||||
def __init__(self):
|
||||
self._subscribers: dict[EventType, list[Callable[[Any], None]]] = defaultdict(
|
||||
list
|
||||
)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def subscribe(self, event_type: EventType, callback: Callable[[Any], None]) -> None:
|
||||
"""Register a callback for a specific event type."""
|
||||
with self._lock:
|
||||
self._subscribers[event_type].append(callback)
|
||||
|
||||
def unsubscribe(
|
||||
self, event_type: EventType, callback: Callable[[Any], None]
|
||||
) -> None:
|
||||
"""Remove a callback for a specific event type."""
|
||||
with self._lock:
|
||||
if callback in self._subscribers[event_type]:
|
||||
self._subscribers[event_type].remove(callback)
|
||||
|
||||
def publish(self, event_type: EventType, event: Any = None) -> None:
|
||||
"""Publish an event to all subscribers."""
|
||||
with self._lock:
|
||||
callbacks = list(self._subscribers.get(event_type, []))
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all subscribers."""
|
||||
with self._lock:
|
||||
self._subscribers.clear()
|
||||
|
||||
def subscriber_count(self, event_type: EventType | None = None) -> int:
|
||||
"""Get subscriber count for an event type, or total if None."""
|
||||
with self._lock:
|
||||
if event_type is None:
|
||||
return sum(len(cb) for cb in self._subscribers.values())
|
||||
return len(self._subscribers.get(event_type, []))
|
||||
|
||||
|
||||
_event_bus: EventBus | None = None
|
||||
|
||||
|
||||
def get_event_bus() -> EventBus:
|
||||
"""Get the global event bus instance."""
|
||||
global _event_bus
|
||||
if _event_bus is None:
|
||||
_event_bus = EventBus()
|
||||
return _event_bus
|
||||
|
||||
|
||||
def set_event_bus(bus: EventBus) -> None:
|
||||
"""Set the global event bus instance (for testing)."""
|
||||
global _event_bus
|
||||
_event_bus = bus
|
||||
@@ -1,77 +0,0 @@
|
||||
"""
|
||||
Event types for the mainline application.
|
||||
Defines the core events that flow through the system.
|
||||
These types support a future migration to an event-driven architecture.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class EventType(Enum):
|
||||
"""Core event types in the mainline application."""
|
||||
|
||||
NEW_HEADLINE = auto()
|
||||
FRAME_TICK = auto()
|
||||
MIC_LEVEL = auto()
|
||||
NTFY_MESSAGE = auto()
|
||||
STREAM_START = auto()
|
||||
STREAM_END = auto()
|
||||
FIGMENT_TRIGGER = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeadlineEvent:
|
||||
"""Event emitted when a new headline is ready for display."""
|
||||
|
||||
title: str
|
||||
source: str
|
||||
timestamp: str
|
||||
language: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameTickEvent:
|
||||
"""Event emitted on each render frame."""
|
||||
|
||||
frame_number: int
|
||||
timestamp: datetime
|
||||
delta_seconds: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class MicLevelEvent:
|
||||
"""Event emitted when microphone level changes significantly."""
|
||||
|
||||
db_level: float
|
||||
excess_above_threshold: float
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class NtfyMessageEvent:
|
||||
"""Event emitted when an ntfy message is received."""
|
||||
|
||||
title: str
|
||||
body: str
|
||||
message_id: str | None = None
|
||||
timestamp: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamEvent:
|
||||
"""Event emitted when stream starts or ends."""
|
||||
|
||||
event_type: EventType
|
||||
headline_count: int = 0
|
||||
timestamp: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FigmentTriggerEvent:
|
||||
"""Event emitted when a figment is triggered."""
|
||||
|
||||
action: str
|
||||
value: float | str | None = None
|
||||
timestamp: datetime | None = None
|
||||
@@ -8,7 +8,6 @@ import pathlib
|
||||
import re
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import feedparser
|
||||
|
||||
@@ -17,13 +16,9 @@ from engine.filter import skip, strip_tags
|
||||
from engine.sources import FEEDS, POETRY_SOURCES
|
||||
from engine.terminal import boot_ln
|
||||
|
||||
# Type alias for headline items
|
||||
HeadlineTuple = tuple[str, str, str]
|
||||
|
||||
|
||||
# ─── SINGLE FEED ──────────────────────────────────────────
|
||||
def fetch_feed(url: str) -> Any | None:
|
||||
"""Fetch and parse a single RSS feed URL."""
|
||||
def fetch_feed(url):
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
||||
@@ -33,9 +28,8 @@ def fetch_feed(url: str) -> Any | None:
|
||||
|
||||
|
||||
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
||||
def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
||||
"""Fetch all RSS feeds and return items, linked count, failed count."""
|
||||
items: list[HeadlineTuple] = []
|
||||
def fetch_all():
|
||||
items = []
|
||||
linked = failed = 0
|
||||
for src, url in FEEDS.items():
|
||||
feed = fetch_feed(url)
|
||||
@@ -65,7 +59,7 @@ def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
||||
|
||||
|
||||
# ─── PROJECT GUTENBERG ────────────────────────────────────
|
||||
def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
||||
def _fetch_gutenberg(url, label):
|
||||
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""
|
||||
Source code feed — reads engine/*.py and emits non-blank, non-comment lines
|
||||
as scroll items. Used by --code mode.
|
||||
Depends on: nothing (stdlib only).
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
_ENGINE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _scope_map(source: str) -> dict[int, str]:
|
||||
"""Return {line_number: scope_label} for every line in source.
|
||||
|
||||
Nodes are sorted by range size descending so inner scopes overwrite
|
||||
outer ones, guaranteeing the narrowest enclosing scope wins.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
return {}
|
||||
|
||||
nodes = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
end = getattr(node, "end_lineno", node.lineno)
|
||||
span = end - node.lineno
|
||||
nodes.append((span, node))
|
||||
|
||||
# Largest range first → inner scopes overwrite on second pass
|
||||
nodes.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
scope = {}
|
||||
for _, node in nodes:
|
||||
end = getattr(node, "end_lineno", node.lineno)
|
||||
if isinstance(node, ast.ClassDef):
|
||||
label = node.name
|
||||
else:
|
||||
label = f"{node.name}()"
|
||||
for ln in range(node.lineno, end + 1):
|
||||
scope[ln] = label
|
||||
|
||||
return scope
|
||||
|
||||
|
||||
def fetch_code():
|
||||
"""Read engine/*.py and return (items, line_count, 0).
|
||||
|
||||
Each item is (text, src, ts) where:
|
||||
text = the code line (rstripped, indentation preserved)
|
||||
src = enclosing function/class name, e.g. 'stream()' or '<module>'
|
||||
ts = dotted module path, e.g. 'engine.scroll'
|
||||
"""
|
||||
items = []
|
||||
for path in sorted(_ENGINE_DIR.glob("*.py")):
|
||||
module = f"engine.{path.stem}"
|
||||
source = path.read_text(encoding="utf-8")
|
||||
scope = _scope_map(source)
|
||||
for lineno, raw in enumerate(source.splitlines(), start=1):
|
||||
stripped = raw.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
label = scope.get(lineno, "<module>")
|
||||
items.append((raw.rstrip(), label, module))
|
||||
|
||||
return items, len(items), 0
|
||||
@@ -1,90 +0,0 @@
|
||||
"""
|
||||
SVG to half-block terminal art rasterization.
|
||||
|
||||
Pipeline: SVG -> cairosvg -> PIL -> greyscale threshold -> half-block encode.
|
||||
Follows the same pixel-pair approach as engine/render.py for OTF fonts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from io import BytesIO
|
||||
|
||||
# cairocffi (used by cairosvg) calls dlopen() to find the Cairo C library.
|
||||
# On macOS with Homebrew, Cairo lives in /opt/homebrew/lib (Apple Silicon) or
|
||||
# /usr/local/lib (Intel), which are not in dyld's default search path.
|
||||
# Setting DYLD_LIBRARY_PATH before the import directs dlopen() to those paths.
|
||||
if sys.platform == "darwin" and not os.environ.get("DYLD_LIBRARY_PATH"):
|
||||
for _brew_lib in ("/opt/homebrew/lib", "/usr/local/lib"):
|
||||
if os.path.exists(os.path.join(_brew_lib, "libcairo.2.dylib")):
|
||||
os.environ["DYLD_LIBRARY_PATH"] = _brew_lib
|
||||
break
|
||||
|
||||
import cairosvg
|
||||
from PIL import Image
|
||||
|
||||
_cache: dict[tuple[str, int, int], list[str]] = {}
|
||||
|
||||
|
||||
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
|
||||
"""Convert SVG file to list of half-block terminal rows (uncolored).
|
||||
|
||||
Args:
|
||||
svg_path: Path to SVG file.
|
||||
width: Target terminal width in columns.
|
||||
height: Target terminal height in rows.
|
||||
|
||||
Returns:
|
||||
List of strings, one per terminal row, containing block characters.
|
||||
"""
|
||||
cache_key = (svg_path, width, height)
|
||||
if cache_key in _cache:
|
||||
return _cache[cache_key]
|
||||
|
||||
# SVG -> PNG in memory
|
||||
png_bytes = cairosvg.svg2png(
|
||||
url=svg_path,
|
||||
output_width=width,
|
||||
output_height=height * 2, # 2 pixel rows per terminal row
|
||||
)
|
||||
|
||||
# PNG -> greyscale PIL image
|
||||
# Composite RGBA onto white background so transparent areas become white (255)
|
||||
# and drawn pixels retain their luminance values.
|
||||
img_rgba = Image.open(BytesIO(png_bytes)).convert("RGBA")
|
||||
img_rgba = img_rgba.resize((width, height * 2), Image.Resampling.LANCZOS)
|
||||
background = Image.new("RGBA", img_rgba.size, (255, 255, 255, 255))
|
||||
background.paste(img_rgba, mask=img_rgba.split()[3])
|
||||
img = background.convert("L")
|
||||
|
||||
data = img.tobytes()
|
||||
pix_w = width
|
||||
pix_h = height * 2
|
||||
# White (255) = empty space, dark (< threshold) = filled pixel
|
||||
threshold = 128
|
||||
|
||||
# Half-block encode: walk pixel pairs
|
||||
rows: list[str] = []
|
||||
for y in range(0, pix_h, 2):
|
||||
row: list[str] = []
|
||||
for x in range(pix_w):
|
||||
top = data[y * pix_w + x] < threshold
|
||||
bot = data[(y + 1) * pix_w + x] < threshold if y + 1 < pix_h else False
|
||||
if top and bot:
|
||||
row.append("█")
|
||||
elif top:
|
||||
row.append("▀")
|
||||
elif bot:
|
||||
row.append("▄")
|
||||
else:
|
||||
row.append(" ")
|
||||
rows.append("".join(row))
|
||||
|
||||
_cache[cache_key] = rows
|
||||
return rows
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Clear the rasterization cache (e.g., on terminal resize)."""
|
||||
_cache.clear()
|
||||
@@ -1,36 +0,0 @@
|
||||
"""
|
||||
Figment trigger protocol and command types.
|
||||
|
||||
Defines the extensible input abstraction for triggering figment displays
|
||||
from any control surface (ntfy, MQTT, serial, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class FigmentAction(Enum):
|
||||
TRIGGER = "trigger"
|
||||
SET_INTENSITY = "set_intensity"
|
||||
SET_INTERVAL = "set_interval"
|
||||
SET_COLOR = "set_color"
|
||||
STOP = "stop"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FigmentCommand:
|
||||
action: FigmentAction
|
||||
value: float | str | None = None
|
||||
|
||||
|
||||
class FigmentTrigger(Protocol):
|
||||
"""Protocol for figment trigger sources.
|
||||
|
||||
Any input source (ntfy, MQTT, serial) can implement this
|
||||
to trigger and control figment displays.
|
||||
"""
|
||||
|
||||
def poll(self) -> FigmentCommand | None: ...
|
||||
@@ -1,57 +0,0 @@
|
||||
"""
|
||||
Frame timing utilities — FPS control and precise timing.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class FrameTimer:
|
||||
"""Frame timer for consistent render loop timing."""
|
||||
|
||||
def __init__(self, target_frame_dt: float = 0.05):
|
||||
self.target_frame_dt = target_frame_dt
|
||||
self._frame_count = 0
|
||||
self._start_time = time.monotonic()
|
||||
self._last_frame_time = self._start_time
|
||||
|
||||
@property
|
||||
def fps(self) -> float:
|
||||
"""Current FPS based on elapsed frames."""
|
||||
elapsed = time.monotonic() - self._start_time
|
||||
if elapsed > 0:
|
||||
return self._frame_count / elapsed
|
||||
return 0.0
|
||||
|
||||
def sleep_until_next_frame(self) -> float:
|
||||
"""Sleep to maintain target frame rate. Returns actual elapsed time."""
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_frame_time
|
||||
self._last_frame_time = now
|
||||
self._frame_count += 1
|
||||
|
||||
sleep_time = max(0, self.target_frame_dt - elapsed)
|
||||
if sleep_time > 0:
|
||||
time.sleep(sleep_time)
|
||||
return elapsed
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset frame counter and start time."""
|
||||
self._frame_count = 0
|
||||
self._start_time = time.monotonic()
|
||||
self._last_frame_time = self._start_time
|
||||
|
||||
|
||||
def calculate_scroll_step(
|
||||
scroll_dur: float, view_height: int, padding: int = 15
|
||||
) -> float:
|
||||
"""Calculate scroll step interval for smooth scrolling.
|
||||
|
||||
Args:
|
||||
scroll_dur: Duration in seconds for one headline to scroll through view
|
||||
view_height: Terminal height in rows
|
||||
padding: Extra rows for off-screen content
|
||||
|
||||
Returns:
|
||||
Time in seconds between scroll steps
|
||||
"""
|
||||
return scroll_dur / (view_height + padding) * 2
|
||||
356
engine/layers.py
356
engine/layers.py
@@ -1,356 +0,0 @@
|
||||
"""
|
||||
Layer compositing — message overlay, ticker zone, firehose, noise.
|
||||
Depends on: config, render, effects.
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
from engine.effects import (
|
||||
EffectChain,
|
||||
EffectContext,
|
||||
fade_line,
|
||||
firehose_line,
|
||||
glitch_bar,
|
||||
noise,
|
||||
vis_trunc,
|
||||
)
|
||||
from engine.render import big_wrap, lr_gradient, msg_gradient
|
||||
from engine.terminal import RST, W_COOL
|
||||
|
||||
MSG_META = "\033[38;5;245m"
|
||||
MSG_BORDER = "\033[2;38;5;37m"
|
||||
|
||||
|
||||
def render_message_overlay(
|
||||
msg: tuple[str, str, float] | None,
|
||||
w: int,
|
||||
h: int,
|
||||
msg_cache: tuple,
|
||||
) -> tuple[list[str], tuple]:
|
||||
"""Render ntfy message overlay.
|
||||
|
||||
Args:
|
||||
msg: (title, body, timestamp) or None
|
||||
w: terminal width
|
||||
h: terminal height
|
||||
msg_cache: (cache_key, rendered_rows) for caching
|
||||
|
||||
Returns:
|
||||
(list of ANSI strings, updated cache)
|
||||
"""
|
||||
overlay = []
|
||||
if msg is None:
|
||||
return overlay, msg_cache
|
||||
|
||||
m_title, m_body, m_ts = msg
|
||||
display_text = m_body or m_title or "(empty)"
|
||||
display_text = re.sub(r"\s+", " ", display_text.upper())
|
||||
|
||||
cache_key = (display_text, w)
|
||||
if msg_cache[0] != cache_key:
|
||||
msg_rows = big_wrap(display_text, w - 4)
|
||||
msg_cache = (cache_key, msg_rows)
|
||||
else:
|
||||
msg_rows = msg_cache[1]
|
||||
|
||||
msg_rows = msg_gradient(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
|
||||
|
||||
elapsed_s = int(time.monotonic() - m_ts)
|
||||
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
|
||||
ts_str = datetime.now().strftime("%H:%M:%S")
|
||||
panel_h = len(msg_rows) + 2
|
||||
panel_top = max(0, (h - panel_h) // 2)
|
||||
|
||||
row_idx = 0
|
||||
for mr in msg_rows:
|
||||
ln = vis_trunc(mr, w)
|
||||
overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}\033[0m\033[K")
|
||||
row_idx += 1
|
||||
|
||||
meta_parts = []
|
||||
if m_title and m_title != m_body:
|
||||
meta_parts.append(m_title)
|
||||
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
|
||||
meta = (
|
||||
" " + " \u00b7 ".join(meta_parts)
|
||||
if len(meta_parts) > 1
|
||||
else " " + meta_parts[0]
|
||||
)
|
||||
overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}\033[0m\033[K")
|
||||
row_idx += 1
|
||||
|
||||
bar = "\u2500" * (w - 4)
|
||||
overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}\033[0m\033[K")
|
||||
|
||||
return overlay, msg_cache
|
||||
|
||||
|
||||
def render_ticker_zone(
|
||||
active: list,
|
||||
scroll_cam: int,
|
||||
ticker_h: int,
|
||||
w: int,
|
||||
noise_cache: dict,
|
||||
grad_offset: float,
|
||||
) -> tuple[list[str], dict]:
|
||||
"""Render the ticker scroll zone.
|
||||
|
||||
Args:
|
||||
active: list of (content_rows, color, canvas_y, meta_idx)
|
||||
scroll_cam: camera position (viewport top)
|
||||
ticker_h: height of ticker zone
|
||||
w: terminal width
|
||||
noise_cache: dict of cy -> noise string
|
||||
grad_offset: gradient animation offset
|
||||
|
||||
Returns:
|
||||
(list of ANSI strings, updated noise_cache)
|
||||
"""
|
||||
buf = []
|
||||
top_zone = max(1, int(ticker_h * 0.25))
|
||||
bot_zone = max(1, int(ticker_h * 0.10))
|
||||
|
||||
def noise_at(cy):
|
||||
if cy not in noise_cache:
|
||||
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
|
||||
return noise_cache[cy]
|
||||
|
||||
for r in range(ticker_h):
|
||||
scr_row = r + 1
|
||||
cy = scroll_cam + r
|
||||
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||
bot_f = min(1.0, (ticker_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0
|
||||
row_fade = min(top_f, bot_f)
|
||||
drawn = False
|
||||
|
||||
for content, hc, by, midx in active:
|
||||
cr = cy - by
|
||||
if 0 <= cr < len(content):
|
||||
raw = content[cr]
|
||||
if cr != midx:
|
||||
colored = lr_gradient([raw], grad_offset)[0]
|
||||
else:
|
||||
colored = raw
|
||||
ln = vis_trunc(colored, w)
|
||||
if row_fade < 1.0:
|
||||
ln = fade_line(ln, row_fade)
|
||||
|
||||
if cr == midx:
|
||||
buf.append(f"\033[{scr_row};1H{W_COOL}{ln}{RST}\033[K")
|
||||
elif ln.strip():
|
||||
buf.append(f"\033[{scr_row};1H{ln}{RST}\033[K")
|
||||
else:
|
||||
buf.append(f"\033[{scr_row};1H\033[K")
|
||||
drawn = True
|
||||
break
|
||||
|
||||
if not drawn:
|
||||
n = noise_at(cy)
|
||||
if row_fade < 1.0 and n:
|
||||
n = fade_line(n, row_fade)
|
||||
if n:
|
||||
buf.append(f"\033[{scr_row};1H{n}")
|
||||
else:
|
||||
buf.append(f"\033[{scr_row};1H\033[K")
|
||||
|
||||
return buf, noise_cache
|
||||
|
||||
|
||||
def apply_glitch(
|
||||
buf: list[str],
|
||||
ticker_buf_start: int,
|
||||
mic_excess: float,
|
||||
w: int,
|
||||
) -> list[str]:
|
||||
"""Apply glitch effect to ticker buffer.
|
||||
|
||||
Args:
|
||||
buf: current buffer
|
||||
ticker_buf_start: index where ticker starts in buffer
|
||||
mic_excess: mic level above threshold
|
||||
w: terminal width
|
||||
|
||||
Returns:
|
||||
Updated buffer with glitches applied
|
||||
"""
|
||||
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
|
||||
n_hits = 4 + int(mic_excess / 2)
|
||||
ticker_buf_len = len(buf) - ticker_buf_start
|
||||
|
||||
if random.random() < glitch_prob and ticker_buf_len > 0:
|
||||
for _ in range(min(n_hits, ticker_buf_len)):
|
||||
gi = random.randint(0, ticker_buf_len - 1)
|
||||
scr_row = gi + 1
|
||||
buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
|
||||
|
||||
return buf
|
||||
|
||||
|
||||
def render_firehose(items: list, w: int, fh: int, h: int) -> list[str]:
|
||||
"""Render firehose strip at bottom of screen."""
|
||||
buf = []
|
||||
if fh > 0:
|
||||
for fr in range(fh):
|
||||
scr_row = h - fh + fr + 1
|
||||
fline = firehose_line(items, w)
|
||||
buf.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||
return buf
|
||||
|
||||
|
||||
_effect_chain = None
|
||||
|
||||
|
||||
def init_effects() -> None:
|
||||
"""Initialize effect plugins and chain."""
|
||||
global _effect_chain
|
||||
from engine.effects import EffectChain, get_registry
|
||||
|
||||
registry = get_registry()
|
||||
|
||||
import effects_plugins
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||
_effect_chain = chain
|
||||
|
||||
|
||||
def process_effects(
|
||||
buf: list[str],
|
||||
w: int,
|
||||
h: int,
|
||||
scroll_cam: int,
|
||||
ticker_h: int,
|
||||
mic_excess: float,
|
||||
grad_offset: float,
|
||||
frame_number: int,
|
||||
has_message: bool,
|
||||
items: list,
|
||||
) -> list[str]:
|
||||
"""Process buffer through effect chain."""
|
||||
if _effect_chain is None:
|
||||
init_effects()
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=w,
|
||||
terminal_height=h,
|
||||
scroll_cam=scroll_cam,
|
||||
ticker_height=ticker_h,
|
||||
mic_excess=mic_excess,
|
||||
grad_offset=grad_offset,
|
||||
frame_number=frame_number,
|
||||
has_message=has_message,
|
||||
items=items,
|
||||
)
|
||||
return _effect_chain.process(buf, ctx)
|
||||
|
||||
|
||||
def get_effect_chain() -> EffectChain | None:
|
||||
"""Get the effect chain instance."""
|
||||
global _effect_chain
|
||||
if _effect_chain is None:
|
||||
init_effects()
|
||||
return _effect_chain
|
||||
|
||||
|
||||
def render_figment_overlay(
|
||||
figment_state,
|
||||
w: int,
|
||||
h: int,
|
||||
) -> list[str]:
|
||||
"""Render figment overlay as ANSI cursor-positioning commands.
|
||||
|
||||
Args:
|
||||
figment_state: FigmentState with phase, progress, rows, gradient, centering.
|
||||
w: terminal width
|
||||
h: terminal height
|
||||
|
||||
Returns:
|
||||
List of ANSI strings to append to display buffer.
|
||||
"""
|
||||
from engine.render import _color_codes_to_ansi
|
||||
|
||||
rows = figment_state.rows
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
phase = figment_state.phase
|
||||
progress = figment_state.progress
|
||||
gradient = figment_state.gradient
|
||||
center_row = figment_state.center_row
|
||||
center_col = figment_state.center_col
|
||||
|
||||
cols = _color_codes_to_ansi(gradient)
|
||||
|
||||
# Build a list of non-space cell positions
|
||||
cell_positions = []
|
||||
for r_idx, row in enumerate(rows):
|
||||
for c_idx, ch in enumerate(row):
|
||||
if ch != " ":
|
||||
cell_positions.append((r_idx, c_idx))
|
||||
|
||||
n_cells = len(cell_positions)
|
||||
if n_cells == 0:
|
||||
return []
|
||||
|
||||
# Use a deterministic seed so the reveal/dissolve pattern is stable per-figment
|
||||
rng = random.Random(hash(tuple(rows[0][:10])) if rows[0] else 42)
|
||||
shuffled = list(cell_positions)
|
||||
rng.shuffle(shuffled)
|
||||
|
||||
# Phase-dependent visibility
|
||||
from effects_plugins.figment import FigmentPhase
|
||||
|
||||
if phase == FigmentPhase.REVEAL:
|
||||
visible_count = int(n_cells * progress)
|
||||
visible = set(shuffled[:visible_count])
|
||||
elif phase == FigmentPhase.HOLD:
|
||||
visible = set(cell_positions)
|
||||
# Strobe: dim some cells periodically
|
||||
if int(progress * 20) % 3 == 0:
|
||||
dim_count = int(n_cells * 0.3)
|
||||
visible -= set(shuffled[:dim_count])
|
||||
elif phase == FigmentPhase.DISSOLVE:
|
||||
remaining_count = int(n_cells * (1.0 - progress))
|
||||
visible = set(shuffled[:remaining_count])
|
||||
else:
|
||||
visible = set(cell_positions)
|
||||
|
||||
# Build overlay commands
|
||||
overlay: list[str] = []
|
||||
n_cols = len(cols)
|
||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||
|
||||
for r_idx, row in enumerate(rows):
|
||||
scr_row = center_row + r_idx + 1 # 1-indexed
|
||||
if scr_row < 1 or scr_row > h:
|
||||
continue
|
||||
|
||||
line_buf: list[str] = []
|
||||
has_content = False
|
||||
|
||||
for c_idx, ch in enumerate(row):
|
||||
scr_col = center_col + c_idx + 1
|
||||
if scr_col < 1 or scr_col > w:
|
||||
continue
|
||||
|
||||
if ch != " " and (r_idx, c_idx) in visible:
|
||||
# Apply gradient color
|
||||
shifted = (c_idx / max(max_x - 1, 1)) % 1.0
|
||||
idx = min(round(shifted * (n_cols - 1)), n_cols - 1)
|
||||
line_buf.append(f"{cols[idx]}{ch}{RST}")
|
||||
has_content = True
|
||||
else:
|
||||
line_buf.append(" ")
|
||||
|
||||
if has_content:
|
||||
line_str = "".join(line_buf).rstrip()
|
||||
if line_str.strip():
|
||||
overlay.append(f"\033[{scr_row};{center_col + 1}H{line_str}{RST}")
|
||||
|
||||
return overlay
|
||||
@@ -4,8 +4,6 @@ Gracefully degrades if sounddevice/numpy are unavailable.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
import numpy as _np
|
||||
@@ -16,9 +14,6 @@ except Exception:
|
||||
_HAS_MIC = False
|
||||
|
||||
|
||||
from engine.events import MicLevelEvent
|
||||
|
||||
|
||||
class MicMonitor:
|
||||
"""Background mic stream that exposes current RMS dB level."""
|
||||
|
||||
@@ -26,7 +21,6 @@ class MicMonitor:
|
||||
self.threshold_db = threshold_db
|
||||
self._db = -99.0
|
||||
self._stream = None
|
||||
self._subscribers: list[Callable[[MicLevelEvent], None]] = []
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
@@ -43,23 +37,6 @@ class MicMonitor:
|
||||
"""dB above threshold (clamped to 0)."""
|
||||
return max(0.0, self._db - self.threshold_db)
|
||||
|
||||
def subscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||
"""Register a callback to be called when mic level changes."""
|
||||
self._subscribers.append(callback)
|
||||
|
||||
def unsubscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||
"""Remove a registered callback."""
|
||||
if callback in self._subscribers:
|
||||
self._subscribers.remove(callback)
|
||||
|
||||
def _emit(self, event: MicLevelEvent) -> None:
|
||||
"""Emit an event to all subscribers."""
|
||||
for cb in self._subscribers:
|
||||
try:
|
||||
cb(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
||||
if not _HAS_MIC:
|
||||
@@ -68,13 +45,6 @@ class MicMonitor:
|
||||
def _cb(indata, frames, t, status):
|
||||
rms = float(_np.sqrt(_np.mean(indata**2)))
|
||||
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
|
||||
if self._subscribers:
|
||||
event = MicLevelEvent(
|
||||
db_level=self._db,
|
||||
excess_above_threshold=max(0.0, self._db - self.threshold_db),
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
self._emit(event)
|
||||
|
||||
try:
|
||||
self._stream = _sd.InputStream(
|
||||
|
||||
@@ -16,12 +16,8 @@ import json
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
from engine.events import NtfyMessageEvent
|
||||
|
||||
|
||||
class NtfyPoller:
|
||||
"""SSE stream listener for ntfy.sh topics. Messages arrive in ~1s (network RTT)."""
|
||||
@@ -32,24 +28,6 @@ class NtfyPoller:
|
||||
self.display_secs = display_secs
|
||||
self._message = None # (title, body, monotonic_timestamp) or None
|
||||
self._lock = threading.Lock()
|
||||
self._subscribers: list[Callable[[NtfyMessageEvent], None]] = []
|
||||
|
||||
def subscribe(self, callback: Callable[[NtfyMessageEvent], None]) -> None:
|
||||
"""Register a callback to be called when a message is received."""
|
||||
self._subscribers.append(callback)
|
||||
|
||||
def unsubscribe(self, callback: Callable[[NtfyMessageEvent], None]) -> None:
|
||||
"""Remove a registered callback."""
|
||||
if callback in self._subscribers:
|
||||
self._subscribers.remove(callback)
|
||||
|
||||
def _emit(self, event: NtfyMessageEvent) -> None:
|
||||
"""Emit an event to all subscribers."""
|
||||
for cb in self._subscribers:
|
||||
try:
|
||||
cb(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
"""Start background stream thread. Returns True."""
|
||||
@@ -110,13 +88,6 @@ class NtfyPoller:
|
||||
data.get("message", ""),
|
||||
time.monotonic(),
|
||||
)
|
||||
event = NtfyMessageEvent(
|
||||
title=data.get("title", ""),
|
||||
body=data.get("message", ""),
|
||||
message_id=data.get("id"),
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
self._emit(event)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(self.reconnect_delay)
|
||||
|
||||
129
engine/render.py
129
engine/render.py
@@ -15,72 +15,38 @@ from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
||||
from engine.terminal import RST
|
||||
from engine.translate import detect_location_language, translate_headline
|
||||
|
||||
|
||||
# ─── GRADIENT ─────────────────────────────────────────────
|
||||
def _color_codes_to_ansi(color_codes):
|
||||
"""Convert a list of 256-color codes to ANSI escape code strings.
|
||||
|
||||
Pattern: first 2 are bold, middle 8 are normal, last 2 are dim.
|
||||
|
||||
Args:
|
||||
color_codes: List of 12 integers (256-color palette codes)
|
||||
|
||||
Returns:
|
||||
List of ANSI escape code strings
|
||||
"""
|
||||
if not color_codes or len(color_codes) != 12:
|
||||
# Fallback to default green if invalid
|
||||
return _default_green_gradient()
|
||||
|
||||
result = []
|
||||
for i, code in enumerate(color_codes):
|
||||
if i < 2:
|
||||
# Bold for first 2 (bright leading edge)
|
||||
result.append(f"\033[1;38;5;{code}m")
|
||||
elif i < 10:
|
||||
# Normal for middle 8
|
||||
result.append(f"\033[38;5;{code}m")
|
||||
else:
|
||||
# Dim for last 2 (dark trailing edge)
|
||||
result.append(f"\033[2;38;5;{code}m")
|
||||
return result
|
||||
|
||||
|
||||
def _default_green_gradient():
|
||||
"""Return the default 12-color green gradient for fallback when no theme is active."""
|
||||
return [
|
||||
"\033[1;38;5;231m", # white
|
||||
"\033[1;38;5;195m", # pale cyan-white
|
||||
"\033[38;5;123m", # bright cyan
|
||||
"\033[38;5;118m", # bright lime
|
||||
"\033[38;5;82m", # lime
|
||||
"\033[38;5;46m", # bright green
|
||||
"\033[38;5;40m", # green
|
||||
"\033[38;5;34m", # medium green
|
||||
"\033[38;5;28m", # dark green
|
||||
"\033[38;5;22m", # deep green
|
||||
"\033[2;38;5;22m", # dim deep green
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
|
||||
def _default_magenta_gradient():
|
||||
"""Return the default 12-color magenta gradient for fallback when no theme is active."""
|
||||
return [
|
||||
"\033[1;38;5;231m", # white
|
||||
"\033[1;38;5;225m", # pale pink-white
|
||||
"\033[38;5;219m", # bright pink
|
||||
"\033[38;5;213m", # hot pink
|
||||
"\033[38;5;207m", # magenta
|
||||
"\033[38;5;201m", # bright magenta
|
||||
"\033[38;5;165m", # orchid-red
|
||||
"\033[38;5;161m", # ruby-magenta
|
||||
"\033[38;5;125m", # dark magenta
|
||||
"\033[38;5;89m", # deep maroon-magenta
|
||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
# Left → right: white-hot leading edge fades to near-black
|
||||
GRAD_COLS = [
|
||||
"\033[1;38;5;231m", # white
|
||||
"\033[1;38;5;195m", # pale cyan-white
|
||||
"\033[38;5;123m", # bright cyan
|
||||
"\033[38;5;118m", # bright lime
|
||||
"\033[38;5;82m", # lime
|
||||
"\033[38;5;46m", # bright green
|
||||
"\033[38;5;40m", # green
|
||||
"\033[38;5;34m", # medium green
|
||||
"\033[38;5;28m", # dark green
|
||||
"\033[38;5;22m", # deep green
|
||||
"\033[2;38;5;22m", # dim deep green
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
# Complementary sweep for queue messages (opposite hue family from ticker greens)
|
||||
MSG_GRAD_COLS = [
|
||||
"\033[1;38;5;231m", # white
|
||||
"\033[1;38;5;225m", # pale pink-white
|
||||
"\033[38;5;219m", # bright pink
|
||||
"\033[38;5;213m", # hot pink
|
||||
"\033[38;5;207m", # magenta
|
||||
"\033[38;5;201m", # bright magenta
|
||||
"\033[38;5;165m", # orchid-red
|
||||
"\033[38;5;161m", # ruby-magenta
|
||||
"\033[38;5;125m", # dark magenta
|
||||
"\033[38;5;89m", # deep maroon-magenta
|
||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
# ─── FONT LOADING ─────────────────────────────────────────
|
||||
_FONT_OBJ = None
|
||||
@@ -223,15 +189,9 @@ def big_wrap(text, max_w, fnt=None):
|
||||
return out
|
||||
|
||||
|
||||
def lr_gradient(rows, offset=0.0, cols=None):
|
||||
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
||||
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||
if cols is None:
|
||||
from engine import config
|
||||
|
||||
if config.ACTIVE_THEME:
|
||||
cols = _color_codes_to_ansi(config.ACTIVE_THEME.main_gradient)
|
||||
else:
|
||||
cols = _default_green_gradient()
|
||||
cols = grad_cols or GRAD_COLS
|
||||
n = len(cols)
|
||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||
out = []
|
||||
@@ -253,30 +213,7 @@ def lr_gradient(rows, offset=0.0, cols=None):
|
||||
|
||||
def lr_gradient_opposite(rows, offset=0.0):
|
||||
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
||||
return lr_gradient(rows, offset, _default_magenta_gradient())
|
||||
|
||||
|
||||
def msg_gradient(rows, offset):
|
||||
"""Apply message (ntfy) gradient using theme complementary colors.
|
||||
|
||||
Returns colored rows using ACTIVE_THEME.message_gradient if available,
|
||||
falling back to default magenta if no theme is set.
|
||||
|
||||
Args:
|
||||
rows: List of text strings to colorize
|
||||
offset: Gradient offset (0.0-1.0) for animation
|
||||
|
||||
Returns:
|
||||
List of rows with ANSI color codes applied
|
||||
"""
|
||||
from engine import config
|
||||
|
||||
cols = (
|
||||
_color_codes_to_ansi(config.ACTIVE_THEME.message_gradient)
|
||||
if config.ACTIVE_THEME
|
||||
else _default_magenta_gradient()
|
||||
)
|
||||
return lr_gradient(rows, offset, cols)
|
||||
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||
|
||||
|
||||
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
||||
|
||||
247
engine/scroll.py
247
engine/scroll.py
@@ -1,105 +1,148 @@
|
||||
"""
|
||||
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
||||
Orchestrates viewport, frame timing, and layers.
|
||||
Depends on: config, terminal, render, effects, ntfy, mic.
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
from engine.display import (
|
||||
Display,
|
||||
TerminalDisplay,
|
||||
from engine.effects import (
|
||||
fade_line,
|
||||
firehose_line,
|
||||
glitch_bar,
|
||||
next_headline,
|
||||
noise,
|
||||
vis_trunc,
|
||||
)
|
||||
from engine.display import (
|
||||
get_monitor as _get_display_monitor,
|
||||
)
|
||||
from engine.frame import calculate_scroll_step
|
||||
from engine.layers import (
|
||||
apply_glitch,
|
||||
process_effects,
|
||||
render_figment_overlay,
|
||||
render_firehose,
|
||||
render_message_overlay,
|
||||
render_ticker_zone,
|
||||
)
|
||||
from engine.viewport import th, tw
|
||||
|
||||
USE_EFFECT_CHAIN = True
|
||||
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite, make_block
|
||||
from engine.terminal import CLR, RST, W_COOL, th, tw
|
||||
|
||||
|
||||
def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
|
||||
def stream(items, ntfy_poller, mic_monitor):
|
||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||
if display is None:
|
||||
display = TerminalDisplay()
|
||||
random.shuffle(items)
|
||||
pool = list(items)
|
||||
seen = set()
|
||||
queued = 0
|
||||
|
||||
time.sleep(0.5)
|
||||
w, h = tw(), th()
|
||||
display.init(w, h)
|
||||
display.clear()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh
|
||||
GAP = 3
|
||||
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||
sys.stdout.write(CLR)
|
||||
sys.stdout.flush()
|
||||
|
||||
w, h = tw(), th()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh # reserve fixed firehose strip at bottom
|
||||
GAP = 3 # blank rows between headlines
|
||||
scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2
|
||||
|
||||
# Taxonomy:
|
||||
# - message: centered ntfy overlay panel
|
||||
# - ticker: large headline text content
|
||||
# - scroll: upward camera motion applied to ticker content
|
||||
# - firehose: fixed carriage-return style strip pinned at bottom
|
||||
# Active ticker blocks: (content_rows, color, canvas_y, meta_idx)
|
||||
active = []
|
||||
scroll_cam = 0
|
||||
ticker_next_y = ticker_view_h
|
||||
scroll_cam = 0 # viewport top in virtual canvas coords
|
||||
ticker_next_y = (
|
||||
ticker_view_h # canvas-y where next block starts (off-screen bottom)
|
||||
)
|
||||
noise_cache = {}
|
||||
scroll_motion_accum = 0.0
|
||||
msg_cache = (None, None)
|
||||
frame_number = 0
|
||||
|
||||
# Figment overlay (optional — requires cairosvg)
|
||||
figment = None
|
||||
if config.FIGMENT:
|
||||
try:
|
||||
from effects_plugins.figment import FigmentEffect
|
||||
def _noise_at(cy):
|
||||
if cy not in noise_cache:
|
||||
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
|
||||
return noise_cache[cy]
|
||||
|
||||
figment = FigmentEffect()
|
||||
figment.config.enabled = True
|
||||
figment.config.params["interval_secs"] = config.FIGMENT_INTERVAL
|
||||
except (ImportError, OSError):
|
||||
pass
|
||||
|
||||
while True:
|
||||
if queued >= config.HEADLINE_LIMIT and not active:
|
||||
break
|
||||
# Message color: bright cyan/white — distinct from headline greens
|
||||
MSG_META = "\033[38;5;245m" # cool grey
|
||||
MSG_BORDER = "\033[2;38;5;37m" # dim teal
|
||||
_msg_cache = (None, None) # (cache_key, rendered_rows)
|
||||
|
||||
while queued < config.HEADLINE_LIMIT or active:
|
||||
t0 = time.monotonic()
|
||||
w, h = tw(), th()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh
|
||||
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||
|
||||
# ── Check for ntfy message ────────────────────────
|
||||
msg_h = 0
|
||||
msg_overlay = []
|
||||
msg = ntfy_poller.get_active_message()
|
||||
msg_overlay, msg_cache = render_message_overlay(msg, w, h, msg_cache)
|
||||
|
||||
buf = []
|
||||
ticker_h = ticker_view_h
|
||||
if msg is not None:
|
||||
m_title, m_body, m_ts = msg
|
||||
# ── Message overlay: centered in the viewport ──
|
||||
display_text = m_body or m_title or "(empty)"
|
||||
display_text = re.sub(r"\s+", " ", display_text.upper())
|
||||
cache_key = (display_text, w)
|
||||
if _msg_cache[0] != cache_key:
|
||||
msg_rows = big_wrap(display_text, w - 4)
|
||||
_msg_cache = (cache_key, msg_rows)
|
||||
else:
|
||||
msg_rows = _msg_cache[1]
|
||||
msg_rows = lr_gradient_opposite(
|
||||
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||
)
|
||||
# Layout: rendered text + meta + border
|
||||
elapsed_s = int(time.monotonic() - m_ts)
|
||||
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
|
||||
ts_str = datetime.now().strftime("%H:%M:%S")
|
||||
panel_h = len(msg_rows) + 2 # meta + border
|
||||
panel_top = max(0, (h - panel_h) // 2)
|
||||
row_idx = 0
|
||||
for mr in msg_rows:
|
||||
ln = vis_trunc(mr, w)
|
||||
msg_overlay.append(
|
||||
f"\033[{panel_top + row_idx + 1};1H {ln}{RST}\033[K"
|
||||
)
|
||||
row_idx += 1
|
||||
# Meta line: title (if distinct) + source + countdown
|
||||
meta_parts = []
|
||||
if m_title and m_title != m_body:
|
||||
meta_parts.append(m_title)
|
||||
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
|
||||
meta = (
|
||||
" " + " \u00b7 ".join(meta_parts)
|
||||
if len(meta_parts) > 1
|
||||
else " " + meta_parts[0]
|
||||
)
|
||||
msg_overlay.append(
|
||||
f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K"
|
||||
)
|
||||
row_idx += 1
|
||||
# Border — constant boundary under message panel
|
||||
bar = "\u2500" * (w - 4)
|
||||
msg_overlay.append(
|
||||
f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K"
|
||||
)
|
||||
|
||||
# Ticker draws above the fixed firehose strip; message is a centered overlay.
|
||||
ticker_h = ticker_view_h - msg_h
|
||||
|
||||
# ── Ticker content + scroll motion (always runs) ──
|
||||
scroll_motion_accum += config.FRAME_DT
|
||||
while scroll_motion_accum >= scroll_step_interval:
|
||||
scroll_motion_accum -= scroll_step_interval
|
||||
scroll_cam += 1
|
||||
|
||||
# Enqueue new headlines when room at the bottom
|
||||
while (
|
||||
ticker_next_y < scroll_cam + ticker_view_h + 10
|
||||
and queued < config.HEADLINE_LIMIT
|
||||
):
|
||||
from engine.effects import next_headline
|
||||
from engine.render import make_block
|
||||
|
||||
t, src, ts = next_headline(pool, items, seen)
|
||||
ticker_content, hc, midx = make_block(t, src, ts, w)
|
||||
active.append((ticker_content, hc, ticker_next_y, midx))
|
||||
ticker_next_y += len(ticker_content) + GAP
|
||||
queued += 1
|
||||
|
||||
# Prune off-screen blocks and stale noise
|
||||
active = [
|
||||
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
|
||||
]
|
||||
@@ -107,55 +150,71 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
|
||||
if k < scroll_cam:
|
||||
del noise_cache[k]
|
||||
|
||||
# Draw ticker zone (above fixed firehose strip)
|
||||
top_zone = max(1, int(ticker_h * 0.25))
|
||||
bot_zone = max(1, int(ticker_h * 0.10))
|
||||
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||
ticker_buf_start = len(buf)
|
||||
|
||||
ticker_buf, noise_cache = render_ticker_zone(
|
||||
active, scroll_cam, ticker_h, w, noise_cache, grad_offset
|
||||
)
|
||||
buf.extend(ticker_buf)
|
||||
ticker_buf_start = len(buf) # track where ticker rows start in buf
|
||||
for r in range(ticker_h):
|
||||
scr_row = r + 1 # 1-indexed ANSI screen row
|
||||
cy = scroll_cam + r
|
||||
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||
bot_f = min(1.0, (ticker_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0
|
||||
row_fade = min(top_f, bot_f)
|
||||
drawn = False
|
||||
for content, hc, by, midx in active:
|
||||
cr = cy - by
|
||||
if 0 <= cr < len(content):
|
||||
raw = content[cr]
|
||||
if cr != midx:
|
||||
colored = lr_gradient([raw], grad_offset)[0]
|
||||
else:
|
||||
colored = raw
|
||||
ln = vis_trunc(colored, w)
|
||||
if row_fade < 1.0:
|
||||
ln = fade_line(ln, row_fade)
|
||||
if cr == midx:
|
||||
buf.append(f"\033[{scr_row};1H{W_COOL}{ln}{RST}\033[K")
|
||||
elif ln.strip():
|
||||
buf.append(f"\033[{scr_row};1H{ln}{RST}\033[K")
|
||||
else:
|
||||
buf.append(f"\033[{scr_row};1H\033[K")
|
||||
drawn = True
|
||||
break
|
||||
if not drawn:
|
||||
n = _noise_at(cy)
|
||||
if row_fade < 1.0 and n:
|
||||
n = fade_line(n, row_fade)
|
||||
if n:
|
||||
buf.append(f"\033[{scr_row};1H{n}")
|
||||
else:
|
||||
buf.append(f"\033[{scr_row};1H\033[K")
|
||||
|
||||
# Glitch — base rate + mic-reactive spikes (ticker zone only)
|
||||
mic_excess = mic_monitor.excess
|
||||
render_start = time.perf_counter()
|
||||
|
||||
if USE_EFFECT_CHAIN:
|
||||
buf = process_effects(
|
||||
buf,
|
||||
w,
|
||||
h,
|
||||
scroll_cam,
|
||||
ticker_h,
|
||||
mic_excess,
|
||||
grad_offset,
|
||||
frame_number,
|
||||
msg is not None,
|
||||
items,
|
||||
)
|
||||
else:
|
||||
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
||||
firehose_buf = render_firehose(items, w, fh, h)
|
||||
buf.extend(firehose_buf)
|
||||
|
||||
# Figment overlay (between effects and ntfy message)
|
||||
if figment and figment.config.enabled:
|
||||
figment_state = figment.get_figment_state(frame_number, w, h)
|
||||
if figment_state is not None:
|
||||
figment_buf = render_figment_overlay(figment_state, w, h)
|
||||
buf.extend(figment_buf)
|
||||
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
|
||||
n_hits = 4 + int(mic_excess / 2)
|
||||
ticker_buf_len = len(buf) - ticker_buf_start
|
||||
if random.random() < glitch_prob and ticker_buf_len > 0:
|
||||
for _ in range(min(n_hits, ticker_buf_len)):
|
||||
gi = random.randint(0, ticker_buf_len - 1)
|
||||
scr_row = gi + 1
|
||||
buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
|
||||
|
||||
if config.FIREHOSE and fh > 0:
|
||||
for fr in range(fh):
|
||||
scr_row = h - fh + fr + 1
|
||||
fline = firehose_line(items, w)
|
||||
buf.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||
if msg_overlay:
|
||||
buf.extend(msg_overlay)
|
||||
|
||||
render_elapsed = (time.perf_counter() - render_start) * 1000
|
||||
monitor = _get_display_monitor()
|
||||
if monitor:
|
||||
chars = sum(len(line) for line in buf)
|
||||
monitor.record_effect("render", render_elapsed, chars, chars)
|
||||
|
||||
display.show(buf)
|
||||
sys.stdout.buffer.write("".join(buf).encode())
|
||||
sys.stdout.flush()
|
||||
|
||||
# Precise frame timing
|
||||
elapsed = time.monotonic() - t0
|
||||
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||
frame_number += 1
|
||||
|
||||
display.cleanup()
|
||||
sys.stdout.write(CLR)
|
||||
sys.stdout.flush()
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""
|
||||
Theme definitions with color gradients for terminal rendering.
|
||||
|
||||
This module is data-only and does not import config or render
|
||||
to prevent circular dependencies.
|
||||
"""
|
||||
|
||||
|
||||
class Theme:
|
||||
"""Represents a color theme with two gradients."""
|
||||
|
||||
def __init__(self, name, main_gradient, message_gradient):
|
||||
"""Initialize a theme with name and color gradients.
|
||||
|
||||
Args:
|
||||
name: Theme identifier string
|
||||
main_gradient: List of 12 ANSI 256-color codes for main gradient
|
||||
message_gradient: List of 12 ANSI 256-color codes for message gradient
|
||||
"""
|
||||
self.name = name
|
||||
self.main_gradient = main_gradient
|
||||
self.message_gradient = message_gradient
|
||||
|
||||
|
||||
# ─── GRADIENT DEFINITIONS ─────────────────────────────────────────────────
|
||||
# Each gradient is 12 ANSI 256-color codes in sequence
|
||||
# Format: [light...] → [medium...] → [dark...] → [black]
|
||||
|
||||
_GREEN_MAIN = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||
_GREEN_MSG = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||
|
||||
_ORANGE_MAIN = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||
_ORANGE_MSG = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||
|
||||
_PURPLE_MAIN = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||
_PURPLE_MSG = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||
|
||||
|
||||
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||
|
||||
THEME_REGISTRY = {
|
||||
"green": Theme("green", _GREEN_MAIN, _GREEN_MSG),
|
||||
"orange": Theme("orange", _ORANGE_MAIN, _ORANGE_MSG),
|
||||
"purple": Theme("purple", _PURPLE_MAIN, _PURPLE_MSG),
|
||||
}
|
||||
|
||||
|
||||
def get_theme(theme_id):
|
||||
"""Retrieve a theme by ID.
|
||||
|
||||
Args:
|
||||
theme_id: Theme identifier string
|
||||
|
||||
Returns:
|
||||
Theme object matching the ID
|
||||
|
||||
Raises:
|
||||
KeyError: If theme_id is not in registry
|
||||
"""
|
||||
return THEME_REGISTRY[theme_id]
|
||||
@@ -7,16 +7,26 @@ import json
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from functools import lru_cache
|
||||
|
||||
from engine.sources import LOCATION_LANGS
|
||||
|
||||
TRANSLATE_CACHE_SIZE = 500
|
||||
_TRANSLATE_CACHE = {}
|
||||
|
||||
|
||||
@lru_cache(maxsize=TRANSLATE_CACHE_SIZE)
|
||||
def _translate_cached(title: str, target_lang: str) -> str:
|
||||
"""Cached translation implementation."""
|
||||
def detect_location_language(title):
|
||||
"""Detect if headline mentions a location, return target language."""
|
||||
title_lower = title.lower()
|
||||
for pattern, lang in LOCATION_LANGS.items():
|
||||
if re.search(pattern, title_lower):
|
||||
return lang
|
||||
return None
|
||||
|
||||
|
||||
def translate_headline(title, target_lang):
|
||||
"""Translate headline via Google Translate API (zero dependencies)."""
|
||||
key = (title, target_lang)
|
||||
if key in _TRANSLATE_CACHE:
|
||||
return _TRANSLATE_CACHE[key]
|
||||
try:
|
||||
q = urllib.parse.quote(title)
|
||||
url = (
|
||||
@@ -29,18 +39,5 @@ def _translate_cached(title: str, target_lang: str) -> str:
|
||||
result = "".join(p[0] for p in data[0] if p[0]) or title
|
||||
except Exception:
|
||||
result = title
|
||||
_TRANSLATE_CACHE[key] = result
|
||||
return result
|
||||
|
||||
|
||||
def detect_location_language(title):
|
||||
"""Detect if headline mentions a location, return target language."""
|
||||
title_lower = title.lower()
|
||||
for pattern, lang in LOCATION_LANGS.items():
|
||||
if re.search(pattern, title_lower):
|
||||
return lang
|
||||
return None
|
||||
|
||||
|
||||
def translate_headline(title: str, target_lang: str) -> str:
|
||||
"""Translate headline via Google Translate API (zero dependencies)."""
|
||||
return _translate_cached(title, target_lang)
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""
|
||||
Shared dataclasses for the mainline application.
|
||||
Provides named types for tuple returns across modules.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeadlineItem:
|
||||
"""A single headline item: title, source, and timestamp."""
|
||||
|
||||
title: str
|
||||
source: str
|
||||
timestamp: str
|
||||
|
||||
def to_tuple(self) -> tuple[str, str, str]:
|
||||
"""Convert to tuple for backward compatibility."""
|
||||
return (self.title, self.source, self.timestamp)
|
||||
|
||||
@classmethod
|
||||
def from_tuple(cls, t: tuple[str, str, str]) -> "HeadlineItem":
|
||||
"""Create from tuple for backward compatibility."""
|
||||
return cls(title=t[0], source=t[1], timestamp=t[2])
|
||||
|
||||
|
||||
def items_to_tuples(items: list[HeadlineItem]) -> list[tuple[str, str, str]]:
|
||||
"""Convert list of HeadlineItem to list of tuples."""
|
||||
return [item.to_tuple() for item in items]
|
||||
|
||||
|
||||
def tuples_to_items(tuples: list[tuple[str, str, str]]) -> list[HeadlineItem]:
|
||||
"""Convert list of tuples to list of HeadlineItem."""
|
||||
return [HeadlineItem.from_tuple(t) for t in tuples]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
"""Result from fetch_all() or fetch_poetry()."""
|
||||
|
||||
items: list[HeadlineItem]
|
||||
linked: int
|
||||
failed: int
|
||||
|
||||
def to_legacy_tuple(self) -> tuple[list[tuple], int, int]:
|
||||
"""Convert to legacy tuple format for backward compatibility."""
|
||||
return ([item.to_tuple() for item in self.items], self.linked, self.failed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Block:
|
||||
"""Rendered headline block from make_block()."""
|
||||
|
||||
content: list[str]
|
||||
color: str
|
||||
meta_row_index: int
|
||||
|
||||
def to_legacy_tuple(self) -> tuple[list[str], str, int]:
|
||||
"""Convert to legacy tuple format for backward compatibility."""
|
||||
return (self.content, self.color, self.meta_row_index)
|
||||
@@ -1,37 +0,0 @@
|
||||
"""
|
||||
Viewport utilities — terminal dimensions and ANSI positioning helpers.
|
||||
No internal dependencies.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def tw() -> int:
|
||||
"""Get terminal width (columns)."""
|
||||
try:
|
||||
return os.get_terminal_size().columns
|
||||
except Exception:
|
||||
return 80
|
||||
|
||||
|
||||
def th() -> int:
|
||||
"""Get terminal height (lines)."""
|
||||
try:
|
||||
return os.get_terminal_size().lines
|
||||
except Exception:
|
||||
return 24
|
||||
|
||||
|
||||
def move_to(row: int, col: int = 1) -> str:
|
||||
"""Generate ANSI escape to move cursor to row, col (1-indexed)."""
|
||||
return f"\033[{row};{col}H"
|
||||
|
||||
|
||||
def clear_screen() -> str:
|
||||
"""Clear screen and move cursor to home."""
|
||||
return "\033[2J\033[H"
|
||||
|
||||
|
||||
def clear_line() -> str:
|
||||
"""Clear current line."""
|
||||
return "\033[K"
|
||||
@@ -1,32 +0,0 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="800px" height="800px" viewBox="0 0 577.362 577.362"
|
||||
xml:space="preserve">
|
||||
<g>
|
||||
<g id="Layer_2_21_">
|
||||
<path d="M547.301,156.98c-23.113-2.132-181.832-24.174-314.358,5.718c-37.848-16.734-57.337-21.019-85.269-31.078
|
||||
c-12.47-4.494-28.209-7.277-41.301-9.458c-26.01-4.322-45.89,1.253-54.697,31.346C36.94,203.846,19.201,253.293,0,311.386
|
||||
c15.118-0.842,40.487-8.836,40.487-8.836l48.214-7.966l-9.964,66.938l57.777-19.526v57.776l66.938-29.883l19.125,49.41
|
||||
c0,0,44.647-34.081,57.375-49.41c28.076,83.634,104.595,105.981,175.71,70.122c21.42-10.806,39.914-46.637,48.129-65.255
|
||||
c23.926-54.229,11.6-93.712-5.891-137.155c20.254-9.562,34.061-13.464,66.344-30.628
|
||||
C582.365,197.764,585.951,161.904,547.301,156.98z M63.352,196.119c11.924-8.396,18.599,0.889,34.511-10.308
|
||||
c6.971-5.183,4.581-18.924-4.542-21.908c-3.997-1.31-6.722-2.897-12.049-5.192c-7.449-2.984-0.851-20.082,7.325-18.676
|
||||
c15.443,2.572,24.575,3.012,32.159,12.125c8.702,10.452,9.008,37.074,4.991,45.843c-9.553,20.885-35.257,19.087-53.923,17.241
|
||||
C57.624,214.097,56.744,201.034,63.352,196.119z M284.073,346.938c-51.915,6.685-102.921,0.794-142.462-42.313
|
||||
c-25.331-27.616-57.231-46.187-88.654-68.611c28.84-11.121,64.49-5.078,84.781,25.704
|
||||
c45.383,68.841,106.344,71.279,176.887,56.247c24.127-5.145,52.9-8.052,76.807-2.983c26.297,5.574,29.279,31.24,12.039,48.118
|
||||
c-18.227,19.775-39.045-0.794-29.482-6.378c7.967-4.38,12.643-10.997,10.482-19.259c-6.197-9.668-21.707-2.975-31.586-1.425
|
||||
C324.953,340.437,312.023,343.344,284.073,346.938z M472.188,381.049c-24.176,34.31-54.775,55.969-100.789,47.602
|
||||
c-27.846-5.059-61.41-30.179-53.789-65.14c34.061,41.836,95.625,35.859,114.75,1.195c16.533-29.969-4.141-62.5-23.793-66.852
|
||||
c-30.676-6.779-69.891-0.134-101.381,4.408c-58.58,8.444-104.48,7.812-152.579-43.844c-26.067-27.99,15.376-53.493-7.736-107.282
|
||||
c44.351,8.578,72.121,22.711,89.247,79.292c11.293,37.294,59.096,61.325,110.762,53.387
|
||||
c38.031-5.842,81.912-22.873,119.703-31.853C499.66,299.786,498.293,343.984,472.188,381.049z M288.195,243.568
|
||||
c31.805-12.135,64.67-9.151,94.362,0C350.475,273.26,301.467,268.479,288.195,243.568z M528.979,198.959
|
||||
c-35.459,17.337-60.961,25.102-98.809,37.055c-5.146,1.626-13.895,1.042-18.438-2.17c-47.803-33.813-114.846-27.425-142.338-6.292
|
||||
c-18.522-11.456-21.038-42.582,8.406-49.304c83.834-19.125,179.45-13.646,248.788,0.793
|
||||
C540.529,183.42,538.674,194.876,528.979,198.959z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,60 +0,0 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="800px" height="800px" viewBox="0 0 559.731 559.731"
|
||||
xml:space="preserve">
|
||||
<g>
|
||||
<g id="Layer_2_36_">
|
||||
<path d="M295.414,162.367l-15.061-39.302l-14.918,39.34c5.049-0.507,10.165-0.774,15.339-0.774
|
||||
C285.718,161.621,290.595,161.898,295.414,162.367z"/>
|
||||
<path d="M522.103,244.126c-20.062-0.631-36.71,12.67-55.787,21.937c-25.111,12.192-17.548-7.526-17.548-7.526l56.419-107.186
|
||||
c-31.346-31.967-127.869-68.324-127.869-68.324l-38.968,85.957L280.774,27.249L221.295,168.84l-38.9-85.804
|
||||
c0,0-96.533,36.356-127.87,68.324l56.418,107.186c0,0,7.564,19.718-17.547,7.525c-19.077-9.266-35.726-22.567-55.788-21.936
|
||||
C17.547,244.767,0,275.481,0,305.565c0,30.084,7.525,68.955,39.493,68.955c31.967,0,47.64-16.926,58.924-23.188
|
||||
c11.284-6.273,20.062,1.252,14.105,12.536S49.524,465.412,49.524,465.412s57.041,40.115,130.375,67.071l33.22-84.083
|
||||
c-49.601-24.91-83.796-76.127-83.796-135.31c0-61.372,36.758-114.214,89.352-137.986c1.511-0.688,3.002-1.406,4.542-2.037
|
||||
c9.964-4.112,20.483-7.095,31.384-9.008l25.732-67.836l25.943,67.731c10.576,1.807,20.779,4.657,30.495,8.53
|
||||
c1.176,0.468,2.391,0.88,3.557,1.377c53.99,23.18,91.925,76.844,91.925,139.229c0,59.795-34.913,111.441-85.346,136.056
|
||||
l32.924,83.337c73.335-26.956,130.375-67.071,130.375-67.071s-57.04-90.26-62.998-101.544
|
||||
c-5.957-11.284,2.821-18.81,14.105-12.536c11.283,6.272,26.956,23.188,58.924,23.188s39.493-38.861,39.493-68.955
|
||||
C559.712,275.472,542.165,244.757,522.103,244.126z"/>
|
||||
<path d="M256.131,173.478c-1.836,0.325-3.682,0.612-5.499,1.004c-8.912,1.932-17.518,4.676-25.723,8.205
|
||||
c-4.045,1.74-7.995,3.634-11.839,5.728c-44.159,24.078-74.195,70.925-74.195,124.667c0,55.146,31.681,102.931,77.743,126.396
|
||||
c19.297,9.831,41.052,15.491,64.146,15.491c22.481,0,43.682-5.393,62.596-14.745c46.895-23.18,79.302-71.394,79.302-127.152
|
||||
c0-54.851-31.336-102.434-77.007-126.043c-3.557-1.836-7.172-3.576-10.892-5.116c-7.86-3.242-16.056-5.814-24.547-7.622
|
||||
c-1.808-0.382-3.652-0.622-5.479-0.937c-1.807-0.306-3.614-0.593-5.44-0.832c-6.082-0.793-12.24-1.348-18.532-1.348
|
||||
c-6.541,0-12.919,0.602-19.221,1.463C259.736,172.895,257.929,173.163,256.131,173.478z M280.783,196.084
|
||||
c10.433,0,20.493,1.501,30.132,4.074c8.559,2.285,16.754,5.441,24.423,9.496c37.093,19.641,62.443,58.608,62.443,103.418
|
||||
c0,43.155-23.543,80.832-58.408,101.114c-17.251,10.04-37.227,15.883-58.59,15.883c-22.127,0-42.753-6.282-60.416-16.992
|
||||
c-33.842-20.531-56.581-57.614-56.581-100.005c0-44.064,24.499-82.486,60.578-102.434c14.889-8.233,31.776-13.196,49.715-14.22
|
||||
C276.309,196.294,278.518,196.084,280.783,196.084z"/>
|
||||
<path d="M236.997,354.764c-6.694,0-12.145,5.45-12.145,12.145v4.398c0,6.694,5.441,12.145,12.145,12.145h16.457
|
||||
c-1.683-11.743-0.717-22.376,0.268-28.688H236.997z"/>
|
||||
<path d="M327.458,383.452c5.001,0,9.295-3.041,11.15-7.373c0.641-1.473,0.994-3.079,0.994-4.771v-4.398
|
||||
c0-1.874-0.507-3.605-1.271-5.192c-1.961-4.074-6.054-6.952-10.873-6.952h-17.882c2.592,8.415,3.5,18.303,1.683,28.688H327.458z"
|
||||
/>
|
||||
<path d="M173.339,313.082c0,36.949,18.752,69.596,47.239,88.94c14.516,9.859,31.566,16.237,49.945,17.978
|
||||
c-7.879-8.176-12.527-17.633-15.089-26.985h-18.437c-6.407,0-12.116-2.85-16.084-7.277c-3.461-3.844-5.623-8.874-5.623-14.43
|
||||
v-4.398c0-5.938,2.41-11.322,6.283-15.243c3.939-3.987,9.39-6.464,15.424-6.464h18.809h49.974h21.697
|
||||
c3.863,0,7.449,1.1,10.595,2.888c6.579,3.729,11.093,10.72,11.093,18.819v4.398c0,7.765-4.131,14.535-10.279,18.379
|
||||
c-3.328,2.075-7.22,3.328-11.428,3.328h-18.676c-3.088,9.056-8.463,18.227-16.791,26.909c17.27-1.798,33.296-7.756,47.162-16.772
|
||||
c29.48-19.173,49.056-52.355,49.056-90.069c0-39.216-21.19-73.498-52.661-92.259c-16.064-9.572-34.75-15.176-54.765-15.176
|
||||
c-20.798,0-40.172,6.043-56.638,16.313C193.698,240.942,173.339,274.64,173.339,313.082z M306.287,274.583
|
||||
c4.513-9.027,15.156-14.64,27.778-14.64c0.775,0,1.502,0.201,2.257,0.249c11.026,0.622,21.22,5.499,27.53,13.598l2.238,2.888
|
||||
l-2.19,2.926c-6.789,9.036-16.667,14.688-26.89,15.597c-0.956,0.086-1.912,0.19-2.878,0.19c-11.284,0-21.362-5.89-27.664-16.16
|
||||
l-1.387-2.257L306.287,274.583z M268.353,311.484l1.271,3.691c1.501,4.398,6.206,13.493,11.159,13.493
|
||||
c4.915,0,9.649-9.372,11.055-13.646l1.138-3.48l3.653,0.201c9.658,0.517,12.594-1.454,13.244-2.065
|
||||
c0.392-0.363,0.641-0.794,0.641-1.722c0-2.639,2.142-4.781,4.781-4.781c2.639,0,4.781,2.143,4.781,4.781
|
||||
c0,3.414-1.253,6.417-3.624,8.664c-3.396,3.223-8.731,4.666-16.84,4.781c-2.534,5.852-8.635,16.839-18.838,16.839
|
||||
c-10.06,0-16.19-10.595-18.81-16.428c-5.756,0.315-13.368-0.249-18.216-4.514c-2.716-2.391-4.16-5.623-4.16-9.343
|
||||
c0-2.639,2.142-4.781,4.781-4.781s4.781,2.143,4.781,4.781c0,0.976,0.258,1.597,0.908,2.171c2.2,1.932,8.004,2.696,14.42,1.855
|
||||
L268.353,311.484z M257.9,273.789l2.238,2.878l-2.19,2.916c-7.411,9.888-18.532,15.788-29.758,15.788
|
||||
c-1.875,0-3.701-0.22-5.499-0.535c-9.018-1.598-16.916-7.058-22.166-15.625l-1.396-2.266l1.186-2.372
|
||||
c3.94-7.87,12.546-13.148,23.055-14.363c1.54-0.182,3.127-0.277,4.733-0.277C240.028,259.942,251.168,265.116,257.9,273.789z"/>
|
||||
<path d="M301.468,383.452c2.228-10.596,1.08-20.636-1.961-28.688h-36.06c-0.918,5.489-2.171,16.591-0.191,28.688
|
||||
c0.517,3.146,1.272,6.359,2.295,9.562c2.763,8.664,7.563,17.231,15.73,24.088c8.443-7.707,13.941-15.94,17.26-24.088
|
||||
C299.86,389.801,300.808,386.607,301.468,383.452z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.6 KiB |
@@ -1,110 +0,0 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="800px" height="800px" viewBox="0 0 589.748 589.748"
|
||||
xml:space="preserve">
|
||||
<g>
|
||||
<g id="Layer_2_2_">
|
||||
<path d="M498.658,267.846c-9.219-9.744-20.59-14.382-33.211-15.491c-13.914-1.234-26.719,3.098-37.514,12.278
|
||||
c-4.82,4.093-15.416,2.763-16.916-5.413c-0.795-4.303-0.096-7.602,2.305-11.246c3.854-5.862,6.98-12.202,10.422-18.331
|
||||
c3.73-6.646,7.508-13.263,11.16-19.947c5.26-9.61,10.375-19.307,15.672-28.898c3.76-6.799,7.785-13.445,11.486-20.273
|
||||
c0.459-0.851,0.104-3.031-0.594-3.48c-7.898-5.106-15.777-10.28-23.982-14.86c-7.602-4.236-15.502-7.975-23.447-11.542
|
||||
c-8.348-3.739-16.889-7.076-25.418-10.404c-0.879-0.344-2.869,0.191-3.299,0.928c-5.26,9.008-10.346,18.111-15.443,27.215
|
||||
c-4.006,7.153-7.918,14.363-11.924,21.516c-2.381,4.255-4.877,8.434-7.297,12.661c-3.193,5.575-6.215,11.255-9.609,16.715
|
||||
c-1.234,1.989-0.363,2.467,1.07,3.232c5.25,2.812,11.016,5.001,15.586,8.673c7.736,6.225,15.109,13.034,21.879,20.301
|
||||
c4.629,4.963,8.598,10.796,11.725,16.82c3.824,7.373,6.865,15.233,9.477,23.132c2.094,6.34,4.006,13.024,4.283,19.632
|
||||
c0.441,10.317,1.473,20.837-1.291,31.04c-2.352,8.645-4.484,17.423-7.764,25.723c-2.41,6.101-6.445,11.58-9.879,17.27
|
||||
c-6.225,10.309-14.354,18.943-24.115,25.925c-6.428,4.599-13.207,8.701-20.035,13.157c14.621,26.584,29.396,53.436,44.266,80.459
|
||||
c4.762-1.788,9.256-3.375,13.664-5.154c7.412-2.974,14.918-5.766,22.129-9.189c6.082-2.888,11.857-6.464,17.662-9.906
|
||||
c7.41-4.399,14.734-8.932,22.012-13.541c0.604-0.382,1.043-2.056,0.717-2.706c-1.768-3.5-3.748-6.904-5.766-10.271
|
||||
c-4.246-7.085-8.635-14.095-12.812-21.219c-3.5-5.967-6.752-12.077-10.166-18.083c-3.711-6.512-7.525-12.957-11.207-19.488
|
||||
c-2.611-4.638-4.887-9.477-7.65-14.019c-2.008-3.299-3.91-6.292-3.768-10.528c0.152-4.6,2.18-7.583,5.824-9.668
|
||||
c3.613-2.056,7.391-1.864,10.814,0.546c2.945,2.074,5.412,5.077,8.615,6.492c5.527,2.438,11.408,4.122,17.232,5.834
|
||||
c7.602,2.228,15.328,0.927,22.586-1.062c7.268-1.989,14.258-5.394,19.861-10.806c2.85-2.754,5.939-5.441,8.09-8.712
|
||||
c4.285-6.493,7.432-13.426,8.885-21.324c1.51-8.195,0.688-16.065-1.645-23.61C508.957,280.516,504.404,273.927,498.658,267.846z"
|
||||
/>
|
||||
<path d="M183.983,301.85c0.421-46.885,24.174-79.417,64.69-100.846c-1.817-3.471-3.461-6.761-5.24-9.983
|
||||
c-3.423-6.177-6.99-12.278-10.375-18.475c-5.518-10.117-10.882-20.32-16.438-30.418c-3.577-6.502-7.574-12.766-10.987-19.345
|
||||
c-1.454-2.802-2.802-3.137-5.613-2.142c-12.642,4.466-25.016,9.543-36.979,15.606c-11.915,6.043-23.418,12.728-34.32,20.492
|
||||
c-1.778,1.262-1.96,2.104-1.004,3.777c2.792,4.848,5.537,9.725,8.271,14.611c4.973,8.874,9.955,17.739,14.86,26.632
|
||||
c3.242,5.871,6.282,11.857,9.572,17.7c5.843,10.375,12.02,20.579,17.643,31.078c2.448,4.571,2.247,10.604-2.639,14.009
|
||||
c-5.011,3.491-9.486,3.596-14.22-0.115c-6.311-4.953-13.167-8.424-20.913-10.509c-11.59-3.127-22.711-1.894-33.564,2.802
|
||||
c-2.18,0.946-4.112,2.429-6.244,3.48c-6.216,3.079-10.815,7.994-14.755,13.455c-4.447,6.168-7.076,13.158-8.683,20.655
|
||||
c-1.73,8.071-1.052,16.008,1.167,23.677c2.878,9.955,8.807,18.149,16.677,24.996c5.613,4.887,12.192,8.339,19.096,9.975
|
||||
c6.666,1.577,13.933,1.367,20.866,0.898c7.621-0.507,14.621-3.528,20.817-8.176c5.699-4.274,11.16-9.209,18.905-3.558
|
||||
c3.242,2.362,5.431,10.375,3.414,13.751c-7.937,13.272-15.816,26.584-23.524,39.99c-4.169,7.249-7.851,14.774-11.915,22.09
|
||||
c-4.456,8.013-9.151,15.902-13.646,23.896c-2.362,4.207-2.094,4.724,2.142,7.277c4.8,2.878,9.505,5.947,14.373,8.711
|
||||
c8.09,4.6,16.18,9.237,24.48,13.436c5.556,2.812,11.427,5.011,17.241,7.286c5.393,2.113,10.892,3.969,16.524,6.006
|
||||
c14.908-27.119,29.653-53.942,44.322-80.631C207.775,381.381,183.563,349.012,183.983,301.85z"/>
|
||||
<path d="M283.979,220.368c-36.777,4.839-64.327,32.302-72.245,60.99c55.348,0,110.629,0,166.129,0
|
||||
C364.667,233.545,324.189,215.08,283.979,220.368z"/>
|
||||
<path d="M381.019,300.482c-9.82,0-19.201,0-28.889,0c0.727,9.562-3.203,28.143-13.1,40.028
|
||||
c-9.926,11.915-22.529,18.207-37.658,19.68c-16.983,1.645-32.694-1.692-45.546-13.464c-13.655-12.498-20.129-27.119-18.81-46.244
|
||||
c-9.763,0-18.972,0-29.223,0c-0.239,38.25,14.688,62.089,45.719,78.986c29.863,16.266,60.559,15.242,88.883-3.433
|
||||
C369.066,358.45,382.291,329.17,381.019,300.482z"/>
|
||||
<path d="M260.656,176.715c3.242,5.948,6.474,11.886,9.477,17.404c6.541-0.88,12.622-2.458,18.675-2.343
|
||||
c9.313,0.182,18.59,1.559,27.893,2.314c0.957,0.077,2.486-0.296,2.869-0.975c2.486-4.332,4.695-8.817,7.057-13.215
|
||||
c2.238-4.169,4.543-8.3,6.752-12.316c-12.719-24.203-25.389-48.319-38.451-73.172c-0.822,1.482-1.358,2.381-1.836,3.309
|
||||
c-1.96,3.825-3.854,7.688-5.862,11.484c-2.438,4.628-4.954,9.218-7.459,13.818c-2.228,4.083-4.456,8.157-6.722,12.221
|
||||
c-2.381,4.274-4.858,8.501-7.201,12.804c-2.381,4.361-4.418,8.932-7.028,13.148c-2.611,4.208-2.917,7.526-0.249,11.762
|
||||
C259.336,174.171,259.967,175.462,260.656,176.715z"/>
|
||||
<path d="M272.991,331.341c10.949,8.501,29.424,10.643,42.047,1.157c10.566-7.938,16.734-22.453,13.721-32.016
|
||||
c-22.807,0-45.632,0-68.41,0C257.127,310.045,263.008,323.595,272.991,331.341z"/>
|
||||
<path d="M322.248,413.836c-1.281-2.447-2.811-3.356-6.119-2.515c-5.699,1.444-11.676,2.133-17.566,2.381
|
||||
c-10.175,0.431-20.388,0.479-30.486-2.696c-2.62,6.034-5.125,11.8-7.688,17.69c22.96,8.894,45.729,8.894,68.889,0.899
|
||||
c-0.049-0.794,0.105-1.492-0.145-1.999C326.886,422.987,324.638,418.379,322.248,413.836z"/>
|
||||
<path d="M541.498,355.343c10.613-15.654,15.863-33.345,15.586-52.556c-0.43-30.237-12.9-55.721-36.088-73.708
|
||||
c-12.527-9.715-25.887-16.065-39.914-18.972c0.469-0.794,0.928-1.597,1.377-2.4c2.295-4.15,4.514-8.338,6.74-12.527
|
||||
c1.914-3.605,3.836-7.21,5.795-10.796c1.482-2.716,3.014-5.403,4.543-8.09c2.295-4.036,4.59-8.081,6.76-12.183
|
||||
c4.189-7.908,3.031-18.59-2.744-25.398c-2.781-3.28-5.785-5.25-7.773-6.56l-0.871-0.583l-4.465-3.213
|
||||
c-3.883-2.812-7.908-5.709-12.184-8.491c-7.707-5.011-14.793-9.343-21.668-13.244c-4.17-2.362-8.387-4.236-12.105-5.891
|
||||
l-3.08-1.377c-1.988-0.909-3.969-1.846-5.957-2.773c-5.633-2.658-11.455-5.402-17.795-7.707c-7.422-2.697-14.861-5.001-22.07-7.22
|
||||
c-3.672-1.138-7.354-2.276-11.008-3.462c-2.236-0.727-5.66-1.683-9.609-1.683c-5.375,0-15.367,1.855-21.832,14.248
|
||||
c-1.338,2.562-2.658,5.125-3.977,7.698L311.625,30.59L294.708,0l-16.639,30.743l-36.873,68.124
|
||||
c-1.884-3.232-3.749-6.474-5.575-9.735c-4.523-8.07-12.125-12.699-20.865-12.699c-2.305,0-4.657,0.334-7,1.004
|
||||
c-4.208,1.195-9.113,2.601-14.038,4.293l-5.747,1.941c-6.866,2.305-13.961,4.686-21.057,7.641
|
||||
c-12.393,5.154-23.543,9.916-34.616,15.902c-9.333,5.049-17.968,10.815-26.316,16.39l-5.106,3.404
|
||||
c-3.796,2.515-7.172,5.25-10.146,7.669c-1.176,0.947-2.343,1.903-3.519,2.821l-12.852,10.002l7.832,14.287l26.479,48.291
|
||||
c-14.86,2.993-28.745,9.763-41.463,20.225c-21.994,18.102-33.938,42.773-34.53,71.355c-0.526,25.293,8.186,48.195,25.178,66.249
|
||||
c14.248,15.128,31.049,24.538,50.107,28.086c-2.936,5.288-5.872,10.575-8.798,15.863c-1.3,2.362-2.562,4.733-3.834,7.115
|
||||
c-1.625,3.05-3.251,6.11-4.963,9.112c-1.214,2.133-2.524,4.218-3.834,6.293c-1.281,2.046-2.563,4.102-3.796,6.187
|
||||
c-5.891,10.012-1.568,21.649,6.015,27.119c7.851,5.671,15.73,11.303,23.677,16.858c12.451,8.702,25.408,15.864,38.508,21.286
|
||||
l4.676,1.941c7.468,3.117,15.195,6.331,23.227,9.123c7.631,2.648,15.3,4.915,22.711,7.104c3.137,0.928,6.264,1.855,9.391,2.812
|
||||
l9.955,4.657c3.892,32.751,35.324,58.283,73.526,58.283c38.508,0,70.112-25.943,73.592-59.058l10.49-3.51l4.715-1.683
|
||||
l10.107-3.118c2.018-0.593,4.035-1.214,6.062-1.778c4.973-1.367,10.117-2.821,15.396-4.743
|
||||
c7.889-2.878,16.352-6.368,26.641-10.949c6.588-2.936,12.938-6.206,18.877-9.696c8.883-5.23,17.566-10.662,25.789-16.142
|
||||
c5.184-3.452,9.707-7.172,14.076-10.776l1.463-1.205c8.492-6.962,9.18-19.153,4.936-26.909c-2.229-4.073-4.562-8.09-6.895-12.097
|
||||
l-2.42-4.159l-3.271-5.651c-3.107-5.374-6.225-10.748-9.295-16.142c-1.156-2.037-2.303-4.073-3.441-6.12
|
||||
c6.961-1.301,13.637-3.404,19.957-6.292C517.552,382.251,531.093,370.69,541.498,355.343z M463.82,378.465
|
||||
c-4.809,0-9.734-0.411-14.764-1.167c3.461,6.254,6.396,11.552,9.332,16.84c3.232,5.823,6.436,11.656,9.727,17.441
|
||||
c4.168,7.325,8.404,14.612,12.621,21.908c3.051,5.278,6.168,10.519,9.096,15.864c0.41,0.746,0.268,2.496-0.287,2.955
|
||||
c-4.562,3.748-9.094,7.573-14,10.844c-8.148,5.422-16.457,10.604-24.891,15.567c-5.471,3.223-11.16,6.12-16.965,8.702
|
||||
c-8.357,3.729-16.811,7.296-25.408,10.433c-6.617,2.409-13.512,4.035-20.281,6.024c-4.82,1.415-9.629,2.83-14.85,4.37
|
||||
c-2.736-4.753-5.49-9.371-8.072-14.066c-2.477-4.504-4.732-9.123-7.172-13.646c-4.34-8.033-8.807-16.008-13.109-24.069
|
||||
c-1.598-2.993-2.133-3.997-3.576-3.997c-0.871,0-2.076,0.363-4.045,0.87c-8.148,2.104-16.324,3.873-24.309,5.661
|
||||
c22.223,7.659,38.221,28.735,38.221,53.607c0,31.326-25.35,56.725-56.609,56.725c-31.27,0-56.61-25.398-56.61-56.725
|
||||
c0-24.566,15.606-45.422,37.409-53.312c-7.516-2.065-15.472-4.341-23.572-6.54c-0.918-0.249-1.721-0.584-2.448-0.584
|
||||
c-1.301,0-2.362,0.546-3.366,2.592c-4.581,9.267-9.744,18.217-14.697,27.301c-3.911,7.182-7.86,14.325-11.791,21.497
|
||||
c-0.804,1.463-1.645,2.897-2.812,4.972c-10.49-3.203-21.076-6.11-31.422-9.696c-9.094-3.155-17.949-6.99-26.852-10.671
|
||||
c-12.345-5.106-23.925-11.638-34.865-19.288c-7.86-5.498-15.664-11.083-23.438-16.696c-0.478-0.344-0.947-1.529-0.717-1.912
|
||||
c2.515-4.274,5.288-8.396,7.746-12.699c3.098-5.422,5.909-10.997,8.931-16.467c5.919-10.729,11.896-21.42,17.834-32.14
|
||||
c1.979-3.576,3.892-7.2,6.264-11.58c-4.848,0.736-9.562,1.109-14.143,1.109c-20.952,0-39.082-7.755-54.085-23.687
|
||||
c-13.78-14.63-20.406-32.607-19.986-52.737c0.478-23.074,9.811-42.38,27.559-56.992c13.952-11.484,29.663-17.643,47.354-17.643
|
||||
c4.523,0,9.17,0.401,13.952,1.224c-14.028-25.589-27.75-50.615-41.692-76.06c4.112-3.204,8.1-6.723,12.479-9.63
|
||||
c9.85-6.521,19.594-13.311,29.959-18.915c10.585-5.718,21.745-10.433,32.866-15.07c8.367-3.481,17.06-6.197,25.646-9.142
|
||||
c4.303-1.472,8.683-2.744,13.053-3.987c0.641-0.182,1.233-0.277,1.788-0.277c1.721,0,3.05,0.908,4.179,2.926
|
||||
c5.393,9.62,11.092,19.067,16.629,28.611c2.018,3.481,3.901,7.048,6.11,11.054c17.853-32.981,35.41-65.426,53.206-98.312
|
||||
c18.322,33.134,36.348,65.732,54.65,98.819c2.467-4.485,4.828-8.597,7.018-12.804c4.553-8.74,8.98-17.538,13.531-26.268
|
||||
c1.463-2.812,2.773-3.968,4.867-3.968c1.014,0,2.219,0.268,3.711,0.755c10.814,3.5,21.773,6.588,32.445,10.461
|
||||
c7.65,2.773,14.938,6.531,22.367,9.916c4.59,2.085,9.285,4.007,13.654,6.483c7.029,3.988,13.914,8.243,20.684,12.651
|
||||
c5.471,3.557,10.682,7.487,15.998,11.265c1.77,1.252,3.777,2.314,5.145,3.92c0.756,0.889,0.977,3.031,0.432,4.074
|
||||
c-3.576,6.751-7.498,13.32-11.18,20.024c-4.236,7.717-8.252,15.558-12.508,23.266c-2.246,4.064-4.895,7.898-7.182,11.943
|
||||
c-3.309,5.862-6.445,11.819-10.012,18.389c4.973-0.947,9.803-1.406,14.498-1.406c17.174,0,32.502,6.13,46.254,16.802
|
||||
c18.951,14.707,28.352,35.065,28.688,58.866c0.209,14.803-3.74,28.927-12.299,41.559c-8.309,12.26-19.039,21.602-32.379,27.693
|
||||
C483.902,376.6,474.101,378.465,463.82,378.465z"/>
|
||||
<path d="M261.746,512.598c0,18.102,14.669,32.818,32.704,32.818c18.034,0,32.704-14.726,32.704-32.818
|
||||
c0-18.092-14.67-32.818-32.704-32.818C276.415,479.779,261.746,494.506,261.746,512.598z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 11 KiB |
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,3 @@
|
||||
[env]
|
||||
_.path = ["/opt/homebrew/lib"]
|
||||
DYLD_LIBRARY_PATH = "/opt/homebrew/lib"
|
||||
|
||||
[tools]
|
||||
python = "3.12"
|
||||
hk = "latest"
|
||||
|
||||
@@ -22,7 +22,6 @@ classifiers = [
|
||||
dependencies = [
|
||||
"feedparser>=6.0.0",
|
||||
"Pillow>=10.0.0",
|
||||
"pyright>=1.1.408",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -30,9 +29,6 @@ mic = [
|
||||
"sounddevice>=0.4.0",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
figment = [
|
||||
"cairosvg>=2.7.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
|
||||
236
tests/fixtures/__init__.py
vendored
236
tests/fixtures/__init__.py
vendored
@@ -1,236 +0,0 @@
|
||||
"""
|
||||
Pytest fixtures for mocking external dependencies (network, filesystem).
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_feed_response():
|
||||
"""Mock RSS feed response data."""
|
||||
return b"""<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Test Feed</title>
|
||||
<link>https://example.com</link>
|
||||
<item>
|
||||
<title>Test Headline One</title>
|
||||
<pubDate>Sat, 15 Mar 2025 12:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Test Headline Two</title>
|
||||
<pubDate>Sat, 15 Mar 2025 11:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Sports: Team Wins Championship</title>
|
||||
<pubDate>Sat, 15 Mar 2025 10:00:00 GMT</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gutenberg_response():
|
||||
"""Mock Project Gutenberg text response."""
|
||||
return """Project Gutenberg's Collection, by Various
|
||||
|
||||
*** START OF SOME TEXT ***
|
||||
This is a test poem with multiple lines
|
||||
that should be parsed as stanzas.
|
||||
|
||||
Another stanza here with different content
|
||||
and more lines to test the parsing logic.
|
||||
|
||||
Yet another stanza for variety
|
||||
in the test data.
|
||||
|
||||
*** END OF SOME TEXT ***"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gutenberg_empty():
|
||||
"""Mock Gutenberg response with no valid stanzas."""
|
||||
return """Project Gutenberg's Collection
|
||||
|
||||
*** START OF TEXT ***
|
||||
THIS IS ALL CAPS AND SHOULD BE SKIPPED
|
||||
|
||||
I.
|
||||
|
||||
*** END OF TEXT ***"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ntfy_message():
|
||||
"""Mock ntfy.sh SSE message."""
|
||||
return json.dumps(
|
||||
{
|
||||
"id": "test123",
|
||||
"event": "message",
|
||||
"title": "Test Title",
|
||||
"message": "Test message body",
|
||||
"time": 1234567890,
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ntfy_keepalive():
|
||||
"""Mock ntfy.sh keepalive message."""
|
||||
return b'data: {"event":"keepalive"}\n\n'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_google_translate_response():
|
||||
"""Mock Google Translate API response."""
|
||||
return json.dumps(
|
||||
[
|
||||
[["Translated text", "Original text", None, 0.8], None, "en"],
|
||||
None,
|
||||
None,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_feedparser():
|
||||
"""Create a mock feedparser.parse function."""
|
||||
|
||||
def _mock(data):
|
||||
mock_result = MagicMock()
|
||||
mock_result.bozo = False
|
||||
mock_result.entries = [
|
||||
{
|
||||
"title": "Test Headline",
|
||||
"published_parsed": (2025, 3, 15, 12, 0, 0, 0, 0, 0),
|
||||
},
|
||||
{
|
||||
"title": "Another Headline",
|
||||
"updated_parsed": (2025, 3, 15, 11, 0, 0, 0, 0, 0),
|
||||
},
|
||||
]
|
||||
return mock_result
|
||||
|
||||
return _mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_urllib_open(mock_feed_response):
|
||||
"""Create a mock urllib.request.urlopen that returns feed data."""
|
||||
|
||||
def _mock(url):
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = mock_feed_response
|
||||
return mock_response
|
||||
|
||||
return _mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_items():
|
||||
"""Sample items as returned by fetch module (title, source, timestamp)."""
|
||||
return [
|
||||
("Headline One", "Test Source", "12:00"),
|
||||
("Headline Two", "Another Source", "11:30"),
|
||||
("Headline Three", "Third Source", "10:45"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config():
|
||||
"""Sample config for testing."""
|
||||
from engine.config import Config
|
||||
|
||||
return Config(
|
||||
headline_limit=100,
|
||||
feed_timeout=10,
|
||||
mic_threshold_db=50,
|
||||
mode="news",
|
||||
firehose=False,
|
||||
ntfy_topic="https://ntfy.sh/test/json",
|
||||
ntfy_reconnect_delay=5,
|
||||
message_display_secs=30,
|
||||
font_dir="fonts",
|
||||
font_path="",
|
||||
font_index=0,
|
||||
font_picker=False,
|
||||
font_sz=60,
|
||||
render_h=8,
|
||||
ssaa=4,
|
||||
scroll_dur=5.625,
|
||||
frame_dt=0.05,
|
||||
firehose_h=12,
|
||||
grad_speed=0.08,
|
||||
glitch_glyphs="░▒▓█▌▐",
|
||||
kata_glyphs="ハミヒーウ",
|
||||
script_fonts={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def poetry_config():
|
||||
"""Sample config for poetry mode."""
|
||||
from engine.config import Config
|
||||
|
||||
return Config(
|
||||
headline_limit=100,
|
||||
feed_timeout=10,
|
||||
mic_threshold_db=50,
|
||||
mode="poetry",
|
||||
firehose=False,
|
||||
ntfy_topic="https://ntfy.sh/test/json",
|
||||
ntfy_reconnect_delay=5,
|
||||
message_display_secs=30,
|
||||
font_dir="fonts",
|
||||
font_path="",
|
||||
font_index=0,
|
||||
font_picker=False,
|
||||
font_sz=60,
|
||||
render_h=8,
|
||||
ssaa=4,
|
||||
scroll_dur=5.625,
|
||||
frame_dt=0.05,
|
||||
firehose_h=12,
|
||||
grad_speed=0.08,
|
||||
glitch_glyphs="░▒▓█▌▐",
|
||||
kata_glyphs="ハミヒーウ",
|
||||
script_fonts={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def firehose_config():
|
||||
"""Sample config with firehose enabled."""
|
||||
from engine.config import Config
|
||||
|
||||
return Config(
|
||||
headline_limit=100,
|
||||
feed_timeout=10,
|
||||
mic_threshold_db=50,
|
||||
mode="news",
|
||||
firehose=True,
|
||||
ntfy_topic="https://ntfy.sh/test/json",
|
||||
ntfy_reconnect_delay=5,
|
||||
message_display_secs=30,
|
||||
font_dir="fonts",
|
||||
font_path="",
|
||||
font_index=0,
|
||||
font_picker=False,
|
||||
font_sz=60,
|
||||
render_h=8,
|
||||
ssaa=4,
|
||||
scroll_dur=5.625,
|
||||
frame_dt=0.05,
|
||||
firehose_h=12,
|
||||
grad_speed=0.08,
|
||||
glitch_glyphs="░▒▓█▌▐",
|
||||
kata_glyphs="ハミヒーウ",
|
||||
script_fonts={},
|
||||
)
|
||||
3
tests/fixtures/test.svg
vendored
3
tests/fixtures/test.svg
vendored
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
||||
<rect x="10" y="10" width="80" height="80" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 155 B |
@@ -7,8 +7,6 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from engine import config
|
||||
|
||||
|
||||
@@ -162,140 +160,3 @@ class TestSetFontSelection:
|
||||
config.set_font_selection(font_path=None, font_index=None)
|
||||
assert original_path == config.FONT_PATH
|
||||
assert original_index == config.FONT_INDEX
|
||||
|
||||
|
||||
class TestConfigDataclass:
|
||||
"""Tests for Config dataclass."""
|
||||
|
||||
def test_config_has_required_fields(self):
|
||||
"""Config has all required fields."""
|
||||
c = config.Config()
|
||||
assert hasattr(c, "headline_limit")
|
||||
assert hasattr(c, "feed_timeout")
|
||||
assert hasattr(c, "mic_threshold_db")
|
||||
assert hasattr(c, "mode")
|
||||
assert hasattr(c, "firehose")
|
||||
assert hasattr(c, "ntfy_topic")
|
||||
assert hasattr(c, "ntfy_reconnect_delay")
|
||||
assert hasattr(c, "message_display_secs")
|
||||
assert hasattr(c, "font_dir")
|
||||
assert hasattr(c, "font_path")
|
||||
assert hasattr(c, "font_index")
|
||||
assert hasattr(c, "font_picker")
|
||||
assert hasattr(c, "font_sz")
|
||||
assert hasattr(c, "render_h")
|
||||
assert hasattr(c, "ssaa")
|
||||
assert hasattr(c, "scroll_dur")
|
||||
assert hasattr(c, "frame_dt")
|
||||
assert hasattr(c, "firehose_h")
|
||||
assert hasattr(c, "grad_speed")
|
||||
assert hasattr(c, "glitch_glyphs")
|
||||
assert hasattr(c, "kata_glyphs")
|
||||
assert hasattr(c, "script_fonts")
|
||||
|
||||
def test_config_defaults(self):
|
||||
"""Config has sensible defaults."""
|
||||
c = config.Config()
|
||||
assert c.headline_limit == 1000
|
||||
assert c.feed_timeout == 10
|
||||
assert c.mic_threshold_db == 50
|
||||
assert c.mode == "news"
|
||||
assert c.firehose is False
|
||||
assert c.ntfy_reconnect_delay == 5
|
||||
assert c.message_display_secs == 30
|
||||
|
||||
def test_config_is_immutable(self):
|
||||
"""Config is frozen (immutable)."""
|
||||
c = config.Config()
|
||||
with pytest.raises(AttributeError):
|
||||
c.headline_limit = 500 # type: ignore
|
||||
|
||||
def test_config_custom_values(self):
|
||||
"""Config accepts custom values."""
|
||||
c = config.Config(
|
||||
headline_limit=500,
|
||||
mode="poetry",
|
||||
firehose=True,
|
||||
ntfy_topic="https://ntfy.sh/test",
|
||||
)
|
||||
assert c.headline_limit == 500
|
||||
assert c.mode == "poetry"
|
||||
assert c.firehose is True
|
||||
assert c.ntfy_topic == "https://ntfy.sh/test"
|
||||
|
||||
|
||||
class TestConfigFromArgs:
|
||||
"""Tests for Config.from_args method."""
|
||||
|
||||
def test_from_args_defaults(self):
|
||||
"""from_args creates config with defaults from empty argv."""
|
||||
c = config.Config.from_args(["prog"])
|
||||
assert c.mode == "news"
|
||||
assert c.firehose is False
|
||||
assert c.font_picker is True
|
||||
|
||||
def test_from_args_poetry_mode(self):
|
||||
"""from_args detects --poetry flag."""
|
||||
c = config.Config.from_args(["prog", "--poetry"])
|
||||
assert c.mode == "poetry"
|
||||
|
||||
def test_from_args_poetry_short_flag(self):
|
||||
"""from_args detects -p short flag."""
|
||||
c = config.Config.from_args(["prog", "-p"])
|
||||
assert c.mode == "poetry"
|
||||
|
||||
def test_from_args_firehose(self):
|
||||
"""from_args detects --firehose flag."""
|
||||
c = config.Config.from_args(["prog", "--firehose"])
|
||||
assert c.firehose is True
|
||||
|
||||
def test_from_args_no_font_picker(self):
|
||||
"""from_args detects --no-font-picker flag."""
|
||||
c = config.Config.from_args(["prog", "--no-font-picker"])
|
||||
assert c.font_picker is False
|
||||
|
||||
def test_from_args_font_index(self):
|
||||
"""from_args parses --font-index."""
|
||||
c = config.Config.from_args(["prog", "--font-index", "3"])
|
||||
assert c.font_index == 3
|
||||
|
||||
|
||||
class TestGetSetConfig:
|
||||
"""Tests for get_config and set_config functions."""
|
||||
|
||||
def test_get_config_returns_config(self):
|
||||
"""get_config returns a Config instance."""
|
||||
c = config.get_config()
|
||||
assert isinstance(c, config.Config)
|
||||
|
||||
def test_set_config_allows_injection(self):
|
||||
"""set_config allows injecting a custom config."""
|
||||
custom = config.Config(mode="poetry", headline_limit=100)
|
||||
config.set_config(custom)
|
||||
assert config.get_config().mode == "poetry"
|
||||
assert config.get_config().headline_limit == 100
|
||||
|
||||
def test_set_config_then_get_config(self):
|
||||
"""set_config followed by get_config returns the set config."""
|
||||
original = config.get_config()
|
||||
test_config = config.Config(headline_limit=42)
|
||||
config.set_config(test_config)
|
||||
result = config.get_config()
|
||||
assert result.headline_limit == 42
|
||||
config.set_config(original)
|
||||
|
||||
|
||||
class TestPlatformFontPaths:
|
||||
"""Tests for platform font path detection."""
|
||||
|
||||
def test_get_platform_font_paths_returns_dict(self):
|
||||
"""_get_platform_font_paths returns a dictionary."""
|
||||
fonts = config._get_platform_font_paths()
|
||||
assert isinstance(fonts, dict)
|
||||
|
||||
def test_platform_font_paths_common_languages(self):
|
||||
"""Common language font mappings exist."""
|
||||
fonts = config._get_platform_font_paths()
|
||||
common = {"ja", "zh-cn", "ko", "ru", "ar", "hi"}
|
||||
found = set(fonts.keys()) & common
|
||||
assert len(found) > 0
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
"""
|
||||
Tests for engine.controller module.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from engine import config
|
||||
from engine.controller import StreamController
|
||||
|
||||
|
||||
class TestStreamController:
|
||||
"""Tests for StreamController class."""
|
||||
|
||||
def test_init_default_config(self):
|
||||
"""StreamController initializes with default config."""
|
||||
controller = StreamController()
|
||||
assert controller.config is not None
|
||||
assert isinstance(controller.config, config.Config)
|
||||
|
||||
def test_init_custom_config(self):
|
||||
"""StreamController accepts custom config."""
|
||||
custom_config = config.Config(headline_limit=500)
|
||||
controller = StreamController(config=custom_config)
|
||||
assert controller.config.headline_limit == 500
|
||||
|
||||
def test_init_sources_none_by_default(self):
|
||||
"""Sources are None until initialized."""
|
||||
controller = StreamController()
|
||||
assert controller.mic is None
|
||||
assert controller.ntfy is None
|
||||
|
||||
@patch("engine.controller.MicMonitor")
|
||||
@patch("engine.controller.NtfyPoller")
|
||||
def test_initialize_sources(self, mock_ntfy, mock_mic):
|
||||
"""initialize_sources creates mic and ntfy instances."""
|
||||
mock_mic_instance = MagicMock()
|
||||
mock_mic_instance.available = True
|
||||
mock_mic_instance.start.return_value = True
|
||||
mock_mic.return_value = mock_mic_instance
|
||||
|
||||
mock_ntfy_instance = MagicMock()
|
||||
mock_ntfy_instance.start.return_value = True
|
||||
mock_ntfy.return_value = mock_ntfy_instance
|
||||
|
||||
controller = StreamController()
|
||||
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||
|
||||
assert mic_ok is True
|
||||
assert ntfy_ok is True
|
||||
assert controller.mic is not None
|
||||
assert controller.ntfy is not None
|
||||
|
||||
@patch("engine.controller.MicMonitor")
|
||||
@patch("engine.controller.NtfyPoller")
|
||||
def test_initialize_sources_mic_unavailable(self, mock_ntfy, mock_mic):
|
||||
"""initialize_sources handles unavailable mic."""
|
||||
mock_mic_instance = MagicMock()
|
||||
mock_mic_instance.available = False
|
||||
mock_mic.return_value = mock_mic_instance
|
||||
|
||||
mock_ntfy_instance = MagicMock()
|
||||
mock_ntfy_instance.start.return_value = True
|
||||
mock_ntfy.return_value = mock_ntfy_instance
|
||||
|
||||
controller = StreamController()
|
||||
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||
|
||||
assert mic_ok is False
|
||||
assert ntfy_ok is True
|
||||
|
||||
|
||||
class TestStreamControllerCleanup:
|
||||
"""Tests for StreamController cleanup."""
|
||||
|
||||
@patch("engine.controller.MicMonitor")
|
||||
def test_cleanup_stops_mic(self, mock_mic):
|
||||
"""cleanup stops the microphone if running."""
|
||||
mock_mic_instance = MagicMock()
|
||||
mock_mic.return_value = mock_mic_instance
|
||||
|
||||
controller = StreamController()
|
||||
controller.mic = mock_mic_instance
|
||||
controller.cleanup()
|
||||
|
||||
mock_mic_instance.stop.assert_called_once()
|
||||
|
||||
|
||||
class TestStreamControllerWarmup:
|
||||
"""Tests for StreamController topic warmup."""
|
||||
|
||||
def test_warmup_topics_idempotent(self):
|
||||
"""warmup_topics can be called multiple times."""
|
||||
StreamController._topics_warmed = False
|
||||
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
StreamController.warmup_topics()
|
||||
StreamController.warmup_topics()
|
||||
|
||||
assert mock_urlopen.call_count >= 3
|
||||
|
||||
def test_warmup_topics_sets_flag(self):
|
||||
"""warmup_topics sets the warmed flag."""
|
||||
StreamController._topics_warmed = False
|
||||
|
||||
with patch("urllib.request.urlopen"):
|
||||
StreamController.warmup_topics()
|
||||
|
||||
assert StreamController._topics_warmed is True
|
||||
|
||||
def test_warmup_topics_skips_after_first(self):
|
||||
"""warmup_topics skips after first call."""
|
||||
StreamController._topics_warmed = True
|
||||
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
StreamController.warmup_topics()
|
||||
|
||||
mock_urlopen.assert_not_called()
|
||||
@@ -1,79 +0,0 @@
|
||||
"""
|
||||
Tests for engine.display module.
|
||||
"""
|
||||
|
||||
from engine.display import NullDisplay, TerminalDisplay
|
||||
|
||||
|
||||
class TestDisplayProtocol:
|
||||
"""Test that display backends satisfy the Display protocol."""
|
||||
|
||||
def test_terminal_display_is_display(self):
|
||||
"""TerminalDisplay satisfies Display protocol."""
|
||||
display = TerminalDisplay()
|
||||
assert hasattr(display, "init")
|
||||
assert hasattr(display, "show")
|
||||
assert hasattr(display, "clear")
|
||||
assert hasattr(display, "cleanup")
|
||||
|
||||
def test_null_display_is_display(self):
|
||||
"""NullDisplay satisfies Display protocol."""
|
||||
display = NullDisplay()
|
||||
assert hasattr(display, "init")
|
||||
assert hasattr(display, "show")
|
||||
assert hasattr(display, "clear")
|
||||
assert hasattr(display, "cleanup")
|
||||
|
||||
|
||||
class TestTerminalDisplay:
|
||||
"""Tests for TerminalDisplay class."""
|
||||
|
||||
def test_init_sets_dimensions(self):
|
||||
"""init stores terminal dimensions."""
|
||||
display = TerminalDisplay()
|
||||
display.init(80, 24)
|
||||
assert display.width == 80
|
||||
assert display.height == 24
|
||||
|
||||
def test_show_returns_none(self):
|
||||
"""show returns None after writing to stdout."""
|
||||
display = TerminalDisplay()
|
||||
display.width = 80
|
||||
display.height = 24
|
||||
display.show(["line1", "line2"])
|
||||
|
||||
def test_clear_does_not_error(self):
|
||||
"""clear works without error."""
|
||||
display = TerminalDisplay()
|
||||
display.clear()
|
||||
|
||||
def test_cleanup_does_not_error(self):
|
||||
"""cleanup works without error."""
|
||||
display = TerminalDisplay()
|
||||
display.cleanup()
|
||||
|
||||
|
||||
class TestNullDisplay:
|
||||
"""Tests for NullDisplay class."""
|
||||
|
||||
def test_init_stores_dimensions(self):
|
||||
"""init stores dimensions."""
|
||||
display = NullDisplay()
|
||||
display.init(100, 50)
|
||||
assert display.width == 100
|
||||
assert display.height == 50
|
||||
|
||||
def test_show_does_nothing(self):
|
||||
"""show discards buffer without error."""
|
||||
display = NullDisplay()
|
||||
display.show(["line1", "line2", "line3"])
|
||||
|
||||
def test_clear_does_nothing(self):
|
||||
"""clear does nothing."""
|
||||
display = NullDisplay()
|
||||
display.clear()
|
||||
|
||||
def test_cleanup_does_nothing(self):
|
||||
"""cleanup does nothing."""
|
||||
display = NullDisplay()
|
||||
display.cleanup()
|
||||
@@ -1,427 +0,0 @@
|
||||
"""
|
||||
Tests for engine.effects module.
|
||||
"""
|
||||
|
||||
from engine.effects import EffectChain, EffectConfig, EffectContext, EffectRegistry
|
||||
|
||||
|
||||
class MockEffect:
|
||||
name = "mock"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def __init__(self):
|
||||
self.processed = False
|
||||
self.last_ctx = None
|
||||
|
||||
def process(self, buf, ctx):
|
||||
self.processed = True
|
||||
self.last_ctx = ctx
|
||||
return buf + ["processed"]
|
||||
|
||||
def configure(self, config):
|
||||
self.config = config
|
||||
|
||||
|
||||
class TestEffectConfig:
|
||||
def test_defaults(self):
|
||||
cfg = EffectConfig()
|
||||
assert cfg.enabled is True
|
||||
assert cfg.intensity == 1.0
|
||||
assert cfg.params == {}
|
||||
|
||||
def test_custom_values(self):
|
||||
cfg = EffectConfig(enabled=False, intensity=0.5, params={"key": "value"})
|
||||
assert cfg.enabled is False
|
||||
assert cfg.intensity == 0.5
|
||||
assert cfg.params == {"key": "value"}
|
||||
|
||||
|
||||
class TestEffectContext:
|
||||
def test_defaults(self):
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
assert ctx.terminal_width == 80
|
||||
assert ctx.terminal_height == 24
|
||||
assert ctx.ticker_height == 20
|
||||
assert ctx.items == []
|
||||
|
||||
def test_with_items(self):
|
||||
items = [("Title", "Source", "12:00")]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
items=items,
|
||||
)
|
||||
assert ctx.items == items
|
||||
|
||||
|
||||
class TestEffectRegistry:
|
||||
def test_init_empty(self):
|
||||
registry = EffectRegistry()
|
||||
assert len(registry.list_all()) == 0
|
||||
|
||||
def test_register(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
registry.register(effect)
|
||||
assert "mock" in registry.list_all()
|
||||
|
||||
def test_get(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
registry.register(effect)
|
||||
retrieved = registry.get("mock")
|
||||
assert retrieved is effect
|
||||
|
||||
def test_get_nonexistent(self):
|
||||
registry = EffectRegistry()
|
||||
assert registry.get("nonexistent") is None
|
||||
|
||||
def test_enable(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.config.enabled = False
|
||||
registry.register(effect)
|
||||
registry.enable("mock")
|
||||
assert effect.config.enabled is True
|
||||
|
||||
def test_disable(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.config.enabled = True
|
||||
registry.register(effect)
|
||||
registry.disable("mock")
|
||||
assert effect.config.enabled is False
|
||||
|
||||
def test_list_enabled(self):
|
||||
registry = EffectRegistry()
|
||||
|
||||
class EnabledEffect:
|
||||
name = "enabled_effect"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
class DisabledEffect:
|
||||
name = "disabled_effect"
|
||||
config = EffectConfig(enabled=False, intensity=1.0)
|
||||
|
||||
registry.register(EnabledEffect())
|
||||
registry.register(DisabledEffect())
|
||||
enabled = registry.list_enabled()
|
||||
assert len(enabled) == 1
|
||||
assert enabled[0].name == "enabled_effect"
|
||||
|
||||
def test_configure(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
registry.register(effect)
|
||||
new_config = EffectConfig(enabled=False, intensity=0.3)
|
||||
registry.configure("mock", new_config)
|
||||
assert effect.config.enabled is False
|
||||
assert effect.config.intensity == 0.3
|
||||
|
||||
def test_is_enabled(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.config.enabled = True
|
||||
registry.register(effect)
|
||||
assert registry.is_enabled("mock") is True
|
||||
assert registry.is_enabled("nonexistent") is False
|
||||
|
||||
|
||||
class TestEffectChain:
|
||||
def test_init(self):
|
||||
registry = EffectRegistry()
|
||||
chain = EffectChain(registry)
|
||||
assert chain.get_order() == []
|
||||
|
||||
def test_set_order(self):
|
||||
registry = EffectRegistry()
|
||||
effect1 = MockEffect()
|
||||
effect1.name = "effect1"
|
||||
effect2 = MockEffect()
|
||||
effect2.name = "effect2"
|
||||
registry.register(effect1)
|
||||
registry.register(effect2)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["effect1", "effect2"])
|
||||
assert chain.get_order() == ["effect1", "effect2"]
|
||||
|
||||
def test_add_effect(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "test_effect"
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
chain.add_effect("test_effect")
|
||||
assert "test_effect" in chain.get_order()
|
||||
|
||||
def test_add_effect_invalid(self):
|
||||
registry = EffectRegistry()
|
||||
chain = EffectChain(registry)
|
||||
result = chain.add_effect("nonexistent")
|
||||
assert result is False
|
||||
|
||||
def test_remove_effect(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "test_effect"
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["test_effect"])
|
||||
chain.remove_effect("test_effect")
|
||||
assert "test_effect" not in chain.get_order()
|
||||
|
||||
def test_reorder(self):
|
||||
registry = EffectRegistry()
|
||||
effect1 = MockEffect()
|
||||
effect1.name = "effect1"
|
||||
effect2 = MockEffect()
|
||||
effect2.name = "effect2"
|
||||
effect3 = MockEffect()
|
||||
effect3.name = "effect3"
|
||||
registry.register(effect1)
|
||||
registry.register(effect2)
|
||||
registry.register(effect3)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["effect1", "effect2", "effect3"])
|
||||
result = chain.reorder(["effect3", "effect1", "effect2"])
|
||||
assert result is True
|
||||
assert chain.get_order() == ["effect3", "effect1", "effect2"]
|
||||
|
||||
def test_reorder_invalid(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "effect1"
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
result = chain.reorder(["effect1", "nonexistent"])
|
||||
assert result is False
|
||||
|
||||
def test_process_empty_chain(self):
|
||||
registry = EffectRegistry()
|
||||
chain = EffectChain(registry)
|
||||
buf = ["line1", "line2"]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
result = chain.process(buf, ctx)
|
||||
assert result == buf
|
||||
|
||||
def test_process_with_effects(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "test_effect"
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["test_effect"])
|
||||
buf = ["line1", "line2"]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
result = chain.process(buf, ctx)
|
||||
assert result == ["line1", "line2", "processed"]
|
||||
assert effect.processed is True
|
||||
assert effect.last_ctx is ctx
|
||||
|
||||
def test_process_disabled_effect(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "test_effect"
|
||||
effect.config.enabled = False
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["test_effect"])
|
||||
buf = ["line1"]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
result = chain.process(buf, ctx)
|
||||
assert result == ["line1"]
|
||||
assert effect.processed is False
|
||||
|
||||
|
||||
class TestEffectsExports:
|
||||
def test_all_exports_are_importable(self):
|
||||
"""Verify all exports in __all__ can actually be imported."""
|
||||
import engine.effects as effects_module
|
||||
|
||||
for name in effects_module.__all__:
|
||||
getattr(effects_module, name)
|
||||
|
||||
|
||||
class TestPerformanceMonitor:
|
||||
def test_empty_stats(self):
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
stats = monitor.get_stats()
|
||||
assert "error" in stats
|
||||
|
||||
def test_record_and_retrieve(self):
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
monitor.start_frame(1)
|
||||
monitor.record_effect("test_effect", 1.5, 100, 150)
|
||||
monitor.end_frame(1, 2.0)
|
||||
|
||||
stats = monitor.get_stats()
|
||||
assert "error" not in stats
|
||||
assert stats["frame_count"] == 1
|
||||
assert "test_effect" in stats["effects"]
|
||||
|
||||
def test_multiple_frames(self):
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor(max_frames=3)
|
||||
for i in range(5):
|
||||
monitor.start_frame(i)
|
||||
monitor.record_effect("effect1", 1.0, 100, 100)
|
||||
monitor.record_effect("effect2", 0.5, 100, 100)
|
||||
monitor.end_frame(i, 1.5)
|
||||
|
||||
stats = monitor.get_stats()
|
||||
assert stats["frame_count"] == 3
|
||||
assert "effect1" in stats["effects"]
|
||||
assert "effect2" in stats["effects"]
|
||||
|
||||
def test_reset(self):
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
monitor.start_frame(1)
|
||||
monitor.record_effect("test", 1.0, 100, 100)
|
||||
monitor.end_frame(1, 1.0)
|
||||
|
||||
monitor.reset()
|
||||
stats = monitor.get_stats()
|
||||
assert "error" in stats
|
||||
|
||||
|
||||
class TestEffectPipelinePerformance:
|
||||
def test_pipeline_stays_within_frame_budget(self):
|
||||
"""Verify effect pipeline completes within frame budget (33ms for 30fps)."""
|
||||
from engine.effects import (
|
||||
EffectChain,
|
||||
EffectConfig,
|
||||
EffectContext,
|
||||
EffectRegistry,
|
||||
)
|
||||
|
||||
class DummyEffect:
|
||||
name = "dummy"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf, ctx):
|
||||
return [line * 2 for line in buf]
|
||||
|
||||
registry = EffectRegistry()
|
||||
registry.register(DummyEffect())
|
||||
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor(max_frames=10)
|
||||
chain = EffectChain(registry, monitor)
|
||||
chain.set_order(["dummy"])
|
||||
|
||||
buf = ["x" * 80] * 20
|
||||
|
||||
for i in range(10):
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=i,
|
||||
has_message=False,
|
||||
)
|
||||
chain.process(buf, ctx)
|
||||
|
||||
stats = monitor.get_stats()
|
||||
assert "error" not in stats
|
||||
assert stats["pipeline"]["max_ms"] < 33.0
|
||||
|
||||
def test_individual_effects_performance(self):
|
||||
"""Verify individual effects don't exceed 10ms per frame."""
|
||||
from engine.effects import (
|
||||
EffectChain,
|
||||
EffectConfig,
|
||||
EffectContext,
|
||||
EffectRegistry,
|
||||
)
|
||||
|
||||
class SlowEffect:
|
||||
name = "slow"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf, ctx):
|
||||
result = []
|
||||
for line in buf:
|
||||
result.append(line)
|
||||
result.append(line + line)
|
||||
return result
|
||||
|
||||
registry = EffectRegistry()
|
||||
registry.register(SlowEffect())
|
||||
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor(max_frames=5)
|
||||
chain = EffectChain(registry, monitor)
|
||||
chain.set_order(["slow"])
|
||||
|
||||
buf = ["x" * 80] * 10
|
||||
|
||||
for i in range(5):
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=i,
|
||||
has_message=False,
|
||||
)
|
||||
chain.process(buf, ctx)
|
||||
|
||||
stats = monitor.get_stats()
|
||||
assert "error" not in stats
|
||||
assert stats["effects"]["slow"]["max_ms"] < 10.0
|
||||
@@ -1,117 +0,0 @@
|
||||
"""
|
||||
Tests for engine.effects.controller module.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from engine.effects.controller import (
|
||||
handle_effects_command,
|
||||
set_effect_chain_ref,
|
||||
)
|
||||
|
||||
|
||||
class TestHandleEffectsCommand:
|
||||
"""Tests for handle_effects_command function."""
|
||||
|
||||
def test_list_effects(self):
|
||||
"""list command returns formatted effects list."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.config.enabled = True
|
||||
mock_plugin.config.intensity = 0.5
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||
mock_chain.return_value.get_order.return_value = ["noise"]
|
||||
|
||||
result = handle_effects_command("/effects list")
|
||||
|
||||
assert "noise: ON" in result
|
||||
assert "intensity=0.5" in result
|
||||
|
||||
def test_enable_effect(self):
|
||||
"""enable command calls registry.enable."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_registry.return_value.get.return_value = mock_plugin
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
result = handle_effects_command("/effects noise on")
|
||||
|
||||
assert "Enabled: noise" in result
|
||||
mock_registry.return_value.enable.assert_called_once_with("noise")
|
||||
|
||||
def test_disable_effect(self):
|
||||
"""disable command calls registry.disable."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_registry.return_value.get.return_value = mock_plugin
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
result = handle_effects_command("/effects noise off")
|
||||
|
||||
assert "Disabled: noise" in result
|
||||
mock_registry.return_value.disable.assert_called_once_with("noise")
|
||||
|
||||
def test_set_intensity(self):
|
||||
"""intensity command sets plugin intensity."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.config.intensity = 0.5
|
||||
mock_registry.return_value.get.return_value = mock_plugin
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
result = handle_effects_command("/effects noise intensity 0.8")
|
||||
|
||||
assert "intensity to 0.8" in result
|
||||
assert mock_plugin.config.intensity == 0.8
|
||||
|
||||
def test_invalid_intensity_range(self):
|
||||
"""intensity outside 0.0-1.0 returns error."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_registry.return_value.get.return_value = mock_plugin
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
result = handle_effects_command("/effects noise intensity 1.5")
|
||||
|
||||
assert "between 0.0 and 1.0" in result
|
||||
|
||||
def test_reorder_pipeline(self):
|
||||
"""reorder command calls chain.reorder."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_registry.return_value.list_all.return_value = {}
|
||||
|
||||
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||
mock_chain_instance = MagicMock()
|
||||
mock_chain_instance.reorder.return_value = True
|
||||
mock_chain.return_value = mock_chain_instance
|
||||
|
||||
result = handle_effects_command("/effects reorder noise,fade")
|
||||
|
||||
assert "Reordered pipeline" in result
|
||||
mock_chain_instance.reorder.assert_called_once_with(["noise", "fade"])
|
||||
|
||||
def test_unknown_command(self):
|
||||
"""unknown command returns error."""
|
||||
result = handle_effects_command("/unknown")
|
||||
assert "Unknown command" in result
|
||||
|
||||
def test_non_effects_command(self):
|
||||
"""non-effects command returns error."""
|
||||
result = handle_effects_command("not a command")
|
||||
assert "Unknown command" in result
|
||||
|
||||
|
||||
class TestSetEffectChainRef:
|
||||
"""Tests for set_effect_chain_ref function."""
|
||||
|
||||
def test_sets_global_ref(self):
|
||||
"""set_effect_chain_ref updates global reference."""
|
||||
mock_chain = MagicMock()
|
||||
set_effect_chain_ref(mock_chain)
|
||||
|
||||
from engine.effects.controller import _get_effect_chain
|
||||
|
||||
result = _get_effect_chain()
|
||||
assert result == mock_chain
|
||||
@@ -1,69 +0,0 @@
|
||||
"""
|
||||
Tests for engine.emitters module.
|
||||
"""
|
||||
|
||||
from engine.emitters import EventEmitter, Startable, Stoppable
|
||||
|
||||
|
||||
class TestEventEmitterProtocol:
|
||||
"""Tests for EventEmitter protocol."""
|
||||
|
||||
def test_protocol_exists(self):
|
||||
"""EventEmitter protocol is defined."""
|
||||
assert EventEmitter is not None
|
||||
|
||||
def test_protocol_has_subscribe_method(self):
|
||||
"""EventEmitter has subscribe method in protocol."""
|
||||
assert hasattr(EventEmitter, "subscribe")
|
||||
|
||||
def test_protocol_has_unsubscribe_method(self):
|
||||
"""EventEmitter has unsubscribe method in protocol."""
|
||||
assert hasattr(EventEmitter, "unsubscribe")
|
||||
|
||||
|
||||
class TestStartableProtocol:
|
||||
"""Tests for Startable protocol."""
|
||||
|
||||
def test_protocol_exists(self):
|
||||
"""Startable protocol is defined."""
|
||||
assert Startable is not None
|
||||
|
||||
def test_protocol_has_start_method(self):
|
||||
"""Startable has start method in protocol."""
|
||||
assert hasattr(Startable, "start")
|
||||
|
||||
|
||||
class TestStoppableProtocol:
|
||||
"""Tests for Stoppable protocol."""
|
||||
|
||||
def test_protocol_exists(self):
|
||||
"""Stoppable protocol is defined."""
|
||||
assert Stoppable is not None
|
||||
|
||||
def test_protocol_has_stop_method(self):
|
||||
"""Stoppable has stop method in protocol."""
|
||||
assert hasattr(Stoppable, "stop")
|
||||
|
||||
|
||||
class TestProtocolCompliance:
|
||||
"""Tests that existing classes comply with protocols."""
|
||||
|
||||
def test_ntfy_poller_complies_with_protocol(self):
|
||||
"""NtfyPoller implements EventEmitter protocol."""
|
||||
from engine.ntfy import NtfyPoller
|
||||
|
||||
poller = NtfyPoller("http://example.com/topic")
|
||||
assert hasattr(poller, "subscribe")
|
||||
assert hasattr(poller, "unsubscribe")
|
||||
assert callable(poller.subscribe)
|
||||
assert callable(poller.unsubscribe)
|
||||
|
||||
def test_mic_monitor_complies_with_protocol(self):
|
||||
"""MicMonitor implements EventEmitter and Startable protocols."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
assert hasattr(monitor, "subscribe")
|
||||
assert hasattr(monitor, "unsubscribe")
|
||||
assert hasattr(monitor, "start")
|
||||
assert hasattr(monitor, "stop")
|
||||
@@ -1,202 +0,0 @@
|
||||
"""
|
||||
Tests for engine.eventbus module.
|
||||
"""
|
||||
|
||||
|
||||
from engine.eventbus import EventBus, get_event_bus, set_event_bus
|
||||
from engine.events import EventType, NtfyMessageEvent
|
||||
|
||||
|
||||
class TestEventBusInit:
|
||||
"""Tests for EventBus initialization."""
|
||||
|
||||
def test_init_creates_empty_subscribers(self):
|
||||
"""EventBus starts with no subscribers."""
|
||||
bus = EventBus()
|
||||
assert bus.subscriber_count() == 0
|
||||
|
||||
|
||||
class TestEventBusSubscribe:
|
||||
"""Tests for EventBus.subscribe method."""
|
||||
|
||||
def test_subscribe_adds_callback(self):
|
||||
"""subscribe() adds a callback for an event type."""
|
||||
bus = EventBus()
|
||||
def callback(e):
|
||||
return None
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||
|
||||
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1
|
||||
|
||||
def test_subscribe_multiple_callbacks_same_event(self):
|
||||
"""Multiple callbacks can be subscribed to the same event type."""
|
||||
bus = EventBus()
|
||||
def cb1(e):
|
||||
return None
|
||||
def cb2(e):
|
||||
return None
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, cb1)
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, cb2)
|
||||
|
||||
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 2
|
||||
|
||||
def test_subscribe_different_event_types(self):
|
||||
"""Callbacks can be subscribed to different event types."""
|
||||
bus = EventBus()
|
||||
def cb1(e):
|
||||
return None
|
||||
def cb2(e):
|
||||
return None
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, cb1)
|
||||
bus.subscribe(EventType.MIC_LEVEL, cb2)
|
||||
|
||||
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1
|
||||
assert bus.subscriber_count(EventType.MIC_LEVEL) == 1
|
||||
|
||||
|
||||
class TestEventBusUnsubscribe:
|
||||
"""Tests for EventBus.unsubscribe method."""
|
||||
|
||||
def test_unsubscribe_removes_callback(self):
|
||||
"""unsubscribe() removes a callback."""
|
||||
bus = EventBus()
|
||||
def callback(e):
|
||||
return None
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||
bus.unsubscribe(EventType.NTFY_MESSAGE, callback)
|
||||
|
||||
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 0
|
||||
|
||||
def test_unsubscribe_nonexistent_callback_no_error(self):
|
||||
"""unsubscribe() handles non-existent callback gracefully."""
|
||||
bus = EventBus()
|
||||
def callback(e):
|
||||
return None
|
||||
|
||||
bus.unsubscribe(EventType.NTFY_MESSAGE, callback)
|
||||
|
||||
|
||||
class TestEventBusPublish:
|
||||
"""Tests for EventBus.publish method."""
|
||||
|
||||
def test_publish_calls_subscriber(self):
|
||||
"""publish() calls the subscriber callback."""
|
||||
bus = EventBus()
|
||||
received = []
|
||||
|
||||
def callback(event):
|
||||
received.append(event)
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||
event = NtfyMessageEvent(title="Test", body="Body")
|
||||
bus.publish(EventType.NTFY_MESSAGE, event)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].title == "Test"
|
||||
|
||||
def test_publish_multiple_subscribers(self):
|
||||
"""publish() calls all subscribers for an event type."""
|
||||
bus = EventBus()
|
||||
received1 = []
|
||||
received2 = []
|
||||
|
||||
def callback1(event):
|
||||
received1.append(event)
|
||||
|
||||
def callback2(event):
|
||||
received2.append(event)
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, callback1)
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, callback2)
|
||||
event = NtfyMessageEvent(title="Test", body="Body")
|
||||
bus.publish(EventType.NTFY_MESSAGE, event)
|
||||
|
||||
assert len(received1) == 1
|
||||
assert len(received2) == 1
|
||||
|
||||
def test_publish_different_event_types(self):
|
||||
"""publish() only calls subscribers for the specific event type."""
|
||||
bus = EventBus()
|
||||
ntfy_received = []
|
||||
mic_received = []
|
||||
|
||||
def ntfy_callback(event):
|
||||
ntfy_received.append(event)
|
||||
|
||||
def mic_callback(event):
|
||||
mic_received.append(event)
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, ntfy_callback)
|
||||
bus.subscribe(EventType.MIC_LEVEL, mic_callback)
|
||||
event = NtfyMessageEvent(title="Test", body="Body")
|
||||
bus.publish(EventType.NTFY_MESSAGE, event)
|
||||
|
||||
assert len(ntfy_received) == 1
|
||||
assert len(mic_received) == 0
|
||||
|
||||
|
||||
class TestEventBusClear:
|
||||
"""Tests for EventBus.clear method."""
|
||||
|
||||
def test_clear_removes_all_subscribers(self):
|
||||
"""clear() removes all subscribers."""
|
||||
bus = EventBus()
|
||||
def cb1(e):
|
||||
return None
|
||||
def cb2(e):
|
||||
return None
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, cb1)
|
||||
bus.subscribe(EventType.MIC_LEVEL, cb2)
|
||||
bus.clear()
|
||||
|
||||
assert bus.subscriber_count() == 0
|
||||
|
||||
|
||||
class TestEventBusThreadSafety:
|
||||
"""Tests for EventBus thread safety."""
|
||||
|
||||
def test_concurrent_subscribe_unsubscribe(self):
|
||||
"""subscribe and unsubscribe can be called concurrently."""
|
||||
import threading
|
||||
|
||||
bus = EventBus()
|
||||
callbacks = [lambda e: None for _ in range(10)]
|
||||
|
||||
def subscribe():
|
||||
for cb in callbacks:
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, cb)
|
||||
|
||||
def unsubscribe():
|
||||
for cb in callbacks:
|
||||
bus.unsubscribe(EventType.NTFY_MESSAGE, cb)
|
||||
|
||||
t1 = threading.Thread(target=subscribe)
|
||||
t2 = threading.Thread(target=unsubscribe)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
|
||||
class TestGlobalEventBus:
|
||||
"""Tests for global event bus functions."""
|
||||
|
||||
def test_get_event_bus_returns_singleton(self):
|
||||
"""get_event_bus() returns the same instance."""
|
||||
bus1 = get_event_bus()
|
||||
bus2 = get_event_bus()
|
||||
assert bus1 is bus2
|
||||
|
||||
def test_set_event_bus_replaces_singleton(self):
|
||||
"""set_event_bus() replaces the global event bus."""
|
||||
new_bus = EventBus()
|
||||
set_event_bus(new_bus)
|
||||
try:
|
||||
assert get_event_bus() is new_bus
|
||||
finally:
|
||||
set_event_bus(None)
|
||||
@@ -1,112 +0,0 @@
|
||||
"""
|
||||
Tests for engine.events module.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from engine import events
|
||||
|
||||
|
||||
class TestEventType:
|
||||
"""Tests for EventType enum."""
|
||||
|
||||
def test_event_types_exist(self):
|
||||
"""All expected event types exist."""
|
||||
assert hasattr(events.EventType, "NEW_HEADLINE")
|
||||
assert hasattr(events.EventType, "FRAME_TICK")
|
||||
assert hasattr(events.EventType, "MIC_LEVEL")
|
||||
assert hasattr(events.EventType, "NTFY_MESSAGE")
|
||||
assert hasattr(events.EventType, "STREAM_START")
|
||||
assert hasattr(events.EventType, "STREAM_END")
|
||||
|
||||
|
||||
class TestHeadlineEvent:
|
||||
"""Tests for HeadlineEvent dataclass."""
|
||||
|
||||
def test_create_headline_event(self):
|
||||
"""HeadlineEvent can be created with required fields."""
|
||||
e = events.HeadlineEvent(
|
||||
title="Test Headline",
|
||||
source="Test Source",
|
||||
timestamp="12:00",
|
||||
)
|
||||
assert e.title == "Test Headline"
|
||||
assert e.source == "Test Source"
|
||||
assert e.timestamp == "12:00"
|
||||
|
||||
def test_headline_event_optional_language(self):
|
||||
"""HeadlineEvent supports optional language field."""
|
||||
e = events.HeadlineEvent(
|
||||
title="Test",
|
||||
source="Test",
|
||||
timestamp="12:00",
|
||||
language="ja",
|
||||
)
|
||||
assert e.language == "ja"
|
||||
|
||||
|
||||
class TestFrameTickEvent:
|
||||
"""Tests for FrameTickEvent dataclass."""
|
||||
|
||||
def test_create_frame_tick_event(self):
|
||||
"""FrameTickEvent can be created."""
|
||||
now = datetime.now()
|
||||
e = events.FrameTickEvent(
|
||||
frame_number=100,
|
||||
timestamp=now,
|
||||
delta_seconds=0.05,
|
||||
)
|
||||
assert e.frame_number == 100
|
||||
assert e.timestamp == now
|
||||
assert e.delta_seconds == 0.05
|
||||
|
||||
|
||||
class TestMicLevelEvent:
|
||||
"""Tests for MicLevelEvent dataclass."""
|
||||
|
||||
def test_create_mic_level_event(self):
|
||||
"""MicLevelEvent can be created."""
|
||||
now = datetime.now()
|
||||
e = events.MicLevelEvent(
|
||||
db_level=60.0,
|
||||
excess_above_threshold=10.0,
|
||||
timestamp=now,
|
||||
)
|
||||
assert e.db_level == 60.0
|
||||
assert e.excess_above_threshold == 10.0
|
||||
|
||||
|
||||
class TestNtfyMessageEvent:
|
||||
"""Tests for NtfyMessageEvent dataclass."""
|
||||
|
||||
def test_create_ntfy_message_event(self):
|
||||
"""NtfyMessageEvent can be created with required fields."""
|
||||
e = events.NtfyMessageEvent(
|
||||
title="Test Title",
|
||||
body="Test Body",
|
||||
)
|
||||
assert e.title == "Test Title"
|
||||
assert e.body == "Test Body"
|
||||
assert e.message_id is None
|
||||
|
||||
def test_ntfy_message_event_with_id(self):
|
||||
"""NtfyMessageEvent supports optional message_id."""
|
||||
e = events.NtfyMessageEvent(
|
||||
title="Test",
|
||||
body="Test",
|
||||
message_id="abc123",
|
||||
)
|
||||
assert e.message_id == "abc123"
|
||||
|
||||
|
||||
class TestStreamEvent:
|
||||
"""Tests for StreamEvent dataclass."""
|
||||
|
||||
def test_create_stream_event(self):
|
||||
"""StreamEvent can be created."""
|
||||
e = events.StreamEvent(
|
||||
event_type=events.EventType.STREAM_START,
|
||||
headline_count=100,
|
||||
)
|
||||
assert e.event_type == events.EventType.STREAM_START
|
||||
assert e.headline_count == 100
|
||||
@@ -1,35 +0,0 @@
|
||||
import re
|
||||
|
||||
from engine.fetch_code import fetch_code
|
||||
|
||||
|
||||
def test_return_shape():
|
||||
items, line_count, ignored = fetch_code()
|
||||
assert isinstance(items, list)
|
||||
assert line_count == len(items)
|
||||
assert ignored == 0
|
||||
|
||||
|
||||
def test_items_are_tuples():
|
||||
items, _, _ = fetch_code()
|
||||
assert items, "expected at least one code line"
|
||||
for item in items:
|
||||
assert isinstance(item, tuple) and len(item) == 3
|
||||
text, src, ts = item
|
||||
assert isinstance(text, str)
|
||||
assert isinstance(src, str)
|
||||
assert isinstance(ts, str)
|
||||
|
||||
|
||||
def test_blank_and_comment_lines_excluded():
|
||||
items, _, _ = fetch_code()
|
||||
for text, _, _ in items:
|
||||
assert text.strip(), "blank line should have been filtered"
|
||||
assert not text.strip().startswith("#"), "comment line should have been filtered"
|
||||
|
||||
|
||||
def test_module_path_format():
|
||||
items, _, _ = fetch_code()
|
||||
pattern = re.compile(r"^engine\.\w+$")
|
||||
for _, _, ts in items:
|
||||
assert pattern.match(ts), f"unexpected module path: {ts!r}"
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Tests for the FigmentEffect plugin."""
|
||||
|
||||
import os
|
||||
from enum import Enum
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
|
||||
|
||||
from effects_plugins.figment import FigmentEffect, FigmentPhase, FigmentState
|
||||
from engine.effects.types import EffectConfig, EffectContext
|
||||
|
||||
FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg")
|
||||
FIGMENTS_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
|
||||
|
||||
|
||||
class TestFigmentPhase:
|
||||
def test_is_enum(self):
|
||||
assert issubclass(FigmentPhase, Enum)
|
||||
|
||||
def test_has_all_phases(self):
|
||||
assert hasattr(FigmentPhase, "REVEAL")
|
||||
assert hasattr(FigmentPhase, "HOLD")
|
||||
assert hasattr(FigmentPhase, "DISSOLVE")
|
||||
|
||||
|
||||
class TestFigmentState:
|
||||
def test_creation(self):
|
||||
state = FigmentState(
|
||||
phase=FigmentPhase.REVEAL,
|
||||
progress=0.5,
|
||||
rows=["█▀▄", " █ "],
|
||||
gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
|
||||
center_row=5,
|
||||
center_col=10,
|
||||
)
|
||||
assert state.phase == FigmentPhase.REVEAL
|
||||
assert state.progress == 0.5
|
||||
assert len(state.rows) == 2
|
||||
|
||||
|
||||
class TestFigmentEffectInit:
|
||||
def test_name(self):
|
||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||
assert effect.name == "figment"
|
||||
|
||||
def test_default_config(self):
|
||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||
assert effect.config.enabled is False
|
||||
assert effect.config.intensity == 1.0
|
||||
assert effect.config.params["interval_secs"] == 60
|
||||
assert effect.config.params["display_secs"] == 4.5
|
||||
|
||||
def test_process_is_noop(self):
|
||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||
buf = ["line1", "line2"]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
)
|
||||
result = effect.process(buf, ctx)
|
||||
assert result == buf
|
||||
assert result is buf
|
||||
|
||||
def test_configure(self):
|
||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||
new_cfg = EffectConfig(enabled=True, intensity=0.5)
|
||||
effect.configure(new_cfg)
|
||||
assert effect.config.enabled is True
|
||||
assert effect.config.intensity == 0.5
|
||||
|
||||
|
||||
class TestFigmentStateMachine:
|
||||
def test_idle_initially(self):
|
||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||
effect.config.enabled = True
|
||||
state = effect.get_figment_state(0, 80, 24)
|
||||
# Timer hasn't fired yet, should be None (idle)
|
||||
assert state is None
|
||||
|
||||
def test_trigger_starts_reveal(self):
|
||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||
effect.config.enabled = True
|
||||
effect.trigger(80, 24)
|
||||
state = effect.get_figment_state(1, 80, 24)
|
||||
assert state is not None
|
||||
assert state.phase == FigmentPhase.REVEAL
|
||||
|
||||
def test_full_cycle(self):
|
||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||
effect.config.enabled = True
|
||||
effect.config.params["display_secs"] = 0.15 # 3 phases x 0.05s
|
||||
|
||||
effect.trigger(40, 20)
|
||||
|
||||
# Advance through reveal (30 frames at 0.05s = 1.5s, but we shrunk it)
|
||||
# With display_secs=0.15, each phase is 0.05s = 1 frame
|
||||
state = effect.get_figment_state(1, 40, 20)
|
||||
assert state is not None
|
||||
assert state.phase == FigmentPhase.REVEAL
|
||||
|
||||
# Advance enough frames to get through all phases
|
||||
for frame in range(2, 100):
|
||||
state = effect.get_figment_state(frame, 40, 20)
|
||||
if state is None:
|
||||
break
|
||||
|
||||
# Should have completed the full cycle back to idle
|
||||
assert state is None
|
||||
|
||||
def test_timer_fires_at_interval(self):
|
||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||
effect.config.enabled = True
|
||||
effect.config.params["interval_secs"] = 0.1 # 2 frames at 20fps
|
||||
|
||||
# Frame 0: idle
|
||||
state = effect.get_figment_state(0, 40, 20)
|
||||
assert state is None
|
||||
|
||||
# Advance past interval (0.1s = 2 frames)
|
||||
state = effect.get_figment_state(1, 40, 20)
|
||||
state = effect.get_figment_state(2, 40, 20)
|
||||
state = effect.get_figment_state(3, 40, 20)
|
||||
# Timer should have fired by now
|
||||
assert state is not None
|
||||
|
||||
|
||||
class TestFigmentEdgeCases:
|
||||
def test_empty_figment_dir(self, tmp_path):
|
||||
effect = FigmentEffect(figment_dir=str(tmp_path))
|
||||
effect.config.enabled = True
|
||||
effect.trigger(40, 20)
|
||||
state = effect.get_figment_state(1, 40, 20)
|
||||
# No SVGs available — should stay idle
|
||||
assert state is None
|
||||
|
||||
def test_missing_figment_dir(self):
|
||||
effect = FigmentEffect(figment_dir="/nonexistent/path")
|
||||
effect.config.enabled = True
|
||||
effect.trigger(40, 20)
|
||||
state = effect.get_figment_state(1, 40, 20)
|
||||
assert state is None
|
||||
|
||||
def test_disabled_ignores_trigger(self):
|
||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||
effect.config.enabled = False
|
||||
effect.trigger(80, 24)
|
||||
state = effect.get_figment_state(1, 80, 24)
|
||||
assert state is None
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Tests for render_figment_overlay in engine.layers."""
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
|
||||
|
||||
from effects_plugins.figment import FigmentPhase, FigmentState
|
||||
from engine.layers import render_figment_overlay
|
||||
|
||||
|
||||
def _make_state(phase=FigmentPhase.HOLD, progress=0.5):
|
||||
return FigmentState(
|
||||
phase=phase,
|
||||
progress=progress,
|
||||
rows=["█▀▄ █", " ▄█▀ ", "█ █"],
|
||||
gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
|
||||
center_row=10,
|
||||
center_col=37,
|
||||
)
|
||||
|
||||
|
||||
class TestRenderFigmentOverlay:
|
||||
def test_returns_list_of_strings(self):
|
||||
state = _make_state()
|
||||
result = render_figment_overlay(state, 80, 24)
|
||||
assert isinstance(result, list)
|
||||
assert all(isinstance(s, str) for s in result)
|
||||
|
||||
def test_contains_ansi_positioning(self):
|
||||
state = _make_state()
|
||||
result = render_figment_overlay(state, 80, 24)
|
||||
# Should contain cursor positioning escape codes
|
||||
assert any("\033[" in s for s in result)
|
||||
|
||||
def test_reveal_phase_partial(self):
|
||||
state = _make_state(phase=FigmentPhase.REVEAL, progress=0.0)
|
||||
result = render_figment_overlay(state, 80, 24)
|
||||
# At progress 0.0, very few cells should be visible
|
||||
# Result should still be a valid list
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_hold_phase_full(self):
|
||||
state = _make_state(phase=FigmentPhase.HOLD, progress=0.5)
|
||||
result = render_figment_overlay(state, 80, 24)
|
||||
# During hold, content should be present
|
||||
assert len(result) > 0
|
||||
|
||||
def test_dissolve_phase(self):
|
||||
state = _make_state(phase=FigmentPhase.DISSOLVE, progress=0.9)
|
||||
result = render_figment_overlay(state, 80, 24)
|
||||
# At high dissolve progress, most cells are gone
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_empty_rows(self):
|
||||
state = FigmentState(
|
||||
phase=FigmentPhase.HOLD,
|
||||
progress=0.5,
|
||||
rows=[],
|
||||
gradient=[46] * 12,
|
||||
center_row=0,
|
||||
center_col=0,
|
||||
)
|
||||
result = render_figment_overlay(state, 80, 24)
|
||||
assert result == []
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Tests for engine.figment_render module."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
|
||||
|
||||
from engine.figment_render import rasterize_svg
|
||||
|
||||
FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg")
|
||||
|
||||
|
||||
class TestRasterizeSvg:
|
||||
def test_returns_list_of_strings(self):
|
||||
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
|
||||
assert isinstance(rows, list)
|
||||
assert all(isinstance(r, str) for r in rows)
|
||||
|
||||
def test_output_height_matches_terminal_height(self):
|
||||
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
|
||||
assert len(rows) == 20
|
||||
|
||||
def test_output_contains_block_characters(self):
|
||||
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
|
||||
all_chars = "".join(rows)
|
||||
block_chars = {"█", "▀", "▄"}
|
||||
assert any(ch in all_chars for ch in block_chars)
|
||||
|
||||
def test_different_sizes_produce_different_output(self):
|
||||
rows_small = rasterize_svg(FIXTURE_SVG, 20, 10)
|
||||
rows_large = rasterize_svg(FIXTURE_SVG, 80, 40)
|
||||
assert len(rows_small) == 10
|
||||
assert len(rows_large) == 40
|
||||
|
||||
def test_nonexistent_file_raises(self):
|
||||
import pytest
|
||||
|
||||
with pytest.raises((FileNotFoundError, OSError)):
|
||||
rasterize_svg("/nonexistent/file.svg", 40, 20)
|
||||
|
||||
|
||||
class TestRasterizeCache:
|
||||
def test_cache_returns_same_result(self):
|
||||
rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
|
||||
rows2 = rasterize_svg(FIXTURE_SVG, 40, 20)
|
||||
assert rows1 == rows2
|
||||
|
||||
def test_cache_invalidated_by_size_change(self):
|
||||
rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
|
||||
rows2 = rasterize_svg(FIXTURE_SVG, 60, 30)
|
||||
assert len(rows1) != len(rows2)
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Tests for engine.figment_trigger module."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from engine.figment_trigger import FigmentAction, FigmentCommand
|
||||
|
||||
|
||||
class TestFigmentAction:
|
||||
def test_is_enum(self):
|
||||
assert issubclass(FigmentAction, Enum)
|
||||
|
||||
def test_has_trigger(self):
|
||||
assert FigmentAction.TRIGGER.value == "trigger"
|
||||
|
||||
def test_has_set_intensity(self):
|
||||
assert FigmentAction.SET_INTENSITY.value == "set_intensity"
|
||||
|
||||
def test_has_set_interval(self):
|
||||
assert FigmentAction.SET_INTERVAL.value == "set_interval"
|
||||
|
||||
def test_has_set_color(self):
|
||||
assert FigmentAction.SET_COLOR.value == "set_color"
|
||||
|
||||
def test_has_stop(self):
|
||||
assert FigmentAction.STOP.value == "stop"
|
||||
|
||||
|
||||
class TestFigmentCommand:
|
||||
def test_trigger_command(self):
|
||||
cmd = FigmentCommand(action=FigmentAction.TRIGGER)
|
||||
assert cmd.action == FigmentAction.TRIGGER
|
||||
assert cmd.value is None
|
||||
|
||||
def test_set_intensity_command(self):
|
||||
cmd = FigmentCommand(action=FigmentAction.SET_INTENSITY, value=0.8)
|
||||
assert cmd.value == 0.8
|
||||
|
||||
def test_set_color_command(self):
|
||||
cmd = FigmentCommand(action=FigmentAction.SET_COLOR, value="orange")
|
||||
assert cmd.value == "orange"
|
||||
@@ -1,63 +0,0 @@
|
||||
"""
|
||||
Tests for engine.frame module.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine.frame import FrameTimer, calculate_scroll_step
|
||||
|
||||
|
||||
class TestFrameTimer:
|
||||
"""Tests for FrameTimer class."""
|
||||
|
||||
def test_init_default(self):
|
||||
"""FrameTimer initializes with default values."""
|
||||
timer = FrameTimer()
|
||||
assert timer.target_frame_dt == 0.05
|
||||
assert timer.fps >= 0
|
||||
|
||||
def test_init_custom(self):
|
||||
"""FrameTimer accepts custom frame duration."""
|
||||
timer = FrameTimer(target_frame_dt=0.1)
|
||||
assert timer.target_frame_dt == 0.1
|
||||
|
||||
def test_fps_calculation(self):
|
||||
"""FrameTimer calculates FPS correctly."""
|
||||
timer = FrameTimer()
|
||||
timer._frame_count = 10
|
||||
timer._start_time = time.monotonic() - 1.0
|
||||
assert timer.fps >= 9.0
|
||||
|
||||
def test_reset(self):
|
||||
"""FrameTimer.reset() clears frame count."""
|
||||
timer = FrameTimer()
|
||||
timer._frame_count = 100
|
||||
timer.reset()
|
||||
assert timer._frame_count == 0
|
||||
|
||||
|
||||
class TestCalculateScrollStep:
|
||||
"""Tests for calculate_scroll_step function."""
|
||||
|
||||
def test_basic_calculation(self):
|
||||
"""calculate_scroll_step returns positive value."""
|
||||
result = calculate_scroll_step(5.0, 24)
|
||||
assert result > 0
|
||||
|
||||
def test_with_padding(self):
|
||||
"""calculate_scroll_step respects padding parameter."""
|
||||
without_padding = calculate_scroll_step(5.0, 24, padding=0)
|
||||
with_padding = calculate_scroll_step(5.0, 24, padding=15)
|
||||
assert with_padding < without_padding
|
||||
|
||||
def test_larger_view_slower_scroll(self):
|
||||
"""Larger view height results in slower scroll steps."""
|
||||
small = calculate_scroll_step(5.0, 10)
|
||||
large = calculate_scroll_step(5.0, 50)
|
||||
assert large < small
|
||||
|
||||
def test_longer_duration_slower_scroll(self):
|
||||
"""Longer scroll duration results in slower scroll steps."""
|
||||
fast = calculate_scroll_step(2.0, 24)
|
||||
slow = calculate_scroll_step(10.0, 24)
|
||||
assert slow > fast
|
||||
@@ -1,96 +0,0 @@
|
||||
"""
|
||||
Tests for engine.layers module.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine import layers
|
||||
|
||||
|
||||
class TestRenderMessageOverlay:
|
||||
"""Tests for render_message_overlay function."""
|
||||
|
||||
def test_no_message_returns_empty(self):
|
||||
"""Returns empty list when msg is None."""
|
||||
result, cache = layers.render_message_overlay(None, 80, 24, (None, None))
|
||||
assert result == []
|
||||
assert cache[0] is None
|
||||
|
||||
def test_message_returns_overlay_lines(self):
|
||||
"""Returns non-empty list when message is present."""
|
||||
msg = ("Test Title", "Test Body", time.monotonic())
|
||||
result, cache = layers.render_message_overlay(msg, 80, 24, (None, None))
|
||||
assert len(result) > 0
|
||||
assert cache[0] is not None
|
||||
|
||||
def test_cache_key_changes_with_text(self):
|
||||
"""Cache key changes when message text changes."""
|
||||
msg1 = ("Title1", "Body1", time.monotonic())
|
||||
msg2 = ("Title2", "Body2", time.monotonic())
|
||||
|
||||
_, cache1 = layers.render_message_overlay(msg1, 80, 24, (None, None))
|
||||
_, cache2 = layers.render_message_overlay(msg2, 80, 24, cache1)
|
||||
|
||||
assert cache1[0] != cache2[0]
|
||||
|
||||
def test_cache_reuse_avoids_recomputation(self):
|
||||
"""Cache is returned when same message is passed (interface test)."""
|
||||
msg = ("Same Title", "Same Body", time.monotonic())
|
||||
|
||||
result1, cache1 = layers.render_message_overlay(msg, 80, 24, (None, None))
|
||||
result2, cache2 = layers.render_message_overlay(msg, 80, 24, cache1)
|
||||
|
||||
assert len(result1) > 0
|
||||
assert len(result2) > 0
|
||||
assert cache1[0] == cache2[0]
|
||||
|
||||
|
||||
class TestRenderFirehose:
|
||||
"""Tests for render_firehose function."""
|
||||
|
||||
def test_no_firehose_returns_empty(self):
|
||||
"""Returns empty list when firehose height is 0."""
|
||||
items = [("Headline", "Source", "12:00")]
|
||||
result = layers.render_firehose(items, 80, 0, 24)
|
||||
assert result == []
|
||||
|
||||
def test_firehose_returns_lines(self):
|
||||
"""Returns lines when firehose height > 0."""
|
||||
items = [("Headline", "Source", "12:00")]
|
||||
result = layers.render_firehose(items, 80, 4, 24)
|
||||
assert len(result) == 4
|
||||
|
||||
def test_firehose_includes_ansi_escapes(self):
|
||||
"""Returns lines containing ANSI escape sequences."""
|
||||
items = [("Headline", "Source", "12:00")]
|
||||
result = layers.render_firehose(items, 80, 1, 24)
|
||||
assert "\033[" in result[0]
|
||||
|
||||
|
||||
class TestApplyGlitch:
|
||||
"""Tests for apply_glitch function."""
|
||||
|
||||
def test_empty_buffer_unchanged(self):
|
||||
"""Empty buffer is returned unchanged."""
|
||||
result = layers.apply_glitch([], 0, 0.0, 80)
|
||||
assert result == []
|
||||
|
||||
def test_buffer_length_preserved(self):
|
||||
"""Buffer length is preserved after glitch application."""
|
||||
buf = [f"\033[{i + 1};1Htest\033[K" for i in range(10)]
|
||||
result = layers.apply_glitch(buf, 0, 0.5, 80)
|
||||
assert len(result) == len(buf)
|
||||
|
||||
|
||||
class TestRenderTickerZone:
|
||||
"""Tests for render_ticker_zone function - focusing on interface."""
|
||||
|
||||
def test_returns_list(self):
|
||||
"""Returns a list of strings."""
|
||||
result, cache = layers.render_ticker_zone([], 0, 10, 80, {}, 0.0)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_returns_dict_for_cache(self):
|
||||
"""Returns a dict for the noise cache."""
|
||||
result, cache = layers.render_ticker_zone([], 0, 10, 80, {}, 0.0)
|
||||
assert isinstance(cache, dict)
|
||||
@@ -2,11 +2,8 @@
|
||||
Tests for engine.mic module.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from engine.events import MicLevelEvent
|
||||
|
||||
|
||||
class TestMicMonitorImport:
|
||||
"""Tests for module import behavior."""
|
||||
@@ -84,66 +81,3 @@ class TestMicMonitorStop:
|
||||
monitor = MicMonitor()
|
||||
monitor.stop()
|
||||
assert monitor._stream is None
|
||||
|
||||
|
||||
class TestMicMonitorEventEmission:
|
||||
"""Tests for MicMonitor event emission."""
|
||||
|
||||
def test_subscribe_adds_callback(self):
|
||||
"""subscribe() adds a callback."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
def callback(e):
|
||||
return None
|
||||
|
||||
monitor.subscribe(callback)
|
||||
|
||||
assert callback in monitor._subscribers
|
||||
|
||||
def test_unsubscribe_removes_callback(self):
|
||||
"""unsubscribe() removes a callback."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
def callback(e):
|
||||
return None
|
||||
monitor.subscribe(callback)
|
||||
|
||||
monitor.unsubscribe(callback)
|
||||
|
||||
assert callback not in monitor._subscribers
|
||||
|
||||
def test_emit_calls_subscribers(self):
|
||||
"""_emit() calls all subscribers."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
received = []
|
||||
|
||||
def callback(event):
|
||||
received.append(event)
|
||||
|
||||
monitor.subscribe(callback)
|
||||
event = MicLevelEvent(
|
||||
db_level=60.0, excess_above_threshold=10.0, timestamp=datetime.now()
|
||||
)
|
||||
monitor._emit(event)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].db_level == 60.0
|
||||
|
||||
def test_emit_handles_subscriber_exception(self):
|
||||
"""_emit() handles exceptions in subscribers gracefully."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
|
||||
def bad_callback(event):
|
||||
raise RuntimeError("test")
|
||||
|
||||
monitor.subscribe(bad_callback)
|
||||
event = MicLevelEvent(
|
||||
db_level=60.0, excess_above_threshold=10.0, timestamp=datetime.now()
|
||||
)
|
||||
monitor._emit(event)
|
||||
|
||||
@@ -5,7 +5,6 @@ Tests for engine.ntfy module.
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from engine.events import NtfyMessageEvent
|
||||
from engine.ntfy import NtfyPoller
|
||||
|
||||
|
||||
@@ -69,54 +68,3 @@ class TestNtfyPollerDismiss:
|
||||
poller.dismiss()
|
||||
|
||||
assert poller._message is None
|
||||
|
||||
|
||||
class TestNtfyPollerEventEmission:
|
||||
"""Tests for NtfyPoller event emission."""
|
||||
|
||||
def test_subscribe_adds_callback(self):
|
||||
"""subscribe() adds a callback."""
|
||||
poller = NtfyPoller("http://example.com/topic")
|
||||
def callback(e):
|
||||
return None
|
||||
|
||||
poller.subscribe(callback)
|
||||
|
||||
assert callback in poller._subscribers
|
||||
|
||||
def test_unsubscribe_removes_callback(self):
|
||||
"""unsubscribe() removes a callback."""
|
||||
poller = NtfyPoller("http://example.com/topic")
|
||||
def callback(e):
|
||||
return None
|
||||
poller.subscribe(callback)
|
||||
|
||||
poller.unsubscribe(callback)
|
||||
|
||||
assert callback not in poller._subscribers
|
||||
|
||||
def test_emit_calls_subscribers(self):
|
||||
"""_emit() calls all subscribers."""
|
||||
poller = NtfyPoller("http://example.com/topic")
|
||||
received = []
|
||||
|
||||
def callback(event):
|
||||
received.append(event)
|
||||
|
||||
poller.subscribe(callback)
|
||||
event = NtfyMessageEvent(title="Test", body="Body")
|
||||
poller._emit(event)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].title == "Test"
|
||||
|
||||
def test_emit_handles_subscriber_exception(self):
|
||||
"""_emit() handles exceptions in subscribers gracefully."""
|
||||
poller = NtfyPoller("http://example.com/topic")
|
||||
|
||||
def bad_callback(event):
|
||||
raise RuntimeError("test")
|
||||
|
||||
poller.subscribe(bad_callback)
|
||||
event = NtfyMessageEvent(title="Test", body="Body")
|
||||
poller._emit(event)
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
"""
|
||||
Tests for engine.render module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from engine import config, render
|
||||
|
||||
|
||||
class TestDefaultGradients:
|
||||
"""Tests for default gradient fallback functions."""
|
||||
|
||||
def test_default_green_gradient_length(self):
|
||||
"""_default_green_gradient returns 12 colors."""
|
||||
gradient = render._default_green_gradient()
|
||||
assert len(gradient) == 12
|
||||
|
||||
def test_default_green_gradient_is_list(self):
|
||||
"""_default_green_gradient returns a list."""
|
||||
gradient = render._default_green_gradient()
|
||||
assert isinstance(gradient, list)
|
||||
|
||||
def test_default_green_gradient_all_strings(self):
|
||||
"""_default_green_gradient returns list of ANSI code strings."""
|
||||
gradient = render._default_green_gradient()
|
||||
assert all(isinstance(code, str) for code in gradient)
|
||||
|
||||
def test_default_magenta_gradient_length(self):
|
||||
"""_default_magenta_gradient returns 12 colors."""
|
||||
gradient = render._default_magenta_gradient()
|
||||
assert len(gradient) == 12
|
||||
|
||||
def test_default_magenta_gradient_is_list(self):
|
||||
"""_default_magenta_gradient returns a list."""
|
||||
gradient = render._default_magenta_gradient()
|
||||
assert isinstance(gradient, list)
|
||||
|
||||
def test_default_magenta_gradient_all_strings(self):
|
||||
"""_default_magenta_gradient returns list of ANSI code strings."""
|
||||
gradient = render._default_magenta_gradient()
|
||||
assert all(isinstance(code, str) for code in gradient)
|
||||
|
||||
|
||||
class TestLrGradientUsesActiveTheme:
|
||||
"""Tests for lr_gradient using active theme."""
|
||||
|
||||
def test_lr_gradient_uses_active_theme_when_cols_none(self):
|
||||
"""lr_gradient uses ACTIVE_THEME.main_gradient when cols=None."""
|
||||
# Save original state
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
# Set a theme
|
||||
config.set_active_theme("green")
|
||||
|
||||
# Create simple test data
|
||||
rows = ["text"]
|
||||
|
||||
# Call without cols parameter (cols=None)
|
||||
result = render.lr_gradient(rows, offset=0.0)
|
||||
|
||||
# Should not raise and should return colored output
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
# Should have ANSI codes (no plain "text")
|
||||
assert result[0] != "text"
|
||||
finally:
|
||||
# Restore original state
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_lr_gradient_fallback_when_no_theme(self):
|
||||
"""lr_gradient uses fallback green when ACTIVE_THEME is None."""
|
||||
# Save original state
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
# Clear the theme
|
||||
config.ACTIVE_THEME = None
|
||||
|
||||
# Create simple test data
|
||||
rows = ["text"]
|
||||
|
||||
# Call without cols parameter (should use fallback)
|
||||
result = render.lr_gradient(rows, offset=0.0)
|
||||
|
||||
# Should not raise and should return colored output
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
# Should have ANSI codes (no plain "text")
|
||||
assert result[0] != "text"
|
||||
finally:
|
||||
# Restore original state
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_lr_gradient_explicit_cols_parameter_still_works(self):
|
||||
"""lr_gradient with explicit cols parameter overrides theme."""
|
||||
# Custom gradient
|
||||
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
||||
|
||||
rows = ["xy"]
|
||||
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
||||
|
||||
# Should use the provided cols
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_lr_gradient_respects_cols_parameter_name(self):
|
||||
"""lr_gradient accepts cols as keyword argument."""
|
||||
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
||||
|
||||
rows = ["xy"]
|
||||
# Call with cols as keyword
|
||||
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
||||
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
class TestLrGradientBasicFunctionality:
|
||||
"""Tests to ensure lr_gradient basic functionality still works."""
|
||||
|
||||
def test_lr_gradient_colors_non_space_chars(self):
|
||||
"""lr_gradient colors non-space characters."""
|
||||
rows = ["hello"]
|
||||
|
||||
# Set a theme for the test
|
||||
original_theme = config.ACTIVE_THEME
|
||||
try:
|
||||
config.set_active_theme("green")
|
||||
result = render.lr_gradient(rows, offset=0.0)
|
||||
|
||||
# Result should have ANSI codes
|
||||
assert any("\033[" in r for r in result), "Expected ANSI codes in result"
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_lr_gradient_preserves_spaces(self):
|
||||
"""lr_gradient preserves spaces in output."""
|
||||
rows = ["a b c"]
|
||||
|
||||
original_theme = config.ACTIVE_THEME
|
||||
try:
|
||||
config.set_active_theme("green")
|
||||
result = render.lr_gradient(rows, offset=0.0)
|
||||
|
||||
# Spaces should be preserved (not colored)
|
||||
assert " " in result[0]
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_lr_gradient_empty_rows(self):
|
||||
"""lr_gradient handles empty rows correctly."""
|
||||
rows = [""]
|
||||
|
||||
original_theme = config.ACTIVE_THEME
|
||||
try:
|
||||
config.set_active_theme("green")
|
||||
result = render.lr_gradient(rows, offset=0.0)
|
||||
|
||||
assert result == [""]
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_lr_gradient_multiple_rows(self):
|
||||
"""lr_gradient handles multiple rows."""
|
||||
rows = ["row1", "row2", "row3"]
|
||||
|
||||
original_theme = config.ACTIVE_THEME
|
||||
try:
|
||||
config.set_active_theme("green")
|
||||
result = render.lr_gradient(rows, offset=0.0)
|
||||
|
||||
assert len(result) == 3
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
|
||||
class TestMsgGradient:
|
||||
"""Tests for msg_gradient function (message/ntfy overlay coloring)."""
|
||||
|
||||
def test_msg_gradient_uses_active_theme(self):
|
||||
"""msg_gradient uses ACTIVE_THEME.message_gradient when theme is set."""
|
||||
# Save original state
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
# Set a theme
|
||||
config.set_active_theme("green")
|
||||
|
||||
# Create simple test data
|
||||
rows = ["MESSAGE"]
|
||||
|
||||
# Call msg_gradient
|
||||
result = render.msg_gradient(rows, offset=0.0)
|
||||
|
||||
# Should return colored output using theme's message_gradient
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
# Should have ANSI codes from the message gradient
|
||||
assert result[0] != "MESSAGE"
|
||||
assert "\033[" in result[0]
|
||||
finally:
|
||||
# Restore original state
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_msg_gradient_fallback_when_no_theme(self):
|
||||
"""msg_gradient uses fallback magenta when ACTIVE_THEME is None."""
|
||||
# Save original state
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
# Clear the theme
|
||||
config.ACTIVE_THEME = None
|
||||
|
||||
# Create simple test data
|
||||
rows = ["MESSAGE"]
|
||||
|
||||
# Call msg_gradient
|
||||
result = render.msg_gradient(rows, offset=0.0)
|
||||
|
||||
# Should return colored output using default magenta
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
# Should have ANSI codes
|
||||
assert result[0] != "MESSAGE"
|
||||
assert "\033[" in result[0]
|
||||
finally:
|
||||
# Restore original state
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_msg_gradient_returns_colored_rows(self):
|
||||
"""msg_gradient returns properly colored rows with animation offset."""
|
||||
# Save original state
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
# Set a theme
|
||||
config.set_active_theme("orange")
|
||||
|
||||
rows = ["NTFY", "ALERT"]
|
||||
|
||||
# Call with offset
|
||||
result = render.msg_gradient(rows, offset=0.5)
|
||||
|
||||
# Should return same number of rows
|
||||
assert len(result) == 2
|
||||
# Both should be colored
|
||||
assert all("\033[" in r for r in result)
|
||||
# Should not be the original text
|
||||
assert result != rows
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_msg_gradient_different_themes_produce_different_results(self):
|
||||
"""msg_gradient produces different colors for different themes."""
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
rows = ["TEST"]
|
||||
|
||||
# Get result with green theme
|
||||
config.set_active_theme("green")
|
||||
result_green = render.msg_gradient(rows, offset=0.0)
|
||||
|
||||
# Get result with orange theme
|
||||
config.set_active_theme("orange")
|
||||
result_orange = render.msg_gradient(rows, offset=0.0)
|
||||
|
||||
# Results should be different (different message gradients)
|
||||
assert result_green != result_orange
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_msg_gradient_preserves_spacing(self):
|
||||
"""msg_gradient preserves spaces in rows."""
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
config.set_active_theme("purple")
|
||||
rows = ["M E S S A G E"]
|
||||
|
||||
result = render.msg_gradient(rows, offset=0.0)
|
||||
|
||||
# Spaces should be preserved
|
||||
assert " " in result[0]
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_msg_gradient_empty_rows(self):
|
||||
"""msg_gradient handles empty rows correctly."""
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
config.set_active_theme("green")
|
||||
rows = [""]
|
||||
|
||||
result = render.msg_gradient(rows, offset=0.0)
|
||||
|
||||
# Empty row should stay empty
|
||||
assert result == [""]
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
@@ -1,169 +0,0 @@
|
||||
"""
|
||||
Tests for engine.themes module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from engine import themes
|
||||
|
||||
|
||||
class TestThemeConstruction:
|
||||
"""Tests for Theme class initialization."""
|
||||
|
||||
def test_theme_construction(self):
|
||||
"""Theme stores name and gradients correctly."""
|
||||
main_grad = ["color1", "color2", "color3"]
|
||||
msg_grad = ["msg1", "msg2", "msg3"]
|
||||
theme = themes.Theme("test_theme", main_grad, msg_grad)
|
||||
|
||||
assert theme.name == "test_theme"
|
||||
assert theme.main_gradient == main_grad
|
||||
assert theme.message_gradient == msg_grad
|
||||
|
||||
|
||||
class TestGradientLength:
|
||||
"""Tests for gradient length validation."""
|
||||
|
||||
def test_gradient_length_green(self):
|
||||
"""Green theme has exactly 12 colors in each gradient."""
|
||||
green = themes.THEME_REGISTRY["green"]
|
||||
assert len(green.main_gradient) == 12
|
||||
assert len(green.message_gradient) == 12
|
||||
|
||||
def test_gradient_length_orange(self):
|
||||
"""Orange theme has exactly 12 colors in each gradient."""
|
||||
orange = themes.THEME_REGISTRY["orange"]
|
||||
assert len(orange.main_gradient) == 12
|
||||
assert len(orange.message_gradient) == 12
|
||||
|
||||
def test_gradient_length_purple(self):
|
||||
"""Purple theme has exactly 12 colors in each gradient."""
|
||||
purple = themes.THEME_REGISTRY["purple"]
|
||||
assert len(purple.main_gradient) == 12
|
||||
assert len(purple.message_gradient) == 12
|
||||
|
||||
|
||||
class TestThemeRegistry:
|
||||
"""Tests for THEME_REGISTRY dictionary."""
|
||||
|
||||
def test_theme_registry_has_three_themes(self):
|
||||
"""Registry contains exactly three themes: green, orange, purple."""
|
||||
assert len(themes.THEME_REGISTRY) == 3
|
||||
assert set(themes.THEME_REGISTRY.keys()) == {"green", "orange", "purple"}
|
||||
|
||||
def test_registry_values_are_themes(self):
|
||||
"""All registry values are Theme instances."""
|
||||
for theme_id, theme in themes.THEME_REGISTRY.items():
|
||||
assert isinstance(theme, themes.Theme)
|
||||
assert theme.name == theme_id
|
||||
|
||||
|
||||
class TestGetTheme:
|
||||
"""Tests for get_theme function."""
|
||||
|
||||
def test_get_theme_valid_green(self):
|
||||
"""get_theme('green') returns correct green Theme."""
|
||||
green = themes.get_theme("green")
|
||||
assert isinstance(green, themes.Theme)
|
||||
assert green.name == "green"
|
||||
|
||||
def test_get_theme_valid_orange(self):
|
||||
"""get_theme('orange') returns correct orange Theme."""
|
||||
orange = themes.get_theme("orange")
|
||||
assert isinstance(orange, themes.Theme)
|
||||
assert orange.name == "orange"
|
||||
|
||||
def test_get_theme_valid_purple(self):
|
||||
"""get_theme('purple') returns correct purple Theme."""
|
||||
purple = themes.get_theme("purple")
|
||||
assert isinstance(purple, themes.Theme)
|
||||
assert purple.name == "purple"
|
||||
|
||||
def test_get_theme_invalid(self):
|
||||
"""get_theme with invalid ID raises KeyError."""
|
||||
with pytest.raises(KeyError):
|
||||
themes.get_theme("invalid_theme")
|
||||
|
||||
def test_get_theme_invalid_none(self):
|
||||
"""get_theme with None raises KeyError."""
|
||||
with pytest.raises(KeyError):
|
||||
themes.get_theme(None)
|
||||
|
||||
|
||||
class TestGreenTheme:
|
||||
"""Tests for green theme specific values."""
|
||||
|
||||
def test_green_theme_unchanged(self):
|
||||
"""Green theme maintains original color sequence."""
|
||||
green = themes.get_theme("green")
|
||||
|
||||
# Expected main gradient: 231→195→123→118→82→46→40→34→28→22→22(dim)→235
|
||||
expected_main = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||
# Expected msg gradient: 231→225→219→213→207→201→165→161→125→89→89(dim)→235
|
||||
expected_msg = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||
|
||||
assert green.main_gradient == expected_main
|
||||
assert green.message_gradient == expected_msg
|
||||
|
||||
def test_green_theme_name(self):
|
||||
"""Green theme has correct name."""
|
||||
green = themes.get_theme("green")
|
||||
assert green.name == "green"
|
||||
|
||||
|
||||
class TestOrangeTheme:
|
||||
"""Tests for orange theme specific values."""
|
||||
|
||||
def test_orange_theme_unchanged(self):
|
||||
"""Orange theme maintains original color sequence."""
|
||||
orange = themes.get_theme("orange")
|
||||
|
||||
# Expected main gradient: 231→215→209→208→202→166→130→94→58→94→94(dim)→235
|
||||
expected_main = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||
# Expected msg gradient: 231→195→33→27→21→21→21→18→18→18→18(dim)→235
|
||||
expected_msg = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||
|
||||
assert orange.main_gradient == expected_main
|
||||
assert orange.message_gradient == expected_msg
|
||||
|
||||
def test_orange_theme_name(self):
|
||||
"""Orange theme has correct name."""
|
||||
orange = themes.get_theme("orange")
|
||||
assert orange.name == "orange"
|
||||
|
||||
|
||||
class TestPurpleTheme:
|
||||
"""Tests for purple theme specific values."""
|
||||
|
||||
def test_purple_theme_unchanged(self):
|
||||
"""Purple theme maintains original color sequence."""
|
||||
purple = themes.get_theme("purple")
|
||||
|
||||
# Expected main gradient: 231→225→177→171→165→135→129→93→57→57→57(dim)→235
|
||||
expected_main = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||
# Expected msg gradient: 231→226→226→220→220→184→184→178→178→172→172(dim)→235
|
||||
expected_msg = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||
|
||||
assert purple.main_gradient == expected_main
|
||||
assert purple.message_gradient == expected_msg
|
||||
|
||||
def test_purple_theme_name(self):
|
||||
"""Purple theme has correct name."""
|
||||
purple = themes.get_theme("purple")
|
||||
assert purple.name == "purple"
|
||||
|
||||
|
||||
class TestThemeDataOnly:
|
||||
"""Tests to ensure themes module has no problematic imports."""
|
||||
|
||||
def test_themes_module_imports(self):
|
||||
"""themes module should be data-only without config/render imports."""
|
||||
import inspect
|
||||
source = inspect.getsource(themes)
|
||||
# Verify no imports of config or render (look for actual import statements)
|
||||
lines = source.split('\n')
|
||||
import_lines = [line for line in lines if line.strip().startswith('import ') or line.strip().startswith('from ')]
|
||||
# Filter out empty and comment lines
|
||||
import_lines = [line for line in import_lines if line.strip() and not line.strip().startswith('#')]
|
||||
# Should have no import lines
|
||||
assert len(import_lines) == 0, f"Found unexpected imports: {import_lines}"
|
||||
@@ -1,95 +0,0 @@
|
||||
"""
|
||||
Tests for engine.types module.
|
||||
"""
|
||||
|
||||
from engine.types import (
|
||||
Block,
|
||||
FetchResult,
|
||||
HeadlineItem,
|
||||
items_to_tuples,
|
||||
tuples_to_items,
|
||||
)
|
||||
|
||||
|
||||
class TestHeadlineItem:
|
||||
"""Tests for HeadlineItem dataclass."""
|
||||
|
||||
def test_create_headline_item(self):
|
||||
"""Can create HeadlineItem with required fields."""
|
||||
item = HeadlineItem(title="Test", source="Source", timestamp="12:00")
|
||||
assert item.title == "Test"
|
||||
assert item.source == "Source"
|
||||
assert item.timestamp == "12:00"
|
||||
|
||||
def test_to_tuple(self):
|
||||
"""to_tuple returns correct tuple."""
|
||||
item = HeadlineItem(title="Test", source="Source", timestamp="12:00")
|
||||
assert item.to_tuple() == ("Test", "Source", "12:00")
|
||||
|
||||
def test_from_tuple(self):
|
||||
"""from_tuple creates HeadlineItem from tuple."""
|
||||
item = HeadlineItem.from_tuple(("Test", "Source", "12:00"))
|
||||
assert item.title == "Test"
|
||||
assert item.source == "Source"
|
||||
assert item.timestamp == "12:00"
|
||||
|
||||
|
||||
class TestItemsConversion:
|
||||
"""Tests for list conversion functions."""
|
||||
|
||||
def test_items_to_tuples(self):
|
||||
"""Converts list of HeadlineItem to list of tuples."""
|
||||
items = [
|
||||
HeadlineItem(title="A", source="S", timestamp="10:00"),
|
||||
HeadlineItem(title="B", source="T", timestamp="11:00"),
|
||||
]
|
||||
result = items_to_tuples(items)
|
||||
assert result == [("A", "S", "10:00"), ("B", "T", "11:00")]
|
||||
|
||||
def test_tuples_to_items(self):
|
||||
"""Converts list of tuples to list of HeadlineItem."""
|
||||
tuples = [("A", "S", "10:00"), ("B", "T", "11:00")]
|
||||
result = tuples_to_items(tuples)
|
||||
assert len(result) == 2
|
||||
assert result[0].title == "A"
|
||||
assert result[1].title == "B"
|
||||
|
||||
|
||||
class TestFetchResult:
|
||||
"""Tests for FetchResult dataclass."""
|
||||
|
||||
def test_create_fetch_result(self):
|
||||
"""Can create FetchResult."""
|
||||
items = [HeadlineItem(title="Test", source="Source", timestamp="12:00")]
|
||||
result = FetchResult(items=items, linked=1, failed=0)
|
||||
assert len(result.items) == 1
|
||||
assert result.linked == 1
|
||||
assert result.failed == 0
|
||||
|
||||
def test_to_legacy_tuple(self):
|
||||
"""to_legacy_tuple returns correct format."""
|
||||
items = [HeadlineItem(title="Test", source="Source", timestamp="12:00")]
|
||||
result = FetchResult(items=items, linked=1, failed=0)
|
||||
legacy = result.to_legacy_tuple()
|
||||
assert legacy[0] == [("Test", "Source", "12:00")]
|
||||
assert legacy[1] == 1
|
||||
assert legacy[2] == 0
|
||||
|
||||
|
||||
class TestBlock:
|
||||
"""Tests for Block dataclass."""
|
||||
|
||||
def test_create_block(self):
|
||||
"""Can create Block."""
|
||||
block = Block(
|
||||
content=["line1", "line2"], color="\033[38;5;46m", meta_row_index=1
|
||||
)
|
||||
assert len(block.content) == 2
|
||||
assert block.color == "\033[38;5;46m"
|
||||
assert block.meta_row_index == 1
|
||||
|
||||
def test_to_legacy_tuple(self):
|
||||
"""to_legacy_tuple returns correct format."""
|
||||
block = Block(content=["line1"], color="green", meta_row_index=0)
|
||||
legacy = block.to_legacy_tuple()
|
||||
assert legacy == (["line1"], "green", 0)
|
||||
@@ -1,64 +0,0 @@
|
||||
"""
|
||||
Tests for engine.viewport module.
|
||||
"""
|
||||
|
||||
from engine import viewport
|
||||
|
||||
|
||||
class TestViewportTw:
|
||||
"""Tests for tw() function."""
|
||||
|
||||
def test_tw_returns_int(self):
|
||||
"""tw() returns an integer."""
|
||||
result = viewport.tw()
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_tw_positive(self):
|
||||
"""tw() returns a positive value."""
|
||||
assert viewport.tw() > 0
|
||||
|
||||
|
||||
class TestViewportTh:
|
||||
"""Tests for th() function."""
|
||||
|
||||
def test_th_returns_int(self):
|
||||
"""th() returns an integer."""
|
||||
result = viewport.th()
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_th_positive(self):
|
||||
"""th() returns a positive value."""
|
||||
assert viewport.th() > 0
|
||||
|
||||
|
||||
class TestViewportMoveTo:
|
||||
"""Tests for move_to() function."""
|
||||
|
||||
def test_move_to_format(self):
|
||||
"""move_to() returns correctly formatted ANSI escape."""
|
||||
result = viewport.move_to(5, 10)
|
||||
assert result == "\033[5;10H"
|
||||
|
||||
def test_move_to_default_col(self):
|
||||
"""move_to() defaults to column 1."""
|
||||
result = viewport.move_to(5)
|
||||
assert result == "\033[5;1H"
|
||||
|
||||
|
||||
class TestViewportClearScreen:
|
||||
"""Tests for clear_screen() function."""
|
||||
|
||||
def test_clear_screen_format(self):
|
||||
"""clear_screen() returns clear screen ANSI escape."""
|
||||
result = viewport.clear_screen()
|
||||
assert "\033[2J" in result
|
||||
assert "\033[H" in result
|
||||
|
||||
|
||||
class TestViewportClearLine:
|
||||
"""Tests for clear_line() function."""
|
||||
|
||||
def test_clear_line_format(self):
|
||||
"""clear_line() returns clear line ANSI escape."""
|
||||
result = viewport.clear_line()
|
||||
assert result == "\033[K"
|
||||
735
uv.lock
generated
Normal file
735
uv.lock
generated
Normal file
@@ -0,0 +1,735 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.11'",
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
toml = [
|
||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "feedparser"
|
||||
version = "6.0.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sgmllib3k" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mainline"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "feedparser" },
|
||||
{ name = "pillow" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
mic = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "sounddevice" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "feedparser", specifier = ">=6.0.0" },
|
||||
{ name = "numpy", marker = "extra == 'mic'", specifier = ">=1.24.0" },
|
||||
{ name = "pillow", specifier = ">=10.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
|
||||
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
||||
{ name = "sounddevice", marker = "extra == 'mic'", specifier = ">=0.4.0" },
|
||||
]
|
||||
provides-extras = ["mic", "dev"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-cov", specifier = ">=4.1.0" },
|
||||
{ name = "pytest-mock", specifier = ">=3.12.0" },
|
||||
{ name = "ruff", specifier = ">=0.1.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.11'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/51/5093a2df15c4dc19da3f79d1021e891f5dcf1d9d1db6ba38891d5590f3fe/numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", size = 16957183, upload-time = "2026-03-09T07:55:57.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/7c/c061f3de0630941073d2598dc271ac2f6cbcf5c83c74a5870fea07488333/numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147", size = 14968734, upload-time = "2026-03-09T07:56:00.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/27/d26c85cbcd86b26e4f125b0668e7a7c0542d19dd7d23ee12e87b550e95b5/numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920", size = 5475288, upload-time = "2026-03-09T07:56:02.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/09/3c4abbc1dcd8010bf1a611d174c7aa689fc505585ec806111b4406f6f1b1/numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9", size = 6805253, upload-time = "2026-03-09T07:56:04.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/bc/e7aa3f6817e40c3f517d407742337cbb8e6fc4b83ce0b55ab780c829243b/numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470", size = 15969479, upload-time = "2026-03-09T07:56:06.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/51/9f5d7a41f0b51649ddf2f2320595e15e122a40610b233d51928dd6c92353/numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71", size = 16901035, upload-time = "2026-03-09T07:56:09.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/6e/b221dd847d7181bc5ee4857bfb026182ef69499f9305eb1371cbb1aea626/numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15", size = 17325657, upload-time = "2026-03-09T07:56:12.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/b8/8f3fd2da596e1063964b758b5e3c970aed1949a05200d7e3d46a9d46d643/numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52", size = 18635512, upload-time = "2026-03-09T07:56:14.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/24/2993b775c37e39d2f8ab4125b44337ab0b2ba106c100980b7c274a22bee7/numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd", size = 6238100, upload-time = "2026-03-09T07:56:17.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/1d/edccf27adedb754db7c4511d5eac8b83f004ae948fe2d3509e8b78097d4c/numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec", size = 12609816, upload-time = "2026-03-09T07:56:19.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/82/190b99153480076c8dce85f4cfe7d53ea84444145ffa54cb58dcd460d66b/numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67", size = 10485757, upload-time = "2026-03-09T07:56:21.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/e4/4dab9fb43c83719c29241c535d9e07be73bea4bc0c6686c5816d8e1b6689/numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028", size = 16834892, upload-time = "2026-03-09T07:58:35.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/29/f8b6d4af90fed3dfda84ebc0df06c9833d38880c79ce954e5b661758aa31/numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8", size = 14893070, upload-time = "2026-03-09T07:58:37.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/04/a19b3c91dbec0a49269407f15d5753673a09832daed40c45e8150e6fa558/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152", size = 5399609, upload-time = "2026-03-09T07:58:39.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/34/4d73603f5420eab89ea8a67097b31364bf7c30f811d4dd84b1659c7476d9/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395", size = 6714355, upload-time = "2026-03-09T07:58:42.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ad/1100d7229bb248394939a12a8074d485b655e8ed44207d328fdd7fcebc7b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79", size = 15800434, upload-time = "2026-03-09T07:58:44.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/fd/16d710c085d28ba4feaf29ac60c936c9d662e390344f94a6beaa2ac9899b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857", size = 16729409, upload-time = "2026-03-09T07:58:47.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage", extra = ["toml"] },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sgmllib3k"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" }
|
||||
|
||||
[[package]]
|
||||
name = "sounddevice"
|
||||
version = "0.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user