Compare commits

...

10 Commits

Author SHA1 Message Date
cottongin
eefb908268 Tidy repo for public release
- Add MIT LICENSE
- Polish README: tagline, permissions docs, clone URL
- Add license, authors, readme, and repository URL to pyproject.toml
- Remove stale docs/plans/ (relocated to .cursor/) and requirements.txt
- Deduplicate and clean up .gitignore

Made-with: Cursor
2026-03-10 08:03:22 -04:00
cottongin
d71bac7b93 Remove tracked files now covered by .gitignore
Add .pytest_cache/, .cursor/, and chat-summaries/ to .gitignore
and untrack the previously committed chat-summaries.

Made-with: Cursor
2026-03-10 07:40:21 -04:00
cottongin
5b71b2275b Restructure config for per-mode style/sound and fix pulse dismiss
Major changes:
- Add StyleConfig dataclass with independent color, width, opacity,
  duration, pulse_speed, sound, and volume per mode (running/completed)
- Replace flat flash_*/sound_*/play_on config with running: and
  completed: YAML sections
- Replace CGEventTap (silently fails in forked daemon) with
  CGEventSourceSecondsSinceLastEventType polling for reliable
  input-based pulse dismissal when Cursor is already frontmost
- Update overlay, sound, and daemon to pass StyleConfig per call
- Rewrite tests for new config shape and dismiss mechanism

Made-with: Cursor
2026-03-10 07:01:52 -04:00
cottongin
c0477d2f40 fix: prevent false positives from stale approval buttons in chat history
State machine no longer transitions directly from IDLE to WAITING_FOR_USER
on approval signals. Must see AGENT_WORKING first — this prevents stale
buttons like "Run this time only" persisting in chat history from
triggering the flash when no agent task is active.

Also removed "Continue" and "Resume" from approval keywords (too generic,
appear in normal chat text).

Made-with: Cursor
2026-03-10 03:17:34 -04:00
cottongin
ba656291ab feat: switch to uv, add check command, fix silent a11y failures
- Added pyobjc-framework-ApplicationServices to dependencies (was
  implicitly available via pyenv's system packages but missing in
  clean venvs)
- Added `cursor-flasher check` command that verifies Cursor is running
  and accessibility permissions are working
- Detector now logs a warning when a11y tree reads fail (previously
  failed silently, making permission issues invisible)
- Switched to uv for dependency management: `uv sync` + `uv run`
- Updated README with uv-based workflow and accessibility
  troubleshooting guide

Made-with: Cursor
2026-03-10 03:12:10 -04:00
cottongin
1a5de8cf8a docs: add README with installation and usage instructions
Made-with: Cursor
2026-03-10 02:57:31 -04:00
cottongin
a5ca7f5d33 fix: tune detection patterns and add state transition logging
- Added "Resume" and "Continue" to approval keywords
- Added state transition logging to daemon for observability
- Guarded signal handler against duplicate SIGTERM delivery
- Verified end-to-end: daemon detects approval prompt, transitions
  to waiting_for_user, overlays 1 window, plays sound

Made-with: Cursor
2026-03-10 02:57:01 -04:00
cottongin
b31f39268e feat: per-window detection — only flash windows needing attention
Detector now walks each AXWindow subtree independently and returns
both aggregate signals (for state machine) and a list of AXWindow
element refs for windows with active approval signals.

Overlay reads position/size directly from AXWindow elements via
AXValueGetValue, eliminating the CGWindowList dependency (which
returned empty names for Electron windows anyway).

Daemon passes only the active AXWindow refs to the overlay, so
only the specific window(s) waiting for user input get flashed.

Made-with: Cursor
2026-03-10 02:54:15 -04:00
cottongin
bce6ec39f8 feat: add CLI with start/stop/status commands
Made-with: Cursor
2026-03-10 02:46:38 -04:00
cottongin
bcd8d4da1a feat: add main daemon loop wiring detection, overlay, and sound
Made-with: Cursor
2026-03-10 02:45:17 -04:00
26 changed files with 2011 additions and 2202 deletions

6
.gitignore vendored
View File

@@ -6,3 +6,9 @@ build/
.pytest_cache/ .pytest_cache/
*.egg *.egg
.venv/ .venv/
a11y_dump.txt
agent-tools/
.cursor/
docs/
chat-summaries/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cottongin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

105
README.md Normal file
View File

