zop-cli 0.2.1__tar.gz → 0.2.3__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.3}/PKG-INFO +1 -1
  2. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/_version.py +1 -1
  3. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/adapters/sqlite_reader.py +15 -3
  4. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/adapters/zotero_api.py +7 -1
  5. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/commands/collection.py +3 -3
  6. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/services/collections.py +20 -10
  7. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_collections.py +61 -7
  8. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_library.py +22 -0
  9. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_zotero_api.py +18 -0
  10. {zop_cli-0.2.1 → zop_cli-0.2.3}/.gitattributes +0 -0
  11. {zop_cli-0.2.1 → zop_cli-0.2.3}/.github/workflows/ci.yml +0 -0
  12. {zop_cli-0.2.1 → zop_cli-0.2.3}/.gitignore +0 -0
  13. {zop_cli-0.2.1 → zop_cli-0.2.3}/CONTRIBUTING.md +0 -0
  14. {zop_cli-0.2.1 → zop_cli-0.2.3}/LICENSE +0 -0
  15. {zop_cli-0.2.1 → zop_cli-0.2.3}/README.md +0 -0
  16. {zop_cli-0.2.1 → zop_cli-0.2.3}/docs/ARCHITECTURE.md +0 -0
  17. {zop_cli-0.2.1 → zop_cli-0.2.3}/pyproject.toml +0 -0
  18. {zop_cli-0.2.1 → zop_cli-0.2.3}/skills/zop/README.md +0 -0
  19. {zop_cli-0.2.1 → zop_cli-0.2.3}/skills/zop/SKILL.md +0 -0
  20. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/__init__.py +0 -0
  21. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/__main__.py +0 -0
  22. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/adapters/__init__.py +0 -0
  23. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/cli.py +0 -0
  24. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/commands/__init__.py +0 -0
  25. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/commands/export.py +0 -0
  26. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/commands/item.py +0 -0
  27. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/commands/library.py +0 -0
  28. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/commands/note.py +0 -0
  29. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/commands/pdf.py +0 -0
  30. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/commands/tag.py +0 -0
  31. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/core/__init__.py +0 -0
  32. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/core/concurrency.py +0 -0
  33. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/core/config.py +0 -0
  34. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/core/envelope.py +0 -0
  35. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/core/errors.py +0 -0
  36. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/models/__init__.py +0 -0
  37. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/models/collection.py +0 -0
  38. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/models/common.py +0 -0
  39. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/models/envelope.py +0 -0
  40. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/models/item.py +0 -0
  41. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/services/__init__.py +0 -0
  42. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/services/export.py +0 -0
  43. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/services/items.py +0 -0
  44. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/services/library.py +0 -0
  45. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/services/notes.py +0 -0
  46. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/services/pdf.py +0 -0
  47. {zop_cli-0.2.1 → zop_cli-0.2.3}/src/zop/services/tags.py +0 -0
  48. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/__init__.py +0 -0
  49. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/conftest.py +0 -0
  50. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/fixtures/test_plan.json +0 -0
  51. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_cli_collection.py +0 -0
  52. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_cli_export.py +0 -0
  53. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_cli_item.py +0 -0
  54. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_cli_library.py +0 -0
  55. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_cli_main.py +0 -0
  56. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_cli_note.py +0 -0
  57. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_cli_plan.py +0 -0
  58. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_cli_tag.py +0 -0
  59. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_config.py +0 -0
  60. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_envelope.py +0 -0
  61. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_export.py +0 -0
  62. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_items_service.py +0 -0
  63. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_notes_service.py +0 -0
  64. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_pdf.py +0 -0
  65. {zop_cli-0.2.1 → zop_cli-0.2.3}/tests/test_tags_service.py +0 -0
  66. {zop_cli-0.2.1 → zop_cli-0.2.3}/tools/inspect_sqlite.py +0 -0
  67. {zop_cli-0.2.1 → zop_cli-0.2.3}/tools/inspect_tables.py +0 -0
  68. {zop_cli-0.2.1 → zop_cli-0.2.3}/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.3
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.3"
@@ -412,7 +412,7 @@ class SqliteReader:
412
412
  with self._connect() as con:
