feat(files): orchestrate connector folder sync
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-21 22:49:47 +02:00
parent a8c3192c3a
commit 378f4d6ac5
19 changed files with 1302 additions and 30 deletions
@@ -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
+1 -1
View File
@@ -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