@@ -0,0 +1,105 @@
# cursor-flasher
A macOS daemon that flashes a pulsing border and plays a sound when your [Cursor](https://cursor.com) AI agent needs attention.
## How It Works
Uses [Cursor hooks](https://cursor.com/docs/agent/hooks) for reliable detection:
- **`preToolUse`** — fires when the agent wants to run a shell command, write a file, or use any tool that may need approval. **Pulses** the border continuously and plays a sound until you click the Cursor window.
- **`stop`** — fires when the agent loop ends. **Flashes** the border once, briefly.
Only tools in the `approval_tools` list trigger the pulse (default: `Shell`, `Write`, `Delete`). Auto-approved tools like `Read` and `Grep` are ignored.
## Prerequisites
- macOS
- [uv](https://docs.astral.sh/uv/)
- Cursor IDE
- **Accessibility** permission for your terminal (System Settings → Privacy & Security → Accessibility) — needed for window enumeration
- **Input Monitoring** permission for the daemon process (System Settings → Privacy & Security → Input Monitoring) — needed for input-based pulse dismissal
## Installation
```bash
# Clone and install
git clone https://code.cottongin.xyz/cursor-flasher && cd cursor-flasher
uv sync
# Install Cursor hooks (global, applies to all projects)
uv run cursor-flasher install
# Start the daemon
uv run cursor-flasher start
```
The `install` command copies the hook script to `~/.cursor/hooks/` and adds entries to `~/.cursor/hooks.json`. Cursor auto-reloads hooks.
## Usage
```bash
uv run cursor-flasher start # background daemon
uv run cursor-flasher start --foreground # foreground (for debugging)
uv run cursor-flasher status
uv run cursor-flasher stop
```
## Configuration
Optional config file at `~/.cursor-flasher/config.yaml`:
```yaml
running: # approval pulse (continuous until you interact)
color: "#FF9500" # border color (hex)
width: 4 # border thickness in pixels
opacity: 0.85 # max border opacity
pulse_speed: 1.5 # pulse cycle speed in seconds
sound: "Glass" # macOS system sound ("" to disable)
volume: 0.5 # 0.0 to 1.0
# sounds: Basso, Blow, Bottle, Frog, Funk, Glass, Hero,
# Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink
completed: # agent stop flash (brief fade-in/out)
color: "#00FF00" # different color for completion
width: 4
opacity: 0.85
duration: 1.5 # flash duration in seconds
sound: "" # no sound by default (Cursor plays its own)
volume: 0.0
flash:
mode: "screen" # "window", "screen", or "allscreens"
# Tools that trigger the pulse + sound (approval mode).
# Others are silently ignored (e.g., Read, Grep, Glob, Task).
approval_tools:
- Shell
- Write
- Delete
general:
approval_delay: 2.5 # seconds to wait before pulsing (filters auto-approvals)
cooldown: 2.0 # minimum seconds between flashes
```
Each mode (`running` and `completed`) has its own color, border style, and sound settings. Set `sound: ""` to disable sound for a particular mode.
## Uninstall
```bash
uv run cursor-flasher uninstall
uv run cursor-flasher stop
```
## Troubleshooting
**Flashing on every tool call (too noisy):**
- Edit `~/.cursor-flasher/config.yaml` and narrow down `approval_tools` to just `Shell`.
**No flash at all:**
- Check daemon: `uv run cursor-flasher status`
- Check hooks installed: `ls ~/.cursor/hooks/cursor-flasher-notify.py`
- Check Cursor Settings → Hooks tab for execution logs
**Pulse doesn't stop:**
- Click the Cursor window to bring it to focus — the pulse auto-dismisses when Cursor is the frontmost app.

View File

@@ -1,102 +0,0 @@
# Cursor Flasher — Design Document
**Date:** 2026-03-10
**Status:** Approved
## Problem
When Cursor's AI agent finishes its turn and is waiting for user input (approval, answering a question, or continuing the conversation), there's no visual or audible signal. The user has to keep checking the window manually.
## Solution
A macOS background daemon that monitors Cursor's accessibility tree and displays a pulsing border overlay around the Cursor window when the agent is waiting for user input. Optionally plays a system sound.
## Architecture
### Detection: Accessibility Tree Polling
A Python process using `pyobjc` polls Cursor's accessibility tree every ~500ms via `AXUIElement` APIs.
**Detection signals:**
- **Approval needed:** Accept/Reject button elements appear in the a11y tree
- **Agent turn complete:** Stop/Cancel button disappears, or thinking indicator goes away
- **Chat input active:** Chat text area becomes the focused element after being inactive
**State machine:**
- `IDLE` — not monitoring (Cursor not in chat or not running)
- `AGENT_WORKING` — agent is generating (Stop button visible, thinking indicator present)
- `WAITING_FOR_USER` — agent is done, user hasn't interacted yet → **trigger flash**
- `USER_INTERACTING` — user started typing/clicking → dismiss flash, return to IDLE
The detection heuristics will be tuned during development after dumping Cursor's full a11y tree.
### Visual Effect: Native macOS Overlay
- A borderless, transparent, non-interactive `NSWindow` positioned over Cursor's window frame
- Draws only a pulsing border (interior is fully click-through)
- Core Animation drives the pulse: opacity oscillates between configurable min/max
- Default: ~4px amber border, 1.5s cycle
**Dismissal triggers:**
- Keyboard input to Cursor (via accessibility or `CGEventTap`)
- Mouse click in Cursor's chat area
- Timeout (default 5 minutes)
- Agent starts working again (Stop button reappears)
### Sound
On transition to `WAITING_FOR_USER`, optionally plays a macOS system sound (default: "Glass"). Configurable sound name, volume, and on/off toggle.
### Configuration
File: `~/.cursor-flasher/config.yaml`
```yaml
pulse:
color: "#FF9500"
width: 4
speed: 1.5
opacity_min: 0.3
opacity_max: 1.0
sound:
enabled: true
name: "Glass"
volume: 0.5
detection:
poll_interval: 0.5
cooldown: 3.0
timeout:
auto_dismiss: 300
```
### Process Management
- CLI: `cursor-flasher start`, `cursor-flasher stop`, `cursor-flasher status`
- Runs as a background daemon
- No menu bar icon (MVP scope)
## Tech Stack
- Python 3.10+
- `pyobjc-framework-Cocoa` — NSWindow, NSApplication, Core Animation
- `pyobjc-framework-Quartz` — AXUIElement, CGEventTap, window management
- `PyYAML` — configuration file parsing
## Installation
- `pip install -e .` from the project directory
- Requires Accessibility permission: System Settings > Privacy & Security > Accessibility (grant to Terminal or Python)
## Scope / Non-goals
- **In scope:** Detection, overlay, sound, CLI, config file
- **Not in scope (MVP):** Menu bar icon, auto-start on login, multi-monitor awareness, Linux/Windows support
## Risks
- **A11y tree fragility:** Cursor UI updates could change element names/structure, breaking detection. Mitigation: make detection patterns configurable, log warnings on detection failures.
- **Accessibility permissions:** Users must grant permission manually. Mitigation: clear error message and instructions on first run.
- **Performance:** Polling a11y tree every 500ms could have CPU cost. Mitigation: only poll when Cursor is the frontmost app or recently active.

File diff suppressed because it is too large Load Diff

40
hooks/notify.py Executable file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Cursor hook script that notifies the cursor-flasher daemon via Unix socket.
Installed as a Cursor hook (preToolUse, stop) to trigger a window flash
when the agent needs user attention. Reads hook JSON from stdin, extracts
workspace and event info, and sends it to the daemon's socket.
"""
import json
import os
import socket
import sys
SOCKET_PATH = os.path.expanduser("~/.cursor-flasher/flasher.sock")
def main() -> None:
try:
data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
return
workspace_roots = data.get("workspace_roots") or []
workspace = workspace_roots[0] if workspace_roots else ""
event = data.get("hook_event_name", "")
tool = data.get("tool_name", "")
msg = json.dumps({"workspace": workspace, "event": event, "tool": tool})
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(1)
s.connect(SOCKET_PATH)
s.sendall(msg.encode())
s.close()
except (ConnectionRefusedError, FileNotFoundError, OSError):
pass
if __name__ == "__main__":
main()

View File

@@ -4,10 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "cursor-flasher" name = "cursor-flasher"
version = "0.1.0" version = "0.2.0"
description = "Flash Cursor's window when the AI agent is waiting for input" description = "Flash Cursor's window when the AI agent needs attention"
readme = "README.md"
license = "MIT"
authors = [{ name = "cottongin" }]
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"pyobjc-framework-applicationservices>=12.1",
"pyobjc-framework-Cocoa", "pyobjc-framework-Cocoa",
"pyobjc-framework-Quartz", "pyobjc-framework-Quartz",
"PyYAML", "PyYAML",
@@ -16,8 +20,14 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = ["pytest", "pytest-mock"] dev = ["pytest", "pytest-mock"]
[project.urls]
Repository = "https://code.cottongin.xyz/cursor-flasher"
[project.scripts] [project.scripts]
cursor-flasher = "cursor_flasher.cli:main" cursor-flasher = "cursor_flasher.cli:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.uv]
dev-dependencies = ["pytest", "pytest-mock"]

View File

@@ -1,5 +0,0 @@
pyobjc-framework-Cocoa
pyobjc-framework-Quartz
PyYAML
pytest
pytest-mock

View File

@@ -1,92 +0,0 @@
"""Dump the accessibility tree of the Cursor application.
Usage: python scripts/dump_a11y_tree.py [--depth N]
Requires Accessibility permissions for the running terminal.
"""
import argparse
import sys
from ApplicationServices import (
AXUIElementCreateApplication,
AXUIElementCopyAttributeNames,
AXUIElementCopyAttributeValue,
)
from Cocoa import NSWorkspace
CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92"
def find_cursor_pid() -> int | None:
"""Find the PID of the running Cursor application."""
workspace = NSWorkspace.sharedWorkspace()
for app in workspace.runningApplications():
bundle = app.bundleIdentifier() or ""
if bundle == CURSOR_BUNDLE_ID:
return app.processIdentifier()
return None
def dump_element(element, depth: int = 0, max_depth: int = 5) -> None:
"""Recursively print an AXUIElement's attributes."""
if depth > max_depth:
return
indent = " " * depth
names_err, attr_names = AXUIElementCopyAttributeNames(element, None)
if names_err or not attr_names:
return
role = ""
title = ""
value = ""
description = ""
for name in attr_names:
err, val = AXUIElementCopyAttributeValue(element, name, None)
if err:
continue
if name == "AXRole":
role = str(val)
elif name == "AXTitle":
title = str(val) if val else ""
elif name == "AXValue":
value_str = str(val)[:100] if val else ""
value = value_str
elif name == "AXDescription":
description = str(val) if val else ""
label = role
if title:
label += f' title="{title}"'
if description:
label += f' desc="{description}"'
if value:
label += f' value="{value}"'
print(f"{indent}{label}")
err, children = AXUIElementCopyAttributeValue(element, "AXChildren", None)
if not err and children:
for child in children:
dump_element(child, depth + 1, max_depth)
def main():
parser = argparse.ArgumentParser(description="Dump Cursor's accessibility tree")
parser.add_argument("--depth", type=int, default=8, help="Max depth to traverse")
args = parser.parse_args()
pid = find_cursor_pid()
if pid is None:
print("Cursor is not running.", file=sys.stderr)
sys.exit(1)
print(f"Found Cursor at PID {pid}")
app_element = AXUIElementCreateApplication(pid)
dump_element(app_element, max_depth=args.depth)
if __name__ == "__main__":
main()

View File

@@ -1,32 +0,0 @@
"""Manual test: shows a pulsing border around Cursor for 10 seconds."""
import sys
import time
from Cocoa import NSApplication, NSRunLoop, NSDate
from cursor_flasher.config import Config
from cursor_flasher.overlay import OverlayManager
from cursor_flasher.detector import CursorDetector
app = NSApplication.sharedApplication()
config = Config()
overlay = OverlayManager(config)
detector = CursorDetector()
pid = detector._find_cursor_pid()
if pid is None:
print("Cursor not running")
sys.exit(1)
print(f"Showing overlay for PID {pid} for 10 seconds...")
overlay.show(pid)
end_time = time.time() + 10
while time.time() < end_time:
NSRunLoop.currentRunLoop().runUntilDate_(
NSDate.dateWithTimeIntervalSinceNow_(0.1)
)
overlay.hide()
print("Done.")

212
src/cursor_flasher/cli.py Normal file
View File

@@ -0,0 +1,212 @@
"""CLI for cursor-flasher: install hooks, start/stop daemon."""
import argparse
import json
import os
import shutil
import signal
import sys
from pathlib import Path
PID_FILE = Path.home() / ".cursor-flasher" / "daemon.pid"
CURSOR_HOOKS_DIR = Path.home() / ".cursor" / "hooks"
CURSOR_HOOKS_JSON = Path.home() / ".cursor" / "hooks.json"
HOOK_SCRIPT_NAME = "cursor-flasher-notify.py"
HOOKS_CONFIG = {
"preToolUse": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
],
"postToolUse": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
],
"postToolUseFailure": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
],
"stop": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
],
}
def _write_pid() -> None:
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
PID_FILE.write_text(str(os.getpid()))
def _read_pid() -> int | None:
if not PID_FILE.exists():
return None
try:
pid = int(PID_FILE.read_text().strip())
os.kill(pid, 0)
return pid
except (ValueError, ProcessLookupError, PermissionError):
PID_FILE.unlink(missing_ok=True)
return None
def _remove_pid() -> None:
PID_FILE.unlink(missing_ok=True)
def _find_hook_source() -> Path:
"""Find the hook script source in the package."""
pkg_dir = Path(__file__).resolve().parent.parent.parent
return pkg_dir / "hooks" / "notify.py"
def cmd_install(args: argparse.Namespace) -> None:
"""Install Cursor hooks for flash notifications."""
CURSOR_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
src = _find_hook_source()
dst = CURSOR_HOOKS_DIR / HOOK_SCRIPT_NAME
if not src.exists():
print(f"ERROR: Hook source not found at {src}")
sys.exit(1)
shutil.copy2(src, dst)
dst.chmod(0o755)
print(f" Installed hook script: {dst}")
existing: dict = {}
if CURSOR_HOOKS_JSON.exists():
try:
existing = json.loads(CURSOR_HOOKS_JSON.read_text())
except (json.JSONDecodeError, ValueError):
existing = {}
existing.setdefault("version", 1)
hooks = existing.setdefault("hooks", {})
for event, entries in HOOKS_CONFIG.items():
event_hooks = hooks.setdefault(event, [])
for entry in entries:
already = any(h.get("command") == entry["command"] for h in event_hooks)
if not already:
event_hooks.append(entry)
CURSOR_HOOKS_JSON.write_text(json.dumps(existing, indent=2) + "\n")
print(f" Updated hooks config: {CURSOR_HOOKS_JSON}")
print("\nInstallation complete. Cursor will auto-reload hooks.")
print("Start the daemon with: cursor-flasher start")
def cmd_uninstall(args: argparse.Namespace) -> None:
"""Remove Cursor hooks."""
dst = CURSOR_HOOKS_DIR / HOOK_SCRIPT_NAME
if dst.exists():
dst.unlink()
print(f" Removed hook script: {dst}")
if CURSOR_HOOKS_JSON.exists():
try:
config = json.loads(CURSOR_HOOKS_JSON.read_text())
except (json.JSONDecodeError, ValueError):
config = {}
hooks = config.get("hooks", {})
changed = False
for event in HOOKS_CONFIG:
if event in hooks:
before = len(hooks[event])
hooks[event] = [
h for h in hooks[event]
if h.get("command") != f"./hooks/{HOOK_SCRIPT_NAME}"
]
if len(hooks[event]) < before:
changed = True
if not hooks[event]:
del hooks[event]
if changed:
CURSOR_HOOKS_JSON.write_text(json.dumps(config, indent=2) + "\n")
print(f" Cleaned hooks config: {CURSOR_HOOKS_JSON}")
print("\nUninstall complete.")
def cmd_start(args: argparse.Namespace) -> None:
existing = _read_pid()
if existing is not None:
print(f"Daemon already running (PID {existing})")
sys.exit(1)
if args.foreground:
from cursor_flasher.daemon import run_daemon
_write_pid()
try:
run_daemon()
finally:
_remove_pid()
else:
pid = os.fork()
if pid > 0:
print(f"Daemon started (PID {pid})")
return
os.setsid()
from cursor_flasher.daemon import run_daemon
_write_pid()
try:
run_daemon()
finally:
_remove_pid()
def cmd_stop(args: argparse.Namespace) -> None:
pid = _read_pid()
if pid is None:
print("Daemon is not running")
sys.exit(1)
os.kill(pid, signal.SIGTERM)
_remove_pid()
print(f"Daemon stopped (PID {pid})")
def cmd_status(args: argparse.Namespace) -> None:
pid = _read_pid()
if pid is None:
print("Daemon is not running")
else:
print(f"Daemon is running (PID {pid})")
def main() -> None:
parser = argparse.ArgumentParser(
prog="cursor-flasher",
description="Flash the Cursor window when the AI agent needs attention",
)
sub = parser.add_subparsers(dest="command")
install_parser = sub.add_parser("install", help="Install Cursor hooks")
install_parser.set_defaults(func=cmd_install)
uninstall_parser = sub.add_parser("uninstall", help="Remove Cursor hooks")
uninstall_parser.set_defaults(func=cmd_uninstall)
start_parser = sub.add_parser("start", help="Start the daemon")
start_parser.add_argument(
"--foreground", "-f", action="store_true",
help="Run in the foreground (don't daemonize)",
)
start_parser.set_defaults(func=cmd_start)
stop_parser = sub.add_parser("stop", help="Stop the daemon")
stop_parser.set_defaults(func=cmd_stop)
status_parser = sub.add_parser("status", help="Check daemon status")
status_parser.set_defaults(func=cmd_status)
args = parser.parse_args()
if not hasattr(args, "func"):
parser.print_help()
sys.exit(1)
args.func(args)

