mkdocs2confluence 0.20.2__tar.gz → 0.20.3__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 (110) hide show
  1. {mkdocs2confluence-0.20.2/src/mkdocs2confluence.egg-info → mkdocs2confluence-0.20.3}/PKG-INFO +1 -1
  2. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/pyproject.toml +1 -1
  3. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3/src/mkdocs2confluence.egg-info}/PKG-INFO +1 -1
  4. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/emitter/xhtml.py +19 -24
  5. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/publisher/client.py +22 -18
  6. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/publisher/http_retry.py +1 -1
  7. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_emitter.py +14 -0
  8. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_publish_client.py +46 -0
  9. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/LICENSE +0 -0
  10. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/README.md +0 -0
  11. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/setup.cfg +0 -0
  12. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  13. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  14. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  15. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  16. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  17. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/__init__.py +0 -0
  18. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/cli.py +0 -0
  19. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/compiler/__init__.py +0 -0
  20. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/compiler/models.py +0 -0
  21. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/compiler/page.py +0 -0
  22. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  23. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  24. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/ir/nodes.py +0 -0
  25. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  26. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  27. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/loader/config.py +0 -0
  28. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  29. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  30. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/loader/page.py +0 -0
  31. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  32. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  33. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  34. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  35. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/pdf/render.py +0 -0
  36. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  37. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  38. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/preprocess/captions.py +0 -0
  39. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  40. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  41. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  42. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  43. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  44. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  45. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/preview/render.py +0 -0
  46. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/preview/server.py +0 -0
  47. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  48. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/publisher/changelog.py +0 -0
  49. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/publisher/executor.py +0 -0
  50. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/publisher/models.py +0 -0
  51. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/publisher/planner.py +0 -0
  52. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/skill_installer.py +0 -0
  53. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/skills/mkdocs-changelog/SKILL.md +0 -0
  54. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/skills/mkdocs-changelog/scripts/changelog_data.py +0 -0
  55. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/sync/__init__.py +0 -0
  56. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/sync/anchoring.py +0 -0
  57. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/sync/command.py +0 -0
  58. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/sync/comments.py +0 -0
  59. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/sync/github.py +0 -0
  60. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/sync/platform.py +0 -0
  61. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/sync/state.py +0 -0
  62. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  63. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/_kroki.py +0 -0
  64. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  65. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/admonition_titles.py +0 -0
  66. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  67. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/attachment_previews.py +0 -0
  68. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/captions.py +0 -0
  69. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  70. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/footer.py +0 -0
  71. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  72. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  73. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  74. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/src/mkdocs_to_confluence/transforms/plantuml.py +0 -0
  75. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_abbrevs.py +0 -0
  76. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_admonition_titles.py +0 -0
  77. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_attachment_previews.py +0 -0
  78. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_captions.py +0 -0
  79. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_changelog_config.py +0 -0
  80. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_changelog_publish.py +0 -0
  81. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_children_macro.py +0 -0
  82. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_cli.py +0 -0
  83. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_editlink.py +0 -0
  84. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_exclude_properties_config.py +0 -0
  85. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_extra_css.py +0 -0
  86. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_footer.py +0 -0
  87. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_frontmatter.py +0 -0
  88. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_icons.py +0 -0
  89. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_images.py +0 -0
  90. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_internallinks.py +0 -0
  91. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_ir.py +0 -0
  92. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_linkdefs.py +0 -0
  93. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_loader.py +0 -0
  94. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_mermaid.py +0 -0
  95. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_page_loader.py +0 -0
  96. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_parser.py +0 -0
  97. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_pdf.py +0 -0
  98. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_plantuml.py +0 -0
  99. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_preprocess.py +0 -0
  100. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_preview.py +0 -0
  101. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_publish_config.py +0 -0
  102. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_publish_pipeline.py +0 -0
  103. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_server.py +0 -0
  104. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_skill_installer.py +0 -0
  105. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_sync_anchoring.py +0 -0
  106. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_sync_command.py +0 -0
  107. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_sync_comments.py +0 -0
  108. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_sync_github.py +0 -0
  109. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_sync_state.py +0 -0
  110. {mkdocs2confluence-0.20.2 → mkdocs2confluence-0.20.3}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.20.2
