confpub-cli 1.7.0__tar.gz → 1.7.2__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/.github/copilot-instructions.md +88 -0
  2. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/PKG-INFO +1 -1
  3. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/__init__.py +1 -1
  4. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/converter.py +39 -1
  5. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/guide.py +3 -1
  6. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/reverse_converter.py +15 -7
  7. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_converter.py +74 -0
  8. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_reverse_converter.py +44 -0
  9. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/.github/workflows/publish.yml +0 -0
  10. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/.gitignore +0 -0
  11. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/CLAUDE.md +0 -0
  12. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/LICENSE +0 -0
  13. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/PRD.md +0 -0
  14. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/README.md +0 -0
  15. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/applier.py +0 -0
  16. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/assets.py +0 -0
  17. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/cli.py +0 -0
  18. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/config.py +0 -0
  19. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/confluence.py +0 -0
  20. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/envelope.py +0 -0
  21. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/errors.py +0 -0
  22. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/front_matter.py +0 -0
  23. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/lockfile.py +0 -0
  24. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/macro_plugin.py +0 -0
  25. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/manifest.py +0 -0
  26. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/output.py +0 -0
  27. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/planner.py +0 -0
  28. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/publish.py +0 -0
  29. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/puller.py +0 -0
  30. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/py.typed +0 -0
  31. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/validator.py +0 -0
  32. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/verifier.py +0 -0
  33. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub.lock +0 -0
  34. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/pyproject.toml +0 -0
  35. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/__init__.py +0 -0
  36. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/conftest.py +0 -0
  37. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_applier.py +0 -0
  38. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_assets.py +0 -0
  39. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_config.py +0 -0
  40. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_confluence.py +0 -0
  41. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_envelope.py +0 -0
  42. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_errors.py +0 -0
  43. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_front_matter.py +0 -0
  44. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_guide.py +0 -0
  45. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_integration.py +0 -0
  46. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_lockfile.py +0 -0
  47. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_macro_plugin.py +0 -0
  48. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_manifest.py +0 -0
  49. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_output.py +0 -0
  50. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_planner.py +0 -0
  51. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_publish.py +0 -0
  52. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_puller.py +0 -0
  53. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_validator.py +0 -0
  54. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_verifier.py +0 -0
  55. {confpub_cli-1.7.0 → confpub_cli-1.7.2}/uv.lock +0 -0
