confpub-cli 1.7.3__tar.gz → 1.7.5__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 (55) hide show
  1. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/PKG-INFO +1 -1
  2. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/__init__.py +1 -1
  3. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/front_matter.py +18 -0
  4. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/manifest.py +3 -0
  5. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/puller.py +35 -51
  6. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_puller.py +14 -10
  7. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/.github/copilot-instructions.md +0 -0
  8. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/.github/workflows/publish.yml +0 -0
  9. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/.gitignore +0 -0
  10. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/CLAUDE.md +0 -0
  11. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/LICENSE +0 -0
  12. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/PRD.md +0 -0
  13. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/README.md +0 -0
  14. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/applier.py +0 -0
  15. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/assets.py +0 -0
  16. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/cli.py +0 -0
  17. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/config.py +0 -0
  18. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/confluence.py +0 -0
  19. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/converter.py +0 -0
  20. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/envelope.py +0 -0
  21. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/errors.py +0 -0
  22. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/guide.py +0 -0
  23. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/lockfile.py +0 -0
  24. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/macro_plugin.py +0 -0
  25. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/output.py +0 -0
  26. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/planner.py +0 -0
  27. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/publish.py +0 -0
  28. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/py.typed +0 -0
  29. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/reverse_converter.py +0 -0
  30. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/validator.py +0 -0
  31. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub/verifier.py +0 -0
  32. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/confpub.lock +0 -0
  33. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/pyproject.toml +0 -0
  34. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/__init__.py +0 -0
  35. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/conftest.py +0 -0
  36. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_applier.py +0 -0
  37. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_assets.py +0 -0
  38. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_config.py +0 -0
  39. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_confluence.py +0 -0
  40. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_converter.py +0 -0
  41. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_envelope.py +0 -0
  42. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_errors.py +0 -0
  43. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_front_matter.py +0 -0
  44. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_guide.py +0 -0
  45. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_integration.py +0 -0
  46. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_lockfile.py +0 -0
  47. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_macro_plugin.py +0 -0
  48. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_manifest.py +0 -0
  49. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_output.py +0 -0
  50. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_planner.py +0 -0
  51. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_publish.py +0 -0
  52. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_reverse_converter.py +0 -0
  53. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_validator.py +0 -0
  54. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/tests/test_verifier.py +0 -0
  55. {confpub_cli-1.7.3 → confpub_cli-1.7.5}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confpub-cli
3
- Version: 1.7.3
3
+ Version: 1.7.5
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__ = "1.7.3"
3
+ __version__ = "1.7.5"
@@ -10,6 +10,8 @@ import dataclasses
10
10
  from dataclasses import dataclass, field
11
11
  from typing import Any
12
12
 
13
+ import yaml
14
+
13
15
  from confpub.converter import extract_front_matter
14
16
  from confpub.errors import ERR_VALIDATION_MARKDOWN, ConfpubError
15
17
 
@@ -24,6 +26,22 @@ class FrontMatterData:
24
26
  labels: list[str] = field(default_factory=list)
25
27
  page_id: str | None = None
26
28
 
29
+ def to_yaml_block(self) -> str:
30
+ """Serialize to a YAML front matter block (``--- ... ---``)."""
31
+ data: dict[str, Any] = {}
32
+ if self.title is not None:
33
+ data["title"] = self.title
34
+ if self.page_id is not None:
35
+ data["page_id"] = self.page_id
36
+ if self.space is not None:
37
+ data["space"] = self.space
38
+ if self.parent is not None:
39
+ data["parent"] = self.parent
40
+ if self.labels:
41
+ data["labels"] = self.labels
42
+ yaml_str = yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
43
+ return f"---\n{yaml_str}---\n\n"
44
+
27
45
 
28
46
  def _validate_string(raw: dict[str, Any], key: str) -> str | None:
29
47
  """Extract and validate a string field, or None if absent."""