413
413
  rows = con.execute(
414
414
  """
415
- SELECT i.key, n.note, i.dateAdded, i.dateModified
415
+ SELECT i.key, n.note, i.dateAdded, i.dateModified, parent.key
416
416
  FROM itemNotes n
417
417
  JOIN items i ON i.itemID = n.itemID
418
418
  JOIN items parent ON parent.itemID = n.parentItemID
@@ -421,8 +421,20 @@ class SqliteReader:
421
421
  """,
422
422
  (item_key, library_id),
423
423
  ).fetchall()
424
- return [{"key": r[0], "note": r[1] or "", "date_added": str(r[2]) if r[2] else "",
425
- "date_modified": str(r[3]) if r[3] else ""} for r in rows if r[0]]
424
+ # parent_key: the parent item's key. The JOIN to `parent` already existed
425
+ # but the SELECT never read parent.key, so callers couldn't attribute a
426
+ # note to its item (#2).
427
+ return [
428
+ {
429
+ "key": r[0],
430
+ "note": r[1] or "",
431
+ "date_added": str(r[2]) if r[2] else "",
432
+ "date_modified": str(r[3]) if r[3] else "",
433
+ "parent_key": r[4],
434
+ }
435
+ for r in rows
436
+ if r[0]
437
+ ]
426
438
 
427
439
  def list_all_tags(self, library_id: int = 1) -> list[dict[str, int | str]]:
428
440
  with self._connect() as con:
@@ -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."""
@@ -106,13 +106,13 @@ def delete_cmd(ctx: click.Context, key: str, yes: bool) -> None:
106
106
 
107
107
  @collection.command("reparent")
108
108
  @click.argument("key")
109
- @click.option("--parent", "parent_name", default=None, help="New parent NAME (omit for top-level).")
109
+ @click.option("--parent", "parent_ref", default=None, help="New parent KEY or NAME (omit for top-level).")
110
110
  @click.pass_context
111
- def reparent_cmd(ctx: click.Context, key: str, parent_name: str | None) -> None:
111
+ def reparent_cmd(ctx: click.Context, key: str, parent_ref: str | None) -> None:
112
112
  """Move a collection under a new parent (or to top-level). Requires API key."""
113
113
  try:
114
114
  svc = _service(ctx)
115
- result = asyncio.run(svc.reparent(key, parent_name))
115
+ result = asyncio.run(svc.reparent(key, parent_ref))
116
116
  emit([result.model_dump()], human=_human(), count=1)
117
117
  except ZopError as e:
118
118
  emit_error(e, human=_human())
@@ -294,23 +294,33 @@ class CollectionsService:
294
294
  async def reparent(
295
295
  self, key: str, new_parent: str | None, *, version: int | None = None
296
296
  ) -> Collection:
297
- """Move collection under new parent (NAME), or detach if new_parent is None."""
297
+ """Move collection under a new parent (KEY or NAME), or detach if None.
298
298
 