@@ -0,0 +1,88 @@
1
+ # Copilot Instructions
2
+
3
+ Agent-first CLI for publishing Markdown to Confluence. Python 3.10+, no linter configured.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ uv pip install -e ".[dev]" # Install with dev deps
9
+ pytest tests/ -v # Run all tests
10
+ pytest tests/test_converter.py -v # Run one module
11
+ pytest tests/test_converter.py::TestHeadings::test_h1 -v # Run single test
12
+ pytest tests/ -k "fingerprint" -v # Run by name pattern
13
+ pytest tests/ -v --cov=confpub # Run with coverage
14
+ uvx hatch version minor # Bump version (patch/minor/major)
15
+ ```
16
+
17
+ ## Architecture
18
+
19
+ ### Command flow
20
+
21
+ Every command follows this path through `cli.py`:
22
+
23
+ 1. Typer handler receives flags and calls `command_context(command_name)` — a context manager that records timing, catches errors, and emits the final JSON envelope on stdout.
24
+ 2. Handler populates a `CommandResult` (result, target, warnings, metrics) then delegates to a domain module (`publish.py`, `applier.py`, `puller.py`, `planner.py`, etc.).
25
+ 3. Domain module calls `ConfluenceClient` (`confluence.py`), which wraps `atlassian-python-api` and translates its exceptions into `ConfpubError`.
26
+ 4. `command_context` serializes an `Envelope` and writes it to stdout; any exception that escapes domain code is caught here too.
27
+
28
+ ### Subcommand groups
29
+
30
+ `page`, `plan`, `auth`, `config`, `space`, `attachment`, `label`, `comment` — each is a `typer.Typer` added to the root `app` in `cli.py`.
31
+
32
+ ### Transactional plan workflow
33
+
34
+ `plan create` → `plan validate` → `plan apply` → `plan verify`
35
+
36
+ Only `plan apply` has side effects. `plan create` fingerprints existing pages (SHA-256 of storage-format body) so that `plan apply` can detect external edits (`ERR_CONFLICT_FINGERPRINT`) before writing.
37
+
38
+ ### Markdown conversion
39
+
40
+ - `converter.py` — `markdown-it-py` + custom `ConfluenceRenderer` → Confluence Storage Format XHTML. Pure function, no I/O.
41
+ - `reverse_converter.py` — BeautifulSoup4 + markdownify → Markdown. Pure function, no I/O.
42
+
43
+ ### Lockfile
44
+
45
+ `confpub.lock` maps page titles → `{page_id, version, content_fingerprint}`. Updated atomically (tempfile + `os.replace`) by publish, pull, apply, and delete.
46
+
47
+ ## Key Conventions
48
+
49
+ ### stdout is JSON-only
50
+
51
+ Every invocation emits exactly one `Envelope` object on stdout. Never write anything else there. Progress events and diagnostics go to stderr via `output.py` helpers (`emit_progress`, `emit_stderr`), and are suppressed when `--quiet`, `LLM=true`, or stdout is not a TTY.
52
+
53
+ ### Error codes are stable public API
54
+
55
+ Error codes use the pattern `ERR_{CATEGORY}_{SPECIFIC}`. Category prefixes map to fixed exit codes:
56
+
57
+ | Prefix | Exit code |
58
+ |---|---|
59
+ | `ERR_VALIDATION` | 10 |
60
+ | `ERR_AUTH` | 20 |
61
+ | `ERR_CONFLICT` | 40 |
62
+ | `ERR_IO` | 50 |
63
+ | `ERR_INTERNAL` | 90 |
64
+
65
+ Never rename or remove an error code constant without a major version bump. Use the builder helpers (`validation_error(...)`, `auth_error(...)`, etc.) in `errors.py` rather than constructing `ConfpubError` directly.
66
+
67
+ ### ConfpubError
68
+
69
+ All domain errors must be `ConfpubError` instances. Fields: `code`, `message`, `retryable`, `suggested_action`, `details`. These flow directly into `Envelope.errors`.
70
+
71
+ ### Pydantic models everywhere
72
+
73
+ `Envelope`, `Lockfile`, `Manifest`, `ConfigModel`, and plan artifacts are all Pydantic v2 `BaseModel`. Use `model_dump(mode="json")` for serialization.
74
+
75
+ ### Credential precedence
76
+
77
+ CLI flags → env vars (`CONFPUB_URL`, `CONFPUB_TOKEN`, `CONFPUB_USER`, `CONFPUB_SPACE`, `CONFPUB_SSL_VERIFY`) → config file (`~/.config/confpub/config.json`) → OS keychain.
78
+
79
+ ### Test conventions
80
+
81
+ - Use the `run_cli` fixture from `conftest.py` (wraps `typer.testing.CliRunner`) for all CLI-level tests.
82
+ - Mock all Confluence API calls with `unittest.mock` — no live integration tests.
83
+ - Group related test cases in classes (e.g. `class TestHeadings`, `class TestApplyPlanReal`).
84
+ - Unit tests for new domain functions go in the matching `test_<module>.py`; CLI-level behavior goes in `test_integration.py`.
85
+
86
+ ## Version
87
+
88
+ Defined in `confpub/__init__.py`. Hatch reads it from `pyproject.toml`. Pushing to `main` triggers the GitHub Actions publish workflow.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confpub-cli
3
- Version: 1.7.0
3
+ Version: 1.7.2
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.0"
3
+ __version__ = "1.7.2"
@@ -713,6 +713,43 @@ def _create_parser() -> MarkdownIt:
713
713
  return md
714
714
 
715
715
 
716
+ _LAYOUT_BLOCK_RE = re.compile(r"<ac:layout>.*?</ac:layout>", re.DOTALL)
717
+
718
+ _LAYOUT_WRAP_PREFIX = (
719
+ '<ac:layout><ac:layout-section ac:type="single"><ac:layout-cell>'
720
+ )
721
+ _LAYOUT_WRAP_SUFFIX = "</ac:layout-cell></ac:layout-section></ac:layout>"
722
+
723
+
724
+ def _wrap_non_layout_content(html: str) -> str:
725
+ """Wrap content outside ``<ac:layout>`` blocks in single-column layouts.
726
+
727
+ Confluence Server's editor cannot handle a mix of layout blocks and
728
+ top-level content on the same page — it enters "layout mode" and makes
729
+ everything outside cells read-only. This function detects when layout
730
+ blocks are present and wraps any surrounding content in its own
731
+ single-column layout so the entire page is in layout mode.
732
+ """
733
+ if "<ac:layout>" not in html:
734
+ return html
735
+
736
+ parts: list[str] = []
737
+ last_end = 0
738
+
739
+ for match in _LAYOUT_BLOCK_RE.finditer(html):
740
+ before = html[last_end:match.start()]
741
+ if before.strip():
742
+ parts.append(_LAYOUT_WRAP_PREFIX + before + _LAYOUT_WRAP_SUFFIX)
743
+ parts.append(match.group(0))
744
+ last_end = match.end()
745
+
746
+ after = html[last_end:]
747
+ if after.strip():
748
+ parts.append(_LAYOUT_WRAP_PREFIX + after + _LAYOUT_WRAP_SUFFIX)
749
+
750
+ return "".join(parts)
751
+
752
+
716
753
  def convert_markdown(md_text: str) -> str:
717
754
  """Convert Markdown text to Confluence Storage Format.
718
755
 
@@ -725,7 +762,8 @@ def convert_markdown(md_text: str) -> str:
725
762
  parser = _create_parser()
726
763
  tokens = parser.parse(md_text)
727
764
  renderer = ConfluenceRenderer()
728
- return renderer.render(tokens, {}, {})
765
+ html = renderer.render(tokens, {}, {})
766
+ return _wrap_non_layout_content(html)
729
767
 
730
768
 
731
769
  def extract_h1_title(md_text: str) -> str | None:
@@ -409,7 +409,7 @@ def build_guide() -> dict[str, Any]:
409
409
  ),
410
410
  "panels": "::: panel Title\\ncontent\\n::: → ac:structured-macro panel",
411
411
  "expand": "::: expand Title\\ncontent\\n::: → ac:structured-macro expand",
412
- "layouts": ":::: layout two-equal\\n::: cell\\n...\\n::::\\n → ac:layout with ac:layout-section",
412
+ "layouts": ":::: layout two-equal\\n::: cell\\n...\\n::::\\n → ac:layout with ac:layout-section. Content outside layout blocks is auto-wrapped in a single-column layout (Confluence requires all content in layout cells when layouts are used).",
413
413
  "status": "{status:Title|colour=Color} → ac:structured-macro status",
414
414
  "toc": "{toc} / {toc:maxLevel=N} → ac:structured-macro toc",
415
415
  "anchor": "{anchor:name} → ac:structured-macro anchor",
@@ -425,6 +425,8 @@ def build_guide() -> dict[str, Any]:
425
425
  "All features are always-on — the parser simply ignores syntax that isn't used. "
426
426
  "Math macros require the Confluence LaTeX Math plugin to be installed on the server. "
427
427
  "Layouts use :::: (4 colons) for the outer layout block and ::: (3 colons) for inner cells. "
428
+ "When layouts are used, ALL page content must live inside layout cells — "
429
+ "confpub auto-wraps any content outside layout blocks in a single-column layout to satisfy this Confluence requirement. "
428
430
  "Use {macro-name:params} for body-less Confluence macros. "
429
431
  "Macros on their own line become block-level (no <p> wrapping)."
430
432
  ),
@@ -452,14 +452,18 @@ def _preprocess_storage_format(html: str) -> tuple[BeautifulSoup, list[str]]:
452
452
  if next_sib and next_sib.name == "ol":
453
453
  hr.decompose()
454
454
 
455
- # 5. Transform ac:layout → div[data-layout-macro]
455
+ # 5. Transform ac:layout → div[data-layout-macro] per section
456
456
  for layout in soup.find_all("ac:layout"):
457
- layout_div = soup.new_tag("div")
458
- layout_div["data-layout-macro"] = "layout"
459
- section = layout.find("ac:layout-section")
460
- if section:
457
+ sections = layout.find_all("ac:layout-section", recursive=False)
458
+ if not sections:
459
+ layout.decompose()
460
+ continue
461
+ # Build one layout div per section, then replace the ac:layout
462
+ section_divs = []
463
+ for section in sections:
464
+ layout_div = soup.new_tag("div")
465
+ layout_div["data-layout-macro"] = "layout"
461
466
  layout_type = section.get("ac:type", "single")
462
- # Convert underscores back to hyphens
463
467
  layout_type = layout_type.replace("_", "-")
464
468
  layout_div["data-layout-type"] = layout_type
465
469
  for cell in section.find_all("ac:layout-cell", recursive=False):
@@ -468,7 +472,11 @@ def _preprocess_storage_format(html: str) -> tuple[BeautifulSoup, list[str]]:
468
472
  for child in list(cell.children):
469
473
  cell_div.append(child.extract())
470
474
  layout_div.append(cell_div)
471
- layout.replace_with(layout_div)
475
+ section_divs.append(layout_div)
476
+ # Insert all section divs after the layout, then remove it
477
+ for div in reversed(section_divs):
478
+ layout.insert_after(div)
479
+ layout.decompose()
472
480
 
473
481
  # 6. Transform ac:link → a
474
482
  for link in soup.find_all("ac:link"):
@@ -421,6 +421,80 @@ class TestContainerLayout:
421
421
  assert result.count("<ac:layout-cell>") == 3
422
422
 
423
423
 
424
+ class TestLayoutWrapping:
425
+ """Confluence Server cannot handle content outside <ac:layout> blocks.
426
+
427
+ When layout blocks are present, all surrounding content must be wrapped
428
+ in single-column layout blocks so the editor stays in layout mode.
429
+ """
430
+
431
+ def test_content_before_layout_is_wrapped(self):
432
+ md = "## Heading\n\nParagraph.\n\n:::: layout two-equal\n::: cell\nLeft\n:::\n::: cell\nRight\n:::\n::::"
433
+ result = convert_markdown(md)
434
+ # The heading and paragraph should be inside a single-column layout
435
+ assert result.startswith('<ac:layout><ac:layout-section ac:type="single"><ac:layout-cell>')
436
+ # The original layout block should still be present
437
+ assert '<ac:layout><ac:layout-section ac:type="two_equal">' in result
438
+
439
+ def test_content_after_layout_is_wrapped(self):
440
+ md = ":::: layout two-equal\n::: cell\nLeft\n:::\n::: cell\nRight\n:::\n::::\n\n## After\n\nMore text."
441
+ result = convert_markdown(md)
442
+ # Should end with a wrapped single-column block
443
+ assert result.endswith("</ac:layout-cell></ac:layout-section></ac:layout>")
444
+ # Count: one two_equal layout + one single wrapper
445
+ assert result.count("<ac:layout>") == 2
446
+
447
+ def test_content_before_and_after_layout_wrapped(self):
448
+ md = (
449
+ "## Before\n\n"
450
+ ":::: layout two-equal\n::: cell\nL\n:::\n::: cell\nR\n:::\n::::\n\n"
451
+ "## After\n"
452
+ )
453
+ result = convert_markdown(md)
454
+ # Two wrappers (before + after) plus the real layout = 3 layout blocks
455
+ assert result.count("<ac:layout>") == 3
456
+ assert result.count("</ac:layout>") == 3
457
+
458
+ def test_no_layout_blocks_unchanged(self):
459
+ md = "## Heading\n\nParagraph."
460
+ result = convert_markdown(md)
461
+ # No layout blocks should be introduced
462
+ assert "<ac:layout>" not in result
463
+
464
+ def test_only_layout_no_extra_wrapping(self):
465
+ md = ":::: layout two-equal\n::: cell\nLeft\n:::\n::: cell\nRight\n:::\n::::"
466
+ result = convert_markdown(md)
467
+ # Only one layout block — no extra wrappers
468
+ assert result.count("<ac:layout>") == 1
469
+
470
+ def test_mixed_content_with_table_and_macros(self):
471
+ """Reproduce the exact scenario from the bug report."""
472
+ md = (
473
+ "## Some heading\n\n"
474
+ "Regular paragraph text.\n\n"
475
+ ":::: layout two-equal\n"
476
+ "::: cell\nLeft column content.\n:::\n"
477
+ "::: cell\nRight column content.\n:::\n"
478
+ "::::\n\n"
479
+ "## Another heading\n\n"
480
+ "| Col A | Col B |\n"
481
+ "|-------|-------|\n"
482
+ "| 1 | 2 |\n"
483
+ )
484
+ result = convert_markdown(md)
485
+ # All content should be inside layout blocks
486
+ assert result.count("<ac:layout>") == 3
487
+ # The heading before should be wrapped
488
+ assert "Some heading" in result
489
+ # The table after should be wrapped
490
+ assert "Col A" in result
491
+ # No top-level content outside layout blocks
492
+ # Split by </ac:layout> and check there's nothing outside
493
+ import re
494
+ outside = re.sub(r"<ac:layout>.*?</ac:layout>", "", result, flags=re.DOTALL)
495
+ assert outside.strip() == ""
496
+
497
+
424
498
  class TestExtractFrontMatter:
425
499
  def test_basic_extraction(self):
426
500
  md = "---\ntitle: Hello\nspace: DEV\n---\n\n# Content"
@@ -355,6 +355,50 @@ class TestLayoutReverse:
355
355
  assert "Right" in result.markdown
356
356
  assert "::::" in result.markdown
357
357
 
358
+ def test_layout_multiple_sections(self):
359
+ """Multiple ac:layout-section elements must all be converted (bug fix)."""
360
+ html = (
361
+ '<ac:layout>'
362
+ '<ac:layout-section ac:type="single">'
363
+ '<ac:layout-cell><p>Section one</p></ac:layout-cell>'
364
+ '</ac:layout-section>'
365
+ '<ac:layout-section ac:type="single">'
366
+ '<ac:layout-cell>'
367
+ '<h1>Main heading</h1>'
368
+ '<p>Body paragraph with <strong>bold</strong> text.</p>'
369
+ '<ul><li>Item A</li><li>Item B</li></ul>'
370
+ '</ac:layout-cell>'
371
+ '</ac:layout-section>'
372
+ '</ac:layout>'
373
+ )
374
+ result = convert_storage_to_markdown(html)
375
+ assert "Section one" in result.markdown
376
+ assert "# Main heading" in result.markdown
377
+ assert "Body paragraph" in result.markdown
378
+ assert "**bold**" in result.markdown
379
+ assert "Item A" in result.markdown
380
+ assert "Item B" in result.markdown
381
+
382
+ def test_layout_mixed_section_types(self):
383
+ """Sections with different layout types preserve their types."""
384
+ html = (
385
+ '<ac:layout>'
386
+ '<ac:layout-section ac:type="single">'
387
+ '<ac:layout-cell><p>Full width</p></ac:layout-cell>'
388
+ '</ac:layout-section>'
389
+ '<ac:layout-section ac:type="two_left_sidebar">'
390
+ '<ac:layout-cell><p>Sidebar</p></ac:layout-cell>'
391
+ '<ac:layout-cell><p>Main content</p></ac:layout-cell>'
392
+ '</ac:layout-section>'
393
+ '</ac:layout>'
394
+ )
395
+ result = convert_storage_to_markdown(html)
396
+ assert ":::: layout single" in result.markdown
397
+ assert ":::: layout two-left-sidebar" in result.markdown
398
+ assert "Full width" in result.markdown
399
+ assert "Sidebar" in result.markdown
400
+ assert "Main content" in result.markdown
401
+
358
402
 
359
403
  class TestPluginRoundTrips:
360
404
  """Test round-trip conversion for new plugin features."""
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