@@ -191,6 +191,9 @@ def generate_manifest_yaml(
191
191
  result = []
192
192
  for p in pages:
193
193
  entry: dict[str, Any] = {"title": p["title"], "file": p["file"]}
194
+ assets = p.get("assets", [])
195
+ if assets:
196
+ entry["assets"] = assets
194
197
  labels = p.get("labels", [])
195
198
  if labels:
196
199
  entry["labels"] = labels
@@ -9,9 +9,7 @@ from __future__ import annotations
9
9
  import os
10
10
  import re
11
11
  from pathlib import Path
12
- from typing import Any
13
-
14
- import yaml
12
+ from typing import Any, Literal
15
13
 
16
14
  from confpub.confluence import ConfluenceClient, build_client
17
15
  from confpub.output import emit_progress
@@ -21,10 +19,13 @@ from confpub.errors import (
21
19
  ERR_VALIDATION_REQUIRED,
22
20
  ConfpubError,
23
21
  )
22
+ from confpub.front_matter import FrontMatterData
24
23
  from confpub.lockfile import Lockfile, load_lockfile, save_lockfile, update_lockfile
25
24
  from confpub.manifest import generate_manifest_yaml
26
25
  from confpub.reverse_converter import convert_storage_to_markdown
27
26
 
27
+ Layout = Literal["flat", "nested"]
28
+
28
29
 
29
30
  def _slugify(title: str) -> str:
30
31
  """Convert a page title to a filename-safe slug.
@@ -77,15 +78,22 @@ def _collect_tree(
77
78
  def _compute_file_paths(
78
79
  pages: list[dict[str, Any]],
79
80
  output_dir: str,
80
- layout: str,
81
+ layout: Layout,
81
82
  root_page_id: str,
82
- ) -> dict[str, str]:
83
- """Compute output file paths for each page. Returns {page_id: file_path}."""
83
+ ) -> tuple[dict[str, str], dict[str, str], dict[str, str | None]]:
84
+ """Compute output file paths and lookup maps for each page.
85
+
86
+ Returns (file_paths, id_to_title, id_to_parent) where:
87
+ - file_paths: {page_id: file_path}
88
+ - id_to_title: {page_id: title}
89
+ - id_to_parent: {page_id: parent_page_id or None}
90
+ """
84
91
  paths: dict[str, str] = {}
85
92
  root_slug: str | None = None
86
93
 
87
- # Build parent lookup
94
+ # Build lookup maps
88
95
  id_to_slug: dict[str, str] = {}
96
+ id_to_title: dict[str, str] = {}
89
97
  id_to_parent: dict[str, str | None] = {}
90
98
 
91
99
  for entry in pages:
@@ -93,6 +101,7 @@ def _compute_file_paths(
93
101
  pid = str(page["id"])
94
102
  slug = _slugify(page.get("title", pid))
95
103
  id_to_slug[pid] = slug
104
+ id_to_title[pid] = page.get("title", "")
96
105
  id_to_parent[pid] = str(entry["parent_id"]) if entry["parent_id"] else None
97
106
  if pid == root_page_id:
98
107
  root_slug = slug
@@ -119,7 +128,7 @@ def _compute_file_paths(
119
128
  # Flat layout
120
129
  paths[pid] = os.path.join(output_dir, f"{slug}.md")
121
130
 
122
- return paths
131
+ return paths, id_to_title, id_to_parent
123
132
 
124
133
 
125
134
  def _check_conflicts(file_paths: dict[str, str], force: bool) -> None:
@@ -147,7 +156,7 @@ def _download_page_attachments(
147
156
  page_id: str,
148
157
  slug: str,
149
158
  output_dir: str,
150
- layout: str,
159
+ layout: Layout,
151
160
  warnings: list[str],
152
161
  file_path: str | None = None,
153
162
  ) -> dict[str, str]:
@@ -164,8 +173,6 @@ def _download_page_attachments(
164
173
  if layout == "nested" and file_path:
165
174
  # Place assets next to the markdown file (e.g. .../page-slug/assets/)
166
175
  assets_dir = os.path.join(os.path.dirname(file_path), "assets")
167
- elif layout == "nested":
168
- assets_dir = os.path.join(output_dir, slug, "assets")
169
176
  else:
170
177
  assets_dir = os.path.join(output_dir, "assets", slug)
171
178
 
@@ -194,11 +201,13 @@ def _build_page_tree(
194
201
  root_page_id: str,
195
202
  output_dir: str = ".",
196
203
  page_labels: dict[str, list[str]] | None = None,
204
+ page_assets: dict[str, list[str]] | None = None,
197
205
  ) -> list[dict[str, Any]]:
198
206
  """Build a hierarchical page tree for manifest generation."""
199
207
  id_to_entry: dict[str, dict[str, Any]] = {}
200
208
  children_map: dict[str | None, list[str]] = {}
201
209
  labels_map = page_labels or {}
210
+ assets_map = page_assets or {}
202
211
 
203
212
  for entry in pages:
204
213
  page = entry["page"]
@@ -214,6 +223,8 @@ def _build_page_tree(
214
223
  }
215
224
  if labels_map.get(pid):
216
225
  node["labels"] = labels_map[pid]
226
+ if assets_map.get(pid):
227
+ node["assets"] = assets_map[pid]
217
228
  id_to_entry[pid] = node
218
229
  children_map.setdefault(parent_id, []).append(pid)
219
230
 
@@ -230,27 +241,6 @@ def _build_page_tree(
230
241
  return [root_entry]
231
242
 
232
243
 
233
- def _build_front_matter(
234
- title: str,
235
- page_id: str,
236
- space: str,
237
- parent: str | None = None,
238
- labels: list[str] | None = None,
239
- ) -> str:
240
- """Build a YAML front matter block for a pulled markdown file."""
241
- data: dict[str, Any] = {
242
- "title": title,
243
- "page_id": page_id,
244
- "space": space,
245
- }
246
- if parent:
247
- data["parent"] = parent
248
- if labels:
249
- data["labels"] = labels
250
- yaml_str = yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
251
- return f"---\n{yaml_str}---\n\n"
252
-
253
-
254
244
  def pull_pages(
255
245
  *,
256
246
  space: str | None = None,
@@ -259,7 +249,7 @@ def pull_pages(
259
249
  output_dir: str = ".",
260
250
  recursive: bool = False,
261
251
  force: bool = False,
262
- layout: str = "flat",
252
+ layout: Layout = "flat",
263
253
  include_attachments: bool = True,
264
254
  ) -> dict[str, Any]:
265
255
  """Pull pages from Confluence to local Markdown files.
@@ -292,29 +282,21 @@ def pull_pages(
292
282
  # Collect all pages to pull
293
283
  all_pages = _collect_tree(client, root_id, recursive)
294
284
 
295
- # Compute output file paths
296
- file_paths = _compute_file_paths(all_pages, output_dir, layout, root_id)
285
+ # Compute output file paths and lookup maps
286
+ file_paths, id_to_title, id_to_parent = _compute_file_paths(all_pages, output_dir, layout, root_id)
297
287
 
298
288
  # Check for conflicts
299
289
  _check_conflicts(file_paths, force)
300
290
 
301
- # Build lookup maps for front matter parent resolution
302
- id_to_title: dict[str, str] = {}
303
- id_to_parent: dict[str, str | None] = {}
304
- for entry in all_pages:
305
- page = entry["page"]
306
- pid = str(page["id"])
307
- id_to_title[pid] = page.get("title", "")
308
- id_to_parent[pid] = str(entry["parent_id"]) if entry["parent_id"] else None
309
-
310
- # Get the root page's parent in Confluence (for front matter + manifest)
311
- ancestors = client.get_page_ancestors(root_id)
291
+ # Get the root page's parent from already-fetched ancestors (no extra API call)
292
+ ancestors = root_page.get("ancestors", [])
312
293
  root_parent_title = ancestors[-1].get("title", "") if ancestors else None
313
294
 
314
295
  # Process each page
315
296
  files_result: list[dict[str, Any]] = []
316
297
  total_attachments = 0
317
298
  pull_warnings: list[str] = []
299
+ pulled_assets: dict[str, list[str]] = {} # page_id -> list of relative asset paths
318
300
 
319
301
  for entry in all_pages:
320
302
  page = entry["page"]
@@ -335,6 +317,8 @@ def pull_pages(
335
317
  )
336
318
  attachments_downloaded = len(attachment_map)
337
319
  total_attachments += attachments_downloaded
320
+ if attachment_map:
321
+ pulled_assets[pid] = list(attachment_map.values())
338
322
 
339
323
  # Convert storage format to markdown
340
324
  body_storage = page.get("body", {}).get("storage", {}).get("value", "")
@@ -353,14 +337,14 @@ def pull_pages(
353
337
  parent_title = id_to_title.get(par_id) if par_id else None
354
338
 
355
339
  # Build and prepend front matter
356
- front_matter = _build_front_matter(
340
+ fm = FrontMatterData(
357
341
  title=page_title,
358
342
  page_id=pid,
359
343
  space=root_space,
360
344
  parent=parent_title,
361
- labels=page_labels,
345
+ labels=page_labels or [],
362
346
  )
363
- markdown_content = front_matter + conv_result.markdown
347
+ markdown_content = fm.to_yaml_block() + conv_result.markdown
364
348
 
365
349
  # Write markdown file
366
350
  os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
@@ -377,11 +361,11 @@ def pull_pages(
377
361
 
378
362
  # Always generate manifest
379
363
  root_title = root_page.get("title", "")
380
- manifest_parent = ancestors[-1].get("title", root_title) if ancestors else root_title
364
+ manifest_parent = root_parent_title or root_title
381
365
  pulled_labels: dict[str, list[str]] = {
382
366
  f["page_id"]: f.get("labels", []) for f in files_result
383
367
  }
384
- page_tree = _build_page_tree(all_pages, file_paths, root_id, output_dir, page_labels=pulled_labels)
368
+ page_tree = _build_page_tree(all_pages, file_paths, root_id, output_dir, page_labels=pulled_labels, page_assets=pulled_assets)
385
369
  manifest_yaml = generate_manifest_yaml(root_space, manifest_parent, page_tree)
386
370
  manifest_path = os.path.join(output_dir, "confpub.yaml")
387
371
  Path(manifest_path).write_text(manifest_yaml, encoding="utf-8")
@@ -10,7 +10,8 @@ import pytest
10
10
  import yaml
11
11
 
12
12
  from confpub.errors import ERR_CONFLICT_FILE_EXISTS, ERR_VALIDATION_REQUIRED, ConfpubError
13
- from confpub.puller import _build_front_matter, _slugify, pull_pages
13
+ from confpub.front_matter import FrontMatterData
14
+ from confpub.puller import _slugify, pull_pages
14
15
 
15
16
 
16
17
  # ---------------------------------------------------------------------------
@@ -594,10 +595,10 @@ class TestManifestFlag:
594
595
 
595
596
  class TestBuildFrontMatter:
596
597
  def test_basic_fields(self):
597
- fm = _build_front_matter("My Page", "123", "SD")
598
- assert fm.startswith("---\n")
599
- assert fm.endswith("---\n\n")
600
- parsed = yaml.safe_load(fm.strip("- \n"))
598
+ block = FrontMatterData(title="My Page", page_id="123", space="SD").to_yaml_block()
599
+ assert block.startswith("---\n")
600
+ assert block.endswith("---\n\n")
601
+ parsed = yaml.safe_load(block.strip("- \n"))
601
602
  assert parsed["title"] == "My Page"
602
603
  assert parsed["page_id"] == "123"
603
604
  assert parsed["space"] == "SD"
@@ -605,14 +606,17 @@ class TestBuildFrontMatter:
605
606
  assert "labels" not in parsed
606
607
 
607
608
  def test_with_parent_and_labels(self):
608
- fm = _build_front_matter("Child", "456", "SD", parent="Parent Page", labels=["a", "b"])
609
- parsed = yaml.safe_load(fm.strip("- \n"))
609
+ block = FrontMatterData(
610
+ title="Child", page_id="456", space="SD",
611
+ parent="Parent Page", labels=["a", "b"],
612
+ ).to_yaml_block()
613
+ parsed = yaml.safe_load(block.strip("- \n"))
610
614
  assert parsed["parent"] == "Parent Page"
611
615
  assert parsed["labels"] == ["a", "b"]
612
616
 
613
617
  def test_empty_labels_omitted(self):
614
- fm = _build_front_matter("Page", "1", "SD", labels=[])
615
- parsed = yaml.safe_load(fm.strip("- \n"))
618
+ block = FrontMatterData(title="Page", page_id="1", space="SD").to_yaml_block()
619
+ parsed = yaml.safe_load(block.strip("- \n"))
616
620
  assert "labels" not in parsed
617
621
 
618
622
 
@@ -661,8 +665,8 @@ class TestFrontMatterInPulledFiles:
661
665
  def test_root_page_has_parent_from_ancestors(self, tmp_path):
662
666
  """Root page gets parent from Confluence ancestors."""
663
667
  page = _make_page("1", "Root")
668
+ page["ancestors"] = [{"title": "Space Home"}]
664
669
  client = _mock_client({"1": page})
665
- client.get_page_ancestors = lambda pid: [{"title": "Space Home"}]
666
670
 
667
671
  with patch("confpub.puller.build_client", return_value=client):
668
672
  pull_pages(page_id="1", output_dir=str(tmp_path))
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