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.
Files changed (46) hide show
  1. confpub_cli-0.2.3/FEEDBACK.md +118 -0
  2. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/PKG-INFO +1 -1
  3. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/__init__.py +1 -1
  4. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/cli.py +20 -9
  5. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/confluence.py +37 -0
  6. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/manifest.py +13 -1
  7. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/output.py +3 -1
  8. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/publish.py +8 -1
  9. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_confluence.py +67 -1
  10. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_integration.py +1 -1
  11. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_manifest.py +33 -0
  12. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_output.py +9 -0
  13. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_publish.py +18 -1
  14. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/.github/workflows/publish.yml +0 -0
  15. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/.gitignore +0 -0
  16. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/LICENSE +0 -0
  17. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/PRD.md +0 -0
  18. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/README.md +0 -0
  19. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/applier.py +0 -0
  20. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/assets.py +0 -0
  21. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/config.py +0 -0
  22. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/converter.py +0 -0
  23. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/envelope.py +0 -0
  24. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/errors.py +0 -0
  25. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/guide.py +0 -0
  26. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/lockfile.py +0 -0
  27. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/planner.py +0 -0
  28. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/py.typed +0 -0
  29. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/validator.py +0 -0
  30. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub/verifier.py +0 -0
  31. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/confpub.lock +0 -0
  32. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/pyproject.toml +0 -0
  33. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/__init__.py +0 -0
  34. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/conftest.py +0 -0
  35. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_applier.py +0 -0
  36. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_assets.py +0 -0
  37. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_config.py +0 -0
  38. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_converter.py +0 -0
  39. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_envelope.py +0 -0
  40. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_errors.py +0 -0
  41. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_guide.py +0 -0
  42. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_lockfile.py +0 -0
  43. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_planner.py +0 -0
  44. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_validator.py +0 -0
  45. {confpub_cli-0.2.2 → confpub_cli-0.2.3}/tests/test_verifier.py +0 -0
  46. {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.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
@@ -1,3 +1,3 @@
1
1
  """confpub — Agent-first CLI to publish Markdown to Confluence."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
@@ -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
- # Phase 6: delegate to confluence.list_pages
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
- target = {"space": space, "title": title or file, "file": file}
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 = title or source_path.stem.replace("-", " ").replace("_", " ").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 "0.1.0" in result.output
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