mkdocs2confluence 0.6.2__tar.gz → 0.6.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 (65) hide show
  1. {mkdocs2confluence-0.6.2/src/mkdocs2confluence.egg-info → mkdocs2confluence-0.6.3}/PKG-INFO +1 -1
  2. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/pyproject.toml +1 -1
  3. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3/src/mkdocs2confluence.egg-info}/PKG-INFO +1 -1
  4. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/transforms/mermaid.py +39 -15
  5. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_mermaid.py +60 -3
  6. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/LICENSE +0 -0
  7. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/README.md +0 -0
  8. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/setup.cfg +0 -0
  9. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  10. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  11. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  12. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  13. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  14. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/__init__.py +0 -0
  15. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/cli.py +0 -0
  16. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  17. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/emitter/xhtml.py +0 -0
  18. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  19. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/ir/document.py +0 -0
  20. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/ir/nodes.py +0 -0
  21. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  22. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  23. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/loader/config.py +0 -0
  24. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  25. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  26. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/loader/page.py +0 -0
  27. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  28. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  29. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  30. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  31. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  32. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  33. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  34. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  35. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  36. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  37. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/preview/render.py +0 -0
  38. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  39. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  40. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/publisher/pipeline.py +0 -0
  41. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  42. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  43. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  44. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  45. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  46. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  47. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_abbrevs.py +0 -0
  48. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_editlink.py +0 -0
  49. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_emitter.py +0 -0
  50. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_extra_css.py +0 -0
  51. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_frontmatter.py +0 -0
  52. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_icons.py +0 -0
  53. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_images.py +0 -0
  54. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_internallinks.py +0 -0
  55. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_ir.py +0 -0
  56. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_linkdefs.py +0 -0
  57. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_loader.py +0 -0
  58. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_page_loader.py +0 -0
  59. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_parser.py +0 -0
  60. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_preprocess.py +0 -0
  61. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_preview.py +0 -0
  62. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_publish_client.py +0 -0
  63. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_publish_config.py +0 -0
  64. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_publish_pipeline.py +0 -0
  65. {mkdocs2confluence-0.6.2 → mkdocs2confluence-0.6.3}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.6.2
3
+ Version: 0.6.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.6.2"
3
+ version = "0.6.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.6.2
3
+ Version: 0.6.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
@@ -18,6 +18,7 @@ import dataclasses
18
18
  import hashlib
19
19
  import sys
20
20
  import threading
21
+ import time
21
22
  import urllib.error
22
23
  import urllib.request
23
24
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -33,6 +34,9 @@ _TIMEOUT = 30 # seconds — fail fast when Kroki is down
33
34
  _MIN_PNG_BYTES = 67 # smallest valid PNG (1×1 px) is 67 bytes
34
35
  _CACHE_LOCK = threading.Lock()
35
36
  _MAX_WORKERS = 8
37
+ _RETRY_ATTEMPTS = 3
38
+ _RETRY_BACKOFF = 1.0 # seconds; doubles each attempt
39
+ _RETRYABLE_HTTP = {429, 500, 502, 503, 504}
36
40
 
37
41
 
38
42
  def _kroki_png(source: str, kroki_url: str) -> bytes:
@@ -63,25 +67,45 @@ def _warn(msg: str) -> None:
63
67
 
64
68
 
65
69
  def _render_one(source: str, kroki_url: str) -> Path | None:
66
- """Render a single diagram to cache. Returns cache path on success, None on failure."""
70
+ """Render a single diagram to cache. Returns cache path on success, None on failure.
71
+
72
+ Transient HTTP errors (429, 5xx) and network blips are retried up to
73
+ ``_RETRY_ATTEMPTS`` times with exponential backoff.
74
+ """
67
75
  path = _cache_path(source)
68
76
  if path.exists():
69
77
  print(" rendering mermaid diagram (cached)")
70
78
  return path
71
- try:
72
- print(f" rendering mermaid diagram via Kroki ({kroki_url})")
73
- png = _kroki_png(source, kroki_url)
74
- if len(png) < _MIN_PNG_BYTES:
75
- raise ValueError(f"Kroki returned {len(png)} bytes (expected a valid PNG)")
76
- with _CACHE_LOCK:
77
- path.write_bytes(png)
78
- return path
79
- except urllib.error.HTTPError as exc:
80
- _warn(f"mermaid diagram: Kroki returned HTTP {exc.code} {exc.reason} — falling back to code block")
81
- except urllib.error.URLError as exc:
82
- _warn(f"mermaid diagram: Kroki unreachable ({exc.reason}) falling back to code block")
83
- except (OSError, ValueError) as exc:
84
- _warn(f"mermaid diagram: {exc} — falling back to code block")
79
+
80
+ last_exc: Exception | None = None
81
+ for attempt in range(_RETRY_ATTEMPTS):
82
+ if attempt > 0:
83
+ delay = _RETRY_BACKOFF * (2 ** (attempt - 1))
84
+ _warn(f"mermaid diagram: retrying in {delay:.0f}s (attempt {attempt + 1}/{_RETRY_ATTEMPTS})")
85
+ time.sleep(delay)
86
+ try:
87
+ print(f" rendering mermaid diagram via Kroki ({kroki_url})")
88
+ png = _kroki_png(source, kroki_url)
89
+ if len(png) < _MIN_PNG_BYTES:
90
+ raise ValueError(f"Kroki returned {len(png)} bytes (expected a valid PNG)")
91
+ with _CACHE_LOCK:
92
+ path.write_bytes(png)
93
+ return path
94
+ except urllib.error.HTTPError as exc:
95
+ if exc.code in _RETRYABLE_HTTP:
96
+ last_exc = exc
97
+ continue # retry
98
+ _warn(f"mermaid diagram: Kroki returned HTTP {exc.code} {exc.reason} — falling back to code block")
99
+ return None
100
+ except urllib.error.URLError as exc:
101
+ last_exc = exc
102
+ continue # retry — network blip
103
+ except (OSError, ValueError) as exc:
104
+ _warn(f"mermaid diagram: {exc} — falling back to code block")
105
+ return None
106
+
107
+ # All retries exhausted
108
+ _warn(f"mermaid diagram: failed after {_RETRY_ATTEMPTS} attempts ({last_exc}) — falling back to code block")
85
109
  return None
