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.
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/PKG-INFO +1 -1
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/__init__.py +1 -1
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/applier.py +4 -1
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/cli.py +55 -1
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/confluence.py +4 -2
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/guide.py +2 -1
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/lockfile.py +11 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/puller.py +2 -2
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_applier.py +36 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_integration.py +1 -1
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_lockfile.py +33 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/.gitignore +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/LICENSE +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/PRD.md +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/README.md +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/assets.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/config.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/converter.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/envelope.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/errors.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/manifest.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/output.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/planner.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/publish.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/py.typed +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/reverse_converter.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/validator.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub/verifier.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/confpub.lock +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/pyproject.toml +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/__init__.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/conftest.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_assets.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_config.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_confluence.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_converter.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_envelope.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_errors.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_guide.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_manifest.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_output.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_planner.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_publish.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_puller.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_validator.py +0 -0
- {confpub_cli-0.8.0 → confpub_cli-1.0.0}/tests/test_verifier.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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(
|
|
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="
|
|
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
|
|
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
|