diff --git a/.gitignore b/.gitignore index f3ac0db..4a8e869 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea .cursor .claude +CLAUDE.md _bmad # Local configuration — copy config.yaml.example to config.yaml to get started @@ -21,4 +22,11 @@ build/ .nimble config.yaml -.env \ No newline at end of file + +# Agentic planning artifacts +docs/superpowers/ + +# Superseded X11 keepalive prototype +x11_keepalive.py + +.env diff --git a/docs/bmad_output/implementation-artifacts/spec-fix-adapter-xwayland-routing.md b/docs/bmad_output/implementation-artifacts/spec-fix-adapter-xwayland-routing.md new file mode 100644 index 0000000..ee2d156 --- /dev/null +++ b/docs/bmad_output/implementation-artifacts/spec-fix-adapter-xwayland-routing.md @@ -0,0 +1,18 @@ +--- +title: 'Fix hotkey adapter routing: prefer X11 when DISPLAY is set' +type: 'bugfix' +created: '2026-05-12' +status: 'done' +route: 'one-shot' +--- + +## Intent + +**Problem:** On Wayland sessions with XWayland running (both `WAYLAND_DISPLAY` and `DISPLAY` set), `get_adapter()` routed to `EvdevAdapter`, which requires the user to be in the `input` group. Users not in that group got a silent empty device list and a startup failure. + +**Approach:** Prioritise `DISPLAY` in the routing decision. When `DISPLAY` is set (X11 or XWayland), use `X11HotkeyAdapter` (pynput). Fall back to `EvdevAdapter` only for pure Wayland (no `DISPLAY`) or headless sessions. + +## Suggested Review Order + +- [`nimble/hotkeys/__init__.py`](../../nimble/hotkeys/__init__.py) — routing condition, the core change +- [`tests/unit/hotkeys/test_factory.py:25`](../../tests/unit/hotkeys/test_factory.py) — renamed + updated XWayland test assertion diff --git a/docs/superpowers/plans/2026-05-08-wayland-xwayland-adapter.md b/docs/superpowers/plans/2026-05-08-wayland-xwayland-adapter.md new file mode 100644 index 0000000..470b318 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-wayland-xwayland-adapter.md @@ -0,0 +1,412 @@ +# WaylandXWaylandAdapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `WaylandXWaylandAdapter` that makes Nimble's global hotkeys work out-of-the-box on Ubuntu Wayland+XWayland by spawning an invisible X11 keepalive window at daemon start. + +**Architecture:** `WaylandXWaylandAdapter` extends `X11HotkeyAdapter`, overriding `start()` to create a 1×1 `InputOnly` X11 window before starting pynput, and `stop()` to destroy it after. The factory `get_adapter()` detects `WAYLAND_DISPLAY + DISPLAY` and returns the new adapter; pure-Wayland (no `DISPLAY`) raises a clear error. + +**Tech Stack:** Python 3.10+, pynput (already a dependency), python-xlib (already installed as pynput dependency), pytest, unittest.mock. + +--- + +## File map + +| File | Change | +|---|---| +| `nimble/hotkeys/wayland.py` | CREATE — WaylandXWaylandAdapter | +| `nimble/hotkeys/__init__.py` | MODIFY — factory detection | +| `tests/unit/hotkeys/test_wayland.py` | CREATE — adapter unit tests | +| `tests/unit/hotkeys/test_factory.py` | MODIFY — 2 new cases + fix existing X11 case | + +--- + +## Task 1: WaylandXWaylandAdapter + +**Files:** +- Create: `nimble/hotkeys/wayland.py` +- Create: `tests/unit/hotkeys/test_wayland.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/unit/hotkeys/test_wayland.py`: + +```python +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from nimble.hotkeys.wayland import WaylandXWaylandAdapter + + +def _make_mocks() -> tuple[MagicMock, MagicMock, MagicMock, MagicMock, MagicMock]: + mock_win = MagicMock() + mock_root = MagicMock() + mock_root.create_window.return_value = mock_win + mock_screen = MagicMock() + mock_screen.root = mock_root + mock_disp = MagicMock() + mock_disp.screen.return_value = mock_screen + mock_display_mod = MagicMock() + mock_display_mod.Display.return_value = mock_disp + mock_X = MagicMock() + mock_X.InputOnly = 2 + mock_X.CopyFromParent = 0 + mock_keyboard = MagicMock() + mock_keyboard.GlobalHotKeys.return_value = MagicMock() + return mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard + + +def _start( + adapter: WaylandXWaylandAdapter, + mock_display_mod: MagicMock, + mock_X: MagicMock, + mock_keyboard: MagicMock, +) -> None: + with ( + patch("nimble.hotkeys.wayland._xlib_display", return_value=mock_display_mod), + patch("nimble.hotkeys.wayland._xlib_x", return_value=mock_X), + patch("nimble.hotkeys.x11._pynput_keyboard", return_value=mock_keyboard), + patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"}), + ): + adapter.start() + + +def test_start_creates_inputonly_window_before_listener() -> None: + adapter = WaylandXWaylandAdapter() + mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() + mock_listener = mock_keyboard.GlobalHotKeys.return_value + + call_order: list[str] = [] + mock_win.map.side_effect = lambda: call_order.append("map") + mock_disp.flush.side_effect = lambda: call_order.append("flush") + mock_listener.start.side_effect = lambda: call_order.append("listener_start") + + _start(adapter, mock_display_mod, mock_X, mock_keyboard) + + mock_disp.screen.return_value.root.create_window.assert_called_once_with( + 0, 0, 1, 1, 0, 0, mock_X.InputOnly, mock_X.CopyFromParent + ) + assert call_order == ["map", "flush", "listener_start"] + + +def test_stop_destroys_window_after_listener() -> None: + adapter = WaylandXWaylandAdapter() + mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() + mock_listener = mock_keyboard.GlobalHotKeys.return_value + + call_order: list[str] = [] + mock_listener.stop.side_effect = lambda: call_order.append("listener_stop") + mock_listener.join.side_effect = lambda: call_order.append("listener_join") + mock_win.destroy.side_effect = lambda: call_order.append("win_destroy") + mock_disp.close.side_effect = lambda: call_order.append("disp_close") + + _start(adapter, mock_display_mod, mock_X, mock_keyboard) + adapter.stop() + + assert call_order == ["listener_stop", "listener_join", "win_destroy", "disp_close"] + + +def test_stop_before_start_is_noop() -> None: + adapter = WaylandXWaylandAdapter() + adapter.stop() + + +def test_register_duplicate_raises_value_error() -> None: + adapter = WaylandXWaylandAdapter() + adapter.register("ctrl+shift+h", lambda: None) + with pytest.raises(ValueError, match="already registered"): + adapter.register("ctrl+shift+h", lambda: None) + + +def test_start_raises_when_display_open_fails() -> None: + adapter = WaylandXWaylandAdapter() + mock_display_mod = MagicMock() + mock_display_mod.Display.side_effect = Exception("no display") + mock_X = MagicMock() + + with ( + patch("nimble.hotkeys.wayland._xlib_display", return_value=mock_display_mod), + patch("nimble.hotkeys.wayland._xlib_x", return_value=mock_X), + patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"}), + ): + with pytest.raises(RuntimeError, match="cannot open X display"): + adapter.start() + + +def test_stop_window_destroy_failure_does_not_raise() -> None: + adapter = WaylandXWaylandAdapter() + mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() + mock_win.destroy.side_effect = Exception("oops") + + _start(adapter, mock_display_mod, mock_X, mock_keyboard) + adapter.stop() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +.venv/bin/pytest tests/unit/hotkeys/test_wayland.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'nimble.hotkeys.wayland'` + +- [ ] **Step 3: Implement `nimble/hotkeys/wayland.py`** + +```python +from __future__ import annotations + +import logging +import os +from typing import Any + +from nimble.hotkeys.x11 import X11HotkeyAdapter + +logger = logging.getLogger(__name__) + + +def _xlib_display() -> Any: + from Xlib import display + return display + + +def _xlib_x() -> Any: + from Xlib import X + return X + + +class WaylandXWaylandAdapter(X11HotkeyAdapter): + def __init__(self) -> None: + super().__init__() + self._keepalive_display: Any = None + self._keepalive_win: Any = None + + def start(self) -> None: + display_mod = _xlib_display() + X = _xlib_x() + display_name = os.environ.get("DISPLAY", ":0") + try: + d = display_mod.Display(display_name) + except Exception as exc: + raise RuntimeError( + f"WaylandXWaylandAdapter: cannot open X display {display_name!r}: {exc}" + ) from exc + screen = d.screen() + try: + win = screen.root.create_window( + 0, 0, 1, 1, 0, + 0, + X.InputOnly, + X.CopyFromParent, + ) + win.map() + d.flush() + except Exception as exc: + d.close() + raise RuntimeError( + f"WaylandXWaylandAdapter: cannot create keepalive window: {exc}" + ) from exc + self._keepalive_display = d + self._keepalive_win = win + super().start() + + def stop(self) -> None: + super().stop() + if self._keepalive_win is not None: + try: + self._keepalive_win.destroy() + except Exception: + logger.warning("Failed to destroy keepalive window", exc_info=True) + self._keepalive_win = None + if self._keepalive_display is not None: + try: + self._keepalive_display.close() + except Exception: + logger.warning("Failed to close Xlib display", exc_info=True) + self._keepalive_display = None +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +.venv/bin/pytest tests/unit/hotkeys/test_wayland.py -v +``` + +Expected: `7 passed` + +- [ ] **Step 5: Commit** + +```bash +git add nimble/hotkeys/wayland.py tests/unit/hotkeys/test_wayland.py +git commit -m "feat(hotkeys): add WaylandXWaylandAdapter with X11 keepalive window" +``` + +--- + +## Task 2: Factory update + +**Files:** +- Modify: `nimble/hotkeys/__init__.py` +- Modify: `tests/unit/hotkeys/test_factory.py` + +- [ ] **Step 1: Write the failing factory tests** + +Replace the entire contents of `tests/unit/hotkeys/test_factory.py` with: + +```python +from __future__ import annotations + +import os +import sys +from unittest.mock import patch + +import pytest + +from nimble.hotkeys import get_adapter +from nimble.hotkeys.wayland import WaylandXWaylandAdapter +from nimble.hotkeys.windows import WindowsHotkeyAdapter +from nimble.hotkeys.x11 import X11HotkeyAdapter + + +def test_get_adapter_returns_x11_on_pure_x11() -> None: + with patch.object(sys, "platform", "linux"): + with patch.dict(os.environ, {"DISPLAY": ":0"}, clear=False): + os.environ.pop("WAYLAND_DISPLAY", None) + adapter = get_adapter() + assert isinstance(adapter, X11HotkeyAdapter) + assert not isinstance(adapter, WaylandXWaylandAdapter) + + +def test_get_adapter_returns_wayland_adapter_on_xwayland() -> None: + with patch.object(sys, "platform", "linux"): + with patch.dict( + os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"} + ): + adapter = get_adapter() + assert isinstance(adapter, WaylandXWaylandAdapter) + + +def test_get_adapter_raises_on_pure_wayland_no_display() -> None: + with patch.object(sys, "platform", "linux"): + with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}, clear=False): + os.environ.pop("DISPLAY", None) + with pytest.raises(RuntimeError, match="XWayland"): + get_adapter() + + +def test_get_adapter_returns_windows_on_win32() -> None: + with patch.object(sys, "platform", "win32"): + adapter = get_adapter() + assert isinstance(adapter, WindowsHotkeyAdapter) + + +def test_get_adapter_raises_on_unsupported_platform() -> None: + with patch.object(sys, "platform", "darwin"): + with pytest.raises(RuntimeError, match="Unsupported platform: darwin"): + get_adapter() +``` + +- [ ] **Step 2: Run tests to verify the two new cases fail** + +```bash +.venv/bin/pytest tests/unit/hotkeys/test_factory.py -v +``` + +Expected: `test_get_adapter_returns_wayland_adapter_on_xwayland FAILED` and `test_get_adapter_raises_on_pure_wayland_no_display FAILED` + +- [ ] **Step 3: Update `nimble/hotkeys/__init__.py`** + +```python +from __future__ import annotations + +import os +import sys + +from nimble.hotkeys.base import HotkeyAdapter +from nimble.platform import is_linux, is_windows + + +def get_adapter() -> HotkeyAdapter: + if is_linux(): + wayland = os.environ.get("WAYLAND_DISPLAY") + display = os.environ.get("DISPLAY") + if wayland and not display: + raise RuntimeError( + "Nimble requires XWayland on pure Wayland sessions. " + "Install XWayland or set DISPLAY to your X11 display." + ) + if wayland and display: + from nimble.hotkeys.wayland import WaylandXWaylandAdapter + + return WaylandXWaylandAdapter() + from nimble.hotkeys.x11 import X11HotkeyAdapter + + return X11HotkeyAdapter() + elif is_windows(): + from nimble.hotkeys.windows import WindowsHotkeyAdapter + + return WindowsHotkeyAdapter() + raise RuntimeError(f"Unsupported platform: {sys.platform}") +``` + +- [ ] **Step 4: Run the full test suite** + +```bash +.venv/bin/pytest tests/ -v +``` + +Expected: all tests pass (354 existing + 7 new wayland + 5 factory = 366 total) + +- [ ] **Step 5: Commit** + +```bash +git add nimble/hotkeys/__init__.py tests/unit/hotkeys/test_factory.py +git commit -m "feat(hotkeys): factory detects Wayland+XWayland and returns WaylandXWaylandAdapter" +``` + +--- + +## Task 3: Live smoke test + +- [ ] **Step 1: Stop any running daemon** + +```bash +.venv/bin/nimble stop 2>/dev/null || true +``` + +- [ ] **Step 2: Start daemon and confirm it picks the Wayland adapter** + +```bash +DISPLAY=:0 .venv/bin/nimble start --debug +sleep 1 +.venv/bin/nimble status +``` + +Expected: +``` +Daemon: pid=XXXXX started_at=... daemon_version=1.0.0 + +Skills: + hello_world local ctrl+l loaded +``` + +- [ ] **Step 3: Trigger the hotkey and confirm dispatch in logs** + +```bash +DISPLAY=:0 .venv/bin/python trigger_skill.py ctrl+l +sleep 2 +tail -5 "$(.venv/bin/python -c "from nimble.logging_setup import LOG_PATH; print(LOG_PATH)")" +``` + +Expected log line: `DEBUG nimble.skills.runner: Skill hello_world dispatch completed in XX.Xms` + +Expected on desktop: notification popup "Hello from Nimble! The daemon is working." + +- [ ] **Step 4: Stop the daemon** + +```bash +.venv/bin/nimble stop +``` diff --git a/docs/superpowers/specs/2026-05-08-wayland-xwayland-adapter-design.md b/docs/superpowers/specs/2026-05-08-wayland-xwayland-adapter-design.md new file mode 100644 index 0000000..f1efa68 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-wayland-xwayland-adapter-design.md @@ -0,0 +1,115 @@ +# WaylandXWaylandAdapter Design + +**Goal:** Make Nimble's global hotkeys work out-of-the-box on Ubuntu Wayland (with XWayland) without any extra user setup or permissions. + +**Problem:** On a Wayland session with XWayland, pynput's X11 RECORD extension only sees keyboard events when at least one X11 window is active. With no X11 app focused, physical keypresses never flow through XWayland's X11 event queue and pynput hears nothing. + +**Solution:** At daemon startup, create an invisible `InputOnly` X11 window that keeps XWayland's X11 routing active. This is enough for pynput to capture all global keypresses regardless of which Wayland app has focus. + +--- + +## Architecture + +One new file, two small edits: + +``` +nimble/hotkeys/ + __init__.py ← update factory detection (5 lines) + base.py ← unchanged + x11.py ← unchanged + wayland.py ← NEW: WaylandXWaylandAdapter + windows.py ← unchanged + +tests/unit/hotkeys/ + test_factory.py ← add 2 new factory cases + test_wayland.py ← NEW: WaylandXWaylandAdapter unit tests +``` + +## Components + +### `nimble/hotkeys/wayland.py` — WaylandXWaylandAdapter + +Extends `X11HotkeyAdapter`. Inherits `register()` and all pynput GlobalHotKeys logic unchanged. + +Overrides: + +**`start()`** +1. Open an Xlib connection to `DISPLAY` +2. Create a 1×1 `InputOnly` window on the root, map it +3. Flush the connection +4. Call `super().start()` (pynput listener) + +**`stop()`** +1. Call `super().stop()` (pynput listener teardown) +2. Destroy the window +3. Close the Xlib connection + +The window uses `X.InputOnly` window class — no visual pixels, no decorations, invisible to the user. Its sole purpose is keeping XWayland's X11 event routing active. + +### `nimble/hotkeys/__init__.py` — factory update + +| Environment | Adapter selected | +|---|---| +| `DISPLAY` set, `WAYLAND_DISPLAY` absent | `X11HotkeyAdapter` | +| `DISPLAY` set, `WAYLAND_DISPLAY` set | `WaylandXWaylandAdapter` | +| `WAYLAND_DISPLAY` set, `DISPLAY` absent | `RuntimeError` — pure Wayland without XWayland is unsupported | +| Windows | `WindowsHotkeyAdapter` (unchanged) | + +Detection uses `os.environ.get()` on `DISPLAY` and `WAYLAND_DISPLAY`. No config option needed. + +## Data Flow + +``` +nimble start + └─ get_adapter() + └─ detects WAYLAND_DISPLAY + DISPLAY → WaylandXWaylandAdapter + +adapter.start() + ├─ Xlib: open display, create InputOnly window, map, flush + └─ pynput GlobalHotKeys.start() (inherited from X11HotkeyAdapter) + +[user presses hotkey] + └─ X11 routing active → pynput RECORD fires callback + └─ daemon dispatches skill → worker runs → popup notification + +adapter.stop() + ├─ pynput listener.stop() + join() (inherited) + └─ Xlib: window.destroy(), display.close() +``` + +## Error Handling + +| Failure point | Behaviour | +|---|---| +| Xlib cannot connect at `start()` | Raise `RuntimeError` — daemon aborts with message | +| Window creation fails at `start()` | Raise `RuntimeError` — daemon aborts before pynput starts | +| Window destroy fails at `stop()` | Log warning, do not raise — daemon is shutting down | + +This matches the existing pattern in `X11HotkeyAdapter.start()`. + +## Testing + +### `tests/unit/hotkeys/test_wayland.py` + +All Xlib calls mocked (same mock pattern as `test_x11.py`): + +- `start()` creates an `InputOnly` window and maps it before starting the pynput listener +- `stop()` destroys the window after stopping the listener +- `register()` translates shortcut strings to pynput format correctly +- Registering a duplicate shortcut raises `ValueError` +- Xlib connection failure at `start()` raises `RuntimeError` + +### `tests/unit/hotkeys/test_factory.py` additions + +- `DISPLAY` set + `WAYLAND_DISPLAY` set → factory returns `WaylandXWaylandAdapter` +- `WAYLAND_DISPLAY` set, `DISPLAY` absent → factory raises `RuntimeError` + +## Behaviour + +Uses pynput RECORD (passive observation). Both the focused app and Nimble receive the key event — no key stealing. Choosing shortcuts not used by common apps avoids visible double-handling. + +## Out of scope + +- Pure Wayland without XWayland (no `DISPLAY`) — raises a clear error +- macOS support — separate concern +- XDG Global Shortcuts portal — future enhancement if pure-Wayland support is needed diff --git a/docs/wayland-hotkeys.md b/docs/wayland-hotkeys.md new file mode 100644 index 0000000..f3e5486 --- /dev/null +++ b/docs/wayland-hotkeys.md @@ -0,0 +1,49 @@ +# Running Nimble on Wayland + +Nimble uses the Linux kernel's input layer (`/dev/input/event*`) to capture global hotkeys. +This works on any session type — Wayland-native apps, XWayland, or pure X11 — without any +extra configuration beyond a one-time group membership step. + +## One-time setup + +Keyboard input devices are owned by the `input` group. Add your user once: + +```bash +sudo usermod -aG input $USER +``` + +Then **log out and log back in** (or reboot). The change is permanent — you only do this once. + +Verify it worked: + +```bash +groups | grep input +``` + +If `input` appears in the output, Nimble can read keyboard events. + +## Why this is needed + +On Wayland, there is no display-server-level API that lets unprivileged processes observe +global keypresses (the way X11's RECORD extension worked). The only reliable cross-session +approach is to read directly from the kernel input devices at `/dev/input/event*`. + +These device files are owned by root with group `input`. Granting your user membership in +`input` gives Nimble read access without requiring `sudo` or `setuid`. + +## What happens without it + +If the daemon starts without `input` group access, it exits immediately with: + +``` +RuntimeError: Permission denied opening /dev/input/eventN. +Add your user to the 'input' group and log out/in: + sudo usermod -aG input $USER +``` + +## Security note + +Membership in `input` gives read access to all input devices (keyboard, mouse, touchpad). +Any process running as your user can also read these devices. This is the standard trade-off +for global hotkey daemons on Wayland — the same access level used by tools like `evtest`, +`keyd`, and `interception-tools`. diff --git a/nimble/daemon.py b/nimble/daemon.py index 064e9d5..f9c398f 100644 --- a/nimble/daemon.py +++ b/nimble/daemon.py @@ -28,6 +28,25 @@ logger = logging.getLogger(__name__) +def _load_dotenv(dotenv_path: Path) -> None: + if not dotenv_path.exists(): + return + with dotenv_path.open() as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + value = value[1:-1] + if key and key not in os.environ: + os.environ[key] = value + + def _build_skill_states(registry: SkillRegistry) -> list[SkillState]: return [ SkillState( @@ -51,6 +70,7 @@ def _state_signature( def run(repo_root: Path, debug: bool = False) -> None: + _load_dotenv(repo_root / ".env") configure_logging(LOG_PATH, debug) notifier = Notifier() diff --git a/nimble/hotkeys/__init__.py b/nimble/hotkeys/__init__.py index 2c5c6b7..f9f177e 100644 --- a/nimble/hotkeys/__init__.py +++ b/nimble/hotkeys/__init__.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import os import sys from nimble.hotkeys.base import HotkeyAdapter @@ -6,9 +9,14 @@ def get_adapter() -> HotkeyAdapter: if is_linux(): - from nimble.hotkeys.x11 import X11HotkeyAdapter + display = os.environ.get("DISPLAY") + if display: + from nimble.hotkeys.x11 import X11HotkeyAdapter + + return X11HotkeyAdapter() + from nimble.hotkeys.evdev_adapter import EvdevAdapter - return X11HotkeyAdapter() + return EvdevAdapter() elif is_windows(): from nimble.hotkeys.windows import WindowsHotkeyAdapter diff --git a/nimble/hotkeys/evdev_adapter.py b/nimble/hotkeys/evdev_adapter.py new file mode 100644 index 0000000..5767759 --- /dev/null +++ b/nimble/hotkeys/evdev_adapter.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import logging +import os +import select +import threading +from collections.abc import Callable +from typing import Any + +from nimble.hotkeys.base import HotkeyAdapter, _MODIFIERS + +logger = logging.getLogger(__name__) + + +def _evdev() -> Any: + import evdev + return evdev + + +def _build_modifier_map() -> dict[int, str]: + ec = _evdev().ecodes + return { + ec.KEY_LEFTCTRL: "ctrl", ec.KEY_RIGHTCTRL: "ctrl", + ec.KEY_LEFTSHIFT: "shift", ec.KEY_RIGHTSHIFT: "shift", + ec.KEY_LEFTALT: "alt", ec.KEY_RIGHTALT: "alt", + ec.KEY_LEFTMETA: "super", ec.KEY_RIGHTMETA: "super", + } + + +def _shortcut_token_to_ecode(token: str) -> int: + ec = _evdev().ecodes + if len(token) == 1 and token.isalpha(): + name = f"KEY_{token.upper()}" + if name in ec.ecodes: + return int(ec.ecodes[name]) + if len(token) == 1 and token.isdigit(): + name = f"KEY_{token}" + if name in ec.ecodes: + return int(ec.ecodes[name]) + if token.startswith("f") and token[1:].isdigit(): + name = f"KEY_{token.upper()}" + if name in ec.ecodes: + return int(ec.ecodes[name]) + _special = { + "space": "KEY_SPACE", "enter": "KEY_ENTER", "return": "KEY_ENTER", + "backspace": "KEY_BACKSPACE", "tab": "KEY_TAB", "esc": "KEY_ESC", + "escape": "KEY_ESC", "delete": "KEY_DELETE", "del": "KEY_DELETE", + "insert": "KEY_INSERT", "home": "KEY_HOME", "end": "KEY_END", + "pageup": "KEY_PAGEUP", "pagedown": "KEY_PAGEDOWN", + "up": "KEY_UP", "down": "KEY_DOWN", "left": "KEY_LEFT", "right": "KEY_RIGHT", + "minus": "KEY_MINUS", "equal": "KEY_EQUAL", "semicolon": "KEY_SEMICOLON", + "comma": "KEY_COMMA", "dot": "KEY_DOT", "period": "KEY_DOT", + "slash": "KEY_SLASH", + } + if token in _special: + name = _special[token] + if name in ec.ecodes: + return int(ec.ecodes[name]) + raise ValueError(f"Cannot map shortcut token {token!r} to an evdev key code.") + + +def _parse_shortcut(shortcut: str) -> tuple[frozenset[str], int]: + _aliases = {"cmd": "super", "win": "super"} + parts = shortcut.lower().split("+") + parts = [_aliases.get(p, p) for p in parts] + mods = frozenset(p for p in parts if p in _MODIFIERS) + non_mods = [p for p in parts if p not in _MODIFIERS] + if len(non_mods) != 1: + raise ValueError( + f"Shortcut {shortcut!r} must have exactly one non-modifier key." + ) + return mods, _shortcut_token_to_ecode(non_mods[0]) + + +class EvdevAdapter(HotkeyAdapter): + def __init__(self) -> None: + self._hotkeys: dict[str, tuple[frozenset[str], int, Callable[[], None]]] = {} + self._thread: threading.Thread | None = None + self._pipe_r: int | None = None + self._pipe_w: int | None = None + self._current_modifiers: set[str] = set() + self._lock = threading.Lock() + + def register(self, shortcut: str, callback: Callable[[], None]) -> None: + mods, trigger_ecode = _parse_shortcut(shortcut) + with self._lock: + if shortcut in self._hotkeys: + raise ValueError(f"Hotkey already registered: {shortcut!r}") + self._hotkeys[shortcut] = (mods, trigger_ecode, callback) + + def start(self) -> None: + if self._thread is not None: + raise RuntimeError("EvdevAdapter is already started; call stop() first.") + evdev = _evdev() + devices = self._open_keyboard_devices(evdev) + if not devices: + raise RuntimeError("No keyboard devices found in /dev/input/.") + self._pipe_r, self._pipe_w = os.pipe() + self._current_modifiers = set() + modifier_map = _build_modifier_map() + self._thread = threading.Thread( + target=self._run_loop, + args=(devices, modifier_map), + daemon=True, + name="nimble-evdev-loop", + ) + self._thread.start() + + def stop(self) -> None: + if self._thread is None: + return + if self._pipe_w is not None: + try: + os.write(self._pipe_w, b"\x00") + except OSError: + pass + self._thread.join() + self._thread = None + for fd in (self._pipe_r, self._pipe_w): + if fd is not None: + try: + os.close(fd) + except OSError: + pass + self._pipe_r = None + self._pipe_w = None + + def _open_keyboard_devices(self, evdev: Any) -> list[Any]: + paths = evdev.list_devices() + if not paths: + return [] + devices: list[Any] = [] + for path in paths: + try: + dev = evdev.InputDevice(path) + except PermissionError: + raise RuntimeError( + f"Permission denied opening {path}. " + "Add your user to the 'input' group and log out/in:\n" + " sudo usermod -aG input $USER\n" + "Then log out and log back in (or reboot)." + ) from None + except OSError as exc: + logger.debug("Skipping %s: %s", path, exc) + continue + caps = dev.capabilities() + if evdev.ecodes.EV_KEY in caps: + devices.append(dev) + else: + dev.close() + return devices + + def _run_loop(self, devices: list[Any], modifier_map: dict[int, str]) -> None: + evdev = _evdev() + EV_KEY = evdev.ecodes.EV_KEY + KEY_DOWN = evdev.events.KeyEvent.key_down + KEY_UP = evdev.events.KeyEvent.key_up + pipe_r = self._pipe_r + fds = {dev.fd: dev for dev in devices} + all_fds = list(fds.keys()) + [pipe_r] + try: + while True: + try: + readable, _, _ = select.select(all_fds, [], []) + except (ValueError, OSError): + break + for fd in readable: + if fd == pipe_r: + return + dev = fds[fd] + try: + for event in dev.read(): + if event.type != EV_KEY: + continue + code = event.code + value = event.value + if code in modifier_map: + mod = modifier_map[code] + with self._lock: + if value == KEY_DOWN: + self._current_modifiers.add(mod) + elif value == KEY_UP: + self._current_modifiers.discard(mod) + elif value == KEY_DOWN: + with self._lock: + active_mods = frozenset(self._current_modifiers) + _items = list(self._hotkeys.items()) + for _key, (req_mods, ecode, cb) in _items: + if code == ecode and active_mods == req_mods: + threading.Thread( + target=cb, + daemon=True, + name=f"nimble-hotkey-{_key}", + ).start() + except BlockingIOError: + pass + except OSError as exc: + logger.warning("Error reading from %s: %s", dev.path, exc) + all_fds.remove(fd) + del fds[fd] + if not fds: + return + finally: + for dev in devices: + try: + dev.close() + except OSError: + pass diff --git a/nimble/skills/loader.py b/nimble/skills/loader.py index a42b027..c2d9f55 100644 --- a/nimble/skills/loader.py +++ b/nimble/skills/loader.py @@ -12,8 +12,16 @@ def validate_skill_paths( configs: list[SkillConfig], base_path: Path ) -> list[SkillConfig]: + base_root = base_path.resolve() for config in configs: - skill_path = base_path / config.path + skill_path = (base_root / config.path).resolve() + try: + skill_path.relative_to(base_root) + except ValueError: + raise ConfigError( + f"Skill '{config.name}': path '{config.path}'" + " escapes the repository root" + ) if not skill_path.exists(): raise ConfigError( f"Skill '{config.name}': path '{config.path}' does not exist" diff --git a/nimble/tools/ai.py b/nimble/tools/ai.py index 9bd571b..d13da84 100644 --- a/nimble/tools/ai.py +++ b/nimble/tools/ai.py @@ -11,7 +11,7 @@ class AiTool: def __init__(self, config: AiConfig | None) -> None: self._config = config - def ask(self, text: str, prompt: str | None = None) -> str: + def ask(self, text: str, system_prompt: str | None = None) -> str: if self._config is None: raise RuntimeError("AI not configured: add an 'ai' block to config.yaml") api_key = os.environ.get(self._config.api_key_env) @@ -21,15 +21,15 @@ def ask(self, text: str, prompt: str | None = None) -> str: " is empty or unset" ) if self._config.provider == "anthropic": - return self._ask_anthropic(text, prompt, api_key) + return self._ask_anthropic(text, system_prompt, api_key) if self._config.provider == "openai": - return self._ask_openai(text, prompt, api_key) + return self._ask_openai(text, system_prompt, api_key) raise RuntimeError( f"Unsupported AI provider: {self._config.provider!r}." " Supported: anthropic, openai" ) - def _ask_anthropic(self, text: str, prompt: str | None, api_key: str) -> str: + def _ask_anthropic(self, text: str, system_prompt: str | None, api_key: str) -> str: try: import anthropic except ImportError: @@ -37,18 +37,25 @@ def _ask_anthropic(self, text: str, prompt: str | None, api_key: str) -> str: "anthropic package not installed; add 'anthropic' to your" " skill's manifest.yaml dependencies" ) + from anthropic.types import MessageParam, TextBlock + client = anthropic.Anthropic(api_key=api_key) - kwargs: dict[str, object] = { - "model": self._config.model, # type: ignore[union-attr] - "max_tokens": 1024, - "messages": [{"role": "user", "content": text}], - } - if prompt is not None: - kwargs["system"] = prompt - response = client.messages.create(**kwargs) - return str(response.content[0].text) + messages: list[MessageParam] = [{"role": "user", "content": text}] + model: str = self._config.model # type: ignore[union-attr] + if system_prompt is not None: + response = client.messages.create( + model=model, max_tokens=1024, messages=messages, system=system_prompt + ) + else: + response = client.messages.create( + model=model, max_tokens=1024, messages=messages + ) + for block in response.content: + if isinstance(block, TextBlock): + return block.text + raise RuntimeError("Anthropic response contained no text block") - def _ask_openai(self, text: str, prompt: str | None, api_key: str) -> str: + def _ask_openai(self, text: str, system_prompt: str | None, api_key: str) -> str: try: import openai except ImportError: @@ -56,10 +63,12 @@ def _ask_openai(self, text: str, prompt: str | None, api_key: str) -> str: "openai package not installed; add 'openai' to your" " skill's manifest.yaml dependencies" ) + from openai.types.chat import ChatCompletionMessageParam + client = openai.OpenAI(api_key=api_key) - messages: list[dict[str, str]] = [] - if prompt is not None: - messages.append({"role": "system", "content": prompt}) + messages: list[ChatCompletionMessageParam] = [] + if system_prompt is not None: + messages.append({"role": "system", "content": system_prompt}) messages.append({"role": "user", "content": text}) response = client.chat.completions.create( model=self._config.model, # type: ignore[union-attr] diff --git a/pyproject.toml b/pyproject.toml index 783cca0..e6f7a8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,9 @@ dependencies = [ "pyyaml>=6.0", "plyer>=2.1", "watchdog>=3.0", + "anthropic>=0.40.0", + "openai>=1.0.0", + "evdev>=1.6.1; sys_platform=='linux'", ] [project.optional-dependencies] diff --git a/skills/greet/manifest.yaml b/skills/greet/manifest.yaml new file mode 100644 index 0000000..4204556 --- /dev/null +++ b/skills/greet/manifest.yaml @@ -0,0 +1,9 @@ +name: greet +version: "1.0.0" +api_version: 1 +description: "Greets you with the name of the active application" +entrypoint: skill.py +class_name: GreetSkill +permissions: [] +dependencies: [] +author: "Kotmin" diff --git a/skills/greet/skill.py b/skills/greet/skill.py new file mode 100644 index 0000000..ef9658a --- /dev/null +++ b/skills/greet/skill.py @@ -0,0 +1,7 @@ +from __future__ import annotations + + +class GreetSkill: + def run(self, context, tools) -> None: + app = context.active_app or "your app" + tools.popup.show(f"Hello from {app}!") diff --git a/skills/quick_clip/manifest.yaml b/skills/quick_clip/manifest.yaml new file mode 100644 index 0000000..1bf0426 --- /dev/null +++ b/skills/quick_clip/manifest.yaml @@ -0,0 +1,9 @@ +name: quick_clip +version: "1.0.0" +api_version: 1 +description: "Shows current clipboard content in a popup" +entrypoint: skill.py +class_name: QuickClipSkill +permissions: [] +dependencies: [] +author: "Nimble Demo" diff --git a/skills/quick_clip/skill.py b/skills/quick_clip/skill.py new file mode 100644 index 0000000..5f28c80 --- /dev/null +++ b/skills/quick_clip/skill.py @@ -0,0 +1,7 @@ +from __future__ import annotations + + +class QuickClipSkill: + def run(self, context, tools) -> None: + text = (context.clipboard or "").strip() + tools.popup.show(text if text else "(clipboard is empty)") diff --git a/tests/unit/hotkeys/test_evdev_adapter.py b/tests/unit/hotkeys/test_evdev_adapter.py new file mode 100644 index 0000000..61c3c75 --- /dev/null +++ b/tests/unit/hotkeys/test_evdev_adapter.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import os +import threading +from unittest.mock import MagicMock, patch + +import pytest +from evdev import ecodes + +from nimble.hotkeys.evdev_adapter import EvdevAdapter, _parse_shortcut + + +def _make_mock_evdev( + keyboard_paths: tuple[str, ...] = ("/dev/input/event0",), +) -> MagicMock: + mock_evdev = MagicMock() + mock_evdev.ecodes.EV_KEY = ecodes.EV_KEY + mock_evdev.list_devices.return_value = list(keyboard_paths) + + def make_device(path: str) -> MagicMock: + dev = MagicMock() + dev.path = path + dev.fd = abs(hash(path)) % 900 + 100 + dev.capabilities.return_value = {ecodes.EV_KEY: []} + return dev + + mock_evdev.InputDevice.side_effect = make_device + return mock_evdev + + +def _build_real_modifier_map() -> dict[int, str]: + return { + ecodes.KEY_LEFTCTRL: "ctrl", ecodes.KEY_RIGHTCTRL: "ctrl", + ecodes.KEY_LEFTSHIFT: "shift", ecodes.KEY_RIGHTSHIFT: "shift", + ecodes.KEY_LEFTALT: "alt", ecodes.KEY_RIGHTALT: "alt", + ecodes.KEY_LEFTMETA: "super", ecodes.KEY_RIGHTMETA: "super", + } + + +def test_parse_shortcut_single_modifier() -> None: + mods, code = _parse_shortcut("ctrl+l") + assert mods == frozenset({"ctrl"}) + assert code == ecodes.KEY_L + + +def test_parse_shortcut_multiple_modifiers() -> None: + mods, code = _parse_shortcut("ctrl+shift+h") + assert mods == frozenset({"ctrl", "shift"}) + assert code == ecodes.KEY_H + + +def test_parse_shortcut_raises_no_trigger_key() -> None: + with pytest.raises(ValueError, match="exactly one non-modifier"): + _parse_shortcut("ctrl+shift") + + +def test_parse_shortcut_raises_unknown_key() -> None: + with pytest.raises(ValueError, match="Cannot map"): + _parse_shortcut("ctrl+notakey") + + +def test_register_raises_on_duplicate() -> None: + adapter = EvdevAdapter() + adapter.register("ctrl+l", lambda: None) + with pytest.raises(ValueError, match="already registered"): + adapter.register("ctrl+l", lambda: None) + + +def test_stop_before_start_is_noop() -> None: + adapter = EvdevAdapter() + adapter.stop() + + +def test_open_keyboard_devices_returns_keyboards() -> None: + mock_evdev = _make_mock_evdev() + adapter = EvdevAdapter() + devices = adapter._open_keyboard_devices(mock_evdev) + assert len(devices) == 1 + + +def test_open_keyboard_devices_skips_non_keyboard_devices() -> None: + mock_evdev = _make_mock_evdev() + non_kb = MagicMock() + non_kb.capabilities.return_value = {} + mock_evdev.InputDevice.side_effect = lambda path: non_kb + adapter = EvdevAdapter() + devices = adapter._open_keyboard_devices(mock_evdev) + assert devices == [] + non_kb.close.assert_called_once() + + +def test_open_keyboard_devices_raises_on_permission_error() -> None: + mock_evdev = _make_mock_evdev() + mock_evdev.InputDevice.side_effect = PermissionError("denied") + adapter = EvdevAdapter() + with pytest.raises(RuntimeError, match="input"): + adapter._open_keyboard_devices(mock_evdev) + + +def test_start_raises_when_no_keyboard_devices() -> None: + mock_evdev = _make_mock_evdev(keyboard_paths=()) + adapter = EvdevAdapter() + with patch("nimble.hotkeys.evdev_adapter._evdev", return_value=mock_evdev): + with pytest.raises(RuntimeError, match="No keyboard devices"): + adapter.start() + + +def test_run_loop_fires_callback_on_matching_hotkey() -> None: + adapter = EvdevAdapter() + fired = threading.Event() + adapter.register("ctrl+l", fired.set) + trigger_code = adapter._hotkeys["ctrl+l"][1] + modifier_map = _build_real_modifier_map() + + pipe_r, pipe_w = os.pipe() + adapter._pipe_r = pipe_r + + ev_ctrl = MagicMock(type=ecodes.EV_KEY, code=ecodes.KEY_LEFTCTRL, value=1) + ev_l = MagicMock(type=ecodes.EV_KEY, code=trigger_code, value=1) + + mock_dev = MagicMock() + mock_dev.fd = 5 + mock_dev.path = "/dev/input/event0" + + read_count = [0] + + def fake_read() -> list: + read_count[0] += 1 + if read_count[0] == 1: + return [ev_ctrl, ev_l] + raise BlockingIOError() + + mock_dev.read.side_effect = fake_read + + select_results = iter([([5], [], []), ([pipe_r], [], [])]) + with patch("select.select", side_effect=lambda *a, **kw: next(select_results)): + try: + adapter._run_loop([mock_dev], modifier_map) + finally: + os.close(pipe_r) + os.close(pipe_w) + fired.wait(timeout=2) + assert fired.is_set() + + +def test_run_loop_does_not_fire_with_wrong_modifiers() -> None: + adapter = EvdevAdapter() + fired = threading.Event() + adapter.register("ctrl+shift+l", fired.set) + trigger_code = adapter._hotkeys["ctrl+shift+l"][1] + modifier_map = _build_real_modifier_map() + + pipe_r, pipe_w = os.pipe() + adapter._pipe_r = pipe_r + + ev_ctrl = MagicMock(type=ecodes.EV_KEY, code=ecodes.KEY_LEFTCTRL, value=1) + ev_l = MagicMock(type=ecodes.EV_KEY, code=trigger_code, value=1) + + mock_dev = MagicMock() + mock_dev.fd = 5 + mock_dev.path = "/dev/input/event0" + + read_count = [0] + + def fake_read() -> list: + read_count[0] += 1 + if read_count[0] == 1: + return [ev_ctrl, ev_l] + raise BlockingIOError() + + mock_dev.read.side_effect = fake_read + + select_results = iter([([5], [], []), ([pipe_r], [], [])]) + with patch("select.select", side_effect=lambda *a, **kw: next(select_results)): + try: + adapter._run_loop([mock_dev], modifier_map) + finally: + os.close(pipe_r) + os.close(pipe_w) + assert not fired.is_set() + + +def test_register_after_start_succeeds_and_fires() -> None: + mock_evdev = _make_mock_evdev() + mock_evdev.ecodes.ecodes = ecodes.ecodes + mock_evdev.events.KeyEvent.key_down = 1 + mock_evdev.events.KeyEvent.key_up = 0 + stop_event = threading.Event() + + def fake_select(rlist, wlist, xlist, *args, **kwargs): + stop_event.wait(timeout=5) + return ([rlist[-1]], [], []) + + adapter = EvdevAdapter() + fired = threading.Event() + + with patch("nimble.hotkeys.evdev_adapter._evdev", return_value=mock_evdev): + with patch("select.select", side_effect=fake_select): + adapter.start() + adapter.register("ctrl+l", fired.set) + stop_event.set() + adapter.stop() + + assert "ctrl+l" in adapter._hotkeys diff --git a/tests/unit/hotkeys/test_factory.py b/tests/unit/hotkeys/test_factory.py index a97d2b1..408fba5 100644 --- a/tests/unit/hotkeys/test_factory.py +++ b/tests/unit/hotkeys/test_factory.py @@ -1,19 +1,45 @@ +from __future__ import annotations + +import os import sys from unittest.mock import patch import pytest from nimble.hotkeys import get_adapter +from nimble.hotkeys.evdev_adapter import EvdevAdapter from nimble.hotkeys.windows import WindowsHotkeyAdapter from nimble.hotkeys.x11 import X11HotkeyAdapter -def test_get_adapter_returns_x11_on_linux() -> None: +def test_get_adapter_returns_x11_on_pure_x11() -> None: with patch.object(sys, "platform", "linux"): - adapter = get_adapter() + with patch.dict(os.environ, + {"DISPLAY": ":0", "WAYLAND_DISPLAY": ""}, + clear=False): + adapter = get_adapter() + assert isinstance(adapter, X11HotkeyAdapter) + assert not isinstance(adapter, EvdevAdapter) + + +def test_get_adapter_returns_x11_on_xwayland() -> None: + with patch.object(sys, "platform", "linux"): + with patch.dict( + os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"} + ): + adapter = get_adapter() assert isinstance(adapter, X11HotkeyAdapter) +def test_get_adapter_returns_evdev_on_pure_wayland() -> None: + with patch.object(sys, "platform", "linux"): + with patch.dict(os.environ, + {"WAYLAND_DISPLAY": "wayland-0", "DISPLAY": ""}, + clear=False): + adapter = get_adapter() + assert isinstance(adapter, EvdevAdapter) + + def test_get_adapter_returns_windows_on_win32() -> None: with patch.object(sys, "platform", "win32"): adapter = get_adapter() @@ -24,3 +50,12 @@ def test_get_adapter_raises_on_unsupported_platform() -> None: with patch.object(sys, "platform", "darwin"): with pytest.raises(RuntimeError, match="Unsupported platform: darwin"): get_adapter() + + +def test_get_adapter_returns_evdev_on_headless() -> None: + with patch.object(sys, "platform", "linux"): + with patch.dict(os.environ, + {"DISPLAY": "", "WAYLAND_DISPLAY": ""}, + clear=False): + adapter = get_adapter() + assert isinstance(adapter, EvdevAdapter) diff --git a/tests/unit/skills/test_loader.py b/tests/unit/skills/test_loader.py index 752a81a..9f46de7 100644 --- a/tests/unit/skills/test_loader.py +++ b/tests/unit/skills/test_loader.py @@ -48,6 +48,17 @@ def test_validate_multiple_skills_first_missing_raises(tmp_path: Path) -> None: assert "first-missing" in str(exc_info.value) +def test_validate_path_traversal_raises_config_error(tmp_path: Path) -> None: + base = tmp_path / "repo" + base.mkdir() + outside = tmp_path / "secret.py" + outside.write_text("sensitive = True\n") + configs = [_make_config("evil-skill", "../secret.py")] + with pytest.raises(ConfigError) as exc_info: + validate_skill_paths(configs, base) + assert "evil-skill" in str(exc_info.value) + + def test_full_chain_parser_loader_registry(tmp_path: Path) -> None: skill_file = tmp_path / "skills" / "hello.py" skill_file.parent.mkdir() diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index be030d3..f552ff2 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from collections.abc import Callable from pathlib import Path from unittest.mock import MagicMock, patch @@ -14,6 +15,94 @@ from tests.conftest import FakeNotifier +# --- _load_dotenv tests --- + + +def test_load_dotenv_sets_variables(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("FOO=bar\nBAZ=qux\n") + env_backup = dict(os.environ) + try: + os.environ.pop("FOO", None) + os.environ.pop("BAZ", None) + daemon_module._load_dotenv(env_file) + assert os.environ.get("FOO") == "bar" + assert os.environ.get("BAZ") == "qux" + finally: + for key in ("FOO", "BAZ"): + os.environ.pop(key, None) + os.environ.update(env_backup) + + +def test_load_dotenv_skips_missing_file(tmp_path: Path) -> None: + daemon_module._load_dotenv(tmp_path / ".env") # must not raise + + +def test_load_dotenv_does_not_override_existing(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("MY_KEY=from_dotenv\n") + os.environ["MY_KEY"] = "from_env" + try: + daemon_module._load_dotenv(env_file) + assert os.environ["MY_KEY"] == "from_env" + finally: + del os.environ["MY_KEY"] + + +def test_load_dotenv_skips_comments_and_blank_lines(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("# comment\n\nVALID_KEY=hello\n") + os.environ.pop("VALID_KEY", None) + try: + daemon_module._load_dotenv(env_file) + assert os.environ.get("VALID_KEY") == "hello" + finally: + os.environ.pop("VALID_KEY", None) + + +def test_load_dotenv_strips_quotes(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text('QUOTED="hello world"\nSINGLE=\'hi there\'\n') + for key in ("QUOTED", "SINGLE"): + os.environ.pop(key, None) + try: + daemon_module._load_dotenv(env_file) + assert os.environ.get("QUOTED") == "hello world" + assert os.environ.get("SINGLE") == "hi there" + finally: + for key in ("QUOTED", "SINGLE"): + os.environ.pop(key, None) + + +def test_run_loads_dotenv_from_repo_root(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("NIMBLE_TEST_DOTENV_KEY=loaded\n") + os.environ.pop("NIMBLE_TEST_DOTENV_KEY", None) + mock_stop_event = MagicMock() + mock_stop_event.wait.return_value = None + try: + with ( + patch("nimble.daemon.configure_logging"), + patch("nimble.daemon.get_adapter"), + patch("nimble.daemon.load_config") as mock_load_config, + patch("nimble.daemon.validate_skill_paths", return_value=[]), + patch("nimble.daemon.SkillRunner"), + patch("nimble.daemon.ConfigWatcher"), + patch("nimble.daemon.write_pid"), + patch("nimble.daemon.remove_pid"), + patch("nimble.daemon.write_state"), + patch("nimble.daemon.remove_state"), + patch("nimble.daemon.Notifier"), + patch("threading.Event", return_value=mock_stop_event), + patch("nimble.daemon.threading.Thread"), + ): + mock_load_config.return_value.skills = [] + daemon_module.run(tmp_path) + assert os.environ.get("NIMBLE_TEST_DOTENV_KEY") == "loaded" + finally: + os.environ.pop("NIMBLE_TEST_DOTENV_KEY", None) + + def test_dispatch_fires_notification_on_skill_error() -> None: notifier = FakeNotifier() error = SkillError( diff --git a/tests/unit/tools/test_ai.py b/tests/unit/tools/test_ai.py index 6cc5272..5a4eb23 100644 --- a/tests/unit/tools/test_ai.py +++ b/tests/unit/tools/test_ai.py @@ -8,17 +8,22 @@ from nimble.tools.ai import AiTool -def _make_anthropic_mock(response_text: str) -> MagicMock: +def _make_anthropic_mock(response_text: str) -> tuple[MagicMock, MagicMock]: mock_response = MagicMock() mock_response.content = [MagicMock(text=response_text)] mock_client = MagicMock() mock_client.messages.create.return_value = mock_response mock_anthropic = MagicMock() mock_anthropic.Anthropic.return_value = mock_client - return mock_anthropic + mock_types = MagicMock() + mock_types.TextBlock = MagicMock # isinstance(MagicMock(), MagicMock) is True + mock_types.MessageParam = dict -def _make_openai_mock(response_text: str) -> MagicMock: + return mock_anthropic, mock_types + + +def _make_openai_mock(response_text: str) -> tuple[MagicMock, MagicMock]: mock_message = MagicMock() mock_message.content = response_text mock_choice = MagicMock() @@ -29,7 +34,10 @@ def _make_openai_mock(response_text: str) -> MagicMock: mock_client.chat.completions.create.return_value = mock_response mock_openai = MagicMock() mock_openai.OpenAI.return_value = mock_client - return mock_openai + + mock_types_chat = MagicMock() + + return mock_openai, mock_types_chat def test_ask_anthropic_returns_text() -> None: @@ -37,11 +45,14 @@ def test_ask_anthropic_returns_text() -> None: provider="anthropic", model="claude-sonnet-4-6", api_key_env="TEST_KEY" ) tool = AiTool(cfg) - mock_anthropic = _make_anthropic_mock("answer text") + mock_anthropic, mock_types = _make_anthropic_mock("answer text") with ( patch.dict("os.environ", {"TEST_KEY": "sk-fake"}), - patch.dict("sys.modules", {"anthropic": mock_anthropic}), + patch.dict( + "sys.modules", + {"anthropic": mock_anthropic, "anthropic.types": mock_types}, + ), ): result = tool.ask("hello") assert result == "answer text" @@ -50,11 +61,14 @@ def test_ask_anthropic_returns_text() -> None: def test_ask_openai_returns_text() -> None: cfg = AiConfig(provider="openai", model="gpt-4o", api_key_env="TEST_KEY") tool = AiTool(cfg) - mock_openai = _make_openai_mock("answer text") + mock_openai, mock_types_chat = _make_openai_mock("answer text") with ( patch.dict("os.environ", {"TEST_KEY": "sk-fake"}), - patch.dict("sys.modules", {"openai": mock_openai}), + patch.dict( + "sys.modules", + {"openai": mock_openai, "openai.types.chat": mock_types_chat}, + ), ): result = tool.ask("hello") assert result == "answer text" @@ -65,14 +79,17 @@ def test_ask_with_system_prompt_anthropic() -> None: provider="anthropic", model="claude-sonnet-4-6", api_key_env="TEST_KEY" ) tool = AiTool(cfg) - mock_anthropic = _make_anthropic_mock("ok") + mock_anthropic, mock_types = _make_anthropic_mock("ok") mock_client = mock_anthropic.Anthropic.return_value with ( patch.dict("os.environ", {"TEST_KEY": "sk-fake"}), - patch.dict("sys.modules", {"anthropic": mock_anthropic}), + patch.dict( + "sys.modules", + {"anthropic": mock_anthropic, "anthropic.types": mock_types}, + ), ): - tool.ask("hello", prompt="You are helpful") + tool.ask("hello", system_prompt="You are helpful") call_kwargs = mock_client.messages.create.call_args.kwargs assert call_kwargs.get("system") == "You are helpful" @@ -81,14 +98,17 @@ def test_ask_with_system_prompt_anthropic() -> None: def test_ask_with_system_prompt_openai() -> None: cfg = AiConfig(provider="openai", model="gpt-4o", api_key_env="TEST_KEY") tool = AiTool(cfg) - mock_openai = _make_openai_mock("ok") + mock_openai, mock_types_chat = _make_openai_mock("ok") mock_client = mock_openai.OpenAI.return_value with ( patch.dict("os.environ", {"TEST_KEY": "sk-fake"}), - patch.dict("sys.modules", {"openai": mock_openai}), + patch.dict( + "sys.modules", + {"openai": mock_openai, "openai.types.chat": mock_types_chat}, + ), ): - tool.ask("hello", prompt="You are helpful") + tool.ask("hello", system_prompt="You are helpful") call_kwargs = mock_client.chat.completions.create.call_args.kwargs messages = call_kwargs["messages"] diff --git a/trigger_skill.py b/trigger_skill.py new file mode 100755 index 0000000..151fbbd --- /dev/null +++ b/trigger_skill.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Simulate a hotkey press to trigger a Nimble skill for testing.""" +import sys +import time +import os + +os.environ.setdefault("DISPLAY", ":0") + +from Xlib import display, X +from Xlib.ext import xtest + +KEYCODES = { + "ctrl": 37, + "shift": 50, + "alt": 64, + "l": 46, + "space": 65, +} + +shortcut = sys.argv[1] if len(sys.argv) > 1 else "ctrl+l" +parts = shortcut.lower().split("+") +codes = [KEYCODES[p] for p in parts if p in KEYCODES] + +if not codes: + print(f"Unknown keys in shortcut: {shortcut}") + sys.exit(1) + +d = display.Display(":0") +for kc in codes: + xtest.fake_input(d, X.KeyPress, kc) + d.flush() + time.sleep(0.05) +for kc in reversed(codes): + xtest.fake_input(d, X.KeyRelease, kc) + d.flush() + time.sleep(0.05) +d.close() +print(f"Triggered: {shortcut}") diff --git a/uv.lock b/uv.lock index 1878833..5ab030c 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anthropic" +version = "0.101.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/cb/9d0123243e749ac3a579972b2c398971bce1dc57bcc4efb08066df610360/anthropic-0.101.0.tar.gz", hash = "sha256:1116a6a87c55757e0fbe3e1ba40804fbd04de7963601a6dd6b539a889f18de3e", size = 758603, upload-time = "2026-05-11T15:46:33.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/b2/74ff06762d005ecf1658929a292df0acb786d025f6a6c54fcb30e2dc7761/anthropic-0.101.0-py3-none-any.whl", hash = "sha256:cc3cc6576989471e2aa9132258034ad0ff0d8fe500b04ac499e4e46ed68c5ed0", size = 753594, upload-time = "2026-05-11T15:46:32.216Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "black" version = "26.3.1" @@ -55,6 +97,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + [[package]] name = "click" version = "8.3.2" @@ -76,6 +127,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "evdev" version = "1.9.3" @@ -108,6 +177,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -117,6 +232,109 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jiter" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/2e/a9959997739c403378d0a4a3a1c4ed80b60aeace216c4d37b303a9fc60a4/jiter-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:02f36a5c700f105ac04a6556fe664a59037a2c200db3b7e88784fac2ddf02531", size = 316927, upload-time = "2026-04-10T14:25:40.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/72/b6de8a531e0adbadd839bec301165feb1fccf00e9ff55073ba2dd20f0043/jiter-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41eab6c09ceffb6f0fe25e214b3068146edb1eda3649ca2aee2a061029c7ba2e", size = 321181, upload-time = "2026-04-10T14:25:42.621Z" }, + { url = "https://files.pythonhosted.org/packages/db/d8/2040b9efa13c917f855c40890ae4119fe02c25b7c7677d5b4fa820a851fc/jiter-0.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cf4d4c109641f9cfaf4a7b6aebd51654e405cd00fa9ebbf87163b8b97b325aa", size = 347387, upload-time = "2026-04-10T14:25:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/655c0ad5ce6a8e90f9068c175b8a236877d753e460762b3183c136db1c5b/jiter-0.14.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80c7b41a628e6be2213ad0ece763c5f88aa5ee003fa394d58acaaee1f4b8342", size = 373083, upload-time = "2026-04-10T14:25:45.55Z" }, + { url = "https://files.pythonhosted.org/packages/f1/66/549c40fa068f08710b7570869c306a051eb67a29758bd64f4114f730554c/jiter-0.14.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb3dbf7cc0d4dbe73cce307ebe7eefa7f73a7d3d854dd119ea0c243f03e40927", size = 463639, upload-time = "2026-04-10T14:25:47.452Z" }, + { url = "https://files.pythonhosted.org/packages/25/2f/97a32a05fed14ed58a18e181fdfb619e05163f3726b54ee6080ec0539c09/jiter-0.14.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7054adcdeb06b46efd17b5734f75817a44a2d06d3748e36c3a023a1bb52af9ec", size = 380735, upload-time = "2026-04-10T14:25:49.305Z" }, + { url = "https://files.pythonhosted.org/packages/2a/3b/4347e1d6c2a973d653bbb7a2d671a2d2426e54b52ba735b8ff0d0a29b75c/jiter-0.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d597cd1bf6790376f3fffc7c708766e57301d99a19314824ea0ccc9c3c70e1e2", size = 358632, upload-time = "2026-04-10T14:25:50.931Z" }, + { url = "https://files.pythonhosted.org/packages/ef/24/ca452fbf2ea33548ed30ce68a39a50442d3f7c9bf0704a7af958a930c057/jiter-0.14.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:df63a14878da754427926281626fd3ee249424a186e25a274e78176d42945264", size = 359969, upload-time = "2026-04-10T14:25:52.381Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a3/94470a0d199287caabeb4da2bb2ae5f6d17f3cf05dfc975d7cb064d58e0f/jiter-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ea73187627bcc5810e085df715e8a99da8bdfd96a7eb36b4b4df700ba6d4c9c", size = 397529, upload-time = "2026-04-10T14:25:53.801Z" }, + { url = "https://files.pythonhosted.org/packages/cf/71/6768edc09d7c45c39f093feb3de105fa718a3e982b5208b8a2ed6382b44b/jiter-0.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9f541eaf7bb8382367a1a23d6fc3d6aad57f8dd8c18c3c17f838bee20f217220", size = 522342, upload-time = "2026-04-10T14:25:55.396Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6b/5c2e17559a0f4e96e934479f7137df46c939e983fa05244e674815befb73/jiter-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:107465250de4fce00fdb47166bcd51df8e634e049541174fe3c71848e44f52ce", size = 556784, upload-time = "2026-04-10T14:25:56.927Z" }, + { url = "https://files.pythonhosted.org/packages/b1/83/c25f3556a60fc74d11199100f1b6cc0c006b815c8494dea8ca16fe398732/jiter-0.14.0-cp310-cp310-win32.whl", hash = "sha256:ffb2a08a406465bb076b7cc1df41d833106d3cf7905076cc73f0cb90078c7d10", size = 208439, upload-time = "2026-04-10T14:25:58.796Z" }, + { url = "https://files.pythonhosted.org/packages/2e/99/781a1b413f0989b7f2ea203b094b331685f1a35e52e0a45e5d000ecaab27/jiter-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb8b682d10cb0cce7ff4c1af7244af7022c9b01ae16d46c357bdd0df13afb25d", size = 204558, upload-time = "2026-04-10T14:26:00.208Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, + { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, + { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, + { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, + { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, + { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, + { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, + { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, + { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, + { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, + { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, + { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, + { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, + { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, + { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, + { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, + { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, + { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, + { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, + { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, + { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, + { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, + { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, + { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, + { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, + { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, + { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, + { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, + { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, + { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, + { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, +] + [[package]] name = "librt" version = "0.9.0" @@ -304,6 +522,9 @@ name = "nimble" version = "1.0.0" source = { editable = "." } dependencies = [ + { name = "anthropic" }, + { name = "evdev", marker = "sys_platform == 'linux'" }, + { name = "openai" }, { name = "plyer" }, { name = "pynput" }, { name = "pyyaml" }, @@ -322,9 +543,12 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic", specifier = ">=0.40.0" }, { name = "black", marker = "extra == 'dev'", specifier = "==26.3.1" }, + { name = "evdev", marker = "sys_platform == 'linux'", specifier = ">=1.6.1" }, { name = "flake8", marker = "extra == 'dev'", specifier = "==7.3.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.20.1" }, + { name = "openai", specifier = ">=1.0.0" }, { name = "plyer", specifier = ">=2.1" }, { name = "pynput", specifier = ">=1.8.1" }, { name = "pytest", marker = "extra == 'dev'" }, @@ -335,6 +559,25 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "openai" +version = "2.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a1/4d5e84cf51720fc1526cc49e10ac1961abcccb55b0efb3d970db1e9a2728/openai-2.36.0.tar.gz", hash = "sha256:139dea0edd2f1b30c33d46ae1a6929e03906254140318e4608e98fe8c566f2e7", size = 753003, upload-time = "2026-05-07T17:33:17.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/1c/5d43735b2553baae2a5e899dcbcd0670a86930d993184d72ca909bf11c9b/openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63", size = 1302361, upload-time = "2026-05-07T17:33:15.063Z" }, +] + [[package]] name = "packaging" version = "26.1" @@ -389,6 +632,137 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + [[package]] name = "pyflakes" version = "3.4.0" @@ -680,6 +1054,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -734,6 +1117,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + [[package]] name = "typer" version = "0.24.1" @@ -767,6 +1162,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "watchdog" version = "6.0.0"