View File

@@ -1,54 +1,82 @@
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import yaml import yaml
STYLE_FIELDS = {
"color": str,
"width": int,
"opacity": float,
"duration": float,
"pulse_speed": float,
"sound": str,
"volume": float,
}
@dataclass
class StyleConfig:
color: str = "#FF9500"
width: int = 4
opacity: float = 0.85
duration: float = 1.5
pulse_speed: float = 1.5
sound: str = "Glass"
volume: float = 0.5
def _default_running() -> StyleConfig:
return StyleConfig()
def _default_completed() -> StyleConfig:
return StyleConfig(sound="", volume=0.0)
@dataclass @dataclass
class Config: class Config:
pulse_color: str = "#FF9500" running: StyleConfig = field(default_factory=_default_running)
pulse_width: int = 4 completed: StyleConfig = field(default_factory=_default_completed)
pulse_speed: float = 1.5
pulse_opacity_min: float = 0.3
pulse_opacity_max: float = 1.0
sound_enabled: bool = True flash_mode: str = "screen"
sound_name: str = "Glass"
sound_volume: float = 0.5
poll_interval: float = 0.5 approval_tools: list[str] = field(
cooldown: float = 3.0 default_factory=lambda: ["Shell", "Write", "Delete"]
)
auto_dismiss: int = 300 approval_delay: float = 2.5
cooldown: float = 2.0
FIELD_MAP: dict[str, dict[str, str]] = { GENERAL_FIELD_MAP: dict[str, str] = {
"pulse": { "approval_delay": "approval_delay",
"color": "pulse_color",
"width": "pulse_width",
"speed": "pulse_speed",
"opacity_min": "pulse_opacity_min",
"opacity_max": "pulse_opacity_max",
},
"sound": {
"enabled": "sound_enabled",
"name": "sound_name",
"volume": "sound_volume",
},
"detection": {
"poll_interval": "poll_interval",
"cooldown": "cooldown", "cooldown": "cooldown",
},
"timeout": {
"auto_dismiss": "auto_dismiss",
},
} }
DEFAULT_CONFIG_PATH = Path.home() / ".cursor-flasher" / "config.yaml" DEFAULT_CONFIG_PATH = Path.home() / ".cursor-flasher" / "config.yaml"
def _parse_style(raw_section: dict, defaults: StyleConfig) -> StyleConfig:
"""Build a StyleConfig from a YAML section, falling back to defaults."""
overrides: dict[str, Any] = {}
for key, typ in STYLE_FIELDS.items():
if key in raw_section:
overrides[key] = typ(raw_section[key])
return StyleConfig(
color=overrides.get("color", defaults.color),
width=overrides.get("width", defaults.width),
opacity=overrides.get("opacity", defaults.opacity),
duration=overrides.get("duration", defaults.duration),
pulse_speed=overrides.get("pulse_speed", defaults.pulse_speed),
sound=overrides.get("sound", defaults.sound),
volume=overrides.get("volume", defaults.volume),
)
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
"""Load config from YAML, falling back to defaults for missing values.""" """Load config from YAML, falling back to defaults for missing values."""
if not path.exists(): if not path.exists():
@@ -60,13 +88,28 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
if not raw or not isinstance(raw, dict): if not raw or not isinstance(raw, dict):
return Config() return Config()
overrides: dict[str, Any] = {} config_kwargs: dict[str, Any] = {}
for section, mapping in FIELD_MAP.items():
section_data = raw.get(section, {})
if not isinstance(section_data, dict):
continue
for yaml_key, field_name in mapping.items():
if yaml_key in section_data:
overrides[field_name] = section_data[yaml_key]
return Config(**overrides) running_raw = raw.get("running")
if isinstance(running_raw, dict):
config_kwargs["running"] = _parse_style(running_raw, _default_running())
completed_raw = raw.get("completed")
if isinstance(completed_raw, dict):
config_kwargs["completed"] = _parse_style(completed_raw, _default_completed())
flash_raw = raw.get("flash")
if isinstance(flash_raw, dict) and "mode" in flash_raw:
config_kwargs["flash_mode"] = flash_raw["mode"]
general_raw = raw.get("general", {})
if isinstance(general_raw, dict):
for yaml_key, field_name in GENERAL_FIELD_MAP.items():
if yaml_key in general_raw:
config_kwargs[field_name] = general_raw[yaml_key]
tools = raw.get("approval_tools")
if isinstance(tools, list):
config_kwargs["approval_tools"] = [str(t) for t in tools]
return Config(**config_kwargs)

View File

@@ -0,0 +1,293 @@
"""Daemon that listens for flash triggers from Cursor hooks via a Unix socket."""
import json
import logging
import os
import signal
import socket
import time
from Cocoa import NSApplication, NSRunLoop, NSDate
from Quartz import (
CGEventSourceSecondsSinceLastEventType,
kCGEventSourceStateHIDSystemState,
kCGEventLeftMouseDown,
kCGEventRightMouseDown,
kCGEventKeyDown,
)
from cursor_flasher.config import Config, load_config
from cursor_flasher.overlay import OverlayManager
from cursor_flasher.sound import play_alert
from cursor_flasher.windows import (
find_window_by_workspace,
screen_frame_for_window,
all_screen_frames,
is_cursor_frontmost,
)
logger = logging.getLogger("cursor_flasher")
SOCKET_DIR = os.path.expanduser("~/.cursor-flasher")
SOCKET_PATH = os.path.join(SOCKET_DIR, "flasher.sock")
INPUT_DISMISS_GRACE = 0.5
class _PendingApproval:
"""An approval trigger waiting for the delay to expire before pulsing."""
__slots__ = ("workspace", "tool", "timestamp")
def __init__(self, workspace: str, tool: str, timestamp: float):
self.workspace = workspace
self.tool = tool
self.timestamp = timestamp
class FlasherDaemon:
def __init__(self, config: Config):
self.config = config
self.overlay = OverlayManager()
self._running = False
self._server: socket.socket | None = None
self._last_flash: float = 0
self._pending: _PendingApproval | None = None
self._pulse_started_at: float = 0
self._cursor_was_frontmost: bool = False
def run(self) -> None:
NSApplication.sharedApplication()
self._running = True
self._setup_socket()
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
logger.info("Cursor Flasher daemon started (socket: %s)", SOCKET_PATH)
logger.info(
"Approval tools: %s delay: %.1fs",
self.config.approval_tools,
self.config.approval_delay,
)
while self._running:
self._check_socket()
self._check_pending()
self._check_input_dismiss()
self._check_focus()
NSRunLoop.currentRunLoop().runUntilDate_(
NSDate.dateWithTimeIntervalSinceNow_(0.1)
)
self._cleanup()
def stop(self) -> None:
self._running = False
def _setup_socket(self) -> None:
os.makedirs(SOCKET_DIR, exist_ok=True)
if os.path.exists(SOCKET_PATH):
os.unlink(SOCKET_PATH)
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._server.bind(SOCKET_PATH)
self._server.listen(5)
self._server.setblocking(False)
def _check_socket(self) -> None:
if self._server is None:
return
try:
conn, _ = self._server.accept()
try:
data = conn.recv(4096)
if data:
self._handle_message(data)
finally:
conn.close()
except BlockingIOError:
pass
def _check_pending(self) -> None:
"""Promote a pending approval to an active pulse after the delay expires."""
if self._pending is None:
return
elapsed = time.monotonic() - self._pending.timestamp
if elapsed < self.config.approval_delay:
return
pending = self._pending
self._pending = None
window = find_window_by_workspace(pending.workspace)
if window is None:
logger.warning("No Cursor window found for pending approval")
return
frames = self._resolve_frames(window["frame"])
logger.info(
"Pulsing for approval (after %.1fs delay): tool=%s window=%s",
elapsed, pending.tool, window["title"],
)
self.overlay.pulse(frames, self.config.running)
self._pulse_started_at = time.monotonic()
self._cursor_was_frontmost = is_cursor_frontmost()
play_alert(self.config.running)
self._last_flash = time.monotonic()
def _check_input_dismiss(self) -> None:
"""Dismiss pulse when user clicks or types while Cursor is frontmost.
Polls CGEventSourceSecondsSinceLastEventType which reads system-wide
HID counters — works reliably from forked daemon processes unlike
CGEventTap callbacks which silently fail without a window server
connection.
"""
if not self.overlay.is_pulsing:
return
if not is_cursor_frontmost():
return
pulse_age = time.monotonic() - self._pulse_started_at
if pulse_age < INPUT_DISMISS_GRACE:
return
last_click = CGEventSourceSecondsSinceLastEventType(
kCGEventSourceStateHIDSystemState, kCGEventLeftMouseDown
)
last_rclick = CGEventSourceSecondsSinceLastEventType(
kCGEventSourceStateHIDSystemState, kCGEventRightMouseDown
)
last_key = CGEventSourceSecondsSinceLastEventType(
kCGEventSourceStateHIDSystemState, kCGEventKeyDown
)
last_input = min(last_click, last_rclick, last_key)
if last_input < (pulse_age - INPUT_DISMISS_GRACE):
logger.info(
"User input in Cursor — dismissing pulse "
"(input %.1fs ago, pulse %.1fs old)",
last_input, pulse_age,
)
self._dismiss_pulse()
def _check_focus(self) -> None:
"""Dismiss pulse when user switches TO Cursor via Cmd+Tab or similar.
Detects the transition from another app to Cursor.
"""
if not self.overlay.is_pulsing:
return
frontmost = is_cursor_frontmost()
if frontmost and not self._cursor_was_frontmost:
logger.info("Cursor became frontmost — dismissing pulse")
self._dismiss_pulse()
self._cursor_was_frontmost = frontmost
def _dismiss_pulse(self) -> None:
"""Centralized pulse dismissal."""
self.overlay.dismiss()
def _handle_message(self, raw: bytes) -> None:
try:
msg = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError):
logger.warning("Invalid message received")
return
workspace = msg.get("workspace", "")
event = msg.get("event", "")
tool = msg.get("tool", "")
logger.info("Received: event=%s tool=%s pulsing=%s pending=%s",
event, tool, self.overlay.is_pulsing,
self._pending is not None)
if event == "preToolUse":
self._handle_approval(workspace, tool)
elif event in ("postToolUse", "postToolUseFailure"):
self._handle_dismiss(event, tool)
elif event == "stop":
self._handle_stop(workspace)
else:
logger.debug("Ignoring event: %s", event)
def _handle_approval(self, workspace: str, tool: str) -> None:
if tool and tool not in self.config.approval_tools:
return
now = time.monotonic()
if (now - self._last_flash) < self.config.cooldown:
return
logger.debug("Queuing approval: tool=%s workspace=%s", tool, workspace)
self._pending = _PendingApproval(workspace, tool, now)
def _handle_dismiss(self, event: str, tool: str) -> None:
if self._pending is not None:
logger.debug(
"Cancelled pending approval (auto-approved): %s tool=%s",
event, tool,
)
self._pending = None
if self.overlay.is_pulsing:
logger.info("Dismissing pulse: %s tool=%s", event, tool)
self._dismiss_pulse()
def _handle_stop(self, workspace: str) -> None:
self._pending = None
if self.overlay.is_pulsing:
self._dismiss_pulse()
return
now = time.monotonic()
if (now - self._last_flash) < self.config.cooldown:
return
window = find_window_by_workspace(workspace)
if window is None:
return
frames = self._resolve_frames(window["frame"])
logger.info("Flash for stop: window=%s", window["title"])
self.overlay.flash(frames, self.config.completed)
play_alert(self.config.completed)
self._last_flash = now
def _resolve_frames(self, window_frame: tuple) -> list[tuple]:
"""Return frame(s) based on flash_mode config."""
mode = self.config.flash_mode
if mode == "allscreens":
return all_screen_frames()
if mode == "screen":
return [screen_frame_for_window(window_frame)]
return [window_frame]
def _cleanup(self) -> None:
self.overlay.hide()
if self._server is not None:
self._server.close()
self._server = None
if os.path.exists(SOCKET_PATH):
os.unlink(SOCKET_PATH)
logger.info("Cursor Flasher daemon stopped")
def _handle_signal(self, signum, frame):
if not self._running:
return
logger.info("Received signal %d, shutting down", signum)
self.stop()
def run_daemon() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
config = load_config()
daemon = FlasherDaemon(config)
daemon.run()

