Compare commits
10 Commits
ab3e1766b1
...
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
|
.coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
coverage.xml
|
|
||||||
|
|||||||
194
AGENTS.md
194
AGENTS.md
@@ -1,194 +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 --all-extras # includes mic support
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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 test-browser # Run e2e browser tests (requires playwright)
|
|
||||||
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 (topics-init + lint + test-cov)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Runtime Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mise run run # Run mainline (terminal)
|
|
||||||
mise run run-poetry # Run with poetry feed
|
|
||||||
mise run run-firehose # Run in firehose mode
|
|
||||||
mise run run-websocket # Run with WebSocket display only
|
|
||||||
mise run run-sixel # Run with Sixel graphics display
|
|
||||||
mise run run-both # Run with both terminal and WebSocket
|
|
||||||
mise run run-client # Run both + open browser
|
|
||||||
mise run cmd # Run C&C command interface
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|
||||||
**IMPORTANT**: Always review the hk documentation before modifying `hk.pkl`:
|
|
||||||
- [hk Configuration Guide](https://hk.jdx.dev/configuration.html)
|
|
||||||
- [hk Hooks Reference](https://hk.jdx.dev/hooks.html)
|
|
||||||
- [hk Builtins](https://hk.jdx.dev/builtins.html)
|
|
||||||
|
|
||||||
The project uses hk configured in `hk.pkl`:
|
|
||||||
- **pre-commit**: runs ruff-format and ruff (with auto-fix)
|
|
||||||
- **pre-push**: runs ruff check + benchmark hook
|
|
||||||
|
|
||||||
## Benchmark Runner
|
|
||||||
|
|
||||||
Run performance benchmarks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mise run benchmark # Run all benchmarks (text output)
|
|
||||||
mise run benchmark-json # Run benchmarks (JSON output)
|
|
||||||
mise run benchmark-report # Run benchmarks (Markdown report)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Benchmark Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run benchmarks
|
|
||||||
uv run python -m engine.benchmark
|
|
||||||
|
|
||||||
# Run with specific displays/effects
|
|
||||||
uv run python -m engine.benchmark --displays null,terminal --effects fade,glitch
|
|
||||||
|
|
||||||
# Save baseline for hook comparisons
|
|
||||||
uv run python -m engine.benchmark --baseline
|
|
||||||
|
|
||||||
# Run in hook mode (compares against baseline)
|
|
||||||
uv run python -m engine.benchmark --hook
|
|
||||||
|
|
||||||
# Hook mode with custom threshold (default: 20% degradation)
|
|
||||||
uv run python -m engine.benchmark --hook --threshold 0.3
|
|
||||||
|
|
||||||
# Custom baseline location
|
|
||||||
uv run python -m engine.benchmark --hook --cache /path/to/cache.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hook Mode
|
|
||||||
|
|
||||||
The `--hook` mode compares current benchmarks against a saved baseline. If performance degrades beyond the threshold (default 20%), it exits with code 1. This is useful for preventing performance regressions in feature branches.
|
|
||||||
|
|
||||||
The pre-push hook runs benchmark in hook mode to catch performance regressions before pushing.
|
|
||||||
|
|
||||||
## 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 and event publishing
|
|
||||||
- **effects/** - plugin architecture with performance monitoring
|
|
||||||
- The render pipeline: fetch → render → effects → scroll → terminal output
|
|
||||||
|
|
||||||
### Display System
|
|
||||||
|
|
||||||
- **Display abstraction** (`engine/display.py`): swap display backends via the Display protocol
|
|
||||||
- `TerminalDisplay` - ANSI terminal output
|
|
||||||
- `WebSocketDisplay` - broadcasts to web clients via WebSocket
|
|
||||||
- `SixelDisplay` - renders to Sixel graphics (pure Python, no C dependency)
|
|
||||||
- `MultiDisplay` - forwards to multiple displays simultaneously
|
|
||||||
|
|
||||||
- **WebSocket display** (`engine/websocket_display.py`): real-time frame broadcasting to web browsers
|
|
||||||
- WebSocket server on port 8765
|
|
||||||
- HTTP server on port 8766 (serves HTML client)
|
|
||||||
- Client at `client/index.html` with ANSI color parsing and fullscreen support
|
|
||||||
|
|
||||||
- **Display modes** (`--display` flag):
|
|
||||||
- `terminal` - Default ANSI terminal output
|
|
||||||
- `websocket` - Web browser display (requires websockets package)
|
|
||||||
- `sixel` - Sixel graphics in supported terminals (iTerm2, mintty, etc.)
|
|
||||||
- `both` - Terminal + WebSocket simultaneously
|
|
||||||
|
|
||||||
### Command & Control
|
|
||||||
|
|
||||||
- C&C uses separate ntfy topics for commands and responses
|
|
||||||
- `NTFY_CC_CMD_TOPIC` - commands from cmdline.py
|
|
||||||
- `NTFY_CC_RESP_TOPIC` - responses back to cmdline.py
|
|
||||||
- Effects controller handles `/effects` commands (list, on/off, intensity, reorder, stats)
|
|
||||||
223
README.md
223
README.md
@@ -15,8 +15,7 @@ python3 mainline.py # news stream
|
|||||||
python3 mainline.py --poetry # literary consciousness mode
|
python3 mainline.py --poetry # literary consciousness mode
|
||||||
python3 mainline.py -p # same
|
python3 mainline.py -p # same
|
||||||
python3 mainline.py --firehose # dense rapid-fire headline mode
|
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||||
python3 mainline.py --display websocket # web browser display only
|
python3 mainline.py --refresh # force re-fetch (bypass cache)
|
||||||
python3 mainline.py --display both # terminal + web browser
|
|
||||||
python3 mainline.py --no-font-picker # skip interactive font picker
|
python3 mainline.py --no-font-picker # skip interactive font picker
|
||||||
python3 mainline.py --font-file path.otf # use a specific font file
|
python3 mainline.py --font-file path.otf # use a specific font file
|
||||||
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
||||||
@@ -29,20 +28,7 @@ Or with uv:
|
|||||||
uv run mainline.py
|
uv run mainline.py
|
||||||
```
|
```
|
||||||
|
|
||||||
First run bootstraps dependencies. Use `uv sync --all-extras` for mic support.
|
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache. With uv, run `uv sync` or `uv sync --all-extras` (includes mic support) instead.
|
||||||
|
|
||||||
### Command & Control (C&C)
|
|
||||||
|
|
||||||
Control mainline remotely using `cmdline.py`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run cmdline.py # Interactive TUI
|
|
||||||
uv run cmdline.py /effects list # List all effects
|
|
||||||
uv run cmdline.py /effects stats # Show performance stats
|
|
||||||
uv run cmdline.py -w /effects stats # Watch mode (auto-refresh)
|
|
||||||
```
|
|
||||||
|
|
||||||
Commands are sent via ntfy.sh topics - useful for controlling a daemonized mainline instance.
|
|
||||||
|
|
||||||
### Config
|
### Config
|
||||||
|
|
||||||
@@ -53,31 +39,20 @@ All constants live in `engine/config.py`:
|
|||||||
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
||||||
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
||||||
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
||||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream for messages |
|
|
||||||
| `NTFY_CC_CMD_TOPIC` | klubhaus URL | ntfy.sh topic for C&C commands |
|
|
||||||
| `NTFY_CC_RESP_TOPIC` | klubhaus URL | ntfy.sh topic for C&C responses |
|
|
||||||
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after dropped SSE |
|
|
||||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
|
||||||
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
||||||
| `FONT_PATH` | first file in `FONT_DIR` | Active display font |
|
| `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
|
||||||
| `FONT_PICKER` | `True` | Show interactive font picker at boot |
|
| `FONT_INDEX` | `0` | Face index within a font collection file |
|
||||||
|
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
|
||||||
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
||||||
| `RENDER_H` | `8` | Terminal rows per headline line |
|
| `RENDER_H` | `8` | Terminal rows per headline line |
|
||||||
| `SSAA` | `4` | Super-sampling factor |
|
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
|
||||||
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
||||||
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
| `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) |
|
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
||||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
|
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
|
||||||
|
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
|
||||||
### Display Modes
|
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||||
|
|
||||||
Mainline supports multiple display backends:
|
|
||||||
|
|
||||||
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
|
||||||
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
|
||||||
- **Both** (`--display both`): Terminal + WebSocket simultaneously
|
|
||||||
|
|
||||||
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
|
|
||||||
|
|
||||||
### Feeds
|
### Feeds
|
||||||
|
|
||||||
@@ -87,15 +62,15 @@ WebSocket mode serves a web client at http://localhost:8766 with ANSI color supp
|
|||||||
|
|
||||||
### Fonts
|
### Fonts
|
||||||
|
|
||||||
A `fonts/` directory is bundled with demo faces. On startup, an interactive picker lists all discovered faces with a live half-block preview.
|
A `fonts/` directory is bundled with demo faces (AgorTechnoDemo, AlphatronDemo, CSBishopDrawn, CubaTechnologyDemo, CyberformDemo, KATA, Microbots, ModernSpaceDemo, Neoform, Pixel Sparta, RaceHugoDemo, Resond, Robocops, Synthetix, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
|
||||||
|
|
||||||
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select.
|
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
|
||||||
|
|
||||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/`.
|
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.
|
||||||
|
|
||||||
### ntfy.sh
|
### 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.
|
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.
|
||||||
|
|
||||||
To push a message:
|
To push a message:
|
||||||
|
|
||||||
@@ -103,54 +78,107 @@ To push a message:
|
|||||||
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Internals
|
## Internals
|
||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
|
|
||||||
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection
|
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
|
||||||
- Feeds are fetched and filtered on startup; results are cached for fast restarts
|
- Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
|
||||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters
|
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
|
||||||
- The ticker uses a sweeping white-hot → deep green gradient
|
- The ticker uses a sweeping white-hot → deep green gradient; ntfy messages use a complementary white-hot → magenta/maroon gradient to distinguish them visually
|
||||||
- Subject-region detection triggers Google Translate and font swap for non-Latin scripts
|
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
|
||||||
- The mic stream runs in a background thread, feeding RMS dB into glitch probability
|
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
||||||
- The viewport scrolls through pre-rendered blocks with fade zones
|
- 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 for messages and C&C commands
|
- An ntfy.sh SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
|
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
|
||||||
|
|
||||||
```
|
```
|
||||||
engine/
|
engine/
|
||||||
__init__.py package marker
|
config.py constants, CLI flags, glyph tables
|
||||||
app.py main(), font picker TUI, boot sequence, C&C poller
|
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
||||||
config.py constants, CLI flags, glyph tables
|
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
||||||
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
filter.py HTML stripping, content filter
|
||||||
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
translate.py Google Translate wrapper + region detection
|
||||||
filter.py HTML stripping, content filter
|
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||||
translate.py Google Translate wrapper + region detection
|
effects.py noise, glitch_bar, fade, firehose
|
||||||
render.py OTF → half-block pipeline (SSAA, gradient)
|
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||||
effects/ plugin architecture for visual effects
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
controller.py handles /effects commands
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
chain.py effect pipeline chaining
|
scroll.py stream() frame loop + message rendering
|
||||||
registry.py effect registration and lookup
|
app.py main(), font picker TUI, boot sequence, signal handler
|
||||||
performance.py performance monitoring
|
|
||||||
fetch.py RSS/Gutenberg fetching + cache
|
tests/
|
||||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
test_config.py
|
||||||
mic.py MicMonitor — standalone, graceful fallback
|
test_filter.py
|
||||||
scroll.py stream() frame loop + message rendering
|
test_mic.py
|
||||||
viewport.py terminal dimension tracking
|
test_ntfy.py
|
||||||
frame.py scroll step calculation, timing
|
test_sources.py
|
||||||
layers.py ticker zone, firehose, message overlay
|
test_terminal.py
|
||||||
eventbus.py thread-safe event publishing
|
|
||||||
events.py event types and definitions
|
|
||||||
controller.py coordinates ntfy/mic monitoring
|
|
||||||
emitters.py background emitters
|
|
||||||
types.py type definitions
|
|
||||||
display.py Display protocol (Terminal, WebSocket, Multi)
|
|
||||||
websocket_display.py WebSocket server for browser clients
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extending
|
||||||
|
|
||||||
|
`ntfy.py` and `mic.py` are fully standalone and designed to be reused by any terminal visualizer. `engine.render` is the importable rendering pipeline for non-terminal targets.
|
||||||
|
|
||||||
|
### NtfyPoller
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
|
||||||
|
poller = NtfyPoller("https://ntfy.sh/my_topic/json")
|
||||||
|
poller.start()
|
||||||
|
|
||||||
|
# in your render loop:
|
||||||
|
msg = poller.get_active_message() # → (title, body, timestamp) or None
|
||||||
|
if msg:
|
||||||
|
title, body, ts = msg
|
||||||
|
render_my_message(title, body) # visualizer-specific
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies: `urllib.request`, `json`, `threading`, `time` — stdlib only. The `since=` parameter is managed automatically on reconnect.
|
||||||
|
|
||||||
|
### MicMonitor
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
mic = MicMonitor(threshold_db=50)
|
||||||
|
result = mic.start() # None = sounddevice unavailable; False = stream failed; True = ok
|
||||||
|
if result:
|
||||||
|
excess = mic.excess # dB above threshold, clamped to 0
|
||||||
|
db = mic.db # raw RMS dB level
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies: `sounddevice`, `numpy` — both optional; degrades gracefully if unavailable.
|
||||||
|
|
||||||
|
### Render pipeline
|
||||||
|
|
||||||
|
`engine.render` exposes the OTF → raster pipeline independently of the terminal scroll loop. The planned `serve.py` extension will import it directly to pre-render headlines as 1-bit bitmaps for an ESP32 thin client:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# planned — serve.py does not yet exist
|
||||||
|
from engine.render import render_line, big_wrap
|
||||||
|
from engine.fetch import fetch_all
|
||||||
|
|
||||||
|
headlines = fetch_all()
|
||||||
|
for h in headlines:
|
||||||
|
rows = big_wrap(h.text, font, width=800) # list of half-block rows
|
||||||
|
# threshold to 1-bit, pack bytes, serve over HTTP
|
||||||
|
```
|
||||||
|
|
||||||
|
See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server + thin client architecture.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -161,7 +189,7 @@ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # minimal (no mic)
|
uv sync # minimal (no mic)
|
||||||
uv sync --all-extras # with mic support
|
uv sync --all-extras # with mic support (sounddevice + numpy)
|
||||||
uv sync --all-extras --group dev # full dev environment
|
uv sync --all-extras --group dev # full dev environment
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -175,19 +203,15 @@ mise run test-cov # run with coverage report
|
|||||||
mise run lint # ruff check
|
mise run lint # ruff check
|
||||||
mise run lint-fix # ruff check --fix
|
mise run lint-fix # ruff check --fix
|
||||||
mise run format # ruff format
|
mise run format # ruff format
|
||||||
|
mise run run # uv run mainline.py
|
||||||
mise run run # terminal display
|
mise run run-poetry # uv run mainline.py --poetry
|
||||||
mise run run-websocket # web display only
|
mise run run-firehose # uv run mainline.py --firehose
|
||||||
mise run run-both # terminal + web
|
|
||||||
mise run run-client # both + open browser
|
|
||||||
|
|
||||||
mise run cmd # C&C command interface
|
|
||||||
mise run cmd-stats # watch effects stats
|
|
||||||
mise run topics-init # initialize ntfy topics
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, and `terminal`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest
|
uv run pytest
|
||||||
uv run pytest --cov=engine --cov-report=term-missing
|
uv run pytest --cov=engine --cov-report=term-missing
|
||||||
@@ -207,23 +231,28 @@ Pre-commit hooks run lint automatically via `hk`.
|
|||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
- Concurrent feed fetching with ThreadPoolExecutor
|
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
|
||||||
- Background feed refresh daemon
|
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
|
||||||
- Translation pre-fetch during boot
|
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
|
||||||
|
|
||||||
### Graphics
|
### Graphics
|
||||||
- Matrix rain katakana underlay
|
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
||||||
- CRT scanline simulation
|
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
|
||||||
- Sixel/iTerm2 inline images
|
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
|
||||||
- Parallax secondary column
|
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
||||||
|
|
||||||
### Cyberpunk Vibes
|
### Cyberpunk Vibes
|
||||||
- Keyword watch list with strobe effects
|
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
|
||||||
- Breaking interrupt with synthesized audio
|
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
|
||||||
- Live data overlay (BTC, ISS position)
|
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
||||||
- Theme switcher (amber, ice, red)
|
- **Theme switcher** — `--amber` (phosphor), `--ice` (electric cyan), `--red` (alert state) palette modes via CLI flag
|
||||||
- Persona modes (surveillance, oracle, underground)
|
- **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy
|
||||||
|
- **Synthesized audio** — short static bursts tied to glitch events, independent of mic input
|
||||||
|
|
||||||
|
### Extensibility
|
||||||
|
- **serve.py** — HTTP server that imports `engine.render` and `engine.fetch` directly to stream 1-bit bitmaps to an ESP32 display
|
||||||
|
- **Rust port** — `ntfy.py` and `render.py` are the natural first targets; clear module boundaries make incremental porting viable
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*
|
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.10+.*
|
||||||
|
|||||||
@@ -1,366 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Mainline Terminal</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
background: #0a0a0a;
|
|
||||||
color: #ccc;
|
|
||||||
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
body.fullscreen {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
body.fullscreen #controls {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
canvas {
|
|
||||||
background: #000;
|
|
||||||
border: 1px solid #333;
|
|
||||||
image-rendering: pixelated;
|
|
||||||
image-rendering: crisp-edges;
|
|
||||||
}
|
|
||||||
body.fullscreen canvas {
|
|
||||||
border: none;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
max-width: 100vw;
|
|
||||||
max-height: 100vh;
|
|
||||||
}
|
|
||||||
#controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
#controls button {
|
|
||||||
background: #333;
|
|
||||||
color: #ccc;
|
|
||||||
border: 1px solid #555;
|
|
||||||
padding: 5px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
#controls button:hover {
|
|
||||||
background: #444;
|
|
||||||
}
|
|
||||||
#controls input {
|
|
||||||
width: 60px;
|
|
||||||
background: #222;
|
|
||||||
color: #ccc;
|
|
||||||
border: 1px solid #444;
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-family: inherit;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
#status {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
#status.connected {
|
|
||||||
color: #4f4;
|
|
||||||
}
|
|
||||||
#status.disconnected {
|
|
||||||
color: #f44;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="container">
|
|
||||||
<canvas id="terminal"></canvas>
|
|
||||||
</div>
|
|
||||||
<div id="controls">
|
|
||||||
<label>Cols: <input type="number" id="cols" value="80" min="20" max="200"></label>
|
|
||||||
<label>Rows: <input type="number" id="rows" value="24" min="10" max="60"></label>
|
|
||||||
<button id="apply">Apply</button>
|
|
||||||
<button id="fullscreen">Fullscreen</button>
|
|
||||||
</div>
|
|
||||||
<div id="status" class="disconnected">Connecting...</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const canvas = document.getElementById('terminal');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const status = document.getElementById('status');
|
|
||||||
const colsInput = document.getElementById('cols');
|
|
||||||
const rowsInput = document.getElementById('rows');
|
|
||||||
const applyBtn = document.getElementById('apply');
|
|
||||||
const fullscreenBtn = document.getElementById('fullscreen');
|
|
||||||
|
|
||||||
const CHAR_WIDTH = 9;
|
|
||||||
const CHAR_HEIGHT = 16;
|
|
||||||
|
|
||||||
const ANSI_COLORS = {
|
|
||||||
0: '#000000', 1: '#cd3131', 2: '#0dbc79', 3: '#e5e510',
|
|
||||||
4: '#2472c8', 5: '#bc3fbc', 6: '#11a8cd', 7: '#e5e5e5',
|
|
||||||
8: '#666666', 9: '#f14c4c', 10: '#23d18b', 11: '#f5f543',
|
|
||||||
12: '#3b8eea', 13: '#d670d6', 14: '#29b8db', 15: '#ffffff',
|
|
||||||
};
|
|
||||||
|
|
||||||
let cols = 80;
|
|
||||||
let rows = 24;
|
|
||||||
let ws = null;
|
|
||||||
|
|
||||||
function resizeCanvas() {
|
|
||||||
canvas.width = cols * CHAR_WIDTH;
|
|
||||||
canvas.height = rows * CHAR_HEIGHT;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAnsi(text) {
|
|
||||||
if (!text) return [];
|
|
||||||
|
|
||||||
const tokens = [];
|
|
||||||
let currentText = '';
|
|
||||||
let fg = '#cccccc';
|
|
||||||
let bg = '#000000';
|
|
||||||
let bold = false;
|
|
||||||
let i = 0;
|
|
||||||
let inEscape = false;
|
|
||||||
let escapeCode = '';
|
|
||||||
|
|
||||||
while (i < text.length) {
|
|
||||||
const char = text[i];
|
|
||||||
|
|
||||||
if (inEscape) {
|
|
||||||
if (char >= '0' && char <= '9' || char === ';' || char === '[') {
|
|
||||||
escapeCode += char;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === 'm') {
|
|
||||||
const codes = escapeCode.replace('\x1b[', '').split(';');
|
|
||||||
|
|
||||||
for (const code of codes) {
|
|
||||||
const num = parseInt(code) || 0;
|
|
||||||
|
|
||||||
if (num === 0) {
|
|
||||||
fg = '#cccccc';
|
|
||||||
bg = '#000000';
|
|
||||||
bold = false;
|
|
||||||
} else if (num === 1) {
|
|
||||||
bold = true;
|
|
||||||
} else if (num === 22) {
|
|
||||||
bold = false;
|
|
||||||
} else if (num === 39) {
|
|
||||||
fg = '#cccccc';
|
|
||||||
} else if (num === 49) {
|
|
||||||
bg = '#000000';
|
|
||||||
} else if (num >= 30 && num <= 37) {
|
|
||||||
fg = ANSI_COLORS[num - 30 + (bold ? 8 : 0)] || '#cccccc';
|
|
||||||
} else if (num >= 40 && num <= 47) {
|
|
||||||
bg = ANSI_COLORS[num - 40] || '#000000';
|
|
||||||
} else if (num >= 90 && num <= 97) {
|
|
||||||
fg = ANSI_COLORS[num - 90 + 8] || '#cccccc';
|
|
||||||
} else if (num >= 100 && num <= 107) {
|
|
||||||
bg = ANSI_COLORS[num - 100 + 8] || '#000000';
|
|
||||||
} else if (num >= 1 && num <= 256) {
|
|
||||||
// 256 colors
|
|
||||||
if (num < 16) {
|
|
||||||
fg = ANSI_COLORS[num] || '#cccccc';
|
|
||||||
} else if (num < 232) {
|
|
||||||
const c = num - 16;
|
|
||||||
const r = Math.floor(c / 36) * 51;
|
|
||||||
const g = Math.floor((c % 36) / 6) * 51;
|
|
||||||
const b = (c % 6) * 51;
|
|
||||||
fg = `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
|
|
||||||
} else {
|
|
||||||
const gray = (num - 232) * 10 + 8;
|
|
||||||
fg = `#${gray.toString(16).repeat(2)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentText) {
|
|
||||||
tokens.push({ text: currentText, fg, bg, bold });
|
|
||||||
currentText = '';
|
|
||||||
}
|
|
||||||
inEscape = false;
|
|
||||||
escapeCode = '';
|
|
||||||
}
|
|
||||||
} else if (char === '\x1b' && text[i + 1] === '[') {
|
|
||||||
if (currentText) {
|
|
||||||
tokens.push({ text: currentText, fg, bg, bold });
|
|
||||||
currentText = '';
|
|
||||||
}
|
|
||||||
inEscape = true;
|
|
||||||
escapeCode = '';
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
currentText += char;
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentText) {
|
|
||||||
tokens.push({ text: currentText, fg, bg, bold });
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLine(text, x, y, lineHeight) {
|
|
||||||
const tokens = parseAnsi(text);
|
|
||||||
let xOffset = x;
|
|
||||||
|
|
||||||
for (const token of tokens) {
|
|
||||||
if (token.text) {
|
|
||||||
if (token.bold) {
|
|
||||||
ctx.font = 'bold 16px monospace';
|
|
||||||
} else {
|
|
||||||
ctx.font = '16px monospace';
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = ctx.measureText(token.text);
|
|
||||||
|
|
||||||
if (token.bg !== '#000000') {
|
|
||||||
ctx.fillStyle = token.bg;
|
|
||||||
ctx.fillRect(xOffset, y - 2, metrics.width + 1, lineHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.fillStyle = token.fg;
|
|
||||||
ctx.fillText(token.text, xOffset, y);
|
|
||||||
xOffset += metrics.width;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.hostname}:8765`;
|
|
||||||
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
status.textContent = 'Connected';
|
|
||||||
status.className = 'connected';
|
|
||||||
sendSize();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
status.textContent = 'Disconnected - Reconnecting...';
|
|
||||||
status.className = 'disconnected';
|
|
||||||
setTimeout(connect, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
status.textContent = 'Connection error';
|
|
||||||
status.className = 'disconnected';
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (data.type === 'frame') {
|
|
||||||
cols = data.width || 80;
|
|
||||||
rows = data.height || 24;
|
|
||||||
colsInput.value = cols;
|
|
||||||
rowsInput.value = rows;
|
|
||||||
resizeCanvas();
|
|
||||||
render(data.lines || []);
|
|
||||||
} else if (data.type === 'clear') {
|
|
||||||
ctx.fillStyle = '#000';
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse message:', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendSize() {
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({
|
|
||||||
type: 'resize',
|
|
||||||
width: parseInt(colsInput.value),
|
|
||||||
height: parseInt(rowsInput.value)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(lines) {
|
|
||||||
ctx.fillStyle = '#000';
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
ctx.font = '16px monospace';
|
|
||||||
ctx.textBaseline = 'top';
|
|
||||||
|
|
||||||
const lineHeight = CHAR_HEIGHT;
|
|
||||||
const maxLines = Math.min(lines.length, rows);
|
|
||||||
|
|
||||||
for (let i = 0; i < maxLines; i++) {
|
|
||||||
const line = lines[i] || '';
|
|
||||||
renderLine(line, 0, i * lineHeight, lineHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateViewportSize() {
|
|
||||||
const isFullscreen = document.fullscreenElement !== null;
|
|
||||||
const padding = isFullscreen ? 0 : 40;
|
|
||||||
const controlsHeight = isFullscreen ? 0 : 60;
|
|
||||||
const availableWidth = window.innerWidth - padding;
|
|
||||||
const availableHeight = window.innerHeight - controlsHeight;
|
|
||||||
cols = Math.max(20, Math.floor(availableWidth / CHAR_WIDTH));
|
|
||||||
rows = Math.max(10, Math.floor(availableHeight / CHAR_HEIGHT));
|
|
||||||
colsInput.value = cols;
|
|
||||||
rowsInput.value = rows;
|
|
||||||
resizeCanvas();
|
|
||||||
console.log('Fullscreen:', isFullscreen, 'Size:', cols, 'x', rows);
|
|
||||||
sendSize();
|
|
||||||
}
|
|
||||||
|
|
||||||
applyBtn.addEventListener('click', () => {
|
|
||||||
cols = parseInt(colsInput.value);
|
|
||||||
rows = parseInt(rowsInput.value);
|
|
||||||
resizeCanvas();
|
|
||||||
sendSize();
|
|
||||||
});
|
|
||||||
|
|
||||||
fullscreenBtn.addEventListener('click', () => {
|
|
||||||
if (!document.fullscreenElement) {
|
|
||||||
document.body.classList.add('fullscreen');
|
|
||||||
document.documentElement.requestFullscreen().then(() => {
|
|
||||||
calculateViewportSize();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
document.exitFullscreen().then(() => {
|
|
||||||
calculateViewportSize();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('fullscreenchange', () => {
|
|
||||||
if (!document.fullscreenElement) {
|
|
||||||
document.body.classList.remove('fullscreen');
|
|
||||||
calculateViewportSize();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if (document.fullscreenElement) {
|
|
||||||
calculateViewportSize();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial setup
|
|
||||||
resizeCanvas();
|
|
||||||
connect();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
256
cmdline.py
256
cmdline.py
@@ -1,256 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
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 os
|
|
||||||
|
|
||||||
os.environ["FORCE_COLOR"] = "1"
|
|
||||||
os.environ["TERM"] = "xterm-256color"
|
|
||||||
|
|
||||||
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,35 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PLUGIN_DIR = Path(__file__).parent
|
|
||||||
|
|
||||||
|
|
||||||
def discover_plugins():
|
|
||||||
from engine.effects.registry import get_registry
|
|
||||||
|
|
||||||
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 hasattr(attr, "name")
|
|
||||||
and hasattr(attr, "process")
|
|
||||||
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:
|
|
||||||
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,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:
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
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
|
|
||||||
126
engine/app.py
126
engine/app.py
@@ -11,8 +11,10 @@ import time
|
|||||||
import tty
|
import tty
|
||||||
|
|
||||||
from engine import config, render
|
from engine import config, render
|
||||||
from engine.controller import StreamController
|
|
||||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
from engine.scroll import stream
|
||||||
from engine.terminal import (
|
from engine.terminal import (
|
||||||
CLR,
|
CLR,
|
||||||
CURSOR_OFF,
|
CURSOR_OFF,
|
||||||
@@ -247,110 +249,6 @@ def pick_font_face():
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
def pick_effects_config():
|
|
||||||
"""Interactive picker for configuring effects pipeline."""
|
|
||||||
import effects_plugins
|
|
||||||
from engine.effects import get_effect_chain, get_registry
|
|
||||||
|
|
||||||
effects_plugins.discover_plugins()
|
|
||||||
|
|
||||||
registry = get_registry()
|
|
||||||
chain = get_effect_chain()
|
|
||||||
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
|
||||||
|
|
||||||
effects = list(registry.list_all().values())
|
|
||||||
if not effects:
|
|
||||||
return
|
|
||||||
|
|
||||||
selected = 0
|
|
||||||
editing_intensity = False
|
|
||||||
intensity_value = 1.0
|
|
||||||
|
|
||||||
def _draw_effects_picker():
|
|
||||||
w = tw()
|
|
||||||
print(CLR, end="")
|
|
||||||
print("\033[1;1H", end="")
|
|
||||||
print(" \033[1;38;5;231mEFFECTS CONFIG\033[0m")
|
|
||||||
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
|
||||||
print()
|
|
||||||
|
|
||||||
for i, effect in enumerate(effects):
|
|
||||||
prefix = " > " if i == selected else " "
|
|
||||||
marker = "[*]" if effect.config.enabled else "[ ]"
|
|
||||||
if editing_intensity and i == selected:
|
|
||||||
print(
|
|
||||||
f"{prefix}{marker} \033[1;38;5;82m{effect.name}\033[0m: intensity={intensity_value:.2f} (use +/- to adjust, Enter to confirm)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
f"{prefix}{marker} {effect.name}: intensity={effect.config.intensity:.2f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
|
||||||
print(
|
|
||||||
" \033[38;5;245mControls: space=toggle on/off | +/-=adjust intensity | arrows=move | Enter=next effect | q=done\033[0m"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _read_effects_key():
|
|
||||||
ch = sys.stdin.read(1)
|
|
||||||
if ch == "\x03":
|
|
||||||
return "interrupt"
|
|
||||||
if ch in ("\r", "\n"):
|
|
||||||
return "enter"
|
|
||||||
if ch == " ":
|
|
||||||
return "toggle"
|
|
||||||
if ch == "q":
|
|
||||||
return "quit"
|
|
||||||
if ch == "+" or ch == "=":
|
|
||||||
return "up"
|
|
||||||
if ch == "-" or ch == "_":
|
|
||||||
return "down"
|
|
||||||
if ch == "\x1b":
|
|
||||||
c1 = sys.stdin.read(1)
|
|
||||||
if c1 != "[":
|
|
||||||
return None
|
|
||||||
c2 = sys.stdin.read(1)
|
|
||||||
if c2 == "A":
|
|
||||||
return "up"
|
|
||||||
if c2 == "B":
|
|
||||||
return "down"
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
return
|
|
||||||
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old_settings = termios.tcgetattr(fd)
|
|
||||||
try:
|
|
||||||
tty.setcbreak(fd)
|
|
||||||
while True:
|
|
||||||
_draw_effects_picker()
|
|
||||||
key = _read_effects_key()
|
|
||||||
|
|
||||||
if key == "quit" or key == "enter":
|
|
||||||
break
|
|
||||||
elif key == "up" and editing_intensity:
|
|
||||||
intensity_value = min(1.0, intensity_value + 0.1)
|
|
||||||
effects[selected].config.intensity = intensity_value
|
|
||||||
elif key == "down" and editing_intensity:
|
|
||||||
intensity_value = max(0.0, intensity_value - 0.1)
|
|
||||||
effects[selected].config.intensity = intensity_value
|
|
||||||
elif key == "up":
|
|
||||||
selected = max(0, selected - 1)
|
|
||||||
intensity_value = effects[selected].config.intensity
|
|
||||||
elif key == "down":
|
|
||||||
selected = min(len(effects) - 1, selected + 1)
|
|
||||||
intensity_value = effects[selected].config.intensity
|
|
||||||
elif key == "toggle":
|
|
||||||
effects[selected].config.enabled = not effects[selected].config.enabled
|
|
||||||
elif key == "interrupt":
|
|
||||||
raise KeyboardInterrupt
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||||
|
|
||||||
@@ -361,13 +259,10 @@ def main():
|
|||||||
|
|
||||||
signal.signal(signal.SIGINT, handle_sigint)
|
signal.signal(signal.SIGINT, handle_sigint)
|
||||||
|
|
||||||
StreamController.warmup_topics()
|
|
||||||
|
|
||||||
w = tw()
|
w = tw()
|
||||||
print(CLR, end="")
|
print(CLR, end="")
|
||||||
print(CURSOR_OFF, end="")
|
print(CURSOR_OFF, end="")
|
||||||
pick_font_face()
|
pick_font_face()
|
||||||
pick_effects_config()
|
|
||||||
w = tw()
|
w = tw()
|
||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
@@ -419,10 +314,9 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
controller = StreamController()
|
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
||||||
mic_ok, ntfy_ok = controller.initialize_sources()
|
mic_ok = mic.start()
|
||||||
|
if mic.available:
|
||||||
if controller.mic and controller.mic.available:
|
|
||||||
boot_ln(
|
boot_ln(
|
||||||
"Microphone",
|
"Microphone",
|
||||||
"ACTIVE"
|
"ACTIVE"
|
||||||
@@ -431,6 +325,12 @@ def main():
|
|||||||
bool(mic_ok),
|
bool(mic_ok),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ntfy = NtfyPoller(
|
||||||
|
config.NTFY_TOPIC,
|
||||||
|
reconnect_delay=config.NTFY_RECONNECT_DELAY,
|
||||||
|
display_secs=config.MESSAGE_DISPLAY_SECS,
|
||||||
|
)
|
||||||
|
ntfy_ok = ntfy.start()
|
||||||
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
||||||
|
|
||||||
if config.FIREHOSE:
|
if config.FIREHOSE:
|
||||||
@@ -443,7 +343,7 @@ def main():
|
|||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
|
|
||||||
controller.run(items)
|
stream(items, ntfy, mic)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
|||||||
@@ -1,638 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Benchmark runner for mainline - tests performance across effects and displays.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python -m engine.benchmark
|
|
||||||
python -m engine.benchmark --output report.md
|
|
||||||
python -m engine.benchmark --displays terminal,websocket --effects glitch,fade
|
|
||||||
python -m engine.benchmark --format json --output benchmark.json
|
|
||||||
|
|
||||||
Headless mode (default): suppress all terminal output during benchmarks.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime
|
|
||||||
from io import StringIO
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BenchmarkResult:
|
|
||||||
"""Result of a single benchmark run."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
display: str
|
|
||||||
effect: str | None
|
|
||||||
iterations: int
|
|
||||||
total_time_ms: float
|
|
||||||
avg_time_ms: float
|
|
||||||
std_dev_ms: float
|
|
||||||
min_ms: float
|
|
||||||
max_ms: float
|
|
||||||
fps: float
|
|
||||||
chars_processed: int
|
|
||||||
chars_per_sec: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BenchmarkReport:
|
|
||||||
"""Complete benchmark report."""
|
|
||||||
|
|
||||||
timestamp: str
|
|
||||||
python_version: str
|
|
||||||
results: list[BenchmarkResult] = field(default_factory=list)
|
|
||||||
summary: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
def get_sample_buffer(width: int = 80, height: int = 24) -> list[str]:
|
|
||||||
"""Generate a sample buffer for benchmarking."""
|
|
||||||
lines = []
|
|
||||||
for i in range(height):
|
|
||||||
line = f"\x1b[32mLine {i}\x1b[0m " + "A" * (width - 10)
|
|
||||||
lines.append(line)
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def benchmark_display(
|
|
||||||
display_class, buffer: list[str], iterations: int = 100
|
|
||||||
) -> BenchmarkResult | None:
|
|
||||||
"""Benchmark a single display."""
|
|
||||||
old_stdout = sys.stdout
|
|
||||||
old_stderr = sys.stderr
|
|
||||||
|
|
||||||
try:
|
|
||||||
sys.stdout = StringIO()
|
|
||||||
sys.stderr = StringIO()
|
|
||||||
|
|
||||||
display = display_class()
|
|
||||||
display.init(80, 24)
|
|
||||||
|
|
||||||
times = []
|
|
||||||
chars = sum(len(line) for line in buffer)
|
|
||||||
|
|
||||||
for _ in range(iterations):
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
display.show(buffer)
|
|
||||||
elapsed = (time.perf_counter() - t0) * 1000
|
|
||||||
times.append(elapsed)
|
|
||||||
|
|
||||||
display.cleanup()
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
sys.stdout = old_stdout
|
|
||||||
sys.stderr = old_stderr
|
|
||||||
|
|
||||||
times_arr = np.array(times)
|
|
||||||
|
|
||||||
return BenchmarkResult(
|
|
||||||
name=f"display_{display_class.__name__}",
|
|
||||||
display=display_class.__name__,
|
|
||||||
effect=None,
|
|
||||||
iterations=iterations,
|
|
||||||
total_time_ms=sum(times),
|
|
||||||
avg_time_ms=float(np.mean(times_arr)),
|
|
||||||
std_dev_ms=float(np.std(times_arr)),
|
|
||||||
min_ms=float(np.min(times_arr)),
|
|
||||||
max_ms=float(np.max(times_arr)),
|
|
||||||
fps=float(1000.0 / np.mean(times_arr)) if np.mean(times_arr) > 0 else 0.0,
|
|
||||||
chars_processed=chars * iterations,
|
|
||||||
chars_per_sec=float((chars * iterations) / (sum(times) / 1000))
|
|
||||||
if sum(times) > 0
|
|
||||||
else 0.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def benchmark_effect_with_display(
|
|
||||||
effect_class, display, buffer: list[str], iterations: int = 100
|
|
||||||
) -> BenchmarkResult | None:
|
|
||||||
"""Benchmark an effect with a display."""
|
|
||||||
old_stdout = sys.stdout
|
|
||||||
old_stderr = sys.stderr
|
|
||||||
|
|
||||||
try:
|
|
||||||
sys.stdout = StringIO()
|
|
||||||
sys.stderr = StringIO()
|
|
||||||
|
|
||||||
effect = effect_class()
|
|
||||||
effect.configure(enabled=True, intensity=1.0)
|
|
||||||
|
|
||||||
times = []
|
|
||||||
chars = sum(len(line) for line in buffer)
|
|
||||||
|
|
||||||
for _ in range(iterations):
|
|
||||||
processed = effect.process(buffer)
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
display.show(processed)
|
|
||||||
elapsed = (time.perf_counter() - t0) * 1000
|
|
||||||
times.append(elapsed)
|
|
||||||
|
|
||||||
display.cleanup()
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
sys.stdout = old_stdout
|
|
||||||
sys.stderr = old_stderr
|
|
||||||
|
|
||||||
times_arr = np.array(times)
|
|
||||||
|
|
||||||
return BenchmarkResult(
|
|
||||||
name=f"effect_{effect_class.__name__}_with_{display.__class__.__name__}",
|
|
||||||
display=display.__class__.__name__,
|
|
||||||
effect=effect_class.__name__,
|
|
||||||
iterations=iterations,
|
|
||||||
total_time_ms=sum(times),
|
|
||||||
avg_time_ms=float(np.mean(times_arr)),
|
|
||||||
std_dev_ms=float(np.std(times_arr)),
|
|
||||||
min_ms=float(np.min(times_arr)),
|
|
||||||
max_ms=float(np.max(times_arr)),
|
|
||||||
fps=float(1000.0 / np.mean(times_arr)) if np.mean(times_arr) > 0 else 0.0,
|
|
||||||
chars_processed=chars * iterations,
|
|
||||||
chars_per_sec=float((chars * iterations) / (sum(times) / 1000))
|
|
||||||
if sum(times) > 0
|
|
||||||
else 0.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_available_displays():
|
|
||||||
"""Get available display classes."""
|
|
||||||
from engine.display import (
|
|
||||||
DisplayRegistry,
|
|
||||||
NullDisplay,
|
|
||||||
TerminalDisplay,
|
|
||||||
)
|
|
||||||
|
|
||||||
DisplayRegistry.initialize()
|
|
||||||
|
|
||||||
displays = [
|
|
||||||
("null", NullDisplay),
|
|
||||||
("terminal", TerminalDisplay),
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
from engine.display.backends.websocket import WebSocketDisplay
|
|
||||||
|
|
||||||
displays.append(("websocket", WebSocketDisplay))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
from engine.display.backends.sixel import SixelDisplay
|
|
||||||
|
|
||||||
displays.append(("sixel", SixelDisplay))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return displays
|
|
||||||
|
|
||||||
|
|
||||||
def get_available_effects():
|
|
||||||
"""Get available effect classes."""
|
|
||||||
try:
|
|
||||||
from engine.effects import get_registry
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
effects = []
|
|
||||||
registry = get_registry()
|
|
||||||
|
|
||||||
for name, effect in registry.list_all().items():
|
|
||||||
if effect:
|
|
||||||
effects.append((name, effect))
|
|
||||||
|
|
||||||
return effects
|
|
||||||
|
|
||||||
|
|
||||||
def run_benchmarks(
|
|
||||||
displays: list[tuple[str, Any]] | None = None,
|
|
||||||
effects: list[tuple[str, Any]] | None = None,
|
|
||||||
iterations: int = 100,
|
|
||||||
verbose: bool = False,
|
|
||||||
) -> BenchmarkReport:
|
|
||||||
"""Run all benchmarks and return report."""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
if displays is None:
|
|
||||||
displays = get_available_displays()
|
|
||||||
|
|
||||||
if effects is None:
|
|
||||||
effects = get_available_effects()
|
|
||||||
|
|
||||||
buffer = get_sample_buffer(80, 24)
|
|
||||||
results = []
|
|
||||||
|
|
||||||
if verbose:
|
|
||||||
print(f"Running benchmarks ({iterations} iterations each)...")
|
|
||||||
|
|
||||||
for name, display_class in displays:
|
|
||||||
if verbose:
|
|
||||||
print(f"Benchmarking display: {name}")
|
|
||||||
|
|
||||||
result = benchmark_display(display_class, buffer, iterations)
|
|
||||||
if result:
|
|
||||||
results.append(result)
|
|
||||||
if verbose:
|
|
||||||
print(f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg")
|
|
||||||
|
|
||||||
if verbose:
|
|
||||||
print()
|
|
||||||
|
|
||||||
for effect_name, effect_class in effects:
|
|
||||||
for display_name, display_class in displays:
|
|
||||||
if display_name == "websocket":
|
|
||||||
continue
|
|
||||||
if verbose:
|
|
||||||
print(f"Benchmarking effect: {effect_name} with {display_name}")
|
|
||||||
|
|
||||||
display = display_class()
|
|
||||||
display.init(80, 24)
|
|
||||||
result = benchmark_effect_with_display(
|
|
||||||
effect_class, display, buffer, iterations
|
|
||||||
)
|
|
||||||
if result:
|
|
||||||
results.append(result)
|
|
||||||
if verbose:
|
|
||||||
print(f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg")
|
|
||||||
|
|
||||||
summary = generate_summary(results)
|
|
||||||
|
|
||||||
return BenchmarkReport(
|
|
||||||
timestamp=datetime.now().isoformat(),
|
|
||||||
python_version=sys.version,
|
|
||||||
results=results,
|
|
||||||
summary=summary,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_summary(results: list[BenchmarkResult]) -> dict[str, Any]:
|
|
||||||
"""Generate summary statistics from results."""
|
|
||||||
by_display: dict[str, list[BenchmarkResult]] = {}
|
|
||||||
by_effect: dict[str, list[BenchmarkResult]] = {}
|
|
||||||
|
|
||||||
for r in results:
|
|
||||||
if r.display not in by_display:
|
|
||||||
by_display[r.display] = []
|
|
||||||
by_display[r.display].append(r)
|
|
||||||
|
|
||||||
if r.effect:
|
|
||||||
if r.effect not in by_effect:
|
|
||||||
by_effect[r.effect] = []
|
|
||||||
by_effect[r.effect].append(r)
|
|
||||||
|
|
||||||
summary = {
|
|
||||||
"by_display": {},
|
|
||||||
"by_effect": {},
|
|
||||||
"overall": {
|
|
||||||
"total_tests": len(results),
|
|
||||||
"displays_tested": len(by_display),
|
|
||||||
"effects_tested": len(by_effect),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for display, res in by_display.items():
|
|
||||||
fps_values = [r.fps for r in res]
|
|
||||||
summary["by_display"][display] = {
|
|
||||||
"avg_fps": float(np.mean(fps_values)),
|
|
||||||
"min_fps": float(np.min(fps_values)),
|
|
||||||
"max_fps": float(np.max(fps_values)),
|
|
||||||
"tests": len(res),
|
|
||||||
}
|
|
||||||
|
|
||||||
for effect, res in by_effect.items():
|
|
||||||
fps_values = [r.fps for r in res]
|
|
||||||
summary["by_effect"][effect] = {
|
|
||||||
"avg_fps": float(np.mean(fps_values)),
|
|
||||||
"min_fps": float(np.min(fps_values)),
|
|
||||||
"max_fps": float(np.max(fps_values)),
|
|
||||||
"tests": len(res),
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CACHE_PATH = Path.home() / ".mainline_benchmark_cache.json"
|
|
||||||
|
|
||||||
|
|
||||||
def load_baseline(cache_path: Path | None = None) -> dict[str, Any] | None:
|
|
||||||
"""Load baseline benchmark results from cache."""
|
|
||||||
path = cache_path or DEFAULT_CACHE_PATH
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with open(path) as f:
|
|
||||||
return json.load(f)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def save_baseline(
|
|
||||||
results: list[BenchmarkResult],
|
|
||||||
cache_path: Path | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Save benchmark results as baseline to cache."""
|
|
||||||
path = cache_path or DEFAULT_CACHE_PATH
|
|
||||||
baseline = {
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"results": {
|
|
||||||
r.name: {
|
|
||||||
"fps": r.fps,
|
|
||||||
"avg_time_ms": r.avg_time_ms,
|
|
||||||
"chars_per_sec": r.chars_per_sec,
|
|
||||||
}
|
|
||||||
for r in results
|
|
||||||
},
|
|
||||||
}
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(path, "w") as f:
|
|
||||||
json.dump(baseline, f, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
def compare_with_baseline(
|
|
||||||
results: list[BenchmarkResult],
|
|
||||||
baseline: dict[str, Any],
|
|
||||||
threshold: float = 0.2,
|
|
||||||
verbose: bool = True,
|
|
||||||
) -> tuple[bool, list[str]]:
|
|
||||||
"""Compare current results with baseline. Returns (pass, messages)."""
|
|
||||||
baseline_results = baseline.get("results", {})
|
|
||||||
failures = []
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
for r in results:
|
|
||||||
if r.name not in baseline_results:
|
|
||||||
warnings.append(f"New test: {r.name} (no baseline)")
|
|
||||||
continue
|
|
||||||
|
|
||||||
b = baseline_results[r.name]
|
|
||||||
if b["fps"] == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
degradation = (b["fps"] - r.fps) / b["fps"]
|
|
||||||
if degradation > threshold:
|
|
||||||
failures.append(
|
|
||||||
f"{r.name}: FPS degraded {degradation * 100:.1f}% "
|
|
||||||
f"(baseline: {b['fps']:.1f}, current: {r.fps:.1f})"
|
|
||||||
)
|
|
||||||
elif verbose:
|
|
||||||
print(f" {r.name}: {r.fps:.1f} FPS (baseline: {b['fps']:.1f})")
|
|
||||||
|
|
||||||
passed = len(failures) == 0
|
|
||||||
messages = []
|
|
||||||
if failures:
|
|
||||||
messages.extend(failures)
|
|
||||||
if warnings:
|
|
||||||
messages.extend(warnings)
|
|
||||||
|
|
||||||
return passed, messages
|
|
||||||
|
|
||||||
|
|
||||||
def run_hook_mode(
|
|
||||||
displays: list[tuple[str, Any]] | None = None,
|
|
||||||
effects: list[tuple[str, Any]] | None = None,
|
|
||||||
iterations: int = 20,
|
|
||||||
threshold: float = 0.2,
|
|
||||||
cache_path: Path | None = None,
|
|
||||||
verbose: bool = False,
|
|
||||||
) -> int:
|
|
||||||
"""Run in hook mode: compare against baseline, exit 0 on pass, 1 on fail."""
|
|
||||||
baseline = load_baseline(cache_path)
|
|
||||||
|
|
||||||
if baseline is None:
|
|
||||||
print("No baseline found. Run with --baseline to create one.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
report = run_benchmarks(displays, effects, iterations, verbose)
|
|
||||||
|
|
||||||
passed, messages = compare_with_baseline(
|
|
||||||
report.results, baseline, threshold, verbose
|
|
||||||
)
|
|
||||||
|
|
||||||
print("\n=== Benchmark Hook Results ===")
|
|
||||||
if passed:
|
|
||||||
print("PASSED - No significant performance degradation")
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
print("FAILED - Performance degradation detected:")
|
|
||||||
for msg in messages:
|
|
||||||
print(f" - {msg}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def format_report_text(report: BenchmarkReport) -> str:
|
|
||||||
"""Format report as human-readable text."""
|
|
||||||
lines = [
|
|
||||||
"# Mainline Performance Benchmark Report",
|
|
||||||
"",
|
|
||||||
f"Generated: {report.timestamp}",
|
|
||||||
f"Python: {report.python_version}",
|
|
||||||
"",
|
|
||||||
"## Summary",
|
|
||||||
"",
|
|
||||||
f"Total tests: {report.summary['overall']['total_tests']}",
|
|
||||||
f"Displays tested: {report.summary['overall']['displays_tested']}",
|
|
||||||
f"Effects tested: {report.summary['overall']['effects_tested']}",
|
|
||||||
"",
|
|
||||||
"## By Display",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
|
|
||||||
for display, stats in report.summary["by_display"].items():
|
|
||||||
lines.append(f"### {display}")
|
|
||||||
lines.append(f"- Avg FPS: {stats['avg_fps']:.1f}")
|
|
||||||
lines.append(f"- Min FPS: {stats['min_fps']:.1f}")
|
|
||||||
lines.append(f"- Max FPS: {stats['max_fps']:.1f}")
|
|
||||||
lines.append(f"- Tests: {stats['tests']}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if report.summary["by_effect"]:
|
|
||||||
lines.append("## By Effect")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
for effect, stats in report.summary["by_effect"].items():
|
|
||||||
lines.append(f"### {effect}")
|
|
||||||
lines.append(f"- Avg FPS: {stats['avg_fps']:.1f}")
|
|
||||||
lines.append(f"- Min FPS: {stats['min_fps']:.1f}")
|
|
||||||
lines.append(f"- Max FPS: {stats['max_fps']:.1f}")
|
|
||||||
lines.append(f"- Tests: {stats['tests']}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("## Detailed Results")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("| Display | Effect | FPS | Avg ms | StdDev ms | Min ms | Max ms |")
|
|
||||||
lines.append("|---------|--------|-----|--------|-----------|--------|--------|")
|
|
||||||
|
|
||||||
for r in report.results:
|
|
||||||
effect_col = r.effect if r.effect else "-"
|
|
||||||
lines.append(
|
|
||||||
f"| {r.display} | {effect_col} | {r.fps:.1f} | {r.avg_time_ms:.2f} | "
|
|
||||||
f"{r.std_dev_ms:.2f} | {r.min_ms:.2f} | {r.max_ms:.2f} |"
|
|
||||||
)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def format_report_json(report: BenchmarkReport) -> str:
|
|
||||||
"""Format report as JSON."""
|
|
||||||
data = {
|
|
||||||
"timestamp": report.timestamp,
|
|
||||||
"python_version": report.python_version,
|
|
||||||
"summary": report.summary,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"name": r.name,
|
|
||||||
"display": r.display,
|
|
||||||
"effect": r.effect,
|
|
||||||
"iterations": r.iterations,
|
|
||||||
"total_time_ms": r.total_time_ms,
|
|
||||||
"avg_time_ms": r.avg_time_ms,
|
|
||||||
"std_dev_ms": r.std_dev_ms,
|
|
||||||
"min_ms": r.min_ms,
|
|
||||||
"max_ms": r.max_ms,
|
|
||||||
"fps": r.fps,
|
|
||||||
"chars_processed": r.chars_processed,
|
|
||||||
"chars_per_sec": r.chars_per_sec,
|
|
||||||
}
|
|
||||||
for r in report.results
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return json.dumps(data, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Run mainline benchmarks")
|
|
||||||
parser.add_argument(
|
|
||||||
"--displays",
|
|
||||||
help="Comma-separated list of displays to test (default: all)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--effects",
|
|
||||||
help="Comma-separated list of effects to test (default: all)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--iterations",
|
|
||||||
type=int,
|
|
||||||
default=100,
|
|
||||||
help="Number of iterations per test (default: 100)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--output",
|
|
||||||
help="Output file path (default: stdout)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--format",
|
|
||||||
choices=["text", "json"],
|
|
||||||
default="text",
|
|
||||||
help="Output format (default: text)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--verbose",
|
|
||||||
"-v",
|
|
||||||
action="store_true",
|
|
||||||
help="Show progress during benchmarking",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--hook",
|
|
||||||
action="store_true",
|
|
||||||
help="Run in hook mode: compare against baseline, exit 0 pass, 1 fail",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--baseline",
|
|
||||||
action="store_true",
|
|
||||||
help="Save current results as baseline for future hook comparisons",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--threshold",
|
|
||||||
type=float,
|
|
||||||
default=0.2,
|
|
||||||
help="Performance degradation threshold for hook mode (default: 0.2 = 20%%)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--cache",
|
|
||||||
type=str,
|
|
||||||
default=None,
|
|
||||||
help="Path to baseline cache file (default: ~/.mainline_benchmark_cache.json)",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
cache_path = Path(args.cache) if args.cache else DEFAULT_CACHE_PATH
|
|
||||||
|
|
||||||
if args.hook:
|
|
||||||
displays = None
|
|
||||||
if args.displays:
|
|
||||||
display_map = dict(get_available_displays())
|
|
||||||
displays = [
|
|
||||||
(name, display_map[name])
|
|
||||||
for name in args.displays.split(",")
|
|
||||||
if name in display_map
|
|
||||||
]
|
|
||||||
|
|
||||||
effects = None
|
|
||||||
if args.effects:
|
|
||||||
effect_map = dict(get_available_effects())
|
|
||||||
effects = [
|
|
||||||
(name, effect_map[name])
|
|
||||||
for name in args.effects.split(",")
|
|
||||||
if name in effect_map
|
|
||||||
]
|
|
||||||
|
|
||||||
return run_hook_mode(
|
|
||||||
displays,
|
|
||||||
effects,
|
|
||||||
iterations=args.iterations,
|
|
||||||
threshold=args.threshold,
|
|
||||||
cache_path=cache_path,
|
|
||||||
verbose=args.verbose,
|
|
||||||
)
|
|
||||||
|
|
||||||
displays = None
|
|
||||||
if args.displays:
|
|
||||||
display_map = dict(get_available_displays())
|
|
||||||
displays = [
|
|
||||||
(name, display_map[name])
|
|
||||||
for name in args.displays.split(",")
|
|
||||||
if name in display_map
|
|
||||||
]
|
|
||||||
|
|
||||||
effects = None
|
|
||||||
if args.effects:
|
|
||||||
effect_map = dict(get_available_effects())
|
|
||||||
effects = [
|
|
||||||
(name, effect_map[name])
|
|
||||||
for name in args.effects.split(",")
|
|
||||||
if name in effect_map
|
|
||||||
]
|
|
||||||
|
|
||||||
report = run_benchmarks(displays, effects, args.iterations, args.verbose)
|
|
||||||
|
|
||||||
if args.baseline:
|
|
||||||
save_baseline(report.results, cache_path)
|
|
||||||
print(f"Baseline saved to {cache_path}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.format == "json":
|
|
||||||
output = format_report_json(report)
|
|
||||||
else:
|
|
||||||
output = format_report_text(report)
|
|
||||||
|
|
||||||
if args.output:
|
|
||||||
with open(args.output, "w") as f:
|
|
||||||
f.write(output)
|
|
||||||
else:
|
|
||||||
print(output)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
161
engine/config.py
161
engine/config.py
@@ -1,28 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
Configuration constants, CLI flags, and glyph tables.
|
Configuration constants, CLI flags, and glyph tables.
|
||||||
Supports both global constants (backward compatible) and injected config for testing.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"}
|
_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."""
|
"""Get value following a CLI flag, if present."""
|
||||||
argv = argv or sys.argv
|
if flag not in sys.argv:
|
||||||
if flag not in argv:
|
|
||||||
return None
|
return None
|
||||||
i = argv.index(flag)
|
i = sys.argv.index(flag)
|
||||||
return argv[i + 1] if i + 1 < len(argv) else None
|
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."""
|
"""Get int CLI argument with safe fallback."""
|
||||||
raw = _arg_value(flag, argv)
|
raw = _arg_value(flag)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return default
|
return default
|
||||||
try:
|
try:
|
||||||
@@ -56,145 +53,6 @@ def list_repo_font_files():
|
|||||||
return _list_font_files(FONT_DIR)
|
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_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
|
||||||
ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/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)
|
|
||||||
|
|
||||||
display: str = "terminal"
|
|
||||||
websocket: bool = False
|
|
||||||
websocket_port: int = 8765
|
|
||||||
|
|
||||||
@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_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json",
|
|
||||||
ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/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(),
|
|
||||||
display=_arg_value("--display", argv) or "terminal",
|
|
||||||
websocket="--websocket" in argv,
|
|
||||||
websocket_port=_arg_int("--websocket-port", 8765, argv),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_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 ──────────────────────────────────────────────
|
# ─── RUNTIME ──────────────────────────────────────────────
|
||||||
HEADLINE_LIMIT = 1000
|
HEADLINE_LIMIT = 1000
|
||||||
FEED_TIMEOUT = 10
|
FEED_TIMEOUT = 10
|
||||||
@@ -204,8 +62,6 @@ FIREHOSE = "--firehose" in sys.argv
|
|||||||
|
|
||||||
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
||||||
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
NTFY_CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
|
||||||
NTFY_CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
|
||||||
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
||||||
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||||
|
|
||||||
@@ -236,11 +92,6 @@ GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
|||||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||||
|
|
||||||
# ─── WEBSOCKET ─────────────────────────────────────────────
|
|
||||||
DISPLAY = _arg_value("--display", sys.argv) or "terminal"
|
|
||||||
WEBSOCKET = "--websocket" in sys.argv
|
|
||||||
WEBSOCKET_PORT = _arg_int("--websocket-port", 8765)
|
|
||||||
|
|
||||||
|
|
||||||
def set_font_selection(font_path=None, font_index=None):
|
def set_font_selection(font_path=None, font_index=None):
|
||||||
"""Set runtime primary font selection."""
|
"""Set runtime primary font selection."""
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
"""
|
|
||||||
Stream controller - manages input sources and orchestrates the render stream.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from engine.config import Config, get_config
|
|
||||||
from engine.display import (
|
|
||||||
DisplayRegistry,
|
|
||||||
MultiDisplay,
|
|
||||||
NullDisplay,
|
|
||||||
SixelDisplay,
|
|
||||||
TerminalDisplay,
|
|
||||||
WebSocketDisplay,
|
|
||||||
)
|
|
||||||
from engine.effects.controller import handle_effects_command
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _get_display(config: Config):
|
|
||||||
"""Get the appropriate display based on config."""
|
|
||||||
DisplayRegistry.initialize()
|
|
||||||
display_mode = config.display.lower()
|
|
||||||
|
|
||||||
displays = []
|
|
||||||
|
|
||||||
if display_mode in ("terminal", "both"):
|
|
||||||
displays.append(TerminalDisplay())
|
|
||||||
|
|
||||||
if display_mode in ("websocket", "both"):
|
|
||||||
ws = WebSocketDisplay(host="0.0.0.0", port=config.websocket_port)
|
|
||||||
ws.start_server()
|
|
||||||
ws.start_http_server()
|
|
||||||
displays.append(ws)
|
|
||||||
|
|
||||||
if display_mode == "sixel":
|
|
||||||
displays.append(SixelDisplay())
|
|
||||||
|
|
||||||
if not displays:
|
|
||||||
return NullDisplay()
|
|
||||||
|
|
||||||
if len(displays) == 1:
|
|
||||||
return displays[0]
|
|
||||||
|
|
||||||
return MultiDisplay(displays)
|
|
||||||
|
|
||||||
|
|
||||||
class StreamController:
|
|
||||||
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
|
||||||
|
|
||||||
_topics_warmed = False
|
|
||||||
|
|
||||||
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
|
|
||||||
self.ntfy_cc: NtfyPoller | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def warmup_topics(cls) -> None:
|
|
||||||
"""Warm up ntfy topics lazily (creates them if they don't exist)."""
|
|
||||||
if cls._topics_warmed:
|
|
||||||
return
|
|
||||||
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
topics = [
|
|
||||||
"https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd",
|
|
||||||
"https://ntfy.sh/klubhaus_terminal_mainline_cc_resp",
|
|
||||||
"https://ntfy.sh/klubhaus_terminal_mainline",
|
|
||||||
]
|
|
||||||
|
|
||||||
for topic in topics:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(
|
|
||||||
topic,
|
|
||||||
data=b"init",
|
|
||||||
headers={
|
|
||||||
"User-Agent": "mainline/0.1",
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
urllib.request.urlopen(req, timeout=5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
cls._topics_warmed = True
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
self.ntfy_cc = NtfyPoller(
|
|
||||||
self.config.ntfy_cc_cmd_topic,
|
|
||||||
reconnect_delay=self.config.ntfy_reconnect_delay,
|
|
||||||
display_secs=5,
|
|
||||||
)
|
|
||||||
self.ntfy_cc.subscribe(self._handle_cc_message)
|
|
||||||
ntfy_cc_ok = self.ntfy_cc.start()
|
|
||||||
|
|
||||||
return bool(mic_ok), ntfy_ok and ntfy_cc_ok
|
|
||||||
|
|
||||||
def _handle_cc_message(self, event) -> None:
|
|
||||||
"""Handle incoming C&C message - like a serial port control interface."""
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
cmd = event.body.strip() if hasattr(event, "body") else str(event).strip()
|
|
||||||
if not cmd.startswith("/"):
|
|
||||||
return
|
|
||||||
|
|
||||||
response = handle_effects_command(cmd)
|
|
||||||
|
|
||||||
topic_url = self.config.ntfy_cc_resp_topic.replace("/json", "")
|
|
||||||
data = response.encode("utf-8")
|
|
||||||
req = urllib.request.Request(
|
|
||||||
topic_url,
|
|
||||||
data=data,
|
|
||||||
headers={"User-Agent": "mainline/0.1", "Content-Type": "text/plain"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
urllib.request.urlopen(req, timeout=5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
display = _get_display(self.config)
|
|
||||||
stream(items, self.ntfy, self.mic, display)
|
|
||||||
if display:
|
|
||||||
display.cleanup()
|
|
||||||
|
|
||||||
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 backend system with registry pattern.
|
|
||||||
|
|
||||||
Allows swapping output backends via the Display protocol.
|
|
||||||
Supports auto-discovery of display backends.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
from engine.display.backends.multi import MultiDisplay
|
|
||||||
from engine.display.backends.null import NullDisplay
|
|
||||||
from engine.display.backends.sixel import SixelDisplay
|
|
||||||
from engine.display.backends.terminal import TerminalDisplay
|
|
||||||
from engine.display.backends.websocket import WebSocketDisplay
|
|
||||||
|
|
||||||
|
|
||||||
class Display(Protocol):
|
|
||||||
"""Protocol for display backends."""
|
|
||||||
|
|
||||||
width: int
|
|
||||||
height: int
|
|
||||||
|
|
||||||
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."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayRegistry:
|
|
||||||
"""Registry for display backends with auto-discovery."""
|
|
||||||
|
|
||||||
_backends: dict[str, type[Display]] = {}
|
|
||||||
_initialized = False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def register(cls, name: str, backend_class: type[Display]) -> None:
|
|
||||||
"""Register a display backend."""
|
|
||||||
cls._backends[name.lower()] = backend_class
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get(cls, name: str) -> type[Display] | None:
|
|
||||||
"""Get a display backend class by name."""
|
|
||||||
return cls._backends.get(name.lower())
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def list_backends(cls) -> list[str]:
|
|
||||||
"""List all available display backend names."""
|
|
||||||
return list(cls._backends.keys())
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(cls, name: str, **kwargs) -> Display | None:
|
|
||||||
"""Create a display instance by name."""
|
|
||||||
backend_class = cls.get(name)
|
|
||||||
if backend_class:
|
|
||||||
return backend_class(**kwargs)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def initialize(cls) -> None:
|
|
||||||
"""Initialize and register all built-in backends."""
|
|
||||||
if cls._initialized:
|
|
||||||
return
|
|
||||||
|
|
||||||
cls.register("terminal", TerminalDisplay)
|
|
||||||
cls.register("null", NullDisplay)
|
|
||||||
cls.register("websocket", WebSocketDisplay)
|
|
||||||
cls.register("sixel", SixelDisplay)
|
|
||||||
|
|
||||||
cls._initialized = True
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Display",
|
|
||||||
"DisplayRegistry",
|
|
||||||
"get_monitor",
|
|
||||||
"TerminalDisplay",
|
|
||||||
"NullDisplay",
|
|
||||||
"WebSocketDisplay",
|
|
||||||
"SixelDisplay",
|
|
||||||
"MultiDisplay",
|
|
||||||
]
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""
|
|
||||||
Multi display backend - forwards to multiple displays.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class MultiDisplay:
|
|
||||||
"""Display that forwards to multiple displays."""
|
|
||||||
|
|
||||||
width: int = 80
|
|
||||||
height: int = 24
|
|
||||||
|
|
||||||
def __init__(self, displays: list):
|
|
||||||
self.displays = displays
|
|
||||||
self.width = 80
|
|
||||||
self.height = 24
|
|
||||||
|
|
||||||
def init(self, width: int, height: int) -> None:
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
for d in self.displays:
|
|
||||||
d.init(width, height)
|
|
||||||
|
|
||||||
def show(self, buffer: list[str]) -> None:
|
|
||||||
for d in self.displays:
|
|
||||||
d.show(buffer)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
for d in self.displays:
|
|
||||||
d.clear()
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
for d in self.displays:
|
|
||||||
d.cleanup()
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""
|
|
||||||
Null/headless display backend.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class NullDisplay:
|
|
||||||
"""Headless/null display - discards all output."""
|
|
||||||
|
|
||||||
width: int = 80
|
|
||||||
height: int = 24
|
|
||||||
|
|
||||||
def init(self, width: int, height: int) -> None:
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
|
|
||||||
def show(self, buffer: list[str]) -> None:
|
|
||||||
from engine.display import get_monitor
|
|
||||||
|
|
||||||
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,269 +0,0 @@
|
|||||||
"""
|
|
||||||
Sixel graphics display backend - renders to sixel graphics in terminal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_ansi(
|
|
||||||
text: str,
|
|
||||||
) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int], bool]]:
|
|
||||||
"""Parse ANSI text into tokens with fg/bg colors.
|
|
||||||
|
|
||||||
Returns list of (text, fg_rgb, bg_rgb, bold).
|
|
||||||
"""
|
|
||||||
tokens = []
|
|
||||||
current_text = ""
|
|
||||||
fg = (204, 204, 204)
|
|
||||||
bg = (0, 0, 0)
|
|
||||||
bold = False
|
|
||||||
i = 0
|
|
||||||
|
|
||||||
ANSI_COLORS = {
|
|
||||||
0: (0, 0, 0),
|
|
||||||
1: (205, 49, 49),
|
|
||||||
2: (13, 188, 121),
|
|
||||||
3: (229, 229, 16),
|
|
||||||
4: (36, 114, 200),
|
|
||||||
5: (188, 63, 188),
|
|
||||||
6: (17, 168, 205),
|
|
||||||
7: (229, 229, 229),
|
|
||||||
8: (102, 102, 102),
|
|
||||||
9: (241, 76, 76),
|
|
||||||
10: (35, 209, 139),
|
|
||||||
11: (245, 245, 67),
|
|
||||||
12: (59, 142, 234),
|
|
||||||
13: (214, 112, 214),
|
|
||||||
14: (41, 184, 219),
|
|
||||||
15: (255, 255, 255),
|
|
||||||
}
|
|
||||||
|
|
||||||
while i < len(text):
|
|
||||||
char = text[i]
|
|
||||||
|
|
||||||
if char == "\x1b" and i + 1 < len(text) and text[i + 1] == "[":
|
|
||||||
if current_text:
|
|
||||||
tokens.append((current_text, fg, bg, bold))
|
|
||||||
current_text = ""
|
|
||||||
|
|
||||||
i += 2
|
|
||||||
code = ""
|
|
||||||
while i < len(text):
|
|
||||||
c = text[i]
|
|
||||||
if c.isalpha():
|
|
||||||
break
|
|
||||||
code += c
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if code:
|
|
||||||
codes = code.split(";")
|
|
||||||
for c in codes:
|
|
||||||
try:
|
|
||||||
n = int(c) if c else 0
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if n == 0:
|
|
||||||
fg = (204, 204, 204)
|
|
||||||
bg = (0, 0, 0)
|
|
||||||
bold = False
|
|
||||||
elif n == 1:
|
|
||||||
bold = True
|
|
||||||
elif n == 22:
|
|
||||||
bold = False
|
|
||||||
elif n == 39:
|
|
||||||
fg = (204, 204, 204)
|
|
||||||
elif n == 49:
|
|
||||||
bg = (0, 0, 0)
|
|
||||||
elif 30 <= n <= 37:
|
|
||||||
fg = ANSI_COLORS.get(n - 30 + (8 if bold else 0), fg)
|
|
||||||
elif 40 <= n <= 47:
|
|
||||||
bg = ANSI_COLORS.get(n - 40, bg)
|
|
||||||
elif 90 <= n <= 97:
|
|
||||||
fg = ANSI_COLORS.get(n - 90 + 8, fg)
|
|
||||||
elif 100 <= n <= 107:
|
|
||||||
bg = ANSI_COLORS.get(n - 100 + 8, bg)
|
|
||||||
elif 1 <= n <= 256:
|
|
||||||
if n < 16:
|
|
||||||
fg = ANSI_COLORS.get(n, fg)
|
|
||||||
elif n < 232:
|
|
||||||
c = n - 16
|
|
||||||
r = (c // 36) * 51
|
|
||||||
g = ((c % 36) // 6) * 51
|
|
||||||
b = (c % 6) * 51
|
|
||||||
fg = (r, g, b)
|
|
||||||
else:
|
|
||||||
gray = (n - 232) * 10 + 8
|
|
||||||
fg = (gray, gray, gray)
|
|
||||||
else:
|
|
||||||
current_text += char
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if current_text:
|
|
||||||
tokens.append((current_text, fg, bg, bold))
|
|
||||||
|
|
||||||
return tokens if tokens else [("", fg, bg, bold)]
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_sixel(image) -> str:
|
|
||||||
"""Encode a PIL Image to sixel format (pure Python)."""
|
|
||||||
img = image.convert("RGBA")
|
|
||||||
width, height = img.size
|
|
||||||
pixels = img.load()
|
|
||||||
|
|
||||||
palette = []
|
|
||||||
pixel_palette_idx = {}
|
|
||||||
|
|
||||||
def get_color_idx(r, g, b, a):
|
|
||||||
if a < 128:
|
|
||||||
return -1
|
|
||||||
key = (r // 32, g // 32, b // 32)
|
|
||||||
if key not in pixel_palette_idx:
|
|
||||||
idx = len(palette)
|
|
||||||
if idx < 256:
|
|
||||||
palette.append((r, g, b))
|
|
||||||
pixel_palette_idx[key] = idx
|
|
||||||
return pixel_palette_idx.get(key, 0)
|
|
||||||
|
|
||||||
for y in range(height):
|
|
||||||
for x in range(width):
|
|
||||||
r, g, b, a = pixels[x, y]
|
|
||||||
get_color_idx(r, g, b, a)
|
|
||||||
|
|
||||||
if not palette:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
if len(palette) == 1:
|
|
||||||
palette = [palette[0], (0, 0, 0)]
|
|
||||||
|
|
||||||
sixel_data = []
|
|
||||||
sixel_data.append(
|
|
||||||
f'"{"".join(f"#{i};2;{r};{g};{b}" for i, (r, g, b) in enumerate(palette))}'
|
|
||||||
)
|
|
||||||
|
|
||||||
for x in range(width):
|
|
||||||
col_data = []
|
|
||||||
for y in range(0, height, 6):
|
|
||||||
bits = 0
|
|
||||||
color_idx = -1
|
|
||||||
for dy in range(6):
|
|
||||||
if y + dy < height:
|
|
||||||
r, g, b, a = pixels[x, y + dy]
|
|
||||||
if a >= 128:
|
|
||||||
bits |= 1 << dy
|
|
||||||
idx = get_color_idx(r, g, b, a)
|
|
||||||
if color_idx == -1:
|
|
||||||
color_idx = idx
|
|
||||||
elif color_idx != idx:
|
|
||||||
color_idx = -2
|
|
||||||
|
|
||||||
if color_idx >= 0:
|
|
||||||
col_data.append(
|
|
||||||
chr(63 + color_idx) + chr(63 + bits)
|
|
||||||
if bits
|
|
||||||
else chr(63 + color_idx) + "?"
|
|
||||||
)
|
|
||||||
elif color_idx == -2:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if col_data:
|
|
||||||
sixel_data.append("".join(col_data) + "$")
|
|
||||||
else:
|
|
||||||
sixel_data.append("-" if x < width - 1 else "$")
|
|
||||||
|
|
||||||
sixel_data.append("\x1b\\")
|
|
||||||
|
|
||||||
return "\x1bPq" + "".join(sixel_data)
|
|
||||||
|
|
||||||
|
|
||||||
class SixelDisplay:
|
|
||||||
"""Sixel graphics display backend - renders to sixel graphics in terminal."""
|
|
||||||
|
|
||||||
width: int = 80
|
|
||||||
height: int = 24
|
|
||||||
|
|
||||||
def __init__(self, cell_width: int = 9, cell_height: int = 16):
|
|
||||||
self.width = 80
|
|
||||||
self.height = 24
|
|
||||||
self.cell_width = cell_width
|
|
||||||
self.cell_height = cell_height
|
|
||||||
self._initialized = False
|
|
||||||
|
|
||||||
def init(self, width: int, height: int) -> None:
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self._initialized = True
|
|
||||||
|
|
||||||
def show(self, buffer: list[str]) -> None:
|
|
||||||
import sys
|
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
|
|
||||||
img_width = self.width * self.cell_width
|
|
||||||
img_height = self.height * self.cell_height
|
|
||||||
|
|
||||||
try:
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
except ImportError:
|
|
||||||
return
|
|
||||||
|
|
||||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
try:
|
|
||||||
font = ImageFont.truetype(
|
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
|
||||||
self.cell_height - 2,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
except Exception:
|
|
||||||
font = None
|
|
||||||
|
|
||||||
for row_idx, line in enumerate(buffer[: self.height]):
|
|
||||||
if row_idx >= self.height:
|
|
||||||
break
|
|
||||||
|
|
||||||
tokens = _parse_ansi(line)
|
|
||||||
x_pos = 0
|
|
||||||
y_pos = row_idx * self.cell_height
|
|
||||||
|
|
||||||
for text, fg, bg, bold in tokens:
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if bg != (0, 0, 0):
|
|
||||||
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
|
||||||
draw.rectangle(bbox, fill=(*bg, 255))
|
|
||||||
|
|
||||||
if bold and font:
|
|
||||||
draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font)
|
|
||||||
|
|
||||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
|
||||||
|
|
||||||
if font:
|
|
||||||
x_pos += draw.textlength(text, font=font)
|
|
||||||
|
|
||||||
sixel = _encode_sixel(img)
|
|
||||||
|
|
||||||
sys.stdout.buffer.write(sixel.encode("utf-8"))
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
|
|
||||||
from engine.display import get_monitor
|
|
||||||
|
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
|
||||||
chars_in = sum(len(line) for line in buffer)
|
|
||||||
monitor.record_effect("sixel_display", elapsed_ms, chars_in, chars_in)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.stdout.buffer.write(b"\x1b[2J\x1b[H")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
pass
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""
|
|
||||||
ANSI terminal display backend.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class TerminalDisplay:
|
|
||||||
"""ANSI terminal display backend."""
|
|
||||||
|
|
||||||
width: int = 80
|
|
||||||
height: int = 24
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
from engine.display import get_monitor
|
|
||||||
|
|
||||||
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)
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
"""
|
|
||||||
WebSocket display backend - broadcasts frame buffer to connected web clients.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
try:
|
|
||||||
import websockets
|
|
||||||
except ImportError:
|
|
||||||
websockets = None
|
|
||||||
|
|
||||||
|
|
||||||
class Display(Protocol):
|
|
||||||
"""Protocol for display backends."""
|
|
||||||
|
|
||||||
width: int
|
|
||||||
height: int
|
|
||||||
|
|
||||||
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 WebSocketDisplay:
|
|
||||||
"""WebSocket display backend - broadcasts to HTML Canvas clients."""
|
|
||||||
|
|
||||||
width: int = 80
|
|
||||||
height: int = 24
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
host: str = "0.0.0.0",
|
|
||||||
port: int = 8765,
|
|
||||||
http_port: int = 8766,
|
|
||||||
):
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.http_port = http_port
|
|
||||||
self.width = 80
|
|
||||||
self.height = 24
|
|
||||||
self._clients: set = set()
|
|
||||||
self._server_running = False
|
|
||||||
self._http_running = False
|
|
||||||
self._server_thread: threading.Thread | None = None
|
|
||||||
self._http_thread: threading.Thread | None = None
|
|
||||||
self._available = True
|
|
||||||
self._max_clients = 10
|
|
||||||
self._client_connected_callback = None
|
|
||||||
self._client_disconnected_callback = None
|
|
||||||
self._frame_delay = 0.0
|
|
||||||
|
|
||||||
try:
|
|
||||||
import websockets as _ws
|
|
||||||
|
|
||||||
self._available = _ws is not None
|
|
||||||
except ImportError:
|
|
||||||
self._available = False
|
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
|
||||||
"""Check if WebSocket support is available."""
|
|
||||||
return self._available
|
|
||||||
|
|
||||||
def init(self, width: int, height: int) -> None:
|
|
||||||
"""Initialize display with dimensions and start server."""
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.start_server()
|
|
||||||
self.start_http_server()
|
|
||||||
|
|
||||||
def show(self, buffer: list[str]) -> None:
|
|
||||||
"""Broadcast buffer to all connected clients."""
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
|
|
||||||
if self._clients:
|
|
||||||
frame_data = {
|
|
||||||
"type": "frame",
|
|
||||||
"width": self.width,
|
|
||||||
"height": self.height,
|
|
||||||
"lines": buffer,
|
|
||||||
}
|
|
||||||
message = json.dumps(frame_data)
|
|
||||||
|
|
||||||
disconnected = set()
|
|
||||||
for client in list(self._clients):
|
|
||||||
try:
|
|
||||||
asyncio.run(client.send(message))
|
|
||||||
except Exception:
|
|
||||||
disconnected.add(client)
|
|
||||||
|
|
||||||
for client in disconnected:
|
|
||||||
self._clients.discard(client)
|
|
||||||
if self._client_disconnected_callback:
|
|
||||||
self._client_disconnected_callback(client)
|
|
||||||
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
|
||||||
chars_in = sum(len(line) for line in buffer)
|
|
||||||
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Broadcast clear command to all clients."""
|
|
||||||
if self._clients:
|
|
||||||
clear_data = {"type": "clear"}
|
|
||||||
message = json.dumps(clear_data)
|
|
||||||
for client in list(self._clients):
|
|
||||||
try:
|
|
||||||
asyncio.run(client.send(message))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
"""Stop the servers."""
|
|
||||||
self.stop_server()
|
|
||||||
self.stop_http_server()
|
|
||||||
|
|
||||||
async def _websocket_handler(self, websocket):
|
|
||||||
"""Handle WebSocket connections."""
|
|
||||||
if len(self._clients) >= self._max_clients:
|
|
||||||
await websocket.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
self._clients.add(websocket)
|
|
||||||
if self._client_connected_callback:
|
|
||||||
self._client_connected_callback(websocket)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async for message in websocket:
|
|
||||||
try:
|
|
||||||
data = json.loads(message)
|
|
||||||
if data.get("type") == "resize":
|
|
||||||
self.width = data.get("width", 80)
|
|
||||||
self.height = data.get("height", 24)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
self._clients.discard(websocket)
|
|
||||||
if self._client_disconnected_callback:
|
|
||||||
self._client_disconnected_callback(websocket)
|
|
||||||
|
|
||||||
async def _run_websocket_server(self):
|
|
||||||
"""Run the WebSocket server."""
|
|
||||||
async with websockets.serve(self._websocket_handler, self.host, self.port):
|
|
||||||
while self._server_running:
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
async def _run_http_server(self):
|
|
||||||
"""Run simple HTTP server for the client."""
|
|
||||||
import os
|
|
||||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
||||||
|
|
||||||
client_dir = os.path.join(
|
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "client"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Handler(SimpleHTTPRequestHandler):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, directory=client_dir, **kwargs)
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
httpd = HTTPServer((self.host, self.http_port), Handler)
|
|
||||||
while self._http_running:
|
|
||||||
httpd.handle_request()
|
|
||||||
|
|
||||||
def _run_async(self, coro):
|
|
||||||
"""Run coroutine in background."""
|
|
||||||
try:
|
|
||||||
asyncio.run(coro)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"WebSocket async error: {e}")
|
|
||||||
|
|
||||||
def start_server(self):
|
|
||||||
"""Start the WebSocket server in a background thread."""
|
|
||||||
if not self._available:
|
|
||||||
return
|
|
||||||
if self._server_thread is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._server_running = True
|
|
||||||
self._server_thread = threading.Thread(
|
|
||||||
target=self._run_async, args=(self._run_websocket_server(),), daemon=True
|
|
||||||
)
|
|
||||||
self._server_thread.start()
|
|
||||||
|
|
||||||
def stop_server(self):
|
|
||||||
"""Stop the WebSocket server."""
|
|
||||||
self._server_running = False
|
|
||||||
self._server_thread = None
|
|
||||||
|
|
||||||
def start_http_server(self):
|
|
||||||
"""Start the HTTP server in a background thread."""
|
|
||||||
if not self._available:
|
|
||||||
return
|
|
||||||
if self._http_thread is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._http_running = True
|
|
||||||
|
|
||||||
self._http_running = True
|
|
||||||
self._http_thread = threading.Thread(
|
|
||||||
target=self._run_async, args=(self._run_http_server(),), daemon=True
|
|
||||||
)
|
|
||||||
self._http_thread.start()
|
|
||||||
|
|
||||||
def stop_http_server(self):
|
|
||||||
"""Stop the HTTP server."""
|
|
||||||
self._http_running = False
|
|
||||||
self._http_thread = None
|
|
||||||
|
|
||||||
def client_count(self) -> int:
|
|
||||||
"""Return number of connected clients."""
|
|
||||||
return len(self._clients)
|
|
||||||
|
|
||||||
def get_ws_port(self) -> int:
|
|
||||||
"""Return WebSocket port."""
|
|
||||||
return self.port
|
|
||||||
|
|
||||||
def get_http_port(self) -> int:
|
|
||||||
"""Return HTTP port."""
|
|
||||||
return self.http_port
|
|
||||||
|
|
||||||
def set_frame_delay(self, delay: float) -> None:
|
|
||||||
"""Set delay between frames in seconds."""
|
|
||||||
self._frame_delay = delay
|
|
||||||
|
|
||||||
def get_frame_delay(self) -> float:
|
|
||||||
"""Get delay between frames."""
|
|
||||||
return self._frame_delay
|
|
||||||
|
|
||||||
def set_client_connected_callback(self, callback) -> None:
|
|
||||||
"""Set callback for client connections."""
|
|
||||||
self._client_connected_callback = callback
|
|
||||||
|
|
||||||
def set_client_disconnected_callback(self, callback) -> None:
|
|
||||||
"""Set callback for client disconnections."""
|
|
||||||
self._client_disconnected_callback = callback
|
|
||||||
@@ -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,39 +0,0 @@
|
|||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectContext:
|
|
||||||
terminal_width: int
|
|
||||||
terminal_height: int
|
|
||||||
scroll_cam: int
|
|
||||||
ticker_height: int
|
|
||||||
mic_excess: float
|
|
||||||
grad_offset: float
|
|
||||||
frame_number: int
|
|
||||||
has_message: bool
|
|
||||||
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:
|
|
||||||
name: str
|
|
||||||
config: EffectConfig
|
|
||||||
|
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def configure(self, config: EffectConfig) -> None:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
@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,67 +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()
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
@@ -8,7 +8,6 @@ import pathlib
|
|||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import feedparser
|
import feedparser
|
||||||
|
|
||||||
@@ -17,13 +16,9 @@ from engine.filter import skip, strip_tags
|
|||||||
from engine.sources import FEEDS, POETRY_SOURCES
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
from engine.terminal import boot_ln
|
from engine.terminal import boot_ln
|
||||||
|
|
||||||
# Type alias for headline items
|
|
||||||
HeadlineTuple = tuple[str, str, str]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── SINGLE FEED ──────────────────────────────────────────
|
# ─── SINGLE FEED ──────────────────────────────────────────
|
||||||
def fetch_feed(url: str) -> Any | None:
|
def fetch_feed(url):
|
||||||
"""Fetch and parse a single RSS feed URL."""
|
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
||||||
@@ -33,9 +28,8 @@ def fetch_feed(url: str) -> Any | None:
|
|||||||
|
|
||||||
|
|
||||||
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
||||||
def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
def fetch_all():
|
||||||
"""Fetch all RSS feeds and return items, linked count, failed count."""
|
items = []
|
||||||
items: list[HeadlineTuple] = []
|
|
||||||
linked = failed = 0
|
linked = failed = 0
|
||||||
for src, url in FEEDS.items():
|
for src, url in FEEDS.items():
|
||||||
feed = fetch_feed(url)
|
feed = fetch_feed(url)
|
||||||
@@ -65,7 +59,7 @@ def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
|||||||
|
|
||||||
|
|
||||||
# ─── PROJECT GUTENBERG ────────────────────────────────────
|
# ─── 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."""
|
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
|
|||||||
@@ -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
|
|
||||||
260
engine/layers.py
260
engine/layers.py
@@ -1,260 +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, lr_gradient_opposite
|
|
||||||
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 = lr_gradient_opposite(
|
|
||||||
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
|
|
||||||
@@ -4,8 +4,6 @@ Gracefully degrades if sounddevice/numpy are unavailable.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import numpy as _np
|
import numpy as _np
|
||||||
@@ -16,9 +14,6 @@ except Exception:
|
|||||||
_HAS_MIC = False
|
_HAS_MIC = False
|
||||||
|
|
||||||
|
|
||||||
from engine.events import MicLevelEvent
|
|
||||||
|
|
||||||
|
|
||||||
class MicMonitor:
|
class MicMonitor:
|
||||||
"""Background mic stream that exposes current RMS dB level."""
|
"""Background mic stream that exposes current RMS dB level."""
|
||||||
|
|
||||||
@@ -26,7 +21,6 @@ class MicMonitor:
|
|||||||
self.threshold_db = threshold_db
|
self.threshold_db = threshold_db
|
||||||
self._db = -99.0
|
self._db = -99.0
|
||||||
self._stream = None
|
self._stream = None
|
||||||
self._subscribers: list[Callable[[MicLevelEvent], None]] = []
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self):
|
def available(self):
|
||||||
@@ -43,23 +37,6 @@ class MicMonitor:
|
|||||||
"""dB above threshold (clamped to 0)."""
|
"""dB above threshold (clamped to 0)."""
|
||||||
return max(0.0, self._db - self.threshold_db)
|
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):
|
def start(self):
|
||||||
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
||||||
if not _HAS_MIC:
|
if not _HAS_MIC:
|
||||||
@@ -68,13 +45,6 @@ class MicMonitor:
|
|||||||
def _cb(indata, frames, t, status):
|
def _cb(indata, frames, t, status):
|
||||||
rms = float(_np.sqrt(_np.mean(indata**2)))
|
rms = float(_np.sqrt(_np.mean(indata**2)))
|
||||||
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
|
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:
|
try:
|
||||||
self._stream = _sd.InputStream(
|
self._stream = _sd.InputStream(
|
||||||
|
|||||||
@@ -16,12 +16,8 @@ import json
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import datetime
|
|
||||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
from engine.events import NtfyMessageEvent
|
|
||||||
|
|
||||||
|
|
||||||
class NtfyPoller:
|
class NtfyPoller:
|
||||||
"""SSE stream listener for ntfy.sh topics. Messages arrive in ~1s (network RTT)."""
|
"""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.display_secs = display_secs
|
||||||
self._message = None # (title, body, monotonic_timestamp) or None
|
self._message = None # (title, body, monotonic_timestamp) or None
|
||||||
self._lock = threading.Lock()
|
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):
|
def start(self):
|
||||||
"""Start background stream thread. Returns True."""
|
"""Start background stream thread. Returns True."""
|
||||||
@@ -110,13 +88,6 @@ class NtfyPoller:
|
|||||||
data.get("message", ""),
|
data.get("message", ""),
|
||||||
time.monotonic(),
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
time.sleep(self.reconnect_delay)
|
time.sleep(self.reconnect_delay)
|
||||||
|
|||||||
229
engine/scroll.py
229
engine/scroll.py
@@ -1,92 +1,148 @@
|
|||||||
"""
|
"""
|
||||||
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
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 random
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
from engine.display import (
|
from engine.effects import (
|
||||||
Display,
|
fade_line,
|
||||||
TerminalDisplay,
|
firehose_line,
|
||||||
|
glitch_bar,
|
||||||
|
next_headline,
|
||||||
|
noise,
|
||||||
|
vis_trunc,
|
||||||
)
|
)
|
||||||
from engine.display import (
|
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite, make_block
|
||||||
get_monitor as _get_display_monitor,
|
from engine.terminal import CLR, RST, W_COOL, th, tw
|
||||||
)
|
|
||||||
from engine.frame import calculate_scroll_step
|
|
||||||
from engine.layers import (
|
|
||||||
apply_glitch,
|
|
||||||
process_effects,
|
|
||||||
render_firehose,
|
|
||||||
render_message_overlay,
|
|
||||||
render_ticker_zone,
|
|
||||||
)
|
|
||||||
from engine.viewport import th, tw
|
|
||||||
|
|
||||||
USE_EFFECT_CHAIN = True
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||||
if display is None:
|
|
||||||
display = TerminalDisplay()
|
|
||||||
random.shuffle(items)
|
random.shuffle(items)
|
||||||
pool = list(items)
|
pool = list(items)
|
||||||
seen = set()
|
seen = set()
|
||||||
queued = 0
|
queued = 0
|
||||||
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
w, h = tw(), th()
|
sys.stdout.write(CLR)
|
||||||
display.init(w, h)
|
sys.stdout.flush()
|
||||||
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)
|
|
||||||
|
|
||||||
|
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 = []
|
active = []
|
||||||
scroll_cam = 0
|
scroll_cam = 0 # viewport top in virtual canvas coords
|
||||||
ticker_next_y = ticker_view_h
|
ticker_next_y = (
|
||||||
|
ticker_view_h # canvas-y where next block starts (off-screen bottom)
|
||||||
|
)
|
||||||
noise_cache = {}
|
noise_cache = {}
|
||||||
scroll_motion_accum = 0.0
|
scroll_motion_accum = 0.0
|
||||||
msg_cache = (None, None)
|
|
||||||
frame_number = 0
|
|
||||||
|
|
||||||
while True:
|
def _noise_at(cy):
|
||||||
if queued >= config.HEADLINE_LIMIT and not active:
|
if cy not in noise_cache:
|
||||||
break
|
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
|
||||||
|
return noise_cache[cy]
|
||||||
|
|
||||||
|
# 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()
|
t0 = time.monotonic()
|
||||||
w, h = tw(), th()
|
w, h = tw(), th()
|
||||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
ticker_view_h = h - fh
|
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 = ntfy_poller.get_active_message()
|
||||||
msg_overlay, msg_cache = render_message_overlay(msg, w, h, msg_cache)
|
|
||||||
|
|
||||||
buf = []
|
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
|
scroll_motion_accum += config.FRAME_DT
|
||||||
while scroll_motion_accum >= scroll_step_interval:
|
while scroll_motion_accum >= scroll_step_interval:
|
||||||
scroll_motion_accum -= scroll_step_interval
|
scroll_motion_accum -= scroll_step_interval
|
||||||
scroll_cam += 1
|
scroll_cam += 1
|
||||||
|
|
||||||
|
# Enqueue new headlines when room at the bottom
|
||||||
while (
|
while (
|
||||||
ticker_next_y < scroll_cam + ticker_view_h + 10
|
ticker_next_y < scroll_cam + ticker_view_h + 10
|
||||||
and queued < config.HEADLINE_LIMIT
|
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)
|
t, src, ts = next_headline(pool, items, seen)
|
||||||
ticker_content, hc, midx = make_block(t, src, ts, w)
|
ticker_content, hc, midx = make_block(t, src, ts, w)
|
||||||
active.append((ticker_content, hc, ticker_next_y, midx))
|
active.append((ticker_content, hc, ticker_next_y, midx))
|
||||||
ticker_next_y += len(ticker_content) + GAP
|
ticker_next_y += len(ticker_content) + GAP
|
||||||
queued += 1
|
queued += 1
|
||||||
|
|
||||||
|
# Prune off-screen blocks and stale noise
|
||||||
active = [
|
active = [
|
||||||
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
|
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
|
||||||
]
|
]
|
||||||
@@ -94,48 +150,71 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
|
|||||||
if k < scroll_cam:
|
if k < scroll_cam:
|
||||||
del noise_cache[k]
|
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
|
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||||
ticker_buf_start = len(buf)
|
ticker_buf_start = len(buf) # track where ticker rows start in buf
|
||||||
|
for r in range(ticker_h):
|
||||||
ticker_buf, noise_cache = render_ticker_zone(
|
scr_row = r + 1 # 1-indexed ANSI screen row
|
||||||
active, scroll_cam, ticker_h, w, noise_cache, grad_offset
|
cy = scroll_cam + r
|
||||||
)
|
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||||
buf.extend(ticker_buf)
|
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
|
mic_excess = mic_monitor.excess
|
||||||
render_start = time.perf_counter()
|
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
|
||||||
|
n_hits = 4 + int(mic_excess / 2)
|
||||||
if USE_EFFECT_CHAIN:
|
ticker_buf_len = len(buf) - ticker_buf_start
|
||||||
buf = process_effects(
|
if random.random() < glitch_prob and ticker_buf_len > 0:
|
||||||
buf,
|
for _ in range(min(n_hits, ticker_buf_len)):
|
||||||
w,
|
gi = random.randint(0, ticker_buf_len - 1)
|
||||||
h,
|
scr_row = gi + 1
|
||||||
scroll_cam,
|
buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
|
||||||
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)
|
|
||||||
|
|
||||||
|
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:
|
if msg_overlay:
|
||||||
buf.extend(msg_overlay)
|
buf.extend(msg_overlay)
|
||||||
|
|
||||||
render_elapsed = (time.perf_counter() - render_start) * 1000
|
sys.stdout.buffer.write("".join(buf).encode())
|
||||||
monitor = _get_display_monitor()
|
sys.stdout.flush()
|
||||||
if monitor:
|
|
||||||
chars = sum(len(line) for line in buf)
|
|
||||||
monitor.record_effect("render", render_elapsed, chars, chars)
|
|
||||||
|
|
||||||
display.show(buf)
|
|
||||||
|
|
||||||
|
# Precise frame timing
|
||||||
elapsed = time.monotonic() - t0
|
elapsed = time.monotonic() - t0
|
||||||
time.sleep(max(0, config.FRAME_DT - elapsed))
|
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||||
frame_number += 1
|
|
||||||
|
|
||||||
display.cleanup()
|
sys.stdout.write(CLR)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|||||||
@@ -7,16 +7,26 @@ import json
|
|||||||
import re
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
from engine.sources import LOCATION_LANGS
|
from engine.sources import LOCATION_LANGS
|
||||||
|
|
||||||
TRANSLATE_CACHE_SIZE = 500
|
_TRANSLATE_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=TRANSLATE_CACHE_SIZE)
|
def detect_location_language(title):
|
||||||
def _translate_cached(title: str, target_lang: str) -> str:
|
"""Detect if headline mentions a location, return target language."""
|
||||||
"""Cached translation implementation."""
|
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:
|
try:
|
||||||
q = urllib.parse.quote(title)
|
q = urllib.parse.quote(title)
|
||||||
url = (
|
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
|
result = "".join(p[0] for p in data[0] if p[0]) or title
|
||||||
except Exception:
|
except Exception:
|
||||||
result = title
|
result = title
|
||||||
|
_TRANSLATE_CACHE[key] = result
|
||||||
return 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"
|
|
||||||
3
hk.pkl
3
hk.pkl
@@ -22,9 +22,6 @@ hooks {
|
|||||||
prefix = "uv run"
|
prefix = "uv run"
|
||||||
check = "ruff check engine/ tests/"
|
check = "ruff check engine/ tests/"
|
||||||
}
|
}
|
||||||
["benchmark"] {
|
|
||||||
check = "uv run python -m engine.benchmark --hook --displays null --iterations 20"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
mise.toml
63
mise.toml
@@ -5,85 +5,48 @@ pkl = "latest"
|
|||||||
|
|
||||||
[tasks]
|
[tasks]
|
||||||
# =====================
|
# =====================
|
||||||
# Testing
|
# Development
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
test = "uv run pytest"
|
test = "uv run pytest"
|
||||||
test-v = { run = "uv run pytest -v", depends = ["sync-all"] }
|
test-v = "uv run pytest -v"
|
||||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html", depends = ["sync-all"] }
|
test-cov = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html"
|
||||||
test-cov-open = { run = "mise run test-cov && open htmlcov/index.html", depends = ["sync-all"] }
|
test-cov-open = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html && open htmlcov/index.html"
|
||||||
|
|
||||||
test-browser-install = { run = "uv run playwright install chromium", depends = ["sync-all"] }
|
|
||||||
test-browser = { run = "uv run pytest tests/e2e/", depends = ["test-browser-install"] }
|
|
||||||
|
|
||||||
# =====================
|
|
||||||
# Linting & Formatting
|
|
||||||
# =====================
|
|
||||||
|
|
||||||
lint = "uv run ruff check engine/ mainline.py"
|
lint = "uv run ruff check engine/ mainline.py"
|
||||||
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
||||||
format = "uv run ruff format engine/ mainline.py"
|
format = "uv run ruff format engine/ mainline.py"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Runtime Modes
|
# Runtime
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
run = "uv run mainline.py"
|
run = "uv run mainline.py"
|
||||||
run-poetry = "uv run mainline.py --poetry"
|
run-poetry = "uv run mainline.py --poetry"
|
||||||
run-firehose = "uv run mainline.py --firehose"
|
run-firehose = "uv run mainline.py --firehose"
|
||||||
|
|
||||||
run-websocket = { run = "uv run mainline.py --display websocket", depends = ["sync-all"] }
|
|
||||||
run-sixel = { run = "uv run mainline.py --display sixel", depends = ["sync-all"] }
|
|
||||||
run-both = { run = "uv run mainline.py --display both", depends = ["sync-all"] }
|
|
||||||
run-client = { run = "mise run run-both & sleep 2 && $(open http://localhost:8766 2>/dev/null || xdg-open http://localhost:8766 2>/dev/null || echo 'Open http://localhost:8766 manually'); wait", depends = ["sync-all"] }
|
|
||||||
|
|
||||||
# =====================
|
|
||||||
# Command & Control
|
|
||||||
# =====================
|
|
||||||
|
|
||||||
cmd = "uv run cmdline.py"
|
|
||||||
cmd-stats = { run = "uv run cmdline.py -w \"/effects stats\"", depends = ["sync-all"] }
|
|
||||||
|
|
||||||
# =====================
|
|
||||||
# Benchmark
|
|
||||||
# =====================
|
|
||||||
|
|
||||||
benchmark = { run = "uv run python -m engine.benchmark", depends = ["sync-all"] }
|
|
||||||
benchmark-json = { run = "uv run python -m engine.benchmark --format json --output benchmark.json", depends = ["sync-all"] }
|
|
||||||
benchmark-report = { run = "uv run python -m engine.benchmark --output BENCHMARK.md", depends = ["sync-all"] }
|
|
||||||
|
|
||||||
# Initialize ntfy topics (warm up before first use)
|
|
||||||
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
|
|
||||||
|
|
||||||
# =====================
|
|
||||||
# Daemon
|
|
||||||
# =====================
|
|
||||||
|
|
||||||
daemon = "nohup uv run mainline.py > nohup.out 2>&1 &"
|
|
||||||
daemon-stop = "pkill -f 'uv run mainline.py' 2>/dev/null || true"
|
|
||||||
daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
|
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Environment
|
# Environment
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
sync = "uv sync"
|
sync = "uv sync"
|
||||||
sync-all = "uv sync --all-extras"
|
sync-all = "uv sync --all-extras"
|
||||||
install = "mise run sync"
|
install = "uv sync"
|
||||||
install-dev = { run = "mise run sync-all && uv sync --group dev", depends = ["sync-all"] }
|
install-dev = "uv sync --group dev"
|
||||||
bootstrap = { run = "mise run sync-all && uv run mainline.py --help", depends = ["sync-all"] }
|
|
||||||
|
|
||||||
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
bootstrap = "uv sync && uv run mainline.py --help"
|
||||||
clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
|
||||||
|
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# CI/CD
|
# CI/CD
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
|
||||||
|
ci-lint = "uv run ruff check engine/ mainline.py"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Git Hooks (via hk)
|
# Git Hooks (via hk)
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
pre-commit = "hk run pre-commit"
|
pre-commit = "hk run pre-commit"
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"feedparser>=6.0.0",
|
"feedparser>=6.0.0",
|
||||||
"Pillow>=10.0.0",
|
"Pillow>=10.0.0",
|
||||||
"pyright>=1.1.408",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -30,15 +29,6 @@ mic = [
|
|||||||
"sounddevice>=0.4.0",
|
"sounddevice>=0.4.0",
|
||||||
"numpy>=1.24.0",
|
"numpy>=1.24.0",
|
||||||
]
|
]
|
||||||
websocket = [
|
|
||||||
"websockets>=12.0",
|
|
||||||
]
|
|
||||||
sixel = [
|
|
||||||
"pysixel>=0.1.0",
|
|
||||||
]
|
|
||||||
browser = [
|
|
||||||
"playwright>=1.40.0",
|
|
||||||
]
|
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-cov>=4.1.0",
|
"pytest-cov>=4.1.0",
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
"""
|
|
||||||
End-to-end tests for web client with headless browser.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import socketserver
|
|
||||||
import threading
|
|
||||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
CLIENT_DIR = Path(__file__).parent.parent.parent / "client"
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
|
|
||||||
"""Threaded HTTP server for handling concurrent requests."""
|
|
||||||
|
|
||||||
daemon_threads = True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def http_server():
|
|
||||||
"""Start a local HTTP server for the client."""
|
|
||||||
os.chdir(CLIENT_DIR)
|
|
||||||
|
|
||||||
handler = SimpleHTTPRequestHandler
|
|
||||||
server = ThreadedHTTPServer(("127.0.0.1", 0), handler)
|
|
||||||
port = server.server_address[1]
|
|
||||||
|
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
yield f"http://127.0.0.1:{port}"
|
|
||||||
|
|
||||||
server.shutdown()
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebClient:
|
|
||||||
"""Tests for the web client using Playwright."""
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def setup_browser(self):
|
|
||||||
"""Set up browser for tests."""
|
|
||||||
pytest.importorskip("playwright")
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
self.playwright = sync_playwright().start()
|
|
||||||
self.browser = self.playwright.chromium.launch(headless=True)
|
|
||||||
self.context = self.browser.new_context()
|
|
||||||
self.page = self.context.new_page()
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
self.page.close()
|
|
||||||
self.context.close()
|
|
||||||
self.browser.close()
|
|
||||||
self.playwright.stop()
|
|
||||||
|
|
||||||
def test_client_loads(self, http_server):
|
|
||||||
"""Web client loads without errors."""
|
|
||||||
response = self.page.goto(http_server)
|
|
||||||
assert response.status == 200, f"Page load failed with status {response.status}"
|
|
||||||
|
|
||||||
self.page.wait_for_load_state("domcontentloaded")
|
|
||||||
|
|
||||||
content = self.page.content()
|
|
||||||
assert "<canvas" in content, "Canvas element not found in page"
|
|
||||||
|
|
||||||
canvas = self.page.locator("#terminal")
|
|
||||||
assert canvas.count() > 0, "Canvas not found"
|
|
||||||
|
|
||||||
def test_status_shows_connecting(self, http_server):
|
|
||||||
"""Status shows connecting initially."""
|
|
||||||
self.page.goto(http_server)
|
|
||||||
self.page.wait_for_load_state("domcontentloaded")
|
|
||||||
|
|
||||||
status = self.page.locator("#status")
|
|
||||||
assert status.count() > 0, "Status element not found"
|
|
||||||
|
|
||||||
def test_canvas_has_dimensions(self, http_server):
|
|
||||||
"""Canvas has correct dimensions after load."""
|
|
||||||
self.page.goto(http_server)
|
|
||||||
self.page.wait_for_load_state("domcontentloaded")
|
|
||||||
|
|
||||||
canvas = self.page.locator("#terminal")
|
|
||||||
assert canvas.count() > 0, "Canvas not found"
|
|
||||||
|
|
||||||
def test_no_console_errors_on_load(self, http_server):
|
|
||||||
"""No JavaScript errors on page load (websocket errors are expected without server)."""
|
|
||||||
js_errors = []
|
|
||||||
|
|
||||||
def handle_console(msg):
|
|
||||||
if msg.type == "error":
|
|
||||||
text = msg.text
|
|
||||||
if "WebSocket" not in text:
|
|
||||||
js_errors.append(text)
|
|
||||||
|
|
||||||
self.page.on("console", handle_console)
|
|
||||||
self.page.goto(http_server)
|
|
||||||
self.page.wait_for_load_state("domcontentloaded")
|
|
||||||
|
|
||||||
assert len(js_errors) == 0, f"JavaScript errors: {js_errors}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebClientProtocol:
|
|
||||||
"""Tests for WebSocket protocol handling in client."""
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def setup_browser(self):
|
|
||||||
"""Set up browser for tests."""
|
|
||||||
pytest.importorskip("playwright")
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
self.playwright = sync_playwright().start()
|
|
||||||
self.browser = self.playwright.chromium.launch(headless=True)
|
|
||||||
self.context = self.browser.new_context()
|
|
||||||
self.page = self.context.new_page()
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
self.page.close()
|
|
||||||
self.context.close()
|
|
||||||
self.browser.close()
|
|
||||||
self.playwright.stop()
|
|
||||||
|
|
||||||
def test_websocket_reconnection(self, http_server):
|
|
||||||
"""Client attempts reconnection on disconnect."""
|
|
||||||
self.page.goto(http_server)
|
|
||||||
self.page.wait_for_load_state("domcontentloaded")
|
|
||||||
|
|
||||||
status = self.page.locator("#status")
|
|
||||||
assert status.count() > 0, "Status element not found"
|
|
||||||
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={},
|
|
||||||
)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for engine.app module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from engine.app import _normalize_preview_rows
|
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizePreviewRows:
|
|
||||||
"""Tests for _normalize_preview_rows function."""
|
|
||||||
|
|
||||||
def test_empty_rows(self):
|
|
||||||
"""Empty input returns empty list."""
|
|
||||||
result = _normalize_preview_rows([])
|
|
||||||
assert result == [""]
|
|
||||||
|
|
||||||
def test_strips_left_padding(self):
|
|
||||||
"""Left padding is stripped."""
|
|
||||||
result = _normalize_preview_rows([" content", " more"])
|
|
||||||
assert all(not r.startswith(" ") for r in result)
|
|
||||||
|
|
||||||
def test_preserves_content(self):
|
|
||||||
"""Content is preserved."""
|
|
||||||
result = _normalize_preview_rows([" hello world "])
|
|
||||||
assert "hello world" in result[0]
|
|
||||||
|
|
||||||
def test_handles_all_empty_rows(self):
|
|
||||||
"""All empty rows returns single empty string."""
|
|
||||||
result = _normalize_preview_rows(["", " ", ""])
|
|
||||||
assert result == [""]
|
|
||||||
|
|
||||||
|
|
||||||
class TestAppConstants:
|
|
||||||
"""Tests for app module constants."""
|
|
||||||
|
|
||||||
def test_title_defined(self):
|
|
||||||
"""TITLE is defined."""
|
|
||||||
from engine.app import TITLE
|
|
||||||
|
|
||||||
assert len(TITLE) > 0
|
|
||||||
|
|
||||||
def test_title_lines_are_strings(self):
|
|
||||||
"""TITLE contains string lines."""
|
|
||||||
from engine.app import TITLE
|
|
||||||
|
|
||||||
assert all(isinstance(line, str) for line in TITLE)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAppImports:
|
|
||||||
"""Tests for app module imports."""
|
|
||||||
|
|
||||||
def test_app_imports_without_error(self):
|
|
||||||
"""Module imports without error."""
|
|
||||||
from engine import app
|
|
||||||
|
|
||||||
assert app is not None
|
|
||||||
@@ -7,8 +7,6 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
|
|
||||||
|
|
||||||
@@ -162,140 +160,3 @@ class TestSetFontSelection:
|
|||||||
config.set_font_selection(font_path=None, font_index=None)
|
config.set_font_selection(font_path=None, font_index=None)
|
||||||
assert original_path == config.FONT_PATH
|
assert original_path == config.FONT_PATH
|
||||||
assert original_index == config.FONT_INDEX
|
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,85 +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()
|
|
||||||
@@ -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,234 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for engine.fetch module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from engine.fetch import (
|
|
||||||
_fetch_gutenberg,
|
|
||||||
fetch_all,
|
|
||||||
fetch_feed,
|
|
||||||
fetch_poetry,
|
|
||||||
load_cache,
|
|
||||||
save_cache,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchFeed:
|
|
||||||
"""Tests for fetch_feed function."""
|
|
||||||
|
|
||||||
@patch("engine.fetch.urllib.request.urlopen")
|
|
||||||
def test_fetch_success(self, mock_urlopen):
|
|
||||||
"""Successful feed fetch returns parsed feed."""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.read.return_value = b"<rss>test</rss>"
|
|
||||||
mock_urlopen.return_value = mock_response
|
|
||||||
|
|
||||||
result = fetch_feed("http://example.com/feed")
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
@patch("engine.fetch.urllib.request.urlopen")
|
|
||||||
def test_fetch_network_error(self, mock_urlopen):
|
|
||||||
"""Network error returns None."""
|
|
||||||
mock_urlopen.side_effect = Exception("Network error")
|
|
||||||
|
|
||||||
result = fetch_feed("http://example.com/feed")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchAll:
|
|
||||||
"""Tests for fetch_all function."""
|
|
||||||
|
|
||||||
@patch("engine.fetch.fetch_feed")
|
|
||||||
@patch("engine.fetch.strip_tags")
|
|
||||||
@patch("engine.fetch.skip")
|
|
||||||
@patch("engine.fetch.boot_ln")
|
|
||||||
def test_fetch_all_success(self, mock_boot, mock_skip, mock_strip, mock_fetch_feed):
|
|
||||||
"""Successful fetch returns items."""
|
|
||||||
mock_feed = MagicMock()
|
|
||||||
mock_feed.bozo = False
|
|
||||||
mock_feed.entries = [
|
|
||||||
{"title": "Headline 1", "published_parsed": (2024, 1, 1, 12, 0, 0)},
|
|
||||||
{"title": "Headline 2", "updated_parsed": (2024, 1, 2, 12, 0, 0)},
|
|
||||||
]
|
|
||||||
mock_fetch_feed.return_value = mock_feed
|
|
||||||
mock_skip.return_value = False
|
|
||||||
mock_strip.side_effect = lambda x: x
|
|
||||||
|
|
||||||
items, linked, failed = fetch_all()
|
|
||||||
|
|
||||||
assert linked > 0
|
|
||||||
assert failed == 0
|
|
||||||
|
|
||||||
@patch("engine.fetch.fetch_feed")
|
|
||||||
@patch("engine.fetch.boot_ln")
|
|
||||||
def test_fetch_all_feed_error(self, mock_boot, mock_fetch_feed):
|
|
||||||
"""Feed error increments failed count."""
|
|
||||||
mock_fetch_feed.return_value = None
|
|
||||||
|
|
||||||
items, linked, failed = fetch_all()
|
|
||||||
|
|
||||||
assert failed > 0
|
|
||||||
|
|
||||||
@patch("engine.fetch.fetch_feed")
|
|
||||||
@patch("engine.fetch.strip_tags")
|
|
||||||
@patch("engine.fetch.skip")
|
|
||||||
@patch("engine.fetch.boot_ln")
|
|
||||||
def test_fetch_all_skips_filtered(
|
|
||||||
self, mock_boot, mock_skip, mock_strip, mock_fetch_feed
|
|
||||||
):
|
|
||||||
"""Filtered headlines are skipped."""
|
|
||||||
mock_feed = MagicMock()
|
|
||||||
mock_feed.bozo = False
|
|
||||||
mock_feed.entries = [
|
|
||||||
{"title": "Sports scores"},
|
|
||||||
{"title": "Valid headline"},
|
|
||||||
]
|
|
||||||
mock_fetch_feed.return_value = mock_feed
|
|
||||||
mock_skip.side_effect = lambda x: x == "Sports scores"
|
|
||||||
mock_strip.side_effect = lambda x: x
|
|
||||||
|
|
||||||
items, linked, failed = fetch_all()
|
|
||||||
|
|
||||||
assert any("Valid headline" in item[0] for item in items)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchGutenberg:
|
|
||||||
"""Tests for _fetch_gutenberg function."""
|
|
||||||
|
|
||||||
@patch("engine.fetch.urllib.request.urlopen")
|
|
||||||
def test_gutenberg_success(self, mock_urlopen):
|
|
||||||
"""Successful gutenberg fetch returns items."""
|
|
||||||
text = """Project Gutenberg
|
|
||||||
|
|
||||||
*** START OF THE PROJECT GUTENBERG ***
|
|
||||||
This is a test poem with multiple lines
|
|
||||||
that should be parsed as a block.
|
|
||||||
|
|
||||||
Another stanza with more content here.
|
|
||||||
|
|
||||||
*** END OF THE PROJECT GUTENBERG ***
|
|
||||||
"""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.read.return_value = text.encode("utf-8")
|
|
||||||
mock_urlopen.return_value = mock_response
|
|
||||||
|
|
||||||
result = _fetch_gutenberg("http://example.com/test", "Test")
|
|
||||||
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
@patch("engine.fetch.urllib.request.urlopen")
|
|
||||||
def test_gutenberg_network_error(self, mock_urlopen):
|
|
||||||
"""Network error returns empty list."""
|
|
||||||
mock_urlopen.side_effect = Exception("Network error")
|
|
||||||
|
|
||||||
result = _fetch_gutenberg("http://example.com/test", "Test")
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
@patch("engine.fetch.urllib.request.urlopen")
|
|
||||||
def test_gutenberg_skips_short_blocks(self, mock_urlopen):
|
|
||||||
"""Blocks shorter than 20 chars are skipped."""
|
|
||||||
text = """*** START OF THE ***
|
|
||||||
Short
|
|
||||||
*** END OF THE ***
|
|
||||||
"""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.read.return_value = text.encode("utf-8")
|
|
||||||
mock_urlopen.return_value = mock_response
|
|
||||||
|
|
||||||
result = _fetch_gutenberg("http://example.com/test", "Test")
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
@patch("engine.fetch.urllib.request.urlopen")
|
|
||||||
def test_gutenberg_skips_all_caps_headers(self, mock_urlopen):
|
|
||||||
"""All-caps lines are skipped as headers."""
|
|
||||||
text = """*** START OF THE ***
|
|
||||||
THIS IS ALL CAPS HEADER
|
|
||||||
more content here
|
|
||||||
*** END OF THE ***
|
|
||||||
"""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.read.return_value = text.encode("utf-8")
|
|
||||||
mock_urlopen.return_value = mock_response
|
|
||||||
|
|
||||||
result = _fetch_gutenberg("http://example.com/test", "Test")
|
|
||||||
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchPoetry:
|
|
||||||
"""Tests for fetch_poetry function."""
|
|
||||||
|
|
||||||
@patch("engine.fetch._fetch_gutenberg")
|
|
||||||
@patch("engine.fetch.boot_ln")
|
|
||||||
def test_fetch_poetry_success(self, mock_boot, mock_fetch):
|
|
||||||
"""Successful poetry fetch returns items."""
|
|
||||||
mock_fetch.return_value = [
|
|
||||||
("Stanza 1 content here", "Test", ""),
|
|
||||||
("Stanza 2 content here", "Test", ""),
|
|
||||||
]
|
|
||||||
|
|
||||||
items, linked, failed = fetch_poetry()
|
|
||||||
|
|
||||||
assert linked > 0
|
|
||||||
assert failed == 0
|
|
||||||
|
|
||||||
@patch("engine.fetch._fetch_gutenberg")
|
|
||||||
@patch("engine.fetch.boot_ln")
|
|
||||||
def test_fetch_poetry_failure(self, mock_boot, mock_fetch):
|
|
||||||
"""Failed fetch increments failed count."""
|
|
||||||
mock_fetch.return_value = []
|
|
||||||
|
|
||||||
items, linked, failed = fetch_poetry()
|
|
||||||
|
|
||||||
assert failed > 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestCache:
|
|
||||||
"""Tests for cache functions."""
|
|
||||||
|
|
||||||
@patch("engine.fetch._cache_path")
|
|
||||||
def test_load_cache_success(self, mock_path):
|
|
||||||
"""Successful cache load returns items."""
|
|
||||||
mock_path.return_value.__str__ = MagicMock(return_value="/tmp/cache")
|
|
||||||
mock_path.return_value.exists.return_value = True
|
|
||||||
mock_path.return_value.read_text.return_value = json.dumps(
|
|
||||||
{"items": [("title", "source", "time")]}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = load_cache()
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
@patch("engine.fetch._cache_path")
|
|
||||||
def test_load_cache_missing_file(self, mock_path):
|
|
||||||
"""Missing cache file returns None."""
|
|
||||||
mock_path.return_value.exists.return_value = False
|
|
||||||
|
|
||||||
result = load_cache()
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
@patch("engine.fetch._cache_path")
|
|
||||||
def test_load_cache_invalid_json(self, mock_path):
|
|
||||||
"""Invalid JSON returns None."""
|
|
||||||
mock_path.return_value.exists.return_value = True
|
|
||||||
mock_path.return_value.read_text.side_effect = json.JSONDecodeError("", "", 0)
|
|
||||||
|
|
||||||
result = load_cache()
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
@patch("engine.fetch._cache_path")
|
|
||||||
def test_save_cache_success(self, mock_path):
|
|
||||||
"""Save cache writes to file."""
|
|
||||||
mock_path.return_value.__truediv__ = MagicMock(
|
|
||||||
return_value=mock_path.return_value
|
|
||||||
)
|
|
||||||
|
|
||||||
save_cache([("title", "source", "time")])
|
|
||||||
@@ -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.
|
Tests for engine.mic module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from engine.events import MicLevelEvent
|
|
||||||
|
|
||||||
|
|
||||||
class TestMicMonitorImport:
|
class TestMicMonitorImport:
|
||||||
"""Tests for module import behavior."""
|
"""Tests for module import behavior."""
|
||||||
@@ -84,66 +81,3 @@ class TestMicMonitorStop:
|
|||||||
monitor = MicMonitor()
|
monitor = MicMonitor()
|
||||||
monitor.stop()
|
monitor.stop()
|
||||||
assert monitor._stream is None
|
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
|
import time
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from engine.events import NtfyMessageEvent
|
|
||||||
from engine.ntfy import NtfyPoller
|
from engine.ntfy import NtfyPoller
|
||||||
|
|
||||||
|
|
||||||
@@ -69,54 +68,3 @@ class TestNtfyPollerDismiss:
|
|||||||
poller.dismiss()
|
poller.dismiss()
|
||||||
|
|
||||||
assert poller._message is None
|
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,127 +0,0 @@
|
|||||||
"""
|
|
||||||
Integration tests for ntfy topics.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
|
|
||||||
class TestNtfyTopics:
|
|
||||||
def test_cc_cmd_topic_exists_and_writable(self):
|
|
||||||
"""Verify C&C CMD topic exists and accepts messages."""
|
|
||||||
from engine.config import NTFY_CC_CMD_TOPIC
|
|
||||||
|
|
||||||
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
|
|
||||||
test_message = f"test_{int(time.time())}"
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
topic_url,
|
|
||||||
data=test_message.encode("utf-8"),
|
|
||||||
headers={
|
|
||||||
"User-Agent": "mainline-test/0.1",
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
assert resp.status == 200
|
|
||||||
except Exception as e:
|
|
||||||
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
|
|
||||||
|
|
||||||
def test_cc_resp_topic_exists_and_writable(self):
|
|
||||||
"""Verify C&C RESP topic exists and accepts messages."""
|
|
||||||
from engine.config import NTFY_CC_RESP_TOPIC
|
|
||||||
|
|
||||||
topic_url = NTFY_CC_RESP_TOPIC.replace("/json", "")
|
|
||||||
test_message = f"test_{int(time.time())}"
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
topic_url,
|
|
||||||
data=test_message.encode("utf-8"),
|
|
||||||
headers={
|
|
||||||
"User-Agent": "mainline-test/0.1",
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
assert resp.status == 200
|
|
||||||
except Exception as e:
|
|
||||||
raise AssertionError(f"Failed to write to C&C RESP topic: {e}") from e
|
|
||||||
|
|
||||||
def test_message_topic_exists_and_writable(self):
|
|
||||||
"""Verify message topic exists and accepts messages."""
|
|
||||||
from engine.config import NTFY_TOPIC
|
|
||||||
|
|
||||||
topic_url = NTFY_TOPIC.replace("/json", "")
|
|
||||||
test_message = f"test_{int(time.time())}"
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
topic_url,
|
|
||||||
data=test_message.encode("utf-8"),
|
|
||||||
headers={
|
|
||||||
"User-Agent": "mainline-test/0.1",
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
assert resp.status == 200
|
|
||||||
except Exception as e:
|
|
||||||
raise AssertionError(f"Failed to write to message topic: {e}") from e
|
|
||||||
|
|
||||||
def test_cc_cmd_topic_readable(self):
|
|
||||||
"""Verify we can read messages from C&C CMD topic."""
|
|
||||||
from engine.config import NTFY_CC_CMD_TOPIC
|
|
||||||
|
|
||||||
test_message = f"integration_test_{int(time.time())}"
|
|
||||||
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
topic_url,
|
|
||||||
data=test_message.encode("utf-8"),
|
|
||||||
headers={
|
|
||||||
"User-Agent": "mainline-test/0.1",
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
urllib.request.urlopen(req, timeout=10)
|
|
||||||
except Exception as e:
|
|
||||||
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
|
|
||||||
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
poll_url = f"{NTFY_CC_CMD_TOPIC}?poll=1&limit=1"
|
|
||||||
req = urllib.request.Request(
|
|
||||||
poll_url,
|
|
||||||
headers={"User-Agent": "mainline-test/0.1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
body = resp.read().decode("utf-8")
|
|
||||||
if body.strip():
|
|
||||||
data = json.loads(body.split("\n")[0])
|
|
||||||
assert isinstance(data, dict)
|
|
||||||
except Exception as e:
|
|
||||||
raise AssertionError(f"Failed to read from C&C CMD topic: {e}") from e
|
|
||||||
|
|
||||||
def test_topics_are_different(self):
|
|
||||||
"""Verify C&C CMD/RESP and message topics are different."""
|
|
||||||
from engine.config import NTFY_CC_CMD_TOPIC, NTFY_CC_RESP_TOPIC, NTFY_TOPIC
|
|
||||||
|
|
||||||
assert NTFY_CC_CMD_TOPIC != NTFY_TOPIC
|
|
||||||
assert NTFY_CC_RESP_TOPIC != NTFY_TOPIC
|
|
||||||
assert NTFY_CC_CMD_TOPIC != NTFY_CC_RESP_TOPIC
|
|
||||||
assert "_cc_cmd" in NTFY_CC_CMD_TOPIC
|
|
||||||
assert "_cc_resp" in NTFY_CC_RESP_TOPIC
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for engine.render module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from engine.render import (
|
|
||||||
GRAD_COLS,
|
|
||||||
MSG_GRAD_COLS,
|
|
||||||
clear_font_cache,
|
|
||||||
font_for_lang,
|
|
||||||
lr_gradient,
|
|
||||||
lr_gradient_opposite,
|
|
||||||
make_block,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGradientConstants:
|
|
||||||
"""Tests for gradient color constants."""
|
|
||||||
|
|
||||||
def test_grad_cols_defined(self):
|
|
||||||
"""GRAD_COLS is defined with expected length."""
|
|
||||||
assert len(GRAD_COLS) > 0
|
|
||||||
assert all(isinstance(c, str) for c in GRAD_COLS)
|
|
||||||
|
|
||||||
def test_msg_grad_cols_defined(self):
|
|
||||||
"""MSG_GRAD_COLS is defined with expected length."""
|
|
||||||
assert len(MSG_GRAD_COLS) > 0
|
|
||||||
assert all(isinstance(c, str) for c in MSG_GRAD_COLS)
|
|
||||||
|
|
||||||
def test_grad_cols_start_with_white(self):
|
|
||||||
"""GRAD_COLS starts with white."""
|
|
||||||
assert "231" in GRAD_COLS[0]
|
|
||||||
|
|
||||||
def test_msg_grad_cols_different_from_grad_cols(self):
|
|
||||||
"""MSG_GRAD_COLS is different from GRAD_COLS."""
|
|
||||||
assert MSG_GRAD_COLS != GRAD_COLS
|
|
||||||
|
|
||||||
|
|
||||||
class TestLrGradient:
|
|
||||||
"""Tests for lr_gradient function."""
|
|
||||||
|
|
||||||
def test_empty_rows(self):
|
|
||||||
"""Empty input returns empty output."""
|
|
||||||
result = lr_gradient([], 0.0)
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_preserves_empty_rows(self):
|
|
||||||
"""Empty rows are preserved."""
|
|
||||||
result = lr_gradient([""], 0.0)
|
|
||||||
assert result == [""]
|
|
||||||
|
|
||||||
def test_adds_gradient_to_content(self):
|
|
||||||
"""Non-empty rows get gradient coloring."""
|
|
||||||
result = lr_gradient(["hello"], 0.0)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert "\033[" in result[0]
|
|
||||||
|
|
||||||
def test_preserves_spaces(self):
|
|
||||||
"""Spaces are preserved without coloring."""
|
|
||||||
result = lr_gradient(["hello world"], 0.0)
|
|
||||||
assert " " in result[0]
|
|
||||||
|
|
||||||
def test_offset_wraps_around(self):
|
|
||||||
"""Offset wraps around at 1.0."""
|
|
||||||
result1 = lr_gradient(["hello"], 0.0)
|
|
||||||
result2 = lr_gradient(["hello"], 1.0)
|
|
||||||
assert result1 != result2 or result1 == result2
|
|
||||||
|
|
||||||
|
|
||||||
class TestLrGradientOpposite:
|
|
||||||
"""Tests for lr_gradient_opposite function."""
|
|
||||||
|
|
||||||
def test_uses_msg_grad_cols(self):
|
|
||||||
"""Uses MSG_GRAD_COLS instead of GRAD_COLS."""
|
|
||||||
result = lr_gradient_opposite(["test"])
|
|
||||||
assert "\033[" in result[0]
|
|
||||||
|
|
||||||
|
|
||||||
class TestClearFontCache:
|
|
||||||
"""Tests for clear_font_cache function."""
|
|
||||||
|
|
||||||
def test_clears_without_error(self):
|
|
||||||
"""Function runs without error."""
|
|
||||||
clear_font_cache()
|
|
||||||
|
|
||||||
|
|
||||||
class TestFontForLang:
|
|
||||||
"""Tests for font_for_lang function."""
|
|
||||||
|
|
||||||
@patch("engine.render.font")
|
|
||||||
def test_returns_default_for_none(self, mock_font):
|
|
||||||
"""Returns default font when lang is None."""
|
|
||||||
result = font_for_lang(None)
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
@patch("engine.render.font")
|
|
||||||
def test_returns_default_for_unknown_lang(self, mock_font):
|
|
||||||
"""Returns default font for unknown language."""
|
|
||||||
result = font_for_lang("unknown_lang")
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestMakeBlock:
|
|
||||||
"""Tests for make_block function."""
|
|
||||||
|
|
||||||
@patch("engine.translate.translate_headline")
|
|
||||||
@patch("engine.translate.detect_location_language")
|
|
||||||
@patch("engine.render.font_for_lang")
|
|
||||||
@patch("engine.render.big_wrap")
|
|
||||||
@patch("engine.render.random")
|
|
||||||
def test_make_block_basic(
|
|
||||||
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
|
||||||
):
|
|
||||||
"""Basic make_block returns content, color, meta index."""
|
|
||||||
mock_wrap.return_value = ["Headline content", ""]
|
|
||||||
mock_random.choice.return_value = "\033[38;5;46m"
|
|
||||||
|
|
||||||
content, color, meta_idx = make_block(
|
|
||||||
"Test headline", "TestSource", "12:00", 80
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(content) > 0
|
|
||||||
assert color is not None
|
|
||||||
assert meta_idx >= 0
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Requires full PIL/font environment")
|
|
||||||
@patch("engine.translate.translate_headline")
|
|
||||||
@patch("engine.translate.detect_location_language")
|
|
||||||
@patch("engine.render.font_for_lang")
|
|
||||||
@patch("engine.render.big_wrap")
|
|
||||||
@patch("engine.render.random")
|
|
||||||
def test_make_block_translation(
|
|
||||||
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
|
||||||
):
|
|
||||||
"""Translation is applied when mode is news."""
|
|
||||||
mock_wrap.return_value = ["Translated"]
|
|
||||||
mock_random.choice.return_value = "\033[38;5;46m"
|
|
||||||
mock_detect.return_value = "de"
|
|
||||||
|
|
||||||
with patch("engine.config.MODE", "news"):
|
|
||||||
content, _, _ = make_block("Test", "Source", "12:00", 80)
|
|
||||||
mock_translate.assert_called_once()
|
|
||||||
|
|
||||||
@patch("engine.translate.translate_headline")
|
|
||||||
@patch("engine.translate.detect_location_language")
|
|
||||||
@patch("engine.render.font_for_lang")
|
|
||||||
@patch("engine.render.big_wrap")
|
|
||||||
@patch("engine.render.random")
|
|
||||||
def test_make_block_no_translation_poetry(
|
|
||||||
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
|
||||||
):
|
|
||||||
"""No translation when mode is poetry."""
|
|
||||||
mock_wrap.return_value = ["Poem content"]
|
|
||||||
mock_random.choice.return_value = "\033[38;5;46m"
|
|
||||||
|
|
||||||
with patch("engine.config.MODE", "poetry"):
|
|
||||||
make_block("Test", "Source", "12:00", 80)
|
|
||||||
mock_translate.assert_not_called()
|
|
||||||
|
|
||||||
@patch("engine.translate.translate_headline")
|
|
||||||
@patch("engine.translate.detect_location_language")
|
|
||||||
@patch("engine.render.font_for_lang")
|
|
||||||
@patch("engine.render.big_wrap")
|
|
||||||
@patch("engine.render.random")
|
|
||||||
def test_make_block_meta_format(
|
|
||||||
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
|
||||||
):
|
|
||||||
"""Meta line includes source and timestamp."""
|
|
||||||
mock_wrap.return_value = ["Content"]
|
|
||||||
mock_random.choice.return_value = "\033[38;5;46m"
|
|
||||||
|
|
||||||
content, _, meta_idx = make_block("Test", "MySource", "14:30", 80)
|
|
||||||
|
|
||||||
meta_line = content[meta_idx]
|
|
||||||
assert "MySource" in meta_line
|
|
||||||
assert "14:30" in meta_line
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderLine:
|
|
||||||
"""Tests for render_line function."""
|
|
||||||
|
|
||||||
def test_empty_string(self):
|
|
||||||
"""Empty string returns empty list."""
|
|
||||||
from engine.render import render_line
|
|
||||||
|
|
||||||
result = render_line("")
|
|
||||||
assert result == [""]
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Requires real font/PIL setup")
|
|
||||||
def test_uses_default_font(self):
|
|
||||||
"""Uses default font when none provided."""
|
|
||||||
from engine.render import render_line
|
|
||||||
|
|
||||||
with patch("engine.render.font") as mock_font:
|
|
||||||
mock_font.return_value = MagicMock()
|
|
||||||
mock_font.return_value.getbbox.return_value = (0, 0, 10, 10)
|
|
||||||
render_line("test")
|
|
||||||
|
|
||||||
def test_getbbox_returns_none(self):
|
|
||||||
"""Handles None bbox gracefully."""
|
|
||||||
from engine.render import render_line
|
|
||||||
|
|
||||||
with patch("engine.render.font") as mock_font:
|
|
||||||
mock_font.return_value = MagicMock()
|
|
||||||
mock_font.return_value.getbbox.return_value = None
|
|
||||||
result = render_line("test")
|
|
||||||
assert result == [""]
|
|
||||||
|
|
||||||
|
|
||||||
class TestBigWrap:
|
|
||||||
"""Tests for big_wrap function."""
|
|
||||||
|
|
||||||
def test_empty_string(self):
|
|
||||||
"""Empty string returns empty list."""
|
|
||||||
from engine.render import big_wrap
|
|
||||||
|
|
||||||
result = big_wrap("", 80)
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Requires real font/PIL setup")
|
|
||||||
def test_single_word_fits(self):
|
|
||||||
"""Single short word returns rendered."""
|
|
||||||
from engine.render import big_wrap
|
|
||||||
|
|
||||||
with patch("engine.render.font") as mock_font:
|
|
||||||
mock_font.return_value = MagicMock()
|
|
||||||
mock_font.return_value.getbbox.return_value = (0, 0, 10, 10)
|
|
||||||
result = big_wrap("test", 80)
|
|
||||||
assert len(result) > 0
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for engine.translate module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from engine.translate import (
|
|
||||||
_translate_cached,
|
|
||||||
detect_location_language,
|
|
||||||
translate_headline,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_translate_cache():
|
|
||||||
"""Clear the LRU cache between tests."""
|
|
||||||
_translate_cached.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
class TestDetectLocationLanguage:
|
|
||||||
"""Tests for detect_location_language function."""
|
|
||||||
|
|
||||||
def test_returns_none_for_unknown_location(self):
|
|
||||||
"""Returns None when no location pattern matches."""
|
|
||||||
result = detect_location_language("Breaking news about technology")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_detects_berlin(self):
|
|
||||||
"""Detects Berlin location."""
|
|
||||||
result = detect_location_language("Berlin police arrest protesters")
|
|
||||||
assert result == "de"
|
|
||||||
|
|
||||||
def test_detects_paris(self):
|
|
||||||
"""Detects Paris location."""
|
|
||||||
result = detect_location_language("Paris fashion week begins")
|
|
||||||
assert result == "fr"
|
|
||||||
|
|
||||||
def test_detects_tokyo(self):
|
|
||||||
"""Detects Tokyo location."""
|
|
||||||
result = detect_location_language("Tokyo stocks rise")
|
|
||||||
assert result == "ja"
|
|
||||||
|
|
||||||
def test_detects_berlin_again(self):
|
|
||||||
"""Detects Berlin location again."""
|
|
||||||
result = detect_location_language("Berlin marathon set to begin")
|
|
||||||
assert result == "de"
|
|
||||||
|
|
||||||
def test_case_insensitive(self):
|
|
||||||
"""Detection is case insensitive."""
|
|
||||||
result = detect_location_language("BERLIN SUMMER FESTIVAL")
|
|
||||||
assert result == "de"
|
|
||||||
|
|
||||||
def test_returns_first_match(self):
|
|
||||||
"""Returns first matching pattern."""
|
|
||||||
result = detect_location_language("Berlin in Paris for the event")
|
|
||||||
assert result == "de"
|
|
||||||
|
|
||||||
|
|
||||||
class TestTranslateHeadline:
|
|
||||||
"""Tests for translate_headline function."""
|
|
||||||
|
|
||||||
def test_returns_translated_text(self):
|
|
||||||
"""Returns translated text from cache."""
|
|
||||||
clear_translate_cache()
|
|
||||||
with patch("engine.translate.translate_headline") as mock_fn:
|
|
||||||
mock_fn.return_value = "Translated title"
|
|
||||||
from engine.translate import translate_headline as th
|
|
||||||
|
|
||||||
result = th("Original title", "de")
|
|
||||||
assert result == "Translated title"
|
|
||||||
|
|
||||||
def test_uses_cached_result(self):
|
|
||||||
"""Translation uses LRU cache."""
|
|
||||||
clear_translate_cache()
|
|
||||||
result1 = translate_headline("Test unique", "es")
|
|
||||||
result2 = translate_headline("Test unique", "es")
|
|
||||||
assert result1 == result2
|
|
||||||
|
|
||||||
|
|
||||||
class TestTranslateCached:
|
|
||||||
"""Tests for _translate_cached function."""
|
|
||||||
|
|
||||||
def test_translation_network_error(self):
|
|
||||||
"""Network error returns original text."""
|
|
||||||
clear_translate_cache()
|
|
||||||
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
|
||||||
mock_urlopen.side_effect = Exception("Network error")
|
|
||||||
|
|
||||||
result = _translate_cached("Hello world", "de")
|
|
||||||
|
|
||||||
assert result == "Hello world"
|
|
||||||
|
|
||||||
def test_translation_invalid_json(self):
|
|
||||||
"""Invalid JSON returns original text."""
|
|
||||||
clear_translate_cache()
|
|
||||||
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.read.return_value = b"invalid json"
|
|
||||||
mock_urlopen.return_value = mock_response
|
|
||||||
|
|
||||||
result = _translate_cached("Hello", "de")
|
|
||||||
|
|
||||||
assert result == "Hello"
|
|
||||||
|
|
||||||
def test_translation_empty_response(self):
|
|
||||||
"""Empty translation response returns original text."""
|
|
||||||
clear_translate_cache()
|
|
||||||
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.read.return_value = json.dumps([[[""], None, "de"], None])
|
|
||||||
mock_urlopen.return_value = mock_response
|
|
||||||
|
|
||||||
result = _translate_cached("Hello", "de")
|
|
||||||
|
|
||||||
assert result == "Hello"
|
|
||||||
@@ -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"
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for engine.display.backends.websocket module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from engine.display.backends.websocket import WebSocketDisplay
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketDisplayImport:
|
|
||||||
"""Test that websocket module can be imported."""
|
|
||||||
|
|
||||||
def test_import_does_not_error(self):
|
|
||||||
"""Module imports without error."""
|
|
||||||
from engine.display import backends
|
|
||||||
|
|
||||||
assert backends is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketDisplayInit:
|
|
||||||
"""Tests for WebSocketDisplay initialization."""
|
|
||||||
|
|
||||||
def test_default_init(self):
|
|
||||||
"""Default initialization sets correct defaults."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", None):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
assert display.host == "0.0.0.0"
|
|
||||||
assert display.port == 8765
|
|
||||||
assert display.http_port == 8766
|
|
||||||
assert display.width == 80
|
|
||||||
assert display.height == 24
|
|
||||||
|
|
||||||
def test_custom_init(self):
|
|
||||||
"""Custom initialization uses provided values."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", None):
|
|
||||||
display = WebSocketDisplay(host="localhost", port=9000, http_port=9001)
|
|
||||||
assert display.host == "localhost"
|
|
||||||
assert display.port == 9000
|
|
||||||
assert display.http_port == 9001
|
|
||||||
|
|
||||||
def test_is_available_when_websockets_present(self):
|
|
||||||
"""is_available returns True when websockets is available."""
|
|
||||||
pytest.importorskip("websockets")
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
assert display.is_available() is True
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
|
||||||
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
|
||||||
)
|
|
||||||
def test_is_available_when_websockets_missing(self):
|
|
||||||
"""is_available returns False when websockets is not available."""
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
assert display.is_available() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketDisplayProtocol:
|
|
||||||
"""Test that WebSocketDisplay satisfies Display protocol."""
|
|
||||||
|
|
||||||
def test_websocket_display_is_display(self):
|
|
||||||
"""WebSocketDisplay satisfies Display protocol."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
assert hasattr(display, "init")
|
|
||||||
assert hasattr(display, "show")
|
|
||||||
assert hasattr(display, "clear")
|
|
||||||
assert hasattr(display, "cleanup")
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketDisplayMethods:
|
|
||||||
"""Tests for WebSocketDisplay methods."""
|
|
||||||
|
|
||||||
def test_init_stores_dimensions(self):
|
|
||||||
"""init stores terminal dimensions."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
display.init(100, 40)
|
|
||||||
assert display.width == 100
|
|
||||||
assert display.height == 40
|
|
||||||
|
|
||||||
def test_client_count_initially_zero(self):
|
|
||||||
"""client_count returns 0 when no clients connected."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
assert display.client_count() == 0
|
|
||||||
|
|
||||||
def test_get_ws_port(self):
|
|
||||||
"""get_ws_port returns configured port."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay(port=9000)
|
|
||||||
assert display.get_ws_port() == 9000
|
|
||||||
|
|
||||||
def test_get_http_port(self):
|
|
||||||
"""get_http_port returns configured port."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay(http_port=9001)
|
|
||||||
assert display.get_http_port() == 9001
|
|
||||||
|
|
||||||
def test_frame_delay_defaults_to_zero(self):
|
|
||||||
"""get_frame_delay returns 0 by default."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
assert display.get_frame_delay() == 0.0
|
|
||||||
|
|
||||||
def test_set_frame_delay(self):
|
|
||||||
"""set_frame_delay stores the value."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
display.set_frame_delay(0.05)
|
|
||||||
assert display.get_frame_delay() == 0.05
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketDisplayCallbacks:
|
|
||||||
"""Tests for WebSocketDisplay callback methods."""
|
|
||||||
|
|
||||||
def test_set_client_connected_callback(self):
|
|
||||||
"""set_client_connected_callback stores callback."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
callback = MagicMock()
|
|
||||||
display.set_client_connected_callback(callback)
|
|
||||||
assert display._client_connected_callback is callback
|
|
||||||
|
|
||||||
def test_set_client_disconnected_callback(self):
|
|
||||||
"""set_client_disconnected_callback stores callback."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
callback = MagicMock()
|
|
||||||
display.set_client_disconnected_callback(callback)
|
|
||||||
assert display._client_disconnected_callback is callback
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketDisplayUnavailable:
|
|
||||||
"""Tests when WebSocket support is unavailable."""
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
|
||||||
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
|
||||||
)
|
|
||||||
def test_start_server_noop_when_unavailable(self):
|
|
||||||
"""start_server does nothing when websockets unavailable."""
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
display.start_server()
|
|
||||||
assert display._server_thread is None
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
|
||||||
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
|
||||||
)
|
|
||||||
def test_start_http_server_noop_when_unavailable(self):
|
|
||||||
"""start_http_server does nothing when websockets unavailable."""
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
display.start_http_server()
|
|
||||||
assert display._http_thread is None
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
|
||||||
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
|
||||||
)
|
|
||||||
def test_show_noops_when_unavailable(self):
|
|
||||||
"""show does nothing when websockets unavailable."""
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
display.show(["line1", "line2"])
|
|
||||||
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