Files
kite/backend/storage/cloud_base.py
T
curo1305 0a7273b9fe feat(12-01): normalized cloud resource capability contract and unit tests
- Define 9 action keys, 3 capability states, 6 reason codes in cloud_base.py
- Immutable CloudCapability, CloudResource, CloudListing frozen dataclasses
- Abstract CloudResourceAdapter with list_folder, get_capabilities, merge_item_capabilities
- No mutation methods in Phase 12 interface (Phase 13 boundary enforced)
- 29 unit tests covering vocabulary, validation, merge behavior, fake adapter
2026-06-18 22:34:23 +02:00

246 lines
9.3 KiB
Python

"""
Provider-neutral cloud resource capability contract for DocuVault Phase 12.
Defines the stable vocabulary (action keys, capability states, reason codes),
immutable normalized value types (CloudCapability, CloudResource, CloudListing),
and the abstract CloudResourceAdapter read-only interface.
Design decisions (12-CONTEXT.md D-06 through D-10):
- Structurally unsupported actions remain visible but greyed out (D-06).
- Capability discovery must never mutate provider content (D-08).
- Permanent vs. temporary limitations have distinct visual states (D-09).
- No mutation methods in Phase 12 contract; mutations added in Phase 13.
Credentials are never fields on any value type. The interface contains no
put, delete, rename, move, or create_folder methods.
"""
from __future__ import annotations
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
# ── Action keys ───────────────────────────────────────────────────────────────
ACTIONS = frozenset({
"browse",
"open",
"preview",
"upload",
"create_folder",
"rename",
"move",
"delete",
"change_tracking",
})
# ── Capability states ─────────────────────────────────────────────────────────
STATE_SUPPORTED = "supported"
STATE_UNSUPPORTED = "unsupported"
STATE_TEMPORARILY_UNAVAILABLE = "temporarily_unavailable"
CAPABILITY_STATES = frozenset({
STATE_SUPPORTED,
STATE_UNSUPPORTED,
STATE_TEMPORARILY_UNAVAILABLE,
})
# ── Stable reason codes ───────────────────────────────────────────────────────
REASON_PROVIDER_UNSUPPORTED = "provider_unsupported"
REASON_INSUFFICIENT_SCOPE = "insufficient_scope"
REASON_READ_ONLY = "read_only"
REASON_REAUTH_REQUIRED = "reauth_required"
REASON_OFFLINE = "offline"
REASON_ITEM_RESTRICTED = "item_restricted"
KNOWN_REASONS = frozenset({
REASON_PROVIDER_UNSUPPORTED,
REASON_INSUFFICIENT_SCOPE,
REASON_READ_ONLY,
REASON_REAUTH_REQUIRED,
REASON_OFFLINE,
REASON_ITEM_RESTRICTED,
})
# ── Normalized value types ────────────────────────────────────────────────────
@dataclass(frozen=True)
class CloudCapability:
"""Normalized capability descriptor for a single action.
state: one of the three CAPABILITY_STATES constants.
reason: one of the KNOWN_REASONS codes when state != supported; None otherwise.
message: controlled adapter output — safe for display; never raw provider text.
Invariants enforced at construction:
- state must be a known capability state.
- reason and message are only present when state is not supported.
- reason, when present, must be a known reason code.
"""
action: str
state: str
reason: Optional[str] = None
message: Optional[str] = None
def __post_init__(self) -> None:
if self.state not in CAPABILITY_STATES:
raise ValueError(
f"Unknown capability state {self.state!r}; "
f"must be one of {sorted(CAPABILITY_STATES)}"
)
if self.action not in ACTIONS:
raise ValueError(
f"Unknown action {self.action!r}; must be one of {sorted(ACTIONS)}"
)
if self.state == STATE_SUPPORTED:
if self.reason is not None or self.message is not None:
raise ValueError(
"reason and message must be None when state is 'supported'"
)
else:
if self.reason is not None and self.reason not in KNOWN_REASONS:
raise ValueError(
f"Unknown reason code {self.reason!r}; "
f"must be one of {sorted(KNOWN_REASONS)}"
)
@dataclass(frozen=True)
class CloudResource:
"""Normalized cloud item metadata — provider-independent representation.
id: stable DocuVault UUID for this item; survives provider rename/move.
provider_item_id: opaque provider-assigned identifier (e.g. Drive file ID).
connection_id: UUID of the CloudConnection that owns this item.
user_id: UUID of the owning user (security boundary).
name: display name as reported by the provider.
kind: "file" or "folder".
parent_ref: opaque provider reference to the parent folder; None at root.
content_type: MIME type for files; None for folders.
size: provider-reported size in bytes; None if not reported or for folders.
modified_at: provider-reported modification time; None if not available.
etag: provider etag or version string for change detection; None if absent.
capabilities: per-item capability overrides merged with connection defaults.
"""
id: uuid.UUID
provider_item_id: str
connection_id: uuid.UUID
user_id: uuid.UUID
name: str
kind: str # "file" | "folder"
parent_ref: Optional[str] = None
content_type: Optional[str] = None
size: Optional[int] = None
modified_at: Optional[datetime] = None
etag: Optional[str] = None
capabilities: dict[str, CloudCapability] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.kind not in ("file", "folder"):
raise ValueError(f"kind must be 'file' or 'folder', got {self.kind!r}")
for action, cap in self.capabilities.items():
if not isinstance(cap, CloudCapability):
raise TypeError(
f"capabilities[{action!r}] must be a CloudCapability instance"
)
@dataclass(frozen=True)
class CloudListing:
"""Result of listing a folder from a provider.
items: normalized CloudResource children of the listed folder.
complete: True when the listing represents the full authoritative contents
(safe to mark unseen children deleted on reconciliation).
False when the listing is partial, paginated, or failed — retained rows
must NOT be marked deleted.
next_page_token: opaque token for continuation; None when exhausted.
"""
items: tuple[CloudResource, ...]
complete: bool = True
next_page_token: Optional[str] = None
def __post_init__(self) -> None:
# Coerce list to tuple for immutability
object.__setattr__(self, "items", tuple(self.items))
# ── Abstract adapter ──────────────────────────────────────────────────────────
class CloudResourceAdapter(ABC):
"""Read-only cloud resource adapter contract for Phase 12.
Phase 12 interface is intentionally limited to browse and capability
discovery. No method here creates, renames, moves, or deletes provider
content. Mutation methods are added in Phase 13 on a separate subclass
interface.
Credentials are passed at construction time (or via dependency injection)
and are never exposed as fields or return values.
"""
@abstractmethod
async def list_folder(
self,
connection_id: uuid.UUID,
user_id: uuid.UUID,
parent_ref: Optional[str] = None,
page_token: Optional[str] = None,
) -> CloudListing:
"""List the direct children of a folder.
parent_ref=None lists the connection root.
Returns a CloudListing with complete=False on partial or failed fetches.
Raises CloudConnectionError (from storage.exceptions) on auth failures.
Never mutates provider content as a side effect.
"""
...
@abstractmethod
async def get_capabilities(
self,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict[str, CloudCapability]:
"""Return connection-level capabilities for all defined ACTIONS.
The returned dict must contain an entry for every action in ACTIONS.
Probing must not create, rename, move, or delete any provider content.
"""
...
def merge_item_capabilities(
self,
connection_caps: dict[str, CloudCapability],
item_caps: dict[str, CloudCapability],
) -> dict[str, CloudCapability]:
"""Merge per-item overrides onto connection defaults.
Item capabilities take precedence over connection capabilities.
Actions absent from item_caps fall back to connection_caps.
The result always contains every action in ACTIONS.
"""
merged: dict[str, CloudCapability] = {}
for action in ACTIONS:
if action in item_caps:
merged[action] = item_caps[action]
elif action in connection_caps:
merged[action] = connection_caps[action]
else:
# Safe default: unsupported with no reason
merged[action] = CloudCapability(
action=action,
state=STATE_UNSUPPORTED,
reason=REASON_PROVIDER_UNSUPPORTED,
message="This action is not supported by the provider.",
)
return merged