86
110
 
87
111
 
@@ -109,6 +109,7 @@ def test_render_fallback_on_network_error(tmp_path, capsys):
109
109
 
110
110
  with (
111
111
  patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path),
112
+ patch("mkdocs_to_confluence.transforms.mermaid.time.sleep"),
112
113
  patch(
113
114
  "mkdocs_to_confluence.transforms.mermaid._kroki_png",
114
115
  side_effect=urllib.error.URLError("timed out"),
@@ -119,7 +120,6 @@ def test_render_fallback_on_network_error(tmp_path, capsys):
119
120
  assert len(attachments) == 0
120
121
  assert updated_nodes[0].attachment_name is None # type: ignore[union-attr]
121
122
  err = capsys.readouterr().err
122
- assert "Kroki unreachable" in err
123
123
  assert "falling back to code block" in err
124
124
 
125
125
 
@@ -131,6 +131,7 @@ def test_render_fallback_on_http_error(tmp_path, capsys):
131
131
 
132
132
  with (
133
133
  patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path),
134
+ patch("mkdocs_to_confluence.transforms.mermaid.time.sleep"),
134
135
  patch(
135
136
  "mkdocs_to_confluence.transforms.mermaid._kroki_png",
136
137
  side_effect=urllib.error.HTTPError(
@@ -143,7 +144,6 @@ def test_render_fallback_on_http_error(tmp_path, capsys):
143
144
  assert len(attachments) == 0
144
145
  assert updated_nodes[0].attachment_name is None # type: ignore[union-attr]
145
146
  err = capsys.readouterr().err
146
- assert "HTTP 503" in err
147
147
  assert "falling back to code block" in err
148
148
 
149
149
 
@@ -258,14 +258,71 @@ def test_render_one_cached(tmp_path):
258
258
 
259
259
 
260
260
  def test_render_one_network_failure_returns_none(tmp_path):
261
- """_render_one returns None on network failure."""
261
+ """_render_one returns None after all retries on persistent network failure."""
262
262
  import urllib.error
263
263
  from mkdocs_to_confluence.transforms.mermaid import _render_one
264
264
 
265
265
  with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
266
+ patch("mkdocs_to_confluence.transforms.mermaid.time.sleep"), \
266
267
  patch("mkdocs_to_confluence.transforms.mermaid._kroki_png",
267
268
  side_effect=urllib.error.URLError("connection refused")):
268
269
  result = _render_one(_SAMPLE_SOURCE, "https://kroki.io")
269
270
 
270
271
  assert result is None
271
272
 
273
+
274
+ # ── Retry behaviour ───────────────────────────────────────────────────────────
275
+
276
+
277
+ def test_render_one_retries_on_503_then_succeeds(tmp_path):
278
+ """_render_one retries on HTTP 503 and succeeds on the second attempt."""
279
+ import urllib.error
280
+ from mkdocs_to_confluence.transforms.mermaid import _render_one
281
+
282
+ calls = [urllib.error.HTTPError("url", 503, "Service Unavailable", {}, None), _FAKE_PNG]
283
+
284
+ def fake_kroki(source: str, url: str) -> bytes:
285
+ result = calls.pop(0)
286
+ if isinstance(result, Exception):
287
+ raise result
288
+ return result
289
+
290
+ with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
291
+ patch("mkdocs_to_confluence.transforms.mermaid.time.sleep") as mock_sleep, \
292
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png", side_effect=fake_kroki):
293
+ result = _render_one(_SAMPLE_SOURCE, "https://kroki.io")
294
+
295
+ assert result is not None
296
+ assert result.exists()
297
+ mock_sleep.assert_called_once_with(1.0) # backed off once
298
+
299
+
300
+ def test_render_one_non_retryable_http_error_returns_none(tmp_path):
301
+ """_render_one does not retry on HTTP 400 (bad input) — returns None immediately."""
302
+ import urllib.error
303
+ from mkdocs_to_confluence.transforms.mermaid import _render_one
304
+
305
+ with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
306
+ patch("mkdocs_to_confluence.transforms.mermaid.time.sleep") as mock_sleep, \
307
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png",
308
+ side_effect=urllib.error.HTTPError("url", 400, "Bad Request", {}, None)):
309
+ result = _render_one(_SAMPLE_SOURCE, "https://kroki.io")
310
+
311
+ assert result is None
312
+ mock_sleep.assert_not_called() # no retry for 400
313
+
314
+
315
+ def test_render_one_exhausts_retries_on_persistent_503(tmp_path):
316
+ """_render_one returns None after all attempts on persistent 503."""
317
+ import urllib.error
318
+ from mkdocs_to_confluence.transforms.mermaid import _render_one, _RETRY_ATTEMPTS
319
+
320
+ with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
321
+ patch("mkdocs_to_confluence.transforms.mermaid.time.sleep"), \
322
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png",
323
+ side_effect=urllib.error.HTTPError("url", 503, "Service Unavailable", {}, None)) as mock_fetch:
324
+ result = _render_one(_SAMPLE_SOURCE, "https://kroki.io")
325
+
326
+ assert result is None
327
+ assert mock_fetch.call_count == _RETRY_ATTEMPTS
328
+