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.
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/PKG-INFO +1 -1
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/__init__.py +1 -1
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/applier.py +4 -1
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/cli.py +64 -6
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/confluence.py +26 -16
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/guide.py +2 -1
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/lockfile.py +12 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/publish.py +13 -4
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/puller.py +4 -4
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_applier.py +36 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_confluence.py +18 -14
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_integration.py +1 -1
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_lockfile.py +33 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/.gitignore +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/LICENSE +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/PRD.md +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/README.md +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/assets.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/config.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/converter.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/envelope.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/errors.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/manifest.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/output.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/planner.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/py.typed +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/reverse_converter.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/validator.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub/verifier.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/confpub.lock +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/pyproject.toml +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/__init__.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/conftest.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_assets.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_config.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_converter.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_envelope.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_errors.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_guide.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_manifest.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_output.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_planner.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_publish.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_puller.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_validator.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-1.0.0}/tests/test_verifier.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
82
|
+
from confpub.errors import ERR_VALIDATION_NOT_FOUND
|
|
82
83
|
raise ConfpubError(
|
|
83
|
-
|
|
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
|
|
179
|
+
from confpub.errors import ERR_VALIDATION_NOT_FOUND
|
|
179
180
|
raise ConfpubError(
|
|
180
|
-
|
|
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(
|
|
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.
|
|
368
|
-
cql,
|
|
369
|
-
start
|
|
370
|
-
limit
|
|
371
|
-
excerpt
|
|
372
|
-
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":
|
|
397
|
-
"limit":
|
|
398
|
-
"has_more": (
|
|
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
|
-
|
|
113
|
-
if
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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="
|
|
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 ==
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
391
|
+
client._mock_api.get.return_value = {"results": [], "totalSize": 0}
|
|
391
392
|
client.search("type = page", include_archived_spaces=True)
|
|
392
|
-
client._mock_api.
|
|
393
|
-
"
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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.
|
|
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
|
|
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
|