View File

@@ -1,131 +0,0 @@
"""Accessibility-based detection of Cursor's agent state.
Detection strategy (based on a11y tree analysis):
- Cursor is an Electron app; web content is exposed via AXWebArea.
- In-app buttons render as AXStaticText with their label in the 'value' attr,
NOT as AXButton elements (those are native window controls only).
- We collect both AXStaticText values and AXButton titles, then match against
known keywords for "agent working" and "approval needed" states.
"""
from dataclasses import dataclass
import re
from ApplicationServices import (
AXUIElementCreateApplication,
AXUIElementCopyAttributeNames,
AXUIElementCopyAttributeValue,
)
from Cocoa import NSWorkspace
CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92"
AGENT_WORKING_EXACT = {"Stop", "Cancel generating"}
AGENT_WORKING_PATTERNS = [re.compile(r"^Generating\b", re.IGNORECASE)]
APPROVAL_EXACT = {"Accept", "Reject", "Accept All", "Deny"}
APPROVAL_PATTERNS = [
re.compile(r"^Run\b", re.IGNORECASE),
re.compile(r"^Allow\b", re.IGNORECASE),
]
@dataclass
class UISignals:
agent_working: bool = False
approval_needed: bool = False
def _text_matches(text: str, exact_set: set[str], patterns: list[re.Pattern]) -> bool:
if text in exact_set:
return True
return any(p.search(text) for p in patterns)
def parse_ui_signals(elements: list[dict]) -> UISignals:
"""Parse flattened UI elements into detection signals."""
agent_working = False
approval_needed = False
for el in elements:
role = el.get("role", "")
label = ""
if role == "AXStaticText":
label = el.get("value", "")
elif role == "AXButton":
label = el.get("title", "")
if not label:
continue
if _text_matches(label, AGENT_WORKING_EXACT, AGENT_WORKING_PATTERNS):
agent_working = True
if _text_matches(label, APPROVAL_EXACT, APPROVAL_PATTERNS):
approval_needed = True
return UISignals(agent_working=agent_working, approval_needed=approval_needed)
class CursorDetector:
"""Polls Cursor's accessibility tree for agent state signals."""
def __init__(self):
self._pid: int | None = None
def poll(self) -> UISignals | None:
"""Poll Cursor's a11y tree and return detected signals, or None if Cursor isn't running."""
pid = self._find_cursor_pid()
if pid is None:
self._pid = None
return None
self._pid = pid
app_element = AXUIElementCreateApplication(pid)
elements = self._collect_elements(app_element, max_depth=15)
return parse_ui_signals(elements)
def _find_cursor_pid(self) -> int | None:
workspace = NSWorkspace.sharedWorkspace()
for app in workspace.runningApplications():
bundle = app.bundleIdentifier() or ""
if bundle == CURSOR_BUNDLE_ID:
return app.processIdentifier()
return None
def _collect_elements(
self, element, max_depth: int = 15, depth: int = 0
) -> list[dict]:
"""Walk the a11y tree collecting button and static text elements."""
if depth > max_depth:
return []
results: list[dict] = []
err, attr_names = AXUIElementCopyAttributeNames(element, None)
if err or not attr_names:
return results
role = ""
title = ""
value = ""
for name in attr_names:
val_err, val = AXUIElementCopyAttributeValue(element, name, None)
if val_err:
continue
if name == "AXRole":
role = str(val)
elif name == "AXTitle":
title = str(val) if val else ""
elif name == "AXValue":
value = str(val) if val else ""
if role == "AXStaticText" and value:
results.append({"role": role, "value": value})
elif role == "AXButton" and title:
results.append({"role": role, "title": title})
err, children = AXUIElementCopyAttributeValue(element, "AXChildren", None)
if not err and children:
for child in children:
results.extend(self._collect_elements(child, max_depth, depth + 1))
return results

View File

