diff --git a/README.md b/README.md index 3533749..d88eb53 100644 --- a/README.md +++ b/README.md @@ -114,16 +114,20 @@ re-renders and reloads. `./install-server.sh --doctor` re-runs the checks alone, read-only. -The plists are **rendered from templates** in `launchd/`, never edited by -hand, because launchd reads its own XML and cannot see `config.py` — so the -two drift silently. `tests/test_launchd_config_sync.py` renders the templates -and asserts they agree with config, including that the ASR server is never -bound off loopback. +The plists are **rendered by `install-server.sh`**, never edited by hand. +`tests/test_install_server_doctor.py` renders them against a fabricated config +and asserts they agree with it — including that the ASR server is never bound +off loopback. -A wildcard bind is refused by `hark.plists` itself, not only by the test -suite — `install-server.sh` stops rather than installing a plist that listens -on every interface. Any other address is accepted, since the two-machine setup -binds to a private one on purpose. +The server's own plist carries **no address at all**: `hark serve` reads +`~/.config/hark/config.toml` directly, so there is one copy of that fact rather +than two that can disagree. (`uvicorn` needed `--host` baked into the plist, +which is what the old drift guard existed to police.) + +A wildcard bind is refused twice: by `install-server.sh` before a plist is +written, and by `hark serve` at startup. The second is the real enforcement; +the first is what turns a launchd crash-loop into a message. Any other address +is accepted, since the two-machine setup binds to a private one on purpose. The shared secret lives at `~/.config/hark/key` (mode 600), outside the repo. @@ -441,13 +445,11 @@ install-server.sh transcription side: deps, model, plists, services install-client.sh builds + installs the agent, plus --doctor swift/ Sources/hark/ the agent — hotkey, capture, paste, overlay - Sources/HarkCore/ config, client, WAV, sanitise, server + Sources/HarkCore/ config, client, WAV, sanitise, the HTTP server Packaging/build-app.sh assembles and signs Hark.app Tests/ SwiftPM suite (48) config.example.toml shape of ~/.config/hark/config.toml -src/hark/ the Python HTTP service (still the one in use) -launchd/ plist templates, rendered by hark.plists -tests/ pytest suite +tests/ pytest suite — drives the installers as subprocesses .github/workflows/ci.yml pytest + shellcheck + the signed bundle build docs/ design specs ``` diff --git a/install-server.sh b/install-server.sh index d2636bb..43f1794 100755 --- a/install-server.sh +++ b/install-server.sh @@ -26,17 +26,30 @@ set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG_DIR="$HOME/.config/hark" +CONFIG_FILE="$CONFIG_DIR/config.toml" KEY_FILE="$CONFIG_DIR/key" LAUNCH_AGENTS="$HOME/Library/LaunchAgents" LABELS=(com.drycodeworks.hark com.drycodeworks.hark-whisper) -# Where the running service lives, kept in step with hark.plists.VENV_DIR — -# tests/test_launchd_config_sync.py asserts the plists point here. The clone is +# Where the running service lives. The clone is # a place to edit code; a daemon that runs out of it breaks when the checkout # moves and silently changes behaviour on `git pull`. -INSTALL_DIR="$HOME/.local/share/hark" -VENV_DIR="$INSTALL_DIR/venv" -VENV_PYTHON="$VENV_DIR/bin/python" +APP_DIR="$HOME/Applications" +APP_DST="$APP_DIR/Hark.app" + +# Minimal TOML reader for the three scalars the plists need. Deliberately not a +# parser: config.toml is two tables of scalars, and `hark serve` is the thing +# that actually validates it. +config_value() { + local table="$1" key="$2" default="$3" + [[ -f "$CONFIG_FILE" ]] || { printf '%s' "$default"; return; } + awk -v t="[$table]" -v k="$key" ' + $0 ~ /^\[/ { in_t = ($0 == t); next } + in_t && $0 ~ "^[[:space:]]*" k "[[:space:]]*=" { + sub(/^[^=]*=[[:space:]]*/, ""); gsub(/"/, ""); sub(/[[:space:]]*(#.*)?$/, ""); + print; exit + }' "$CONFIG_FILE" | head -1 | grep . || printf '%s' "$default" +} MODEL_DIR="$HOME/.local/share/whisper-cpp" MODEL_NAME="ggml-large-v3-turbo.bin" @@ -79,31 +92,36 @@ doctor_fail() { # Asked of the INSTALLED package, not the clone: the installed one is what # launchd is running, and if the two have drifted then the clone's answer is # the wrong one to probe with. +# Probed over LOOPBACK, not over the configured bind address. +# +# The server accepts loopback by design — a client on this machine is the same +# trust boundary whichever address it dials — and on a tailnet bind the server's +# own machine cannot reach itself at that address anyway. Probing the bind +# address from here reported the service as down while it was serving the other +# machine perfectly. hark_url() { - "$VENV_PYTHON" -c \ - 'from hark import config; print(f"http://{config.HARK_HOST}:{config.HARK_PORT}")' + printf 'http://127.0.0.1:%s' "$(config_value server port 8911)" } # ============================================================================== # Checks # ============================================================================== -# The plists name an absolute path inside VENV_DIR. If that venv is missing or -# broken, launchd's only account of it is a restart loop and a spawn error in -# /tmp/hark.err — so check it here, first, where the message can say what to do. +# The plist names an absolute path inside the bundle. If it is missing or its +# signature is broken, launchd's only account is a restart loop and a spawn +# error in /tmp/hark.err — so check it here, where the message can say what to do. check_server_installed() { - if [[ ! -x "$VENV_DIR/bin/uvicorn" ]]; then - doctor_fail "the server is installed at ${VENV_DIR}" \ - "re-run ./install-server.sh (it installs the package there; launchd runs that copy, not this clone)" + if [[ ! -x "$APP_DST/Contents/MacOS/hark" ]]; then + doctor_fail "the server is installed at ${APP_DST}" \ + "re-run ./install-server.sh (launchd runs that bundle, not this clone)" return 1 fi - if ! "$VENV_PYTHON" -c 'import hark' >/dev/null 2>&1; then - doctor_fail "the server is installed at ${VENV_DIR}" \ - "the venv exists but cannot import hark — re-run ./install-server.sh" + if ! codesign --verify --strict "$APP_DST" 2>/dev/null; then + doctor_fail "the server bundle's signature verifies" \ + "rebuild it: ./install-server.sh" return 1 fi - doctor_pass "the server is installed at ${VENV_DIR}" - return 0 + doctor_pass "the server is installed at ${APP_DST}" } check_model() { @@ -223,6 +241,75 @@ run_doctor() { # Sourcing this file defines the check_* functions and stops here, so the test # suite can exercise them without running an install. Everything below this +render_plists() { + log "Rendering launchd plists from config..." + mkdir -p "$LAUNCH_AGENTS" + + BIND="$(config_value server bind 127.0.0.1)" + # Refused here as well as in `hark serve`. The server exits with an + # explanation, but launchd answers that with a crash loop, so catching it at + # render is the difference between a message and a restart storm. + case "$(printf '%s' "$BIND" | tr -d '[:space:]')" in + 0.0.0.0|::|"") + err "server.bind is \"${BIND}\", which listens on every network interface." + err "hark's response is pasted into whatever has focus, so this lets anyone" + err "who can reach this machine choose what gets typed." + err "Use 127.0.0.1, or this machine's private (tailnet/VPN/LAN) address." + return 1 + ;; + esac + PORT="$(config_value server port 8911)" + WHISPER_PORT="$(config_value whisper port 8910)" + WHISPER_BIN="$(command -v whisper-server || echo /opt/homebrew/bin/whisper-server)" + + cat > "$LAUNCH_AGENTS/com.drycodeworks.hark.plist" < + + + + Labelcom.drycodeworks.hark + ProgramArguments + + ${APP_DST}/Contents/MacOS/hark + serve + + RunAtLoad + KeepAlive + StandardOutPath/tmp/hark.log + StandardErrorPath/tmp/hark.err + + +PLIST + + cat > "$LAUNCH_AGENTS/com.drycodeworks.hark-whisper.plist" < + + + + Labelcom.drycodeworks.hark-whisper + ProgramArguments + + ${WHISPER_BIN} + --model + ${MODEL_PATH} + --host + 127.0.0.1 + --port + ${WHISPER_PORT} + --language + en + + RunAtLoad + KeepAlive + StandardOutPath/tmp/hark-whisper.log + StandardErrorPath/tmp/hark-whisper.err + + +PLIST + + log "Rendered both plists (hark: ${BIND}:${PORT}, whisper: 127.0.0.1:${WHISPER_PORT})" +} + # line only runs when the script is executed directly. if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then return 0 @@ -282,25 +369,38 @@ else log "Model saved to ${MODEL_PATH}" fi -# --- 3. Install the server ----------------------------------------------------- - -# launchd runs THIS copy, not the clone. Rebuilt from scratch on every run so a -# dependency dropped from pyproject.toml actually leaves, rather than lingering -# in the installed environment and hiding a missing declaration until someone -# installs fresh. It is a few seconds and a 26 MB directory. -log "Installing the server into ${VENV_DIR}..." -mkdir -p "$INSTALL_DIR" -rm -rf "$VENV_DIR" -uv venv --quiet "$VENV_DIR" -uv pip install --quiet --python "$VENV_PYTHON" "$REPO_DIR" - -# Prove it before a plist points launchd at it: a venv that cannot import hark -# would otherwise surface as a restart loop with a traceback in /tmp/hark.err. -if ! "$VENV_PYTHON" -c 'import hark' >/dev/null 2>&1; then - err "installed ${VENV_DIR} but it cannot import hark — aborting." +# --- 3. Build and install the server ------------------------------------------ + +# One signed bundle, two roles: `hark serve` here, `hark agent` on whatever Mac +# you dictate from. install-client.sh installs the same artifact, so a +# single-machine setup ends up with one copy that plays both parts. +if ! command -v swift >/dev/null 2>&1; then + err "swift not found. Install the Xcode command line tools: xcode-select --install" exit 1 fi -log "Installed. The clone is now only needed to re-install." + +log "Building the server..." +(cd "$REPO_DIR/swift" && swift build -c release >/dev/null && bash Packaging/build-app.sh >/dev/null) + +log "Installing to ${APP_DST}..." +mkdir -p "$APP_DIR" +# Replaced wholesale: a stale file left inside the bundle invalidates the +# signature, and that surfaces much later as an unexplained TCC re-prompt. +rm -rf "$APP_DST" +cp -R "$REPO_DIR/swift/Packaging/Hark.app" "$APP_DST" + +# Prove it runs before a plist points launchd at it. A binary that cannot +# start would otherwise surface as a restart loop with nothing in the log. +# Captured, not piped: `hark` with no arguments prints usage and exits 2 — +# correct behaviour — and under `set -o pipefail` that makes the pipeline fail +# no matter what grep says, so the check condemned a working binary. +usage_out="$("$APP_DST/Contents/MacOS/hark" 2>&1 || true)" +if [[ "$usage_out" != *"usage: hark"* ]]; then + err "installed ${APP_DST} but the binary does not run — aborting." + err "got: ${usage_out}" + exit 1 +fi +log "Installed." # --- 4. Shared secret --------------------------------------------------------- @@ -309,24 +409,20 @@ chmod 700 "$CONFIG_DIR" if [[ -s "$KEY_FILE" ]]; then log "Shared secret already exists (${KEY_FILE}) — leaving it alone." else - # Generated by config.hark_key() rather than here, so there is exactly one - # implementation of how the key is created and persisted. Regenerating a - # key that already exists would silently 401 every configured client. - log "Generating the shared secret..." - # Run from the INSTALLED package: the key the server will read must be - # written by the same code that will read it. - "$VENV_PYTHON" -c 'from hark import config; config.hark_key()' - log "Wrote ${KEY_FILE}" + # Created by the server on first use (KeyFile.ensure), not here, so there is + # exactly one implementation of how the key is generated and persisted. + # Regenerating a key that already exists would silently 401 every configured + # client, which is why this branch only reports. + log "No shared secret yet — the server will create ${KEY_FILE} on first start." fi # --- 5. Render the plists ----------------------------------------------------- +# +# Rendered here rather than by a Python module, so the server has no Python at +# all. The wildcard-bind check is enforced by `hark serve` itself at startup — +# it refuses 0.0.0.0 with an explanation — so this does not re-implement it. -log "Rendering launchd plists from config..." -mkdir -p "$LAUNCH_AGENTS" -# From the CLONE, not the installed venv: the templates live in launchd/ and -# are not shipped in the wheel. Rendering is an install-time task, and this -# script is part of the checkout that has them. -(cd "$REPO_DIR" && uv run --quiet python -m hark.plists >/dev/null) +render_plists # --- 6. Load the services ----------------------------------------------------- diff --git a/launchd/com.drycodeworks.hark-whisper.plist.template b/launchd/com.drycodeworks.hark-whisper.plist.template deleted file mode 100644 index ba19094..0000000 --- a/launchd/com.drycodeworks.hark-whisper.plist.template +++ /dev/null @@ -1,32 +0,0 @@ - - - - - Label - com.drycodeworks.hark-whisper - ProgramArguments - - @WHISPER_SERVER@ - --model - @MODEL@ - --host - @WHISPER_HOST@ - --port - @WHISPER_PORT@ - --language - en - --no-timestamps - --suppress-nst - --prompt - @PROMPT@ - - RunAtLoad - - KeepAlive - - StandardOutPath - /tmp/hark-whisper.log - StandardErrorPath - /tmp/hark-whisper.err - - diff --git a/launchd/com.drycodeworks.hark.plist.template b/launchd/com.drycodeworks.hark.plist.template deleted file mode 100644 index ead1a51..0000000 --- a/launchd/com.drycodeworks.hark.plist.template +++ /dev/null @@ -1,29 +0,0 @@ - - - - - Label - com.drycodeworks.hark - - ProgramArguments - - @UVICORN@ - hark.app:app - --host - @BIND@ - --port - @PORT@ - - RunAtLoad - - KeepAlive - - StandardOutPath - /tmp/hark.log - StandardErrorPath - /tmp/hark.err - - diff --git a/pyproject.toml b/pyproject.toml index ef7f8e9..1f7400e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,22 +2,12 @@ name = "hark" version = "0.1.0" requires-python = ">=3.12" -dependencies = [ - "fastapi>=0.115", - "uvicorn>=0.32", - "httpx>=0.27", -] +dependencies = [] [dependency-groups] -dev = ["pytest>=8.3", "pytest-asyncio>=0.24", "respx>=0.21"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/hark"] +dev = ["pytest>=8.3"] [tool.pytest.ini_options] -pythonpath = ["src"] -asyncio_mode = "auto" +# No pythonpath and no asyncio: nothing here imports a package any more. The +# suite drives install-server.sh and install-client.sh as subprocesses, which +# is what a user actually runs. diff --git a/src/hark/__init__.py b/src/hark/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/hark/app.py b/src/hark/app.py deleted file mode 100644 index e5cc3bc..0000000 --- a/src/hark/app.py +++ /dev/null @@ -1,148 +0,0 @@ -"""The hark service: audio in, transcript out. - -Deliberately contains no business logic - sanitization and ASR each live in -their own module and are imported here by name so tests can fake them. The -The server does not inject the transcript anywhere; it returns it in the HTTP -response and the client pastes it at the cursor. See -docs/superpowers/specs/2026-07-14-dictate-design.md, "REVISED 2026-07-14". -""" - -import asyncio -import logging -import secrets -import sys - -from fastapi import FastAPI, HTTPException, Request - -from hark import config -from hark.audio import InvalidAudioError, rms -from hark.sanitize import sanitize -from hark.whisper import WhisperUnavailableError, transcribe - -# Under uvicorn's logging config the `hark` logger has no handler and an -# effective level of WARNING, so every logger.info() below was silently -# dropped - including the success line, the only server-side record that a -# transcription happened. stdout, not stderr, because launchd routes -# StandardOutPath to /tmp/hark.log, which is where that record is -# expected to be found. -logging.basicConfig( - level=logging.INFO, - stream=sys.stdout, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", -) - -logger = logging.getLogger("hark") -# Explicit, so the level holds even if something else already configured the -# root logger and made basicConfig() a no-op. -logger.setLevel(logging.INFO) - -app = FastAPI(title="hark") - -KEY_HEADER = "x-hark-key" -AUDIO_WAV = "audio/wav" - -# Silence (energy gate trips, or the transcript has no alphanumeric content) -# is not an error: it means "nothing was said," and the client treats an -# empty string as "paste nothing." -EMPTY_TEXT = {"text": ""} - - -def _has_alphanumeric(text: str) -> bool: - return any(ch.isalnum() for ch in text) - - -def _authorize(request: Request) -> None: - """Reject anything that isn't our client. - - Both checks matter, and each one independently defeats the drive-by CSRF: - a page in a browser on any tailnet device could POST a WAV of the - attacker's choosing - a CORS-*simple* request, so no preflight - and - thereby choose the text returned in the response. X-Hark-Key and a - Content-Type of audio/wav are both NON-safelisted (only text/plain, - multipart/form-data and x-www-form-urlencoded are safelisted Content-Type - values), so requiring them forces a preflight; no CORS middleware is - installed, so that preflight fails and the browser blocks the request. - """ - presented = request.headers.get(KEY_HEADER, "") - if not secrets.compare_digest(presented, config.hark_key()): - logger.warning("rejected unauthenticated POST /dictate") - raise HTTPException(status_code=401, detail="missing or invalid X-Hark-Key") - - # Ignore parameters: `audio/wav; charset=binary` is still audio/wav. - media_type = request.headers.get("content-type", "").split(";")[0].strip().lower() - if media_type != AUDIO_WAV: - logger.warning("rejected POST /dictate with content-type %r", media_type) - raise HTTPException( - status_code=415, detail=f"expected Content-Type: {AUDIO_WAV}" - ) - - -@app.get("/health") -async def health() -> dict[str, str]: - return {"status": "ok"} - - -@app.post("/dictate") -async def dictate(request: Request) -> dict: - _authorize(request) - - wav = await request.body() - - # Whisper hallucinates on silence (" Thank you." for digital silence, "." - # for faint noise), so silence has to be caught on the AUDIO - by the time - # there is a transcript it is too late to tell "said nothing" from "said - # thank you". Gating here also skips a pointless whisper round-trip. - # - # An empty body used to 503 blaming whisper, when the real cause is a - # mis-permissioned mic producing a zero-byte WAV. - try: - amplitude = await asyncio.to_thread(rms, wav) - except InvalidAudioError as exc: - logger.warning("rejected audio: %s", exc) - raise HTTPException( - status_code=400, - detail=( - f"{exc}. Check that the client has microphone permission and is " - "sending 16 kHz mono 16-bit PCM WAV." - ), - ) from exc - - if amplitude < config.SILENCE_RMS_THRESHOLD: - logger.info( - "silent audio (rms %.1f < %.1f); returning empty transcript", - amplitude, - config.SILENCE_RMS_THRESHOLD, - ) - return EMPTY_TEXT - - try: - raw = await transcribe(wav) - except WhisperUnavailableError as exc: - logger.error("whisper-server unavailable: %s", exc) - raise HTTPException(status_code=503, detail=str(exc)) from exc - - text = sanitize(raw) - - # Audio loud enough to pass the gate can still transcribe to bare - # punctuation. Nothing was said, so there is nothing to return. - if not _has_alphanumeric(text): - logger.info("transcript has no alphanumerics; returning empty transcript") - return EMPTY_TEXT - - # Log the LENGTH only, never the text - transcripts are private and must - # never settle into a world-readable file in /tmp. - # - # The rms is logged on SUCCESS too, not just when the gate rejects audio. - # Without it there is no record of how much headroom real speech has above - # SILENCE_RMS_THRESHOLD, so the day the gate starts eating utterances (a - # noisier room, a mic further away, a quieter voice) there would be no data - # to recalibrate from - only a user reporting that dictation "just stopped - # working sometimes". The threshold was calibrated on synthetic audio, so - # this is the only real-world evidence there is. - logger.info( - "transcribed %d chars (rms %.1f, threshold %.1f)", - len(text), - amplitude, - config.SILENCE_RMS_THRESHOLD, - ) - return {"text": text} diff --git a/src/hark/audio.py b/src/hark/audio.py deleted file mode 100644 index 2668812..0000000 --- a/src/hark/audio.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Audio energy measurement, used to gate silence before it reaches whisper. - -Whisper hallucinates on silence. Measured against the live whisper-server on -this hardware: 1.5s and 6s of digital silence both transcribe as " Thank you.", -and low-level noise transcribes as " .". So a transcript-level check for -emptiness is dead code - the engine never emits an empty string - and a -mis-tapped hotkey would return "Thank you." for the client to paste. - -The gate therefore has to sit on the AUDIO, before transcription. A denylist of -hallucinated phrases would be wrong: "Thank you." is a perfectly legitimate -thing to dictate. Loudness is the signal that actually distinguishes "the user -said nothing" from "the user said thank you". - -Implemented on stdlib `wave` + `array`; `audioop` is removed in Python 3.13 -(this runs on 3.13), so the RMS is computed by hand. -""" - -import array -import io -import wave - -# The documented wire format is 16 kHz mono 16-bit PCM WAV. The threshold in -# config is calibrated on the signed-16-bit scale, so any other sample width -# would be silently mis-scaled against it - refuse it loudly instead. -SUPPORTED_SAMPLE_WIDTH = 2 - - -class InvalidAudioError(Exception): - """The request body is not a WAV we can measure.""" - - -def rms(wav: bytes) -> float: - """Return the root-mean-square amplitude of a WAV's PCM samples. - - 0.0 for digital silence; roughly 3000-5000 for normal speech. - """ - if not wav: - raise InvalidAudioError( - "empty audio body - the microphone produced no samples" - ) - - try: - with wave.open(io.BytesIO(wav), "rb") as reader: - width = reader.getsampwidth() - if width != SUPPORTED_SAMPLE_WIDTH: - raise InvalidAudioError( - f"expected 16-bit PCM samples, got {width * 8}-bit" - ) - frames = reader.readframes(reader.getnframes()) - except InvalidAudioError: - raise - except (wave.Error, EOFError, OSError, ValueError) as exc: - raise InvalidAudioError(f"not a readable WAV: {exc}") from exc - - samples = array.array("h") # signed 16-bit, matching SUPPORTED_SAMPLE_WIDTH - # Ignore a trailing partial sample rather than raising on an odd byte count. - usable = len(frames) - (len(frames) % samples.itemsize) - samples.frombytes(frames[:usable]) - if not samples: - return 0.0 - if sys_is_big_endian(): - samples.byteswap() # WAV PCM is little-endian on the wire - - return (sum(s * s for s in samples) / len(samples)) ** 0.5 - - -def sys_is_big_endian() -> bool: - return array.array("h", b"\x01\x00")[0] != 1 diff --git a/src/hark/config.py b/src/hark/config.py deleted file mode 100644 index d14a404..0000000 --- a/src/hark/config.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Deployment configuration. - -The defaults describe the single-machine setup: record, transcribe and paste -all on one Mac, bound to loopback, exposed to nothing. That is the safe default -and the one a new user should get without reading anything. - -The two-machine setup — a laptop recording, a desktop transcribing — is the -same architecture with a different bind address. Personal values (a tailnet -bind address, a vocabulary prompt, a calibrated silence threshold) belong in -``~/.config/hark/config.toml``, which lives outside the repo and is never -published. See ``config.example.toml``. -""" - -import os -import secrets -import tomllib -from pathlib import Path - -CONFIG_FILE = Path( - os.environ.get("HARK_CONFIG", Path.home() / ".config/hark/config.toml") -) - - -def _load(path: Path) -> dict: - """Parse the TOML config, or return {} when there isn't one. - - A missing file is the ordinary single-machine case, not an error. A - malformed one is deliberately left to raise: silently falling back to - defaults could bind the service somewhere the user did not ask for. - """ - try: - return tomllib.loads(path.read_text()) - except FileNotFoundError: - return {} - - -_cfg = _load(CONFIG_FILE) -_server = _cfg.get("server", {}) -_whisper = _cfg.get("whisper", {}) -_audio = _cfg.get("audio", {}) - -# whisper-server: loopback only, and deliberately not configurable. Making the -# ASR server remote-reachable is never correct — it is the one component that -# handles raw audio, and audio must not leave the machine that recorded it. -WHISPER_HOST = "127.0.0.1" -WHISPER_PORT = _whisper.get("port", 8910) -WHISPER_URL = f"http://{WHISPER_HOST}:{WHISPER_PORT}" - -# hark: loopback by default, so the stock install exposes nothing. Set -# server.bind to a private address (e.g. a tailnet IP) for the two-machine -# setup. Never 0.0.0.0 — see the plist drift guard, which enforces this. -HARK_HOST = _server.get("bind", "127.0.0.1") -HARK_PORT = _server.get("port", 8911) - -MODEL_PATH = Path( - _whisper.get("model", "~/.local/share/whisper-cpp/ggml-large-v3-turbo.bin") -).expanduser() - -# Vocabulary biasing. whisper-server's --prompt seeds the decoder, which is the -# cheapest accuracy win available and gates whether an LLM cleanup pass is ever -# needed. Empty by default — one person's jargon is another person's noise. -# Set whisper.prompt to your own terms and extend it as words show up mangled. -VOCAB_PROMPT = _whisper.get("prompt", "") - -TRANSCRIBE_TIMEOUT_S = 60.0 -CONNECT_TIMEOUT_S = 5.0 - -# Below this RMS amplitude (signed-16-bit scale) the audio is treated as -# silence and never reaches whisper. -# -# Whisper hallucinates confident text on silence, so the transcript cannot be -# trusted to reveal that nothing was said. Measured against a live -# whisper-server: -# -# digital silence RMS 0.00 -> " Thank you." <- would be returned -# low-level noise RMS 9.30 -> " ." -# speech, -32 dB RMS 79.17 -> correct transcript -# speech, -26 dB RMS 157.97 -> correct transcript -# `say` speech @ 16 kHz RMS 3151.94 -> correct transcript -# tests/fixtures/hello.wav RMS 4774.99 -> correct transcript -# -# 150 sits ~16x above the noise floor that hallucinates and ~21x below normal -# speech. A false reject is visible (the response says {"text": ""}) and costs -# one repeated utterance; a false accept silently returns "Thank you." for the -# client to paste. -# -# Honest caveat: this was calibrated against synthetic `say` audio, not a real -# microphone. If your mic has a higher noise floor, raise it — the separation -# is three orders of magnitude, so there is room. The server logs the measured -# rms on every request precisely so you can calibrate from evidence. -SILENCE_RMS_THRESHOLD = _audio.get("silence_rms_threshold", 150.0) - -# The shared secret gating POST /dictate, sent by the client as X-Hark-Key. -# -# Without it the endpoint was CSRF-reachable: a page open in a browser on any -# machine that can route to this one could POST a WAV of the attacker's choosing -# (a CORS-simple request needs no preflight) and thereby choose the text typed -# into a live agent's terminal. X-Hark-Key is a non-safelisted header, so -# requiring it forces a preflight, which fails - no CORS middleware is installed. -# -# Deliberately not a credential system: one user, one key, one file. The key is -# generated on first use and persisted, so the client can be configured once by -# reading the file. It lives outside the repo and is never committed. -KEY_FILE = Path.home() / ".config/hark/key" - - -def _key_file() -> Path: - return Path(os.environ.get("HARK_KEY_FILE", KEY_FILE)) - - -def hark_key() -> str: - """Return the shared secret, generating and persisting one if needed.""" - from_env = os.environ.get("HARK_KEY") - if from_env: - return from_env - - path = _key_file() - try: - return path.read_text().strip() - except FileNotFoundError: - pass - - path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - key = secrets.token_urlsafe(32) - try: - # Exclusive create: if a concurrent request won the race, use its key - # rather than overwriting it and locking that request's client out. - with path.open("x") as f: - f.write(key + "\n") - path.chmod(0o600) - except FileExistsError: - return path.read_text().strip() - return key diff --git a/src/hark/plists.py b/src/hark/plists.py deleted file mode 100644 index 16a5a9e..0000000 --- a/src/hark/plists.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Render the launchd plist templates from config. - -launchd does not read ``config.py`` — it reads its own XML. Anything that must -agree between the two (bind address, ports, model path, vocabulary prompt) can -therefore drift silently into production. The templates in ``launchd/`` carry -placeholders instead of values, this module fills them from config, and -``tests/test_launchd_config_sync.py`` renders them and asserts agreement. That -makes drift a test failure rather than a mystery. - -Run it directly to install the services:: - - uv run python -m hark.plists # render to ~/Library/LaunchAgents - uv run python -m hark.plists --print # render to stdout, install nothing -""" - -import argparse -import shutil -import sys -from pathlib import Path -from xml.sax.saxutils import escape - -from hark import config - -# Rendering is an INSTALL-time task, run from a checkout: the templates live in -# the repo and are not shipped in the wheel, so these resolve only when this -# module is imported from the source tree. Running `python -m hark.plists` out -# of the installed venv is a mistake with a specific message - see main(). -REPO_ROOT = Path(__file__).resolve().parent.parent.parent -TEMPLATE_DIR = REPO_ROOT / "launchd" -LAUNCH_AGENTS = Path.home() / "Library/LaunchAgents" - -# Where install-server.sh installs the package for the running service. -# -# The clone is a place you edit code, not a place a daemon lives. Pointing -# launchd at the checkout made moving or deleting it break the service - with -# KeepAlive true, that shows up only as a restart loop and a nonzero exit in -# /tmp/hark.err - and made `git pull` live-patch a running daemon, so the next -# utterance ran whatever had just landed. Installing into a stable prefix makes -# the clone disposable and upgrades explicit: re-run install-server.sh. -INSTALL_DIR = Path.home() / ".local/share/hark" -VENV_DIR = INSTALL_DIR / "venv" - -TEMPLATES = ( - "com.drycodeworks.hark.plist", - "com.drycodeworks.hark-whisper.plist", -) - - -def _tool(name: str, fallback: str) -> str: - """Absolute path to an installed CLI. - - launchd jobs get a bare PATH — nothing from a login shell, no Homebrew — - so every executable in a plist must be an absolute path or the job dies at - load with a spawn error and no useful message. - """ - return shutil.which(name) or fallback - - -class UnsafeBindError(ValueError): - """Raised when the configured bind address would expose the service.""" - - -# 0.0.0.0 and :: are every interface; "" is how most socket APIs spell the -# same thing. Everything else is allowed on purpose — the two-machine setup -# binds to a private address, so this cannot be a whitelist of loopback. -WILDCARD_BINDS = frozenset({"0.0.0.0", "::", ""}) - - -def _check_bind(host: str) -> str: - """Refuse a wildcard bind before it can reach a plist. - - `hark` returns text that goes onto the clipboard and is then pasted into - whatever has focus, so an endpoint reachable from every attached network - lets anyone who can route to this machine choose what gets typed into the - user's terminal. The drift guard in the test suite asserted this, but - `install-server.sh` renders and bootstraps without ever running pytest — - so for an actual user the check did not exist. Enforcing it here is what - makes the promise in README.md and config.example.toml true. - """ - if host.strip() in WILDCARD_BINDS: - raise UnsafeBindError( - f"server.bind is {host!r}, which listens on every network interface.\n" - "hark's response is pasted into whatever has focus, so this lets " - "anyone who can reach this machine choose what gets typed.\n" - "Use 127.0.0.1 for a single machine, or the private address of " - "this machine (a tailnet/VPN/LAN IP) for the two-machine setup.\n" - "Set it in ~/.config/hark/config.toml." - ) - return host - - -def substitutions() -> dict[str, str]: - """The placeholder → value map, derived entirely from config.""" - return { - # The installed venv's uvicorn, by absolute path - not `uv run` from - # the clone. uv is needed to install the service, not to run it. - "@UVICORN@": str(VENV_DIR / "bin" / "uvicorn"), - "@WHISPER_SERVER@": _tool("whisper-server", "/opt/homebrew/bin/whisper-server"), - "@BIND@": _check_bind(config.HARK_HOST), - "@PORT@": str(config.HARK_PORT), - "@WHISPER_HOST@": config.WHISPER_HOST, - "@WHISPER_PORT@": str(config.WHISPER_PORT), - "@MODEL@": str(config.MODEL_PATH), - "@PROMPT@": config.VOCAB_PROMPT, - } - - -def render(name: str) -> str: - """Return the rendered plist XML for one template.""" - text = (TEMPLATE_DIR / f"{name}.template").read_text() - for token, value in substitutions().items(): - # The values land inside XML text nodes, and a vocabulary prompt is - # free text a user wrote — an unescaped & or < would produce a plist - # that launchd rejects as malformed. - text = text.replace(token, escape(value)) - leftover = [t for t in substitutions() if t in text] - if leftover: - raise AssertionError(f"unsubstituted placeholder in {name}: {leftover}") - return text - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--print", - action="store_true", - dest="print_only", - help="write the rendered plists to stdout instead of installing them", - ) - args = parser.parse_args(argv) - - # Same reasoning as the bind check below: this is reachable by running the - # installed copy instead of the checkout, and a FileNotFoundError naming a - # path inside site-packages/ does not tell anyone what to do about it. - if not TEMPLATE_DIR.is_dir(): - print( - f"error: no plist templates at {TEMPLATE_DIR}.\n" - "The templates live in the repo and are not shipped in the wheel, " - "so rendering has to run from a checkout:\n" - " cd /path/to/hark && uv run python -m hark.plists", - file=sys.stderr, - ) - return 1 - - # A misconfigured bind is a user error in a TOML file, not a bug — it - # deserves the message, not a traceback. install-server.sh calls this, so - # this is what the user sees mid-install. - try: - rendered = {name: render(name) for name in TEMPLATES} - except UnsafeBindError as exc: - print(f"error: {exc}", file=sys.stderr) - return 1 - - if args.print_only: - for name, text in rendered.items(): - print(f"===== {name} =====") - print(text) - return 0 - - LAUNCH_AGENTS.mkdir(parents=True, exist_ok=True) - for name, text in rendered.items(): - target = LAUNCH_AGENTS / name - target.write_text(text) - print(f"wrote {target}") - - label_args = " ".join(f"gui/$(id -u)/{n.removesuffix('.plist')}" for n in TEMPLATES) - print( - "\nNot loaded yet. To (re)load:\n" - f" for l in {label_args}; do launchctl bootout $l 2>/dev/null; done\n" - f" for n in {' '.join(TEMPLATES)}; do " - 'launchctl bootstrap gui/$(id -u) "$HOME/Library/LaunchAgents/$n"; done' - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/hark/sanitize.py b/src/hark/sanitize.py deleted file mode 100644 index 3281b75..0000000 --- a/src/hark/sanitize.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Transcript sanitization. - -Dictation never wants a literal newline. Collapsing them is what makes it -structurally impossible for a transcript to submit a prompt prematurely, -rather than relying on bracketed paste to save us. - -Control characters -- C0 (0x00-0x1F, 0x7F) and C1 (0x80-0x9F), which -includes the 8-bit single-byte equivalents of ESC-prefixed sequences like -CSI/OSC/DCS -- are replaced with a space rather than deleted, so a stray -control character can't smuggle an escape sequence into the receiving -application. Substituting instead of deleting also means two words -separated only by a control character become two space-separated words -after whitespace collapsing, not one fused word. -""" - -import re - -_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]") -_WHITESPACE = re.compile(r"\s+") - - -def sanitize(raw: str) -> str: - without_control = _CONTROL_CHARS.sub(" ", raw) - return _WHITESPACE.sub(" ", without_control).strip() diff --git a/src/hark/whisper.py b/src/hark/whisper.py deleted file mode 100644 index 51dac12..0000000 --- a/src/hark/whisper.py +++ /dev/null @@ -1,38 +0,0 @@ -"""HTTP client for whisper.cpp's whisper-server. - -The server holds the model resident; a fresh `whisper-cli` per utterance would -reload 1.5 GB every time. Vocabulary biasing is applied at server startup via ---prompt (see launchd/), not per request. -""" - -import httpx - -from hark import config - - -class WhisperUnavailableError(Exception): - """whisper-server did not answer, or answered with an error.""" - - -async def transcribe(wav: bytes, base_url: str = config.WHISPER_URL) -> str: - files = {"file": ("audio.wav", wav, "audio/wav")} - data = {"response_format": "json", "temperature": "0.0"} - timeout = httpx.Timeout( - config.TRANSCRIBE_TIMEOUT_S, connect=config.CONNECT_TIMEOUT_S - ) - try: - async with httpx.AsyncClient(timeout=timeout) as client: - response = await client.post( - f"{base_url}/inference", files=files, data=data - ) - response.raise_for_status() - text = response.json()["text"] - if not isinstance(text, str): - raise ValueError(f"expected str for 'text', got {text!r}") - return text - except httpx.HTTPError as exc: - raise WhisperUnavailableError(str(exc)) from exc - except (ValueError, KeyError) as exc: - raise WhisperUnavailableError( - f"malformed response from whisper-server: {exc}" - ) from exc diff --git a/swift/Sources/HarkCore/HarkServer.swift b/swift/Sources/HarkCore/HarkServer.swift index f9b2d52..ab863ec 100644 --- a/swift/Sources/HarkCore/HarkServer.swift +++ b/swift/Sources/HarkCore/HarkServer.swift @@ -63,6 +63,13 @@ public protocol WhisperTranscribing { extension WhisperClient: WhisperTranscribing {} public final class HarkServer { + /// 0.0.0.0 and :: are every interface; "" is how most socket APIs spell + /// the same thing. Mirrors WILDCARD_BINDS in src/hark/plists.py. + static let wildcardBinds: Set = ["0.0.0.0", "::", ""] + + /// The address connections must have arrived at, set by start(). + private var boundHost = "127.0.0.1" + let config: HarkConfig let key: String let whisper: any WhisperTranscribing @@ -90,7 +97,47 @@ public final class HarkServer { guard let p = NWEndpoint.Port(rawValue: portNumber) else { throw HarkServerError.bindFailed("invalid port \(portNumber)") } - let listener = try NWListener(using: .tcp, on: p) + + // REFUSE A WILDCARD BIND. hark's response is pasted into whatever has + // focus, so an endpoint reachable from every attached network lets + // anyone who can route here choose what gets typed into the user's + // terminal. This is a remote keystroke injector, not a data leak. + // + // The Python server has enforced this since #6; porting the server + // without it silently undid that fix. + let host = config.bindHost.trimmingCharacters(in: .whitespaces) + guard !Self.wildcardBinds.contains(host) else { + throw HarkServerError.bindFailed( + """ + server.bind is "\(config.bindHost)", which listens on every network \ + interface. + hark's response is pasted into whatever has focus, so this lets anyone \ + who can reach this machine choose what gets typed. + Use 127.0.0.1 for a single machine, or the private address of this \ + machine (a tailnet/VPN/LAN IP) for the two-machine setup. + Set it in ~/.config/hark/config.toml. + """) + } + + // `NWListener(using: .tcp, on: p)` accepts on every interface regardless + // of config, so `bind` was decorative: measured with bind = "127.0.0.1", + // lsof reported `TCP *:8914 (LISTEN)` and another machine on the tailnet + // got a 200 — while the log claimed loopback. + // + // requiredLocalEndpoint restricts it, but too much: it is stricter than + // a BSD bind(). A connection from the SERVER'S OWN machine to its own + // tailnet address is delivered over loopback, so its path does not match + // the utun endpoint and the handshake never completes — measured, the + // Studio timed out reaching its own server while the laptop got a 200. + // That breaks the single-machine setup on a tailnet, which is the most + // common one. + // + // So the address is enforced per-connection instead, in handle(), which + // is where the security question actually lives: refuse to SERVE anyone + // who did not arrive at the configured address. + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + let listener = try NWListener(using: params, on: p) listener.newConnectionHandler = { [weak self] conn in self?.handle(conn) } @@ -98,7 +145,8 @@ public final class HarkServer { if case .failed(let e) = state { logger.error("listener failed: \(e)") } } listener.start(queue: .global(qos: .userInitiated)) - logger.info("hark listening on \(config.bindHost):\(portNumber)") + boundHost = host + logger.info("hark listening on \(host):\(portNumber)") return listener } @@ -114,12 +162,37 @@ public final class HarkServer { conn.stateUpdateHandler = { [weak self, weak conn] state in guard let self, let conn else { return } if case .ready = state { + // Enforce server.bind here rather than on the listener. hark's + // response is pasted into whatever has focus, so serving a + // connection that arrived on an interface the operator did not + // name is the thing to prevent — and refusing at accept costs + // an attacker a handshake and gets them nothing. + guard self.arrivedAtConfiguredAddress(conn) else { + self.logger.error("refused a connection that did not arrive at \(self.boundHost)") + conn.cancel() + return + } self.receiveLoop(conn, buffer: Data()) } } conn.start(queue: .global(qos: .default)) } + /// True when the connection's local address is the one `server.bind` names. + /// + /// Loopback is always accepted: a client on this machine may reach the + /// server either by 127.0.0.1 or by the machine's own configured address, + /// and both are the same trust boundary. + private func arrivedAtConfiguredAddress(_ conn: NWConnection) -> Bool { + guard case .hostPort(let host, _)? = conn.currentPath?.localEndpoint else { + // No path yet: fail closed rather than guess. + return false + } + let local = "\(host)".split(separator: "%").first.map(String.init) ?? "\(host)" + if local == boundHost { return true } + return ["127.0.0.1", "::1"].contains(local) + } + private func receiveLoop(_ conn: NWConnection, buffer: Data) { conn.receive(minimumIncompleteLength: 1, maximumLength: 256 * 1024) { [weak self, weak conn] data, _, isComplete, error in guard let conn else { return } @@ -143,6 +216,15 @@ public final class HarkServer { } catch HTTPParseError.incomplete { // Need more data. self.receiveLoop(conn, buffer: buf) + } catch HTTPParseError.tooLarge { + // 413 rather than 400: the request was understood and refused + // on size, and saying so is what tells a client to send less + // rather than to send it again. + let resp = HTTPResponse(status: 413, contentType: "application/json", + body: Data("{\"detail\":\"request body too large\"}".utf8)) + conn.send(content: resp.serialized, completion: .contentProcessed { _ in + conn.cancel() + }) } catch { // Unparseable request: 400. let resp = HTTPResponse(status: 400, contentType: "application/json", @@ -315,11 +397,17 @@ enum ConstantTime { } } -enum HTTPParseError: Error { +enum HTTPParseError: Error, Equatable { case incomplete case malformed + case tooLarge } +/// 16 MB — roughly 8 minutes of 16 kHz mono s16, far past any hold-to-talk +/// utterance. The client caps its own uploads at 1 MB; this is the server +/// refusing to trust that. +let maxBodyBytes = 16 * 1024 * 1024 + enum HTTPParser { /// Parse a single HTTP/1.1 request if the buffer holds it completely. /// Returns (request, bytesConsumed). Throws `.incomplete` if more data is @@ -348,6 +436,13 @@ enum HTTPParser { } let contentLength = Int(headers["content-length"] ?? "0") ?? 0 + + // CAP THE BODY. The body is parsed before routing, so before the key is + // checked — an unauthenticated request can otherwise make the server + // buffer whatever Content-Length it claims. 16 MB is ~8 minutes of the + // 16 kHz mono s16 audio this accepts, well past any hold-to-talk + // utterance, and refusing here costs nothing a real client would miss. + guard contentLength <= maxBodyBytes else { throw HTTPParseError.tooLarge } let bodyStart = headerEnd.upperBound let available = data.count - bodyStart guard available >= contentLength else { throw HTTPParseError.incomplete } diff --git a/swift/Tests/HarkCoreTests/BindGuardTests.swift b/swift/Tests/HarkCoreTests/BindGuardTests.swift new file mode 100644 index 0000000..d75f5ed --- /dev/null +++ b/swift/Tests/HarkCoreTests/BindGuardTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import HarkCore + +/// The bind guard and the body cap. +/// +/// Both were missing from the Swift server while present in the Python one it +/// replaces, and both are the kind of thing that looks fine until someone +/// looks at `lsof`. +final class BindGuardTests: XCTestCase { + + /// hark's response is pasted into whatever has focus, so an endpoint on + /// every interface lets anyone who can route here choose what gets typed. + /// Enforced in the Python server since #6; porting the server without it + /// silently undid that. + func testWildcardBindsAreRefused() throws { + for host in ["0.0.0.0", "::", "", " "] { + let cfg = HarkConfig(bindHost: host, harkPort: 8999) + let server = HarkServer(config: cfg) + XCTAssertThrowsError(try server.start(), "bind \(host.debugDescription) must be refused") { error in + guard case HarkServerError.bindFailed(let message) = error else { + return XCTFail("expected bindFailed, got \(error)") + } + XCTAssertTrue(message.contains("every network interface"), + "the message must say why, not just that it failed") + } + } + } + + func testAPrivateAddressIsAccepted() throws { + let cfg = HarkConfig(bindHost: "127.0.0.1", harkPort: 8998) + let listener = try HarkServer(config: cfg).start() + defer { listener.cancel() } + XCTAssertNotNil(listener) + } + + /// The body is parsed before routing, so before the key is checked. Without + /// a cap an unauthenticated request can make the server buffer whatever + /// Content-Length it claims. + func testAnOversizedBodyIsRefusedBeforeItIsBuffered() { + let claimed = maxBodyBytes + 1 + let head = "POST /dictate HTTP/1.1\r\nContent-Type: audio/wav\r\nContent-Length: \(claimed)\r\n\r\n" + XCTAssertThrowsError(try HTTPParser.parseComplete(from: Data(head.utf8))) { error in + XCTAssertEqual(error as? HTTPParseError, .tooLarge, + "a huge Content-Length must be refused, not awaited") + } + } + + func testABodyAtTheLimitIsNotRefusedOnSize() { + // At the cap it is incomplete (the body has not arrived), NOT tooLarge. + let head = "POST /dictate HTTP/1.1\r\nContent-Type: audio/wav\r\nContent-Length: \(maxBodyBytes)\r\n\r\n" + XCTAssertThrowsError(try HTTPParser.parseComplete(from: Data(head.utf8))) { error in + XCTAssertEqual(error as? HTTPParseError, .incomplete) + } + } + + func testTheCapLeavesRoomForRealUtterances() { + // 16 kHz mono s16 = 32000 B/s. A hold-to-talk utterance is seconds. + XCTAssertGreaterThan(maxBodyBytes, 32000 * 60, "under a minute of audio would be too tight") + } +} diff --git a/tests/conftest.py b/tests/conftest.py index 600a3d2..e14af60 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,7 @@ -import pytest +"""Shared pytest configuration. -TEST_KEY = "test-shared-secret" - - -@pytest.fixture(autouse=True) -def hark_key(monkeypatch): - """Pin the shared secret for every test. - - Also keeps the suite from touching the real key file under $HOME: with - HARK_KEY set, config.hark_key() never falls through to the file. - """ - monkeypatch.setenv("HARK_KEY", TEST_KEY) - monkeypatch.delenv("HARK_KEY_FILE", raising=False) - return TEST_KEY +The suite is now entirely about the shell installers — the Python server it +used to exercise was replaced by `hark serve` and deleted. Its fixtures +(HARK_KEY, and keeping tests off the real key file) went with it: nothing here +imports hark, and every test that touches $HOME is already given a tmp_path. +""" diff --git a/tests/fixtures/hello.wav b/tests/fixtures/hello.wav deleted file mode 100644 index 710cb3c..0000000 Binary files a/tests/fixtures/hello.wav and /dev/null differ diff --git a/tests/fixtures/silence.wav b/tests/fixtures/silence.wav deleted file mode 100644 index afe4068..0000000 Binary files a/tests/fixtures/silence.wav and /dev/null differ diff --git a/tests/test_app.py b/tests/test_app.py deleted file mode 100644 index aa200f7..0000000 --- a/tests/test_app.py +++ /dev/null @@ -1,226 +0,0 @@ -import logging -from pathlib import Path - -import pytest -from fastapi.testclient import TestClient - -from hark import app as app_module -from hark.app import app -from hark.whisper import WhisperUnavailableError - -from conftest import TEST_KEY - -FIXTURES = Path(__file__).parent / "fixtures" -WAV = (FIXTURES / "hello.wav").read_bytes() # real speech, RMS ~4775 -SILENCE = (FIXTURES / "silence.wav").read_bytes() # digital silence, RMS 0 - -HEADERS = {"content-type": "audio/wav", "x-hark-key": TEST_KEY} - - -@pytest.fixture -def stub_transcribe(monkeypatch): - """Fake whisper's transcribe() so tests don't need a live server.""" - - async def fake_transcribe(wav, base_url=None): - return " Hello, world.\nSecond line. " - - monkeypatch.setattr(app_module, "transcribe", fake_transcribe) - - -def test_dictate_sanitizes_the_returned_text(stub_transcribe): - response = TestClient(app).post("/dictate", content=WAV, headers=HEADERS) - assert response.status_code == 200 - # Newline collapsed - this is the guarantee against premature submit on - # whatever the client pastes the text into. - assert response.json() == {"text": "Hello, world. Second line."} - - -def test_dictate_gates_on_silent_audio_without_ever_calling_whisper(monkeypatch): - """The real engine does NOT return an empty string on silence - verified - against the live whisper-server, 1.5s and 6s of digital silence both come - back as " Thank you.". So `if not text:` was dead code and a mis-tapped - hotkey would have returned "Thank you." for the client to paste. - - Gate on the audio instead, before transcribing - which also saves a - pointless whisper round-trip. - """ - called = [] - - async def spy_transcribe(wav, base_url=None): - called.append(wav) - return " Thank you.\n" # what the live engine actually emits - - monkeypatch.setattr(app_module, "transcribe", spy_transcribe) - - response = TestClient(app).post("/dictate", content=SILENCE, headers=HEADERS) - - assert response.status_code == 200 - assert response.json() == {"text": ""} - assert called == [], "whisper must not be called at all for silent audio" - - -def test_dictate_returns_thank_you_when_the_audio_has_real_energy(monkeypatch): - """The guard is an energy gate, NOT a denylist. "Thank you." is a - perfectly legitimate thing a person might dictate, and it must be - returned. - """ - - async def thanks(wav, base_url=None): - return " Thank you.\n" - - monkeypatch.setattr(app_module, "transcribe", thanks) - - response = TestClient(app).post("/dictate", content=WAV, headers=HEADERS) - - assert response.status_code == 200 - assert response.json() == {"text": "Thank you."} - - -def test_dictate_returns_empty_text_when_transcript_has_no_alphanumerics( - monkeypatch, -): - """Low-level noise that clears the energy gate still makes whisper emit - bare punctuation - the live server returns " .\\n". Nothing was said, so - the client should paste nothing. - """ - - async def punctuation(wav, base_url=None): - return " .\n" # what the live engine actually emits for faint noise - - monkeypatch.setattr(app_module, "transcribe", punctuation) - - response = TestClient(app).post("/dictate", content=WAV, headers=HEADERS) - - assert response.status_code == 200 - assert response.json() == {"text": ""} - - -def test_dictate_rejects_an_empty_body_naming_the_real_cause(stub_transcribe): - """A zero-byte WAV is the most likely first-run failure (mis-permissioned - mic). It used to produce a 503 blaming whisper, sending the user off to - debug the wrong process entirely. - """ - response = TestClient(app).post("/dictate", content=b"", headers=HEADERS) - - assert response.status_code == 400 - detail = response.json()["detail"].lower() - assert "microphone" in detail or "mic" in detail - - -def test_dictate_rejects_a_body_that_is_not_a_wav(stub_transcribe): - response = TestClient(app).post( - "/dictate", content=b"not a wav at all", headers=HEADERS - ) - assert response.status_code == 400 - - -def test_dictate_returns_503_when_whisper_down(monkeypatch): - async def down(wav, base_url=None): - raise WhisperUnavailableError("connection refused") - - monkeypatch.setattr(app_module, "transcribe", down) - response = TestClient(app).post("/dictate", content=WAV, headers=HEADERS) - assert response.status_code == 503 - - -def test_health_endpoint(): - assert TestClient(app).get("/health").status_code == 200 - - -# --- auth ------------------------------------------------------------------ -# -# POST /dictate was unauthenticated and CSRF-reachable. A page open in a -# browser on ANY tailnet device could -# -# fetch(url, {method: 'POST', mode: 'no-cors', body: wavBlob}) -# -# which is a CORS-*simple* request - no preflight - so the attacker chose the -# WAV and therefore chose the text that landed in the response. -# -# Both X-Hark-Key and Content-Type: audio/wav are non-safelisted headers -# (only text/plain, multipart/form-data and x-www-form-urlencoded are safelisted -# Content-Type values), so requiring either forces the browser to preflight; no -# CORS middleware is installed, so the preflight fails and the browser blocks -# the request. Requiring both means each independently closes the hole. - - -def test_dictate_rejects_a_request_with_no_key(stub_transcribe): - response = TestClient(app).post( - "/dictate", content=WAV, headers={"content-type": "audio/wav"} - ) - assert response.status_code == 401 - - -def test_dictate_rejects_a_request_with_the_wrong_key(stub_transcribe): - response = TestClient(app).post( - "/dictate", - content=WAV, - headers={"content-type": "audio/wav", "x-hark-key": "not-the-key"}, - ) - assert response.status_code == 401 - - -def test_dictate_rejects_a_wrong_content_type(stub_transcribe): - """The drive-by CSRF body would arrive as text/plain - a safelisted - Content-Type that needs no preflight. - """ - response = TestClient(app).post( - "/dictate", - content=WAV, - headers={"content-type": "text/plain", "x-hark-key": TEST_KEY}, - ) - assert response.status_code == 415 - - -def test_dictate_rejects_a_missing_content_type(stub_transcribe): - response = TestClient(app).post( - "/dictate", content=WAV, headers={"x-hark-key": TEST_KEY} - ) - assert response.status_code in (401, 415) - - -def test_dictate_accepts_content_type_with_parameters(stub_transcribe): - """`audio/wav; charset=binary` is still audio/wav.""" - response = TestClient(app).post( - "/dictate", - content=WAV, - headers={ - "content-type": "audio/wav; charset=binary", - "x-hark-key": TEST_KEY, - }, - ) - assert response.status_code == 200 - assert response.json() == {"text": "Hello, world. Second line."} - - -def test_health_needs_no_key(): - """Liveness must stay reachable - launchd and the client both poll it.""" - assert TestClient(app).get("/health").status_code == 200 - - -# --- logging --------------------------------------------------------------- - - -def test_dictated_logger_actually_emits_info(): - """Under uvicorn's logging config the `hark` logger had no handler and - an effective level of WARNING, so the success line was silently dropped. - """ - assert logging.getLogger("hark").getEffectiveLevel() <= logging.INFO - - -def test_success_log_records_length_but_never_the_transcript(stub_transcribe, caplog): - """The log must identify the size of what was transcribed - and must NOT - contain the transcript itself. Audio and text never leave this hardware; - that includes not settling into a world-readable file in /tmp. - """ - with caplog.at_level(logging.INFO, logger="hark"): - response = TestClient(app).post("/dictate", content=WAV, headers=HEADERS) - assert response.status_code == 200 - - logged = "\n".join(r.getMessage() for r in caplog.records) - assert "26" in logged, "the transcript LENGTH must be recorded" - - transcript = "Hello, world. Second line." - assert transcript not in logged - for fragment in ("Hello", "world", "Second line"): - assert fragment not in logged, f"transcript fragment {fragment!r} was logged" diff --git a/tests/test_audio.py b/tests/test_audio.py deleted file mode 100644 index 911a8ed..0000000 --- a/tests/test_audio.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Tests for the energy gate. - -Calibration (measured against the live whisper-server on this hardware): - - silence.wav (digital silence) RMS 0.00 -> whisper: " Thank you.\\n" - low-level noise RMS 9.30 -> whisper: " .\\n" - `say` speech @ 16 kHz RMS 3151.94 -> correct transcript - speech attenuated -26 dB RMS 157.97 -> correct transcript - hello.wav (repo fixture) RMS 4774.99 -> correct transcript - -Whisper hallucinates confident-looking text on silence, so "silent transcript --> inject nothing" is false in practice; the gate has to be on the AUDIO. -""" - -import wave -from pathlib import Path - -import pytest - -from hark import config -from hark.audio import InvalidAudioError, rms - -FIXTURES = Path(__file__).parent / "fixtures" -SILENCE = (FIXTURES / "silence.wav").read_bytes() -SPEECH = (FIXTURES / "hello.wav").read_bytes() - - -def test_rms_of_digital_silence_is_zero(): - assert rms(SILENCE) == 0.0 - - -def test_rms_of_real_speech_is_large(): - assert rms(SPEECH) > 1000 - - -def test_silence_is_below_the_threshold_and_speech_is_far_above(): - """The gate only works if the two populations are cleanly separated. If a - future threshold edit collapses that separation, this fails. - """ - assert rms(SILENCE) < config.SILENCE_RMS_THRESHOLD - assert rms(SPEECH) > config.SILENCE_RMS_THRESHOLD - # Real speech should clear the bar by a wide margin, not squeak past it. - assert rms(SPEECH) > 10 * config.SILENCE_RMS_THRESHOLD - - -def test_threshold_sits_above_the_noise_floor_that_hallucinates(): - """Low-level noise (RMS ~9.3) made whisper emit " .". The threshold must be - comfortably above that noise floor, or the gate lets it through. - """ - assert config.SILENCE_RMS_THRESHOLD > 50 - - -def test_rms_rejects_empty_bytes(): - with pytest.raises(InvalidAudioError): - rms(b"") - - -def test_rms_rejects_bytes_that_are_not_a_wav(): - with pytest.raises(InvalidAudioError): - rms(b"this is not a RIFF header at all") - - -def test_rms_rejects_truncated_wav(): - with pytest.raises(InvalidAudioError): - rms(SPEECH[:20]) - - -def test_rms_rejects_unsupported_sample_width(tmp_path): - """The threshold is calibrated on the 16-bit scale. An 8-bit WAV would be - silently mis-scaled, so refuse it loudly instead - the documented contract - is 16 kHz mono 16-bit PCM. - """ - path = tmp_path / "eight_bit.wav" - with wave.open(str(path), "wb") as w: - w.setnchannels(1) - w.setsampwidth(1) - w.setframerate(16000) - w.writeframes(b"\x80" * 1000) - - with pytest.raises(InvalidAudioError): - rms(path.read_bytes()) - - -def test_rms_handles_a_wav_with_zero_frames(tmp_path): - """A header-only WAV must not raise ZeroDivisionError.""" - path = tmp_path / "empty.wav" - with wave.open(str(path), "wb") as w: - w.setnchannels(1) - w.setsampwidth(2) - w.setframerate(16000) - w.writeframes(b"") - - assert rms(path.read_bytes()) == 0.0 diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index fea4d69..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,58 +0,0 @@ -"""The shared secret that gates POST /dictate. - -Deliberately not a credential system: one user, one key, one file. -""" - -import stat - -from hark import config - - -def test_env_var_wins(monkeypatch): - monkeypatch.setenv("HARK_KEY", "from-the-env") - assert config.hark_key() == "from-the-env" - - -def test_key_is_generated_and_persisted_when_absent(monkeypatch, tmp_path): - monkeypatch.delenv("HARK_KEY", raising=False) - key_file = tmp_path / "nested" / "key" - monkeypatch.setenv("HARK_KEY_FILE", str(key_file)) - - generated = config.hark_key() - - assert generated - assert key_file.exists() - assert key_file.read_text().strip() == generated - # Stable across calls - a key that changed per request would lock the - # client out after the first dictation. - assert config.hark_key() == generated - - -def test_generated_key_is_not_world_readable(monkeypatch, tmp_path): - monkeypatch.delenv("HARK_KEY", raising=False) - key_file = tmp_path / "key" - monkeypatch.setenv("HARK_KEY_FILE", str(key_file)) - - config.hark_key() - - mode = stat.S_IMODE(key_file.stat().st_mode) - assert mode == 0o600, f"key file is {oct(mode)}, must be 0600" - - -def test_generated_key_has_real_entropy(monkeypatch, tmp_path): - monkeypatch.delenv("HARK_KEY", raising=False) - monkeypatch.setenv("HARK_KEY_FILE", str(tmp_path / "key")) - - key = config.hark_key() - - assert len(key) >= 32 - - -def test_existing_key_file_is_read_not_overwritten(monkeypatch, tmp_path): - monkeypatch.delenv("HARK_KEY", raising=False) - key_file = tmp_path / "key" - key_file.write_text("already-here\n") - monkeypatch.setenv("HARK_KEY_FILE", str(key_file)) - - assert config.hark_key() == "already-here" - assert key_file.read_text() == "already-here\n" diff --git a/tests/test_install_server_doctor.py b/tests/test_install_server_doctor.py index 382d8e4..0a03e0c 100644 --- a/tests/test_install_server_doctor.py +++ b/tests/test_install_server_doctor.py @@ -121,12 +121,11 @@ def test_the_pinned_revision_looks_like_a_commit_sha(self): assert re.search(r'"[0-9a-f]{40}"', line), f"not a full commit sha: {line}" -def run_check_server_installed(venv_dir: Path) -> tuple[int, str]: - """Run check_server_installed against a fabricated install prefix.""" +def run_check_server_installed(app_dst: Path) -> tuple[int, str]: + """Run check_server_installed against a fabricated bundle.""" program = f""" source {SCRIPT} - VENV_DIR="{venv_dir}" - VENV_PYTHON="$VENV_DIR/bin/python" + APP_DST="{app_dst}" check_server_installed """ result = subprocess.run( @@ -139,26 +138,19 @@ def run_check_server_installed(venv_dir: Path) -> tuple[int, str]: class TestServerInstalled: - """The plists name an absolute path inside the install prefix, and launchd - reports a bad one only as a restart loop plus a spawn error in a log file - nobody is watching. This check is the thing that says so out loud, so it - must not PASS on an install that cannot actually run. + """The plist names an absolute path inside the bundle, and launchd reports a + bad one only as a restart loop plus a spawn error in a log file nobody is + watching. This check is the thing that says so out loud, so it must not PASS + on an install that cannot actually run. """ - def _venv(self, tmp_path: Path, *, importable: bool) -> Path: - venv = tmp_path / "venv" - (venv / "bin").mkdir(parents=True) - (venv / "bin" / "uvicorn").write_text("#!/bin/sh\n") - (venv / "bin" / "uvicorn").chmod(0o755) - python = venv / "bin" / "python" - python.write_text("#!/bin/sh\nexit %d\n" % (0 if importable else 1)) - python.chmod(0o755) - return venv - - def test_a_working_install_passes(self, tmp_path): - status, out = run_check_server_installed(self._venv(tmp_path, importable=True)) - assert status == 0, out - assert "FAIL" not in out + def _bundle(self, tmp_path: Path, *, executable: bool = True) -> Path: + app = tmp_path / "Hark.app" + (app / "Contents" / "MacOS").mkdir(parents=True) + binary = app / "Contents" / "MacOS" / "hark" + binary.write_text("#!/bin/sh\necho 'usage: hark ' >&2\nexit 2\n") + binary.chmod(0o755 if executable else 0o644) + return app def test_a_missing_install_fails(self, tmp_path): status, out = run_check_server_installed(tmp_path / "not-installed") @@ -166,12 +158,20 @@ def test_a_missing_install_fails(self, tmp_path): assert "FAIL" in out assert "install-server.sh" in out - def test_a_venv_that_cannot_import_hark_is_not_a_pass(self, tmp_path): - # The trap this exists for: uvicorn is on disk, so an existence check - # alone would PASS, while launchd cannot start the app at all. - status, out = run_check_server_installed(self._venv(tmp_path, importable=False)) + def test_a_non_executable_binary_is_not_a_pass(self, tmp_path): + # The trap: the bundle exists, so a directory check alone would PASS + # while launchd cannot spawn it at all. + status, out = run_check_server_installed(self._bundle(tmp_path, executable=False)) assert status != 0, out - assert "cannot import hark" in out + assert "FAIL" in out + + def test_an_unsigned_bundle_is_not_a_pass(self, tmp_path): + # A fabricated bundle has no signature. Signature verification is what + # catches a partially-replaced bundle, whose only other symptom is an + # unexplained TCC re-prompt much later. + status, out = run_check_server_installed(self._bundle(tmp_path)) + assert status != 0, out + assert "signature" in out def test_sourcing_the_script_installs_nothing(): @@ -219,3 +219,120 @@ def test_a_label_that_merely_contains_ours_is_not_a_match(): status, out = run_check(listing("com.example.com.drycodeworks.hark.backup")) assert status != 0 assert out.count("FAIL") == 2 + + +class TestPlistRendering: + """The launchd drift guard, ported from test_launchd_config_sync.py. + + The plists are now rendered by install-server.sh rather than by a Python + module, but what they must satisfy is unchanged: they are what launchd + actually runs, and a wrong one surfaces only as a restart loop and a spawn + error in a log nobody is watching. + """ + + def _render(self, tmp_path: Path, config: str) -> tuple[int, dict[str, str]]: + cfg_dir = tmp_path / ".config" / "hark" + cfg_dir.mkdir(parents=True) + (cfg_dir / "config.toml").write_text(config) + agents = tmp_path / "Library" / "LaunchAgents" + agents.mkdir(parents=True) + program = f""" + source {SCRIPT} + CONFIG_FILE="{cfg_dir}/config.toml" + LAUNCH_AGENTS="{agents}" + APP_DST="{tmp_path}/Hark.app" + MODEL_PATH="{tmp_path}/model.bin" + render_plists + """ + r = subprocess.run(["bash", "-c", program], capture_output=True, text=True, + env={"PATH": "/usr/bin:/bin", "HOME": str(tmp_path)}) + rendered = {p.name: p.read_text() for p in agents.glob("*.plist")} + return r.returncode, rendered + + ONE_MACHINE = '[server]\nbind = "127.0.0.1"\nport = 8911\n\n[whisper]\nport = 8910\n' + + def test_both_plists_are_rendered(self, tmp_path): + rc, plists = self._render(tmp_path, self.ONE_MACHINE) + assert rc == 0 + assert set(plists) == {"com.drycodeworks.hark.plist", + "com.drycodeworks.hark-whisper.plist"} + + def test_no_placeholder_survives(self, tmp_path): + _, plists = self._render(tmp_path, self.ONE_MACHINE) + for name, text in plists.items(): + assert "@" not in text, f"{name} still has an unsubstituted placeholder" + assert "${" not in text, f"{name} has an unexpanded shell variable" + + def test_the_port_matches_config(self, tmp_path): + cfg = '[server]\nbind = "127.0.0.1"\nport = 9111\n\n[whisper]\nport = 9110\n' + _, plists = self._render(tmp_path, cfg) + assert "9110" in plists["com.drycodeworks.hark-whisper.plist"] + + def test_whisper_stays_on_loopback(self, tmp_path): + # whisper handles raw audio. It must never be reachable off-box, and + # its host is deliberately not configurable. + cfg = '[server]\nbind = "100.64.66.46"\nport = 8911\n\n[whisper]\nport = 8910\n' + _, plists = self._render(tmp_path, cfg) + w = plists["com.drycodeworks.hark-whisper.plist"] + assert "127.0.0.1" in w + assert "100.64.66.46" not in w, "the tailnet address leaked into whisper's plist" + + def test_the_plist_points_at_the_installed_bundle_not_the_clone(self, tmp_path): + _, plists = self._render(tmp_path, self.ONE_MACHINE) + hark = plists["com.drycodeworks.hark.plist"] + assert str(tmp_path / "Hark.app") in hark + assert "/swift/Packaging/" not in hark, "points into the build tree, not the install" + + def test_it_runs_the_serve_role(self, tmp_path): + _, plists = self._render(tmp_path, self.ONE_MACHINE) + assert "serve" in plists["com.drycodeworks.hark.plist"] + + def test_the_server_plist_carries_no_address(self, tmp_path): + """`hark serve` reads config.toml itself, so the plist encodes nothing. + + uvicorn needed --host and --port baked into the plist, which is exactly + what the old drift guard existed to police: two copies of the same fact + that could disagree. There is now one copy. + """ + cfg = '[server]\nbind = "100.64.66.46"\nport = 9911\n\n[whisper]\nport = 8910\n' + _, plists = self._render(tmp_path, cfg) + hark = plists["com.drycodeworks.hark.plist"] + assert "100.64.66.46" not in hark + assert "9911" not in hark + + def test_no_working_directory_is_set(self, tmp_path): + # A WorkingDirectory would make the service depend on a path that can + # move, which is the failure the install prefix exists to avoid. + _, plists = self._render(tmp_path, self.ONE_MACHINE) + for text in plists.values(): + assert "WorkingDirectory" not in text + + @pytest.mark.parametrize("host", ["0.0.0.0", "::"]) + def test_a_wildcard_bind_is_refused_at_render(self, tmp_path, host): + # `hark serve` also refuses this, but launchd answers that with a crash + # loop — catching it here is the difference between a message and a + # restart storm. + cfg = f'[server]\nbind = "{host}"\nport = 8911\n\n[whisper]\nport = 8910\n' + rc, plists = self._render(tmp_path, cfg) + assert rc != 0, f"bind {host!r} must be refused" + assert plists == {}, "nothing should be written when the bind is refused" + + def test_an_empty_bind_falls_back_to_loopback(self, tmp_path): + """`bind = ""` means unset, not "every interface". + + The Python guard refused it, because there the value went straight to a + socket API where empty spells the wildcard. Here it never reaches one: + an absent or empty value takes the default, and the default is + loopback. Defaulting to the safe end is better than refusing, but it is + a deliberate difference rather than an oversight. + """ + cfg = '[server]\nbind = ""\nport = 8911\n\n[whisper]\nport = 8910\n' + rc, plists = self._render(tmp_path, cfg) + assert rc == 0 + assert plists != {} + + @pytest.mark.parametrize("host", ["127.0.0.1", "100.64.66.46", "192.168.1.10"]) + def test_private_binds_are_allowed(self, tmp_path, host): + cfg = f'[server]\nbind = "{host}"\nport = 8911\n\n[whisper]\nport = 8910\n' + rc, _ = self._render(tmp_path, cfg) + assert rc == 0 diff --git a/tests/test_launchd_config_sync.py b/tests/test_launchd_config_sync.py deleted file mode 100644 index 7a3cb55..0000000 --- a/tests/test_launchd_config_sync.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Guard against launchd plist drift from src/hark/config.py. - -The launchd plists carry values (host, port, model path, vocab prompt) that -MUST match config.py — launchd doesn't read config.py, it reads its own XML. -Nothing enforces agreement except a human reading both files side by side. - -The plists are now rendered from templates by ``hark.plists``, so these -tests render them and parse the result with plistlib. That closes a loop the -old version could not: a template that loses a placeholder, or a substitution -that stops reaching config, now fails here instead of silently drifting into -production. -""" - -import ipaddress -import plistlib -from pathlib import Path - -import pytest - -from hark import config, plists - -TEMPLATE_DIR = Path(plists.TEMPLATE_DIR) -WHISPER_PLIST = "com.drycodeworks.hark-whisper.plist" -DICTATED_PLIST = "com.drycodeworks.hark.plist" - - -def render_plist(name: str) -> dict: - return plistlib.loads(plists.render(name).encode()) - - -def arg_after(args: list[str], flag: str) -> str: - """Return the ProgramArguments value immediately following `flag`. - - Fails with a clear message (not an IndexError) if the flag is absent or - is the last element with nothing after it. - """ - if flag not in args: - raise AssertionError(f"{flag!r} not found in ProgramArguments: {args!r}") - idx = args.index(flag) - if idx + 1 >= len(args): - raise AssertionError(f"{flag!r} has no value after it in: {args!r}") - return args[idx + 1] - - -def is_loopback(host: str) -> bool: - try: - return ipaddress.ip_address(host).is_loopback - except ValueError: - return host == "localhost" - - -class TestTemplatesRender: - def test_every_template_exists(self): - for name in plists.TEMPLATES: - assert (TEMPLATE_DIR / f"{name}.template").is_file() - - def test_no_placeholder_survives_rendering(self): - # render() raises on a leftover placeholder; this also asserts the - # result is valid plist XML, which a half-substituted file is not. - for name in plists.TEMPLATES: - assert render_plist(name)["Label"].startswith("com.drycodeworks.") - - def test_no_personal_path_is_baked_into_a_template(self): - # The templates are published. A rendered plist may contain the - # invoking user's home directory; a template never may. - for name in plists.TEMPLATES: - text = (TEMPLATE_DIR / f"{name}.template").read_text() - assert "/Users/" not in text - assert str(Path.home()) not in text - - -class TestWhisperServerPlist: - def setup_method(self): - self.plist = render_plist(WHISPER_PLIST) - self.args = self.plist["ProgramArguments"] - - def test_prompt_matches_vocab_prompt(self): - plist_prompt = arg_after(self.args, "--prompt") - assert plist_prompt == config.VOCAB_PROMPT - - def test_host_matches_config_and_is_loopback(self): - plist_host = arg_after(self.args, "--host") - assert plist_host == config.WHISPER_HOST - # This is the constraint that keeps the ASR server off the network: - # audio must never leave the user's own hardware, so whisper-server - # may only ever bind to loopback. - assert is_loopback(plist_host), ( - f"whisper-server plist host {plist_host!r} is not loopback — " - "this would expose raw audio transcription beyond localhost." - ) - - def test_port_matches_config(self): - plist_port = arg_after(self.args, "--port") - assert plist_port == str(config.WHISPER_PORT) - - def test_model_path_matches_config(self): - plist_model = arg_after(self.args, "--model") - assert plist_model == str(config.MODEL_PATH) - - -class TestDictatedPlist: - def setup_method(self): - self.plist = render_plist(DICTATED_PLIST) - self.args = self.plist["ProgramArguments"] - - def test_host_matches_config(self): - plist_host = arg_after(self.args, "--host") - assert plist_host == config.HARK_HOST - - def test_port_matches_config(self): - plist_port = arg_after(self.args, "--port") - assert plist_port == str(config.HARK_PORT) - - def test_host_is_not_wildcard_bind(self): - # Asserted against the rendered plist's literal value, not just against - # config.HARK_HOST, so this still catches the failure mode even if - # someone writes 0.0.0.0 into their own config.toml: binding hark - # to all interfaces would expose the injection service to every - # attached network, violating the "audio/text never leaves this - # hardware" privacy premise. - plist_host = arg_after(self.args, "--host") - assert plist_host != "0.0.0.0" - assert plist_host != "" - - @pytest.mark.parametrize("host", ["0.0.0.0", "::", "", " "]) - def test_wildcard_bind_is_refused_at_render(self, monkeypatch, host): - # This used to assert the opposite — that the dangerous value reached - # the plist — because the guard was test-only and install-server.sh - # never ran pytest. It is enforced in render() now, so the same - # scenario must raise instead of producing a plist. - monkeypatch.setattr(config, "HARK_HOST", host) - with pytest.raises(plists.UnsafeBindError): - plists.render(DICTATED_PLIST) - - @pytest.mark.parametrize("host", ["127.0.0.1", "10.0.0.2", "192.168.1.9", "100.64.0.1"]) - def test_private_binds_are_still_allowed(self, monkeypatch, host): - # The guard must refuse wildcards ONLY. The two-machine setup binds to - # a private address on purpose, so a whitelist of loopback would break - # a supported configuration. - monkeypatch.setattr(config, "HARK_HOST", host) - assert arg_after(render_plist(DICTATED_PLIST)["ProgramArguments"], "--host") == host - - # The clone must not be load-bearing for the running service. These three - # replace an earlier test that asserted the opposite — that WorkingDirectory - # WAS the repo root — which made moving the checkout break the service and - # `git pull` live-patch a running daemon (issue #3). The intent changed; - # this is not a bug fix on top of the old assertion. - - def test_runs_the_installed_venv_not_the_clone(self): - program = Path(self.args[0]) - assert program == plists.VENV_DIR / "bin" / "uvicorn", ( - f"launchd would run {program}, not the installed server" - ) - - def test_nothing_in_the_plist_points_into_the_clone(self): - repo = str(plists.REPO_ROOT) - offenders = [v for v in self.args if isinstance(v, str) and v.startswith(repo)] - assert not offenders, ( - f"these reach into the checkout, so moving it breaks the service: {offenders}" - ) - - def test_rendering_without_templates_explains_itself(self, monkeypatch, capsys, tmp_path): - # Reachable by running the INSTALLED copy instead of the checkout: the - # templates are not in the wheel. It cost a failed install to find, and - # a FileNotFoundError naming a path inside site-packages/ says nothing - # about what to do next. - monkeypatch.setattr(plists, "TEMPLATE_DIR", tmp_path / "gone") - assert plists.main([]) == 1 - err = capsys.readouterr().err - assert "from a checkout" in err, err - - def test_sets_no_working_directory(self): - # Absence is the assertion: nothing here resolves a relative path, and - # a WorkingDirectory is what tied the daemon to the checkout before. - assert "WorkingDirectory" not in self.plist diff --git a/tests/test_sanitize.py b/tests/test_sanitize.py deleted file mode 100644 index c2edb5a..0000000 --- a/tests/test_sanitize.py +++ /dev/null @@ -1,55 +0,0 @@ -from hark.sanitize import sanitize - - -def test_collapses_newlines_to_spaces(): - assert sanitize("hello\nworld") == "hello world" - - -def test_collapses_carriage_returns(): - assert sanitize("hello\r\nworld") == "hello world" - - -def test_collapses_runs_of_whitespace(): - assert sanitize("hello \n\n world") == "hello world" - - -def test_trims_leading_and_trailing_whitespace(): - assert sanitize(" hello world \n") == "hello world" - - -def test_preserves_shell_metacharacters_verbatim(): - # These must survive untouched. They are safe because the transcript is - # passed to tmux on stdin via load-buffer, never interpolated into a - # command line. Mangling them here would corrupt legitimate dictation. - raw = 'rm -rf $HOME; echo "hi" && `whoami`' - assert sanitize(raw) == 'rm -rf $HOME; echo "hi" && `whoami`' - - -def test_empty_string_returns_empty(): - assert sanitize("") == "" - - -def test_whitespace_only_returns_empty(): - assert sanitize(" \n\t \r\n ") == "" - - -def test_strips_ascii_control_characters(): - # A stray ESC in a transcript could otherwise be interpreted as an escape - # sequence by the receiving application. - assert sanitize("hello\x1b[31mworld\x00") == "hello [31mworld" - - -def test_strips_c1_control_characters(): - # C1 controls (0x80-0x9F) are the 8-bit single-byte equivalents of ESC- - # prefixed C0 sequences: U+009B (CSI) == ESC [, U+009D (OSC) == ESC ], - # U+0090 (DCS) == ESC P. A speech-to-text engine that emits these must - # not be able to smuggle escape sequences into the receiving application. - assert "\x9b" not in sanitize("hello\x9bworld") - assert "\x9d" not in sanitize("hello\x9dworld") - - -def test_control_character_between_words_separates_rather_than_fuses(): - # Control-stripping must run as a substitution (control char -> space) - # before whitespace collapsing, not a deletion, or two words separated - # only by a control character get silently fused into one. - assert sanitize("rm\x0c-rf") == "rm -rf" diff --git a/tests/test_whisper.py b/tests/test_whisper.py deleted file mode 100644 index b8f735e..0000000 --- a/tests/test_whisper.py +++ /dev/null @@ -1,88 +0,0 @@ -import httpx -import pytest -import respx - -from hark import config -from hark.whisper import transcribe, WhisperUnavailableError - -BASE = "http://127.0.0.1:8910" - - -@respx.mock -async def test_transcribe_returns_text(): - respx.post(f"{BASE}/inference").mock( - return_value=httpx.Response(200, json={"text": " hello world "}) - ) - assert await transcribe(b"RIFFfake", base_url=BASE) == " hello world " - - -@respx.mock -async def test_transcribe_posts_wav_as_multipart_file(): - route = respx.post(f"{BASE}/inference").mock( - return_value=httpx.Response(200, json={"text": "ok"}) - ) - await transcribe(b"RIFFfake", base_url=BASE) - - body = route.calls.last.request.content - assert b"RIFFfake" in body - assert b'name="file"' in body - - -@respx.mock -async def test_transcribe_raises_when_server_down(): - respx.post(f"{BASE}/inference").mock( - side_effect=httpx.ConnectError("refused") - ) - with pytest.raises(WhisperUnavailableError): - await transcribe(b"RIFFfake", base_url=BASE) - - -@respx.mock -async def test_transcribe_raises_on_http_error(): - respx.post(f"{BASE}/inference").mock(return_value=httpx.Response(500)) - with pytest.raises(WhisperUnavailableError): - await transcribe(b"RIFFfake", base_url=BASE) - - -@respx.mock -async def test_transcribe_raises_on_non_json_body(): - respx.post(f"{BASE}/inference").mock( - return_value=httpx.Response(200, content=b"not json") - ) - with pytest.raises(WhisperUnavailableError): - await transcribe(b"RIFFfake", base_url=BASE) - - -@respx.mock -async def test_transcribe_raises_when_json_missing_text_key(): - respx.post(f"{BASE}/inference").mock( - return_value=httpx.Response(200, json={"oops": "no text field"}) - ) - with pytest.raises(WhisperUnavailableError): - await transcribe(b"RIFFfake", base_url=BASE) - - -@respx.mock -async def test_transcribe_uses_default_base_url_from_config(): - route = respx.post(f"{config.WHISPER_URL}/inference").mock( - return_value=httpx.Response(200, json={"text": "ok"}) - ) - assert await transcribe(b"RIFFfake") == "ok" - assert route.called - - -@respx.mock -async def test_transcribe_raises_when_text_is_null(): - respx.post(f"{BASE}/inference").mock( - return_value=httpx.Response(200, json={"text": None}) - ) - with pytest.raises(WhisperUnavailableError): - await transcribe(b"RIFFfake", base_url=BASE) - - -@respx.mock -async def test_transcribe_empty_string_is_not_an_error(): - respx.post(f"{BASE}/inference").mock( - return_value=httpx.Response(200, json={"text": ""}) - ) - assert await transcribe(b"RIFFfake", base_url=BASE) == "" diff --git a/uv.lock b/uv.lock index 3826e43..75eacab 100644 --- a/uv.lock +++ b/uv.lock @@ -1,285 +1,65 @@ version = 1 -revision = 3 +revision = 1 requires-python = ">=3.12" -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -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 = "anyio" -version = "4.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, -] - -[[package]] -name = "certifi" -version = "2026.6.17" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, -] - -[[package]] -name = "click" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, -] - [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "fastapi" -version = "0.139.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, -] - -[[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" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] [[package]] name = "hark" version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "fastapi" }, - { name = "httpx" }, - { name = "uvicorn" }, -] +source = { virtual = "." } [package.dev-dependencies] dev = [ { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "respx" }, ] [package.metadata] -requires-dist = [ - { name = "fastapi", specifier = ">=0.115" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "uvicorn", specifier = ">=0.32" }, -] [package.metadata.requires-dev] -dev = [ - { name = "pytest", specifier = ">=8.3" }, - { name = "pytest-asyncio", specifier = ">=0.24" }, - { name = "respx", specifier = ">=0.21" }, -] - -[[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.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, -] +dev = [{ name = "pytest", specifier = ">=8.3" }] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] [[package]] name = "packaging" version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "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" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } wheels = [ - { url = "https://files.pythonhosted.org/packages/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/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/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, ] [[package]] @@ -293,79 +73,7 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, -] - -[[package]] -name = "respx" -version = "0.23.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, -] - -[[package]] -name = "starlette" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] - -[[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 = "uvicorn" -version = "0.51.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 }, ]