zop-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zop/core/envelope.py ADDED
@@ -0,0 +1,71 @@
1
+ """Output envelope helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import time
8
+ from typing import Any
9
+
10
+ from zop.core.errors import ZopError
11
+ from zop.models.envelope import Envelope, Meta
12
+
13
+
14
+ def emit(
15
+ data: Any = None,
16
+ *,
17
+ error: ZopError | None = None,
18
+ human: bool = False,
19
+ count: int | None = None,
20
+ ) -> None:
21
+ """Emit a JSON envelope to stdout.
22
+
23
+ Args:
24
+ data: Payload (will be JSON-serialized).
25
+ error: If provided, the envelope is marked as failed and `error` is
26
+ rendered as the error block.
27
+ human: If True, pretty-print; if False (default), compact JSON.
28
+ count: Optional item count for the meta block.
29
+ """
30
+ start = time.perf_counter_ns()
31
+ if error is not None:
32
+ env = Envelope(ok=False, error=error.to_block())
33
+ else:
34
+ env = Envelope(ok=True, data=data, meta=Meta(count=count))
35
+ latency_ms = int((time.perf_counter_ns() - start) / 1_000_000)
36
+ env = env.model_copy(update={"meta": env.meta.model_copy(update={"latency_ms": latency_ms})})
37
+
38
+ if human:
39
+ sys.stdout.write(env.to_human())
40
+ else:
41
+ sys.stdout.write(env.to_json())
42
+ sys.stdout.write("\n")
43
+ sys.stdout.flush()
44
+
45
+
46
+ def emit_error(err: ZopError, *, human: bool = False) -> None:
47
+ """Emit a top-level error envelope."""
48
+ emit(error=err, human=human)
49
+
50
+
51
+ def emit_batch(
52
+ successes: list[Any],
53
+ failures: list[tuple[str, ZopError]],
54
+ *,
55
+ human: bool = False,
56
+ ) -> None:
57
+ """Emit a batch result with both successes and per-item failures."""
58
+ data: dict[str, Any] = {
59
+ "succeeded": [s.model_dump() if hasattr(s, "model_dump") else s for s in successes],
60
+ "failed": [
61
+ {"key": k, "error": e.to_block().model_dump()}
62
+ for k, e in failures
63
+ ],
64
+ }
65
+ env = Envelope(ok=not failures, data=data, meta=Meta(count=len(successes) + len(failures)))
66
+ if human:
67
+ sys.stdout.write(json.dumps(env.model_dump(), indent=2, ensure_ascii=False))
68
+ else:
69
+ sys.stdout.write(env.to_json())
70
+ sys.stdout.write("\n")
71
+ sys.stdout.flush()
zop/core/errors.py ADDED
@@ -0,0 +1,92 @@
1
+ """Error types and structured Result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass, field
7
+ from typing import TypeVar
8
+
9
+ from zop.models.envelope import ErrorBlock
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ class ZopError(Exception):
15
+ """Base error for all zop errors."""
16
+
17
+ code: str = "zop_error"
18
+ retryable: bool = False
19
+ hint: str | None = None
20
+
21
+ def __init__(self, message: str, *, hint: str | None = None) -> None:
22
+ super().__init__(message)
23
+ self.message = message
24
+ if hint is not None:
25
+ self.hint = hint
26
+
27
+ def to_block(self) -> ErrorBlock:
28
+ return ErrorBlock(
29
+ code=self.code,
30
+ message=self.message,
31
+ hint=self.hint,
32
+ retryable=self.retryable,
33
+ )
34
+
35
+
36
+ class AuthError(ZopError):
37
+ code = "auth_missing"
38
+ hint = "Run 'zop config init' or set ZOP_LIBRARY_ID + ZOP_API_KEY env vars"
39
+
40
+
41
+ class NotFoundError(ZopError):
42
+ code = "not_found"
43
+
44
+
45
+ class ConflictError(ZopError):
46
+ """Resource already exists, or operation would create a conflict."""
47
+
48
+ code = "conflict"
49
+
50
+
51
+ class ApiError(ZopError):
52
+ code = "api_error"
53
+ retryable = True
54
+
55
+ def __init__(self, status: int, message: str) -> None:
56
+ super().__init__(f"HTTP {status}: {message}")
57
+ self.status = status
58
+
59
+
60
+ class ValidationError(ZopError):
61
+ code = "validation_error"
62
+
63
+
64
+ class NetworkError(ZopError):
65
+ code = "network_error"
66
+ retryable = True
67
+
68
+
69
+ @dataclass
70
+ class BatchResult[T]:
71
+ """Result of a batch operation: successes + per-item failures."""
72
+
73
+ successes: list[T] = field(default_factory=list)
74
+ failures: list[tuple[str, ErrorBlock]] = field(default_factory=list) # (key, error)
75
+
76
+ @property
77
+ def ok(self) -> bool:
78
+ return not self.failures
79
+
80
+ @property
81
+ def count(self) -> int:
82
+ return len(self.successes) + len(self.failures)
83
+
84
+ def add_failure(self, key: str, err: ZopError) -> None:
85
+ self.failures.append((key, err.to_block()))
86
+
87
+ def merge(self, other: BatchResult[T]) -> None:
88
+ self.successes.extend(other.successes)
89
+ self.failures.extend(other.failures)
90
+
91
+ def iter_failures(self) -> Iterable[tuple[str, ErrorBlock]]:
92
+ return iter(self.failures)
zop/models/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """Data models (pydantic v2)."""
2
+
3
+ from zop.models.collection import Collection, CollectionSummary, CollectionTree
4
+ from zop.models.common import ID_PATTERN, ItemType
5
+ from zop.models.item import Item, ItemSummary
6
+
7
+ __all__ = [
8
+ "ID_PATTERN",
9
+ "Collection",
10
+ "CollectionSummary",
11
+ "CollectionTree",
12
+ "Item",
13
+ "ItemSummary",
14
+ "ItemType",
15
+ ]
@@ -0,0 +1,45 @@
1
+ """Collection models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from zop.models.common import ID_PATTERN
10
+
11
+
12
+ class CollectionSummary(BaseModel):
13
+ """Minimal collection info (saves tokens for list views)."""
14
+
15
+ model_config = ConfigDict(frozen=True)
16
+
17
+ key: str = Field(pattern=ID_PATTERN)
18
+ name: str
19
+ parent_key: str | None = Field(default=None, pattern=ID_PATTERN)
20
+
21
+
22
+ class Collection(CollectionSummary):
23
+ """Full collection info."""
24
+
25
+ version: int = 0
26
+ item_count: int = 0
27
+ synced: bool = True
28
+
29
+
30
+ class CollectionTree(BaseModel):
31
+ """Collection as a node in a parent/child tree."""
32
+
33
+ model_config = ConfigDict(frozen=True)
34
+
35
+ key: str = Field(pattern=ID_PATTERN)
36
+ name: str
37
+ parent_key: str | None = Field(default=None, pattern=ID_PATTERN)
38
+ item_count: int = 0
39
+ children: list[CollectionTree] = Field(default_factory=list)
40
+
41
+ def walk(self) -> Iterator[CollectionTree]:
42
+ """Pre-order traversal (self before descendants)."""
43
+ yield self
44
+ for child in self.children:
45
+ yield from child.walk()
zop/models/common.py ADDED
@@ -0,0 +1,30 @@
1
+ """Common model types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ # Zotero item keys / collection keys are 8-char alphanumeric
8
+ ID_PATTERN = r"^[A-Z0-9]{8}$"
9
+
10
+
11
+ class ItemType(StrEnum):
12
+ """Subset of Zotero item types (extend as needed)."""
13
+
14
+ BOOK = "book"
15
+ BOOK_SECTION = "bookSection"
16
+ JOURNAL_ARTICLE = "journalArticle"
17
+ CONFERENCE_PAPER = "conferencePaper"
18
+ PREPRINT = "preprint"
19
+ REPORT = "report"
20
+ DOCUMENT = "document"
21
+ DATASET = "dataset"
22
+ WEBPAGE = "webpage"
23
+ COMPUTER_PROGRAM = "computerProgram"
24
+ THESIS = "thesis"
25
+ MANUSCRIPT = "manuscript"
26
+ UNKNOWN = "unknown"
27
+
28
+ @classmethod
29
+ def _missing_(cls, value: object) -> ItemType:
30
+ return cls.UNKNOWN
zop/models/envelope.py ADDED
@@ -0,0 +1,58 @@
1
+ """JSON envelope for all CLI output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+ from zop import __version__
11
+
12
+
13
+ class Meta(BaseModel):
14
+ """Response metadata."""
15
+
16
+ model_config = ConfigDict(frozen=True)
17
+
18
+ cli_version: str = __version__
19
+ request_id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
20
+ latency_ms: int = 0
21
+ count: int | None = None
22
+ extra: dict[str, Any] = Field(default_factory=dict)
23
+
24
+
25
+ class ErrorBlock(BaseModel):
26
+ """Error information block."""
27
+
28
+ model_config = ConfigDict(frozen=True)
29
+
30
+ code: str
31
+ message: str
32
+ hint: str | None = None
33
+ retryable: bool = False
34
+
35
+
36
+ class Envelope(BaseModel):
37
+ """Top-level response envelope.
38
+
39
+ All CLI output (success or error) is wrapped in this structure so that
40
+ agents can rely on a stable shape.
41
+ """
42
+
43
+ model_config = ConfigDict(frozen=True)
44
+
45
+ ok: bool
46
+ data: Any = None
47
+ error: ErrorBlock | None = None
48
+ meta: Meta = Field(default_factory=Meta)
49
+
50
+ def to_json(self) -> str:
51
+ """Serialize to JSON string (compact, deterministic)."""
52
+ return self.model_dump_json(exclude_none=True)
53
+
54
+ def to_human(self) -> str:
55
+ """Serialize to human-readable text (best-effort)."""
56
+ if not self.ok and self.error:
57
+ return f"Error [{self.error.code}]: {self.error.message}"
58
+ return self.model_dump_json(indent=2, exclude_none=True)
zop/models/item.py ADDED
@@ -0,0 +1,34 @@
1
+ """Item models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from zop.models.common import ID_PATTERN, ItemType
8
+
9
+
10
+ class ItemSummary(BaseModel):
11
+ """Minimal item info (used for list views)."""
12
+
13
+ model_config = ConfigDict(frozen=True)
14
+
15
+ key: str = Field(pattern=ID_PATTERN)
16
+ item_type: ItemType
17
+ title: str
18
+ creators: list[str] = Field(default_factory=list) # "Last, First" strings
19
+ year: int | None = None
20
+ date: str | None = None # raw Zotero date string
21
+
22
+
23
+ class Item(ItemSummary):
24
+ """Full item metadata."""
25
+
26
+ abstract: str | None = None
27
+ doi: str | None = None
28
+ url: str | None = None
29
+ tags: list[str] = Field(default_factory=list)
30
+ collections: list[str] = Field(default_factory=list) # keys
31
+ version: int = 0
32
+ date_added: str | None = None
33
+ date_modified: str | None = None
34
+ extra: dict[str, str] = Field(default_factory=dict)
@@ -0,0 +1,19 @@
1
+ """Service layer: business orchestration."""
2
+
3
+ from zop.services.collections import CollectionsService
4
+ from zop.services.export import ExportService
5
+ from zop.services.items import ItemsService
6
+ from zop.services.library import LibraryService
7
+ from zop.services.notes import NotesService
8
+ from zop.services.pdf import PdfService
9
+ from zop.services.tags import TagsService
10
+
11
+ __all__ = [
12
+ "CollectionsService",
13
+ "ExportService",
14
+ "ItemsService",
15
+ "LibraryService",
16
+ "NotesService",
17
+ "PdfService",
18
+ "TagsService",
19
+ ]
@@ -0,0 +1,326 @@
1
+ """Collection service: business logic for collection operations.
2
+
3
+ This layer:
4
+ - Validates inputs
5
+ - Resolves collection names <-> keys
6
+ - Coordinates SQLite reads and Web API writes
7
+ - Aggregates per-item failures in batch ops
8
+ - Implements a real dry-run that checks existence + would-create conflicts
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ from zop.adapters.sqlite_reader import SqliteReader
19
+ from zop.adapters.zotero_api import ApiCreds, ZoteroApi
20
+ from zop.core.errors import (
21
+ AuthError,
22
+ NotFoundError,
23
+ ValidationError,
24
+ ZopError,
25
+ )
26
+ from zop.models.collection import Collection, CollectionTree
27
+
28
+
29
+ @dataclass
30
+ class PlanNode:
31
+ """One entry in a reorg plan.
32
+
33
+ `parent` is a NAME (resolved against current library state during validate).
34
+ `items` are item keys to assign to the new collection.
35
+ """
36
+
37
+ name: str
38
+ parent: str | None = None
39
+ items: list[str] = field(default_factory=list)
40
+ parent_key: str | None = None # set during validate()
41
+
42
+
43
+ @dataclass
44
+ class PlanReport:
45
+ """Result of validating a plan against current library state."""
46
+
47
+ to_create: list[PlanNode] = field(default_factory=list)
48
+ item_assignments: list[tuple[str, str]] = field(default_factory=list) # (item_key, coll_name)
49
+ conflicts: list[str] = field(default_factory=list)
50
+ unresolved_parents: list[str] = field(default_factory=list)
51
+
52
+ @property
53
+ def ok(self) -> bool:
54
+ return not self.conflicts and not self.unresolved_parents
55
+
56
+
57
+ class CollectionsService:
58
+ """High-level collection operations."""
59
+
60
+ def __init__(
61
+ self,
62
+ db_path: Path | str | None = None,
63
+ *,
64
+ creds: ApiCreds | None = None,
65
+ ) -> None:
66
+ if db_path is None:
67
+ raise ValidationError(
68
+ "db_path required (set data_dir in config or pass explicitly)"
69
+ )
70
+ self._db_path = Path(db_path)
71
+ self._creds = creds
72
+ self._reader = SqliteReader(self._db_path)
73
+
74
+ # ---- Read ----
75
+
76
+ def list_all(self) -> list[Collection]:
77
+ return self._reader.list_collections()
78
+
79
+ def list_tree(self) -> list[CollectionTree]:
80
+ return self._reader.build_tree()
81
+
82
+ def get(self, key: str) -> Collection:
83
+ return self._reader.get_collection(key)
84
+
85
+ def items(self, key: str) -> list[str]:
86
+ return [it.key for it in self._reader.list_collection_items(key)]
87
+
88
+ def resolve(self, name: str) -> Collection:
89
+ for c in self.list_all():
90
+ if c.name == name:
91
+ return c
92
+ raise NotFoundError(f"No collection named '{name}'")
93
+
94
+ # ---- Plan validation (real dry-run) ----
95
+
96
+ def validate_plan(self, plan: list[PlanNode]) -> PlanReport:
97
+ """Check a plan against current state.
98
+
99
+ Validates:
100
+ - No duplicate names (vs current library)
101
+ - All parent references resolve to existing collections OR to other
102
+ nodes in the plan (which will be created first; creation is
103
+ topologically ordered so parents are created before children)
104
+ - All item keys exist locally (best-effort; the API will catch missed ones)
105
+ """
106
+ existing = {c.name: c for c in self.list_all()}
107
+ report = PlanReport()
108
+ plan_by_name: dict[str, PlanNode] = {n.name: n for n in plan}
109
+
110
+ # First pass: name uniqueness + item existence
111
+ for node in plan:
112
+ if not node.name.strip():
113
+ report.conflicts.append("Empty collection name in plan")
114
+ continue
115
+ if node.name in existing:
116
+ report.conflicts.append(
117
+ f"Collection '{node.name}' already exists (key={existing[node.name].key})"
118
+ )
119
+ continue
120
+ # Defer to second pass for parent resolution
121
+
122
+ # Second pass: parent resolution + item checks (only on nodes that
123
+ # passed the uniqueness check)
124
+ for node in plan:
125
+ if any(node.name in c for c in report.conflicts if "already exists" in c):
126
+ continue # skip nodes that failed uniqueness
127
+ if not node.name.strip():
128
+ continue
129
+ if node.parent:
130
+ if node.parent in existing:
131
+ node.parent_key = existing[node.parent].key
132
+ elif node.parent in plan_by_name:
133
+ # Parent will be created in the same plan; mark deferred.
134
+ node.parent_key = "__PLAN_PARENT__" # resolved at exec time
135
+ else:
136
+ if node.parent not in report.unresolved_parents:
137
+ report.unresolved_parents.append(node.parent)
138
+ continue
139
+ report.to_create.append(node)
140
+
141
+ for item_key in node.items:
142
+ if not self._item_exists_locally(item_key):
143
+ report.conflicts.append(
144
+ f"Item '{item_key}' (for collection '{node.name}') not found locally"
145
+ )
146
+ else:
147
+ report.item_assignments.append((item_key, node.name))
148
+
149
+ return report
150
+
151
+ def _item_exists_locally(self, key: str) -> bool:
152
+ try:
153
+ with self._reader._connect() as con:
154
+ row = con.execute(
155
+ "SELECT 1 FROM items WHERE key = ? LIMIT 1", (key,)
156
+ ).fetchone()
157
+ return row is not None
158
+ except Exception:
159
+ return False
160
+
161
+ # ---- Write (requires API credentials) ----
162
+
163
+ def _require_api(self) -> ZoteroApi:
164
+ if not self._creds or not self._creds.api_key:
165
+ raise AuthError("API credentials required for write operations")
166
+ return ZoteroApi(self._creds)
167
+
168
+ async def create(
169
+ self,
170
+ name: str,
171
+ *,
172
+ parent: str | None = None,
173
+ ) -> Collection:
174
+ """Create one collection. `parent` is the parent NAME."""
175
+ api = self._require_api()
176
+ payload: dict[str, object] = {"name": name}
177
+ if parent:
178
+ p = self.resolve(parent)
179
+ payload["parentCollection"] = p.key
180
+ async with api:
181
+ result = await api.create_collections([payload])
182
+ if not result:
183
+ raise ZopError(f"Failed to create '{name}' (empty response)")
184
+ r = result[0]
185
+ return Collection(
186
+ key=r["key"],
187
+ name=r["data"]["name"],
188
+ parent_key=r["data"].get("parentCollection") or None,
189
+ version=r["version"],
190
+ )
191
+
192
+ async def create_many(self, plan: list[PlanNode]) -> list[Collection]:
193
+ """Create all collections in a validated plan (batched POST, topologically ordered).
194
+
195
+ Handles intra-plan parent references: a node whose parent is another
196
+ node in the plan is created after its parent. We process in waves
197
+ (Kahn-style topological order), POSTing each wave as a batch.
198
+ """
199
+ report = self.validate_plan(plan)
200
+ if not report.ok:
201
+ raise ValidationError(
202
+ f"Plan invalid. conflicts={report.conflicts} unresolved={report.unresolved_parents}"
203
+ )
204
+
205
+ plan_by_name = {n.name: n for n in report.to_create}
206
+ # Build dependency: who depends on whom
207
+ # remaining[node] = set of node-names this node still waits on
208
+ remaining: dict[str, set[str]] = {}
209
+ for n in report.to_create:
210
+ if n.parent_key == "__PLAN_PARENT__" and n.parent in plan_by_name:
211
+ remaining[n.name] = {n.parent}
212
+ else:
213
+ remaining[n.name] = set()
214
+
215
+ api = self._require_api()
216
+ created: dict[str, Collection] = {} # name -> Collection
217
+ async with api:
218
+ while remaining:
219
+ # Find nodes with no remaining dependencies
220
+ ready = [n for n in report.to_create if n.name in remaining and not remaining[n.name]]
221
+ if not ready:
222
+ raise ValidationError("Plan has a cycle in parent references")
223
+ payloads: list[dict[str, object]] = []
224
+ for n in ready:
225
+ parent_key: str | None = None
226
+ if n.parent:
227
+ if n.parent in created:
228
+ parent_key = created[n.parent].key
229
+ elif n.parent in plan_by_name and n.parent_key == "__PLAN_PARENT__":
230
+ # Should have been picked up already by topo sort
231
+ raise ValidationError(
232
+ f"Parent '{n.parent}' not yet created for '{n.name}'"
233
+ )
234
+ else:
235
+ # Use the resolved key from validate_plan (existing parent)
236
+ parent_key = n.parent_key
237
+ payloads.append(
238
+ {"name": n.name, "parentCollection": parent_key}
239
+ )
240
+ results = await api.create_collections(payloads)
241
+ if len(results) != len(ready):
242
+ raise ZopError(
243
+ f"API returned {len(results)} collections, expected {len(ready)}"
244
+ )
245
+ for n, r in zip(ready, results, strict=True):
246
+ c = Collection(
247
+ key=r["key"],
248
+ name=r["data"]["name"],
249
+ parent_key=r["data"].get("parentCollection") or None,
250
+ version=r["version"],
251
+ )
252
+ created[n.name] = c
253
+ # Remove processed nodes from remaining, decrement dependents
254
+ for n in ready:
255
+ del remaining[n.name]
256
+ for deps in remaining.values():
257
+ deps.difference_update({n.name for n in ready})
258
+
259
+ return list(created.values())
260
+
261
+ async def delete(self, key: str) -> None:
262
+ api = self._require_api()
263
+ async with api:
264
+ await api.delete_collection(key)
265
+
266
+ async def reparent(
267
+ self, key: str, new_parent: str | None, *, version: int | None = None
268
+ ) -> Collection:
269
+ """Move collection under new parent (NAME), or detach if new_parent is None."""
270
+
271
+ api = self._require_api()
272
+ if new_parent is None:
273
+ parent_key: str | None | object = False # detach
274
+ else:
275
+ parent_key = self.resolve(new_parent).key
276
+ async with api:
277
+ r = await api.update_collection(key, parent_key=parent_key, version=version)
278
+ return Collection(
279
+ key=r["key"],
280
+ name=r["data"]["name"],
281
+ parent_key=r["data"].get("parentCollection") or None,
282
+ version=r["version"],
283
+ )
284
+
285
+ async def move_items(
286
+ self,
287
+ item_keys: Sequence[str],
288
+ to_collection_key: str,
289
+ ) -> tuple[list[str], list[tuple[str, Exception]]]:
290
+ """Add items to a collection (preserves existing memberships).
291
+
292
+ Fetches each item's current collections, adds the target, then
293
+ bounded-concurrent PATCH. Per-item failures are isolated.
294
+ """
295
+ api = self._require_api()
296
+ async with api:
297
+ # Fetch current state + version per item.
298
+ async def _fetch(k: str) -> tuple[str, list[str], int] | tuple[str, Exception]:
299
+ try:
300
+ item = await api.get_item(k)
301
+ colls = list(item["data"].get("collections", []))
302
+ if to_collection_key not in colls:
303
+ colls = [*colls, to_collection_key]
304
+ return (k, colls, item["version"])
305
+ except Exception as e:
306
+ return (k, e)
307
+
308
+ fetched = await asyncio.gather(
309
+ *[_fetch(k) for k in item_keys], return_exceptions=False
310
+ )
311
+
312
+ updates: list[tuple[str, list[str], int | None]] = []
313
+ fetch_failures: list[tuple[str, Exception]] = []
314
+ for r in fetched:
315
+ if len(r) == 3 and isinstance(r[1], list):
316
+ _, colls, ver = r
317
+ updates.append((r[0], colls, ver))
318
+ else:
319
+ fetch_failures.append((r[0], r[1]))
320
+
321
+ ok, move_failures = await api.batch_update_item_collections(updates)
322
+
323
+ return ok, fetch_failures + move_failures
324
+
325
+
326
+ __all__ = ["CollectionsService", "PlanNode", "PlanReport"]