@@ -1,33 +1,25 @@
"""Native macOS overlay window that draws a pulsing border around a target window.""" """Native macOS overlay that draws a flash or pulsing border around one or more frames."""
import enum
import logging
import math import math
import objc import objc
from Cocoa import ( from Cocoa import (
NSApplication,
NSWindow, NSWindow,
NSBorderlessWindowMask, NSBorderlessWindowMask,
NSColor, NSColor,
NSView, NSView,
NSBezierPath, NSBezierPath,
NSTimer, NSTimer,
NSScreen,
) )
from Foundation import NSInsetRect from Foundation import NSInsetRect
from Quartz import (
CGWindowListCopyWindowInfo,
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID,
kCGWindowOwnerPID,
kCGWindowBounds,
kCGWindowLayer,
kCGWindowNumber,
)
from cursor_flasher.config import Config from cursor_flasher.config import StyleConfig
logger = logging.getLogger("cursor_flasher")
def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor: def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor:
"""Convert a hex color string to NSColor."""
hex_str = hex_str.lstrip("#") hex_str = hex_str.lstrip("#")
r = int(hex_str[0:2], 16) / 255.0 r = int(hex_str[0:2], 16) / 255.0
g = int(hex_str[2:4], 16) / 255.0 g = int(hex_str[2:4], 16) / 255.0
@@ -35,185 +27,176 @@ def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor:
return NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha) return NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha)
class PulseBorderView(NSView): class FlashBorderView(NSView):
"""Custom view that draws a pulsing border rectangle.""" """View that draws a solid border rectangle at a given opacity."""
def initWithFrame_config_(self, frame, config): def initWithFrame_(self, frame):
self = objc.super(PulseBorderView, self).initWithFrame_(frame) self = objc.super(FlashBorderView, self).initWithFrame_(frame)
if self is None: if self is None:
return None return None
self._config = config self._style = StyleConfig()
self._phase = 0.0 self._alpha = 0.0
return self return self
def drawRect_(self, rect): def drawRect_(self, rect):
opacity_range = self._config.pulse_opacity_max - self._config.pulse_opacity_min if self._alpha <= 0:
alpha = self._config.pulse_opacity_min + opacity_range * ( return
0.5 + 0.5 * math.sin(self._phase) color = hex_to_nscolor(self._style.color, self._alpha)
)
color = hex_to_nscolor(self._config.pulse_color, alpha)
color.setStroke() color.setStroke()
width = self._style.width
width = self._config.pulse_width
inset = width / 2.0 inset = width / 2.0
bounds = objc.super(PulseBorderView, self).bounds() bounds = objc.super(FlashBorderView, self).bounds()
inset_rect = NSInsetRect(bounds, inset, inset) inset_rect = NSInsetRect(bounds, inset, inset)
path = NSBezierPath.bezierPathWithRect_(inset_rect) path = NSBezierPath.bezierPathWithRect_(inset_rect)
path.setLineWidth_(width) path.setLineWidth_(width)
path.stroke() path.stroke()
def setPhase_(self, phase): def setAlpha_(self, alpha):
self._phase = phase self._alpha = alpha
self.setNeedsDisplay_(True) self.setNeedsDisplay_(True)
class _OverlayEntry: class _Mode(enum.Enum):
"""A single overlay window + view pair.""" IDLE = "idle"
__slots__ = ("window", "view") FLASH = "flash"
PULSE = "pulse"
def __init__(self, window: NSWindow, view: PulseBorderView):
self.window = window
self.view = view
class OverlayManager: class OverlayManager:
"""Manages overlay windows for all Cursor windows belonging to a PID. """Manages overlay borders on one or more frames simultaneously.
Creates one transparent overlay per Cursor window and keeps them Two modes:
positioned and animated in sync. - flash(): brief fade-in/hold/fade-out, auto-dismisses
- pulse(): continuous sine-wave pulsing until dismiss() is called
""" """
def __init__(self, config: Config): def __init__(self):
self._config = config self._panels: list[tuple[NSWindow, FlashBorderView]] = []
self._overlays: list[_OverlayEntry] = []
self._timer: NSTimer | None = None self._timer: NSTimer | None = None
self._phase = 0.0 self._elapsed: float = 0.0
self._target_pid: int | None = None self._mode: _Mode = _Mode.IDLE
self._style: StyleConfig = StyleConfig()
def show(self, pid: int) -> None: @property
"""Show pulsing overlays around every window belonging to `pid`.""" def is_pulsing(self) -> bool:
self._target_pid = pid return self._mode == _Mode.PULSE
frames = self._get_all_window_frames(pid)
if not frames:
return
self._sync_overlays(frames) def flash(self, frames: list[tuple], style: StyleConfig) -> None:
"""Brief flash: fade in, hold, fade out, auto-dismiss."""
self._show(frames, _Mode.FLASH, style)
for entry in self._overlays: def pulse(self, frames: list[tuple], style: StyleConfig) -> None:
entry.window.orderFrontRegardless() """Continuous pulse: sine-wave opacity until dismiss() is called."""
self._show(frames, _Mode.PULSE, style)
self._start_animation() def dismiss(self) -> None:
"""Stop any animation and hide all overlays."""
self._stop_timer()
self._mode = _Mode.IDLE
for window, view in self._panels:
view.setAlpha_(0.0)
window.setAlphaValue_(0.0)
window.orderOut_(None)
logger.debug("Overlay dismissed (%d panels hidden)", len(self._panels))
def hide(self) -> None: def hide(self) -> None:
"""Hide all overlay windows.""" self.dismiss()
self._stop_animation()
for entry in self._overlays:
entry.window.orderOut_(None)
def update_positions(self) -> None: def _show(self, frames: list[tuple], mode: _Mode, style: StyleConfig) -> None:
"""Reposition overlays to track current Cursor window positions.""" self._stop_timer()
if self._target_pid is None: self._elapsed = 0.0
return self._mode = mode
frames = self._get_all_window_frames(self._target_pid) self._style = style
self._sync_overlays(frames)
def _sync_overlays(self, frames: list[tuple]) -> None: self._ensure_panels(len(frames))
"""Ensure we have exactly len(frames) overlays, positioned correctly.
Reuses existing overlay windows where possible, creates new ones for i, frame in enumerate(frames):
if more windows appeared, and hides extras if windows closed. window, view = self._panels[i]
""" view._style = style
needed = len(frames) window.setFrame_display_(frame, True)
existing = len(self._overlays)
for i in range(needed):
frame = frames[i]
content_rect = ((0, 0), (frame[1][0], frame[1][1])) content_rect = ((0, 0), (frame[1][0], frame[1][1]))
view.setFrame_(content_rect)
view.setAlpha_(style.opacity)
window.setAlphaValue_(1.0)
window.orderFrontRegardless()
if i < existing: for j in range(len(frames), len(self._panels)):
entry = self._overlays[i] self._panels[j][0].orderOut_(None)
entry.window.setFrame_display_(frame, True)
entry.view.setFrame_(content_rect)
else:
entry = self._create_overlay(frame)
self._overlays.append(entry)
entry.window.orderFrontRegardless()
for i in range(needed, existing): interval = 1.0 / 30.0
self._overlays[i].window.orderOut_(None)
if needed < existing:
self._overlays = self._overlays[:needed]
def _create_overlay(self, frame) -> _OverlayEntry:
window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
frame, NSBorderlessWindowMask, 2, False # NSBackingStoreBuffered
)
window.setOpaque_(False)
window.setBackgroundColor_(NSColor.clearColor())
window.setLevel_(25) # Above normal windows
window.setIgnoresMouseEvents_(True)
window.setHasShadow_(False)
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
view = PulseBorderView.alloc().initWithFrame_config_(
content_rect, self._config
)
window.setContentView_(view)
return _OverlayEntry(window, view)
def _start_animation(self) -> None:
if self._timer is not None:
return
interval = 1.0 / 30.0 # 30 fps
self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
interval, self, "_tick:", None, True interval, self, "_tick:", None, True
) )
def _stop_animation(self) -> None: def _ensure_panels(self, count: int) -> None:
"""Grow the panel pool if needed."""
while len(self._panels) < count:
dummy = ((0, 0), (1, 1))
self._panels.append(self._create_overlay(dummy))
def _create_overlay(self, frame) -> tuple:
window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
frame, NSBorderlessWindowMask, 2, False
)
window.setOpaque_(False)
window.setBackgroundColor_(NSColor.clearColor())
window.setLevel_(2147483631)
window.setIgnoresMouseEvents_(True)
window.setHasShadow_(False)
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
view = FlashBorderView.alloc().initWithFrame_(content_rect)
window.setContentView_(view)
return window, view
def _stop_timer(self) -> None:
if self._timer is not None: if self._timer is not None:
self._timer.invalidate() self._timer.invalidate()
self._timer = None self._timer = None
self._phase = 0.0
@objc.python_method @objc.python_method
def _tick_impl(self): def _tick_impl(self):
speed = self._config.pulse_speed dt = 1.0 / 30.0
step = (2.0 * math.pi) / (speed * 30.0) self._elapsed += dt
self._phase += step
for entry in self._overlays: match self._mode:
entry.view.setPhase_(self._phase) case _Mode.FLASH:
self.update_positions() self._tick_flash()
case _Mode.PULSE:
self._tick_pulse()
case _Mode.IDLE:
self._stop_timer()
def _tick_flash(self) -> None:
duration = self._style.duration
fade_in = 0.15
fade_out = 0.4
hold_end = duration - fade_out
if self._elapsed < fade_in:
alpha = self._style.opacity * (self._elapsed / fade_in)
elif self._elapsed < hold_end:
alpha = self._style.opacity
elif self._elapsed < duration:
progress = (self._elapsed - hold_end) / fade_out
alpha = self._style.opacity * (1.0 - progress)
else:
self.dismiss()
return
self._set_all_alpha(alpha)
def _tick_pulse(self) -> None:
speed = self._style.pulse_speed
phase = (2.0 * math.pi * self._elapsed) / speed
opacity_min = 0.3
t = 0.5 + 0.5 * math.sin(phase)
alpha = opacity_min + (self._style.opacity - opacity_min) * t
self._set_all_alpha(alpha)
def _set_all_alpha(self, alpha: float) -> None:
for _, view in self._panels:
view.setAlpha_(alpha)
def _tick_(self, timer) -> None: def _tick_(self, timer) -> None:
self._tick_impl() self._tick_impl()
def _get_all_window_frames(self, pid: int) -> list[tuple]:
"""Get screen frames for all on-screen windows belonging to `pid`."""
window_list = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
)
if not window_list:
return []
screen_height = NSScreen.mainScreen().frame().size.height
frames = []
for info in window_list:
if info.get(kCGWindowOwnerPID) != pid:
continue
if info.get(kCGWindowLayer, 999) != 0:
continue
bounds = info.get(kCGWindowBounds)
if bounds is None:
continue
w = bounds["Width"]
h = bounds["Height"]
if w < 100 or h < 100:
continue
x = bounds["X"]
y = screen_height - bounds["Y"] - h
frames.append(((x, y), (w, h)))
return frames

View File

@@ -1,17 +1,14 @@
"""System sound playback.""" """System sound playback."""
from Cocoa import NSSound from Cocoa import NSSound
from cursor_flasher.config import Config from cursor_flasher.config import StyleConfig
def play_alert(config: Config) -> None: def play_alert(style: StyleConfig) -> None:
"""Play the configured alert sound if enabled.""" if not style.sound:
if not config.sound_enabled:
return return
sound = NSSound.soundNamed_(style.sound)
sound = NSSound.soundNamed_(config.sound_name)
if sound is None: if sound is None:
return return
sound.setVolume_(style.volume)
sound.setVolume_(config.sound_volume)
sound.play() sound.play()

View File

@@ -1,53 +0,0 @@
import enum
import time
class FlasherState(enum.Enum):
IDLE = "idle"
AGENT_WORKING = "agent_working"
WAITING_FOR_USER = "waiting_for_user"
class StateMachine:
def __init__(self, cooldown: float = 3.0):
self.state = FlasherState.IDLE
self.cooldown = cooldown
self._last_dismiss_time: float = 0
def update(self, *, agent_working: bool, approval_needed: bool) -> bool:
"""Update state based on detected signals. Returns True if state changed."""
old = self.state
if approval_needed and self.state != FlasherState.WAITING_FOR_USER:
if not self._in_cooldown():
self.state = FlasherState.WAITING_FOR_USER
return self.state != old
match self.state:
case FlasherState.IDLE:
if agent_working:
self.state = FlasherState.AGENT_WORKING
case FlasherState.AGENT_WORKING:
if not agent_working:
if not self._in_cooldown():
self.state = FlasherState.WAITING_FOR_USER
else:
self.state = FlasherState.IDLE
case FlasherState.WAITING_FOR_USER:
if agent_working:
self.state = FlasherState.AGENT_WORKING
return self.state != old
def dismiss(self) -> bool:
"""User interaction detected — dismiss the flash."""
if self.state == FlasherState.WAITING_FOR_USER:
self.state = FlasherState.IDLE
self._last_dismiss_time = time.monotonic()
return True
return False
def _in_cooldown(self) -> bool:
if self._last_dismiss_time == 0:
return False
return (time.monotonic() - self._last_dismiss_time) < self.cooldown

View File

