briefcase-python 2.4.1

Python bindings for Briefcase AI
Documentation
"""
WebhookEmitter — POST BriefcaseEvents as JSON to a configured URL.

Features:
- HMAC-SHA256 signature in X-Briefcase-Signature header
- CloudEvents 1.0 headers on every request
- Per-event-type subscription filtering
- Retry with exponential backoff on 5xx (max 3 attempts)
- Configurable timeout (default 10 s)
- Async HTTP via httpx (optional dependency)
"""

from __future__ import annotations

import hashlib
import hmac
import json
from typing import List, Optional

from briefcase.events.types import BriefcaseEvent

try:
    import httpx as httpx
except ImportError:
    httpx = None  # type: ignore[assignment]


class WebhookEmitter:
    """POST BriefcaseEvents to a webhook endpoint.

    Args:
        url: Destination URL for POST requests.
        secret: HMAC-SHA256 signing secret.
        subscribed_events: List of event_type strings to forward.
                           If None or empty, all events are forwarded.
        timeout: HTTP request timeout in seconds (default 10).
    """

    def __init__(
        self,
        url: str,
        secret: str = "",
        subscribed_events: Optional[List[str]] = None,
        timeout: float = 10.0,
    ) -> None:
        self._url = url
        self._secret = secret
        self._subscribed_events = subscribed_events or []
        self._timeout = timeout

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _is_subscribed(self, event: BriefcaseEvent) -> bool:
        if not self._subscribed_events:
            return True
        return event.event_type in self._subscribed_events

    @staticmethod
    def _sign(body: bytes, secret: str) -> str:
        """Return HMAC-SHA256 hex digest of *body*."""
        return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()

    def _build_headers(self, event: BriefcaseEvent, body: bytes) -> dict:
        return {
            "Content-Type": "application/json",
            "X-Briefcase-Signature": self._sign(body, self._secret),
            # CloudEvents 1.0
            "ce-id": event.idempotency_key,
            "ce-type": event.event_type,
            "ce-source": "briefcase-ai",
            "ce-specversion": "1.0",
            "ce-time": event.timestamp.isoformat(),
        }

    # ------------------------------------------------------------------
    # Public interface
    # ------------------------------------------------------------------

    async def emit(self, event: BriefcaseEvent) -> bool:
        """Send *event* to the webhook endpoint.

        Returns True if the request was acknowledged (2xx), False otherwise.
        Silently handles all errors (timeout, network, exhausted retries).
        """
        if httpx is None:
            raise ImportError(
                "httpx is required for WebhookEmitter. "
                "Install it with: pip install 'briefcase-ai[splunk]'"
            )

        if not self._is_subscribed(event):
            return False

        body = json.dumps(
            {
                "event_type": event.event_type,
                "decision_id": event.decision_id,
                "timestamp": event.timestamp.isoformat(),
                "idempotency_key": event.idempotency_key,
                "payload": event.payload,
            },
            default=str,
        ).encode()

        headers = self._build_headers(event, body)
        max_attempts = 3

        async with httpx.AsyncClient(timeout=self._timeout) as client:
            for attempt in range(max_attempts):
                try:
                    response = await client.post(self._url, content=body, headers=headers)
                    if response.status_code < 500:
                        return response.status_code < 400
                    # 5xx — retry unless this was the last attempt
                    if attempt >= max_attempts - 1:
                        return False
                except httpx.TimeoutException:
                    return False
                except Exception:
                    return False
            return False