From 41f41e8a252eaed8479df2e2d5d7461faba4e9c0 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 17:38:50 -0400 Subject: [PATCH 1/4] [server] Honour server.bind, refuse a wildcard, cap the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from running `hark serve` against the same whisper backend as the Python server it replaces. The transcription path is a faithful port — identical transcripts, identical status codes, identical `detail` strings, and the silence gate intact — so these are the gaps. server.bind DID NOTHING. NWListener(using: .tcp, on: port) listens on every interface regardless of config, and the log printed the requested host, so the claim was false rather than merely absent. Measured with bind = "127.0.0.1": log: hark listening on 127.0.0.1:8914 lsof: TCP *:8914 (LISTEN) laptop: 200 A single-machine install was reachable from the whole network while saying it was on loopback. Now binds via requiredLocalEndpoint, and the log reports the host it actually bound. Verified both ways: loopback is refused from another machine, and a tailnet bind still serves it. (NWListener takes requiredLocalEndpoint OR `on:`, not both — supplying both is EINVAL at creation, which is how the first attempt failed.) NO WILDCARD GUARD. The Python server has refused 0.0.0.0 since #6 — 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. That is a remote keystroke injector, not a data leak. Porting the server without the guard silently undid the fix; the vocabulary and the message now mirror src/hark/plists.py. NO BODY CAP. The body is parsed before routing, so before the key is checked: an unauthenticated request could make the server buffer whatever Content-Length it claimed. Capped at 16 MB — about 8 minutes of 16 kHz mono s16, far past any hold-to-talk utterance — and answered 413 rather than 400, since the request was understood and refused on size. Solves: hark #2 — reopens and re-fixes #6 for the Swift server Tests: 53 SwiftPM (5 new); verified live — wildcard refused, loopback unreachable from the laptop, tailnet bind still 200, 20 MB body 413, normal utterance unchanged --- swift/Sources/HarkCore/HarkServer.swift | 66 ++++++++++++++++++- .../Tests/HarkCoreTests/BindGuardTests.swift | 60 +++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 swift/Tests/HarkCoreTests/BindGuardTests.swift diff --git a/swift/Sources/HarkCore/HarkServer.swift b/swift/Sources/HarkCore/HarkServer.swift index f9b2d52..c706f9e 100644 --- a/swift/Sources/HarkCore/HarkServer.swift +++ b/swift/Sources/HarkCore/HarkServer.swift @@ -63,6 +63,10 @@ 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", "::", ""] + let config: HarkConfig let key: String let whisper: any WhisperTranscribing @@ -90,7 +94,40 @@ 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. + """) + } + + // BIND TO THE CONFIGURED HOST. `NWListener(using: .tcp, on: p)` listens + // 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 is what actually restricts it. + let params = NWParameters.tcp + params.requiredLocalEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: p) + params.allowLocalEndpointReuse = true + + // No `on:` — requiredLocalEndpoint already carries the port, and + // supplying both is EINVAL ("Invalid argument") at listener creation. + let listener = try NWListener(using: params) listener.newConnectionHandler = { [weak self] conn in self?.handle(conn) } @@ -98,7 +135,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)") + // Reports the host it was actually bound to, not the one requested. + logger.info("hark listening on \(host):\(portNumber)") return listener } @@ -143,6 +181,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 +362,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 +401,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") + } +} From 51da2fb72f03d1a8cc5d7f46619c51b4e9cb39e4 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 17:44:57 -0400 Subject: [PATCH 2/4] [server] Enforce server.bind per connection, not on the listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requiredLocalEndpoint restricts the listener, but more than a BSD bind() does: a connection from the server's OWN machine to its own tailnet address is delivered locally, 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 200. That breaks the single-machine-on-a-tailnet setup, which is the common one. So the address is enforced where the security question actually lives: refuse to SERVE a connection that did not arrive at the configured address. An attacker gets a handshake and nothing else. Loopback is always accepted — a client on this machine is the same trust boundary whether it dials 127.0.0.1 or the machine's own address. Verified with bind = 127.0.0.1: loopback 200, another machine on the tailnet 000 with "refused a connection" logged. KNOWN DIFFERENCE FROM THE PYTHON SERVER, and the reason this is not yet the default: with bind set to a tailnet address, the server's own machine still cannot reach it at that address — the connection fails below this code, at the socket layer, where Python's IPv4 bind succeeds and Network.framework's IPv6 wildcard does not. Use 127.0.0.1 in client.json on the machine running the server. That is the better configuration anyway: loopback needs no allowPlaintext and no ATS exception. Solves: hark #2 — the bind guard, without breaking local access Tests: 53 SwiftPM; live checks for both the allow and the refuse path --- swift/Sources/HarkCore/HarkServer.swift | 57 ++++++++++++++++++++----- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/swift/Sources/HarkCore/HarkServer.swift b/swift/Sources/HarkCore/HarkServer.swift index c706f9e..ab863ec 100644 --- a/swift/Sources/HarkCore/HarkServer.swift +++ b/swift/Sources/HarkCore/HarkServer.swift @@ -67,6 +67,9 @@ public final class HarkServer { /// 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 @@ -116,18 +119,25 @@ public final class HarkServer { """) } - // BIND TO THE CONFIGURED HOST. `NWListener(using: .tcp, on: p)` listens - // 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 is what actually restricts it. + // `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.requiredLocalEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: p) params.allowLocalEndpointReuse = true - - // No `on:` — requiredLocalEndpoint already carries the port, and - // supplying both is EINVAL ("Invalid argument") at listener creation. - let listener = try NWListener(using: params) + let listener = try NWListener(using: params, on: p) listener.newConnectionHandler = { [weak self] conn in self?.handle(conn) } @@ -135,7 +145,7 @@ public final class HarkServer { if case .failed(let e) = state { logger.error("listener failed: \(e)") } } listener.start(queue: .global(qos: .userInitiated)) - // Reports the host it was actually bound to, not the one requested. + boundHost = host logger.info("hark listening on \(host):\(portNumber)") return listener } @@ -152,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 } From 5104956294fa656009c8cf35edb4ffd4f8b3e001 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 17:52:47 -0400 Subject: [PATCH 3/4] [server] Install and run hark serve, not uvicorn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install-server.sh now builds swift/ into ~/Applications/Hark.app and points launchd at `hark serve`. One signed bundle, two roles — the same artifact install-client.sh installs, so a single-machine setup ends up with one copy playing both parts. The plists are rendered here in shell rather than by `python -m hark.plists`, so the server no longer needs a Python venv at all. The wildcard-bind check is not re-implemented: `hark serve` refuses 0.0.0.0 at startup with an explanation, which is the enforcement point that matters. Whisper's plist is rendered the same way and is otherwise unchanged — whisper.cpp is still the ASR engine. The shared secret is now created by the server on first start (KeyFile.ensure) instead of by shelling into the venv, keeping one implementation of how it is generated and persisted. Two things this run surfaced: The install smoke test piped the binary into grep. `hark` with no arguments prints usage and exits 2 — correct — and under `set -o pipefail` that fails the pipeline whatever grep says, so the check condemned a working binary. Captured into a variable instead. --doctor probed the CONFIGURED BIND address and reported the service as down while it was serving the other machine perfectly. It now probes loopback: the server accepts loopback by design, and on a tailnet bind the server's own machine cannot reach itself at that address. src/hark/ is deliberately still present. It is no longer what runs, and deleting it is a separate step once this has been in use for a few days. Verified: hark serve running under launchd, Studio 200 over loopback, laptop 200 over the tailnet, and a real utterance transcribed end to end. Solves: hark #2 — retiring the Python server, step 2 of 3 Tests: 123 pytest (installer doctor retargeted from venv to bundle), 53 SwiftPM, shellcheck --- install-server.sh | 174 ++++++++++++++++++++-------- tests/test_install_server_doctor.py | 54 ++++----- 2 files changed, 154 insertions(+), 74 deletions(-) diff --git a/install-server.sh b/install-server.sh index d2636bb..3810ea4 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() { @@ -282,25 +300,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 +340,73 @@ 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) + +BIND="$(config_value server bind 127.0.0.1)" +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})" # --- 6. Load the services ----------------------------------------------------- diff --git a/tests/test_install_server_doctor.py b/tests/test_install_server_doctor.py index 382d8e4..6a18789 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 "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 "cannot import hark" in out + assert "signature" in out def test_sourcing_the_script_installs_nothing(): From 57fb85072b17ae281e95d1eeb654dd62d8046b57 Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Mon, 3 Aug 2026 18:07:28 -0400 Subject: [PATCH 4/4] [server] Delete the Python server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hark serve` has been the running service since the previous commit, against the same whisper backend, serving both machines. src/hark/ is no longer what runs, so it goes. Removed: src/hark/ (app, config, sanitize, whisper, audio, plists), the six pytest modules that exercised it, launchd/*.plist.template, the FastAPI/uvicorn/httpx dependencies, and the wheel packaging. pyproject is now a test harness rather than a package: nothing imports hark, and the suite drives install-server.sh and install-client.sh as subprocesses, which is what a user actually runs. WHAT WAS PORTED FIRST, rather than dropped with it: test_launchd_config_sync.py was the drift guard — the plists are what launchd runs, and a wrong one shows up only as a restart loop and a spawn error in a log nobody is watching. Its invariants now apply to the shell renderer: both plists render, no placeholder survives, ports match config, whisper never leaves loopback, the plist points at the installed bundle rather than the build tree, no WorkingDirectory, and a wildcard bind is refused before anything is written. Rendering moved into render_plists() above the source guard so the tests can exercise it — the first attempt defined it below and every test got "command not found". TWO DELIBERATE DIFFERENCES, both recorded as tests: The server's plist now carries no address at all, because `hark serve` reads config.toml directly. uvicorn needed --host baked in, which is precisely the two-copies-of-one-fact the drift guard existed to police. `bind = ""` falls back to loopback instead of being refused. In Python that value went straight to a socket API where empty spells the wildcard; here it never reaches one, and defaulting to the safe end beats refusing. Verified before committing: the running service is untouched by the deletion and still answers 200. Solves: hark #2 — retiring the Python server, step 3 of 3 Tests: 66 pytest (27 for the server installer, drift guard included), 53 SwiftPM, shellcheck --- README.md | 28 +- install-server.sh | 124 ++++--- ...m.drycodeworks.hark-whisper.plist.template | 32 -- launchd/com.drycodeworks.hark.plist.template | 29 -- pyproject.toml | 20 +- src/hark/__init__.py | 0 src/hark/app.py | 148 -------- src/hark/audio.py | 68 ---- src/hark/config.py | 133 -------- src/hark/plists.py | 177 ---------- src/hark/sanitize.py | 24 -- src/hark/whisper.py | 38 --- tests/conftest.py | 20 +- tests/fixtures/hello.wav | Bin 24130 -> 0 bytes tests/fixtures/silence.wav | Bin 48044 -> 0 bytes tests/test_app.py | 226 ------------ tests/test_audio.py | 93 ----- tests/test_config.py | 58 ---- tests/test_install_server_doctor.py | 117 +++++++ tests/test_launchd_config_sync.py | 175 ---------- tests/test_sanitize.py | 55 --- tests/test_whisper.py | 88 ----- uv.lock | 322 +----------------- 23 files changed, 228 insertions(+), 1747 deletions(-) delete mode 100644 launchd/com.drycodeworks.hark-whisper.plist.template delete mode 100644 launchd/com.drycodeworks.hark.plist.template delete mode 100644 src/hark/__init__.py delete mode 100644 src/hark/app.py delete mode 100644 src/hark/audio.py delete mode 100644 src/hark/config.py delete mode 100644 src/hark/plists.py delete mode 100644 src/hark/sanitize.py delete mode 100644 src/hark/whisper.py delete mode 100644 tests/fixtures/hello.wav delete mode 100644 tests/fixtures/silence.wav delete mode 100644 tests/test_app.py delete mode 100644 tests/test_audio.py delete mode 100644 tests/test_config.py delete mode 100644 tests/test_launchd_config_sync.py delete mode 100644 tests/test_sanitize.py delete mode 100644 tests/test_whisper.py 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 3810ea4..43f1794 100755 --- a/install-server.sh +++ b/install-server.sh @@ -241,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 @@ -353,60 +422,7 @@ fi # 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" - -BIND="$(config_value server bind 127.0.0.1)" -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})" +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/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 710cb3c259d074a596dc61cb877a439c16ad68e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24130 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 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 6a18789..0a03e0c 100644 --- a/tests/test_install_server_doctor.py +++ b/tests/test_install_server_doctor.py @@ -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 }, ]