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.
@@ -0,0 +1,315 @@
1
+ """Async Zotero Web API client (batch-capable).
2
+
3
+ Differences from pyzotero-based tools (e.g. zot):
4
+ - Real batch POST for collection creation
5
+ - Bounded-concurrency PATCH for item moves (pyzotero's addto_collection is single-item per call)
6
+ - True reparent via PATCH /collections/{key} with parentCollection=false (pyzotero doesn't expose this)
7
+ - Per-item error isolation: one failure doesn't abort the batch
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass
15
+ from typing import Any, cast
16
+
17
+ import httpx
18
+
19
+ from zop.core.concurrency import chunked
20
+ from zop.core.errors import ApiError, AuthError, ConflictError, NotFoundError
21
+
22
+ _SENTINEL = object()
23
+
24
+
25
+ @dataclass
26
+ class ApiCreds:
27
+ library_id: str
28
+ api_key: str
29
+ library_type: str = "user" # "user" or "group"
30
+
31
+
32
+ class ZoteroApi:
33
+ """Thin async wrapper over the Zotero Web API."""
34
+
35
+ BASE_URL = "https://api.zotero.org"
36
+ USER_AGENT = "zop/0.1 (+https://github.com/anomalyco/zop)"
37
+
38
+ # API limits per Zotero docs
39
+ BATCH_WRITE_LIMIT = 50 # max objects per POST/PATCH
40
+ BATCH_READ_LIMIT = 100 # max items per GET
41
+
42
+ def __init__(
43
+ self,
44
+ creds: ApiCreds,
45
+ *,
46
+ concurrency: int = 8,
47
+ timeout: float = 30.0,
48
+ transport: httpx.AsyncBaseTransport | None = None,
49
+ ) -> None:
50
+ if not creds.library_id or not creds.api_key:
51
+ raise AuthError("library_id and api_key are required")
52
+ self.creds = creds
53
+ self.concurrency = concurrency
54
+ self._client = httpx.AsyncClient(
55
+ base_url=self.BASE_URL,
56
+ headers={
57
+ "Zotero-API-Key": creds.api_key,
58
+ "Zotero-API-Version": "3",
59
+ "User-Agent": self.USER_AGENT,
60
+ },
61
+ timeout=timeout,
62
+ transport=transport,
63
+ )
64
+
65
+ async def __aenter__(self) -> ZoteroApi:
66
+ return self
67
+
68
+ async def __aexit__(self, *exc: object) -> None:
69
+ await self.close()
70
+
71
+ async def close(self) -> None:
72
+ await self._client.aclose()
73
+
74
+ # ---- URL helpers ----
75
+
76
+ def _root(self) -> str:
77
+ return f"/{self.creds.library_type}s/{self.creds.library_id}"
78
+
79
+ def _coll_url(self, key: str | None = None) -> str:
80
+ base = f"{self._root()}/collections"
81
+ return f"{base}/{key}" if key else base
82
+
83
+ def _items_url(self, key: str | None = None) -> str:
84
+ base = f"{self._root()}/items"
85
+ return f"{base}/{key}" if key else base
86
+
87
+ # ---- Response handling ----
88
+
89
+ def _check(self, resp: httpx.Response) -> Any:
90
+ """Parse response or raise structured error."""
91
+ if resp.status_code == 404:
92
+ raise NotFoundError(f"Not found: {resp.url}")
93
+ if resp.status_code in (409, 412):
94
+ raise ConflictError(f"Conflict (HTTP {resp.status_code}): {resp.text[:200]}")
95
+ if resp.status_code in (401, 403):
96
+ raise AuthError(f"Auth failed (HTTP {resp.status_code})")
97
+ if resp.status_code >= 400:
98
+ raise ApiError(resp.status_code, resp.text[:200])
99
+ if not resp.content:
100
+ return None
101
+ return resp.json()
102
+
103
+ # ---- Collections ----
104
+
105
+ async def list_collections(self, *, limit: int = 100) -> list[dict[str, Any]]:
106
+ """Fetch all collections for the library (paginated)."""
107
+ out: list[dict[str, Any]] = []
108
+ start = 0
109
+ while True:
110
+ resp = await self._client.get(
111
+ self._coll_url(),
112
+ params={"limit": min(limit, self.BATCH_READ_LIMIT), "start": start},
113
+ )
114
+ data = self._check(resp)
115
+ if not data:
116
+ break
117
+ out.extend(data)
118
+ if len(data) < self.BATCH_READ_LIMIT:
119
+ break
120
+ start += len(data)
121
+ return out
122
+
123
+ async def create_collections(
124
+ self, payloads: Sequence[dict[str, Any]]
125
+ ) -> list[dict[str, Any]]:
126
+ """Create up to BATCH_WRITE_LIMIT collections per request.
127
+
128
+ Returns the created collection objects (each with `key`, `version`, etc.)
129
+ in the same order as the input. The Zotero API returns:
130
+ `{"successful": {...}, "unchanged": {...}, "failed": {...}, "success": {...}}`
131
+ """
132
+ if not payloads:
133
+ return []
134
+ results: list[dict[str, Any]] = []
135
+ for batch in chunked(list(payloads), self.BATCH_WRITE_LIMIT):
136
+ resp = await self._client.post(self._coll_url(), json=list(batch))
137
+ data = self._check(resp)
138
+ if isinstance(data, dict):
139
+ # data has keys: successful (dict by index), unchanged, failed, success
140
+ # Reconstruct ordered list of created collections.
141
+ created = data.get("successful", {})
142
+ if isinstance(created, dict):
143
+ results.extend(created.values())
144
+ else:
145
+ results.extend(created)
146
+ elif isinstance(data, list):
147
+ results.extend(data)
148
+ return results
149
+
150
+ async def update_collection(
151
+ self,
152
+ key: str,
153
+ *,
154
+ name: str | None = None,
155
+ parent_key: str | None | object = _SENTINEL,
156
+ version: int | None = None,
157
+ ) -> dict[str, Any]:
158
+ """Update a collection.
159
+
160
+ Args:
161
+ key: Collection key.
162
+ name: New name (optional).
163
+ parent_key: New parent key. Pass ``None`` or ``False`` to detach to
164
+ top-level. Default (sentinel) leaves parent unchanged.
165
+ version: If-Unmodified-Since-Version for optimistic locking.
166
+ """
167
+ payload: dict[str, Any] = {}
168
+ if name is not None:
169
+ payload["name"] = name
170
+ if parent_key is not _SENTINEL:
171
+ payload["parentCollection"] = parent_key if parent_key else False
172
+ headers: dict[str, str] = {}
173
+ if version is not None:
174
+ headers["If-Unmodified-Since-Version"] = str(version)
175
+ resp = await self._client.patch(self._coll_url(key), json=payload, headers=headers)
176
+ return cast("dict[str, Any]", self._check(resp))
177
+
178
+ async def delete_collection(self, key: str, *, version: int | None = None) -> None:
179
+ """Delete a collection. CASCADE: deletes all subcollections."""
180
+ headers: dict[str, str] = {}
181
+ if version is not None:
182
+ headers["If-Unmodified-Since-Version"] = str(version)
183
+ resp = await self._client.delete(self._coll_url(key), headers=headers)
184
+ self._check(resp)
185
+
186
+ # ---- Items ----
187
+
188
+ async def get_item(self, key: str) -> dict[str, Any]:
189
+ resp = await self._client.get(self._items_url(key))
190
+ return cast("dict[str, Any]", self._check(resp))
191
+
192
+ async def update_item_collections(
193
+ self, key: str, collections: list[str], *, version: int | None = None
194
+ ) -> dict[str, Any]:
195
+ """Set an item's collection membership (replaces existing)."""
196
+ headers: dict[str, str] = {}
197
+ if version is not None:
198
+ headers["If-Unmodified-Since-Version"] = str(version)
199
+ resp = await self._client.patch(
200
+ self._items_url(key), json={"collections": collections}, headers=headers
201
+ )
202
+ return cast("dict[str, Any]", self._check(resp))
203
+
204
+ async def batch_update_item_collections(
205
+ self,
206
+ updates: Sequence[tuple[str, list[str], int | None]],
207
+ *,
208
+ concurrency: int | None = None,
209
+ ) -> tuple[list[str], list[tuple[str, Exception]]]:
210
+ """Move many items concurrently with bounded parallelism.
211
+
212
+ Args:
213
+ updates: Sequence of ``(item_key, target_collections, version)``.
214
+ concurrency: Override default concurrency.
215
+
216
+ Returns:
217
+ ``(success_keys, failures)`` where ``failures`` is a list of
218
+ ``(item_key, exception)`` pairs.
219
+ """
220
+ if not updates:
221
+ return [], []
222
+ sem = asyncio.Semaphore(concurrency or self.concurrency)
223
+
224
+ async def _one(item_key: str, colls: list[str], ver: int | None) -> str:
225
+ async with sem:
226
+ await self.update_item_collections(item_key, colls, version=ver)
227
+ return item_key
228
+
229
+ results = await asyncio.gather(
230
+ *(_one(k, c, v) for k, c, v in updates), return_exceptions=True
231
+ )
232
+ successes: list[str] = []
233
+ failures: list[tuple[str, Exception]] = []
234
+ for (k, _, _), r in zip(updates, results, strict=True):
235
+ if isinstance(r, Exception):
236
+ failures.append((k, r))
237
+ else:
238
+ successes.append(cast(str, r))
239
+ return successes, failures
240
+
241
+ async def update_item(
242
+ self,
243
+ key: str,
244
+ data: dict[str, Any],
245
+ *,
246
+ version: int | None = None,
247
+ ) -> dict[str, Any]:
248
+ """Patch an item's fields.
249
+
250
+ Args:
251
+ key: Item key.
252
+ data: Partial item payload (e.g. ``{"tags": [...]}`` or
253
+ ``{"title": ...}``). Replaces the listed fields server-side.
254
+ version: ``If-Unmodified-Since-Version`` for optimistic locking.
255
+
256
+ Returns:
257
+ The updated item dict, or an empty dict on an empty response.
258
+ """
259
+ headers: dict[str, str] = {}
260
+ if version is not None:
261
+ headers["If-Unmodified-Since-Version"] = str(version)
262
+ resp = await self._client.patch(self._items_url(key), json=data, headers=headers)
263
+ result = self._check(resp)
264
+ return result if isinstance(result, dict) else {}
265
+
266
+ async def delete_item(self, key: str, *, version: int | None = None) -> None:
267
+ """Delete an item.
268
+
269
+ Args:
270
+ key: Item key.
271
+ version: ``If-Unmodified-Since-Version``. Zotero requires this for
272
+ item DELETE in practice.
273
+ """
274
+ headers: dict[str, str] = {}
275
+ if version is not None:
276
+ headers["If-Unmodified-Since-Version"] = str(version)
277
+ resp = await self._client.delete(self._items_url(key), headers=headers)
278
+ self._check(resp)
279
+
280
+ async def create_items(
281
+ self, payloads: Sequence[dict[str, Any]]
282
+ ) -> list[dict[str, Any]]:
283
+ """Create items via batch POST /items.
284
+
285
+ Sends payloads as a single POST body (list), chunked at
286
+ ``BATCH_WRITE_LIMIT``. Returns the created item dicts extracted from
287
+ the server's ``successful`` envelope (each contains ``key``,
288
+ ``version``, etc.), in the order the server reports them.
289
+
290
+ Args:
291
+ payloads: Item template dicts (e.g.
292
+ ``[{"itemType": "journalArticle", "DOI": "..."}]``).
293
+
294
+ Returns:
295
+ List of created items. Empty if the server rejected all entries;
296
+ the caller decides whether empty means an error.
297
+ """
298
+ if not payloads:
299
+ return []
300
+ results: list[dict[str, Any]] = []
301
+ for batch in chunked(list(payloads), self.BATCH_WRITE_LIMIT):
302
+ resp = await self._client.post(self._items_url(), json=list(batch))
303
+ data = self._check(resp)
304
+ if isinstance(data, dict):
305
+ created = data.get("successful", {})
306
+ if isinstance(created, dict):
307
+ results.extend(created.values())
308
+ else:
309
+ results.extend(created)
310
+ elif isinstance(data, list):
311
+ results.extend(data)
312
+ return results
313
+
314
+
315
+ __all__ = ["ApiCreds", "ZoteroApi"]
zop/cli.py ADDED
@@ -0,0 +1,96 @@
1
+ """CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import NoReturn
7
+
8
+ import click
9
+
10
+ from zop import __version__
11
+ from zop.commands.collection import collection as collection_cmd
12
+ from zop.commands.export import export_cmd
13
+ from zop.commands.item import item as item_cmd
14
+ from zop.commands.library import duplicates_cmd, recent_cmd, stats_cmd
15
+ from zop.commands.note import note as note_cmd
16
+ from zop.commands.pdf import pdf as pdf_cmd
17
+ from zop.commands.tag import tag as tag_cmd
18
+
19
+
20
+ @click.group(
21
+ name="zop",
22
+ help="High-throughput Zotero CLI. Reads from local SQLite; writes via Web API.",
23
+ context_settings={"help_option_names": ["-h", "--help"]},
24
+ )
25
+ @click.version_option(__version__, "-V", "--version")
26
+ @click.option(
27
+ "--json",
28
+ "force_json",
29
+ is_flag=True,
30
+ default=False,
31
+ help="Force JSON envelope output.",
32
+ )
33
+ @click.option(
34
+ "--human",
35
+ "force_human",
36
+ is_flag=True,
37
+ default=False,
38
+ help="Force human-readable output.",
39
+ )
40
+ @click.option(
41
+ "-q",
42
+ "--quiet",
43
+ is_flag=True,
44
+ default=False,
45
+ help="Suppress non-essential output (errors only).",
46
+ )
47
+ @click.option(
48
+ "-v",
49
+ "--verbose",
50
+ count=True,
51
+ help="Increase verbosity (-v, -vv).",
52
+ )
53
+ @click.pass_context
54
+ def main(
55
+ ctx: click.Context,
56
+ force_json: bool,
57
+ force_human: bool,
58
+ quiet: bool,
59
+ verbose: int,
60
+ ) -> None:
61
+ """Entry point."""
62
+ if force_json and force_human:
63
+ raise click.UsageError("--json and --human are mutually exclusive")
64
+ if force_json:
65
+ human = False
66
+ elif force_human:
67
+ human = True
68
+ else:
69
+ human = sys.stdout.isatty()
70
+ ctx.ensure_object(dict)
71
+ ctx.obj["human"] = human
72
+ ctx.obj["quiet"] = quiet
73
+ ctx.obj["verbose"] = verbose
74
+
75
+
76
+ main.add_command(collection_cmd, name="collection")
77
+ main.add_command(item_cmd, name="item")
78
+ main.add_command(tag_cmd, name="tag")
79
+ main.add_command(note_cmd, name="note")
80
+ main.add_command(pdf_cmd, name="pdf")
81
+ main.add_command(export_cmd, name="export")
82
+ main.add_command(stats_cmd, name="stats")
83
+ main.add_command(recent_cmd, name="recent")
84
+ main.add_command(duplicates_cmd, name="duplicates")
85
+
86
+
87
+ def run() -> NoReturn:
88
+ """Entry point for setuptools/uv script wrapper."""
89
+ try:
90
+ main(standalone_mode=True)
91
+ except click.ClickException as exc:
92
+ exc.show()
93
+ sys.exit(exc.exit_code)
94
+ except click.exceptions.Abort:
95
+ sys.exit(1)
96
+ sys.exit(0)
@@ -0,0 +1,21 @@
1
+ """CLI command groups."""
2
+
3
+ from zop.commands.collection import collection
4
+ from zop.commands.export import export_cmd
5
+ from zop.commands.item import item
6
+ from zop.commands.library import duplicates_cmd, recent_cmd, stats_cmd
7
+ from zop.commands.note import note
8
+ from zop.commands.pdf import pdf
9
+ from zop.commands.tag import tag
10
+
11
+ __all__ = [
12
+ "collection",
13
+ "duplicates_cmd",
14
+ "export_cmd",
15
+ "item",
16
+ "note",
17
+ "pdf",
18
+ "recent_cmd",
19
+ "stats_cmd",
20
+ "tag",
21
+ ]
@@ -0,0 +1,221 @@
1
+ """Collection CLI subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from zop.adapters.zotero_api import ApiCreds
13
+ from zop.core.config import load_config
14
+ from zop.core.envelope import emit, emit_batch, emit_error
15
+ from zop.core.errors import ZopError
16
+ from zop.services.collections import CollectionsService, PlanNode
17
+
18
+
19
+ def _service(ctx: click.Context) -> CollectionsService:
20
+ cfg = load_config()
21
+ creds: ApiCreds | None = None
22
+ if cfg.has_write_credentials:
23
+ creds = ApiCreds(library_id=cfg.library_id, api_key=cfg.api_key)
24
+ if not cfg.data_dir:
25
+ raise click.UsageError(
26
+ "data_dir not configured. Run 'zop config init' or set in "
27
+ "~/.config/zop/config.toml"
28
+ )
29
+ return CollectionsService(db_path=Path(cfg.data_dir) / "zotero.sqlite", creds=creds)
30
+
31
+
32
+ def _human() -> bool:
33
+ return sys.stdout.isatty()
34
+
35
+
36
+ @click.group(name="collection")
37
+ def collection() -> None:
38
+ """Manage Zotero collections."""
39
+
40
+
41
+ @collection.command("list")
42
+ @click.option("--tree", is_flag=True, help="Show as parent/child tree.")
43
+ @click.option("--flat", is_flag=True, help="Show as flat list (default).")
44
+ @click.pass_context
45
+ def list_cmd(ctx: click.Context, tree: bool, flat: bool) -> None:
46
+ """List all collections."""
47
+ try:
48
+ svc = _service(ctx)
49
+ if tree:
50
+ nodes = svc.list_tree()
51
+ data = [n.model_dump() for n in nodes]
52
+ else:
53
+ data = [c.model_dump() for c in svc.list_all()]
54
+ emit(data, human=_human(), count=len(data))
55
+ except ZopError as e:
56
+ emit_error(e, human=_human())
57
+ sys.exit(1)
58
+
59
+
60
+ @collection.command("items")
61
+ @click.argument("key")
62
+ @click.pass_context
63
+ def items_cmd(ctx: click.Context, key: str) -> None:
64
+ """List item keys in a collection (by KEY)."""
65
+ try:
66
+ svc = _service(ctx)
67
+ data = svc.items(key)
68
+ emit(data, human=_human(), count=len(data))
69
+ except ZopError as e:
70
+ emit_error(e, human=_human())
71
+ sys.exit(1)
72
+
73
+
74
+ @collection.command("create")
75
+ @click.argument("name")
76
+ @click.option("--parent", "parent_name", default=None, help="Parent collection NAME.")
77
+ @click.pass_context
78
+ def create_cmd(ctx: click.Context, name: str, parent_name: str | None) -> None:
79
+ """Create a collection (NAME). Requires API key."""
80
+ try:
81
+ svc = _service(ctx)
82
+ result = asyncio.run(svc.create(name, parent=parent_name))
83
+ emit([result.model_dump()], human=_human(), count=1)
84
+ except ZopError as e:
85
+ emit_error(e, human=_human())
86
+ sys.exit(1)
87
+
88
+
89
+ @collection.command("delete")
90
+ @click.argument("key")
91
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
92
+ @click.pass_context
93
+ def delete_cmd(ctx: click.Context, key: str, yes: bool) -> None:
94
+ """Delete a collection (cascades to subcollections). Requires API key."""
95
+ if not yes:
96
+ click.confirm(f"Delete collection {key} (and all subcollections)?", abort=True)
97
+ try:
98
+ svc = _service(ctx)
99
+ asyncio.run(svc.delete(key))
100
+ emit({"deleted": key}, human=_human())
101
+ except ZopError as e:
102
+ emit_error(e, human=_human())
103
+ sys.exit(1)
104
+
105
+
106
+ @collection.command("reparent")
107
+ @click.argument("key")
108
+ @click.option("--parent", "parent_name", default=None, help="New parent NAME (omit for top-level).")
109
+ @click.pass_context
110
+ def reparent_cmd(ctx: click.Context, key: str, parent_name: str | None) -> None:
111
+ """Move a collection under a new parent (or to top-level). Requires API key."""
112
+ try:
113
+ svc = _service(ctx)
114
+ result = asyncio.run(svc.reparent(key, parent_name))
115
+ emit([result.model_dump()], human=_human(), count=1)
116
+ except ZopError as e:
117
+ emit_error(e, human=_human())
118
+ sys.exit(1)
119
+
120
+
121
+ @collection.command("move")
122
+ @click.argument("item_keys", nargs=-1, required=True)
123
+ @click.option("--to", "to_collection_key", required=True, help="Target collection KEY.")
124
+ @click.pass_context
125
+ def move_cmd(
126
+ ctx: click.Context,
127
+ item_keys: tuple[str, ...],
128
+ to_collection_key: str,
129
+ ) -> None:
130
+ """Move items into a collection. Bounded-concurrent PATCH."""
131
+ try:
132
+ svc = _service(ctx)
133
+ ok, fail = asyncio.run(svc.move_items(list(item_keys), to_collection_key))
134
+ emit_batch(
135
+ [{"key": k} for k in ok],
136
+ [(k, _wrapped(e)) for k, e in fail],
137
+ human=_human(),
138
+ )
139
+ if fail:
140
+ sys.exit(2)
141
+ except ZopError as e:
142
+ emit_error(e, human=_human())
143
+ sys.exit(1)
144
+
145
+
146
+ @collection.command("plan")
147
+ @click.argument("plan_file", type=click.Path(exists=True, path_type=Path))
148
+ @click.option("--dry-run", is_flag=True, help="Validate without executing.")
149
+ @click.option("--execute", "do_execute", is_flag=True, help="Actually execute.")
150
+ @click.pass_context
151
+ def plan_cmd(
152
+ ctx: click.Context,
153
+ plan_file: Path,
154
+ dry_run: bool,
155
+ do_execute: bool,
156
+ ) -> None:
157
+ """Batch reorg from a plan JSON file.
158
+
159
+ Plan format::
160
+
161
+ {
162
+ "collections": [
163
+ {"name": "Topic A", "parent": "ExistingParent", "items": ["KEY1", "KEY2"]},
164
+ {"name": "Topic B", "items": []}
165
+ ]
166
+ }
167
+ """
168
+ if dry_run == do_execute:
169
+ raise click.UsageError("Specify exactly one of --dry-run or --execute")
170
+ try:
171
+ with plan_file.open("r", encoding="utf-8") as f:
172
+ raw = json.load(f)
173
+ plan = [
174
+ PlanNode(
175
+ name=p["name"],
176
+ parent=p.get("parent"),
177
+ items=p.get("items", []),
178
+ )
179
+ for p in raw.get("collections", [])
180
+ ]
181
+ svc = _service(ctx)
182
+ report = svc.validate_plan(plan)
183
+ out = {
184
+ "to_create": [
185
+ {"name": n.name, "parent": n.parent, "items": n.items}
186
+ for n in report.to_create
187
+ ],
188
+ "assignments": [{"item": k, "collection": c} for k, c in report.item_assignments],
189
+ "conflicts": report.conflicts,
190
+ "unresolved_parents": report.unresolved_parents,
191
+ "ok": report.ok,
192
+ }
193
+ if dry_run:
194
+ emit(out, human=_human())
195
+ if not report.ok:
196
+ sys.exit(2)
197
+ return
198
+ # execute
199
+ if not report.ok:
200
+ emit(out, human=_human())
201
+ sys.exit(2)
202
+ created = asyncio.run(svc.create_many(plan))
203
+ emit(
204
+ {
205
+ "created": [c.model_dump() for c in created],
206
+ "assignments_pending": report.item_assignments,
207
+ },
208
+ human=_human(),
209
+ count=len(created),
210
+ )
211
+ # Note: item assignment to newly-created collections would go here
212
+ # in a follow-up step (separate API call after collections exist).
213
+ except ZopError as e:
214
+ emit_error(e, human=_human())
215
+ sys.exit(1)
216
+
217
+
218
+ def _wrapped(e: BaseException) -> ZopError:
219
+ if isinstance(e, ZopError):
220
+ return e
221
+ return ZopError(str(e))