mkdocs2confluence 0.6.1__tar.gz → 0.6.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 (65) hide show
  1. {mkdocs2confluence-0.6.1/src/mkdocs2confluence.egg-info → mkdocs2confluence-0.6.2}/PKG-INFO +1 -1
  2. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/pyproject.toml +1 -1
  3. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2/src/mkdocs2confluence.egg-info}/PKG-INFO +1 -1
  4. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/transforms/mermaid.py +56 -31
  5. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_mermaid.py +72 -0
  6. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/LICENSE +0 -0
  7. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/README.md +0 -0
  8. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/setup.cfg +0 -0
  9. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  10. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  11. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  12. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  13. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  14. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/__init__.py +0 -0
  15. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/cli.py +0 -0
  16. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  17. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/emitter/xhtml.py +0 -0
  18. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  19. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/ir/document.py +0 -0
  20. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/ir/nodes.py +0 -0
  21. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  22. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  23. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/loader/config.py +0 -0
  24. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  25. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  26. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/loader/page.py +0 -0
  27. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  28. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  29. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  30. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  31. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  32. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  33. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  34. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  35. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  36. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  37. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/preview/render.py +0 -0
  38. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  39. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  40. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/publisher/pipeline.py +0 -0
  41. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  42. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  43. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  44. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  45. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  46. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  47. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_abbrevs.py +0 -0
  48. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_editlink.py +0 -0
  49. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_emitter.py +0 -0
  50. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_extra_css.py +0 -0
  51. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_frontmatter.py +0 -0
  52. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_icons.py +0 -0
  53. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_images.py +0 -0
  54. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_internallinks.py +0 -0
  55. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_ir.py +0 -0
  56. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_linkdefs.py +0 -0
  57. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_loader.py +0 -0
  58. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_page_loader.py +0 -0
  59. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_parser.py +0 -0
  60. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_preprocess.py +0 -0
  61. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_preview.py +0 -0
  62. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_publish_client.py +0 -0
  63. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_publish_config.py +0 -0
  64. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_publish_pipeline.py +0 -0
  65. {mkdocs2confluence-0.6.1 → mkdocs2confluence-0.6.2}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.6.1
3
+ Version: 0.6.2
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.1"
3
+ version = "0.6.2"
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.1
3
+ Version: 0.6.2
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
@@ -17,8 +17,10 @@ from __future__ import annotations
17
17
  import dataclasses
18
18
  import hashlib
19
19
  import sys
20
+ import threading
20
21
  import urllib.error
21
22
  import urllib.request
23
+ from concurrent.futures import ThreadPoolExecutor, as_completed
22
24
  from pathlib import Path
23
25
  from typing import cast
24
26
 
@@ -29,6 +31,8 @@ _CACHE_DIR = Path.home() / ".cache" / "mk2conf" / "mermaid"
29
31
  DEFAULT_KROKI_URL = "https://kroki.io"
30
32
  _TIMEOUT = 30 # seconds — fail fast when Kroki is down
31
33
  _MIN_PNG_BYTES = 67 # smallest valid PNG (1×1 px) is 67 bytes
34
+ _CACHE_LOCK = threading.Lock()
35
+ _MAX_WORKERS = 8
32
36
 
33
37
 
34
38
  def _kroki_png(source: str, kroki_url: str) -> bytes:
@@ -58,12 +62,36 @@ def _warn(msg: str) -> None:
58
62
  print(f" warning {msg}", file=sys.stderr)
59
63
 
60
64
 
65
+ 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."""
67
+ path = _cache_path(source)
68
+ if path.exists():
69
+ print(" rendering mermaid diagram (cached)")
70
+ 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")
85
+ return None
86
+
87
+
61
88
  def render_mermaid_diagrams(
62
89
  nodes: tuple[IRNode, ...],
63
90
  kroki_url: str = DEFAULT_KROKI_URL,
64
91
  ) -> tuple[tuple[IRNode, ...], list[Path]]:
65
92
  """Render all :class:`MermaidDiagram` nodes to PNG via Kroki.
66
93
 
94
+ Diagrams are rendered concurrently (up to ``_MAX_WORKERS`` threads).
67
95
  Returns the updated IR node tuple (with ``attachment_name`` set on each
68
96
  successfully rendered diagram) and a list of PNG :class:`Path` objects to
69
97
  upload as page attachments.
