""" Cloud analysis version-key helper — Phase 14. Provides compute_version_key: the single canonical helper for deriving the idempotency key that determines whether a cloud item has changed since its last analysis. Version key precedence (PATTERNS.md, D-19): 1. provider `version` field (most reliable — survives filename/path changes) 2. provider `etag` (HTTP-standard opaque identifier from HEAD/metadata) 3. metadata fingerprint — deterministic hash of (provider_item_id, size, modified_at, content_type) — used when provider supplies neither version nor etag 4. content_hash — accepted as an ADDITIONAL post-hydration fact, never required before bytes are downloaded (D-20) Rules: - Service raises ValueError only — never HTTPException (CLAUDE.md). - Content hash is NEVER used as a fallback that would require byte download. - The same inputs must always produce the same key (deterministic). - Different inputs must reliably produce different keys. """ from __future__ import annotations import hashlib from typing import Optional # ── Public constant ─────────────────────────────────────────────────────────── #: Prefix tags that identify which precedence level produced the key. #: These are load-bearing — callers can strip the prefix to get the raw value, #: or compare keys with mismatched prefixes without hashing collisions. _PREFIX_VERSION = "v:" _PREFIX_ETAG = "e:" _PREFIX_FINGERPRINT = "fp:" _PREFIX_CONTENT_HASH = "ch:" def compute_version_key( *, provider_item_id: str, version: Optional[str], etag: Optional[str], size: Optional[int], modified_at: Optional[str], content_type: Optional[str], content_hash: Optional[str] = None, ) -> str: """Return the canonical version key for a cloud item. The key uniquely identifies the content state of a provider item. Passing content_hash is optional and only enhances the fingerprint after bytes have already been hydrated — it is never a precondition for calling this function. Args: provider_item_id: Opaque provider-assigned item identifier. version: Provider version string (e.g. Google Drive `version`). Takes precedence over etag. etag: Provider etag (e.g. OneDrive/WebDAV ETag header). Used when version is None. size: Provider-reported byte size. Used in the fingerprint fallback. modified_at: Provider-reported last-modified timestamp string. Used in the fingerprint fallback. content_type: MIME content type. Used in the fingerprint fallback. content_hash: Optional SHA-256 or similar hash computed after bytes are already hydrated. When provided alongside a version or etag, it is appended to the key to allow post-analysis re-keying without triggering a new download. When provided without version or etag, it is used as a supplementary fingerprint component. Returns: A non-empty string that uniquely represents the item's current content state at the given precedence level. Raises: ValueError: provider_item_id is empty or None. """ if not provider_item_id: raise ValueError("provider_item_id must not be empty") if version: base = f"{_PREFIX_VERSION}{version}" elif etag: base = f"{_PREFIX_ETAG}{etag}" else: base = _compute_fingerprint(provider_item_id, size, modified_at, content_type) # Post-hydration content hash enhances the key without requiring a new download. if content_hash: base = f"{base}|{_PREFIX_CONTENT_HASH}{content_hash}" return base def compute_fingerprint( provider_item_id: str, size: Optional[int], modified_at: Optional[str], content_type: Optional[str], ) -> str: """Return the stable metadata fingerprint for an item lacking version/etag. Public entry point for callers that need only the fingerprint portion (e.g. when pre-computing the cloud_analysis_job_items.fingerprint field). The fingerprint is a hex-encoded SHA-256 of the canonical field string. """ return _compute_fingerprint(provider_item_id, size, modified_at, content_type) # ── Internal helpers ────────────────────────────────────────────────────────── def _compute_fingerprint( provider_item_id: str, size: Optional[int], modified_at: Optional[str], content_type: Optional[str], ) -> str: """Return a prefixed SHA-256 fingerprint of item metadata.""" # Canonical form: fields joined with ':' using empty string for None canonical = ":".join([ provider_item_id, str(size) if size is not None else "", modified_at or "", content_type or "", ]) digest = hashlib.sha256(canonical.encode()).hexdigest() return f"{_PREFIX_FINGERPRINT}{digest}"