confpub-cli 0.8.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.8.0 → confpub_cli-1.0.0}/PKG-INFO +1 -1
  2. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/__init__.py +1 -1
  3. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/applier.py +4 -1
  4. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/cli.py +55 -1
  5. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/confluence.py +4 -2
  6. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/guide.py +2 -1
  7. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/lockfile.py +11 -0
  8. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/puller.py +2 -2
  9. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_applier.py +36 -0
  10. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_integration.py +1 -1
  11. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_lockfile.py +33 -0
  12. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/.github/workflows/publish.yml +0 -0
  13. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/.gitignore +0 -0
  14. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/LICENSE +0 -0
  15. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/PRD.md +0 -0
  16. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/README.md +0 -0
  17. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/assets.py +0 -0
  18. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/config.py +0 -0
  19. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/converter.py +0 -0
  20. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/envelope.py +0 -0
  21. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/errors.py +0 -0
  22. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/manifest.py +0 -0
  23. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/output.py +0 -0
  24. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/planner.py +0 -0
  25. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/publish.py +0 -0
  26. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/py.typed +0 -0
  27. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/reverse_converter.py +0 -0
  28. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/validator.py +0 -0
  29. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/verifier.py +0 -0
  30. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub.lock +0 -0
  31. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/pyproject.toml +0 -0
  32. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/__init__.py +0 -0
  33. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/conftest.py +0 -0
  34. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_assets.py +0 -0
  35. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_config.py +0 -0
  36. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_confluence.py +0 -0
  37. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_converter.py +0 -0
  38. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_envelope.py +0 -0
  39. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_errors.py +0 -0
  40. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_guide.py +0 -0
  41. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_manifest.py +0 -0
  42. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_output.py +0 -0
  43. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_planner.py +0 -0
  44. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_publish.py +0 -0
  45. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_puller.py +0 -0
  46. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_reverse_converter.py +0 -0
  47. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_validator.py +0 -0
  48. {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_verifier.py +0 -0
  49. {confpub_cli-0.8.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.8.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.8.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
 
@@ -235,6 +237,12 @@ def page_publish(
235
237
  if page_id:
236
238
  target["page_id"] = page_id
237
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
+ )
238
246
  if not page_id and not parent:
239
247
  raise ConfpubError(
240
248
  "ERR_VALIDATION_REQUIRED",
@@ -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
 
@@ -262,11 +262,13 @@ class ConfluenceClient:
262
262
  self._handle_error(exc, "list_spaces")
263
263
  return []
264
264
 
265
- 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]]:
266
266
  """List pages in a space."""
267
267
  self._call_count += 1
268
268
  try:
269
- 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
+ )
270
272
  return result if isinstance(result, list) else []
271
273
  except Exception as exc:
272
274
  self._handle_error(exc, "list_pages")
@@ -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",
@@ -90,3 +90,14 @@ def update_lockfile(
90
90
  """Update a page entry in the lockfile."""
91
91
  lockfile.pages[title] = LockPageEntry(page_id=page_id, version=version)
92
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
@@ -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")
@@ -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