@@ -0,0 +1,153 @@
"""Cursor window discovery and geometry via macOS accessibility APIs.
Only used for finding Cursor windows and reading their screen position.
Detection of agent state is handled by Cursor hooks, not a11y polling.
"""
import logging
from ApplicationServices import (
AXUIElementCreateApplication,
AXUIElementCopyAttributeValue,
AXValueGetValue,
kAXValueTypeCGPoint,
kAXValueTypeCGSize,
)
from Cocoa import NSScreen, NSWorkspace
logger = logging.getLogger("cursor_flasher")
CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92"
def is_cursor_frontmost() -> bool:
"""Return True if Cursor is the frontmost (active) application."""
app = NSWorkspace.sharedWorkspace().frontmostApplication()
if app is None:
return False
return (app.bundleIdentifier() or "") == CURSOR_BUNDLE_ID
def find_cursor_pid() -> int | None:
workspace = NSWorkspace.sharedWorkspace()
for app in workspace.runningApplications():
bundle = app.bundleIdentifier() or ""
if bundle == CURSOR_BUNDLE_ID:
return app.processIdentifier()
return None
def get_cursor_windows() -> list[dict]:
"""Return a list of Cursor windows with their titles and AX-coordinate frames.
Each entry: {"title": str, "frame": ((x, y), (w, h))}
Frame is in AppKit coordinates (bottom-left origin).
"""
pid = find_cursor_pid()
if pid is None:
return []
app = AXUIElementCreateApplication(pid)
err, children = AXUIElementCopyAttributeValue(app, "AXChildren", None)
if err or not children:
return []
screen_height = NSScreen.mainScreen().frame().size.height
results = []
for child in children:
err, role = AXUIElementCopyAttributeValue(child, "AXRole", None)
if err or str(role) != "AXWindow":
continue
err, title = AXUIElementCopyAttributeValue(child, "AXTitle", None)
title = str(title) if not err and title else ""
frame = _read_frame(child, screen_height)
if frame is None:
continue
results.append({"title": title, "frame": frame})
return results
def find_window_by_workspace(workspace_path: str) -> dict | None:
"""Find the Cursor window whose title contains the workspace folder name.
Cursor titles are typically "<filename> — <project-name>".
Returns None if no match or ambiguous (avoids flashing the wrong window).
Only falls back to the sole window if there's exactly one.
"""
windows = get_cursor_windows()
if not windows:
return None
if not workspace_path:
return windows[0] if len(windows) == 1 else None
folder_name = workspace_path.rstrip("/").rsplit("/", 1)[-1]
if not folder_name:
return windows[0] if len(windows) == 1 else None
matches = [w for w in windows if folder_name in w["title"]]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
exact = [w for w in matches if w["title"].endswith(folder_name)]
if len(exact) == 1:
return exact[0]
return matches[0]
return windows[0] if len(windows) == 1 else None
def screen_frame_for_window(window_frame: tuple) -> tuple:
"""Return the NSScreen frame of the monitor containing the window's center.
Falls back to the main screen if no screen contains the center point.
"""
wx, wy = window_frame[0]
ww, wh = window_frame[1]
cx = wx + ww / 2.0
cy = wy + wh / 2.0
for screen in NSScreen.screens():
sf = screen.frame()
sx, sy = sf.origin.x, sf.origin.y
sw, sh = sf.size.width, sf.size.height
if sx <= cx < sx + sw and sy <= cy < sy + sh:
return ((sx, sy), (sw, sh))
main = NSScreen.mainScreen().frame()
return ((main.origin.x, main.origin.y), (main.size.width, main.size.height))
def all_screen_frames() -> list[tuple]:
"""Return frames for every connected screen."""
frames = []
for screen in NSScreen.screens():
sf = screen.frame()
frames.append(((sf.origin.x, sf.origin.y), (sf.size.width, sf.size.height)))
return frames
def _read_frame(ax_window, screen_height: float) -> tuple | None:
_, pos_val = AXUIElementCopyAttributeValue(ax_window, "AXPosition", None)
_, size_val = AXUIElementCopyAttributeValue(ax_window, "AXSize", None)
if pos_val is None or size_val is None:
return None
_, point = AXValueGetValue(pos_val, kAXValueTypeCGPoint, None)
_, size = AXValueGetValue(size_val, kAXValueTypeCGSize, None)
if point is None or size is None:
return None
x = point.x
w = size.width
h = size.height
y = screen_height - point.y - h
return ((x, y), (w, h))

View File

@@ -1,56 +1,119 @@
import pytest from cursor_flasher.config import Config, StyleConfig, load_config
from pathlib import Path
from cursor_flasher.config import Config, load_config, DEFAULT_CONFIG_PATH
class TestDefaultConfig: class TestDefaultConfig:
def test_has_pulse_settings(self): def test_running_defaults(self):
cfg = Config() c = Config()
assert cfg.pulse_color == "#FF9500" assert c.running.color == "#FF9500"
assert cfg.pulse_width == 4 assert c.running.width == 4
assert cfg.pulse_speed == 1.5 assert c.running.duration == 1.5
assert cfg.pulse_opacity_min == 0.3 assert c.running.opacity == 0.85
assert cfg.pulse_opacity_max == 1.0 assert c.running.pulse_speed == 1.5
assert c.running.sound == "Glass"
assert c.running.volume == 0.5
def test_has_sound_settings(self): def test_completed_defaults(self):
cfg = Config() c = Config()
assert cfg.sound_enabled is True assert c.completed.color == "#FF9500"
assert cfg.sound_name == "Glass" assert c.completed.width == 4
assert cfg.sound_volume == 0.5 assert c.completed.sound == ""
assert c.completed.volume == 0.0
def test_has_detection_settings(self): def test_has_approval_tools(self):
cfg = Config() c = Config()
assert cfg.poll_interval == 0.5 assert c.approval_tools == ["Shell", "Write", "Delete"]
assert cfg.cooldown == 3.0
def test_has_timeout_settings(self): def test_has_cooldown(self):
cfg = Config() c = Config()
assert cfg.auto_dismiss == 300 assert c.cooldown == 2.0
def test_has_flash_mode(self):
c = Config()
assert c.flash_mode == "screen"
class TestLoadConfig: class TestLoadConfig:
def test_loads_from_yaml(self, tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text(
"pulse:\n"
' color: "#00FF00"\n'
" width: 8\n"
"sound:\n"
" enabled: false\n"
)
cfg = load_config(config_file)
assert cfg.pulse_color == "#00FF00"
assert cfg.pulse_width == 8
assert cfg.sound_enabled is False
assert cfg.pulse_speed == 1.5
assert cfg.sound_name == "Glass"
def test_missing_file_returns_defaults(self, tmp_path): def test_missing_file_returns_defaults(self, tmp_path):
cfg = load_config(tmp_path / "nonexistent.yaml") c = load_config(tmp_path / "nope.yaml")
assert cfg.pulse_color == "#FF9500" assert c == Config()
def test_empty_file_returns_defaults(self, tmp_path): def test_empty_file_returns_defaults(self, tmp_path):
config_file = tmp_path / "config.yaml" p = tmp_path / "config.yaml"
config_file.write_text("") p.write_text("")
cfg = load_config(config_file) c = load_config(p)
assert cfg.pulse_color == "#FF9500" assert c == Config()
def test_loads_running_overrides(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"running:\n color: '#00FF00'\n duration: 2.0\n sound: Ping\n"
)
c = load_config(p)
assert c.running.color == "#00FF00"
assert c.running.duration == 2.0
assert c.running.sound == "Ping"
assert c.running.width == 4
def test_loads_completed_overrides(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"completed:\n color: '#0000FF'\n sound: Hero\n volume: 0.8\n"
)
c = load_config(p)
assert c.completed.color == "#0000FF"
assert c.completed.sound == "Hero"
assert c.completed.volume == 0.8
def test_loads_flash_mode(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text("flash:\n mode: allscreens\n")
c = load_config(p)
assert c.flash_mode == "allscreens"
def test_loads_general_overrides(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text("general:\n cooldown: 5.0\n approval_delay: 3.0\n")
c = load_config(p)
assert c.cooldown == 5.0
assert c.approval_delay == 3.0
def test_loads_approval_tools(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text("approval_tools:\n - Shell\n - MCP\n")
c = load_config(p)
assert c.approval_tools == ["Shell", "MCP"]
def test_full_config(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"running:\n"
" color: '#FF0000'\n"
" width: 6\n"
" opacity: 0.9\n"
" pulse_speed: 2.0\n"
" sound: Glass\n"
" volume: 0.8\n"
"completed:\n"
" color: '#00FF00'\n"
" sound: ''\n"
"flash:\n"
" mode: window\n"
"general:\n"
" approval_delay: 1.0\n"
" cooldown: 3.0\n"
"approval_tools:\n"
" - Shell\n"
)
c = load_config(p)
assert c.running.color == "#FF0000"
assert c.running.width == 6
assert c.running.opacity == 0.9
assert c.running.pulse_speed == 2.0
assert c.running.sound == "Glass"
assert c.running.volume == 0.8
assert c.completed.color == "#00FF00"
assert c.completed.sound == ""
assert c.flash_mode == "window"
assert c.approval_delay == 1.0
assert c.cooldown == 3.0
assert c.approval_tools == ["Shell"]

388
tests/test_daemon.py Normal file
View File

@@ -0,0 +1,388 @@
"""Tests for the daemon's message handling logic."""
import json
import time
from unittest.mock import patch, MagicMock
from cursor_flasher.config import Config, StyleConfig
from cursor_flasher.daemon import FlasherDaemon
class TestFlasherDaemon:
def _make_daemon(self, **config_overrides) -> FlasherDaemon:
config = Config(**config_overrides)
with patch("cursor_flasher.daemon.OverlayManager"):
daemon = FlasherDaemon(config)
daemon.overlay.is_pulsing = False
return daemon
def test_preToolUse_queues_pending(self):
daemon = self._make_daemon()
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert daemon._pending is not None
assert daemon._pending.tool == "Shell"
daemon.overlay.pulse.assert_not_called()
def test_pending_promotes_after_delay(self):
daemon = self._make_daemon(approval_delay=0.0, flash_mode="screen")
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
screen = ((0, 0), (1920, 1080))
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"):
daemon._check_pending()
daemon.overlay.pulse.assert_called_once_with([screen], daemon.config.running)
def test_postToolUse_cancels_pending(self):
"""Auto-approved tools: postToolUse arrives before delay, cancels the pulse."""
daemon = self._make_daemon(approval_delay=10.0)
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert daemon._pending is not None
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "postToolUse", "tool": "Shell"}).encode()
)
assert daemon._pending is None
daemon.overlay.pulse.assert_not_called()
def test_preToolUse_skips_non_approval_tool(self):
daemon = self._make_daemon()
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Read"}).encode()
)
assert daemon._pending is None
def test_preToolUse_respects_custom_tool_list(self):
daemon = self._make_daemon(approval_delay=0.0, flash_mode="screen", approval_tools=["Shell", "MCP"])
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "MCP"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=((0, 0), (1920, 1080))), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"):
daemon._check_pending()
daemon.overlay.pulse.assert_called_once()
def test_stop_flashes_briefly(self):
daemon = self._make_daemon(flash_mode="screen")
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
screen = ((0, 0), (1920, 1080))
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
patch("cursor_flasher.daemon.play_alert"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
daemon.overlay.flash.assert_called_once_with([screen], daemon.config.completed)
def test_stop_flashes_window_frame_when_window_mode(self):
daemon = self._make_daemon(flash_mode="window")
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.play_alert"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
daemon.overlay.flash.assert_called_once_with(
[((0, 0), (800, 600))], daemon.config.completed
)
def test_allscreens_mode_uses_all_screens(self):
daemon = self._make_daemon(flash_mode="allscreens", approval_delay=0.0)
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
screens = [((0, 0), (1920, 1080)), ((-1920, 0), (1920, 1080))]
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.all_screen_frames", return_value=screens), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"):
daemon._check_pending()
daemon.overlay.pulse.assert_called_once_with(screens, daemon.config.running)
def test_stop_dismisses_active_pulse(self):
daemon = self._make_daemon()
daemon.overlay.is_pulsing = True
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
daemon.overlay.dismiss.assert_called_once()
daemon.overlay.flash.assert_not_called()
def test_stop_clears_pending(self):
daemon = self._make_daemon(approval_delay=10.0)
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert daemon._pending is not None
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
assert daemon._pending is None
def test_postToolUse_dismisses_active_pulse(self):
daemon = self._make_daemon()
daemon.overlay.is_pulsing = True
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "postToolUse", "tool": "Shell"}).encode()
)
daemon.overlay.dismiss.assert_called_once()
def test_postToolUseFailure_dismisses_pulse(self):
daemon = self._make_daemon()
daemon.overlay.is_pulsing = True
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "postToolUseFailure", "tool": "Shell"}).encode()
)
daemon.overlay.dismiss.assert_called_once()
def test_cooldown_prevents_rapid_triggers(self):
daemon = self._make_daemon(cooldown=5.0)
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
daemon._last_flash = time.monotonic()
daemon._pending = None
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert daemon._pending is None
def test_invalid_json_ignored(self):
daemon = self._make_daemon()
daemon._handle_message(b"not json")
daemon.overlay.pulse.assert_not_called()
daemon.overlay.flash.assert_not_called()
def test_no_window_found(self):
daemon = self._make_daemon(approval_delay=0.0)
daemon._handle_message(
json.dumps({"workspace": "/nope", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=None):
daemon._check_pending()
daemon.overlay.pulse.assert_not_called()
def test_focus_transition_dismisses_pulse(self):
"""Pulse dismisses when user switches TO Cursor from another app."""
daemon = self._make_daemon()
daemon.overlay.is_pulsing = True
daemon._cursor_was_frontmost = False
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
daemon._check_focus()
daemon.overlay.dismiss.assert_called_once()
def test_focus_no_dismiss_when_cursor_already_frontmost(self):
"""No dismiss if Cursor was already frontmost (no transition)."""
daemon = self._make_daemon()
daemon.overlay.is_pulsing = True
daemon._cursor_was_frontmost = True
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
daemon._check_focus()
daemon.overlay.dismiss.assert_not_called()
def test_focus_tracks_state_changes(self):
"""_cursor_was_frontmost updates each tick."""
daemon = self._make_daemon()
daemon.overlay.is_pulsing = True
daemon._cursor_was_frontmost = True
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False):
daemon._check_focus()
assert daemon._cursor_was_frontmost is False
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
daemon._check_focus()
daemon.overlay.dismiss.assert_called_once()
def test_focus_no_dismiss_when_not_pulsing(self):
daemon = self._make_daemon()
daemon.overlay.is_pulsing = False
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
daemon._check_focus()
daemon.overlay.dismiss.assert_not_called()
def test_input_dismiss_when_pulsing_and_cursor_focused(self):
"""Recent input + Cursor frontmost + past grace period = dismiss."""
daemon = self._make_daemon()
daemon.overlay.is_pulsing = True
daemon._pulse_started_at = time.monotonic() - 2.0
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.2):
daemon._check_input_dismiss()
daemon.overlay.dismiss.assert_called_once()
def test_input_dismiss_skipped_during_grace_period(self):
"""No dismiss if pulse just started (within grace period)."""
daemon = self._make_daemon()
daemon.overlay.is_pulsing = True
daemon._pulse_started_at = time.monotonic() - 0.1
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.05):
daemon._check_input_dismiss()
daemon.overlay.dismiss.assert_not_called()
def test_input_dismiss_skipped_when_not_pulsing(self):
daemon = self._make_daemon()
daemon.overlay.is_pulsing = False
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.1):
daemon._check_input_dismiss()
daemon.overlay.dismiss.assert_not_called()
def test_input_dismiss_skipped_when_cursor_not_frontmost(self):
daemon = self._make_daemon()
daemon.overlay.is_pulsing = True
daemon._pulse_started_at = time.monotonic() - 2.0
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False):
daemon._check_input_dismiss()
daemon.overlay.dismiss.assert_not_called()
def test_input_dismiss_ignores_old_input(self):
"""Input from before the pulse started should not trigger dismiss."""
daemon = self._make_daemon()
daemon.overlay.is_pulsing = True
daemon._pulse_started_at = time.monotonic() - 2.0
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=5.0):
daemon._check_input_dismiss()
daemon.overlay.dismiss.assert_not_called()
def test_running_style_sound_plays_on_approval(self):
"""Running style with sound configured plays on approval pulse."""
running = StyleConfig(sound="Glass", volume=0.5)
daemon = self._make_daemon(approval_delay=0.0, flash_mode="window", running=running)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert") as mock_alert:
daemon._check_pending()
mock_alert.assert_called_once_with(running)
def test_completed_style_sound_plays_on_stop(self):
"""Completed style with sound configured plays on stop flash."""
completed = StyleConfig(sound="Ping", volume=0.7)
daemon = self._make_daemon(flash_mode="window", completed=completed)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.play_alert") as mock_alert:
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
mock_alert.assert_called_once_with(completed)
def test_no_sound_when_style_sound_empty(self):
"""No sound plays when the style has sound="" (the completed default)."""
completed = StyleConfig(sound="", volume=0.0)
daemon = self._make_daemon(flash_mode="window", completed=completed)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.play_alert") as mock_alert:
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
mock_alert.assert_called_once_with(completed)
def test_custom_colors_per_mode(self):
"""Different colors for running vs completed are passed through."""
running = StyleConfig(color="#FF0000")
completed = StyleConfig(color="#00FF00")
daemon = self._make_daemon(
approval_delay=0.0, flash_mode="window",
running=running, completed=completed,
)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"):
daemon._check_pending()
daemon.overlay.pulse.assert_called_once_with(
[((0, 0), (800, 600))], running
)
daemon.overlay.pulse.reset_mock()
daemon._last_flash = 0
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.play_alert"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
daemon.overlay.flash.assert_called_once_with(
[((0, 0), (800, 600))], completed
)

View File

@@ -1,72 +0,0 @@
import pytest
from unittest.mock import patch, MagicMock
from cursor_flasher.detector import (
CursorDetector,
parse_ui_signals,
UISignals,
)
class TestParseUISignals:
def test_no_elements_means_no_signals(self):
signals = parse_ui_signals([])
assert signals.agent_working is False
assert signals.approval_needed is False
def test_stop_text_means_agent_working(self):
elements = [{"role": "AXStaticText", "value": "Stop"}]
signals = parse_ui_signals(elements)
assert signals.agent_working is True
def test_cancel_generating_means_agent_working(self):
elements = [{"role": "AXStaticText", "value": "Cancel generating"}]
signals = parse_ui_signals(elements)
assert signals.agent_working is True
def test_accept_text_means_approval_needed(self):
elements = [{"role": "AXStaticText", "value": "Accept"}]
signals = parse_ui_signals(elements)
assert signals.approval_needed is True
def test_reject_text_means_approval_needed(self):
elements = [{"role": "AXStaticText", "value": "Reject"}]
signals = parse_ui_signals(elements)
assert signals.approval_needed is True
def test_run_this_time_means_approval_needed(self):
elements = [{"role": "AXStaticText", "value": "Run this time only (⏎)"}]
signals = parse_ui_signals(elements)
assert signals.approval_needed is True
def test_both_signals(self):
elements = [
{"role": "AXStaticText", "value": "Stop"},
{"role": "AXStaticText", "value": "Accept"},
]
signals = parse_ui_signals(elements)
assert signals.agent_working is True
assert signals.approval_needed is True
def test_irrelevant_text_ignored(self):
elements = [{"role": "AXStaticText", "value": "Settings"}]
signals = parse_ui_signals(elements)
assert signals.agent_working is False
assert signals.approval_needed is False
def test_button_role_also_detected(self):
elements = [{"role": "AXButton", "title": "Accept"}]
signals = parse_ui_signals(elements)
assert signals.approval_needed is True
def test_partial_match_on_run_command(self):
elements = [{"role": "AXStaticText", "value": "Run command"}]
signals = parse_ui_signals(elements)
assert signals.approval_needed is True
class TestCursorDetector:
def test_returns_none_when_cursor_not_running(self):
detector = CursorDetector()
with patch.object(detector, "_find_cursor_pid", return_value=None):
signals = detector.poll()
assert signals is None

105
tests/test_hook.py Normal file
View File

@@ -0,0 +1,105 @@
"""Tests for the hook notification script."""
import json
import os
import socket
import tempfile
import threading
import pytest
def _short_sock_path():
"""Create a short socket path that fits macOS's 104-char limit."""
fd, path = tempfile.mkstemp(suffix=".sock", dir="/tmp")
os.close(fd)
os.unlink(path)
return path
def _run_hook_main(stdin_data: str, socket_path: str):
"""Run the hook's main() with patched stdin and socket path."""
import io
import sys
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hooks"))
import notify
with patch.object(notify, "SOCKET_PATH", socket_path), \
patch("sys.stdin", io.StringIO(stdin_data)):
notify.main()
class TestHookNotify:
def test_sends_message_to_socket(self):
sock_path = _short_sock_path()
received = []
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
def accept():
conn, _ = server.accept()
data = conn.recv(4096)
received.append(json.loads(data))
conn.close()
t = threading.Thread(target=accept)
t.start()
try:
hook_input = json.dumps({
"workspace_roots": ["/Users/me/project"],
"hook_event_name": "preToolUse",
"tool_name": "Shell",
})
_run_hook_main(hook_input, sock_path)
t.join(timeout=2)
finally:
server.close()
if os.path.exists(sock_path):
os.unlink(sock_path)
assert len(received) == 1
assert received[0]["workspace"] == "/Users/me/project"
assert received[0]["event"] == "preToolUse"
assert received[0]["tool"] == "Shell"
def test_handles_missing_socket_gracefully(self):
hook_input = json.dumps({
"workspace_roots": ["/Users/me/project"],
"hook_event_name": "stop",
})
_run_hook_main(hook_input, "/tmp/nonexistent.sock")
def test_handles_empty_workspace_roots(self):
sock_path = _short_sock_path()
received = []
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
def accept():
conn, _ = server.accept()
data = conn.recv(4096)
received.append(json.loads(data))
conn.close()
t = threading.Thread(target=accept)
t.start()
try:
hook_input = json.dumps({
"workspace_roots": [],
"hook_event_name": "stop",
})
_run_hook_main(hook_input, sock_path)
t.join(timeout=2)
finally:
server.close()
if os.path.exists(sock_path):
os.unlink(sock_path)
assert received[0]["workspace"] == ""

View File

