confpub-cli 1.7.4__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.4 → confpub_cli-1.7.5}/PKG-INFO +1 -1
  2. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/__init__.py +1 -1
  3. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/front_matter.py +18 -0
  4. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/puller.py +27 -50
  5. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_puller.py +14 -10
  6. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/.github/copilot-instructions.md +0 -0
  7. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/.github/workflows/publish.yml +0 -0
  8. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/.gitignore +0 -0
  9. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/CLAUDE.md +0 -0
  10. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/LICENSE +0 -0
  11. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/PRD.md +0 -0
  12. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/README.md +0 -0
  13. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/applier.py +0 -0
  14. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/assets.py +0 -0
  15. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/cli.py +0 -0
  16. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/config.py +0 -0
  17. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/confluence.py +0 -0
  18. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/converter.py +0 -0
  19. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/envelope.py +0 -0
  20. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/errors.py +0 -0
  21. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/guide.py +0 -0
  22. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/lockfile.py +0 -0
  23. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/macro_plugin.py +0 -0
  24. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/manifest.py +0 -0
  25. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/output.py +0 -0
  26. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/planner.py +0 -0
  27. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/publish.py +0 -0
  28. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/py.typed +0 -0
  29. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/reverse_converter.py +0 -0
  30. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/validator.py +0 -0
  31. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub/verifier.py +0 -0
  32. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/confpub.lock +0 -0
  33. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/pyproject.toml +0 -0
  34. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/__init__.py +0 -0
  35. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/conftest.py +0 -0
  36. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_applier.py +0 -0
  37. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_assets.py +0 -0
  38. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_config.py +0 -0
  39. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_confluence.py +0 -0
  40. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_converter.py +0 -0
  41. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_envelope.py +0 -0
  42. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_errors.py +0 -0
  43. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_front_matter.py +0 -0
  44. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_guide.py +0 -0
  45. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_integration.py +0 -0
  46. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_lockfile.py +0 -0
  47. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_macro_plugin.py +0 -0
  48. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_manifest.py +0 -0
  49. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_output.py +0 -0
  50. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_planner.py +0 -0
  51. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_publish.py +0 -0
  52. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_reverse_converter.py +0 -0
  53. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_validator.py +0 -0
  54. {confpub_cli-1.7.4 → confpub_cli-1.7.5}/tests/test_verifier.py +0 -0
  55. {confpub_cli-1.7.4 → 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.4
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.4"
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."""
@@ -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
 
@@ -234,27 +241,6 @@ def _build_page_tree(
234
241
  return [root_entry]
235
242
 
236
243
 
237
- def _build_front_matter(
238
- title: str,
239
- page_id: str,
240
- space: str,
241
- parent: str | None = None,
242
- labels: list[str] | None = None,
243
- ) -> str:
244
- """Build a YAML front matter block for a pulled markdown file."""
245
- data: dict[str, Any] = {
246
- "title": title,
247
- "page_id": page_id,
248
- "space": space,
249
- }
250
- if parent:
251
- data["parent"] = parent
252
- if labels:
253
- data["labels"] = labels
254
- yaml_str = yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
255
- return f"---\n{yaml_str}---\n\n"
256
-
257
-
258
244
  def pull_pages(
259
245
  *,
260
246
  space: str | None = None,
@@ -263,7 +249,7 @@ def pull_pages(
263
249
  output_dir: str = ".",
264
250
  recursive: bool = False,
265
251
  force: bool = False,
266
- layout: str = "flat",
252
+ layout: Layout = "flat",
267
253
  include_attachments: bool = True,
268
254
  ) -> dict[str, Any]:
269
255
  """Pull pages from Confluence to local Markdown files.
@@ -296,23 +282,14 @@ def pull_pages(
296
282
  # Collect all pages to pull
297
283
  all_pages = _collect_tree(client, root_id, recursive)
298
284
 
299
- # Compute output file paths
300
- 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)
301
287
 
302
288
  # Check for conflicts
303
289
  _check_conflicts(file_paths, force)
304
290
 
305
- # Build lookup maps for front matter parent resolution
306
- id_to_title: dict[str, str] = {}
307
- id_to_parent: dict[str, str | None] = {}
308
- for entry in all_pages:
309
- page = entry["page"]
310
- pid = str(page["id"])
311
- id_to_title[pid] = page.get("title", "")
312
- id_to_parent[pid] = str(entry["parent_id"]) if entry["parent_id"] else None
313
-
314
- # Get the root page's parent in Confluence (for front matter + manifest)
315
- 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", [])
316
293
  root_parent_title = ancestors[-1].get("title", "") if ancestors else None
317
294
 
318
295
  # Process each page
@@ -360,14 +337,14 @@ def pull_pages(
360
337
  parent_title = id_to_title.get(par_id) if par_id else None
361
338
 
362
339
  # Build and prepend front matter
363
- front_matter = _build_front_matter(
340
+ fm = FrontMatterData(
364
341
  title=page_title,
365
342
  page_id=pid,
366
343
  space=root_space,
367
344
  parent=parent_title,
368
- labels=page_labels,
345
+ labels=page_labels or [],
369
346
  )
370
- markdown_content = front_matter + conv_result.markdown
347
+ markdown_content = fm.to_yaml_block() + conv_result.markdown
371
348
 
372
349
  # Write markdown file
373
350
  os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
@@ -384,7 +361,7 @@ def pull_pages(
384
361
 
385
362
  # Always generate manifest
386
363
  root_title = root_page.get("title", "")
387
- manifest_parent = ancestors[-1].get("title", root_title) if ancestors else root_title
364
+ manifest_parent = root_parent_title or root_title
388
365
  pulled_labels: dict[str, list[str]] = {
389
366
  f["page_id"]: f.get("labels", []) for f in files_result
390
367
  }
@@ -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