confpub-cli 0.2.2__tar.gz → 0.2.3__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.2.3/FEEDBACK.md +118 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/PKG-INFO +1 -1
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/__init__.py +1 -1
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/cli.py +20 -9
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/confluence.py +37 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/manifest.py +13 -1
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/output.py +3 -1
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/publish.py +8 -1
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_confluence.py +67 -1
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_integration.py +1 -1
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_manifest.py +33 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_output.py +9 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_publish.py +18 -1
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/.github/workflows/publish.yml +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/.gitignore +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/LICENSE +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/PRD.md +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/README.md +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/applier.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/assets.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/config.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/converter.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/envelope.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/errors.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/guide.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/lockfile.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/planner.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/py.typed +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/validator.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/verifier.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub.lock +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/pyproject.toml +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/__init__.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/conftest.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_applier.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_assets.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_config.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_converter.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_envelope.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_errors.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_guide.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_lockfile.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_planner.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_validator.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_verifier.py +0 -0
- {confpub_cli-0.2.2 → confpub_cli-0.2.3}/uv.lock +0 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# confpub-cli v0.2.2 — Blind Test Feedback
|
|
2
|
+
|
|
3
|
+
**Tester:** Claude Code (Opus 4.6), acting as an LLM agent
|
|
4
|
+
**Date:** 2026-03-01
|
|
5
|
+
**Method:** Zero-shot exploration starting from `uvx confpub-cli --help`, then progressively deeper testing of every subcommand, error path, and workflow.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overall Impression
|
|
10
|
+
|
|
11
|
+
confpub is a **remarkably well-designed agent-first CLI**. The `guide` command as a single bootstrap entry point is a standout idea — I was able to learn the entire CLI schema, error codes, auth precedence, and concurrency rules in one call. The structured JSON envelope on stdout with progress on stderr is exactly right for machine consumption. This is one of the best-designed CLIs I've encountered for agent integration.
|
|
12
|
+
|
|
13
|
+
**Score: 8.5/10** — excellent foundation, a few rough edges to polish.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## What Works Well
|
|
18
|
+
|
|
19
|
+
### 1. The `guide` command is brilliant
|
|
20
|
+
One call to `confpub guide` gave me everything: all commands with flags, mutation indicators, error codes with exit codes and retry hints, auth precedence, and concurrency rules. The `--section` filter is a nice touch. This is the gold standard for agent-discoverability.
|
|
21
|
+
|
|
22
|
+
### 2. Structured JSON envelope is consistent
|
|
23
|
+
Every response — success or failure — follows the same shape: `schema_version`, `request_id`, `ok`, `command`, `target`, `result`, `warnings`, `errors`, `metrics`. I never had to guess the format. The `request_id` is useful for log correlation.
|
|
24
|
+
|
|
25
|
+
### 3. Error codes are stable and actionable
|
|
26
|
+
`ERR_IO_FILE_NOT_FOUND` with `retryable: true` and `suggested_action: "retry"` — that's exactly what an agent needs. The exit code bucketing (10=validation, 20=auth, 40=conflict, 50=IO, 90=internal) is clean. I could build a robust retry/escalation loop from just the `guide --section error_codes` output.
|
|
27
|
+
|
|
28
|
+
### 4. Transactional plan workflow
|
|
29
|
+
The `plan create → validate → apply → verify` pipeline with fingerprint-based conflict detection is a strong design. Separating the plan artifact from execution lets an agent inspect and reason about changes before committing. The `--dry-run` flag on both `page publish` and `plan apply` is essential.
|
|
30
|
+
|
|
31
|
+
### 5. Safety annotations
|
|
32
|
+
The `safety_flags` in the guide output (e.g., `--cascade: "Also deletes child pages"`) are a great signal for agents to ask for human confirmation before using dangerous flags.
|
|
33
|
+
|
|
34
|
+
### 6. Auth precedence is well-thought-out
|
|
35
|
+
CLI flags → env vars → config file → OS keychain, with `LLM=true` suppressing interactive prompts — exactly right.
|
|
36
|
+
|
|
37
|
+
### 7. Mutation markers
|
|
38
|
+
Every command in the guide is tagged with `mutates: true/false` and grouped (`read`, `write`, `transactional`). This makes it trivial for an agent to know which commands are safe to call speculatively.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Issues Found
|
|
43
|
+
|
|
44
|
+
### Bug: `--quiet` flag does not suppress stderr
|
|
45
|
+
|
|
46
|
+
**Severity:** Medium
|
|
47
|
+
**Steps:** Run `confpub-cli --quiet page publish page.md --space SD --parent "Software Development" --dry-run` and capture stderr.
|
|
48
|
+
**Expected:** stderr should be empty (or at least suppress the "Can't find..." message).
|
|
49
|
+
**Actual:** The message `Can't find 'Page' page on https://thomasklokrohde.atlassian.net/wiki` still appears on stderr, identical to the non-quiet run.
|
|
50
|
+
|
|
51
|
+
### Bug: `target.title` shows raw filename instead of resolved title
|
|
52
|
+
|
|
53
|
+
**Severity:** Low
|
|
54
|
+
**Steps:** Run `page publish page.md --space SD --parent "..." --dry-run` (no `--title` flag).
|
|
55
|
+
**Expected:** `target.title` should be `"Page"` (the resolved title).
|
|
56
|
+
**Actual:** `target.title` is `"page.md"` (the raw filename), while the actual page title used in `result.changes` is `"Page"`. An agent parsing `target.title` would get the wrong value.
|
|
57
|
+
|
|
58
|
+
### Bug: Nonexistent space returns `ERR_INTERNAL_SDK` (exit 90) instead of a specific error
|
|
59
|
+
|
|
60
|
+
**Severity:** Medium
|
|
61
|
+
**Steps:** Run `page inspect --space NONEXISTENT --title "nope"`.
|
|
62
|
+
**Expected:** A specific error like `ERR_AUTH_FORBIDDEN` (exit 20) or a new `ERR_NOT_FOUND` code.
|
|
63
|
+
**Actual:** Returns `ERR_INTERNAL_SDK` with exit code 90 and `suggested_action: "escalate"`. The message is `"Unexpected API error (get_page): The calling user does not have permission to view the content"`. This is misleading — it's not an internal error, it's a permissions/not-found issue. An agent following the `escalate` suggestion would file a bug report instead of trying a different space key.
|
|
64
|
+
|
|
65
|
+
### Rough edge: `--verbose` flag has no visible effect
|
|
66
|
+
|
|
67
|
+
**Severity:** Low
|
|
68
|
+
**Steps:** Compare `auth inspect` output with and without `--verbose`.
|
|
69
|
+
**Observation:** The JSON output is identical. No extra diagnostics field, no additional stderr output. Either verbose mode isn't implemented yet, or its effect is too subtle to observe.
|
|
70
|
+
|
|
71
|
+
### Rough edge: `page.list` and `page.inspect` responses are extremely verbose
|
|
72
|
+
|
|
73
|
+
**Severity:** Medium (for agent consumption)
|
|
74
|
+
**Observation:** These commands return the raw Confluence API response including `_expandable`, `_links`, `macroRenderedOutput`, `profilePicture`, `accountType`, etc. A single `page.list` for 5 pages produced ~300 lines of JSON. For an agent working within a context window, this is wasteful. A slimmed-down response with just `id`, `title`, `version.number`, `version.when`, and `webui` link would be far more token-efficient. The full raw response could be available behind `--verbose` or a `--raw` flag.
|
|
75
|
+
|
|
76
|
+
### Rough edge: Manifest validation error exposes raw Pydantic internals
|
|
77
|
+
|
|
78
|
+
**Severity:** Low
|
|
79
|
+
**Steps:** Submit a manifest missing `parent` and `pages.0.title`.
|
|
80
|
+
**Actual message:**
|
|
81
|
+
```
|
|
82
|
+
Invalid manifest: 2 validation errors for Manifest
|
|
83
|
+
parent
|
|
84
|
+
Field required [type=missing, input_value={'space': 'SD', ...}, input_type=dict]
|
|
85
|
+
For further information visit https://errors.pydantic.dev/2.12/v/missing
|
|
86
|
+
```
|
|
87
|
+
**Suggestion:** Wrap Pydantic errors into a cleaner `details` array, e.g.:
|
|
88
|
+
```json
|
|
89
|
+
"details": {
|
|
90
|
+
"missing_fields": ["parent", "pages[0].title"]
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
The raw Pydantic output with `input_value`, `input_type`, and the pydantic.dev URL leaks implementation details.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Suggestions
|
|
98
|
+
|
|
99
|
+
### 1. Add a `--compact` or `--slim` output mode
|
|
100
|
+
For agent use, return only the fields that matter. The full Confluence API payloads in `page.list` and `page.inspect` are 10x more data than an agent needs. This would significantly reduce token usage.
|
|
101
|
+
|
|
102
|
+
### 2. Consider a `page.get-body` subcommand (or flag)
|
|
103
|
+
`page.inspect` returns the full storage-format body inline, which is great for inspection but makes the response enormous. A `--no-body` flag on inspect (or a separate `page.body` command) would let agents choose when they need content vs. metadata.
|
|
104
|
+
|
|
105
|
+
### 3. Add `schema_version` to the manifest validation error
|
|
106
|
+
When a manifest is missing `schema_version`, the error message mentions `parent` and `pages.0.title` but doesn't flag the missing `schema_version`. (Tested: a manifest without `schema_version` but with `parent` was accepted — so it appears optional. The README says it should be there. Clarify whether it's required.)
|
|
107
|
+
|
|
108
|
+
### 4. The `config set` subcommand help is sparse
|
|
109
|
+
`config set --help` doesn't show what keys are valid or their expected values. Adding a `config list-keys` or including examples in the help text would help.
|
|
110
|
+
|
|
111
|
+
### 5. Document the title-from-filename behavior
|
|
112
|
+
The help text says `--title TEXT Page title (defaults to filename)` but the actual behavior is "filename without extension, title-cased" (e.g., `test-confpub.md` → `Test Confpub`, `page.md` → `Page`). Document the transformation rule.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Summary
|
|
117
|
+
|
|
118
|
+
confpub nails the core agent-first design philosophy: structured output, stable error codes, a self-describing `guide` command, mutation markers, safety annotations, and a clean transactional workflow. The main areas for improvement are (1) trimming the verbose Confluence API payloads for agent-friendly output, (2) fixing the `--quiet` bug, and (3) better error classification for permission/not-found cases vs. true internal errors. This is a tool I'd be confident integrating into an autonomous agent workflow today.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confpub-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
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
|
|
@@ -16,7 +16,7 @@ import typer
|
|
|
16
16
|
from confpub import __version__
|
|
17
17
|
from confpub.envelope import Envelope
|
|
18
18
|
from confpub.errors import ConfpubError, exit_code_for, ERR_INTERNAL_SDK
|
|
19
|
-
from confpub.output import emit_stderr, emit_stdout, set_quiet, set_verbose
|
|
19
|
+
from confpub.output import emit_stderr, emit_stdout, is_verbose, set_quiet, set_verbose
|
|
20
20
|
|
|
21
21
|
# ---------------------------------------------------------------------------
|
|
22
22
|
# Subcommand group apps
|
|
@@ -101,6 +101,9 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
101
101
|
except ConfpubError as e:
|
|
102
102
|
duration_ms = int((time.monotonic() - start) * 1000)
|
|
103
103
|
ctx.metrics["duration_ms"] = duration_ms
|
|
104
|
+
if is_verbose():
|
|
105
|
+
import traceback as tb
|
|
106
|
+
ctx.metrics["diagnostics"] = {"traceback": tb.format_exc()}
|
|
104
107
|
envelope = Envelope.failure(
|
|
105
108
|
command_name,
|
|
106
109
|
[e],
|
|
@@ -133,6 +136,12 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
133
136
|
else:
|
|
134
137
|
duration_ms = int((time.monotonic() - start) * 1000)
|
|
135
138
|
ctx.metrics["duration_ms"] = duration_ms
|
|
139
|
+
if is_verbose():
|
|
140
|
+
ctx.metrics["diagnostics"] = {
|
|
141
|
+
"command": command_name,
|
|
142
|
+
"target": ctx.target,
|
|
143
|
+
"warning_count": len(ctx.warnings),
|
|
144
|
+
}
|
|
136
145
|
envelope = Envelope.success(
|
|
137
146
|
command_name,
|
|
138
147
|
ctx.result,
|
|
@@ -154,11 +163,10 @@ def page_list(
|
|
|
154
163
|
) -> None:
|
|
155
164
|
"""List pages in a Confluence space."""
|
|
156
165
|
with command_context("page.list", target={"space": space}) as ctx:
|
|
157
|
-
|
|
158
|
-
from confpub.confluence import build_client
|
|
166
|
+
from confpub.confluence import build_client, _slim_page
|
|
159
167
|
client = build_client()
|
|
160
168
|
pages = client.list_pages(space)
|
|
161
|
-
ctx.result = {"pages": pages}
|
|
169
|
+
ctx.result = {"pages": [_slim_page(p) for p in pages]}
|
|
162
170
|
|
|
163
171
|
|
|
164
172
|
@page_app.command("inspect")
|
|
@@ -166,10 +174,11 @@ def page_inspect(
|
|
|
166
174
|
space: str = typer.Option(None, "--space", help="Confluence space key"),
|
|
167
175
|
title: str = typer.Option(None, "--title", help="Page title"),
|
|
168
176
|
page_id: str = typer.Option(None, "--page-id", help="Confluence page ID"),
|
|
177
|
+
raw: bool = typer.Option(False, "--raw", help="Return full raw API response"),
|
|
169
178
|
) -> None:
|
|
170
179
|
"""Inspect a Confluence page."""
|
|
171
180
|
with command_context("page.inspect", target={"space": space, "title": title, "page_id": page_id}) as ctx:
|
|
172
|
-
from confpub.confluence import build_client
|
|
181
|
+
from confpub.confluence import build_client, _slim_page
|
|
173
182
|
client = build_client()
|
|
174
183
|
if page_id:
|
|
175
184
|
page = client.get_page_by_id(page_id)
|
|
@@ -178,7 +187,7 @@ def page_inspect(
|
|
|
178
187
|
if not space or not title:
|
|
179
188
|
raise validation_error(ERR_VALIDATION_REQUIRED, "Either --page-id or both --space and --title are required")
|
|
180
189
|
page = client.get_page(space, title)
|
|
181
|
-
ctx.result = page
|
|
190
|
+
ctx.result = page if raw else _slim_page(page) if page else page
|
|
182
191
|
|
|
183
192
|
|
|
184
193
|
@page_app.command("publish")
|
|
@@ -186,12 +195,14 @@ def page_publish(
|
|
|
186
195
|
file: str = typer.Argument(..., help="Markdown file to publish"),
|
|
187
196
|
space: str = typer.Option(..., "--space", help="Confluence space key"),
|
|
188
197
|
parent: str = typer.Option(..., "--parent", help="Parent page title"),
|
|
189
|
-
title: Optional[str] = typer.Option(None, "--title", help="Page title (defaults to filename)"),
|
|
198
|
+
title: Optional[str] = typer.Option(None, "--title", help="Page title (defaults to filename stem, hyphen/underscore→spaces, title-cased)"),
|
|
190
199
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
|
|
191
200
|
backup: bool = typer.Option(False, "--backup", help="Backup existing page before overwriting"),
|
|
192
201
|
) -> None:
|
|
193
202
|
"""Publish a single Markdown file to Confluence."""
|
|
194
|
-
|
|
203
|
+
from confpub.publish import derive_title
|
|
204
|
+
resolved_title = derive_title(file, title)
|
|
205
|
+
target = {"space": space, "title": resolved_title, "file": file}
|
|
195
206
|
with command_context("page.publish", target=target) as ctx:
|
|
196
207
|
from confpub.publish import publish_page
|
|
197
208
|
result = publish_page(
|
|
@@ -329,7 +340,7 @@ def auth_inspect() -> None:
|
|
|
329
340
|
|
|
330
341
|
@config_app.command("set")
|
|
331
342
|
def config_set(
|
|
332
|
-
key: str = typer.Argument(..., help="Configuration key"),
|
|
343
|
+
key: str = typer.Argument(..., help="Configuration key (base_url, user, token)"),
|
|
333
344
|
value: str = typer.Argument(..., help="Configuration value"),
|
|
334
345
|
) -> None:
|
|
335
346
|
"""Set a configuration value."""
|
|
@@ -59,6 +59,20 @@ class ConfluenceClient:
|
|
|
59
59
|
raise ConfpubError(ERR_IO_TIMEOUT, f"Request timed out: {msg}") from exc
|
|
60
60
|
if "ConnectionError" in msg or "connection" in msg.lower():
|
|
61
61
|
raise ConfpubError(ERR_IO_CONNECTION, f"Connection failed: {msg}") from exc
|
|
62
|
+
# Permission denied (e.g., nonexistent space, restricted content)
|
|
63
|
+
if "permission" in msg.lower() or "not permitted" in msg.lower():
|
|
64
|
+
raise ConfpubError(
|
|
65
|
+
ERR_AUTH_FORBIDDEN,
|
|
66
|
+
f"Permission denied ({context}): {msg}",
|
|
67
|
+
suggested_action="escalate",
|
|
68
|
+
) from exc
|
|
69
|
+
# Not found (404 or explicit "not found")
|
|
70
|
+
if "404" in msg or "not found" in msg.lower():
|
|
71
|
+
from confpub.errors import ERR_IO_FILE_NOT_FOUND
|
|
72
|
+
raise ConfpubError(
|
|
73
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
74
|
+
f"Resource not found ({context}): {msg}",
|
|
75
|
+
) from exc
|
|
62
76
|
raise ConfpubError(ERR_INTERNAL_SDK, f"Unexpected API error ({context}): {msg}") from exc
|
|
63
77
|
|
|
64
78
|
# ------------------------------------------------------------------
|
|
@@ -229,6 +243,29 @@ class ConfluenceClient:
|
|
|
229
243
|
return None
|
|
230
244
|
|
|
231
245
|
|
|
246
|
+
def _slim_page(page: dict[str, Any]) -> dict[str, Any]:
|
|
247
|
+
"""Extract agent-relevant fields from a raw Confluence page object."""
|
|
248
|
+
result: dict[str, Any] = {
|
|
249
|
+
"id": page.get("id"),
|
|
250
|
+
"title": page.get("title"),
|
|
251
|
+
}
|
|
252
|
+
version = page.get("version")
|
|
253
|
+
if isinstance(version, dict):
|
|
254
|
+
result["version"] = {
|
|
255
|
+
"number": version.get("number"),
|
|
256
|
+
"when": version.get("when"),
|
|
257
|
+
"by": version.get("by", {}).get("displayName") if isinstance(version.get("by"), dict) else None,
|
|
258
|
+
}
|
|
259
|
+
body = page.get("body", {}).get("storage", {}).get("value")
|
|
260
|
+
if body is not None:
|
|
261
|
+
result["body_storage"] = body
|
|
262
|
+
links = page.get("_links", {})
|
|
263
|
+
if "webui" in links:
|
|
264
|
+
base = links.get("base", "")
|
|
265
|
+
result["webui"] = base + links["webui"]
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
|
|
232
269
|
def build_client(
|
|
233
270
|
cli_url: str | None = None,
|
|
234
271
|
cli_user: str | None = None,
|
|
@@ -11,7 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
from typing import Any, Literal, Optional
|
|
12
12
|
|
|
13
13
|
import yaml
|
|
14
|
-
from pydantic import BaseModel, Field, model_validator
|
|
14
|
+
from pydantic import BaseModel, Field, ValidationError, model_validator
|
|
15
15
|
|
|
16
16
|
from confpub.errors import ERR_VALIDATION_MANIFEST, ConfpubError
|
|
17
17
|
|
|
@@ -150,6 +150,18 @@ def load_manifest(path: str) -> Manifest:
|
|
|
150
150
|
return Manifest(**data)
|
|
151
151
|
except ConfpubError:
|
|
152
152
|
raise
|
|
153
|
+
except ValidationError as exc:
|
|
154
|
+
details = {
|
|
155
|
+
"validation_errors": [
|
|
156
|
+
{"field": ".".join(str(loc) for loc in e["loc"]), "message": e["msg"]}
|
|
157
|
+
for e in exc.errors()
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
raise ConfpubError(
|
|
161
|
+
ERR_VALIDATION_MANIFEST,
|
|
162
|
+
f"Invalid manifest: {len(exc.errors())} validation error(s)",
|
|
163
|
+
details=details,
|
|
164
|
+
) from exc
|
|
153
165
|
except Exception as exc:
|
|
154
166
|
raise ConfpubError(
|
|
155
167
|
ERR_VALIDATION_MANIFEST,
|
|
@@ -75,6 +75,8 @@ def emit_progress(step: int, total: int, message: str) -> None:
|
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
def emit_stderr(message: str) -> None:
|
|
78
|
-
"""Write a diagnostic message to stderr."""
|
|
78
|
+
"""Write a diagnostic message to stderr. Suppressed in quiet mode."""
|
|
79
|
+
if is_quiet():
|
|
80
|
+
return
|
|
79
81
|
sys.stderr.write(message + "\n")
|
|
80
82
|
sys.stderr.flush()
|
|
@@ -22,6 +22,13 @@ from confpub.errors import (
|
|
|
22
22
|
from confpub.lockfile import Lockfile, load_lockfile, save_lockfile, update_lockfile
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def derive_title(file: str, title: str | None = None) -> str:
|
|
26
|
+
"""Derive page title from explicit title or filename."""
|
|
27
|
+
if title:
|
|
28
|
+
return title
|
|
29
|
+
return Path(file).stem.replace("-", " ").replace("_", " ").title()
|
|
30
|
+
|
|
31
|
+
|
|
25
32
|
def publish_page(
|
|
26
33
|
file: str,
|
|
27
34
|
space: str,
|
|
@@ -44,7 +51,7 @@ def publish_page(
|
|
|
44
51
|
)
|
|
45
52
|
|
|
46
53
|
# Derive title from filename if not provided
|
|
47
|
-
page_title =
|
|
54
|
+
page_title = derive_title(file, title)
|
|
48
55
|
|
|
49
56
|
# Read and convert
|
|
50
57
|
md_text = source_path.read_text(encoding="utf-8")
|
|
@@ -5,12 +5,13 @@ from unittest.mock import MagicMock, patch
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
7
|
from confpub.config import ResolvedConfig
|
|
8
|
-
from confpub.confluence import ConfluenceClient
|
|
8
|
+
from confpub.confluence import ConfluenceClient, _slim_page
|
|
9
9
|
from confpub.errors import (
|
|
10
10
|
ERR_AUTH_FORBIDDEN,
|
|
11
11
|
ERR_AUTH_REQUIRED,
|
|
12
12
|
ERR_CONFLICT_PAGE_EXISTS,
|
|
13
13
|
ERR_IO_CONNECTION,
|
|
14
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
14
15
|
ERR_INTERNAL_SDK,
|
|
15
16
|
ConfpubError,
|
|
16
17
|
)
|
|
@@ -148,8 +149,73 @@ class TestErrorTranslation:
|
|
|
148
149
|
client.list_spaces()
|
|
149
150
|
assert exc_info.value.code == ERR_IO_CONNECTION
|
|
150
151
|
|
|
152
|
+
def test_permission_error(self, client):
|
|
153
|
+
client._mock_api.get_all_spaces.side_effect = Exception(
|
|
154
|
+
"User does not have permission to view the content"
|
|
155
|
+
)
|
|
156
|
+
with pytest.raises(ConfpubError) as exc_info:
|
|
157
|
+
client.list_spaces()
|
|
158
|
+
assert exc_info.value.code == ERR_AUTH_FORBIDDEN
|
|
159
|
+
|
|
160
|
+
def test_not_found_error(self, client):
|
|
161
|
+
client._mock_api.get_all_spaces.side_effect = Exception("404 Not Found")
|
|
162
|
+
with pytest.raises(ConfpubError) as exc_info:
|
|
163
|
+
client.list_spaces()
|
|
164
|
+
assert exc_info.value.code == ERR_IO_FILE_NOT_FOUND
|
|
165
|
+
|
|
151
166
|
def test_generic_error(self, client):
|
|
152
167
|
client._mock_api.get_all_spaces.side_effect = Exception("Something weird happened")
|
|
153
168
|
with pytest.raises(ConfpubError) as exc_info:
|
|
154
169
|
client.list_spaces()
|
|
155
170
|
assert exc_info.value.code == ERR_INTERNAL_SDK
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class TestSlimPage:
|
|
174
|
+
def test_extracts_basic_fields(self):
|
|
175
|
+
page = {"id": "123", "title": "Test Page", "_expandable": {"stuff": "..."}}
|
|
176
|
+
result = _slim_page(page)
|
|
177
|
+
assert result == {"id": "123", "title": "Test Page"}
|
|
178
|
+
assert "_expandable" not in result
|
|
179
|
+
|
|
180
|
+
def test_extracts_version(self):
|
|
181
|
+
page = {
|
|
182
|
+
"id": "123",
|
|
183
|
+
"title": "Test",
|
|
184
|
+
"version": {
|
|
185
|
+
"number": 5,
|
|
186
|
+
"when": "2025-01-01T00:00:00Z",
|
|
187
|
+
"by": {"displayName": "Alice", "profilePicture": "/avatar.png"},
|
|
188
|
+
"minorEdit": False,
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
result = _slim_page(page)
|
|
192
|
+
assert result["version"] == {
|
|
193
|
+
"number": 5,
|
|
194
|
+
"when": "2025-01-01T00:00:00Z",
|
|
195
|
+
"by": "Alice",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
def test_extracts_body_storage(self):
|
|
199
|
+
page = {
|
|
200
|
+
"id": "1",
|
|
201
|
+
"title": "T",
|
|
202
|
+
"body": {"storage": {"value": "<p>hi</p>", "representation": "storage"}},
|
|
203
|
+
}
|
|
204
|
+
result = _slim_page(page)
|
|
205
|
+
assert result["body_storage"] == "<p>hi</p>"
|
|
206
|
+
|
|
207
|
+
def test_extracts_webui_link(self):
|
|
208
|
+
page = {
|
|
209
|
+
"id": "1",
|
|
210
|
+
"title": "T",
|
|
211
|
+
"_links": {"base": "https://wiki.example.com", "webui": "/spaces/DEV/pages/1"},
|
|
212
|
+
}
|
|
213
|
+
result = _slim_page(page)
|
|
214
|
+
assert result["webui"] == "https://wiki.example.com/spaces/DEV/pages/1"
|
|
215
|
+
|
|
216
|
+
def test_omits_missing_optional_fields(self):
|
|
217
|
+
page = {"id": "1", "title": "T"}
|
|
218
|
+
result = _slim_page(page)
|
|
219
|
+
assert "version" not in result
|
|
220
|
+
assert "body_storage" not in result
|
|
221
|
+
assert "webui" not in result
|
|
@@ -22,7 +22,7 @@ class TestCLIHelp:
|
|
|
22
22
|
def test_version(self):
|
|
23
23
|
result = runner.invoke(app, ["--version"])
|
|
24
24
|
assert result.exit_code == 0
|
|
25
|
-
assert "
|
|
25
|
+
assert "confpub" in result.output
|
|
26
26
|
|
|
27
27
|
def test_page_help(self):
|
|
28
28
|
result = runner.invoke(app, ["page", "--help"])
|
|
@@ -170,6 +170,39 @@ class TestResolvePageTree:
|
|
|
170
170
|
assert flat[0].assets == ["img/*.png"]
|
|
171
171
|
|
|
172
172
|
|
|
173
|
+
class TestManifestValidationErrors:
|
|
174
|
+
def test_pydantic_errors_are_clean(self, tmp_path):
|
|
175
|
+
"""Pydantic ValidationError should produce clean details, not raw internals."""
|
|
176
|
+
f = tmp_path / "bad_manifest.yaml"
|
|
177
|
+
# conflict_strategy only allows "fail", "overwrite", "skip"
|
|
178
|
+
f.write_text("space: DEV\nparent: Root\nconflict_strategy: invalid_value\n")
|
|
179
|
+
with pytest.raises(ConfpubError) as exc_info:
|
|
180
|
+
load_manifest(str(f))
|
|
181
|
+
err = exc_info.value
|
|
182
|
+
assert err.code == ERR_VALIDATION_MANIFEST
|
|
183
|
+
assert "validation error" in err.error_message
|
|
184
|
+
assert "validation_errors" in err.details
|
|
185
|
+
for entry in err.details["validation_errors"]:
|
|
186
|
+
assert "field" in entry
|
|
187
|
+
assert "message" in entry
|
|
188
|
+
# Should not contain raw Pydantic internals
|
|
189
|
+
assert "input_value" not in str(entry)
|
|
190
|
+
assert "pydantic" not in str(entry).lower()
|
|
191
|
+
|
|
192
|
+
def test_missing_required_fields(self, tmp_path):
|
|
193
|
+
"""Missing required fields produce clean validation errors."""
|
|
194
|
+
f = tmp_path / "minimal.yaml"
|
|
195
|
+
f.write_text("labels: []\n") # missing space and parent
|
|
196
|
+
with pytest.raises(ConfpubError) as exc_info:
|
|
197
|
+
load_manifest(str(f))
|
|
198
|
+
err = exc_info.value
|
|
199
|
+
assert err.code == ERR_VALIDATION_MANIFEST
|
|
200
|
+
assert "validation_errors" in err.details
|
|
201
|
+
fields = [e["field"] for e in err.details["validation_errors"]]
|
|
202
|
+
assert "space" in fields
|
|
203
|
+
assert "parent" in fields
|
|
204
|
+
|
|
205
|
+
|
|
173
206
|
class TestPlanArtifact:
|
|
174
207
|
def test_create_plan(self):
|
|
175
208
|
plan = PlanArtifact(
|
|
@@ -85,6 +85,15 @@ class TestEmitProgress:
|
|
|
85
85
|
|
|
86
86
|
class TestEmitStderr:
|
|
87
87
|
def test_writes_to_stderr(self, capsys):
|
|
88
|
+
set_quiet(False)
|
|
88
89
|
emit_stderr("debug info")
|
|
89
90
|
captured = capsys.readouterr()
|
|
90
91
|
assert "debug info" in captured.err
|
|
92
|
+
set_quiet(None) # type: ignore[arg-type]
|
|
93
|
+
|
|
94
|
+
def test_suppressed_in_quiet_mode(self, capsys):
|
|
95
|
+
set_quiet(True)
|
|
96
|
+
emit_stderr("should not appear")
|
|
97
|
+
captured = capsys.readouterr()
|
|
98
|
+
assert captured.err == ""
|
|
99
|
+
set_quiet(None) # type: ignore[arg-type]
|
|
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
9
|
from confpub.errors import ConfpubError, ERR_IO_FILE_NOT_FOUND
|
|
10
|
-
from confpub.publish import publish_page
|
|
10
|
+
from confpub.publish import derive_title, publish_page
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@pytest.fixture
|
|
@@ -30,6 +30,23 @@ def mock_client():
|
|
|
30
30
|
return client
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
class TestDeriveTitle:
|
|
34
|
+
def test_explicit_title_wins(self):
|
|
35
|
+
assert derive_title("some-file.md", "My Custom Title") == "My Custom Title"
|
|
36
|
+
|
|
37
|
+
def test_derives_from_filename(self):
|
|
38
|
+
assert derive_title("my-cool-page.md") == "My Cool Page"
|
|
39
|
+
|
|
40
|
+
def test_underscores_to_spaces(self):
|
|
41
|
+
assert derive_title("api_reference.md") == "Api Reference"
|
|
42
|
+
|
|
43
|
+
def test_mixed_separators(self):
|
|
44
|
+
assert derive_title("my-api_docs.md") == "My Api Docs"
|
|
45
|
+
|
|
46
|
+
def test_path_uses_stem_only(self):
|
|
47
|
+
assert derive_title("docs/subfolder/overview.md") == "Overview"
|
|
48
|
+
|
|
49
|
+
|
|
33
50
|
class TestPublishDryRun:
|
|
34
51
|
@patch("confpub.publish.load_config")
|
|
35
52
|
@patch("confpub.publish.ConfluenceClient")
|
|
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
|