confpub-cli 0.7.0__tar.gz → 0.8.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-0.8.0}/PKG-INFO +1 -1
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/__init__.py +1 -1
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/cli.py +9 -5
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/confluence.py +22 -14
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/lockfile.py +1 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/publish.py +13 -4
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/puller.py +2 -2
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_confluence.py +18 -14
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/.gitignore +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/LICENSE +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/PRD.md +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/README.md +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/applier.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/assets.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/config.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/converter.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/envelope.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/errors.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/guide.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/manifest.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/output.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/planner.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/py.typed +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/reverse_converter.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/validator.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub/verifier.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/confpub.lock +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/pyproject.toml +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/__init__.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/conftest.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_applier.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_assets.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_config.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_converter.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_envelope.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_errors.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_guide.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_integration.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_lockfile.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_manifest.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_output.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_planner.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_publish.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_puller.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_validator.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.0}/tests/test_verifier.py +0 -0
- {confpub_cli-0.7.0 → confpub_cli-0.8.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: 0.8.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
|
|
@@ -231,15 +231,15 @@ def page_publish(
|
|
|
231
231
|
"""Publish a single Markdown file to Confluence."""
|
|
232
232
|
from confpub.publish import derive_title
|
|
233
233
|
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
234
|
target = {"space": space, "title": resolved_title, "file": file}
|
|
240
235
|
if page_id:
|
|
241
236
|
target["page_id"] = page_id
|
|
242
237
|
with command_context("page.publish", target=target) as ctx:
|
|
238
|
+
if not page_id and not parent:
|
|
239
|
+
raise ConfpubError(
|
|
240
|
+
"ERR_VALIDATION_REQUIRED",
|
|
241
|
+
"Either --page-id or --parent is required",
|
|
242
|
+
)
|
|
243
243
|
from confpub.publish import publish_page
|
|
244
244
|
result = publish_page(
|
|
245
245
|
file=file,
|
|
@@ -546,3 +546,7 @@ def run() -> None:
|
|
|
546
546
|
envelope = Envelope.failure("cli", [err])
|
|
547
547
|
emit_stdout(envelope.to_json_bytes())
|
|
548
548
|
sys.exit(10)
|
|
549
|
+
except ConfpubError as e:
|
|
550
|
+
envelope = Envelope.failure("cli", [e])
|
|
551
|
+
emit_stdout(envelope.to_json_bytes())
|
|
552
|
+
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:
|
|
@@ -338,6 +340,9 @@ class ConfluenceClient:
|
|
|
338
340
|
try:
|
|
339
341
|
result = self._api.attach_file(filepath, page_id=page_id)
|
|
340
342
|
if isinstance(result, dict):
|
|
343
|
+
# API returns {"results": [...]} wrapper — extract the attachment
|
|
344
|
+
if "results" in result and isinstance(result["results"], list) and result["results"]:
|
|
345
|
+
return _slim_attachment(result["results"][0])
|
|
341
346
|
return _slim_attachment(result)
|
|
342
347
|
return {"uploaded": True, "file": filepath}
|
|
343
348
|
except Exception as exc:
|
|
@@ -364,13 +369,13 @@ class ConfluenceClient:
|
|
|
364
369
|
"""
|
|
365
370
|
self._call_count += 1
|
|
366
371
|
try:
|
|
367
|
-
raw = self._api.
|
|
368
|
-
cql,
|
|
369
|
-
start
|
|
370
|
-
limit
|
|
371
|
-
excerpt
|
|
372
|
-
include_archived_spaces
|
|
373
|
-
)
|
|
372
|
+
raw = self._api.get("rest/api/search", params={
|
|
373
|
+
"cql": cql,
|
|
374
|
+
"start": start,
|
|
375
|
+
"limit": limit,
|
|
376
|
+
"excerpt": "highlight",
|
|
377
|
+
"includeArchivedSpaces": include_archived_spaces,
|
|
378
|
+
})
|
|
374
379
|
except Exception as exc:
|
|
375
380
|
msg = str(exc)
|
|
376
381
|
if "400" in msg or "cannot be parsed" in msg.lower():
|
|
@@ -386,6 +391,9 @@ class ConfluenceClient:
|
|
|
386
391
|
results_raw = raw.get("results", []) if isinstance(raw, dict) else []
|
|
387
392
|
total = raw.get("totalSize", 0) if isinstance(raw, dict) else 0
|
|
388
393
|
|
|
394
|
+
api_start = raw.get("start", start) if isinstance(raw, dict) else start
|
|
395
|
+
api_limit = raw.get("limit", limit) if isinstance(raw, dict) else limit
|
|
396
|
+
|
|
389
397
|
results = [
|
|
390
398
|
_slim_search_result(r, base_url=base_url, excerpt_length=excerpt_length)
|
|
391
399
|
for r in results_raw
|
|
@@ -393,9 +401,9 @@ class ConfluenceClient:
|
|
|
393
401
|
return {
|
|
394
402
|
"results": results,
|
|
395
403
|
"total": total,
|
|
396
|
-
"start":
|
|
397
|
-
"limit":
|
|
398
|
-
"has_more": (
|
|
404
|
+
"start": api_start,
|
|
405
|
+
"limit": api_limit,
|
|
406
|
+
"has_more": (api_start + api_limit) < total,
|
|
399
407
|
}
|
|
400
408
|
|
|
401
409
|
# ------------------------------------------------------------------
|
|
@@ -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({
|
|
@@ -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
|
}
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|