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