3
+ Version: 0.20.3
4
4
  Summary: Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more
5
5
  Author: Anders Hybertz
6
6
  License: GPL-3.0-or-later
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.20.2"
3
+ version = "0.20.3"
4
4
  description = "Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more"
5
5
  readme = "README.md"
6
6
  license = { text = "GPL-3.0-or-later" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.20.2
3
+ Version: 0.20.3
4
4
  Summary: Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more
5
5
  Author: Anders Hybertz
6
6
  License: GPL-3.0-or-later
@@ -258,13 +258,9 @@ def _emit_section(node: Section) -> str:
258
258
  # Confluence's auto heading-anchors use a different naming scheme than the
259
259
  # MkDocs slug our links carry, so without an explicit named anchor a
260
260
  # `#slug` link lands at the top of the page instead of the section.
261
- name = html.escape(node.explicit_anchor or node.anchor)
261
+ name = node.explicit_anchor or node.anchor
262
262
  if name:
263
- anchor = (
264
- f'<ac:structured-macro ac:name="anchor">'
265
- f'<ac:parameter ac:name=""><![CDATA[{name}]]></ac:parameter>'
266
- f"</ac:structured-macro>"
267
- )
263
+ anchor = _anchor_macro(name)
268
264
  heading = f"{anchor}<{tag}{style_attr}>{title_html}</{tag}>\n"
269
265
  body = emit(node.children)
270
266
  return heading + body
@@ -303,6 +299,20 @@ def _emit_front_matter(node: FrontMatter) -> str:
303
299
  return "".join(parts)
304
300
 
305
301
 
302
+ def _anchor_macro(name: str) -> str:
303
+ """Confluence anchor macro for *name*.
304
+
305
+ The name sits in CDATA, which is not entity-decoded, so it must not be
306
+ HTML-escaped — only a literal ``]]>`` needs splitting.
307
+ """
308
+ safe = name.replace("]]>", "]]]]><![CDATA[>")
309
+ return (
310
+ f'<ac:structured-macro ac:name="anchor">'
311
+ f'<ac:parameter ac:name=""><![CDATA[{safe}]]></ac:parameter>'
312
+ f"</ac:structured-macro>"
313
+ )
314
+
315
+
306
316
  def _xml_safe(text: str) -> str:
307
317
  """HTML-escape *text* and encode non-ASCII as XML numeric character references.
308
318
 
@@ -694,12 +704,7 @@ def _emit_abbrev_glossary_block(node: AbbrevGlossaryBlock) -> str:
694
704
  """End-of-page abbreviations list with Confluence anchor targets."""
695
705
  parts: list[str] = ["<hr />\n<h6>Abbreviations</h6>\n<ol>\n"]
696
706
  for fn in node.footnoted:
697
- anchor = html.escape(f"abbr-{fn.number}")
698
- anchor_macro = (
699
- f'<ac:structured-macro ac:name="anchor">'
700
- f'<ac:parameter ac:name=""><![CDATA[{anchor}]]></ac:parameter>'
701
- f"</ac:structured-macro>"
702
- )
707
+ anchor_macro = _anchor_macro(f"abbr-{fn.number}")
703
708
  abbr = html.escape(fn.abbr)
704
709
  defn = _emit_inlines(fn.definition)
705
710
  parts.append(f"<li>{anchor_macro}<strong>{abbr}</strong> — {defn}</li>\n")
@@ -714,12 +719,7 @@ def _emit_footnote_block(node: FootnoteBlock) -> str:
714
719
  """Footnotes section: heading + ordered list with anchor targets."""
715
720
  items: list[str] = []
716
721
  for fn in node.items:
717
- anchor = html.escape(f"fn-{fn.label}")
718
- anchor_macro = (
719
- f'<ac:structured-macro ac:name="anchor">'
720
- f'<ac:parameter ac:name=""><![CDATA[{anchor}]]></ac:parameter>'
721
- f"</ac:structured-macro>"
722
- )
722
+ anchor_macro = _anchor_macro(f"fn-{fn.label}")
723
723
  content = _emit_inlines(fn.children)
724
724
  items.append(f"<li>{anchor_macro}{content}</li>\n")
725
725
  return "<h2>Footnotes</h2>\n<ol>\n" + "".join(items) + "</ol>\n"
@@ -780,12 +780,7 @@ def _emit_inline(node: IRNode) -> str:
780
780
  if isinstance(node, RawInlineHtml):
781
781
  return node.html_str
782
782
  if isinstance(node, AnchorNode):
783
- name = html.escape(node.name)
784
- return (
785
- f'<ac:structured-macro ac:name="anchor">'
786
- f"<ac:parameter ac:name=\"\"><![CDATA[{name}]]></ac:parameter>"
787
- f"</ac:structured-macro>"
788
- )
783
+ return _anchor_macro(node.name)
789
784
  # Fallback: emit unknown inline nodes as escaped repr
790
785
  return html.escape(repr(node))
791
786
 
@@ -67,6 +67,10 @@ class ConfluenceClient:
67
67
  raise RuntimeError("ConfluenceClient must be used as a context manager.")
68
68
  return self._client
69
69
 
70
+ def _get(self, url: str, **kwargs: Any) -> httpx.Response:
71
+ # Reads are rate-limited too; retry 429s like writes do.
72
+ return http_request_with_retry(lambda: self._http.get(url, **kwargs), f"GET {url}")
73
+
70
74
  def _base(self) -> str:
71
75
  # Strip trailing slash and any trailing /wiki so users can supply either
72
76
  # "https://org.atlassian.net" or "https://org.atlassian.net/wiki".
@@ -95,7 +99,7 @@ class ConfluenceClient:
95
99
  Raises :class:`ConfluenceError` when the space is not found.
96
100
  """
97
101
  url = self._v2("/spaces")
98
- resp = self._http.get(url, params={"keys": space_key, "limit": 1})
102
+ resp = self._get(url, params={"keys": space_key, "limit": 1})
99
103
  self._raise_for_status(resp, f"get_space_id({space_key!r})")
100
104
  data = resp.json()
101
105
  results = data.get("results", [])
@@ -112,7 +116,7 @@ class ConfluenceClient:
112
116
  Raises :class:`ConfluenceError` when the page is not found.
113
117
  """
114
118
  url = self._v2(f"/pages/{page_id}")
115
- resp = self._http.get(url)
119
+ resp = self._get(url)
116
120
  if resp.status_code == 404:
117
121
  raise ConfluenceError(
118
122
  f"Parent page {page_id!r} not found (HTTP 404). "
@@ -147,7 +151,7 @@ class ConfluenceClient:
147
151
  else:
148
152
  url = self._v2(f"/pages/{parent_id}/direct-children")
149
153
 
150
- resp = self._http.get(url, params={"limit": 250})
154
+ resp = self._get(url, params={"limit": 250})
151
155
  self._raise_for_status(resp, f"find_folder_under({title!r})")
152
156
  for item in resp.json().get("results", []):
153
157
  if item.get("type") == "folder" and item.get("title") == title:
@@ -168,7 +172,7 @@ class ConfluenceClient:
168
172
  if not space_key:
169
173
  return None
170
174
  cql = f'type=folder AND title="{title}" AND space="{space_key}"'
171
- resp = self._http.get(
175
+ resp = self._get(
172
176
  self._v1("/content/search"),
173
177
  params={"cql": cql, "limit": 10},
174
178
  )
@@ -218,7 +222,7 @@ class ConfluenceClient:
218
222
  return pages from other spaces.
219
223
  """
220
224
  url = self._v2(f"/spaces/{space_id}/pages")
221
- resp = self._http.get(
225
+ resp = self._get(
222
226
  url,
223
227
  params={"title": title, "status": "current", "limit": 10},
224
228
  )
@@ -303,7 +307,7 @@ class ConfluenceClient:
303
307
  """
304
308
  key = "content-appearance-published"
305
309
  prop_url = self._v1(f"/content/{page_id}/property/{key}")
306
- get_resp = self._http.get(prop_url)
310
+ get_resp = self._get(prop_url)
307
311
 
308
312
  if get_resp.status_code == 200:
309
313
  current_version = get_resp.json().get("version", {}).get("number", 1)
@@ -330,7 +334,7 @@ class ConfluenceClient:
330
334
  can safely treat a missing hash as "unknown — must update".
331
335
  """
332
336
  url = self._v1(f"/content/{page_id}/property/mk2conf-content-hash")
333
- resp = self._http.get(url)
337
+ resp = self._get(url)
334
338
  if resp.is_error:
335
339
  return None
336
340
  value = resp.json().get("value", "")
@@ -345,7 +349,7 @@ class ConfluenceClient:
345
349
  """
346
350
  key = "mk2conf-content-hash"
347
351
  prop_url = self._v1(f"/content/{page_id}/property/{key}")
348
- get_resp = self._http.get(prop_url)
352
+ get_resp = self._get(prop_url)
349
353
  if get_resp.status_code == 200:
350
354
  current_version = get_resp.json().get("version", {}).get("number", 1)
351
355
  http_request_with_retry(
@@ -373,7 +377,7 @@ class ConfluenceClient:
373
377
  label_url = self._v1(f"/content/{page_id}/label")
374
378
 
375
379
  # Remove all existing labels
376
- existing_resp = self._http.get(label_url)
380
+ existing_resp = self._get(label_url)
377
381
  if existing_resp.status_code == 200:
378
382
  for lbl in existing_resp.json().get("results", []):
379
383
  name = lbl.get("name", "")
@@ -453,7 +457,7 @@ class ConfluenceClient:
453
457
  Returns the ``spaceContentStates`` list, which is the same for every
454
458
  page in the space.
455
459
  """
456
- resp = self._http.get(self._v1(f"/content/{page_id}/state/available"))
460
+ resp = self._get(self._v1(f"/content/{page_id}/state/available"))
457
461
  if resp.is_success:
458
462
  data: dict[str, Any] = resp.json()
459
463
  return list(data.get("spaceContentStates") or [])
@@ -466,7 +470,7 @@ class ConfluenceClient:
466
470
  Uses the v2 ``GET /pages/{id}/attachments`` endpoint.
467
471
  """
468
472
  url = self._v2(f"/pages/{page_id}/attachments")
469
- resp = self._http.get(url, params={"limit": 250})
473
+ resp = self._get(url, params={"limit": 250})
470
474
  self._raise_for_status(resp, f"list_attachments({page_id!r})")
471
475
  results: list[dict[str, Any]] = resp.json().get("results", [])
472
476
  return {r["title"]: r for r in results}
@@ -533,7 +537,7 @@ class ConfluenceClient:
533
537
  a publish.
534
538
  """
535
539
  url = self._v2(f"/pages/{page_id}/properties/mk2conf-managed")
536
- get_resp = self._http.get(url)
540
+ get_resp = self._get(url)
537
541
  if get_resp.status_code == 200:
538
542
  return # already stamped
539
543
  http_request_with_retry(
@@ -555,7 +559,7 @@ class ConfluenceClient:
555
559
  url = self._v2(f"/pages/{page_id}/descendants")
556
560
  params: dict[str, str | int] = {"depth": "all", "limit": 250}
557
561
  while True:
558
- resp = self._http.get(url, params=params)
562
+ resp = self._get(url, params=params)
559
563
  self._raise_for_status(resp, f"get_descendant_ids({page_id!r})")
560
564
  data = resp.json()
561
565
  for item in data.get("results", []):
@@ -571,7 +575,7 @@ class ConfluenceClient:
571
575
 
572
576
  def is_managed(self, page_id: str) -> bool:
573
577
  """Return ``True`` if *page_id* has the ``mk2conf-managed`` property."""
574
- resp = self._http.get(self._v2(f"/pages/{page_id}/properties/mk2conf-managed"))
578
+ resp = self._get(self._v2(f"/pages/{page_id}/properties/mk2conf-managed"))
575
579
  return resp.status_code == 200
576
580
 
577
581
  def delete_page(self, page_id: str) -> None:
@@ -590,7 +594,7 @@ class ConfluenceClient:
590
594
  url = self._v2(f"/pages/{page_id}/inline-comments")
591
595
  params: dict[str, Any] = {"resolution-status": "open", "body-format": "storage", "limit": 250}
592
596
  while True:
593
- resp = self._http.get(url, params=params)
597
+ resp = self._get(url, params=params)
594
598
  self._raise_for_status(resp, f"get_page_inline_comments({page_id!r})")
595
599
  data = resp.json()
596
600
  results.extend(data.get("results", []))
@@ -607,7 +611,7 @@ class ConfluenceClient:
607
611
  url = self._v2(f"/pages/{page_id}/footer-comments")
608
612
  params: dict[str, Any] = {"resolution-status": "open", "body-format": "storage", "limit": 250}
609
613
  while True:
610
- resp = self._http.get(url, params=params)
614
+ resp = self._get(url, params=params)
611
615
  self._raise_for_status(resp, f"get_page_footer_comments({page_id!r})")
612
616
  data = resp.json()
613
617
  results.extend(data.get("results", []))
@@ -635,7 +639,7 @@ class ConfluenceClient:
635
639
  def resolve_inline_comment(self, comment_id: str) -> None:
636
640
  """Resolve an inline comment by setting *resolved=true*."""
637
641
  url = self._v2(f"/inline-comments/{comment_id}")
638
- get_resp = self._http.get(url, params={"body-format": "storage"})
642
+ get_resp = self._get(url, params={"body-format": "storage"})
639
643
  self._raise_for_status(get_resp, f"get_inline_comment({comment_id!r})")
640
644
  data = get_resp.json()
641
645
  version = data.get("version", {}).get("number", 1)
@@ -650,7 +654,7 @@ class ConfluenceClient:
650
654
  def resolve_footer_comment(self, comment_id: str) -> None:
651
655
  """Resolve a footer comment by setting *resolved=true*."""
652
656
  url = self._v2(f"/footer-comments/{comment_id}")
653
- get_resp = self._http.get(url, params={"body-format": "storage"})
657
+ get_resp = self._get(url, params={"body-format": "storage"})
654
658
  self._raise_for_status(get_resp, f"get_footer_comment({comment_id!r})")
655
659
  data = get_resp.json()
656
660
  version = data.get("version", {}).get("number", 1)
@@ -38,7 +38,7 @@ def http_request_with_retry(fn: Callable[[], httpx.Response], context: str) -> h
38
38
  )
39
39
  header = resp.headers.get("Retry-After", "")
40
40
  try:
41
- wait = min(float(header), _RETRY_AFTER_CAP)
41
+ wait = min(max(float(header), 0.0), _RETRY_AFTER_CAP)
42
42
  except ValueError:
43
43
  wait = min(2.0 ** attempt + random.uniform(0.0, 1.0), _RETRY_AFTER_CAP)
44
44
  print(
@@ -113,6 +113,20 @@ class TestSectionEmitter:
113
113
  assert "{ #test-me }" not in out
114
114
  assert "<h2>Test me</h2>" in out
115
115
 
116
+ def test_anchor_name_not_html_escaped_in_cdata(self) -> None:
117
+ # CDATA is not entity-decoded, so escaping the name there would make it
118
+ # differ from the (attribute-decoded) ac:anchor on links pointing at it.
119
+ from mkdocs_to_confluence.ir.nodes import AnchorNode
120
+
121
+ out = emit((Paragraph((AnchorNode(name="it's"),)),))
122
+ assert "<![CDATA[it's]]>" in out
123
+
124
+ def test_anchor_name_splits_cdata_terminator(self) -> None:
125
+ from mkdocs_to_confluence.ir.nodes import AnchorNode
126
+
127
+ out = emit((Paragraph((AnchorNode(name="a]]>b"),)),))
128
+ assert "<![CDATA[a]]]]><![CDATA[>b]]>" in out
129
+
116
130
 
117
131
  class TestParagraphEmitter:
118
132
  def test_simple_paragraph(self) -> None:
@@ -1061,3 +1061,49 @@ def test_request_prints_warning_each_retry(monkeypatch: pytest.MonkeyPatch, caps
1061
1061
  assert out.count("rate-limited") == 2
1062
1062
  assert "attempt 1/3" in out
1063
1063
  assert "attempt 2/3" in out
1064
+
1065
+
1066
+ def test_request_clamps_negative_retry_after(monkeypatch: pytest.MonkeyPatch) -> None:
1067
+ # time.sleep raises ValueError on negative values.
1068
+ sleeps: list[float] = []
1069
+ monkeypatch.setattr("time.sleep", lambda s: sleeps.append(s))
1070
+ transport = _MockTransport(
1071
+ _rate_limit_response(retry_after="-5"),
1072
+ _json_response({}),
1073
+ )
1074
+ with httpx.Client(transport=transport) as http:
1075
+ http_request_with_retry(
1076
+ lambda: http.get("https://example.atlassian.net/wiki/api/v2/test"), "test_op"
1077
+ )
1078
+ assert sleeps == [0.0]
1079
+
1080
+
1081
+ def test_client_get_retries_on_429(monkeypatch: pytest.MonkeyPatch) -> None:
1082
+ # Reads are rate-limited too; a 429 on a lookup must not abort the publish.
1083
+ monkeypatch.setattr("time.sleep", lambda _: None)
1084
+ transport = _MockTransport(
1085
+ _rate_limit_response(),
1086
+ _json_response({"results": [{"id": "7", "title": "Home"}]}),
1087
+ )
1088
+ with ConfluenceClient(_make_config()) as client:
1089
+ client._client = httpx.Client(transport=transport) # type: ignore[assignment]
1090
+ page = client.find_page("42", "Home")
1091
+ assert page == {"id": "7", "title": "Home"}
1092
+ assert len(transport.requests) == 2
1093
+
1094
+
1095
+ def test_client_paginated_get_retries_on_429(monkeypatch: pytest.MonkeyPatch) -> None:
1096
+ monkeypatch.setattr("time.sleep", lambda _: None)
1097
+ transport = _MockTransport(
1098
+ _json_response({
1099
+ "results": [{"id": "1", "type": "page"}],
1100
+ "_links": {"next": "/wiki/api/v2/pages/9/descendants?cursor=abc"},
1101
+ }),
1102
+ _rate_limit_response(),
1103
+ _json_response({"results": [{"id": "2", "type": "page"}], "_links": {}}),
1104
+ )
1105
+ with ConfluenceClient(_make_config()) as client:
1106
+ client._client = httpx.Client(transport=transport) # type: ignore[assignment]
1107
+ ids = client.get_descendant_ids("9")
1108
+ assert ids == ["1", "2"]
1109
+ assert transport.requests[2].url.params["cursor"] == "abc"