@@ -1,20 +0,0 @@
import pytest
from unittest.mock import patch, MagicMock
from cursor_flasher.sound import play_alert
from cursor_flasher.config import Config
class TestPlayAlert:
def test_does_nothing_when_disabled(self):
config = Config(sound_enabled=False)
play_alert(config)
@patch("cursor_flasher.sound.NSSound")
def test_plays_named_sound(self, mock_nssound):
mock_sound_obj = MagicMock()
mock_nssound.soundNamed_.return_value = mock_sound_obj
config = Config(sound_enabled=True, sound_name="Glass", sound_volume=0.7)
play_alert(config)
mock_nssound.soundNamed_.assert_called_once_with("Glass")
mock_sound_obj.setVolume_.assert_called_once_with(0.7)
mock_sound_obj.play.assert_called_once()

View File

@@ -1,75 +0,0 @@
import pytest
from cursor_flasher.state import FlasherState, StateMachine
class TestStateMachine:
def test_initial_state_is_idle(self):
sm = StateMachine()
assert sm.state == FlasherState.IDLE
def test_idle_to_agent_working(self):
sm = StateMachine()
changed = sm.update(agent_working=True, approval_needed=False)
assert sm.state == FlasherState.AGENT_WORKING
assert changed is True
def test_agent_working_to_waiting(self):
sm = StateMachine()
sm.update(agent_working=True, approval_needed=False)
changed = sm.update(agent_working=False, approval_needed=False)
assert sm.state == FlasherState.WAITING_FOR_USER
assert changed is True
def test_approval_needed_triggers_waiting(self):
sm = StateMachine()
sm.update(agent_working=True, approval_needed=False)
changed = sm.update(agent_working=False, approval_needed=True)
assert sm.state == FlasherState.WAITING_FOR_USER
assert changed is True
def test_idle_does_not_jump_to_waiting(self):
sm = StateMachine()
changed = sm.update(agent_working=False, approval_needed=False)
assert sm.state == FlasherState.IDLE
assert changed is False
def test_waiting_to_user_interacting(self):
sm = StateMachine()
sm.update(agent_working=True, approval_needed=False)
sm.update(agent_working=False, approval_needed=False)
assert sm.state == FlasherState.WAITING_FOR_USER
changed = sm.dismiss()
assert sm.state == FlasherState.IDLE
assert changed is True
def test_waiting_to_agent_working(self):
sm = StateMachine()
sm.update(agent_working=True, approval_needed=False)
sm.update(agent_working=False, approval_needed=False)
changed = sm.update(agent_working=True, approval_needed=False)
assert sm.state == FlasherState.AGENT_WORKING
assert changed is True
def test_no_change_returns_false(self):
sm = StateMachine()
sm.update(agent_working=True, approval_needed=False)
changed = sm.update(agent_working=True, approval_needed=False)
assert changed is False
def test_cooldown_prevents_immediate_retrigger(self):
sm = StateMachine(cooldown=5.0)
sm.update(agent_working=True, approval_needed=False)
sm.update(agent_working=False, approval_needed=False)
assert sm.state == FlasherState.WAITING_FOR_USER
sm.dismiss()
sm.update(agent_working=True, approval_needed=False)
changed = sm.update(agent_working=False, approval_needed=False)
assert sm.cooldown == 5.0
def test_direct_approval_from_idle(self):
"""If we detect approval buttons without seeing agent_working first,
still transition to WAITING_FOR_USER."""
sm = StateMachine()
changed = sm.update(agent_working=False, approval_needed=True)
assert sm.state == FlasherState.WAITING_FOR_USER
assert changed is True

348
uv.lock generated Normal file
View File

@@ -0,0 +1,348 @@
version = 1
requires-python = ">=3.10"
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "cursor-flasher"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "pyobjc-framework-applicationservices" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-quartz" },
{ name = "pyyaml" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-mock" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-mock" },
]
[package.metadata]
requires-dist = [
{ name = "pyobjc-framework-applicationservices", specifier = ">=12.1" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-quartz" },
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "pytest-mock", marker = "extra == 'dev'" },
{ name = "pyyaml" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest" },
{ name = "pytest-mock" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
]
[[package]]
name = "pyobjc-core"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/bf/3dbb1783388da54e650f8a6b88bde03c101d9ba93dfe8ab1b1873f1cd999/pyobjc_core-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93418e79c1655f66b4352168f8c85c942707cb1d3ea13a1da3e6f6a143bacda7", size = 676748 },
{ url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263 },
{ url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335 },
{ url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370 },
{ url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586 },
{ url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164 },
{ url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204 },
]
[[package]]
name = "pyobjc-framework-applicationservices"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-coretext" },
{ name = "pyobjc-framework-quartz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/6a/d4e613c8e926a5744fc47a9e9fea08384a510dc4f27d844f7ad7a2d793bd/pyobjc_framework_applicationservices-12.1.tar.gz", hash = "sha256:c06abb74f119bc27aeb41bf1aef8102c0ae1288aec1ac8665ea186a067a8945b", size = 103247 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/9d/3cf36e7b08832e71f5d48ddfa1047865cf2dfc53df8c0f2a82843ea9507a/pyobjc_framework_applicationservices-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c4fd1b008757182b9e2603a63c6ffa930cc412fab47294ec64260ab3f8ec695d", size = 32791 },
{ url = "https://files.pythonhosted.org/packages/17/86/d07eff705ff909a0ffa96d14fc14026e9fc9dd716233648c53dfd5056b8e/pyobjc_framework_applicationservices-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bdddd492eeac6d14ff2f5bd342aba29e30dffa72a2d358c08444da22129890e2", size = 32784 },
{ url = "https://files.pythonhosted.org/packages/37/a7/55fa88def5c02732c4b747606ff1cbce6e1f890734bbd00f5596b21eaa02/pyobjc_framework_applicationservices-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c8f6e2fb3b3e9214ab4864ef04eee18f592b46a986c86ea0113448b310520532", size = 32835 },
{ url = "https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bd1f4dbb38234a24ae6819f5e22485cf7dd3dd4074ff3bf9a9fdb4c01a3b4a38", size = 32859 },
{ url = "https://files.pythonhosted.org/packages/66/3a/0f1d4dcf2345e875e5ea9761d5a70969e241d24089133d21f008dde596f5/pyobjc_framework_applicationservices-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8a5d2845249b6a85ba9e320a9848468c3f8cd6f59605a9a43f406a7810eaa830", size = 33115 },
{ url = "https://files.pythonhosted.org/packages/40/44/3196b40fec68b4413c92875311f17ccf4c3ff7d2e53676f8fc18ad29bd18/pyobjc_framework_applicationservices-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f43c9a24ad97a9121276d4d571aa04a924282c80d7291cfb3b29839c3e2013a8", size = 32997 },
{ url = "https://files.pythonhosted.org/packages/fd/bb/dab21d2210d3ef7dd0616df7e8ea89b5d8d62444133a25f76e649a947168/pyobjc_framework_applicationservices-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1f72e20009a4ebfd5ed5b23dc11c1528ad6b55cc63ee71952ddb2a5e5f1cb7da", size = 33238 },
]
[[package]]
name = "pyobjc-framework-cocoa"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/aa/2b2d7ec3ac4b112a605e9bd5c5e5e4fd31d60a8a4b610ab19cc4838aa92a/pyobjc_framework_cocoa-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9b880d3bdcd102809d704b6d8e14e31611443aa892d9f60e8491e457182fdd48", size = 383825 },
{ url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812 },
{ url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590 },
{ url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689 },
{ url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843 },
{ url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932 },
{ url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970 },
]
[[package]]
name = "pyobjc-framework-coretext"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-quartz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/29/da/682c9c92a39f713bd3c56e7375fa8f1b10ad558ecb075258ab6f1cdd4a6d/pyobjc_framework_coretext-12.1.tar.gz", hash = "sha256:e0adb717738fae395dc645c9e8a10bb5f6a4277e73cba8fa2a57f3b518e71da5", size = 90124 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1c/ddecc72a672d681476c668bcedcfb8ade16383c028eac566ac7458fb91ef/pyobjc_framework_coretext-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1c8315dcef6699c2953461d97117fe81402f7c29cff36d2950dacce028a362fd", size = 29987 },
{ url = "https://files.pythonhosted.org/packages/f0/81/7b8efc41e743adfa2d74b92dec263c91bcebfb188d2a8f5eea1886a195ff/pyobjc_framework_coretext-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4f6742ba5b0bb7629c345e99eff928fbfd9e9d3d667421ac1a2a43bdb7ba9833", size = 29990 },
{ url = "https://files.pythonhosted.org/packages/cd/0f/ddf45bf0e3ba4fbdc7772de4728fd97ffc34a0b5a15e1ab1115b202fe4ae/pyobjc_framework_coretext-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d246fa654bdbf43bae3969887d58f0b336c29b795ad55a54eb76397d0e62b93c", size = 30108 },
{ url = "https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7cbb2c28580e6704ce10b9a991ccd9563a22b3a75f67c36cf612544bd8b21b5f", size = 30110 },
{ url = "https://files.pythonhosted.org/packages/0f/5d/85e059349e9cfbd57269a1f11f56747b3ff5799a3bcbd95485f363c623d8/pyobjc_framework_coretext-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:14100d1e39efb30f57869671fb6fce8d668f80c82e25e7930fb364866e5c0dab", size = 30697 },
{ url = "https://files.pythonhosted.org/packages/ef/c3/adf9d306e9ead108167ab7a974ab7d171dbacf31c72fad63e12585f58023/pyobjc_framework_coretext-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:782a1a9617ea267c05226e9cd81a8dec529969a607fe1e037541ee1feb9524e9", size = 30095 },
{ url = "https://files.pythonhosted.org/packages/bd/ca/6321295f47a47b0fca7de7e751ddc0ddc360413f4e506335fe9b0f0fb085/pyobjc_framework_coretext-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7afe379c5a870fa3e66e6f65231c3c1732d9ccd2cd2a4904b2cd5178c9e3c562", size = 30702 },
]
[[package]]
name = "pyobjc-framework-quartz"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/f4/50c42c84796886e4d360407fb629000bb68d843b2502c88318375441676f/pyobjc_framework_quartz-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c6f312ae79ef8b3019dcf4b3374c52035c7c7bc4a09a1748b61b041bb685a0ed", size = 217799 },
{ url = "https://files.pythonhosted.org/packages/b7/ef/dcd22b743e38b3c430fce4788176c2c5afa8bfb01085b8143b02d1e75201/pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a", size = 217795 },
{ url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798 },
{ url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206 },
{ url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317 },
{ url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558 },
{ url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
]
[[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 }
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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770 },
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626 },
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712 },
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296 },
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530 },
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227 },
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567 },
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556 },
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106 },
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504 },
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561 },
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
]