feat(files): orchestrate connector folder sync
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from govoplan_files.backend.storage.connector_browse import (
|
||||
ConnectorBrowseItem,
|
||||
browse_connector_profile,
|
||||
normalize_connector_browse_path,
|
||||
)
|
||||
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorFolderSkip:
|
||||
path: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ConnectorFolderDiscovery:
|
||||
files: list[ConnectorBrowseItem] = field(default_factory=list)
|
||||
skipped: list[ConnectorFolderSkip] = field(default_factory=list)
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
def discover_connector_folder(
|
||||
profile: ConnectorProfile,
|
||||
*,
|
||||
path: str,
|
||||
library_id: str | None,
|
||||
recursive: bool,
|
||||
max_files: int,
|
||||
max_depth: int,
|
||||
) -> ConnectorFolderDiscovery:
|
||||
"""Discover a bounded, deterministic set of files below one connector folder."""
|
||||
|
||||
root_path = normalize_connector_browse_path(path)
|
||||
queue: deque[tuple[str, int]] = deque([(root_path, 0)])
|
||||
visited_folders: set[str] = set()
|
||||
seen_files: set[tuple[str, str]] = set()
|
||||
result = ConnectorFolderDiscovery()
|
||||
|
||||
while queue:
|
||||
folder_path, depth = queue.popleft()
|
||||
if folder_path in visited_folders:
|
||||
continue
|
||||
visited_folders.add(folder_path)
|
||||
|
||||
continuation_token: str | None = None
|
||||
seen_tokens: set[str] = set()
|
||||
while True:
|
||||
items = browse_connector_profile(
|
||||
profile,
|
||||
path=folder_path,
|
||||
library_id=library_id,
|
||||
continuation_token=continuation_token,
|
||||
)
|
||||
for item in items:
|
||||
item_path = normalize_connector_browse_path(item.path)
|
||||
if item.kind == "file":
|
||||
identity = (str(item.external_id or ""), item_path)
|
||||
if identity in seen_files:
|
||||
continue
|
||||
if len(result.files) >= max_files:
|
||||
result.truncated = True
|
||||
result.skipped.append(
|
||||
ConnectorFolderSkip(
|
||||
path=item_path or folder_path,
|
||||
reason=f"The manual sync limit of {max_files} files was reached.",
|
||||
)
|
||||
)
|
||||
return result
|
||||
seen_files.add(identity)
|
||||
result.files.append(item)
|
||||
continue
|
||||
|
||||
if item.kind == "folder":
|
||||
if not recursive:
|
||||
result.skipped.append(
|
||||
ConnectorFolderSkip(
|
||||
path=item_path,
|
||||
reason="Subfolder skipped because recursive sync is disabled.",
|
||||
)
|
||||
)
|
||||
elif depth >= max_depth:
|
||||
result.skipped.append(
|
||||
ConnectorFolderSkip(
|
||||
path=item_path,
|
||||
reason=f"Subfolder skipped at the configured depth limit of {max_depth}.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
queue.append((item_path, depth + 1))
|
||||
continue
|
||||
|
||||
result.skipped.append(
|
||||
ConnectorFolderSkip(
|
||||
path=item_path,
|
||||
reason=f"Connector item kind {item.kind!r} cannot be synchronized as a file.",
|
||||
)
|
||||
)
|
||||
|
||||
next_token = _next_continuation_token(items)
|
||||
if not next_token or next_token in seen_tokens:
|
||||
break
|
||||
seen_tokens.add(next_token)
|
||||
continuation_token = next_token
|
||||
|
||||
result.files.sort(key=lambda item: normalize_connector_browse_path(item.path).casefold())
|
||||
result.skipped.sort(key=lambda item: (item.path.casefold(), item.reason))
|
||||
return result
|
||||
|
||||
|
||||
def connector_relative_path(*, space_root: str, item_path: str) -> str:
|
||||
root = normalize_connector_browse_path(space_root)
|
||||
item = normalize_connector_browse_path(item_path)
|
||||
if not root:
|
||||
return item
|
||||
if item == root:
|
||||
return item.rsplit("/", 1)[-1]
|
||||
prefix = f"{root}/"
|
||||
if not item.startswith(prefix):
|
||||
raise ValueError("Connector returned a file outside the linked connector-space root")
|
||||
return item[len(prefix) :]
|
||||
|
||||
|
||||
def join_connector_path(*parts: str | None) -> str:
|
||||
return normalize_connector_browse_path(
|
||||
"/".join(str(part or "").strip("/\\") for part in parts if str(part or "").strip("/\\"))
|
||||
)
|
||||
|
||||
|
||||
def _next_continuation_token(items: list[ConnectorBrowseItem]) -> str | None:
|
||||
for item in reversed(items):
|
||||
value = item.metadata.get("next_continuation_token")
|
||||
if value is not None and str(value).strip():
|
||||
return str(value).strip()
|
||||
return None
|
||||
@@ -1108,7 +1108,7 @@ def _resolution_by_path(conflict_resolutions: Iterable[FileConflictResolution] |
|
||||
|
||||
def _normalize_conflict_strategy(strategy: str | None) -> str:
|
||||
normalized = (strategy or "reject").lower().strip()
|
||||
if normalized not in {"reject", "overwrite", "rename"}:
|
||||
if normalized not in {"reject", "overwrite", "rename", "skip"}:
|
||||
raise FileStorageError("Unsupported conflict strategy")
|
||||
return normalized
|
||||
|
||||
|
||||
Reference in New Issue
Block a user