@@ -76,47 +104,44 @@ def render_mermaid_diagrams(
76
104
  except OSError as exc:
77
105
  _warn(f"cannot create mermaid cache dir {_CACHE_DIR}: {exc} — all diagrams will fall back to code blocks")
78
106
 
107
+ # Collect all unresolved MermaidDiagram nodes (deduplicated by source).
108
+ diagrams: list[MermaidDiagram] = []
109
+ seen_sources: set[str] = set()
110
+ for top_node in nodes:
111
+ for node in walk(top_node):
112
+ if isinstance(node, MermaidDiagram) and node.attachment_name is None:
113
+ if node.source not in seen_sources:
114
+ diagrams.append(node)
115
+ seen_sources.add(node.source)
116
+
117
+ if not diagrams:
118
+ return nodes, []
119
+
120
+ # Render all diagrams concurrently, preserving source → result mapping.
121
+ source_to_path: dict[str, Path | None] = {}
122
+ with ThreadPoolExecutor(max_workers=min(_MAX_WORKERS, len(diagrams))) as pool:
123
+ future_to_source = {
124
+ pool.submit(_render_one, d.source, kroki_url): d.source for d in diagrams
125
+ }
126
+ for future in as_completed(future_to_source):
127
+ source = future_to_source[future]
128
+ source_to_path[source] = future.result()
129
+
130
+ # Build replacements and attachments from results.
79
131
  attachments: list[Path] = []
80
132
  replacements: dict[int, IRNode] = {}
81
133
  seen_paths: set[Path] = set()
82
134
 
83
135
  for top_node in nodes:
84
136
  for node in walk(top_node):
85
- if not isinstance(node, MermaidDiagram):
137
+ if not isinstance(node, MermaidDiagram) or node.attachment_name is not None:
86
138
  continue
87
- if node.attachment_name is not None:
88
- continue # already resolved
89
-
90
- path = _cache_path(node.source)
91
- if not path.exists():
92
- try:
93
- print(f" rendering mermaid diagram via Kroki ({kroki_url})")
94
- png = _kroki_png(node.source, kroki_url)
95
- if len(png) < _MIN_PNG_BYTES:
96
- raise ValueError(f"Kroki returned {len(png)} bytes (expected a valid PNG)")
97
- path.write_bytes(png)
98
- except urllib.error.HTTPError as exc:
99
- _warn(
100
- f"mermaid diagram: Kroki returned HTTP {exc.code} {exc.reason}"
101
- " — falling back to code block"
102
- )
103
- continue
104
- except urllib.error.URLError as exc:
105
- _warn(
106
- f"mermaid diagram: Kroki unreachable ({exc.reason})"
107
- " — falling back to code block"
108
- )
109
- continue
110
- except (OSError, ValueError) as exc:
111
- _warn(f"mermaid diagram: {exc} — falling back to code block")
112
- continue
113
- else:
114
- print(" rendering mermaid diagram (cached)")
115
-
139
+ path = source_to_path.get(node.source)
140
+ if path is None:
141
+ continue # render failed — leave as code block
116
142
  if path not in seen_paths:
117
143
  attachments.append(path)
118
144
  seen_paths.add(path)
119
-
120
145
  replacements[id(node)] = dataclasses.replace(
121
146
  node, attachment_name=path.name, local_path=path
122
147
  )
@@ -197,3 +197,75 @@ def test_render_mermaid_none_skips(tmp_path):
197
197
  mock_fetch.assert_not_called()
198
198
  assert len(attachments) == 0
199
199
  assert updated_nodes[0].attachment_name == "already_set.png" # type: ignore[union-attr]
200
+
201
+
202
+ # ── Parallel rendering ────────────────────────────────────────────────────────
203
+
204
+
205
+ def test_render_multiple_diagrams_concurrently(tmp_path):
206
+ """Multiple distinct diagrams are all rendered (parallel path)."""
207
+ sources = [f"graph TD\n A{i} --> B{i}\n" for i in range(4)]
208
+ nodes = tuple(MermaidDiagram(source=s) for s in sources)
209
+
210
+ with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
211
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png", return_value=_FAKE_PNG):
212
+ updated, attachments = render_mermaid_diagrams(nodes)
213
+
214
+ assert len(attachments) == 4
215
+ for node in updated:
216
+ assert isinstance(node, MermaidDiagram)
217
+ assert node.attachment_name is not None
218
+
219
+
220
+ def test_render_one_failure_does_not_block_others(tmp_path):
221
+ """If one diagram fails, the rest still render successfully."""
222
+ import urllib.error
223
+
224
+ good_source = "graph TD\n A --> B\n"
225
+ bad_source = "graph TD\n X --> Y\n"
226
+ nodes = (MermaidDiagram(source=good_source), MermaidDiagram(source=bad_source))
227
+
228
+ def fake_kroki(source: str, url: str) -> bytes:
229
+ if source == bad_source:
230
+ raise urllib.error.URLError("timeout")
231
+ return _FAKE_PNG
232
+
233
+ with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
234
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png", side_effect=fake_kroki):
235
+ updated, attachments = render_mermaid_diagrams(nodes)
236
+
237
+ # One attachment for the successful diagram
238
+ assert len(attachments) == 1
239
+ # Good diagram got attachment_name; bad one stayed as code-block fallback
240
+ assert updated[0].attachment_name is not None # type: ignore[union-attr]
241
+ assert updated[1].attachment_name is None # type: ignore[union-attr]
242
+
243
+
244
+ def test_render_one_cached(tmp_path):
245
+ """_render_one returns the cache path immediately for cached diagrams."""
246
+ from mkdocs_to_confluence.transforms.mermaid import _render_one, _cache_path
247
+
248
+ path = tmp_path / "mermaid_cached.png"
249
+ path.write_bytes(_FAKE_PNG)
250
+
251
+ with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
252
+ patch("mkdocs_to_confluence.transforms.mermaid._cache_path", return_value=path), \
253
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png") as mock_fetch:
254
+ result = _render_one(_SAMPLE_SOURCE, "https://kroki.io")
255
+
256
+ mock_fetch.assert_not_called()
257
+ assert result == path
258
+
259
+
260
+ def test_render_one_network_failure_returns_none(tmp_path):
261
+ """_render_one returns None on network failure."""
262
+ import urllib.error
263
+ from mkdocs_to_confluence.transforms.mermaid import _render_one
264
+
265
+ with patch("mkdocs_to_confluence.transforms.mermaid._CACHE_DIR", tmp_path), \
266
+ patch("mkdocs_to_confluence.transforms.mermaid._kroki_png",
267
+ side_effect=urllib.error.URLError("connection refused")):
268
+ result = _render_one(_SAMPLE_SOURCE, "https://kroki.io")
269
+
270
+ assert result is None
271
+