299
+ ``new_parent`` may be a collection KEY (8-char) or NAME, resolved the same
300
+ way as ``create``'s ``--parent`` (KEY first, then local name, then the
301
+ Web API for a parent not yet in local SQLite). This matches SKILL.md's
302
+ ``--parent "KeyOrName"`` contract shared by all collection write commands.
303
+ """
299
304
  api = self._require_api()
300
- if new_parent is None:
301
- parent_key: str | None | object = False # detach
302
- else:
303
- parent_key = self.resolve(new_parent).key
304
305
  async with api:
306
+ if new_parent is None:
307
+ parent_key: str | None | object = False # detach to top-level
308
+ else:
309
+ parent_key = await self._resolve_parent(api, new_parent)
305
310
  if version is None:
306
311
  current = await api.get_collection(key)
307
312
  version = current["version"]
308
- r = await api.update_collection(key, parent_key=parent_key, version=version)
313
+ # Zotero PATCH /collections/{key} returns 204 No Content (no body),
314
+ # so the patch response carries no fields to read. Re-read the
315
+ # collection to return its true updated state (new version, name,
316
+ # parent) — subscripting the patch response crashes (BUG-11).
317
+ await api.update_collection(key, parent_key=parent_key, version=version)
318
+ updated = await api.get_collection(key)
309
319
  return Collection(
310
- key=r["key"],
311
- name=r["data"]["name"],
312
- parent_key=r["data"].get("parentCollection") or None,
313
- version=r["version"],
320
+ key=updated["key"],
321
+ name=updated["data"]["name"],
322
+ parent_key=updated["data"].get("parentCollection") or None,
323
+ version=updated["version"],
314
324
  )
315
325
 
316
326
  async def move_items(
@@ -222,23 +222,77 @@ 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
269
+
270
+
271
+ async def test_reparent_accepts_parent_key_directly(
272
+ fake_db: Path,
273
+ creds: ApiCreds,
274
+ fake_api: AsyncMock,
275
+ monkeypatch: pytest.MonkeyPatch,
276
+ ) -> None:
277
+ """#1: reparent --parent must accept an 8-char KEY (like create), not just a NAME.
278
+ Passing a KEY short-circuits name lookup — no list_collections scan. Regression
279
+ for ``reparent <KEY> --parent <父KEY>`` failing with ``No collection named '<KEY>'``.
280
+ """
281
+ fake_api.update_collection.return_value = {} # 204 No Content
282
+ fake_api.get_collection.return_value = {
283
+ "key": "KEY00001",
284
+ "version": 6,
285
+ "data": {"name": "child", "parentCollection": "PARENT01"},
286
+ }
287
+ monkeypatch.setattr(CollectionsService, "_require_api", lambda self: fake_api)
288
+ svc = CollectionsService(db_path=fake_db, creds=creds)
289
+
290
+ await svc.reparent("KEY00001", "PARENT01") # an 8-char KEY, not a NAME
291
+
292
+ # KEY path must not scan collection names via the API
293
+ fake_api.list_collections.assert_not_awaited()
294
+ # update_collection must receive the raw KEY as parent_key
295
+ assert fake_api.update_collection.call_args.kwargs["parent_key"] == "PARENT01"
242
296
 
243
297
 
244
298
  # ---- create: --parent accepts KEY + API fallback (BUG-7) ----
@@ -45,6 +45,7 @@ def fake_db(tmp_path: Path) -> Iterator[Path]:
45
45
  CREATE TABLE fields (fieldID INTEGER PRIMARY KEY, fieldName TEXT);
46
46
  CREATE TABLE itemCreators (itemID INT, creatorID INT, orderIndex INT);
47
47
  CREATE TABLE creators (creatorID INTEGER PRIMARY KEY, firstName TEXT, lastName TEXT);
48
+ CREATE TABLE itemNotes (itemID INT, parentItemID INT, note TEXT);
48
49
 
49
50
  -- Deliberately non-standard itemTypeID mapping (exposes BUG-1).
50
51
  INSERT INTO itemTypes VALUES
@@ -73,6 +74,11 @@ def fake_db(tmp_path: Path) -> Iterator[Path]:
73
74
  INSERT INTO tags VALUES (1, 'cs.AI'), (2, 'to-read');
74
75
  INSERT INTO collections VALUES (1, 1, 'COLL0001');
75
76
 
77
+ -- A child note item (itemID 10) attached to JOURN001 (itemID 1) — for #2.
78
+ INSERT INTO items (itemID, itemTypeID, dateAdded, dateModified, libraryID, key) VALUES
79
+ (10, 28, datetime('now','-1 days'), datetime('now'), 1, 'NOTEX001');
80
+ INSERT INTO itemNotes VALUES (10, 1, 'A note on Paper One');
81
+
76
82
  -- Titles (for duplicates by title + recent display).
77
83
  INSERT INTO itemDataValues VALUES (10, 'Paper One');
78
84
  INSERT INTO itemData VALUES (1, 1, 10);
@@ -156,3 +162,19 @@ def test_get_item_populates_year(fake_db: Path) -> None:
156
162
  reader = SqliteReader(fake_db)
157
163
  item = reader.get_item("JOURN001")
158
164
  assert item.year == 2024
165
+
166
+
167
+ def test_get_item_notes_includes_parent_key(fake_db: Path) -> None:
168
+ """#2: note list must surface parent_key so callers can attribute a note to
169
+ its item. The JOIN to the parent item already exists; the SELECT must include
170
+ parent.key and the returned dict must carry it (not be silently absent).
171
+ """
172
+ reader = SqliteReader(fake_db)
173
+ notes = reader.get_item_notes("JOURN001")
174
+ assert len(notes) == 1
175
+ note = notes[0]
176
+ assert note["key"] == "NOTEX001"
177
+ assert note["note"] == "A note on Paper One"
178
+ assert note["parent_key"] == "JOURN001" # the fix
179
+ assert note["date_added"] # non-empty
180
+ assert note["date_modified"]
@@ -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