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
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
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}"
|
||||
Reference in New Issue
Block a user