confpub-cli 0.7.0__tar.gz → 1.0.0__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 (49) hide show
  1. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/PKG-INFO +1 -1
  2. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/__init__.py +1 -1
  3. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/applier.py +4 -1
  4. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/cli.py +64 -6
  5. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/confluence.py +26 -16
  6. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/guide.py +2 -1
  7. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/lockfile.py +12 -0
  8. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/publish.py +13 -4
  9. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/puller.py +4 -4
  10. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_applier.py +36 -0
  11. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_confluence.py +18 -14
  12. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_integration.py +1 -1
  13. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_lockfile.py +33 -0
  14. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/.github/workflows/publish.yml +0 -0
  15. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/.gitignore +0 -0
  16. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/LICENSE +0 -0
  17. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/PRD.md +0 -0
  18. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/README.md +0 -0
  19. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/assets.py +0 -0
  20. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/config.py +0 -0
  21. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/converter.py +0 -0
  22. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/envelope.py +0 -0
  23. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/errors.py +0 -0
  24. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/manifest.py +0 -0
  25. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/output.py +0 -0
  26. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/planner.py +0 -0
  27. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/py.typed +0 -0
  28. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/reverse_converter.py +0 -0
  29. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/validator.py +0 -0
  30. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/verifier.py +0 -0
  31. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub.lock +0 -0
  32. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/pyproject.toml +0 -0
  33. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/__init__.py +0 -0
  34. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/conftest.py +0 -0
  35. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_assets.py +0 -0
  36. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_config.py +0 -0
  37. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_converter.py +0 -0
  38. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_envelope.py +0 -0
  39. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_errors.py +0 -0
  40. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_guide.py +0 -0
  41. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_manifest.py +0 -0
  42. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_output.py +0 -0
  43. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_planner.py +0 -0
  44. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_publish.py +0 -0
  45. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_puller.py +0 -0
  46. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_reverse_converter.py +0 -0
  47. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_validator.py +0 -0
  48. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_verifier.py +0 -0
  49. {confpub_cli-0.7.0 → confpub_cli-1.0.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confpub-cli
3
- Version: 0.7.0
3
+ Version: 1.0.0
4
4
  Summary: Agent-first CLI to publish Markdown to Confluence
5
5
  Project-URL: Homepage, https://github.com/ThomasRohde/confpub-cli
6
6
  Project-URL: Repository, https://github.com/ThomasRohde/confpub-cli.git
@@ -1,3 +1,3 @@
1
1
  """confpub — Agent-first CLI to publish Markdown to Confluence."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "1.0.0"
@@ -14,7 +14,7 @@ from typing import Any
14
14
  from confpub.assets import AssetRef, discover_assets, rewrite_image_urls, upload_assets
15
15
  from confpub.config import load_config
16
16
  from confpub.confluence import ConfluenceClient
17
- from confpub.converter import convert_markdown
17
+ from confpub.converter import convert_markdown, fingerprint_content
18
18
  from confpub.errors import ERR_CONFLICT_FINGERPRINT, ERR_IO_FILE_NOT_FOUND, ConfpubError
19
19
  from confpub.lockfile import Lockfile, load_lockfile, save_lockfile, update_lockfile
20
20
  from confpub.manifest import PlanArtifact
@@ -126,6 +126,7 @@ def apply_plan(
126
126
 
127
127
  # Update lockfile and parent tracking
128
128
  update_lockfile(lockfile, page.title, new_id, new_version if isinstance(new_version, int) else 1)
129
+ lockfile.pages[page.title].content_fingerprint = fingerprint_content(storage)
129
130
  parent_ids[page.title] = new_id
130
131
 
131
132
  counts["create"] += 1
@@ -180,6 +181,7 @@ def apply_plan(
180
181
  lockfile, page.title, page.confluence_page_id,
181
182
  new_version if isinstance(new_version, int) else 1,
182
183
  )
184
+ lockfile.pages[page.title].content_fingerprint = fingerprint_content(storage)
183
185
  parent_ids[page.title] = page.confluence_page_id
184
186
 
185
187
  counts["update"] += 1
@@ -193,4 +195,5 @@ def apply_plan(
193
195
  "dry_run": dry_run,
194
196
  "changes": changes,
195
197
  "summary": counts,
198
+ "lockfile_updated": not dry_run and len(changes) > 0,
196
199
  }
@@ -171,12 +171,14 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
171
171
  @page_app.command("list")
172
172
  def page_list(
173
173
  space: str = typer.Option(..., "--space", help="Confluence space key"),
174
+ limit: int = typer.Option(25, "--limit", help="Maximum number of pages to return"),
175
+ start: int = typer.Option(0, "--start", help="Starting offset for pagination"),
174
176
  ) -> None:
175
177
  """List pages in a Confluence space."""
176
178
  with command_context("page.list", target={"space": space}) as ctx:
177
179
  from confpub.confluence import build_client, _slim_page
178
180
  client = build_client()
179
- pages = client.list_pages(space)
181
+ pages = client.list_pages(space, start=start, limit=limit)
180
182
  ctx.result = {"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/")) for p in pages]}
181
183
 
182
184
 
@@ -231,15 +233,21 @@ def page_publish(
231
233
  """Publish a single Markdown file to Confluence."""
232
234
  from confpub.publish import derive_title
233
235
  resolved_title = derive_title(file, title)
234
- if not page_id and not parent:
235
- raise ConfpubError(
236
- "ERR_VALIDATION_REQUIRED",
237
- "Either --page-id or --parent is required",
238
- )
239
236
  target = {"space": space, "title": resolved_title, "file": file}
240
237
  if page_id:
241
238
  target["page_id"] = page_id
242
239
  with command_context("page.publish", target=target) as ctx:
240
+ from pathlib import Path as _Path
241
+ if not _Path(file).exists():
242
+ raise ConfpubError(
243
+ "ERR_IO_FILE_NOT_FOUND",
244
+ f"File not found: {file}",
245
+ )
246
+ if not page_id and not parent:
247
+ raise ConfpubError(
248
+ "ERR_VALIDATION_REQUIRED",
249
+ "Either --page-id or --parent is required",
250
+ )
243
251
  from confpub.publish import publish_page
244
252
  result = publish_page(
245
253
  file=file,
@@ -307,12 +315,54 @@ def page_delete(
307
315
  )
308
316
  from confpub.confluence import build_client
309
317
  client = build_client()
318
+
319
+ # Collect page IDs that will be deleted (for lockfile cleanup)
320
+ deleted_ids: set[str] = set()
310
321
  if page_id:
311
322
  if cascade:
323
+ # Collect descendant IDs before deleting
324
+ def _collect_ids(pid: str) -> None:
325
+ children = client.get_page_children(pid)
326
+ for child in children:
327
+ cid = str(child["id"])
328
+ deleted_ids.add(cid)
329
+ _collect_ids(cid)
330
+ _collect_ids(page_id)
312
331
  client._delete_descendants(page_id)
332
+ deleted_ids.add(page_id)
313
333
  result = client.delete_page(page_id)
314
334
  else:
335
+ if cascade:
336
+ page = client.get_page(space, title)
337
+ if page:
338
+ pid = str(page["id"])
339
+ def _collect_ids_by_title(p: str) -> None:
340
+ children = client.get_page_children(p)
341
+ for child in children:
342
+ cid = str(child["id"])
343
+ deleted_ids.add(cid)
344
+ _collect_ids_by_title(cid)
345
+ _collect_ids_by_title(pid)
346
+ deleted_ids.add(pid)
315
347
  result = client.delete_page_by_title(space, title, cascade=cascade)
348
+
349
+ # Clean up lockfile entries for deleted pages
350
+ from pathlib import Path
351
+ from confpub.lockfile import Lockfile, load_lockfile, save_lockfile, remove_from_lockfile
352
+ lockfile_path = Path.cwd() / "confpub.lock"
353
+ lockfile = load_lockfile(lockfile_path)
354
+ if lockfile:
355
+ removed = False
356
+ if title and not page_id:
357
+ removed = remove_from_lockfile(lockfile, title) or removed
358
+ # Remove entries matching any deleted page ID
359
+ for lf_title, entry in list(lockfile.pages.items()):
360
+ if entry.page_id in deleted_ids:
361
+ remove_from_lockfile(lockfile, lf_title)
362
+ removed = True
363
+ if removed:
364
+ save_lockfile(lockfile_path, lockfile)
365
+
316
366
  ctx.result = result
317
367
 
318
368
 
@@ -494,6 +544,10 @@ def search(
494
544
  excerpt_length=excerpt_length,
495
545
  )
496
546
  result["cql_query"] = effective_cql
547
+ if space and result.get("total", 0) == 0:
548
+ ctx.warnings.append(
549
+ f"No results found. Verify space '{space}' exists (use 'space list' to check)."
550
+ )
497
551
  ctx.result = result
498
552
 
499
553
 
@@ -546,3 +600,7 @@ def run() -> None:
546
600
  envelope = Envelope.failure("cli", [err])
547
601
  emit_stdout(envelope.to_json_bytes())
548
602
  sys.exit(10)
603
+ except ConfpubError as e:
604
+ envelope = Envelope.failure("cli", [e])
605
+ emit_stdout(envelope.to_json_bytes())
606
+ sys.exit(exit_code_for(e.code))
@@ -64,6 +64,7 @@ class ConfluenceClient:
64
64
  ERR_AUTH_FORBIDDEN,
65
65
  f"Permission denied: {msg}",
66
66
  suggested_action="escalate",
67
+ details={"note": "Confluence returns HTTP 403 for both forbidden and nonexistent resources. Verify the resource exists."},
67
68
  ) from exc
68
69
  if "timeout" in msg.lower() or "Timeout" in msg:
69
70
  raise ConfpubError(ERR_IO_TIMEOUT, f"Request timed out: {msg}") from exc
@@ -78,9 +79,9 @@ class ConfluenceClient:
78
79
  ) from exc
79
80
  # Not found (404 or explicit "not found")
80
81
  if "404" in msg or "not found" in msg.lower():
81
- from confpub.errors import ERR_IO_FILE_NOT_FOUND
82
+ from confpub.errors import ERR_VALIDATION_NOT_FOUND
82
83
  raise ConfpubError(
83
- ERR_IO_FILE_NOT_FOUND,
84
+ ERR_VALIDATION_NOT_FOUND,
84
85
  f"Resource not found ({context}): {msg}",
85
86
  ) from exc
86
87
  raise ConfpubError(ERR_INTERNAL_SDK, f"Unexpected API error ({context}): {msg}") from exc
@@ -175,10 +176,11 @@ class ConfluenceClient:
175
176
  """Delete a page by space and title."""
176
177
  page = self.get_page(space, title)
177
178
  if not page:
178
- from confpub.errors import ERR_IO_FILE_NOT_FOUND
179
+ from confpub.errors import ERR_VALIDATION_NOT_FOUND
179
180
  raise ConfpubError(
180
- ERR_IO_FILE_NOT_FOUND,
181
+ ERR_VALIDATION_NOT_FOUND,
181
182
  f"Page '{title}' not found in space '{space}'",
183
+ retryable=False,
182
184
  )
183
185
  page_id = str(page["id"])
184
186
  if cascade:
@@ -260,11 +262,13 @@ class ConfluenceClient:
260
262
  self._handle_error(exc, "list_spaces")
261
263
  return []
262
264
 
263
- def list_pages(self, space: str) -> list[dict[str, Any]]:
265
+ def list_pages(self, space: str, *, start: int = 0, limit: int = 25) -> list[dict[str, Any]]:
264
266
  """List pages in a space."""
265
267
  self._call_count += 1
266
268
  try:
267
- result = self._api.get_all_pages_from_space(space, expand="version")
269
+ result = self._api.get_all_pages_from_space(
270
+ space, start=start, limit=limit, expand="version",
271
+ )
268
272
  return result if isinstance(result, list) else []
269
273
  except Exception as exc:
270
274
  self._handle_error(exc, "list_pages")
@@ -338,6 +342,9 @@ class ConfluenceClient:
338
342
  try:
339
343
  result = self._api.attach_file(filepath, page_id=page_id)
340
344
  if isinstance(result, dict):
345
+ # API returns {"results": [...]} wrapper — extract the attachment
346
+ if "results" in result and isinstance(result["results"], list) and result["results"]:
347
+ return _slim_attachment(result["results"][0])
341
348
  return _slim_attachment(result)
342
349
  return {"uploaded": True, "file": filepath}
343
350
  except Exception as exc:
@@ -364,13 +371,13 @@ class ConfluenceClient:
364
371
  """
365
372
  self._call_count += 1
366
373
  try:
367
- raw = self._api.cql(
368
- cql,
369
- start=start,
370
- limit=limit,
371
- excerpt="highlight",
372
- include_archived_spaces=include_archived_spaces,
373
- )
374
+ raw = self._api.get("rest/api/search", params={
375
+ "cql": cql,
376
+ "start": start,
377
+ "limit": limit,
378
+ "excerpt": "highlight",
379
+ "includeArchivedSpaces": include_archived_spaces,
380
+ })
374
381
  except Exception as exc:
375
382
  msg = str(exc)
376
383
  if "400" in msg or "cannot be parsed" in msg.lower():
@@ -386,6 +393,9 @@ class ConfluenceClient:
386
393
  results_raw = raw.get("results", []) if isinstance(raw, dict) else []
387
394
  total = raw.get("totalSize", 0) if isinstance(raw, dict) else 0
388
395
 
396
+ api_start = raw.get("start", start) if isinstance(raw, dict) else start
397
+ api_limit = raw.get("limit", limit) if isinstance(raw, dict) else limit
398
+
389
399
  results = [
390
400
  _slim_search_result(r, base_url=base_url, excerpt_length=excerpt_length)
391
401
  for r in results_raw
@@ -393,9 +403,9 @@ class ConfluenceClient:
393
403
  return {
394
404
  "results": results,
395
405
  "total": total,
396
- "start": start,
397
- "limit": limit,
398
- "has_more": (start + limit) < total,
406
+ "start": api_start,
407
+ "limit": api_limit,
408
+ "has_more": (api_start + api_limit) < total,
399
409
  }
400
410
 
401
411
  # ------------------------------------------------------------------
@@ -86,7 +86,7 @@ def build_guide() -> dict[str, Any]:
86
86
  "group": "read",
87
87
  "mutates": False,
88
88
  "description": "List pages in a Confluence space",
89
- "flags": ["--space"],
89
+ "flags": ["--space", "--limit", "--start"],
90
90
  },
91
91
  "page.inspect": {
92
92
  "group": "read",
@@ -244,6 +244,7 @@ def build_guide() -> dict[str, Any]:
244
244
  },
245
245
  "behavior": [
246
246
  "Created/updated automatically by page.publish, page.pull, and plan.apply",
247
+ "Entries removed automatically by page.delete (including --cascade)",
247
248
  "Written atomically (temp file + rename) for crash safety",
248
249
  "Used by plan.create to detect existing pages and versions",
249
250
  "Does not prevent concurrent operations — purely local state tracking",
@@ -21,6 +21,7 @@ class LockPageEntry(BaseModel):
21
21
 
22
22
  page_id: str
23
23
  version: int = 1
24
+ content_fingerprint: str | None = None
24
25
 
25
26
 
26
27
  class Lockfile(BaseModel):
@@ -89,3 +90,14 @@ def update_lockfile(
89
90
  """Update a page entry in the lockfile."""
90
91
  lockfile.pages[title] = LockPageEntry(page_id=page_id, version=version)
91
92
  return lockfile
93
+
94
+
95
+ def remove_from_lockfile(lockfile: Lockfile, title: str) -> bool:
96
+ """Remove a page entry from the lockfile by title.
97
+
98
+ Returns True if the entry was found and removed, False otherwise.
99
+ """
100
+ if title in lockfile.pages:
101
+ del lockfile.pages[title]
102
+ return True
103
+ return False
@@ -109,9 +109,16 @@ def publish_page(
109
109
 
110
110
  # Detect noop — skip update when content is unchanged
111
111
  if operation == "update":
112
- remote_fingerprint = client.fingerprint_page(existing_page_id)
113
- if remote_fingerprint and remote_fingerprint == local_fingerprint:
114
- operation = "noop"
112
+ # Check lockfile fingerprint first (avoids Confluence normalization mismatch)
113
+ if page_title in lockfile.pages:
114
+ entry = lockfile.pages[page_title]
115
+ if entry.content_fingerprint and entry.content_fingerprint == local_fingerprint:
116
+ operation = "noop"
117
+ # Fallback: compare against remote fingerprint
118
+ if operation == "update":
119
+ remote_fingerprint = client.fingerprint_page(existing_page_id)
120
+ if remote_fingerprint and remote_fingerprint == local_fingerprint:
121
+ operation = "noop"
115
122
 
116
123
  if dry_run:
117
124
  change: dict[str, Any] = {
@@ -186,7 +193,9 @@ def publish_page(
186
193
  uploaded_attachments = [a.source_path for a in assets]
187
194
 
188
195
  # Update lockfile
189
- update_lockfile(lockfile, page_title, page_id, new_version if isinstance(new_version, int) else 1)
196
+ new_version_int = new_version if isinstance(new_version, int) else 1
197
+ update_lockfile(lockfile, page_title, page_id, new_version_int)
198
+ lockfile.pages[page_title].content_fingerprint = local_fingerprint
190
199
  save_lockfile(lockfile_path, lockfile)
191
200
 
192
201
  change = {
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from typing import Any
13
13
 
14
14
  from confpub.confluence import ConfluenceClient, build_client
15
- from confpub.output import emit_stderr
15
+ from confpub.output import emit_progress
16
16
  from confpub.errors import (
17
17
  ERR_CONFLICT_FILE_EXISTS,
18
18
  ERR_VALIDATION_NOT_FOUND,
@@ -47,7 +47,7 @@ def _collect_tree(
47
47
  def _walk(pid: str, parent_id: str | None) -> None:
48
48
  children = client.get_page_children_deep(pid)
49
49
  if children:
50
- emit_stderr(f"Found {len(children)} child page(s) under {pid}")
50
+ emit_progress(0, 0, f"Found {len(children)} child page(s) under {pid}")
51
51
  for child in children:
52
52
  child_id = str(child["id"])
53
53
  pages.append({
@@ -130,8 +130,8 @@ def _check_conflicts(file_paths: dict[str, str], force: bool) -> None:
130
130
  ERR_CONFLICT_FILE_EXISTS,
131
131
  f"Output files already exist: {', '.join(existing[:5])}"
132
132
  + (f" (and {len(existing) - 5} more)" if len(existing) > 5 else ""),
133
- details={"existing_files": existing},
134
- suggested_action="fix_input",
133
+ details={"existing_files": existing, "hint": "Use --force to overwrite existing files"},
134
+ suggested_action="retry_with_flag",
135
135
  )
136
136
 
137
137
 
@@ -127,6 +127,42 @@ class TestApplyPlanReal:
127
127
  assert "Existing Page" in lock_data["pages"]
128
128
 
129
129
 
130
+ class TestApplyLockfileFingerprints:
131
+ @patch("confpub.applier.load_config")
132
+ @patch("confpub.applier.ConfluenceClient")
133
+ def test_lockfile_entries_have_fingerprints(self, MockClient, mock_config, plan_dir, mock_client):
134
+ MockClient.return_value = mock_client
135
+ mock_config.return_value = MagicMock()
136
+
137
+ result = apply_plan(str(plan_dir / "plan.json"), dry_run=False)
138
+
139
+ lockfile_path = plan_dir / "confpub.lock"
140
+ assert lockfile_path.exists()
141
+ lock_data = json.loads(lockfile_path.read_text())
142
+ for title in ("New Page", "Existing Page"):
143
+ entry = lock_data["pages"][title]
144
+ assert entry["content_fingerprint"] is not None, f"{title} has null fingerprint"
145
+ assert len(entry["content_fingerprint"]) == 64 # SHA-256 hex digest
146
+
147
+ @patch("confpub.applier.load_config")
148
+ @patch("confpub.applier.ConfluenceClient")
149
+ def test_lockfile_updated_in_result(self, MockClient, mock_config, plan_dir, mock_client):
150
+ MockClient.return_value = mock_client
151
+ mock_config.return_value = MagicMock()
152
+
153
+ result = apply_plan(str(plan_dir / "plan.json"), dry_run=False)
154
+ assert result["lockfile_updated"] is True
155
+
156
+ @patch("confpub.applier.load_config")
157
+ @patch("confpub.applier.ConfluenceClient")
158
+ def test_lockfile_updated_false_on_dry_run(self, MockClient, mock_config, plan_dir, mock_client):
159
+ MockClient.return_value = mock_client
160
+ mock_config.return_value = MagicMock()
161
+
162
+ result = apply_plan(str(plan_dir / "plan.json"), dry_run=True)
163
+ assert result["lockfile_updated"] is False
164
+
165
+
130
166
  class TestFingerprintCheck:
131
167
  @patch("confpub.applier.load_config")
132
168
  @patch("confpub.applier.ConfluenceClient")
@@ -12,6 +12,7 @@ from confpub.errors import (
12
12
  ERR_CONFLICT_PAGE_EXISTS,
13
13
  ERR_IO_CONNECTION,
14
14
  ERR_IO_FILE_NOT_FOUND,
15
+ ERR_VALIDATION_NOT_FOUND,
15
16
  ERR_INTERNAL_SDK,
16
17
  ERR_VALIDATION_REQUIRED,
17
18
  ConfpubError,
@@ -260,7 +261,7 @@ class TestErrorTranslation:
260
261
  client._mock_api.get_all_spaces.side_effect = Exception("404 Not Found")
261
262
  with pytest.raises(ConfpubError) as exc_info:
262
263
  client.list_spaces()
263
- assert exc_info.value.code == ERR_IO_FILE_NOT_FOUND
264
+ assert exc_info.value.code == ERR_VALIDATION_NOT_FOUND
264
265
 
265
266
  def test_generic_error(self, client):
266
267
  client._mock_api.get_all_spaces.side_effect = Exception("Something weird happened")
@@ -322,7 +323,7 @@ class TestSlimPage:
322
323
 
323
324
  class TestSearch:
324
325
  def test_structured_results(self, client):
325
- client._mock_api.cql.return_value = {
326
+ client._mock_api.get.return_value = {
326
327
  "results": [
327
328
  {
328
329
  "entityType": "content",
@@ -357,14 +358,14 @@ class TestSearch:
357
358
  assert r["container_title"] == "Development"
358
359
 
359
360
  def test_empty_results(self, client):
360
- client._mock_api.cql.return_value = {"results": [], "totalSize": 0}
361
+ client._mock_api.get.return_value = {"results": [], "totalSize": 0}
361
362
  result = client.search('title = "nonexistent"')
362
363
  assert result["results"] == []
363
364
  assert result["total"] == 0
364
365
  assert result["has_more"] is False
365
366
 
366
367
  def test_has_more_pagination(self, client):
367
- client._mock_api.cql.return_value = {
368
+ client._mock_api.get.return_value = {
368
369
  "results": [{"entityType": "content", "content": {"id": "1"}, "excerpt": ""}],
369
370
  "totalSize": 50,
370
371
  }
@@ -375,31 +376,34 @@ class TestSearch:
375
376
  assert result["limit"] == 10
376
377
 
377
378
  def test_invalid_cql_raises_validation(self, client):
378
- client._mock_api.cql.side_effect = Exception("400 The query cannot be parsed")
379
+ client._mock_api.get.side_effect = Exception("400 The query cannot be parsed")
379
380
  with pytest.raises(ConfpubError) as exc_info:
380
381
  client.search("invalid!!")
381
382
  assert exc_info.value.code == ERR_VALIDATION_REQUIRED
382
383
 
383
384
  def test_auth_error(self, client):
384
- client._mock_api.cql.side_effect = Exception("401 Unauthorized")
385
+ client._mock_api.get.side_effect = Exception("401 Unauthorized")
385
386
  with pytest.raises(ConfpubError) as exc_info:
386
387
  client.search("type = page")
387
388
  assert exc_info.value.code == ERR_AUTH_FORBIDDEN
388
389
 
389
390
  def test_include_archived_passthrough(self, client):
390
- client._mock_api.cql.return_value = {"results": [], "totalSize": 0}
391
+ client._mock_api.get.return_value = {"results": [], "totalSize": 0}
391
392
  client.search("type = page", include_archived_spaces=True)
392
- client._mock_api.cql.assert_called_once_with(
393
- "type = page",
394
- start=0,
395
- limit=25,
396
- excerpt="highlight",
397
- include_archived_spaces=True,
393
+ client._mock_api.get.assert_called_once_with(
394
+ "rest/api/search",
395
+ params={
396
+ "cql": "type = page",
397
+ "start": 0,
398
+ "limit": 25,
399
+ "excerpt": "highlight",
400
+ "includeArchivedSpaces": True,
401
+ },
398
402
  )
399
403
 
400
404
  def test_excerpt_truncation(self, client):
401
405
  long_excerpt = "word " * 100 # 500 chars
402
- client._mock_api.cql.return_value = {
406
+ client._mock_api.get.return_value = {
403
407
  "results": [{"entityType": "content", "content": {"id": "1"}, "excerpt": long_excerpt}],
404
408
  "totalSize": 1,
405
409
  }
@@ -110,7 +110,7 @@ class TestPersonalSpaceKeyCLI:
110
110
  """--space ~thro must arrive as literal '~thro' in the command handler."""
111
111
  captured = {}
112
112
 
113
- def fake_list_pages(self, space):
113
+ def fake_list_pages(self, space, **kwargs):
114
114
  captured["space"] = space
115
115
  return []
116
116
 
@@ -8,6 +8,7 @@ from confpub.lockfile import (
8
8
  LockPageEntry,
9
9
  Lockfile,
10
10
  load_lockfile,
11
+ remove_from_lockfile,
11
12
  save_lockfile,
12
13
  update_lockfile,
13
14
  )
@@ -110,3 +111,35 @@ class TestUpdateLockfile:
110
111
  update_lockfile(lf, "A", "1", 2)
111
112
  assert lf.pages["B"].page_id == "2"
112
113
  assert lf.pages["B"].version == 1
114
+
115
+
116
+ class TestRemoveFromLockfile:
117
+ def test_remove_existing_entry(self):
118
+ lf = Lockfile(pages={
119
+ "A": LockPageEntry(page_id="1", version=1),
120
+ "B": LockPageEntry(page_id="2", version=2),
121
+ })
122
+ result = remove_from_lockfile(lf, "A")
123
+ assert result is True
124
+ assert "A" not in lf.pages
125
+ assert "B" in lf.pages
126
+
127
+ def test_remove_nonexistent_entry(self):
128
+ lf = Lockfile(pages={
129
+ "A": LockPageEntry(page_id="1", version=1),
130
+ })
131
+ result = remove_from_lockfile(lf, "Missing")
132
+ assert result is False
133
+ assert "A" in lf.pages
134
+
135
+ def test_remove_from_empty_lockfile(self):
136
+ lf = Lockfile()
137
+ result = remove_from_lockfile(lf, "Anything")
138
+ assert result is False
139
+
140
+ def test_remove_all_entries(self):
141
+ lf = Lockfile(pages={
142
+ "A": LockPageEntry(page_id="1", version=1),
143
+ })
144
+ remove_from_lockfile(lf, "A")
145
+ assert len(lf.pages) == 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes