markdown-to-confluence 0.5.1__py3-none-any.whl → 0.5.3__py3-none-any.whl
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.
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/METADATA +160 -11
- markdown_to_confluence-0.5.3.dist-info/RECORD +55 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/licenses/LICENSE +1 -1
- md2conf/__init__.py +2 -2
- md2conf/__main__.py +94 -29
- md2conf/api.py +55 -10
- md2conf/attachment.py +72 -0
- md2conf/coalesce.py +43 -0
- md2conf/collection.py +1 -1
- md2conf/{extra.py → compatibility.py} +1 -1
- md2conf/converter.py +417 -590
- md2conf/csf.py +13 -11
- md2conf/drawio/__init__.py +0 -0
- md2conf/drawio/extension.py +116 -0
- md2conf/{drawio.py → drawio/render.py} +1 -1
- md2conf/emoticon.py +3 -3
- md2conf/environment.py +2 -2
- md2conf/extension.py +78 -0
- md2conf/external.py +49 -0
- md2conf/formatting.py +135 -0
- md2conf/frontmatter.py +70 -0
- md2conf/image.py +127 -0
- md2conf/latex.py +7 -186
- md2conf/local.py +8 -8
- md2conf/markdown.py +1 -1
- md2conf/matcher.py +1 -1
- md2conf/mermaid/__init__.py +0 -0
- md2conf/mermaid/config.py +20 -0
- md2conf/mermaid/extension.py +109 -0
- md2conf/{mermaid.py → mermaid/render.py} +10 -38
- md2conf/mermaid/scanner.py +55 -0
- md2conf/metadata.py +1 -1
- md2conf/options.py +116 -0
- md2conf/plantuml/__init__.py +0 -0
- md2conf/plantuml/config.py +20 -0
- md2conf/plantuml/extension.py +158 -0
- md2conf/plantuml/render.py +139 -0
- md2conf/plantuml/scanner.py +56 -0
- md2conf/png.py +202 -0
- md2conf/processor.py +32 -11
- md2conf/publisher.py +17 -18
- md2conf/scanner.py +31 -128
- md2conf/serializer.py +2 -2
- md2conf/svg.py +341 -0
- md2conf/text.py +1 -1
- md2conf/toc.py +1 -1
- md2conf/uri.py +1 -1
- md2conf/xml.py +1 -1
- markdown_to_confluence-0.5.1.dist-info/RECORD +0 -35
- md2conf/domain.py +0 -52
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/zip-safe +0 -0
md2conf/api.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Publish Markdown files to Confluence wiki.
|
|
3
3
|
|
|
4
|
-
Copyright 2022-
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
5
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
@@ -11,7 +11,9 @@ import enum
|
|
|
11
11
|
import io
|
|
12
12
|
import logging
|
|
13
13
|
import mimetypes
|
|
14
|
+
import random
|
|
14
15
|
import ssl
|
|
16
|
+
import time
|
|
15
17
|
import typing
|
|
16
18
|
from dataclasses import dataclass
|
|
17
19
|
from pathlib import Path
|
|
@@ -23,20 +25,23 @@ import requests
|
|
|
23
25
|
import truststore
|
|
24
26
|
from requests.adapters import HTTPAdapter
|
|
25
27
|
|
|
26
|
-
from .
|
|
27
|
-
from .
|
|
28
|
+
from .compatibility import override
|
|
29
|
+
from .environment import ArgumentError, ConfluenceError, ConnectionProperties, PageError
|
|
28
30
|
from .metadata import ConfluenceSiteMetadata
|
|
29
31
|
from .serializer import JsonType, json_to_object, object_to_json_payload
|
|
30
32
|
|
|
31
33
|
T = TypeVar("T")
|
|
32
34
|
|
|
35
|
+
# spellchecker: disable
|
|
33
36
|
mimetypes.add_type("application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx", strict=True)
|
|
34
37
|
mimetypes.add_type("text/vnd.mermaid", ".mmd", strict=True)
|
|
38
|
+
mimetypes.add_type("text/vnd.plantuml", ".puml", strict=True)
|
|
35
39
|
mimetypes.add_type("application/vnd.oasis.opendocument.presentation", ".odp", strict=True)
|
|
36
40
|
mimetypes.add_type("application/vnd.oasis.opendocument.spreadsheet", ".ods", strict=True)
|
|
37
41
|
mimetypes.add_type("application/vnd.oasis.opendocument.text", ".odt", strict=True)
|
|
38
42
|
mimetypes.add_type("application/vnd.openxmlformats-officedocument.presentationml.presentation", ".pptx", strict=True)
|
|
39
43
|
mimetypes.add_type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx", strict=True)
|
|
44
|
+
# spellchecker: enable
|
|
40
45
|
|
|
41
46
|
|
|
42
47
|
def build_url(base_url: str, query: dict[str, str] | None = None) -> str:
|
|
@@ -366,11 +371,11 @@ class ConfluenceAPI:
|
|
|
366
371
|
Encapsulates operations that can be invoked via the [Confluence REST API](https://developer.atlassian.com/cloud/confluence/rest/v2/).
|
|
367
372
|
"""
|
|
368
373
|
|
|
369
|
-
properties:
|
|
374
|
+
properties: ConnectionProperties
|
|
370
375
|
session: "ConfluenceSession | None" = None
|
|
371
376
|
|
|
372
|
-
def __init__(self, properties:
|
|
373
|
-
self.properties = properties or
|
|
377
|
+
def __init__(self, properties: ConnectionProperties | None = None) -> None:
|
|
378
|
+
self.properties = properties or ConnectionProperties()
|
|
374
379
|
|
|
375
380
|
def __enter__(self) -> "ConfluenceSession":
|
|
376
381
|
"""
|
|
@@ -622,7 +627,16 @@ class ConfluenceSession:
|
|
|
622
627
|
|
|
623
628
|
return id
|
|
624
629
|
|
|
630
|
+
@overload
|
|
631
|
+
def get_space_id(self, *, space_id: str | None = None) -> str | None: ...
|
|
632
|
+
|
|
633
|
+
@overload
|
|
634
|
+
def get_space_id(self, *, space_key: str | None = None) -> str | None: ...
|
|
635
|
+
|
|
625
636
|
def get_space_id(self, *, space_id: str | None = None, space_key: str | None = None) -> str | None:
|
|
637
|
+
return self._get_space_id(space_id=space_id, space_key=space_key)
|
|
638
|
+
|
|
639
|
+
def _get_space_id(self, *, space_id: str | None = None, space_key: str | None = None) -> str | None:
|
|
626
640
|
"""
|
|
627
641
|
Coalesces a space ID or space key into a space ID, accounting for site default.
|
|
628
642
|
|
|
@@ -643,6 +657,15 @@ class ConfluenceSession:
|
|
|
643
657
|
# space ID and key are unset, and no default space is configured
|
|
644
658
|
return None
|
|
645
659
|
|
|
660
|
+
def get_homepage_id(self, space_id: str) -> str:
|
|
661
|
+
"""
|
|
662
|
+
Returns the page ID corresponding to the space home page.
|
|
663
|
+
"""
|
|
664
|
+
|
|
665
|
+
path = f"/spaces/{space_id}"
|
|
666
|
+
data = self._get(ConfluenceVersion.VERSION_2, path, dict[str, JsonType])
|
|
667
|
+
return typing.cast(str, data["homepageId"])
|
|
668
|
+
|
|
646
669
|
def get_attachment_by_name(self, page_id: str, filename: str) -> ConfluenceAttachment:
|
|
647
670
|
"""
|
|
648
671
|
Retrieves a Confluence page attachment by an unprefixed file name.
|
|
@@ -827,7 +850,7 @@ class ConfluenceSession:
|
|
|
827
850
|
query = {
|
|
828
851
|
"title": title,
|
|
829
852
|
}
|
|
830
|
-
space_id = self.
|
|
853
|
+
space_id = self._get_space_id(space_id=space_id, space_key=space_key)
|
|
831
854
|
if space_id is not None:
|
|
832
855
|
query["space-id"] = space_id
|
|
833
856
|
|
|
@@ -839,16 +862,38 @@ class ConfluenceSession:
|
|
|
839
862
|
page = json_to_object(ConfluencePageProperties, results[0])
|
|
840
863
|
return page
|
|
841
864
|
|
|
842
|
-
def get_page(self, page_id: str) -> ConfluencePage:
|
|
865
|
+
def get_page(self, page_id: str, *, retries: int = 3, retry_delay: float = 1.0) -> ConfluencePage:
|
|
843
866
|
"""
|
|
844
867
|
Retrieves Confluence wiki page details and content.
|
|
845
868
|
|
|
869
|
+
Includes retry logic to handle eventual consistency issues when fetching
|
|
870
|
+
a newly created page that may not be immediately available via the API.
|
|
871
|
+
|
|
846
872
|
:param page_id: The Confluence page ID.
|
|
873
|
+
:param retries: Number of retry attempts for 404 errors (default: 3).
|
|
874
|
+
:param retry_delay: Initial delay in seconds between retries, doubles each attempt (default: 1.0).
|
|
847
875
|
:returns: Confluence page info and content.
|
|
848
876
|
"""
|
|
849
877
|
|
|
850
878
|
path = f"/pages/{page_id}"
|
|
851
|
-
|
|
879
|
+
last_error: requests.HTTPError | None = None
|
|
880
|
+
|
|
881
|
+
for attempt in range(retries + 1):
|
|
882
|
+
try:
|
|
883
|
+
return self._get(ConfluenceVersion.VERSION_2, path, ConfluencePage, query={"body-format": "storage"})
|
|
884
|
+
except requests.HTTPError as e:
|
|
885
|
+
if e.response is not None and e.response.status_code == 404 and attempt < retries:
|
|
886
|
+
delay = retry_delay * (2**attempt) + random.uniform(0, 1)
|
|
887
|
+
LOGGER.debug("Page %s not found, retrying in %.1f seconds (attempt %d/%d)", page_id, delay, attempt + 1, retries)
|
|
888
|
+
time.sleep(delay)
|
|
889
|
+
last_error = e
|
|
890
|
+
else:
|
|
891
|
+
raise
|
|
892
|
+
|
|
893
|
+
# This should not be reached, but satisfies type checker
|
|
894
|
+
if last_error is not None:
|
|
895
|
+
raise last_error
|
|
896
|
+
raise ConfluenceError(f"Failed to get page {page_id}")
|
|
852
897
|
|
|
853
898
|
def get_page_properties(self, page_id: str) -> ConfluencePageProperties:
|
|
854
899
|
"""
|
|
@@ -980,7 +1025,7 @@ class ConfluenceSession:
|
|
|
980
1025
|
:returns: Confluence page ID of a matching page (if found), or `None`.
|
|
981
1026
|
"""
|
|
982
1027
|
|
|
983
|
-
space_id = self.
|
|
1028
|
+
space_id = self._get_space_id(space_id=space_id, space_key=space_key)
|
|
984
1029
|
path = "/pages"
|
|
985
1030
|
query = {"title": title}
|
|
986
1031
|
if space_id is not None:
|
md2conf/attachment.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ImageData:
|
|
16
|
+
path: Path
|
|
17
|
+
description: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class EmbeddedFileData:
|
|
22
|
+
data: bytes
|
|
23
|
+
description: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AttachmentCatalog:
|
|
27
|
+
"Maintains a list of files and binary data to be uploaded to Confluence as attachments."
|
|
28
|
+
|
|
29
|
+
images: list[ImageData]
|
|
30
|
+
embedded_files: dict[str, EmbeddedFileData]
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self.images = []
|
|
34
|
+
self.embedded_files = {}
|
|
35
|
+
|
|
36
|
+
def add_image(self, data: ImageData) -> None:
|
|
37
|
+
self.images.append(data)
|
|
38
|
+
|
|
39
|
+
def add_embed(self, filename: str, data: EmbeddedFileData) -> None:
|
|
40
|
+
self.embedded_files[filename] = data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def attachment_name(ref: Path | str) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Safe name for use with attachment uploads.
|
|
46
|
+
|
|
47
|
+
Mutates a relative path such that it meets Confluence's attachment naming requirements.
|
|
48
|
+
|
|
49
|
+
Allowed characters:
|
|
50
|
+
|
|
51
|
+
* Alphanumeric characters: 0-9, a-z, A-Z
|
|
52
|
+
* Special characters: hyphen (-), underscore (_), period (.)
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
if isinstance(ref, Path):
|
|
56
|
+
path = ref
|
|
57
|
+
else:
|
|
58
|
+
path = Path(ref)
|
|
59
|
+
|
|
60
|
+
if path.drive or path.root:
|
|
61
|
+
raise ValueError(f"required: relative path; got: {ref}")
|
|
62
|
+
|
|
63
|
+
regexp = re.compile(r"[^\-0-9A-Za-z_.]", re.UNICODE)
|
|
64
|
+
|
|
65
|
+
def replace_part(part: str) -> str:
|
|
66
|
+
if part == "..":
|
|
67
|
+
return "PAR"
|
|
68
|
+
else:
|
|
69
|
+
return regexp.sub("_", part)
|
|
70
|
+
|
|
71
|
+
parts = [replace_part(p) for p in path.parts]
|
|
72
|
+
return Path(*parts).as_posix().replace("/", "_")
|
md2conf/coalesce.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import dataclasses
|
|
11
|
+
from typing import Any, ClassVar, Protocol, TypeVar
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DataclassInstance(Protocol):
|
|
15
|
+
__dataclass_fields__: ClassVar[dict[str, dataclasses.Field[Any]]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
D = TypeVar("D", bound=DataclassInstance)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def coalesce(target: D, source: D) -> D:
|
|
22
|
+
"""
|
|
23
|
+
Implements nullish coalescing assignment on each field of a data-class.
|
|
24
|
+
|
|
25
|
+
Iterates over each field of the data-class, and evaluates the right operand and assigns it to the left only if
|
|
26
|
+
the left operand is `None`. Applies recursively when the field is a data-class.
|
|
27
|
+
|
|
28
|
+
:returns: A newly created data-class instance.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
updates: dict[str, Any] = {}
|
|
32
|
+
for field in dataclasses.fields(target):
|
|
33
|
+
target_field = getattr(target, field.name, None)
|
|
34
|
+
source_field = getattr(source, field.name, None)
|
|
35
|
+
|
|
36
|
+
if target_field is None:
|
|
37
|
+
if source_field is not None:
|
|
38
|
+
updates[field.name] = copy.deepcopy(source_field)
|
|
39
|
+
elif dataclasses.is_dataclass(field.type):
|
|
40
|
+
if source_field is not None:
|
|
41
|
+
updates[field.name] = coalesce(target_field, source_field)
|
|
42
|
+
|
|
43
|
+
return dataclasses.replace(target, **updates)
|
md2conf/collection.py
CHANGED