confpub-cli 1.7.0__tar.gz → 1.7.1__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.1/.github/copilot-instructions.md +88 -0
  2. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/PKG-INFO +1 -1
  3. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/__init__.py +1 -1
  4. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/converter.py +39 -1
  5. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/guide.py +3 -1
  6. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_converter.py +74 -0
  7. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/.github/workflows/publish.yml +0 -0
  8. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/.gitignore +0 -0
  9. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/CLAUDE.md +0 -0
  10. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/LICENSE +0 -0
  11. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/PRD.md +0 -0
  12. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/README.md +0 -0
  13. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/applier.py +0 -0
  14. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/assets.py +0 -0
  15. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/cli.py +0 -0
  16. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/config.py +0 -0
  17. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/confluence.py +0 -0
  18. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/envelope.py +0 -0
  19. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/errors.py +0 -0
  20. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/front_matter.py +0 -0
  21. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/lockfile.py +0 -0
  22. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/macro_plugin.py +0 -0
  23. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/manifest.py +0 -0
  24. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/output.py +0 -0
  25. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/planner.py +0 -0
  26. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/publish.py +0 -0
  27. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/puller.py +0 -0
  28. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/py.typed +0 -0
  29. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/reverse_converter.py +0 -0
  30. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/validator.py +0 -0
  31. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub/verifier.py +0 -0
  32. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/confpub.lock +0 -0
  33. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/pyproject.toml +0 -0
  34. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/__init__.py +0 -0
  35. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/conftest.py +0 -0
  36. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_applier.py +0 -0
  37. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_assets.py +0 -0
  38. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_config.py +0 -0
  39. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_confluence.py +0 -0
  40. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_envelope.py +0 -0
  41. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_errors.py +0 -0
  42. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_front_matter.py +0 -0
  43. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_guide.py +0 -0
  44. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_integration.py +0 -0
  45. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_lockfile.py +0 -0
  46. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_macro_plugin.py +0 -0
  47. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_manifest.py +0 -0
  48. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_output.py +0 -0
  49. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_planner.py +0 -0
  50. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_publish.py +0 -0
  51. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_puller.py +0 -0
  52. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_reverse_converter.py +0 -0
  53. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_validator.py +0 -0
  54. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/tests/test_verifier.py +0 -0
  55. {confpub_cli-1.7.0 → confpub_cli-1.7.1}/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.1
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.1"
@@ -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
  ),
@@ -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"
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