confpub-cli 1.7.2__tar.gz → 1.7.4__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.2 → confpub_cli-1.7.4}/PKG-INFO +1 -1
  2. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/__init__.py +1 -1
  3. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/cli.py +0 -2
  4. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/manifest.py +3 -0
  5. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/puller.py +84 -25
  6. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_puller.py +106 -15
  7. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/.github/copilot-instructions.md +0 -0
  8. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/.github/workflows/publish.yml +0 -0
  9. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/.gitignore +0 -0
  10. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/CLAUDE.md +0 -0
  11. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/LICENSE +0 -0
  12. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/PRD.md +0 -0
  13. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/README.md +0 -0
  14. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/applier.py +0 -0
  15. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/assets.py +0 -0
  16. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/config.py +0 -0
  17. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/confluence.py +0 -0
  18. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/converter.py +0 -0
  19. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/envelope.py +0 -0
  20. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/errors.py +0 -0
  21. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/front_matter.py +0 -0
  22. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/guide.py +0 -0
  23. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/lockfile.py +0 -0
  24. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/macro_plugin.py +0 -0
  25. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/output.py +0 -0
  26. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/planner.py +0 -0
  27. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/publish.py +0 -0
  28. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/py.typed +0 -0
  29. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/reverse_converter.py +0 -0
  30. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/validator.py +0 -0
  31. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub/verifier.py +0 -0
  32. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/confpub.lock +0 -0
  33. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/pyproject.toml +0 -0
  34. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/__init__.py +0 -0
  35. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/conftest.py +0 -0
  36. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_applier.py +0 -0
  37. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_assets.py +0 -0
  38. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_config.py +0 -0
  39. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_confluence.py +0 -0
  40. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_converter.py +0 -0
  41. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_envelope.py +0 -0
  42. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_errors.py +0 -0
  43. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_front_matter.py +0 -0
  44. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_guide.py +0 -0
  45. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_integration.py +0 -0
  46. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_lockfile.py +0 -0
  47. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_macro_plugin.py +0 -0
  48. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_manifest.py +0 -0
  49. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_output.py +0 -0
  50. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_planner.py +0 -0
  51. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_publish.py +0 -0
  52. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_reverse_converter.py +0 -0
  53. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_validator.py +0 -0
  54. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/tests/test_verifier.py +0 -0
  55. {confpub_cli-1.7.2 → confpub_cli-1.7.4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confpub-cli
3
- Version: 1.7.2
3
+ Version: 1.7.4
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.2"
3
+ __version__ = "1.7.4"
@@ -357,7 +357,6 @@ def page_pull(
357
357
  force: bool = typer.Option(False, "--force", help="Overwrite existing files"),
358
358
  layout: str = typer.Option("flat", "--layout", help="Output layout: flat or nested"),
359
359
  no_attachments: bool = typer.Option(False, "--no-attachments", help="Skip downloading attachments"),
360
- manifest: bool = typer.Option(False, "--manifest", help="Generate confpub.yaml manifest"),
361
360
  ) -> None:
362
361
  """Pull Confluence pages to local Markdown files."""
363
362
  with command_context("page.pull", target={"space": space, "title": title, "page_id": page_id}) as ctx:
@@ -378,7 +377,6 @@ def page_pull(
378
377
  force=force,
379
378
  layout=layout,
380
379
  include_attachments=not no_attachments,
381
- generate_manifest=manifest,
382
380
  )
383
381
  ctx.warnings.extend(result.pop("warnings", []))
384
382
  ctx.result = result
@@ -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
@@ -11,6 +11,8 @@ import re
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
+ import yaml
15
+
14
16
  from confpub.confluence import ConfluenceClient, build_client
15
17
  from confpub.output import emit_progress
16
18
  from confpub.errors import (
@@ -147,6 +149,7 @@ def _download_page_attachments(
147
149
  output_dir: str,
148
150
  layout: str,
149
151
  warnings: list[str],
152
+ file_path: str | None = None,
150
153
  ) -> dict[str, str]:
151
154
  """Download attachments for a page. Returns {attachment_name: local_path}.
152
155
 
@@ -158,7 +161,10 @@ def _download_page_attachments(
158
161
  if not attachments:
159
162
  return attachment_map
160
163
 
161
- if layout == "nested":
164
+ if layout == "nested" and file_path:
165
+ # Place assets next to the markdown file (e.g. .../page-slug/assets/)
166
+ assets_dir = os.path.join(os.path.dirname(file_path), "assets")
167
+ elif layout == "nested":
162
168
  assets_dir = os.path.join(output_dir, slug, "assets")
163
169
  else:
164
170
  assets_dir = os.path.join(output_dir, "assets", slug)
@@ -188,11 +194,13 @@ def _build_page_tree(
188
194
  root_page_id: str,
189
195
  output_dir: str = ".",
190
196
  page_labels: dict[str, list[str]] | None = None,
197
+ page_assets: dict[str, list[str]] | None = None,
191
198
  ) -> list[dict[str, Any]]:
192
199
  """Build a hierarchical page tree for manifest generation."""
193
200
  id_to_entry: dict[str, dict[str, Any]] = {}
194
201
  children_map: dict[str | None, list[str]] = {}
195
202
  labels_map = page_labels or {}
203
+ assets_map = page_assets or {}
196
204
 
197
205
  for entry in pages:
198
206
  page = entry["page"]
@@ -208,6 +216,8 @@ def _build_page_tree(
208
216
  }
209
217
  if labels_map.get(pid):
210
218
  node["labels"] = labels_map[pid]
219
+ if assets_map.get(pid):
220
+ node["assets"] = assets_map[pid]
211
221
  id_to_entry[pid] = node
212
222
  children_map.setdefault(parent_id, []).append(pid)
213
223
 
@@ -224,6 +234,27 @@ def _build_page_tree(
224
234
  return [root_entry]
225
235
 
226
236
 
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
+
227
258
  def pull_pages(
228
259
  *,
229
260
  space: str | None = None,
@@ -234,7 +265,6 @@ def pull_pages(
234
265
  force: bool = False,
235
266
  layout: str = "flat",
236
267
  include_attachments: bool = True,
237
- generate_manifest: bool = False,
238
268
  ) -> dict[str, Any]:
239
269
  """Pull pages from Confluence to local Markdown files.
240
270
 
@@ -272,10 +302,24 @@ def pull_pages(
272
302
  # Check for conflicts
273
303
  _check_conflicts(file_paths, force)
274
304
 
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)
316
+ root_parent_title = ancestors[-1].get("title", "") if ancestors else None
317
+
275
318
  # Process each page
276
319
  files_result: list[dict[str, Any]] = []
277
320
  total_attachments = 0
278
321
  pull_warnings: list[str] = []
322
+ pulled_assets: dict[str, list[str]] = {} # page_id -> list of relative asset paths
279
323
 
280
324
  for entry in all_pages:
281
325
  page = entry["page"]
@@ -288,27 +332,47 @@ def pull_pages(
288
332
  # Download attachments
289
333
  attachment_map: dict[str, str] = {}
290
334
  attachments_downloaded = 0
335
+ out_path = file_paths[pid]
291
336
  if include_attachments:
292
337
  attachment_map = _download_page_attachments(
293
338
  client, pid, slug, output_dir, layout, pull_warnings,
339
+ file_path=out_path,
294
340
  )
295
341
  attachments_downloaded = len(attachment_map)
296
342
  total_attachments += attachments_downloaded
343
+ if attachment_map:
344
+ pulled_assets[pid] = list(attachment_map.values())
297
345
 
298
346
  # Convert storage format to markdown
299
347
  body_storage = page.get("body", {}).get("storage", {}).get("value", "")
300
- result = convert_storage_to_markdown(
348
+ conv_result = convert_storage_to_markdown(
301
349
  body_storage, attachment_map=attachment_map,
302
350
  )
303
351
 
304
- # Write markdown file
305
- out_path = file_paths[pid]
306
- os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
307
- Path(out_path).write_text(result.markdown, encoding="utf-8")
308
-
309
352
  # Fetch labels
310
353
  page_labels = [lbl["name"] for lbl in client.get_labels(pid)]
311
354
 
355
+ # Determine parent title for front matter
356
+ if pid == root_id:
357
+ parent_title = root_parent_title
358
+ else:
359
+ par_id = id_to_parent.get(pid)
360
+ parent_title = id_to_title.get(par_id) if par_id else None
361
+
362
+ # Build and prepend front matter
363
+ front_matter = _build_front_matter(
364
+ title=page_title,
365
+ page_id=pid,
366
+ space=root_space,
367
+ parent=parent_title,
368
+ labels=page_labels,
369
+ )
370
+ markdown_content = front_matter + conv_result.markdown
371
+
372
+ # Write markdown file
373
+ os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
374
+ Path(out_path).write_text(markdown_content, encoding="utf-8")
375
+
312
376
  files_result.append({
313
377
  "page_id": pid,
314
378
  "title": page_title,
@@ -318,22 +382,17 @@ def pull_pages(
318
382
  "labels": page_labels,
319
383
  })
320
384
 
321
- # Generate manifest if explicitly requested
322
- manifest_file: str | None = None
323
- if generate_manifest:
324
- root_title = root_page.get("title", "")
325
- # Determine the actual parent of the root page
326
- ancestors = client.get_page_ancestors(root_id)
327
- manifest_parent = ancestors[-1].get("title", root_title) if ancestors else root_title
328
- # Collect labels by page ID for manifest generation
329
- pulled_labels: dict[str, list[str]] = {
330
- f["page_id"]: f.get("labels", []) for f in files_result
331
- }
332
- page_tree = _build_page_tree(all_pages, file_paths, root_id, output_dir, page_labels=pulled_labels)
333
- manifest_yaml = generate_manifest_yaml(root_space, manifest_parent, page_tree)
334
- manifest_path = os.path.join(output_dir, "confpub.yaml")
335
- Path(manifest_path).write_text(manifest_yaml, encoding="utf-8")
336
- manifest_file = manifest_path
385
+ # Always generate manifest
386
+ root_title = root_page.get("title", "")
387
+ manifest_parent = ancestors[-1].get("title", root_title) if ancestors else root_title
388
+ pulled_labels: dict[str, list[str]] = {
389
+ f["page_id"]: f.get("labels", []) for f in files_result
390
+ }
391
+ page_tree = _build_page_tree(all_pages, file_paths, root_id, output_dir, page_labels=pulled_labels, page_assets=pulled_assets)
392
+ manifest_yaml = generate_manifest_yaml(root_space, manifest_parent, page_tree)
393
+ manifest_path = os.path.join(output_dir, "confpub.yaml")
394
+ Path(manifest_path).write_text(manifest_yaml, encoding="utf-8")
395
+ manifest_file: str = manifest_path
337
396
 
338
397
  # Update lockfile
339
398
  lockfile_path = os.path.join(output_dir, "confpub.lock")
@@ -356,6 +415,6 @@ def pull_pages(
356
415
  "summary": {
357
416
  "pages_pulled": len(files_result),
358
417
  "attachments_downloaded": total_attachments,
359
- "manifest_generated": manifest_file is not None,
418
+ "manifest_generated": True,
360
419
  },
361
420
  }
@@ -7,8 +7,10 @@ from unittest.mock import MagicMock, patch
7
7
 
8
8
  import pytest
9
9
 
10
+ import yaml
11
+
10
12
  from confpub.errors import ERR_CONFLICT_FILE_EXISTS, ERR_VALIDATION_REQUIRED, ConfpubError
11
- from confpub.puller import _slugify, pull_pages
13
+ from confpub.puller import _build_front_matter, _slugify, pull_pages
12
14
 
13
15
 
14
16
  # ---------------------------------------------------------------------------
@@ -61,12 +63,16 @@ def _mock_client(pages: dict[str, dict], children: dict[str, list] | None = None
61
63
  def get_page_ancestors(pid):
62
64
  return []
63
65
 
66
+ def get_labels(pid):
67
+ return []
68
+
64
69
  client.get_page_by_id = get_page_by_id
65
70
  client.get_page = get_page
66
71
  client.get_page_children_deep = get_page_children_deep
67
72
  client.get_attachments = get_attachments
68
73
  client.download_attachment = download_attachment
69
74
  client.get_page_ancestors = get_page_ancestors
75
+ client.get_labels = get_labels
70
76
  return client
71
77
 
72
78
 
@@ -113,11 +119,14 @@ class TestSinglePagePull:
113
119
  assert result["files"][0]["page_id"] == "123"
114
120
  assert result["files"][0]["title"] == "Overview"
115
121
 
116
- # File should exist
122
+ # File should exist with front matter
117
123
  md_file = tmp_path / "overview.md"
118
124
  assert md_file.exists()
119
125
  content = md_file.read_text()
126
+ assert content.startswith("---\n")
120
127
  assert "Hello world" in content
128
+ assert "page_id: '123'" in content
129
+ assert "title: Overview" in content
121
130
 
122
131
  def test_pull_single_page_by_space_title(self, tmp_path):
123
132
  page = _make_page("456", "Getting Started", "<h1>Welcome</h1>")
@@ -180,7 +189,6 @@ class TestRecursivePull:
180
189
  page_id="1",
181
190
  output_dir=str(tmp_path),
182
191
  recursive=True,
183
- generate_manifest=True,
184
192
  )
185
193
 
186
194
  assert result["summary"]["manifest_generated"] is True
@@ -410,7 +418,6 @@ class TestDataCenterCompat:
410
418
  result = pull_pages(
411
419
  space="PROJ", title="Root",
412
420
  output_dir=str(tmp_path), recursive=True,
413
- generate_manifest=True,
414
421
  )
415
422
 
416
423
  assert result["summary"]["pages_pulled"] == 2
@@ -482,6 +489,7 @@ class TestMultiLevelRecursivePull:
482
489
  assert (tmp_path / "child.md").exists()
483
490
  assert (tmp_path / "grandchild.md").exists()
484
491
  gc_content = (tmp_path / "grandchild.md").read_text()
492
+ assert gc_content.startswith("---\n")
485
493
  assert "Deep content" in gc_content
486
494
 
487
495
  def test_page_id_recursive_combination(self, tmp_path):
@@ -527,7 +535,6 @@ class TestManifestPathSeparators:
527
535
  output_dir=str(tmp_path),
528
536
  recursive=True,
529
537
  layout="nested",
530
- generate_manifest=True,
531
538
  )
532
539
 
533
540
  manifest_content = Path(result["manifest_file"]).read_text()
@@ -537,10 +544,10 @@ class TestManifestPathSeparators:
537
544
  assert "\\" not in line, f"Backslash found in manifest path: {line}"
538
545
 
539
546
 
540
- class TestNestedLayoutNoAutoManifest:
541
- """Bug 4: --layout nested without --manifest should NOT create confpub.yaml."""
547
+ class TestManifestAlwaysGenerated:
548
+ """Manifest is always generated regardless of layout."""
542
549
 
543
- def test_nested_without_manifest_flag(self, tmp_path):
550
+ def test_nested_always_generates_manifest(self, tmp_path):
544
551
  root = _make_page("1", "Root")
545
552
  child = _make_page("2", "Child")
546
553
  pages = {"1": root, "2": child}
@@ -553,17 +560,16 @@ class TestNestedLayoutNoAutoManifest:
553
560
  output_dir=str(tmp_path),
554
561
  recursive=True,
555
562
  layout="nested",
556
- generate_manifest=False,
557
563
  )
558
564
 
559
- assert result["summary"]["manifest_generated"] is False
560
- assert result["manifest_file"] is None
561
- assert not (tmp_path / "confpub.yaml").exists()
565
+ assert result["summary"]["manifest_generated"] is True
566
+ assert result["manifest_file"] is not None
567
+ assert (tmp_path / "confpub.yaml").exists()
562
568
 
563
569
 
564
570
  class TestManifestFlag:
565
- def test_single_page_with_manifest_flag(self, tmp_path):
566
- """--manifest generates confpub.yaml even for a single page."""
571
+ def test_single_page_generates_manifest(self, tmp_path):
572
+ """Manifest is always generated, even for a single page."""
567
573
  page = _make_page("1", "Solo Page")
568
574
  client = _mock_client({"1": page})
569
575
 
@@ -571,7 +577,6 @@ class TestManifestFlag:
571
577
  result = pull_pages(
572
578
  page_id="1",
573
579
  output_dir=str(tmp_path),
574
- generate_manifest=True,
575
580
  )
576
581
 
577
582
  assert result["summary"]["manifest_generated"] is True
@@ -580,3 +585,89 @@ class TestManifestFlag:
580
585
  assert manifest.exists()
581
586
  content = manifest.read_text()
582
587
  assert "Solo Page" in content
588
+
589
+
590
+ # ---------------------------------------------------------------------------
591
+ # Front matter tests
592
+ # ---------------------------------------------------------------------------
593
+
594
+
595
+ class TestBuildFrontMatter:
596
+ 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"))
601
+ assert parsed["title"] == "My Page"
602
+ assert parsed["page_id"] == "123"
603
+ assert parsed["space"] == "SD"
604
+ assert "parent" not in parsed
605
+ assert "labels" not in parsed
606
+
607
+ 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"))
610
+ assert parsed["parent"] == "Parent Page"
611
+ assert parsed["labels"] == ["a", "b"]
612
+
613
+ def test_empty_labels_omitted(self):
614
+ fm = _build_front_matter("Page", "1", "SD", labels=[])
615
+ parsed = yaml.safe_load(fm.strip("- \n"))
616
+ assert "labels" not in parsed
617
+
618
+
619
+ class TestFrontMatterInPulledFiles:
620
+ def test_front_matter_fields_correct(self, tmp_path):
621
+ """Pulled files contain correct front matter metadata."""
622
+ root = _make_page("1", "Root", space_key="SD")
623
+ child = _make_page("2", "Child Page", "<p>Body</p>", space_key="SD")
624
+ pages = {"1": root, "2": child}
625
+ children = {"1": [child]}
626
+ client = _mock_client(pages, children)
627
+ client.get_labels = lambda pid: [{"name": "my-label"}] if pid == "2" else []
628
+
629
+ with patch("confpub.puller.build_client", return_value=client):
630
+ pull_pages(
631
+ page_id="1",
632
+ output_dir=str(tmp_path),
633
+ recursive=True,
634
+ )
635
+
636
+ # Check child file front matter
637
+ child_content = (tmp_path / "child-page.md").read_text()
638
+ assert child_content.startswith("---\n")
639
+ # Extract YAML block
640
+ parts = child_content.split("---\n", 2)
641
+ fm = yaml.safe_load(parts[1])
642
+ assert fm["title"] == "Child Page"
643
+ assert fm["page_id"] == "2"
644
+ assert fm["space"] == "SD"
645
+ assert fm["parent"] == "Root"
646
+ assert fm["labels"] == ["my-label"]
647
+
648
+ def test_root_page_has_no_parent_when_no_ancestors(self, tmp_path):
649
+ """Root page omits parent when Confluence has no ancestors."""
650
+ page = _make_page("1", "Root")
651
+ client = _mock_client({"1": page})
652
+
653
+ with patch("confpub.puller.build_client", return_value=client):
654
+ pull_pages(page_id="1", output_dir=str(tmp_path))
655
+
656
+ content = (tmp_path / "root.md").read_text()
657
+ parts = content.split("---\n", 2)
658
+ fm = yaml.safe_load(parts[1])
659
+ assert "parent" not in fm
660
+
661
+ def test_root_page_has_parent_from_ancestors(self, tmp_path):
662
+ """Root page gets parent from Confluence ancestors."""
663
+ page = _make_page("1", "Root")
664
+ client = _mock_client({"1": page})
665
+ client.get_page_ancestors = lambda pid: [{"title": "Space Home"}]
666
+
667
+ with patch("confpub.puller.build_client", return_value=client):
668
+ pull_pages(page_id="1", output_dir=str(tmp_path))
669
+
670
+ content = (tmp_path / "root.md").read_text()
671
+ parts = content.split("---\n", 2)
672
+ fm = yaml.safe_load(parts[1])
673
+ assert fm["parent"] == "Space Home"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes