Files
kite/backend/services/cloud_analysis_versioning.py
T
curo1305 1df61040c6 feat(14-02): add version-key, settings helpers and Phase 14 cache service functions
- cloud_analysis_versioning.py: compute_version_key with version>etag>fingerprint
  precedence (D-19); compute_fingerprint helper; content_hash is optional post-
  hydration only (D-20); raises ValueError only, no HTTPException
- cloud_analysis_settings.py: get_or_create_user_analysis_settings (upsert on
  first access), update_user_analysis_settings with enum validation and tier-capped
  cache limit, build_default_settings for API responses; raises ValueError only
- cloud_cache.py: Phase 14 durable cache functions added alongside existing Phase 12
  in-memory TTL cache — create_cache_entry, list_cache_entries (owner-scoped),
  evict_lru_entries (LRU, skips pin_count>0 and active_job_count>0), increment/
  decrement_quota_for_cache (atomic UPDATE pattern); re-exports compute_version_key
  from cloud_analysis_versioning so tests have one import site
2026-06-23 15:14:19 +02:00

133 lines
5.2 KiB
Python

"""
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}"