zop-cli 0.2.1__tar.gz → 0.2.2__tar.gz

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.
Files changed (68) hide show
  1. {zop_cli-0.2.1 → zop_cli-0.2.2}/PKG-INFO +1 -1
  2. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/_version.py +1 -1
  3. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/adapters/zotero_api.py +7 -1
  4. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/services/collections.py +10 -5
  5. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_collections.py +34 -7
  6. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_zotero_api.py +18 -0
  7. {zop_cli-0.2.1 → zop_cli-0.2.2}/.gitattributes +0 -0
  8. {zop_cli-0.2.1 → zop_cli-0.2.2}/.github/workflows/ci.yml +0 -0
  9. {zop_cli-0.2.1 → zop_cli-0.2.2}/.gitignore +0 -0
  10. {zop_cli-0.2.1 → zop_cli-0.2.2}/CONTRIBUTING.md +0 -0
  11. {zop_cli-0.2.1 → zop_cli-0.2.2}/LICENSE +0 -0
  12. {zop_cli-0.2.1 → zop_cli-0.2.2}/README.md +0 -0
  13. {zop_cli-0.2.1 → zop_cli-0.2.2}/docs/ARCHITECTURE.md +0 -0
  14. {zop_cli-0.2.1 → zop_cli-0.2.2}/pyproject.toml +0 -0
  15. {zop_cli-0.2.1 → zop_cli-0.2.2}/skills/zop/README.md +0 -0
  16. {zop_cli-0.2.1 → zop_cli-0.2.2}/skills/zop/SKILL.md +0 -0
  17. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/__init__.py +0 -0
  18. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/__main__.py +0 -0
  19. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/adapters/__init__.py +0 -0
  20. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/adapters/sqlite_reader.py +0 -0
  21. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/cli.py +0 -0
  22. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/commands/__init__.py +0 -0
  23. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/commands/collection.py +0 -0
  24. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/commands/export.py +0 -0
  25. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/commands/item.py +0 -0
  26. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/commands/library.py +0 -0
  27. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/commands/note.py +0 -0
  28. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/commands/pdf.py +0 -0
  29. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/commands/tag.py +0 -0
  30. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/core/__init__.py +0 -0
  31. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/core/concurrency.py +0 -0
  32. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/core/config.py +0 -0
  33. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/core/envelope.py +0 -0
  34. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/core/errors.py +0 -0
  35. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/models/__init__.py +0 -0
  36. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/models/collection.py +0 -0
  37. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/models/common.py +0 -0
  38. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/models/envelope.py +0 -0
  39. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/models/item.py +0 -0
  40. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/services/__init__.py +0 -0
  41. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/services/export.py +0 -0
  42. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/services/items.py +0 -0
  43. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/services/library.py +0 -0
  44. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/services/notes.py +0 -0
  45. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/services/pdf.py +0 -0
  46. {zop_cli-0.2.1 → zop_cli-0.2.2}/src/zop/services/tags.py +0 -0
  47. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/__init__.py +0 -0
  48. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/conftest.py +0 -0
  49. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/fixtures/test_plan.json +0 -0
  50. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_cli_collection.py +0 -0
  51. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_cli_export.py +0 -0
  52. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_cli_item.py +0 -0
  53. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_cli_library.py +0 -0
  54. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_cli_main.py +0 -0
  55. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_cli_note.py +0 -0
  56. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_cli_plan.py +0 -0
  57. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_cli_tag.py +0 -0
  58. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_config.py +0 -0
  59. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_envelope.py +0 -0
  60. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_export.py +0 -0
  61. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_items_service.py +0 -0
  62. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_library.py +0 -0
  63. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_notes_service.py +0 -0
  64. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_pdf.py +0 -0
  65. {zop_cli-0.2.1 → zop_cli-0.2.2}/tests/test_tags_service.py +0 -0
  66. {zop_cli-0.2.1 → zop_cli-0.2.2}/tools/inspect_sqlite.py +0 -0
  67. {zop_cli-0.2.1 → zop_cli-0.2.2}/tools/inspect_tables.py +0 -0
  68. {zop_cli-0.2.1 → zop_cli-0.2.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zop-cli
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: High-throughput Zotero CLI focused on batch operations and automation
5
5
  Project-URL: Homepage, https://github.com/anomalyco/zop
6
6
  Project-URL: Issues, https://github.com/anomalyco/zop/issues
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.2.1"
5
+ __version__ = "0.2.2"
@@ -172,6 +172,11 @@ class ZoteroApi:
172
172
  parent_key: New parent key. Pass ``None`` or ``False`` to detach to
173
173
  top-level. Default (sentinel) leaves parent unchanged.
174
174
  version: If-Unmodified-Since-Version for optimistic locking.
175
+
176
+ Returns:
177
+ The updated collection dict, or an empty dict on an empty response.
178
+ Zotero answers single-object PATCHes with ``204 No Content``, so
179
+ callers must not subscript the result to read fields.
175
180
  """
176
181
  payload: dict[str, Any] = {}
177
182
  if name is not None:
@@ -182,7 +187,8 @@ class ZoteroApi:
182
187
  if version is not None:
183
188
  headers["If-Unmodified-Since-Version"] = str(version)
184
189
  resp = await self._client.patch(self._coll_url(key), json=payload, headers=headers)
185
- return cast("dict[str, Any]", self._check(resp))
190
+ result = self._check(resp)
191
+ return result if isinstance(result, dict) else {}
186
192
 
187
193
  async def delete_collection(self, key: str, *, version: int | None = None) -> None:
188
194
  """Delete a collection. CASCADE: deletes all subcollections."""
@@ -305,12 +305,17 @@ class CollectionsService:
305
305
  if version is None:
306
306
  current = await api.get_collection(key)
307
307
  version = current["version"]
308
- r = await api.update_collection(key, parent_key=parent_key, version=version)
308
+ # Zotero PATCH /collections/{key} returns 204 No Content (no body),
309
+ # so the patch response carries no fields to read. Re-read the
310
+ # collection to return its true updated state (new version, name,
311
+ # parent) — subscripting the patch response crashes (BUG-11).
312
+ await api.update_collection(key, parent_key=parent_key, version=version)
313
+ updated = await api.get_collection(key)
309
314
  return Collection(
310
- key=r["key"],
311
- name=r["data"]["name"],
312
- parent_key=r["data"].get("parentCollection") or None,
313
- version=r["version"],
315
+ key=updated["key"],
316
+ name=updated["data"]["name"],
317
+ parent_key=updated["data"].get("parentCollection") or None,
318
+ version=updated["version"],
314
319
  )
315
320
 
316
321
  async def move_items(
@@ -222,23 +222,50 @@ async def test_reparent_fetches_version_when_not_provided(
222
222
  monkeypatch: pytest.MonkeyPatch,
223
223
  ) -> None:
224
224
  fake_api.get_collection.return_value = {
225
- "key": "K",
225
+ "key": "KEY00001",
226
226
  "version": 5,
227
227
  "data": {"name": "n", "parentCollection": False},
228
228
  }
229
- fake_api.update_collection.return_value = {
229
+ fake_api.update_collection.return_value = {} # 204 No Content
230
+ monkeypatch.setattr(CollectionsService, "_require_api", lambda self: fake_api)
231
+ svc = CollectionsService(db_path=fake_db, creds=creds)
232
+
233
+ await svc.reparent("KEY00001", None) # detach to top-level
234
+
235
+ # get_collection called twice: once to read the version, once to re-read
236
+ # the updated state (PATCH /collections/{key} returns no body — BUG-11).
237
+ assert fake_api.get_collection.await_count == 2
238
+ assert all(c.args == ("KEY00001",) for c in fake_api.get_collection.call_args_list)
239
+ assert fake_api.update_collection.await_count == 1
240
+ assert fake_api.update_collection.call_args.kwargs["version"] == 5
241
+
242
+
243
+ async def test_reparent_returns_updated_state_after_empty_patch(
244
+ fake_db: Path,
245
+ creds: ApiCreds,
246
+ fake_api: AsyncMock,
247
+ monkeypatch: pytest.MonkeyPatch,
248
+ ) -> None:
249
+ """BUG-11: PATCH /collections/{key} returns 204 No Content → update_collection
250
+ yields {}. reparent must NOT subscript the patch response; it re-reads the
251
+ collection to build the result, so it returns a Collection instead of
252
+ raising ``TypeError: 'NoneType' object is not subscriptable``.
253
+ """
254
+ fake_api.update_collection.return_value = {} # real 204 behavior
255
+ fake_api.get_collection.return_value = {
230
256
  "key": "KEY00001",
231
257
  "version": 6,
232
- "data": {"name": "n", "parentCollection": False},
258
+ "data": {"name": "ExistingParent", "parentCollection": "PARENT01"},
233
259
  }
234
260
  monkeypatch.setattr(CollectionsService, "_require_api", lambda self: fake_api)
235
261
  svc = CollectionsService(db_path=fake_db, creds=creds)
236
262
 
237
- await svc.reparent("K", None) # detach to top-level
263
+ # "ExistingParent" resolves locally to PARENT01; reparent KEY00001 under it.
264
+ result = await svc.reparent("KEY00001", "ExistingParent")
238
265
 
239
- fake_api.get_collection.assert_awaited_once_with("K")
240
- assert fake_api.update_collection.await_count == 1
241
- assert fake_api.update_collection.call_args.kwargs["version"] == 5
266
+ assert result.key == "KEY00001"
267
+ assert result.parent_key == "PARENT01"
268
+ assert result.version == 6
242
269
 
243
270
 
244
271
  # ---- create: --parent accepts KEY + API fallback (BUG-7) ----
@@ -178,6 +178,24 @@ async def test_get_collection_gets_single_collection(creds: ApiCreds) -> None:
178
178
  assert result["version"] == 9
179
179
 
180
180
 
181
+ # ---- update_collection ----
182
+
183
+
184
+ async def test_update_collection_empty_body_returns_empty_dict(creds: ApiCreds) -> None:
185
+ """BUG-11: Zotero PATCH /collections/{key} returns 204 No Content (empty
186
+ body). update_collection must yield {} (like update_item), not None — a
187
+ ``None`` return crashes any caller that subscripts the result (reparent).
188
+ """
189
+
190
+ def handler(request: httpx.Request) -> httpx.Response:
191
+ return httpx.Response(204) # no content
192
+
193
+ async with ZoteroApi(creds, transport=httpx.MockTransport(handler)) as api:
194
+ result = await api.update_collection("K", parent_key="P", version=5)
195
+
196
+ assert result == {}
197
+
198
+
181
199
  # ---- _check status-code mapping ----
182
200
 
183
201
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes