From ad6d7a27deed1a4d46b51618c315db18a581a56c Mon Sep 17 00:00:00 2001 From: adagradschool Date: Mon, 23 Mar 2026 15:52:14 -0400 Subject: [PATCH] Refactor trace hooks around Python runtime modules --- .github/workflows/ci.yml | 10 +- .gitignore | 3 + Makefile | 1 + README.md | 12 +- package-lock.json | 6 + plugins/trace/hooks/hook_utils.py | 38 -- plugins/trace/hooks/lib.sh | 526 +----------------- plugins/trace/hooks/post_tool_use.sh | 29 +- plugins/trace/hooks/py/__init__.py | 1 + plugins/trace/hooks/py/cli.py | 81 +++ plugins/trace/hooks/py/context.py | 63 +++ plugins/trace/hooks/py/handlers.py | 244 ++++++++ plugins/trace/hooks/py/otlp.py | 278 +++++++++ plugins/trace/hooks/py/settings.py | 33 ++ plugins/trace/hooks/py/state.py | 135 +++++ .../stop_parser.py} | 100 ++-- plugins/trace/hooks/py/traceparent.py | 31 ++ plugins/trace/hooks/session_end.sh | 24 +- plugins/trace/hooks/session_start.sh | 46 +- plugins/trace/hooks/stop_hook.sh | 109 +--- plugins/trace/hooks/user_prompt_submit.sh | 12 +- plugins/trace/setup.sh | 62 +-- pyproject.toml | 20 + scripts/replay-fixtures.sh | 11 +- scripts/validate-manifests.sh | 8 +- tests/test_handlers.py | 92 +++ tests/test_hook_utils.py | 98 ++++ tests/test_otlp.py | 64 +++ tests/test_state.py | 62 +++ tests/test_stop_hook_logic.py | 103 ++++ tests/test_traceparent.py | 37 ++ uv.lock | 175 ++++++ 32 files changed, 1645 insertions(+), 869 deletions(-) create mode 100644 package-lock.json delete mode 100644 plugins/trace/hooks/hook_utils.py mode change 100755 => 100644 plugins/trace/hooks/lib.sh create mode 100644 plugins/trace/hooks/py/__init__.py create mode 100644 plugins/trace/hooks/py/cli.py create mode 100644 plugins/trace/hooks/py/context.py create mode 100644 plugins/trace/hooks/py/handlers.py create mode 100644 plugins/trace/hooks/py/otlp.py create mode 100644 plugins/trace/hooks/py/settings.py create mode 100644 plugins/trace/hooks/py/state.py rename plugins/trace/hooks/{parse_stop_transcript.py => py/stop_parser.py} (83%) mode change 100755 => 100644 create mode 100644 plugins/trace/hooks/py/traceparent.py mode change 100755 => 100644 plugins/trace/setup.sh create mode 100644 pyproject.toml create mode 100644 tests/test_handlers.py create mode 100644 tests/test_hook_utils.py create mode 100644 tests/test_otlp.py create mode 100644 tests/test_state.py create mode 100644 tests/test_stop_hook_logic.py create mode 100644 tests/test_traceparent.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acc8423..7221829 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,6 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - name: Install jq (ubuntu) - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y jq - - name: Validate manifests - run: ./scripts/validate-manifests.sh - - name: Fixture replay - run: ./scripts/replay-fixtures.sh + - uses: astral-sh/setup-uv@v4 + - name: Run test suite + run: make test diff --git a/.gitignore b/.gitignore index e63be8b..f073d88 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ __pycache__/ *.pyc *.pyo +.pytest_cache/ +.venv/ +*.egg-info/ # local state .claude/ diff --git a/Makefile b/Makefile index 01a028b..5d360dc 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ lint: if command -v shfmt >/dev/null 2>&1; then shfmt -d plugins/trace/hooks/*.sh plugins/trace/setup.sh scripts/*.sh; else echo "shfmt not installed, skipping"; fi test: validate lint + uv run --extra dev pytest ./scripts/replay-fixtures.sh smoke: diff --git a/README.md b/README.md index 54f9d14..2117537 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The plugin hooks into Claude Code's lifecycle and emits [OTLP/HTTP JSON](docs/ot - Claude Code CLI installed - A PromptLayer account and API key ([dashboard.promptlayer.com](https://dashboard.promptlayer.com)) -- `jq`, `curl`, `uuidgen`, and `python3` available in your shell +- `python3` available in your shell - **macOS** or **Linux** (tested on Ubuntu) ## Configuration @@ -61,7 +61,7 @@ This plugin is **OpenTelemetry (OTLP/HTTP JSON)** compatible: - **Open standard** — traces follow the [OTLP specification](https://opentelemetry.io/docs/specs/otlp/), not a vendor-specific format - **Portable** — swap or fan-out to any OTLP-compatible backend (Datadog, Honeycomb, Grafana Tempo, etc.) by changing one endpoint URL -- **No SDK lock-in** — the plugin uses plain `curl` to send `ExportTraceServiceRequest` payloads; no proprietary client libraries required +- **No SDK lock-in** — the plugin sends `ExportTraceServiceRequest` payloads directly over OTLP/HTTP JSON using the Python standard library; no proprietary client libraries required ## Troubleshooting @@ -69,9 +69,15 @@ See [docs/troubleshooting.md](docs/troubleshooting.md). ## Local Development +Install Python dev tooling with: + +```bash +uv sync --extra dev +``` + ```bash make dev # Symlink repo as marketplace source, install plugin, run setup make uninstall # Remove local install and cleanup artifacts -make test # Validate manifests + lint + fixture replay +make test # Validate manifests + lint + pytest unit tests + fixture replay make smoke # E2E smoke test (requires ANTHROPIC_API_KEY) ``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..67f4a1b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "promptlayer-claude-plugins", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/plugins/trace/hooks/hook_utils.py b/plugins/trace/hooks/hook_utils.py deleted file mode 100644 index e5466eb..0000000 --- a/plugins/trace/hooks/hook_utils.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 - -import base64 -import binascii -import sys -import time - - -def hex_to_base64(hex_value: str) -> int: - raw = binascii.unhexlify(hex_value) - print(base64.b64encode(raw).decode("ascii")) - return 0 - - -def now_ns() -> int: - print(time.time_ns()) - return 0 - - -def main() -> int: - if len(sys.argv) < 2: - raise SystemExit("usage: hook_utils.py [args]") - - command = sys.argv[1] - if command == "hex_to_base64": - if len(sys.argv) != 3: - raise SystemExit("usage: hook_utils.py hex_to_base64 ") - return hex_to_base64(sys.argv[2]) - if command == "now_ns": - if len(sys.argv) != 2: - raise SystemExit("usage: hook_utils.py now_ns") - return now_ns() - - raise SystemExit(f"unknown command: {command}") - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/trace/hooks/lib.sh b/plugins/trace/hooks/lib.sh old mode 100755 new mode 100644 index 4dfb07b..9318847 --- a/plugins/trace/hooks/lib.sh +++ b/plugins/trace/hooks/lib.sh @@ -47,531 +47,13 @@ tracing_enabled() { } check_requirements() { - local cmd - for cmd in jq curl uuidgen python3; do - if ! command -v "$cmd" >/dev/null 2>&1; then - log "ERROR" "Missing required command: $cmd" - return 1 - fi - done - if [[ -z "$PL_API_KEY" ]]; then - log "ERROR" "PROMPTLAYER_API_KEY is not set" - return 1 - fi - return 0 -} - -generate_trace_id() { - uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]' -} - -generate_span_id() { - uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]' | cut -c1-16 -} - -parse_traceparent() { - local raw="${1:-}" - [[ -z "$raw" ]] && return 1 - - raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" - - if [[ ! "$raw" =~ ^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(-.+)?$ ]]; then - return 1 - fi - - local version="${BASH_REMATCH[1]}" - local trace_id="${BASH_REMATCH[2]}" - local parent_span_id="${BASH_REMATCH[3]}" - local trace_flags="${BASH_REMATCH[4]}" - local suffix="${BASH_REMATCH[5]:-}" - - if [[ "$version" == "ff" ]]; then - return 1 - fi - if [[ "$version" == "00" && -n "$suffix" ]]; then - return 1 - fi - if [[ "$trace_id" == "00000000000000000000000000000000" ]]; then - return 1 - fi - if [[ "$parent_span_id" == "0000000000000000" ]]; then - return 1 - fi - - printf '%s %s %s %s\n' "$version" "$trace_id" "$parent_span_id" "$trace_flags" -} - -load_initial_trace_context() { - PL_INITIAL_TRACEPARENT_VERSION="" - PL_INITIAL_TRACE_ID="" - PL_INITIAL_PARENT_SPAN_ID="" - PL_INITIAL_TRACE_FLAGS="" - PL_INITIAL_TRACE_CONTEXT_SOURCE="generated" - - local raw="${PROMPTLAYER_TRACEPARENT:-}" - if [[ -z "$raw" ]]; then - return 0 - fi - - local parsed - if ! parsed="$(parse_traceparent "$raw")"; then - log "WARN" "Ignoring invalid PROMPTLAYER_TRACEPARENT" + if ! command -v python3 >/dev/null 2>&1; then + log "ERROR" "Missing required command: python3" return 1 fi - - read -r PL_INITIAL_TRACEPARENT_VERSION PL_INITIAL_TRACE_ID PL_INITIAL_PARENT_SPAN_ID PL_INITIAL_TRACE_FLAGS <<<"$parsed" - PL_INITIAL_TRACE_CONTEXT_SOURCE="external_traceparent" - return 0 -} - -normalize_hex_id() { - local raw="$1" - local expected_len="$2" - local fallback="$3" - local label="$4" - - local clean - clean="$(echo "$raw" | tr -cd '[:xdigit:]' | tr '[:upper:]' '[:lower:]')" - if [[ -z "$clean" ]]; then - clean="$fallback" - fi - - if ((${#clean} > expected_len)); then - clean="${clean:0:expected_len}" - elif ((${#clean} < expected_len)); then - clean="$(printf "%-${expected_len}s" "$clean" | tr ' ' '0')" - fi - - if [[ "$clean" != "$raw" ]]; then - log "WARN" "Normalized $label from '$raw' to '$clean'" - fi - - echo "$clean" -} - -hex_to_base64() { - local hex="$1" - python3 "$PL_HOOKS_DIR/hook_utils.py" hex_to_base64 "$hex" -} - -now_ns() { - python3 "$PL_HOOKS_DIR/hook_utils.py" now_ns -} - -session_state_file() { - echo "$PL_SESSION_STATE_DIR/$1.json" -} - -acquire_session_lock() { - local sid="$1" - [[ -z "$sid" ]] && return 1 - - local lock_dir="$PL_LOCK_DIR/$sid.lock" - local attempts=0 - while ! mkdir "$lock_dir" 2>/dev/null; do - attempts=$((attempts + 1)) - if ((attempts >= 250)); then - log "ERROR" "Timed out waiting for session lock session_id=$sid" - return 1 - fi - sleep 0.02 - done - - export PL_HELD_SESSION_LOCK="$lock_dir" - return 0 -} - -release_session_lock() { - local lock_dir="${PL_HELD_SESSION_LOCK:-}" - if [[ -n "$lock_dir" ]]; then - rmdir "$lock_dir" 2>/dev/null || rm -rf "$lock_dir" - unset PL_HELD_SESSION_LOCK - fi - return 0 -} - -acquire_queue_lock() { - local lock_dir="$PL_LOCK_DIR/queue.lock" - local attempts=0 - while ! mkdir "$lock_dir" 2>/dev/null; do - attempts=$((attempts + 1)) - if ((attempts >= 250)); then - log "ERROR" "Timed out waiting for queue lock" - return 1 - fi - sleep 0.02 - done - - export PL_HELD_QUEUE_LOCK="$lock_dir" - return 0 -} - -release_queue_lock() { - local lock_dir="${PL_HELD_QUEUE_LOCK:-}" - if [[ -n "$lock_dir" ]]; then - rmdir "$lock_dir" 2>/dev/null || rm -rf "$lock_dir" - unset PL_HELD_QUEUE_LOCK - fi - return 0 -} - -get_session_state() { - local sid="$1" - local key="$2" - local f - f="$(session_state_file "$sid")" - if [[ -f "$f" ]]; then - jq -r ".${key} // empty" "$f" 2>/dev/null || true - fi -} - -set_session_state() { - local sid="$1" - local key="$2" - local val="$3" - local f - f="$(session_state_file "$sid")" - local current - current='{}' - if [[ -f "$f" ]]; then - current="$(cat "$f")" - fi - echo "$current" | jq --arg k "$key" --arg v "$val" '.[$k] = $v' >"$f" -} - -ensure_session_initialized() { - local sid="$1" - local requested_start_ns="${2:-}" - [[ -z "$sid" ]] && return 1 - - local trace_id session_span_id session_parent_span_id session_start_ns init_source pending_tool_calls - trace_id="$(get_session_state "$sid" trace_id)" - session_span_id="$(get_session_state "$sid" session_span_id)" - session_parent_span_id="$(get_session_state "$sid" session_parent_span_id)" - session_start_ns="$(get_session_state "$sid" session_start_ns)" - init_source="$(get_session_state "$sid" session_init_source)" - pending_tool_calls="$(get_session_state "$sid" pending_tool_calls)" - - # Normal path: SessionStart already created state. - if [[ -n "$trace_id" && -n "$session_span_id" ]]; then - if [[ -z "$session_start_ns" ]]; then - [[ -z "$requested_start_ns" ]] && requested_start_ns="$(now_ns)" - set_session_state "$sid" session_start_ns "$requested_start_ns" - fi - if [[ -z "$init_source" ]]; then - set_session_state "$sid" session_init_source "unknown" - fi - if [[ -z "$pending_tool_calls" ]]; then - set_session_state "$sid" pending_tool_calls "[]" - fi - if [[ -z "$session_parent_span_id" ]]; then - set_session_state "$sid" session_parent_span_id "" - fi - if [[ -z "$(get_session_state "$sid" session_traceparent_version)" ]]; then - set_session_state "$sid" session_traceparent_version "" - fi - if [[ -z "$(get_session_state "$sid" session_trace_flags)" ]]; then - set_session_state "$sid" session_trace_flags "" - fi - if [[ -z "$(get_session_state "$sid" trace_context_source)" ]]; then - set_session_state "$sid" trace_context_source "generated" - fi - return 0 - fi - - # Fallback path for SDK environments that do not surface SessionStart. - [[ -z "$requested_start_ns" ]] && requested_start_ns="$(now_ns)" - load_initial_trace_context || true - [[ -z "$trace_id" ]] && trace_id="${PL_INITIAL_TRACE_ID:-}" - [[ -z "$trace_id" ]] && trace_id="$(generate_trace_id)" - [[ -z "$session_span_id" ]] && session_span_id="$(generate_span_id)" - - set_session_state "$sid" trace_id "$trace_id" - set_session_state "$sid" session_span_id "$session_span_id" - set_session_state "$sid" session_parent_span_id "${PL_INITIAL_PARENT_SPAN_ID:-}" - set_session_state "$sid" session_start_ns "$requested_start_ns" - set_session_state "$sid" current_turn_start_ns "" - set_session_state "$sid" pending_tool_calls "[]" - set_session_state "$sid" session_init_source "lazy_init" - set_session_state "$sid" session_traceparent_version "${PL_INITIAL_TRACEPARENT_VERSION:-}" - set_session_state "$sid" session_trace_flags "${PL_INITIAL_TRACE_FLAGS:-}" - set_session_state "$sid" trace_context_source "${PL_INITIAL_TRACE_CONTEXT_SOURCE:-generated}" - - log "INFO" "Session initialized lazily session_id=$sid trace_id=$trace_id" -} - -post_otlp_payload_file() { - local payload_file="$1" - local status response_file - response_file="$(mktemp "${TMPDIR:-/tmp}/pl-otlp-response.XXXXXX")" - status="$(curl -sS -o "$response_file" -w "%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - -H "X-Api-Key: $PL_API_KEY" \ - -H "User-Agent: $PL_USER_AGENT" \ - --connect-timeout "$PL_OTLP_CONNECT_TIMEOUT" \ - --max-time "$PL_OTLP_MAX_TIME" \ - "$PL_OTLP_ENDPOINT" \ - -d @"$payload_file" || true)" - - if [[ "$status" != "200" ]]; then - log "ERROR" "Failed OTLP export status=$status" - rm -f "$response_file" + if [[ -z "$PL_API_KEY" ]]; then + log "ERROR" "PROMPTLAYER_API_KEY is not set" return 1 fi - - # OTLP JSON can return 200 with partialSuccess and rejected spans. - # Treat this as non-retryable (same payload is likely to be rejected again). - if command -v jq >/dev/null 2>&1; then - local rejected - rejected="$(jq -r '.partialSuccess.rejectedSpans // 0' "$response_file" 2>/dev/null || echo 0)" - if [[ "$rejected" != "0" ]]; then - local message - message="$(jq -r '.partialSuccess.errorMessage // "Unknown rejection"' "$response_file" 2>/dev/null || true)" - log "ERROR" "OTLP partial success: rejected_spans=$rejected message=$message" - rm -f "$response_file" - return 2 - fi - fi - - rm -f "$response_file" - return 0 -} - -append_to_otlp_queue_file() { - local payload_file="$1" - acquire_queue_lock || return 1 - # Compact to single line to preserve ndjson format - jq -c '.' "$payload_file" >>"$PL_QUEUE_FILE" - chmod 600 "$PL_QUEUE_FILE" 2>/dev/null || true - release_queue_lock return 0 } - -drain_otlp_queue() { - if [[ ! -s "$PL_QUEUE_FILE" ]]; then - return 0 - fi - - local drain_limit="$PL_QUEUE_DRAIN_LIMIT" - if [[ ! "$drain_limit" =~ ^[0-9]+$ ]]; then - drain_limit=10 - fi - if ((drain_limit <= 0)); then - return 0 - fi - - acquire_queue_lock || return 0 - - local -a queue_payloads - mapfile -t queue_payloads <"$PL_QUEUE_FILE" || true - - local total max_attempts i rc replayed dropped retryable_fail fail_index - total="${#queue_payloads[@]}" - if ((total == 0)); then - release_queue_lock - return 0 - fi - - max_attempts=$((total < drain_limit ? total : drain_limit)) - replayed=0 - dropped=0 - retryable_fail=0 - fail_index=-1 - - local queued_tmp - queued_tmp="$(mktemp "${TMPDIR:-/tmp}/pl-otlp-queued.XXXXXX")" - - for ((i = 0; i < max_attempts; i++)); do - if [[ -z "${queue_payloads[$i]}" ]]; then - continue - fi - - printf '%s' "${queue_payloads[$i]}" >"$queued_tmp" - - if post_otlp_payload_file "$queued_tmp"; then - replayed=$((replayed + 1)) - continue - else - rc=$? - fi - - if [[ "$rc" == "2" ]]; then - dropped=$((dropped + 1)) - continue - fi - - retryable_fail=1 - fail_index="$i" - break - done - - rm -f "$queued_tmp" - - local tmp remaining_start - tmp="$(mktemp "${TMPDIR:-/tmp}/pl-otlp-queue.XXXXXX")" - if ((retryable_fail == 1)); then - remaining_start="$fail_index" - else - remaining_start="$max_attempts" - fi - - for ((i = remaining_start; i < total; i++)); do - printf '%s\n' "${queue_payloads[$i]}" >>"$tmp" - done - - mv "$tmp" "$PL_QUEUE_FILE" - chmod 600 "$PL_QUEUE_FILE" 2>/dev/null || true - release_queue_lock - - if ((replayed > 0)); then - log "INFO" "Drained queued OTLP payloads count=$replayed" - fi - if ((dropped > 0)); then - log "WARN" "Dropped non-retryable queued OTLP payloads count=$dropped" - fi - - return 0 -} - -send_otlp_payload_file() { - local payload_file="$1" - local rc - - drain_otlp_queue || true - - if post_otlp_payload_file "$payload_file"; then - return 0 - else - rc=$? - fi - - if [[ "$rc" == "1" ]]; then - append_to_otlp_queue_file "$payload_file" || log "ERROR" "Failed to append OTLP payload to queue" - fi - return 1 -} - -kind_int_to_string() { - case "$1" in - 0) echo "SPAN_KIND_UNSPECIFIED" ;; - 1) echo "SPAN_KIND_INTERNAL" ;; - 2) echo "SPAN_KIND_SERVER" ;; - 3) echo "SPAN_KIND_CLIENT" ;; - 4) echo "SPAN_KIND_PRODUCER" ;; - 5) echo "SPAN_KIND_CONSUMER" ;; - *) echo "SPAN_KIND_UNSPECIFIED" ;; - esac -} - -build_span_json() { - local trace_id="$1" - local span_id="$2" - local parent_span_id="$3" - local name="$4" - local kind="$5" - local start_ns="$6" - local end_ns="$7" - local attrs_json="$8" - - # Convert integer kind to protobuf JSON enum string if needed - if [[ "$kind" =~ ^[0-9]+$ ]]; then - kind="$(kind_int_to_string "$kind")" - fi - - trace_id="$(normalize_hex_id "$trace_id" 32 "$(generate_trace_id)" "trace_id")" - span_id="$(normalize_hex_id "$span_id" 16 "$(generate_span_id)" "span_id")" - if [[ -n "$parent_span_id" ]]; then - parent_span_id="$(normalize_hex_id "$parent_span_id" 16 "$(generate_span_id)" "parent_span_id")" - fi - - local trace_id_b64 span_id_b64 parent_span_id_b64 - trace_id_b64="$(hex_to_base64 "$trace_id")" - span_id_b64="$(hex_to_base64 "$span_id")" - parent_span_id_b64="" - if [[ -n "$parent_span_id" ]]; then - parent_span_id_b64="$(hex_to_base64 "$parent_span_id")" - fi - - local span_json - span_json="$(jq -cn \ - --arg trace_id "$trace_id_b64" \ - --arg span_id "$span_id_b64" \ - --arg parent_span_id "$parent_span_id_b64" \ - --arg name "$name" \ - --arg kind "$kind" \ - --arg start "$start_ns" \ - --arg end_time "$end_ns" \ - --argjson attributes "$attrs_json" \ - '{ - traceId: $trace_id, - spanId: $span_id, - parentSpanId: (if $parent_span_id == "" then null else $parent_span_id end), - name: $name, - kind: $kind, - startTimeUnixNano: $start, - endTimeUnixNano: $end_time, - attributes: ( - $attributes - | to_entries - | map(select(.value != null)) - | map( - . as $kv - | { - key: $kv.key, - value: ( - if ($kv.value | type) == "string" then - {stringValue: $kv.value} - elif ($kv.value | type) == "boolean" then - {boolValue: $kv.value} - elif ($kv.value | type) == "number" then - if ($kv.value | floor) == $kv.value then - {intValue: ($kv.value | tostring)} - else - {doubleValue: $kv.value} - end - else - {stringValue: ($kv.value | tojson)} - end - ) - } - ) - ) - }')" - - echo "$span_json" -} - -emit_spans_batch_file() { - local spans_file="$1" - if [[ ! -s "$spans_file" ]]; then - return 0 - fi - - local payload_file - payload_file="$(mktemp "${TMPDIR:-/tmp}/pl-otlp-batch.XXXXXX")" - jq -cs '{resourceSpans:[{resource:{attributes:[{key:"service.name",value:{stringValue:"claude-code"}}]},scopeSpans:[{spans:.}]}]}' "$spans_file" >"$payload_file" - send_otlp_payload_file "$payload_file" - local rc=$? - rm -f "$payload_file" - return $rc -} - -emit_span() { - local trace_id="$1" - local span_id="$2" - local parent_span_id="$3" - local name="$4" - local kind="$5" - local start_ns="$6" - local end_ns="$7" - local attrs_json="$8" - - local span_json spans_file - span_json="$(build_span_json "$trace_id" "$span_id" "$parent_span_id" "$name" "$kind" "$start_ns" "$end_ns" "$attrs_json")" || return 1 - spans_file="$(mktemp "${TMPDIR:-/tmp}/pl-otlp-span.XXXXXX")" - printf '%s\n' "$span_json" >"$spans_file" - emit_spans_batch_file "$spans_file" - local rc=$? - rm -f "$spans_file" - return $rc -} diff --git a/plugins/trace/hooks/post_tool_use.sh b/plugins/trace/hooks/post_tool_use.sh index 876bad1..8de5302 100755 --- a/plugins/trace/hooks/post_tool_use.sh +++ b/plugins/trace/hooks/post_tool_use.sh @@ -9,33 +9,8 @@ tracing_enabled || exit 0 check_requirements || exit 0 input="$(cat)" -session_id="$(echo "$input" | jq -r '.session_id // empty')" -tool_name="$(echo "$input" | jq -r '.tool_name // empty')" -tool_input="$(echo "$input" | jq -c '.tool_input // {}')" -tool_output="$(echo "$input" | jq -c '.tool_response // .output // {}')" +result="$(printf '%s' "$input" | python3 "$SCRIPT_DIR/py/cli.py" post-tool-use)" +IFS=$'\t' read -r session_id tool_name <<<"$result" [[ -z "$session_id" || -z "$tool_name" ]] && exit 0 -ensure_session_initialized "$session_id" - -trace_id="$(get_session_state "$session_id" trace_id)" -[[ -z "$trace_id" ]] && exit 0 -turn_start_ns="$(get_session_state "$session_id" current_turn_start_ns)" -if [[ -z "$turn_start_ns" ]]; then - set_session_state "$session_id" current_turn_start_ns "$(now_ns)" -fi - -attrs="$(jq -nc \ - --arg source claude-code \ - --arg hook PostToolUse \ - --arg tool_name "$tool_name" \ - --argjson function_input "$tool_input" \ - --argjson function_output "$tool_output" \ - '{source:$source,hook:$hook,tool_name:$tool_name,node_type:"CODE_EXECUTION",function_input:$function_input,function_output:$function_output}')" - -pending_tool_calls="$(get_session_state "$session_id" pending_tool_calls)" -[[ -z "$pending_tool_calls" ]] && pending_tool_calls='[]' - -pending_tool_calls="$(echo "$pending_tool_calls" | jq -c --argjson attrs "$attrs" '. + [$attrs]')" -set_session_state "$session_id" pending_tool_calls "$pending_tool_calls" - log "INFO" "PostToolUse captured session_id=$session_id tool=$tool_name" diff --git a/plugins/trace/hooks/py/__init__.py b/plugins/trace/hooks/py/__init__.py new file mode 100644 index 0000000..4068576 --- /dev/null +++ b/plugins/trace/hooks/py/__init__.py @@ -0,0 +1 @@ +# Python runtime package for trace plugin hooks. diff --git a/plugins/trace/hooks/py/cli.py b/plugins/trace/hooks/py/cli.py new file mode 100644 index 0000000..cb42d87 --- /dev/null +++ b/plugins/trace/hooks/py/cli.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 + +import os +import pathlib +import sys + + +THIS_DIR = pathlib.Path(__file__).resolve().parent +if str(THIS_DIR) not in sys.path: + sys.path.insert(0, str(THIS_DIR)) + +from context import load_context +from handlers import ( + handle_parse_stop_transcript, + handle_post_tool_use, + handle_session_end, + handle_session_start, + handle_stop_hook, + handle_user_prompt_submit, +) +from otlp import generate_session_id, generate_span_id, generate_trace_id, probe_endpoint +from settings import write_settings_env + + +def read_stdin() -> str: + return sys.stdin.read() + + +def main() -> int: + if len(sys.argv) < 2: + raise SystemExit("usage: cli.py [args]") + + command = sys.argv[1] + if command in {"session-start", "user-prompt-submit", "post-tool-use", "session-end", "stop-hook"}: + ctx = load_context() + raw_input = read_stdin() + if command == "session-start": + output = handle_session_start(ctx, raw_input) + elif command == "user-prompt-submit": + output = handle_user_prompt_submit(ctx, raw_input) + elif command == "post-tool-use": + output = handle_post_tool_use(ctx, raw_input) + elif command == "session-end": + output = handle_session_end(ctx, raw_input) + else: + output = handle_stop_hook(ctx, raw_input) + if output: + print(output) + return 0 + + if command == "generate-trace-id": + print(generate_trace_id()) + return 0 + if command == "generate-span-id": + print(generate_span_id()) + return 0 + if command == "generate-session-id": + print(generate_session_id()) + return 0 + if command == "write-settings-env": + if len(sys.argv) != 6: + raise SystemExit("usage: cli.py write-settings-env ") + print(write_settings_env(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5])) + return 0 + if command == "probe-endpoint": + if len(sys.argv) != 4: + raise SystemExit("usage: cli.py probe-endpoint ") + print(probe_endpoint(sys.argv[2], sys.argv[3])) + return 0 + if command == "parse-stop-transcript": + if len(sys.argv) not in {4, 5}: + raise SystemExit("usage: cli.py parse-stop-transcript [session_id]") + expected_session_id = sys.argv[4] if len(sys.argv) == 5 else None + print(handle_parse_stop_transcript(sys.argv[2], sys.argv[3], expected_session_id)) + return 0 + + raise SystemExit(f"unknown command: {command}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/trace/hooks/py/context.py b/plugins/trace/hooks/py/context.py new file mode 100644 index 0000000..58528d5 --- /dev/null +++ b/plugins/trace/hooks/py/context.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass +import os +import subprocess + + +PLUGIN_VERSION = "1.0.0" + + +def env_int(name: str, default: int) -> int: + try: + return int(os.environ.get(name, str(default))) + except Exception: + return default + + +def detect_claude_version() -> str: + try: + result = subprocess.run( + ["claude", "--version"], + check=False, + capture_output=True, + text=True, + ) + except Exception: + return "unknown" + return result.stdout.strip() or "unknown" + + +@dataclass(frozen=True) +class HookContext: + log_file: str + queue_file: str + session_state_dir: str + lock_dir: str + debug: str + api_key: str + otlp_endpoint: str + queue_drain_limit: int + otlp_connect_timeout: int + otlp_max_time: int + plugin_version: str + cc_version: str + user_agent: str + + +def load_context() -> HookContext: + cc_version = detect_claude_version() + plugin_version = PLUGIN_VERSION + return HookContext( + log_file=os.path.expanduser("~/.claude/state/promptlayer_hook.log"), + queue_file=os.path.expanduser("~/.claude/state/promptlayer_otlp_queue.ndjson"), + session_state_dir=os.path.expanduser("~/.claude/state/promptlayer_sessions"), + lock_dir=os.path.expanduser("~/.claude/state/promptlayer_locks"), + debug=os.environ.get("PROMPTLAYER_CC_DEBUG", "false"), + api_key=os.environ.get("PROMPTLAYER_API_KEY", ""), + otlp_endpoint=os.environ.get("PROMPTLAYER_OTLP_ENDPOINT", "https://api.promptlayer.com/v1/traces"), + queue_drain_limit=env_int("PROMPTLAYER_QUEUE_DRAIN_LIMIT", 10), + otlp_connect_timeout=env_int("PROMPTLAYER_OTLP_CONNECT_TIMEOUT", 5), + otlp_max_time=env_int("PROMPTLAYER_OTLP_MAX_TIME", 12), + plugin_version=plugin_version, + cc_version=cc_version, + user_agent=f"promptlayer-claude-plugin/{plugin_version} claude-code/{cc_version}", + ) diff --git a/plugins/trace/hooks/py/handlers.py b/plugins/trace/hooks/py/handlers.py new file mode 100644 index 0000000..e4e2040 --- /dev/null +++ b/plugins/trace/hooks/py/handlers.py @@ -0,0 +1,244 @@ +import json +import os +import time +from typing import Optional + +from otlp import ( + SpanSpec, + build_payload, + build_span, + generate_session_id, + generate_span_id, + generate_trace_id, + send_payload_with_queueing, +) +from state import ( + acquire_lock, + ensure_session_initialized, + load_session_state, + parse_pending_tool_calls, + release_lock, + save_session_state, + session_lock_path, +) +from stop_parser import build_stop_hook_span_specs, parse_transcript +from traceparent import parse_traceparent + + +def read_stdin_json(raw: str): + if not raw.strip(): + return {} + try: + data = json.loads(raw) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def handle_session_start(ctx, raw_input: str) -> str: + input_data = read_stdin_json(raw_input) + session_id = input_data.get("session_id") + session_id = str(session_id) if session_id else generate_session_id() + + state, path = load_session_state(ctx.session_state_dir, session_id) + existing = bool(state.trace_id and state.session_span_id) + if existing: + state, _ = ensure_session_initialized( + state, + traceparent_raw=os.environ.get("PROMPTLAYER_TRACEPARENT", ""), + generate_trace_id=generate_trace_id, + generate_span_id=generate_span_id, + ) + status = "existing" + else: + trace_context = parse_traceparent(os.environ.get("PROMPTLAYER_TRACEPARENT", "")) + state.trace_id = trace_context["trace_id"] if trace_context else generate_trace_id() + state.session_span_id = generate_span_id() + state.session_parent_span_id = trace_context["parent_span_id"] if trace_context else "" + state.session_start_ns = str(time.time_ns()) + state.current_turn_start_ns = "" + state.pending_tool_calls = "[]" + state.session_init_source = "session_start_hook" + state.session_traceparent_version = trace_context["version"] if trace_context else "" + state.session_trace_flags = trace_context["trace_flags"] if trace_context else "" + state.trace_context_source = trace_context["source"] if trace_context else "generated" + status = "captured" + + save_session_state(path, state) + return f"{session_id}\t{state.trace_id}\t{status}" + + +def handle_user_prompt_submit(ctx, raw_input: str) -> str: + input_data = read_stdin_json(raw_input) + session_id = input_data.get("session_id") + if not session_id: + return "" + + state, path = load_session_state(ctx.session_state_dir, str(session_id)) + state, _ = ensure_session_initialized( + state, + traceparent_raw=os.environ.get("PROMPTLAYER_TRACEPARENT", ""), + generate_trace_id=generate_trace_id, + generate_span_id=generate_span_id, + ) + if not state.trace_id or not state.session_span_id: + return "" + + state.current_turn_start_ns = str(time.time_ns()) + state.pending_tool_calls = "[]" + save_session_state(path, state) + return str(session_id) + + +def handle_post_tool_use(ctx, raw_input: str) -> str: + input_data = read_stdin_json(raw_input) + session_id = input_data.get("session_id") + tool_name = input_data.get("tool_name") + if not session_id or not tool_name: + return "" + + tool_input = input_data.get("tool_input", {}) + tool_output = input_data.get("tool_response", input_data.get("output", {})) + + state, path = load_session_state(ctx.session_state_dir, str(session_id)) + state, _ = ensure_session_initialized( + state, + traceparent_raw=os.environ.get("PROMPTLAYER_TRACEPARENT", ""), + generate_trace_id=generate_trace_id, + generate_span_id=generate_span_id, + ) + if not state.trace_id: + return "" + if not state.current_turn_start_ns: + state.current_turn_start_ns = str(time.time_ns()) + + pending_tool_calls = parse_pending_tool_calls(state.pending_tool_calls) + pending_tool_calls.append( + { + "source": "claude-code", + "hook": "PostToolUse", + "tool_name": str(tool_name), + "node_type": "CODE_EXECUTION", + "function_input": tool_input, + "function_output": tool_output, + } + ) + state.pending_tool_calls = json.dumps(pending_tool_calls, ensure_ascii=False, separators=(",", ":")) + save_session_state(path, state) + return f"{session_id}\t{tool_name}" + + +def handle_session_end(ctx, raw_input: str) -> str: + input_data = read_stdin_json(raw_input) + session_id = input_data.get("session_id") + if not session_id: + return "" + + lock_path = session_lock_path(ctx.lock_dir, str(session_id)) + if not acquire_lock(lock_path): + return "" + + try: + state, path = load_session_state(ctx.session_state_dir, str(session_id)) + if not state.trace_id or not state.session_span_id: + return "" + + spec = build_span( + SpanSpec( + trace_id=state.trace_id, + span_id=state.session_span_id, + parent_span_id=state.session_parent_span_id, + name="Claude Code session", + kind="1", + start_ns=state.session_start_ns or str(time.time_ns()), + end_ns=str(time.time_ns()), + attrs={ + "source": "claude-code", + "hook": "SessionEnd", + "node_type": "WORKFLOW", + "session.lifecycle": "complete", + }, + ) + ) + send_payload_with_queueing(ctx, build_payload([spec])) + try: + os.remove(path) + except FileNotFoundError: + pass + return str(session_id) + finally: + release_lock(lock_path) + + +def resolve_stop_session_id(input_data): + session_id = input_data.get("session_id") + transcript_path = input_data.get("transcript_path") + if not session_id and transcript_path: + session_id = os.path.basename(str(transcript_path)) + if session_id.endswith(".jsonl"): + session_id = session_id[: -len(".jsonl")] + return session_id + + +def handle_stop_hook(ctx, raw_input: str) -> str: + input_data = read_stdin_json(raw_input) + session_id = resolve_stop_session_id(input_data) + transcript_path = input_data.get("transcript_path") + if not session_id: + return "" + + lock_path = session_lock_path(ctx.lock_dir, str(session_id)) + if not acquire_lock(lock_path): + return "" + + try: + state, path = load_session_state(ctx.session_state_dir, str(session_id)) + state, _ = ensure_session_initialized( + state, + traceparent_raw=os.environ.get("PROMPTLAYER_TRACEPARENT", ""), + generate_trace_id=generate_trace_id, + generate_span_id=generate_span_id, + ) + if not state.trace_id or not state.session_span_id: + return "" + + turn_start_ns = state.current_turn_start_ns or str(time.time_ns()) + pending_tool_calls = state.pending_tool_calls or "[]" + state.current_turn_start_ns = "" + state.pending_tool_calls = "[]" + save_session_state(path, state) + finally: + release_lock(lock_path) + + if not transcript_path or not os.path.exists(str(transcript_path)): + return f"{session_id}\tmissing_transcript" + + pending_payloads = parse_pending_tool_calls(pending_tool_calls) + attempts = 0 + while True: + parsed = parse_transcript(str(transcript_path), int(turn_start_ns), pending_payloads, str(session_id)) + if parsed.get("llms") or attempts >= 10: + break + attempts += 1 + time.sleep(0.2) + + span_specs = build_stop_hook_span_specs( + parsed=parsed, + trace_id=state.trace_id, + session_span_id=state.session_span_id, + session_parent_span_id=state.session_parent_span_id, + session_start_ns=state.session_start_ns or str(time.time_ns()), + session_init_source=state.session_init_source, + generate_span_id=generate_span_id, + ) + spans = [build_span(span_spec) for span_spec in span_specs] + if spans: + send_payload_with_queueing(ctx, build_payload(spans)) + return f"{session_id}\tok" + + +def handle_parse_stop_transcript(transcript_path: str, turn_start_ns: str, expected_session_id: Optional[str]) -> str: + pending_raw = os.environ.get("PL_PENDING_TOOL_CALLS", "[]") + pending_payloads = parse_pending_tool_calls(pending_raw) + parsed = parse_transcript(transcript_path, int(turn_start_ns) or None, pending_payloads, expected_session_id) + return json.dumps(parsed, ensure_ascii=False, separators=(",", ":")) diff --git a/plugins/trace/hooks/py/otlp.py b/plugins/trace/hooks/py/otlp.py new file mode 100644 index 0000000..ca7c58e --- /dev/null +++ b/plugins/trace/hooks/py/otlp.py @@ -0,0 +1,278 @@ +from dataclasses import dataclass +import base64 +import binascii +import json +import os +import secrets +import uuid +from urllib import error, request + +from state import acquire_lock, queue_lock_path, release_lock + + +@dataclass +class SpanSpec: + trace_id: str + span_id: str + parent_span_id: str + name: str + kind: str + start_ns: str + end_ns: str + attrs: dict + + +def compact_json(value) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def generate_trace_id() -> str: + return secrets.token_hex(16) + + +def generate_span_id() -> str: + return secrets.token_hex(8) + + +def generate_session_id() -> str: + return str(uuid.uuid4()) + + +def normalize_hex_id(raw: str, expected_len: int, fallback: str) -> str: + clean = "".join(ch for ch in str(raw).lower() if ch in "0123456789abcdef") + if not clean: + clean = fallback + if len(clean) > expected_len: + clean = clean[:expected_len] + if len(clean) < expected_len: + clean = clean.ljust(expected_len, "0") + return clean + + +def hex_to_base64(hex_value: str) -> str: + raw = binascii.unhexlify(hex_value) + return base64.b64encode(raw).decode("ascii") + + +def kind_int_to_string(kind) -> str: + value = str(kind) + return { + "0": "SPAN_KIND_UNSPECIFIED", + "1": "SPAN_KIND_INTERNAL", + "2": "SPAN_KIND_SERVER", + "3": "SPAN_KIND_CLIENT", + "4": "SPAN_KIND_PRODUCER", + "5": "SPAN_KIND_CONSUMER", + }.get(value, "SPAN_KIND_UNSPECIFIED") + + +def otlp_attribute_value(value): + if isinstance(value, str): + return {"stringValue": value} + if isinstance(value, bool): + return {"boolValue": value} + if isinstance(value, int): + return {"intValue": str(value)} + if isinstance(value, float): + if value.is_integer(): + return {"intValue": str(int(value))} + return {"doubleValue": value} + return {"stringValue": compact_json(value)} + + +def build_span(spec: SpanSpec): + trace_id = normalize_hex_id(spec.trace_id, 32, generate_trace_id()) + span_id = normalize_hex_id(spec.span_id, 16, generate_span_id()) + parent_span = "" + if spec.parent_span_id: + parent_span = normalize_hex_id(spec.parent_span_id, 16, generate_span_id()) + + attributes = [] + for key, value in (spec.attrs or {}).items(): + if value is None: + continue + attributes.append({"key": key, "value": otlp_attribute_value(value)}) + + span = { + "traceId": hex_to_base64(trace_id), + "spanId": hex_to_base64(span_id), + "name": spec.name, + "kind": kind_int_to_string(spec.kind), + "startTimeUnixNano": str(spec.start_ns), + "endTimeUnixNano": str(spec.end_ns), + "attributes": attributes, + } + if parent_span: + span["parentSpanId"] = hex_to_base64(parent_span) + return span + + +def build_payload(spans): + return { + "resourceSpans": [ + { + "resource": { + "attributes": [ + {"key": "service.name", "value": {"stringValue": "claude-code"}} + ] + }, + "scopeSpans": [{"spans": spans}], + } + ] + } + + +def http_post_json(endpoint: str, payload, api_key: str = "", user_agent: str = "", timeout: int = 12): + body = compact_json(payload).encode("utf-8") + headers = {"Content-Type": "application/json"} + if api_key: + headers["X-Api-Key"] = api_key + if user_agent: + headers["User-Agent"] = user_agent + + req = request.Request(endpoint, data=body, headers=headers, method="POST") + try: + with request.urlopen(req, timeout=max(timeout, 1)) as response: + return response.getcode(), response.read().decode("utf-8") + except error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8", errors="replace") + except Exception: + return 0, "" + + +def parse_partial_success(response_text: str): + if not response_text: + return 0, "" + try: + parsed = json.loads(response_text) + except Exception: + return 0, "" + partial = parsed.get("partialSuccess", {}) + if not isinstance(partial, dict): + return 0, "" + rejected = partial.get("rejectedSpans", 0) + try: + rejected_int = int(rejected) + except Exception: + rejected_int = 0 + message = partial.get("errorMessage", "") + return rejected_int, str(message) if message else "" + + +def post_otlp_payload(ctx, payload): + return http_post_json( + ctx.otlp_endpoint, + payload, + api_key=ctx.api_key, + user_agent=ctx.user_agent, + timeout=ctx.otlp_max_time, + ) + + +def append_queue_payload(ctx, payload): + if not ctx.queue_file or not ctx.lock_dir: + return False + + os.makedirs(os.path.dirname(ctx.queue_file), exist_ok=True) + lock_path = queue_lock_path(ctx.lock_dir) + if not acquire_lock(lock_path): + return False + try: + with open(ctx.queue_file, "a", encoding="utf-8") as f: + f.write(compact_json(payload)) + f.write("\n") + try: + os.chmod(ctx.queue_file, 0o600) + except Exception: + pass + return True + finally: + release_lock(lock_path) + + +def read_queue_payloads(queue_file: str): + if not os.path.exists(queue_file) or os.path.getsize(queue_file) == 0: + return [] + + payloads = [] + with open(queue_file, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + payloads.append(json.loads(line)) + except Exception: + continue + return payloads + + +def write_queue_payloads(queue_file: str, payloads) -> None: + os.makedirs(os.path.dirname(queue_file), exist_ok=True) + with open(queue_file, "w", encoding="utf-8") as f: + for payload in payloads: + f.write(compact_json(payload)) + f.write("\n") + try: + os.chmod(queue_file, 0o600) + except Exception: + pass + + +def post_payload_result(ctx, payload): + status, response_text = post_otlp_payload(ctx, payload) + if status != 200: + return 1 + rejected, _ = parse_partial_success(response_text) + if rejected: + return 2 + return 0 + + +def drain_queue(ctx): + if not ctx.queue_file or not ctx.lock_dir or not os.path.exists(ctx.queue_file): + return + if ctx.queue_drain_limit <= 0: + return + + lock_path = queue_lock_path(ctx.lock_dir) + if not acquire_lock(lock_path): + return + try: + payloads = read_queue_payloads(ctx.queue_file) + if not payloads: + return + + max_attempts = min(len(payloads), ctx.queue_drain_limit) + remaining_start = max_attempts + for idx in range(max_attempts): + result = post_payload_result(ctx, payloads[idx]) + if result == 0: + continue + if result == 2: + continue + remaining_start = idx + break + + if max_attempts < len(payloads): + remaining = payloads[remaining_start:] + elif remaining_start < max_attempts: + remaining = payloads[remaining_start:] + else: + remaining = payloads[max_attempts:] + write_queue_payloads(ctx.queue_file, remaining) + finally: + release_lock(lock_path) + + +def send_payload_with_queueing(ctx, payload): + drain_queue(ctx) + result = post_payload_result(ctx, payload) + if result == 1: + append_queue_payload(ctx, payload) + return result + + +def probe_endpoint(endpoint: str, api_key: str) -> str: + status, _ = http_post_json(endpoint, {"resourceSpans": []}, api_key=api_key, timeout=12) + return f"{status:03d}" if status else "000" diff --git a/plugins/trace/hooks/py/settings.py b/plugins/trace/hooks/py/settings.py new file mode 100644 index 0000000..7dedd47 --- /dev/null +++ b/plugins/trace/hooks/py/settings.py @@ -0,0 +1,33 @@ +import json +import os + + +def write_settings_env(settings_file: str, api_key: str, endpoint: str, debug: str) -> str: + env_values = { + "TRACE_TO_PROMPTLAYER": "true", + "PROMPTLAYER_API_KEY": api_key, + "PROMPTLAYER_OTLP_ENDPOINT": endpoint, + "PROMPTLAYER_CC_DEBUG": debug, + } + + settings = {} + if os.path.exists(settings_file): + try: + with open(settings_file, encoding="utf-8") as f: + settings = json.load(f) + except Exception as exc: + raise SystemExit(f"invalid settings json: {exc}") + if not isinstance(settings, dict): + raise SystemExit("invalid settings json: root must be an object") + + current_env = settings.get("env", {}) + if not isinstance(current_env, dict): + current_env = {} + current_env.update(env_values) + settings["env"] = current_env + + os.makedirs(os.path.dirname(settings_file), exist_ok=True) + with open(settings_file, "w", encoding="utf-8") as f: + json.dump(settings, f, ensure_ascii=False, indent=2) + f.write("\n") + return settings_file diff --git a/plugins/trace/hooks/py/state.py b/plugins/trace/hooks/py/state.py new file mode 100644 index 0000000..0e61d2a --- /dev/null +++ b/plugins/trace/hooks/py/state.py @@ -0,0 +1,135 @@ +from dataclasses import dataclass +import json +import os +import shutil +import time + +from traceparent import parse_traceparent + + +@dataclass +class SessionState: + trace_id: str = "" + session_span_id: str = "" + session_parent_span_id: str = "" + session_start_ns: str = "" + current_turn_start_ns: str = "" + pending_tool_calls: str = "" + session_init_source: str = "" + session_traceparent_version: str = "" + session_trace_flags: str = "" + trace_context_source: str = "" + + @classmethod + def from_dict(cls, data): + if not isinstance(data, dict): + return cls() + return cls(**{field: str(data.get(field, "")) for field in cls.__dataclass_fields__}) + + def to_dict(self): + return {field: getattr(self, field, "") for field in self.__dataclass_fields__} + + +def compact_json(value) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def session_state_path(session_state_dir: str, session_id: str) -> str: + return os.path.join(session_state_dir, f"{session_id}.json") + + +def load_session_state(session_state_dir: str, session_id: str): + path = session_state_path(session_state_dir, session_id) + if not os.path.exists(path): + return SessionState(), path + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except Exception: + return SessionState(), path + return SessionState.from_dict(data), path + + +def save_session_state(path: str, state: SessionState) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(state.to_dict(), f, ensure_ascii=False, separators=(",", ":")) + + +def parse_pending_tool_calls(raw: str): + if not raw: + return [] + try: + data = json.loads(raw) + except Exception: + return [] + return data if isinstance(data, list) else [] + + +def session_lock_path(lock_dir: str, session_id: str) -> str: + return os.path.join(lock_dir, f"{session_id}.lock") + + +def queue_lock_path(lock_dir: str) -> str: + return os.path.join(lock_dir, "queue.lock") + + +def acquire_lock(path: str, attempts: int = 250, sleep_seconds: float = 0.02) -> bool: + for _ in range(attempts): + try: + os.mkdir(path) + return True + except FileExistsError: + time.sleep(sleep_seconds) + except Exception: + return False + return False + + +def release_lock(path: str) -> None: + try: + os.rmdir(path) + except Exception: + shutil.rmtree(path, ignore_errors=True) + + +def ensure_session_initialized( + state: SessionState, + *, + traceparent_raw: str, + generate_trace_id, + generate_span_id, + requested_start_ns=None, +): + if state.trace_id and state.session_span_id: + if not state.session_start_ns: + state.session_start_ns = str(requested_start_ns or time.time_ns()) + if not state.session_init_source: + state.session_init_source = "unknown" + if not state.pending_tool_calls: + state.pending_tool_calls = "[]" + if not state.session_parent_span_id: + state.session_parent_span_id = "" + if not state.session_traceparent_version: + state.session_traceparent_version = "" + if not state.session_trace_flags: + state.session_trace_flags = "" + if not state.trace_context_source: + state.trace_context_source = "generated" + return state, False + + trace_context = parse_traceparent(traceparent_raw) + if requested_start_ns is None: + requested_start_ns = time.time_ns() + + state.trace_id = trace_context["trace_id"] if trace_context else generate_trace_id() + state.session_span_id = generate_span_id() + state.session_parent_span_id = trace_context["parent_span_id"] if trace_context else "" + state.session_start_ns = str(requested_start_ns) + state.current_turn_start_ns = "" + state.pending_tool_calls = "[]" + state.session_init_source = "lazy_init" + state.session_traceparent_version = trace_context["version"] if trace_context else "" + state.session_trace_flags = trace_context["trace_flags"] if trace_context else "" + state.trace_context_source = trace_context["source"] if trace_context else "generated" + return state, True diff --git a/plugins/trace/hooks/parse_stop_transcript.py b/plugins/trace/hooks/py/stop_parser.py old mode 100755 new mode 100644 similarity index 83% rename from plugins/trace/hooks/parse_stop_transcript.py rename to plugins/trace/hooks/py/stop_parser.py index 74a4b0d..5e062d0 --- a/plugins/trace/hooks/parse_stop_transcript.py +++ b/plugins/trace/hooks/py/stop_parser.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 -"""Parse a Claude transcript and return finalized turn/tool/llm spans as JSON.""" +"""Pure transcript parsing and stop-hook span-spec derivation.""" import json -import os -import sys from datetime import datetime, timezone +from otlp import SpanSpec + def parse_iso_to_ns(raw): if not raw: @@ -172,9 +172,9 @@ def parse_transcript(transcript_path, turn_start_fallback, pending_payloads, exp is_error = bool(block.get("is_error", False)) match_idx = None - for idx, item in enumerate(pending_tool_uses): + for candidate_idx, item in enumerate(pending_tool_uses): if tool_use_id and item.get("id") == tool_use_id: - match_idx = idx + match_idx = candidate_idx break if match_idx is None and pending_tool_uses: match_idx = 0 @@ -277,8 +277,6 @@ def parse_transcript(transcript_path, turn_start_fallback, pending_payloads, exp } ) - # Claude can emit intermediate assistant records that contain only - # empty thinking blocks. Those should not consume the user's prompt. if not output_text and not tool_calls: continue @@ -345,31 +343,71 @@ def parse_transcript(transcript_path, turn_start_fallback, pending_payloads, exp } -def main(): - if len(sys.argv) < 3: - print( - json.dumps( - {"error": "Usage: parse_stop_transcript.py [session_id]"} +def build_stop_hook_span_specs( + *, + parsed, + trace_id, + session_span_id, + session_parent_span_id, + session_start_ns, + session_init_source, + generate_span_id, +): + turn = parsed.get("turn", {}) + turn_start_ns = str(turn.get("start_ns", session_start_ns)) + turn_end_ns = str(turn.get("end_ns", turn_start_ns)) + + if session_init_source == "lazy_init": + session_hook_attr = "StopFallback" + session_lifecycle_attr = "stop_fallback" + else: + session_hook_attr = "Stop" + session_lifecycle_attr = "in_progress" + + span_specs = [ + SpanSpec( + trace_id=trace_id, + span_id=session_span_id, + parent_span_id=session_parent_span_id, + name="Claude Code session", + kind="1", + start_ns=str(session_start_ns), + end_ns=turn_end_ns, + attrs={ + "source": "claude-code", + "hook": session_hook_attr, + "node_type": "WORKFLOW", + "session.lifecycle": session_lifecycle_attr, + }, + ) + ] + + for tool in parsed.get("tools", []): + span_specs.append( + SpanSpec( + trace_id=trace_id, + span_id=generate_span_id(), + parent_span_id=session_span_id, + name=tool.get("name", ""), + kind="3", + start_ns=str(tool.get("start_ns", turn_start_ns)), + end_ns=str(tool.get("end_ns", turn_end_ns)), + attrs=tool.get("attributes", {}), ) ) - return 1 - - transcript_path = sys.argv[1] - turn_start_fallback = safe_int(sys.argv[2], 0) or None - expected_session_id = sys.argv[3] if len(sys.argv) > 3 else None - - pending_raw = os.environ.get("PL_PENDING_TOOL_CALLS", "[]") - try: - pending_payloads = json.loads(pending_raw) - except Exception: - pending_payloads = [] - if not isinstance(pending_payloads, list): - pending_payloads = [] - - parsed = parse_transcript(transcript_path, turn_start_fallback, pending_payloads, expected_session_id) - print(json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))) - return 0 + for llm in parsed.get("llms", []): + span_specs.append( + SpanSpec( + trace_id=trace_id, + span_id=generate_span_id(), + parent_span_id=session_span_id, + name=llm.get("name", ""), + kind="3", + start_ns=str(llm.get("start_ns", turn_start_ns)), + end_ns=str(llm.get("end_ns", turn_end_ns)), + attrs=llm.get("attributes", {}), + ) + ) -if __name__ == "__main__": - raise SystemExit(main()) + return span_specs diff --git a/plugins/trace/hooks/py/traceparent.py b/plugins/trace/hooks/py/traceparent.py new file mode 100644 index 0000000..7b94538 --- /dev/null +++ b/plugins/trace/hooks/py/traceparent.py @@ -0,0 +1,31 @@ +def parse_traceparent(raw: str): + if not raw: + return None + + parts = raw.lower().split("-") + if len(parts) < 4: + return None + + version, trace_id, parent_span_id, trace_flags = parts[:4] + suffix = parts[4:] + + if len(version) != 2 or len(trace_id) != 32 or len(parent_span_id) != 16 or len(trace_flags) != 2: + return None + + hexdigits = set("0123456789abcdef") + if any(ch not in hexdigits for ch in version + trace_id + parent_span_id + trace_flags): + return None + if version == "ff": + return None + if version == "00" and suffix: + return None + if trace_id == "0" * 32 or parent_span_id == "0" * 16: + return None + + return { + "version": version, + "trace_id": trace_id, + "parent_span_id": parent_span_id, + "trace_flags": trace_flags, + "source": "external_traceparent", + } diff --git a/plugins/trace/hooks/session_end.sh b/plugins/trace/hooks/session_end.sh index 18ae1b6..8d0f21c 100755 --- a/plugins/trace/hooks/session_end.sh +++ b/plugins/trace/hooks/session_end.sh @@ -9,29 +9,7 @@ tracing_enabled || exit 0 check_requirements || exit 0 input="$(cat)" -session_id="$(echo "$input" | jq -r '.session_id // empty')" +session_id="$(printf '%s' "$input" | python3 "$SCRIPT_DIR/py/cli.py" session-end)" [[ -z "$session_id" ]] && exit 0 -acquire_session_lock "$session_id" || exit 0 -trap 'release_session_lock' EXIT - -trace_id="$(get_session_state "$session_id" trace_id)" -session_span_id="$(get_session_state "$session_id" session_span_id)" -session_parent_span_id="$(get_session_state "$session_id" session_parent_span_id)" -session_start_ns="$(get_session_state "$session_id" session_start_ns)" -[[ -z "$trace_id" || -z "$session_span_id" ]] && exit 0 -[[ -z "$session_start_ns" ]] && session_start_ns="$(now_ns)" - -release_session_lock -trap - EXIT - -# Always emit/re-emit root span with final end time. The server upserts on -# span_id conflict, so this safely updates the end time and lifecycle attribute. -end_ns="$(now_ns)" -attrs='{"source":"claude-code","hook":"SessionEnd","node_type":"WORKFLOW","session.lifecycle":"complete"}' -emit_span "$trace_id" "$session_span_id" "$session_parent_span_id" "Claude Code session" "1" "$session_start_ns" "$end_ns" "$attrs" || true - -acquire_session_lock "$session_id" || exit 0 -trap 'release_session_lock' EXIT -rm -f "$PL_SESSION_STATE_DIR/$session_id.json" log "INFO" "SessionEnd finalized session_id=$session_id" diff --git a/plugins/trace/hooks/session_start.sh b/plugins/trace/hooks/session_start.sh index 282fa51..18dab3c 100755 --- a/plugins/trace/hooks/session_start.sh +++ b/plugins/trace/hooks/session_start.sh @@ -9,49 +9,13 @@ tracing_enabled || exit 0 check_requirements || exit 0 input="$(cat)" -session_id="$(echo "$input" | jq -r '.session_id // empty')" -[[ -z "$session_id" ]] && session_id="$(uuidgen | tr '[:upper:]' '[:lower:]')" +result="$(printf '%s' "$input" | python3 "$SCRIPT_DIR/py/cli.py" session-start)" +IFS=$'\t' read -r session_id trace_id status <<<"$result" +[[ -z "$session_id" ]] && exit 0 -existing_trace_id="$(get_session_state "$session_id" trace_id)" -existing_session_span_id="$(get_session_state "$session_id" session_span_id)" -if [[ -n "$existing_trace_id" && -n "$existing_session_span_id" ]]; then - if [[ -z "$(get_session_state "$session_id" session_start_ns)" ]]; then - set_session_state "$session_id" session_start_ns "$(now_ns)" - fi - if [[ -z "$(get_session_state "$session_id" pending_tool_calls)" ]]; then - set_session_state "$session_id" pending_tool_calls "[]" - fi - if [[ -z "$(get_session_state "$session_id" session_parent_span_id)" ]]; then - set_session_state "$session_id" session_parent_span_id "" - fi - if [[ -z "$(get_session_state "$session_id" session_traceparent_version)" ]]; then - set_session_state "$session_id" session_traceparent_version "" - fi - if [[ -z "$(get_session_state "$session_id" session_trace_flags)" ]]; then - set_session_state "$session_id" session_trace_flags "" - fi - if [[ -z "$(get_session_state "$session_id" trace_context_source)" ]]; then - set_session_state "$session_id" trace_context_source "generated" - fi - log "INFO" "SessionStart ignored existing state session_id=$session_id trace_id=$existing_trace_id" +if [[ "$status" == "existing" ]]; then + log "INFO" "SessionStart ignored existing state session_id=$session_id trace_id=$trace_id" exit 0 fi -load_initial_trace_context || true -trace_id="${PL_INITIAL_TRACE_ID:-}" -[[ -z "$trace_id" ]] && trace_id="$(generate_trace_id)" -span_id="$(generate_span_id)" -start_ns="$(now_ns)" - -set_session_state "$session_id" trace_id "$trace_id" -set_session_state "$session_id" session_span_id "$span_id" -set_session_state "$session_id" session_parent_span_id "${PL_INITIAL_PARENT_SPAN_ID:-}" -set_session_state "$session_id" session_start_ns "$start_ns" -set_session_state "$session_id" current_turn_start_ns "" -set_session_state "$session_id" pending_tool_calls "[]" -set_session_state "$session_id" session_init_source "session_start_hook" -set_session_state "$session_id" session_traceparent_version "${PL_INITIAL_TRACEPARENT_VERSION:-}" -set_session_state "$session_id" session_trace_flags "${PL_INITIAL_TRACE_FLAGS:-}" -set_session_state "$session_id" trace_context_source "${PL_INITIAL_TRACE_CONTEXT_SOURCE:-generated}" - log "INFO" "SessionStart captured session_id=$session_id trace_id=$trace_id" diff --git a/plugins/trace/hooks/stop_hook.sh b/plugins/trace/hooks/stop_hook.sh index 407d406..d1c3d9e 100755 --- a/plugins/trace/hooks/stop_hook.sh +++ b/plugins/trace/hooks/stop_hook.sh @@ -9,115 +9,12 @@ tracing_enabled || exit 0 check_requirements || exit 0 input="$(cat)" -session_id="$(echo "$input" | jq -r '.session_id // empty')" -transcript_path="$(echo "$input" | jq -r '.transcript_path // empty')" - -if [[ -z "$session_id" && -n "$transcript_path" ]]; then - session_id="$(basename "$transcript_path" .jsonl)" -fi +result="$(printf '%s' "$input" | python3 "$SCRIPT_DIR/py/cli.py" stop-hook)" +IFS=$'\t' read -r session_id status <<<"$result" [[ -z "$session_id" ]] && exit 0 -spans_file="$(mktemp "${TMPDIR:-/tmp}/pl-stop-spans.XXXXXX")" -cleanup() { - rm -f "$spans_file" - release_session_lock -} -trap cleanup EXIT - -add_span_to_batch() { - local trace="$1" - local span="$2" - local parent="$3" - local span_name="$4" - local span_kind="$5" - local start="$6" - local end="$7" - local attrs="$8" - - local span_json - span_json="$(build_span_json "$trace" "$span" "$parent" "$span_name" "$span_kind" "$start" "$end" "$attrs")" || return 1 - printf '%s\n' "$span_json" >>"$spans_file" -} - -acquire_session_lock "$session_id" || exit 0 -ensure_session_initialized "$session_id" - -trace_id="$(get_session_state "$session_id" trace_id)" -session_span_id="$(get_session_state "$session_id" session_span_id)" -session_parent_span_id="$(get_session_state "$session_id" session_parent_span_id)" -turn_start_ns="$(get_session_state "$session_id" current_turn_start_ns)" -pending_tool_calls="$(get_session_state "$session_id" pending_tool_calls)" -session_init_source="$(get_session_state "$session_id" session_init_source)" -session_start_ns="$(get_session_state "$session_id" session_start_ns)" - -[[ -z "$trace_id" || -z "$session_span_id" ]] && exit 0 -[[ -z "$pending_tool_calls" ]] && pending_tool_calls='[]' -[[ -z "$session_start_ns" ]] && session_start_ns="$(now_ns)" - -[[ -z "$turn_start_ns" ]] && turn_start_ns="$(now_ns)" -# Keep lock scope short: snapshot + clear turn-specific mutable state. -set_session_state "$session_id" current_turn_start_ns "" -set_session_state "$session_id" pending_tool_calls "[]" - -release_session_lock - -parse_transcript_with_retry() { - local attempts=0 - local parsed llm_count - while true; do - parsed="$(PL_PENDING_TOOL_CALLS="$pending_tool_calls" python3 "$SCRIPT_DIR/parse_stop_transcript.py" "$transcript_path" "$turn_start_ns" "$session_id")" - llm_count="$(echo "$parsed" | jq -r '.llms | length')" - if [[ "$llm_count" -gt 0 || $attempts -ge 10 ]]; then - echo "$parsed" - return 0 - fi - attempts=$((attempts + 1)) - sleep 0.2 - done -} - -if [[ -z "$transcript_path" || ! -f "$transcript_path" ]]; then +if [[ "$status" == "missing_transcript" ]]; then log "WARN" "Stop missing transcript session_id=$session_id" -else - parsed="$(parse_transcript_with_retry)" - - turn_start_ns="$(echo "$parsed" | jq -r '.turn.start_ns')" - turn_end_ns="$(echo "$parsed" | jq -r '.turn.end_ns')" - - # Emit (or re-emit) the root session span eagerly so the trace is visible - # in the UI before the session ends. The server upserts on span_id conflict, - # so re-emitting with an updated end time is safe. - if [[ "$session_init_source" == "lazy_init" ]]; then - session_hook_attr="StopFallback" - session_lifecycle_attr="stop_fallback" - else - session_hook_attr="Stop" - session_lifecycle_attr="in_progress" - fi - session_attrs="{\"source\":\"claude-code\",\"hook\":\"$session_hook_attr\",\"node_type\":\"WORKFLOW\",\"session.lifecycle\":\"$session_lifecycle_attr\"}" - add_span_to_batch "$trace_id" "$session_span_id" "$session_parent_span_id" "Claude Code session" "1" "$session_start_ns" "$turn_end_ns" "$session_attrs" || true - - while IFS= read -r tool; do - [[ -z "$tool" ]] && continue - span_id="$(generate_span_id)" - name="$(echo "$tool" | jq -r '.name')" - start_ns="$(echo "$tool" | jq -r '.start_ns')" - end_ns="$(echo "$tool" | jq -r '.end_ns')" - attrs="$(echo "$tool" | jq -c '.attributes')" - add_span_to_batch "$trace_id" "$span_id" "$session_span_id" "$name" "3" "$start_ns" "$end_ns" "$attrs" || true - done < <(echo "$parsed" | jq -c '.tools[]?') - - while IFS= read -r llm; do - [[ -z "$llm" ]] && continue - span_id="$(generate_span_id)" - name="$(echo "$llm" | jq -r '.name')" - start_ns="$(echo "$llm" | jq -r '.start_ns')" - end_ns="$(echo "$llm" | jq -r '.end_ns')" - attrs="$(echo "$llm" | jq -c '.attributes')" - add_span_to_batch "$trace_id" "$span_id" "$session_span_id" "$name" "3" "$start_ns" "$end_ns" "$attrs" || true - done < <(echo "$parsed" | jq -c '.llms[]?') fi -emit_spans_batch_file "$spans_file" || true - log "INFO" "Stop finalized session_id=$session_id" diff --git a/plugins/trace/hooks/user_prompt_submit.sh b/plugins/trace/hooks/user_prompt_submit.sh index 8383da4..e72d1c2 100755 --- a/plugins/trace/hooks/user_prompt_submit.sh +++ b/plugins/trace/hooks/user_prompt_submit.sh @@ -9,17 +9,7 @@ tracing_enabled || exit 0 check_requirements || exit 0 input="$(cat)" -session_id="$(echo "$input" | jq -r '.session_id // empty')" +session_id="$(printf '%s' "$input" | python3 "$SCRIPT_DIR/py/cli.py" user-prompt-submit)" [[ -z "$session_id" ]] && exit 0 -ensure_session_initialized "$session_id" - -trace_id="$(get_session_state "$session_id" trace_id)" -session_span_id="$(get_session_state "$session_id" session_span_id)" -[[ -z "$trace_id" || -z "$session_span_id" ]] && exit 0 -start_ns="$(now_ns)" - -set_session_state "$session_id" current_turn_start_ns "$start_ns" -set_session_state "$session_id" pending_tool_calls "[]" - log "INFO" "UserPromptSubmit captured session_id=$session_id" diff --git a/plugins/trace/setup.sh b/plugins/trace/setup.sh old mode 100755 new mode 100644 index 0246a68..8a0c8c1 --- a/plugins/trace/setup.sh +++ b/plugins/trace/setup.sh @@ -12,17 +12,9 @@ DEFAULT_ENDPOINT="https://api.promptlayer.com/v1/traces" install_hint() { local cmd="$1" if [[ "$OSTYPE" == "darwin"* ]]; then - if [[ "$cmd" == "uuidgen" ]]; then - echo " uuidgen should already be available on macOS." - else - echo " Install with: brew install $cmd" - fi + echo " Install with: brew install $cmd" else - if [[ "$cmd" == "uuidgen" ]]; then - echo " Install with: sudo apt-get install uuid-runtime" - else - echo " Install with: sudo apt-get install $cmd" - fi + echo " Install with: sudo apt-get install $cmd" fi } @@ -54,17 +46,8 @@ load_env_key() { test_endpoint() { local endpoint="$1" local api_key="$2" - - local payload status - payload='{"resourceSpans":[]}' - status="$(curl -sS -o /dev/null -w "%{http_code}" \ - -X POST \ - -H "Content-Type: application/json" \ - -H "X-Api-Key: $api_key" \ - --connect-timeout 5 \ - --max-time 12 \ - "$endpoint" \ - -d "$payload" || true)" + local status + status="$(python3 "$HOOKS_DIR/py/cli.py" probe-endpoint "$endpoint" "$api_key")" if [[ "$status" == "000" || -z "$status" ]]; then echo "WARN: Could not reach endpoint: $endpoint" @@ -94,21 +77,26 @@ test_endpoint() { echo "WARN: Endpoint check returned unexpected status: $status" } -for hook in lib.sh session_start.sh user_prompt_submit.sh post_tool_use.sh stop_hook.sh session_end.sh hooks.json parse_stop_transcript.py; do +for hook in lib.sh session_start.sh user_prompt_submit.sh post_tool_use.sh stop_hook.sh session_end.sh hooks.json; do if [[ ! -f "$HOOKS_DIR/$hook" ]]; then echo "Error: missing plugin file: $HOOKS_DIR/$hook" exit 1 fi done -for cmd in jq curl uuidgen python3; do - if ! command -v "$cmd" >/dev/null 2>&1; then - echo "Error: missing required command: $cmd" - install_hint "$cmd" +for py_file in cli.py context.py handlers.py otlp.py settings.py state.py stop_parser.py traceparent.py; do + if [[ ! -f "$HOOKS_DIR/py/$py_file" ]]; then + echo "Error: missing plugin file: $HOOKS_DIR/py/$py_file" exit 1 fi done +if ! command -v python3 >/dev/null 2>&1; then + echo "Error: missing required command: python3" + install_hint "python3" + exit 1 +fi + default_key="${PROMPTLAYER_API_KEY:-}" if [[ -z "$default_key" ]]; then if env_key="$(load_env_key 2>/dev/null)"; then @@ -160,32 +148,12 @@ fi mkdir -p "$HOME/.claude" settings_file="$HOME/.claude/settings.json" -if [[ -f "$settings_file" ]] && ! jq empty "$settings_file" >/dev/null 2>&1; then +if ! python3 "$HOOKS_DIR/py/cli.py" write-settings-env "$settings_file" "$api_key" "$endpoint" "$debug" >/dev/null 2>&1; then echo "Error: $settings_file exists but is not valid JSON." echo "Fix or remove it, then rerun setup." exit 1 fi -env_json="$( - jq -n \ - --arg k "$api_key" \ - --arg e "$endpoint" \ - --arg d "$debug" \ - '{ - "TRACE_TO_PROMPTLAYER": "true", - "PROMPTLAYER_API_KEY": $k, - "PROMPTLAYER_OTLP_ENDPOINT": $e, - "PROMPTLAYER_CC_DEBUG": $d - }' -)" - -if [[ -f "$settings_file" ]]; then - tmp="$(jq --argjson env "$env_json" '.env = (.env // {}) + $env' "$settings_file")" - echo "$tmp" >"$settings_file" -else - jq -n --argjson env "$env_json" '{env: $env}' >"$settings_file" -fi - chmod 600 "$settings_file" 2>/dev/null || true echo "" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..72b394c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "promptlayer-claude-plugins" +version = "1.0.0" +description = "Development metadata and test tooling for PromptLayer Claude Code plugins." +requires-python = ">=3.9" + +[project.optional-dependencies] +dev = [ + "pytest>=8.0,<9.0", +] + +[tool.setuptools] +py-modules = [] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/scripts/replay-fixtures.sh b/scripts/replay-fixtures.sh index b84e258..0bc6745 100755 --- a/scripts/replay-fixtures.sh +++ b/scripts/replay-fixtures.sh @@ -4,11 +4,6 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" -if ! command -v jq >/dev/null 2>&1; then - echo "jq is not installed, skipping fixture replay" - exit 0 -fi - TRACEPARENT_VALID="00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" TRACEPARENT_FUTURE="01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-03" TRACEPARENT_FUTURE_SUFFIXED="02-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-05-deadbeef" @@ -44,7 +39,9 @@ state_value() { local home="$1" local sid="$2" local key="$3" - jq -r ".${key} // empty" "$home/.claude/state/promptlayer_sessions/$sid.json" + python3 -c 'import json, sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get(sys.argv[2], ""))' \ + "$home/.claude/state/promptlayer_sessions/$sid.json" \ + "$key" } run_hook() { @@ -125,7 +122,7 @@ test_full_history_parser() { trap 'rm -f "$parsed_file"' RETURN PL_PENDING_TOOL_CALLS='[{"tool_name":"DocsSearch","function_input":{"query":"current status"},"function_output":{"content":"Current status: all systems operational."}}]' \ - python3 plugins/trace/hooks/parse_stop_transcript.py \ + python3 plugins/trace/hooks/py/cli.py parse-stop-transcript \ plugins/trace/testdata/stop_transcript_full_history.jsonl \ 0 \ "$SESSION_ID" >"$parsed_file" diff --git a/scripts/validate-manifests.sh b/scripts/validate-manifests.sh index e9bf5bb..c1f2308 100755 --- a/scripts/validate-manifests.sh +++ b/scripts/validate-manifests.sh @@ -3,11 +3,7 @@ set -euo pipefail validate_json() { local file="$1" - if command -v jq >/dev/null 2>&1; then - jq -e . "$file" >/dev/null - else - python3 -m json.tool "$file" >/dev/null - fi + python3 -m json.tool "$file" >/dev/null } validate_json .claude-plugin/marketplace.json @@ -15,7 +11,7 @@ validate_json plugins/trace/.claude-plugin/plugin.json validate_json plugins/trace/hooks/hooks.json # Verify plugin version in lib.sh matches marketplace.json -manifest_version="$(jq -r '.version' .claude-plugin/marketplace.json)" +manifest_version="$(python3 -c 'import json; print(json.load(open(".claude-plugin/marketplace.json", encoding="utf-8"))["version"])')" lib_version="$(sed -n 's/^export PL_PLUGIN_VERSION="\(.*\)"/\1/p' plugins/trace/hooks/lib.sh)" if [[ "$manifest_version" != "$lib_version" ]]; then echo "ERROR: version mismatch — marketplace.json=$manifest_version lib.sh=$lib_version" >&2 diff --git a/tests/test_handlers.py b/tests/test_handlers.py new file mode 100644 index 0000000..09f83e0 --- /dev/null +++ b/tests/test_handlers.py @@ -0,0 +1,92 @@ +import json +import pathlib +import sys +from types import SimpleNamespace + + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +HOOKS_DIR = REPO_ROOT / "plugins" / "trace" / "hooks" / "py" +sys.path.insert(0, str(HOOKS_DIR)) + +from handlers import handle_post_tool_use, handle_session_start, handle_stop_hook + + +def make_ctx(tmp_path): + (tmp_path / "sessions").mkdir(exist_ok=True) + (tmp_path / "locks").mkdir(exist_ok=True) + return SimpleNamespace( + log_file=str(tmp_path / "hook.log"), + queue_file=str(tmp_path / "queue.ndjson"), + session_state_dir=str(tmp_path / "sessions"), + lock_dir=str(tmp_path / "locks"), + debug="false", + api_key="pl_test_key", + otlp_endpoint="http://127.0.0.1:9/v1/traces", + queue_drain_limit=10, + otlp_connect_timeout=1, + otlp_max_time=1, + plugin_version="1.0.0", + cc_version="test", + user_agent="promptlayer-test", + ) + + +def load_state(tmp_path, session_id): + with open(tmp_path / "sessions" / f"{session_id}.json", encoding="utf-8") as f: + return json.load(f) + + +def test_handle_session_start_creates_state_file(tmp_path, monkeypatch): + monkeypatch.delenv("PROMPTLAYER_TRACEPARENT", raising=False) + ctx = make_ctx(tmp_path) + + result = handle_session_start(ctx, '{"session_id":"example-session-id"}') + + assert result.endswith("\tcaptured") + state = load_state(tmp_path, "example-session-id") + assert len(state["trace_id"]) == 32 + assert len(state["session_span_id"]) == 16 + assert state["trace_context_source"] == "generated" + + +def test_handle_post_tool_use_appends_pending_tool_call(tmp_path, monkeypatch): + monkeypatch.delenv("PROMPTLAYER_TRACEPARENT", raising=False) + ctx = make_ctx(tmp_path) + handle_session_start(ctx, '{"session_id":"example-session-id"}') + + result = handle_post_tool_use( + ctx, + json.dumps( + { + "session_id": "example-session-id", + "tool_name": "DocsSearch", + "tool_input": {"query": "status"}, + "tool_response": {"content": "ok"}, + } + ), + ) + + assert result == "example-session-id\tDocsSearch" + state = load_state(tmp_path, "example-session-id") + pending = json.loads(state["pending_tool_calls"]) + assert len(pending) == 1 + assert pending[0]["tool_name"] == "DocsSearch" + assert pending[0]["function_input"] == {"query": "status"} + + +def test_handle_stop_hook_returns_missing_transcript_marker(tmp_path, monkeypatch): + monkeypatch.delenv("PROMPTLAYER_TRACEPARENT", raising=False) + ctx = make_ctx(tmp_path) + handle_session_start(ctx, '{"session_id":"example-session-id"}') + + result = handle_stop_hook( + ctx, + json.dumps( + { + "session_id": "example-session-id", + "transcript_path": str(tmp_path / "missing.jsonl"), + } + ), + ) + + assert result == "example-session-id\tmissing_transcript" diff --git a/tests/test_hook_utils.py b/tests/test_hook_utils.py new file mode 100644 index 0000000..b7a46f3 --- /dev/null +++ b/tests/test_hook_utils.py @@ -0,0 +1,98 @@ +import json +import pathlib +import subprocess +import sys +import tempfile +import unittest + + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +HOOK_CLI = REPO_ROOT / "plugins" / "trace" / "hooks" / "py" / "cli.py" + + +def run_hook_cli(*args: str) -> str: + result = subprocess.run( + [sys.executable, str(HOOK_CLI), *args], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +class HookUtilsIdTests(unittest.TestCase): + def test_generate_trace_id_returns_32_lower_hex(self) -> None: + trace_id = run_hook_cli("generate-trace-id") + self.assertRegex(trace_id, r"^[0-9a-f]{32}$") + + def test_generate_span_id_returns_16_lower_hex(self) -> None: + span_id = run_hook_cli("generate-span-id") + self.assertRegex(span_id, r"^[0-9a-f]{16}$") + + def test_generate_session_id_returns_uuid_string(self) -> None: + session_id = run_hook_cli("generate-session-id") + self.assertRegex(session_id, r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + + +class HookUtilsSettingsTests(unittest.TestCase): + def test_write_settings_env_creates_new_settings_file(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + settings_path = pathlib.Path(tmpdir) / "settings.json" + run_hook_cli( + "write-settings-env", + str(settings_path), + "pl_test_key", + "https://api.promptlayer.com/v1/traces", + "false", + ) + + with settings_path.open(encoding="utf-8") as f: + settings = json.load(f) + + self.assertEqual(settings["env"]["TRACE_TO_PROMPTLAYER"], "true") + self.assertEqual(settings["env"]["PROMPTLAYER_API_KEY"], "pl_test_key") + self.assertEqual(settings["env"]["PROMPTLAYER_OTLP_ENDPOINT"], "https://api.promptlayer.com/v1/traces") + self.assertEqual(settings["env"]["PROMPTLAYER_CC_DEBUG"], "false") + + def test_write_settings_env_merges_existing_settings(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + settings_path = pathlib.Path(tmpdir) / "settings.json" + settings_path.write_text( + json.dumps({"foo": "bar", "env": {"EXISTING": "1", "PROMPTLAYER_CC_DEBUG": "true"}}), + encoding="utf-8", + ) + + run_hook_cli( + "write-settings-env", + str(settings_path), + "pl_test_key", + "https://example.com/v1/traces", + "false", + ) + + with settings_path.open(encoding="utf-8") as f: + settings = json.load(f) + + self.assertEqual(settings["foo"], "bar") + self.assertEqual(settings["env"]["EXISTING"], "1") + self.assertEqual(settings["env"]["PROMPTLAYER_API_KEY"], "pl_test_key") + self.assertEqual(settings["env"]["PROMPTLAYER_OTLP_ENDPOINT"], "https://example.com/v1/traces") + self.assertEqual(settings["env"]["PROMPTLAYER_CC_DEBUG"], "false") + + def test_write_settings_env_rejects_invalid_json(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + settings_path = pathlib.Path(tmpdir) / "settings.json" + settings_path.write_text("{not valid json", encoding="utf-8") + + with self.assertRaises(subprocess.CalledProcessError): + run_hook_cli( + "write-settings-env", + str(settings_path), + "pl_test_key", + "https://example.com/v1/traces", + "false", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_otlp.py b/tests/test_otlp.py new file mode 100644 index 0000000..b5c502f --- /dev/null +++ b/tests/test_otlp.py @@ -0,0 +1,64 @@ +import base64 +import pathlib +import sys + + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +HOOKS_DIR = REPO_ROOT / "plugins" / "trace" / "hooks" / "py" +sys.path.insert(0, str(HOOKS_DIR)) + +from otlp import SpanSpec, build_payload, build_span, parse_partial_success + + +def decode_b64(value: str) -> str: + return base64.b64decode(value).hex() + + +def test_build_span_encodes_ids_and_attribute_types(): + span = build_span( + SpanSpec( + trace_id="4bf92f3577b34da6a3ce929d0e0e4736", + span_id="00f067aa0ba902b7", + parent_span_id="89abcdef01234567", + name="Claude Code session", + kind="1", + start_ns="100", + end_ns="200", + attrs={ + "string_attr": "value", + "bool_attr": True, + "int_attr": 42, + "float_attr": 3.5, + "obj_attr": {"k": "v"}, + }, + ) + ) + + assert decode_b64(span["traceId"]) == "4bf92f3577b34da6a3ce929d0e0e4736" + assert decode_b64(span["spanId"]) == "00f067aa0ba902b7" + assert decode_b64(span["parentSpanId"]) == "89abcdef01234567" + assert span["kind"] == "SPAN_KIND_INTERNAL" + + attr_map = {item["key"]: item["value"] for item in span["attributes"]} + assert attr_map["string_attr"] == {"stringValue": "value"} + assert attr_map["bool_attr"] == {"boolValue": True} + assert attr_map["int_attr"] == {"intValue": "42"} + assert attr_map["float_attr"] == {"doubleValue": 3.5} + assert attr_map["obj_attr"] == {"stringValue": '{"k":"v"}'} + + +def test_build_payload_wraps_spans_with_service_name(): + payload = build_payload([{"name": "span"}]) + + assert payload["resourceSpans"][0]["resource"]["attributes"][0]["key"] == "service.name" + assert payload["resourceSpans"][0]["scopeSpans"][0]["spans"] == [{"name": "span"}] + + +def test_parse_partial_success_handles_rejections_and_invalid_json(): + rejected, message = parse_partial_success('{"partialSuccess":{"rejectedSpans":2,"errorMessage":"bad span"}}') + assert rejected == 2 + assert message == "bad span" + + rejected, message = parse_partial_success("not-json") + assert rejected == 0 + assert message == "" diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..a3ca588 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,62 @@ +import pathlib +import sys + + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +HOOKS_DIR = REPO_ROOT / "plugins" / "trace" / "hooks" / "py" +sys.path.insert(0, str(HOOKS_DIR)) + +from state import SessionState, ensure_session_initialized + + +def test_ensure_session_initialized_uses_traceparent_when_available(): + state = SessionState() + + state, created = ensure_session_initialized( + state, + traceparent_raw="00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + generate_trace_id=lambda: "generated-trace-id", + generate_span_id=lambda: "generated-span-id", + requested_start_ns=123, + ) + + assert created is True + assert state.trace_id == "4bf92f3577b34da6a3ce929d0e0e4736" + assert state.session_parent_span_id == "00f067aa0ba902b7" + assert state.session_span_id == "generated-span-id" + assert state.session_start_ns == "123" + assert state.session_init_source == "lazy_init" + assert state.trace_context_source == "external_traceparent" + + +def test_ensure_session_initialized_backfills_existing_state_defaults(): + state = SessionState( + trace_id="trace-id", + session_span_id="span-id", + session_start_ns="", + pending_tool_calls="", + session_parent_span_id="", + session_traceparent_version="", + session_trace_flags="", + trace_context_source="", + session_init_source="", + ) + + state, created = ensure_session_initialized( + state, + traceparent_raw="", + generate_trace_id=lambda: "unused-trace-id", + generate_span_id=lambda: "unused-span-id", + requested_start_ns=999, + ) + + assert created is False + assert state.trace_id == "trace-id" + assert state.session_span_id == "span-id" + assert state.session_start_ns == "999" + assert state.pending_tool_calls == "[]" + assert state.session_parent_span_id == "" + assert state.session_traceparent_version == "" + assert state.session_trace_flags == "" + assert state.trace_context_source == "generated" + assert state.session_init_source == "unknown" diff --git a/tests/test_stop_hook_logic.py b/tests/test_stop_hook_logic.py new file mode 100644 index 0000000..2a3e0cf --- /dev/null +++ b/tests/test_stop_hook_logic.py @@ -0,0 +1,103 @@ +import pathlib +import sys + + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +HOOKS_DIR = REPO_ROOT / "plugins" / "trace" / "hooks" / "py" +sys.path.insert(0, str(HOOKS_DIR)) + +from stop_parser import build_stop_hook_span_specs, parse_transcript + + +SESSION_ID = "example-session-id" + + +def test_parse_transcript_full_history_fixture_preserves_expected_spans(): + parsed = parse_transcript( + str(REPO_ROOT / "plugins" / "trace" / "testdata" / "stop_transcript_full_history.jsonl"), + 0, + [ + { + "tool_name": "DocsSearch", + "function_input": {"query": "current status"}, + "function_output": {"content": "Current status: all systems operational."}, + } + ], + SESSION_ID, + ) + + llms = parsed["llms"] + tools = parsed["tools"] + + assert len(llms) == 2 + assert len(tools) == 1 + assert llms[0]["name"] == "LLM Call (User)" + assert llms[1]["name"] == "LLM call" + assert tools[0]["name"] == "Tool: DocsSearch" + assert llms[0]["attributes"]["promptlayer.prompt_history_mode"] == "full_session" + assert llms[1]["attributes"]["gen_ai.completion.0.content"] == "All systems are operational." + + +def test_build_stop_hook_span_specs_builds_root_and_child_span_specs(): + parsed = { + "turn": {"start_ns": 100, "end_ns": 220}, + "tools": [ + { + "name": "Tool: DocsSearch", + "start_ns": 110, + "end_ns": 150, + "attributes": {"tool_name": "DocsSearch"}, + } + ], + "llms": [ + { + "name": "LLM Call (User)", + "start_ns": 150, + "end_ns": 220, + "attributes": {"gen_ai.request.model": "claude"}, + } + ], + } + + span_specs = build_stop_hook_span_specs( + parsed=parsed, + trace_id="trace1234", + session_span_id="rootspan", + session_parent_span_id="parentspan", + session_start_ns="90", + session_init_source="session_start_hook", + generate_span_id=lambda: "childspan", + ) + + assert len(span_specs) == 3 + assert span_specs[0].name == "Claude Code session" + assert span_specs[0].span_id == "rootspan" + assert span_specs[0].parent_span_id == "parentspan" + assert span_specs[0].attrs["hook"] == "Stop" + assert span_specs[0].attrs["session.lifecycle"] == "in_progress" + + assert span_specs[1].name == "Tool: DocsSearch" + assert span_specs[1].parent_span_id == "rootspan" + assert span_specs[1].kind == "3" + + assert span_specs[2].name == "LLM Call (User)" + assert span_specs[2].parent_span_id == "rootspan" + assert span_specs[2].kind == "3" + + +def test_build_stop_hook_span_specs_uses_lazy_init_session_attrs(): + parsed = {"turn": {"start_ns": 100, "end_ns": 120}, "tools": [], "llms": []} + + span_specs = build_stop_hook_span_specs( + parsed=parsed, + trace_id="trace1234", + session_span_id="rootspan", + session_parent_span_id="", + session_start_ns="90", + session_init_source="lazy_init", + generate_span_id=lambda: "unused", + ) + + assert len(span_specs) == 1 + assert span_specs[0].attrs["hook"] == "StopFallback" + assert span_specs[0].attrs["session.lifecycle"] == "stop_fallback" diff --git a/tests/test_traceparent.py b/tests/test_traceparent.py new file mode 100644 index 0000000..4180a17 --- /dev/null +++ b/tests/test_traceparent.py @@ -0,0 +1,37 @@ +import pathlib +import sys + + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +HOOKS_DIR = REPO_ROOT / "plugins" / "trace" / "hooks" / "py" +sys.path.insert(0, str(HOOKS_DIR)) + +from traceparent import parse_traceparent + + +def test_parse_traceparent_accepts_valid_v00_header(): + parsed = parse_traceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") + + assert parsed == { + "version": "00", + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", + "parent_span_id": "00f067aa0ba902b7", + "trace_flags": "01", + "source": "external_traceparent", + } + + +def test_parse_traceparent_accepts_future_version_with_suffix(): + parsed = parse_traceparent("02-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-05-deadbeef") + + assert parsed["version"] == "02" + assert parsed["trace_flags"] == "05" + + +def test_parse_traceparent_rejects_invalid_inputs(): + assert parse_traceparent("") is None + assert parse_traceparent("bogus") is None + assert parse_traceparent("ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") is None + assert parse_traceparent("00-00000000000000000000000000000000-00f067aa0ba902b7-01") is None + assert parse_traceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01") is None + assert parse_traceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-deadbeef") is None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..aa65a34 --- /dev/null +++ b/uv.lock @@ -0,0 +1,175 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "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 = "promptlayer-claude-plugins" +version = "1.0.0" +source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0,<9.0" }] +provides-extras = ["dev"] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]