markdown-to-confluence 0.4.1__py3-none-any.whl → 0.4.2__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.
md2conf/drawio.py ADDED
@@ -0,0 +1,222 @@
1
+ """
2
+ Publish Markdown files to Confluence wiki.
3
+
4
+ Copyright 2022-2025, Levente Hunyadi
5
+
6
+ :see: https://github.com/hunyadi/md2conf
7
+ """
8
+
9
+ import base64
10
+ import typing
11
+ import zlib
12
+ from pathlib import Path
13
+ from struct import unpack
14
+ from urllib.parse import unquote_to_bytes
15
+
16
+ import lxml.etree as ET
17
+
18
+
19
+ class DrawioError(ValueError):
20
+ """
21
+ Raised when the input does not adhere to the draw.io document format, or processing the input into a draw.io diagram fails.
22
+
23
+ Examples include:
24
+
25
+ * invalid or corrupt PNG file
26
+ * PNG chunk with embedded diagram data not found
27
+ * the structure of the outer XML does not match the expected format
28
+ * URL decoding error
29
+ * decompression error during INFLATE
30
+ """
31
+
32
+
33
+ def inflate(data: bytes) -> bytes:
34
+ """
35
+ Decompresses (inflates) data compressed using the raw DEFLATE algorithm.
36
+
37
+ :param data: Compressed data using raw DEFLATE format.
38
+ :returns: Uncompressed data.
39
+ """
40
+
41
+ # -zlib.MAX_WBITS indicates raw DEFLATE stream (no zlib/gzip headers)
42
+ return zlib.decompress(data, -zlib.MAX_WBITS)
43
+
44
+
45
+ def decompress_diagram(xml_data: typing.Union[bytes, str]) -> ET._Element:
46
+ """
47
+ Decompresses the text content of the `<diagram>` element in a draw.io XML document.
48
+
49
+ If the data is not compressed, the de-serialized XML element tree is returned.
50
+
51
+ Expected input (as `bytes` or `str`):
52
+ ```
53
+ <mxfile>
54
+ <diagram>... ENCODED_COMPRESSED_DATA ...</diagram>
55
+ </mxfile>
56
+ ```
57
+
58
+ Output (as XML element tree):
59
+ ```
60
+ <mxfile>
61
+ <diagram>
62
+ <mxGraphModel>
63
+ <root>
64
+ ...
65
+ </root>
66
+ </mxGraphModel>
67
+ </diagram>
68
+ </mxfile>
69
+ ```
70
+
71
+ :param xml_data: The serialized XML document.
72
+ :returns: XML element tree with the text contained within the `<diagram>` element expanded into a sub-tree.
73
+ """
74
+
75
+ try:
76
+ root = ET.fromstring(xml_data)
77
+ except ET.ParseError as e:
78
+ raise DrawioError("invalid outer XML") from e
79
+
80
+ if root.tag != "mxfile":
81
+ raise DrawioError("root element is not `<mxfile>`")
82
+
83
+ diagram_elem = root.find("diagram")
84
+ if diagram_elem is None:
85
+ raise DrawioError("`<diagram>` element not found")
86
+
87
+ if len(diagram_elem) > 0:
88
+ # already decompressed
89
+ return root
90
+
91
+ if diagram_elem.text is None:
92
+ raise DrawioError("`<diagram>` element has no data")
93
+
94
+ # reverse base64-encoding of inner data
95
+ try:
96
+ base64_decoded = base64.b64decode(diagram_elem.text, validate=True)
97
+ except ValueError as e:
98
+ raise DrawioError("raw text data in `<diagram>` element is not properly Base64-encoded") from e
99
+
100
+ # decompress inner data
101
+ try:
102
+ embedded_data = inflate(base64_decoded)
103
+ except zlib.error as e:
104
+ raise DrawioError("`<diagram>` element text data cannot be decompressed using INFLATE") from e
105
+
106
+ # reverse URL-encoding of inner data
107
+ try:
108
+ url_decoded = unquote_to_bytes(embedded_data)
109
+ except ValueError as e:
110
+ raise DrawioError("decompressed data in `<diagram>` element is not properly URL-encoded") from e
111
+
112
+ # create sub-tree from decompressed data
113
+ try:
114
+ tree = ET.fromstring(url_decoded)
115
+ except ET.ParseError as e:
116
+ raise DrawioError("invalid inner XML extracted from `<diagram>` element") from e
117
+
118
+ # update document
119
+ diagram_elem.text = None
120
+ diagram_elem.append(tree)
121
+
122
+ return root
123
+
124
+
125
+ def extract_xml_from_png(png_data: bytes) -> ET._Element:
126
+ """
127
+ Extracts an editable draw.io diagram from a PNG file.
128
+
129
+ :param png_data: PNG binary data, with an embedded draw.io diagram.
130
+ :returns: XML element tree of a draw.io diagram.
131
+ """
132
+
133
+ # PNG signature is always the first 8 bytes
134
+ png_signature = b"\x89PNG\r\n\x1a\n"
135
+ if not png_data.startswith(png_signature):
136
+ raise DrawioError("not a valid PNG file")
137
+
138
+ offset = len(png_signature)
139
+ while offset < len(png_data):
140
+ if offset + 8 > len(png_data):
141
+ raise DrawioError("corrupted PNG: incomplete chunk header")
142
+
143
+ # read chunk length (4 bytes) and type (4 bytes)
144
+ (length,) = unpack(">I", png_data[offset : offset + 4])
145
+ chunk_type = png_data[offset + 4 : offset + 8]
146
+ offset += 8
147
+
148
+ if offset + length + 4 > len(png_data):
149
+ raise DrawioError(f"corrupted PNG: incomplete data for chunk {chunk_type.decode('ascii')}")
150
+
151
+ # read chunk data
152
+ chunk_data = png_data[offset : offset + length]
153
+ offset += length
154
+
155
+ # skip CRC (4 bytes)
156
+ offset += 4
157
+
158
+ # extracts draw.io diagram data from a `tEXt` chunk with the keyword `mxfile` embedded in a PNG
159
+ if chunk_type != b"tEXt":
160
+ continue
161
+
162
+ # format: keyword\0text
163
+ null_pos = chunk_data.find(b"\x00")
164
+ if null_pos < 0:
165
+ raise DrawioError("corrupted PNG: tEXt chunk missing keyword")
166
+
167
+ keyword = chunk_data[:null_pos].decode("latin1")
168
+ if keyword != "mxfile":
169
+ continue
170
+
171
+ textual_data = chunk_data[null_pos + 1 :]
172
+
173
+ try:
174
+ url_decoded = unquote_to_bytes(textual_data)
175
+ except ValueError as e:
176
+ raise DrawioError("data in `tEXt` chunk is not properly URL-encoded") from e
177
+
178
+ # decompress data embedded in the outer XML wrapper
179
+ return decompress_diagram(url_decoded)
180
+
181
+ # matching `tEXt` chunk not found
182
+ raise DrawioError("not a PNG file made with draw.io")
183
+
184
+
185
+ def extract_xml_from_svg(svg_data: bytes) -> ET._Element:
186
+ """
187
+ Extracts an editable draw.io diagram from an SVG file.
188
+
189
+ :param svg_data: SVG XML data, with an embedded draw.io diagram.
190
+ :returns: XML element tree of a draw.io diagram.
191
+ """
192
+
193
+ try:
194
+ root = ET.fromstring(svg_data)
195
+ except ET.ParseError as e:
196
+ raise DrawioError("invalid SVG XML") from e
197
+
198
+ content = root.attrib.get("content")
199
+ if content is None:
200
+ raise DrawioError("SVG root element has no attribute `content`")
201
+
202
+ return decompress_diagram(content)
203
+
204
+
205
+ def extract_diagram(path: Path) -> bytes:
206
+ """
207
+ Extracts an editable draw.io diagram from a PNG file.
208
+
209
+ :param path: Path to a PNG or SVG file with an embedded draw.io diagram.
210
+ :returns: XML data of a draw.io diagram as bytes.
211
+ """
212
+
213
+ if path.name.endswith(".drawio.png"):
214
+ with open(path, "rb") as png_file:
215
+ root = extract_xml_from_png(png_file.read())
216
+ elif path.name.endswith(".drawio.svg"):
217
+ with open(path, "rb") as svg_file:
218
+ root = extract_xml_from_svg(svg_file.read())
219
+ else:
220
+ raise DrawioError(f"unrecognized file type for {path.name}")
221
+
222
+ return ET.tostring(root, encoding="utf8", method="xml")
md2conf/local.py CHANGED
@@ -66,6 +66,7 @@ class LocalProcessor(Processor):
66
66
  page_id=page_id,
67
67
  space_key=node.space_key or self.site.space_key or "HOME",
68
68
  title=node.title or "",
69
+ synchronized=node.synchronized,
69
70
  ),
70
71
  )
71
72
 
md2conf/matcher.py CHANGED
@@ -10,14 +10,57 @@ import os.path
10
10
  from dataclasses import dataclass
11
11
  from fnmatch import fnmatch
12
12
  from pathlib import Path
13
- from typing import Iterable, Optional, Union, overload
13
+ from typing import Iterable, Optional, Union, final, overload
14
14
 
15
15
 
16
- @dataclass(frozen=True)
16
+ @dataclass(frozen=True, eq=True)
17
+ class _BaseEntry:
18
+ """
19
+ Represents a file or directory entry.
20
+
21
+ Entries are primarily sorted alphabetically case-insensitive.
22
+ When two items are equal case-insensitive, conflicting items are put in case-sensitive order.
23
+
24
+ :param name: Name of the file-system entry.
25
+ """
26
+
27
+ name: str
28
+
29
+ @property
30
+ def lower_name(self) -> str:
31
+ return self.name.lower()
32
+
33
+ def __lt__(self, other: "_BaseEntry") -> bool:
34
+ return (self.lower_name, self.name) < (other.lower_name, other.name)
35
+
36
+ def __le__(self, other: "_BaseEntry") -> bool:
37
+ return (self.lower_name, self.name) <= (other.lower_name, other.name)
38
+
39
+ def __ge__(self, other: "_BaseEntry") -> bool:
40
+ return (self.lower_name, self.name) >= (other.lower_name, other.name)
41
+
42
+ def __gt__(self, other: "_BaseEntry") -> bool:
43
+ return (self.lower_name, self.name) > (other.lower_name, other.name)
44
+
45
+
46
+ @final
47
+ class FileEntry(_BaseEntry):
48
+ pass
49
+
50
+
51
+ @final
52
+ class DirectoryEntry(_BaseEntry):
53
+ pass
54
+
55
+
56
+ @dataclass(frozen=True, eq=True)
17
57
  class Entry:
18
58
  """
19
59
  Represents a file or directory entry.
20
60
 
61
+ When sorted, directories come before files and items are primarily arranged in alphabetical order case-insensitive.
62
+ When two items are equal case-insensitive, conflicting items are put in case-sensitive order.
63
+
21
64
  :param name: Name of the file-system entry to match against the rule-set.
22
65
  :param is_dir: True if the entry is a directory.
23
66
  """
@@ -25,6 +68,22 @@ class Entry:
25
68
  name: str
26
69
  is_dir: bool
27
70
 
71
+ @property
72
+ def lower_name(self) -> str:
73
+ return self.name.lower()
74
+
75
+ def __lt__(self, other: "Entry") -> bool:
76
+ return (not self.is_dir, self.lower_name, self.name) < (not other.is_dir, other.lower_name, other.name)
77
+
78
+ def __le__(self, other: "Entry") -> bool:
79
+ return (not self.is_dir, self.lower_name, self.name) <= (not other.is_dir, other.lower_name, other.name)
80
+
81
+ def __ge__(self, other: "Entry") -> bool:
82
+ return (not self.is_dir, self.lower_name, self.name) >= (not other.is_dir, other.lower_name, other.name)
83
+
84
+ def __gt__(self, other: "Entry") -> bool:
85
+ return (not self.is_dir, self.lower_name, self.name) > (not other.is_dir, other.lower_name, other.name)
86
+
28
87
 
29
88
  @dataclass
30
89
  class MatcherOptions:
@@ -146,9 +205,9 @@ class Matcher:
146
205
  :returns: A filtered list of names that didn't match any of the exclusion rules.
147
206
  """
148
207
 
149
- return [entry for entry in entries if self.is_included(entry)]
208
+ return sorted(entry for entry in entries if self.is_included(entry))
150
209
 
151
- def scandir(self, path: Path) -> list[Entry]:
210
+ def listing(self, path: Path) -> list[Entry]:
152
211
  """
153
212
  Returns only those entries in a directory whose name doesn't match any of the exclusion rules.
154
213
 
md2conf/metadata.py CHANGED
@@ -33,8 +33,10 @@ class ConfluencePageMetadata:
33
33
  :param page_id: Confluence page ID.
34
34
  :param space_key: Confluence space key.
35
35
  :param title: Document title.
36
+ :param synchronized: True if the document content is parsed and synchronized with Confluence.
36
37
  """
37
38
 
38
39
  page_id: str
39
40
  space_key: str
40
41
  title: str
42
+ synchronized: bool
md2conf/processor.py CHANGED
@@ -15,7 +15,7 @@ from typing import Iterable, Optional
15
15
 
16
16
  from .collection import ConfluencePageCollection
17
17
  from .converter import ConfluenceDocument, ConfluenceDocumentOptions, ConfluencePageID
18
- from .matcher import Matcher, MatcherOptions
18
+ from .matcher import DirectoryEntry, FileEntry, Matcher, MatcherOptions
19
19
  from .metadata import ConfluenceSiteMetadata
20
20
  from .properties import ArgumentError
21
21
  from .scanner import Scanner
@@ -28,6 +28,7 @@ class DocumentNode:
28
28
  page_id: Optional[str]
29
29
  space_key: Optional[str]
30
30
  title: Optional[str]
31
+ synchronized: bool
31
32
 
32
33
  _children: list["DocumentNode"]
33
34
 
@@ -35,13 +36,15 @@ class DocumentNode:
35
36
  self,
36
37
  absolute_path: Path,
37
38
  page_id: Optional[str],
38
- space_key: Optional[str] = None,
39
- title: Optional[str] = None,
39
+ space_key: Optional[str],
40
+ title: Optional[str],
41
+ synchronized: bool,
40
42
  ):
41
43
  self.absolute_path = absolute_path
42
44
  self.page_id = page_id
43
45
  self.space_key = space_key
44
46
  self.title = title
47
+ self.synchronized = synchronized
45
48
  self._children = []
46
49
 
47
50
  def count(self) -> int:
@@ -98,16 +101,11 @@ class Processor:
98
101
  local_dir = local_dir.resolve(True)
99
102
  LOGGER.info("Processing directory: %s", local_dir)
100
103
 
101
- # Step 1: build index of all Markdown files in directory hierarchy
104
+ # build index of all Markdown files in directory hierarchy
102
105
  root = self._index_directory(local_dir, None)
103
106
  LOGGER.info("Indexed %d document(s)", root.count())
104
107
 
105
- # Step 2: synchronize directory tree structure with page hierarchy in space
106
- self._synchronize_tree(root, self.options.root_page_id)
107
-
108
- # Step 3: synchronize files in directory hierarchy with pages in space
109
- for path, metadata in self.page_metadata.items():
110
- self._synchronize_page(path, ConfluencePageID(metadata.page_id))
108
+ self._process_items(root)
111
109
 
112
110
  def process_page(self, path: Path) -> None:
113
111
  """
@@ -115,16 +113,22 @@ class Processor:
115
113
  """
116
114
 
117
115
  LOGGER.info("Processing page: %s", path)
118
-
119
- # Step 1: parse Markdown file
120
116
  root = self._index_file(path)
121
117
 
122
- # Step 2: find matching page in Confluence
118
+ self._process_items(root)
119
+
120
+ def _process_items(self, root: DocumentNode) -> None:
121
+ """
122
+ Processes a sub-tree rooted at an ancestor node.
123
+ """
124
+
125
+ # synchronize directory tree structure with page hierarchy in space (find matching pages in Confluence)
123
126
  self._synchronize_tree(root, self.options.root_page_id)
124
127
 
125
- # Step 3: synchronize document with page in space
128
+ # synchronize files in directory hierarchy with pages in space
126
129
  for path, metadata in self.page_metadata.items():
127
- self._synchronize_page(path, ConfluencePageID(metadata.page_id))
130
+ if metadata.synchronized:
131
+ self._synchronize_page(path, ConfluencePageID(metadata.page_id))
128
132
 
129
133
  def _synchronize_page(self, path: Path, page_id: ConfluencePageID) -> None:
130
134
  """
@@ -161,36 +165,40 @@ class Processor:
161
165
 
162
166
  matcher = Matcher(MatcherOptions(source=".mdignore", extension="md"), local_dir)
163
167
 
164
- files: list[Path] = []
165
- directories: list[Path] = []
168
+ files: list[FileEntry] = []
169
+ directories: list[DirectoryEntry] = []
166
170
  for entry in os.scandir(local_dir):
167
171
  if matcher.is_excluded(entry):
168
172
  continue
169
173
 
170
174
  if entry.is_file():
171
- files.append(local_dir / entry.name)
175
+ files.append(FileEntry(entry.name))
172
176
  elif entry.is_dir():
173
- directories.append(local_dir / entry.name)
177
+ directories.append(DirectoryEntry(entry.name))
178
+
179
+ files.sort()
180
+ directories.sort()
174
181
 
175
182
  # make page act as parent node
176
183
  parent_doc: Optional[Path] = None
177
- if (local_dir / "index.md") in files:
184
+ if FileEntry("index.md") in files:
178
185
  parent_doc = local_dir / "index.md"
179
- elif (local_dir / "README.md") in files:
186
+ elif FileEntry("README.md") in files:
180
187
  parent_doc = local_dir / "README.md"
181
- elif (local_dir / f"{local_dir.name}.md") in files:
188
+ elif FileEntry(f"{local_dir.name}.md") in files:
182
189
  parent_doc = local_dir / f"{local_dir.name}.md"
183
190
 
184
191
  if parent_doc is None and self.options.keep_hierarchy:
185
192
  parent_doc = local_dir / "index.md"
186
193
 
187
194
  # create a blank page for directory entry
188
- with open(parent_doc, "w"):
189
- pass
195
+ with open(parent_doc, "w") as f:
196
+ print("[[_LISTING_]]", file=f)
190
197
 
191
198
  if parent_doc is not None:
192
- if parent_doc in files:
193
- files.remove(parent_doc)
199
+ parent_entry = FileEntry(parent_doc.name)
200
+ if parent_entry in files:
201
+ files.remove(parent_entry)
194
202
 
195
203
  # promote Markdown document in directory as parent page in Confluence
196
204
  node = self._index_file(parent_doc)
@@ -201,11 +209,11 @@ class Processor:
201
209
  raise ArgumentError(f"root page requires corresponding top-level Markdown document in {local_dir}")
202
210
 
203
211
  for file in files:
204
- node = self._index_file(file)
212
+ node = self._index_file(local_dir / Path(file.name))
205
213
  parent.add_child(node)
206
214
 
207
215
  for directory in directories:
208
- self._index_directory(directory, parent)
216
+ self._index_directory(local_dir / Path(directory.name), parent)
209
217
 
210
218
  return parent
211
219
 
@@ -224,6 +232,7 @@ class Processor:
224
232
  page_id=document.page_id,
225
233
  space_key=document.space_key,
226
234
  title=document.title,
235
+ synchronized=document.synchronized if document.synchronized is not None else True,
227
236
  )
228
237
 
229
238
  def _generate_hash(self, absolute_path: Path) -> str:
md2conf/scanner.py CHANGED
@@ -69,6 +69,7 @@ class DocumentProperties:
69
69
  :param generated_by: Text identifying the tool that generated the document.
70
70
  :param title: The title extracted from front-matter.
71
71
  :param tags: A list of tags (content labels) extracted from front-matter.
72
+ :param synchronized: True if the document content is parsed and synchronized with Confluence.
72
73
  :param properties: A dictionary of key-value pairs extracted from front-matter to apply as page properties.
73
74
  """
74
75
 
@@ -79,6 +80,7 @@ class DocumentProperties:
79
80
  generated_by: Optional[str]
80
81
  title: Optional[str]
81
82
  tags: Optional[list[str]]
83
+ synchronized: Optional[bool]
82
84
  properties: Optional[dict[str, JsonType]]
83
85
 
84
86
 
@@ -92,6 +94,7 @@ class ScannedDocument:
92
94
  :param generated_by: Text identifying the tool that generated the document.
93
95
  :param title: The title extracted from front-matter.
94
96
  :param tags: A list of tags (content labels) extracted from front-matter.
97
+ :param synchronized: True if the document content is parsed and synchronized with Confluence.
95
98
  :param properties: A dictionary of key-value pairs extracted from front-matter to apply as page properties.
96
99
  :param text: Text that remains after front-matter and inline properties have been extracted.
97
100
  """
@@ -101,6 +104,7 @@ class ScannedDocument:
101
104
  generated_by: Optional[str]
102
105
  title: Optional[str]
103
106
  tags: Optional[list[str]]
107
+ synchronized: Optional[bool]
104
108
  properties: Optional[dict[str, JsonType]]
105
109
  text: str
106
110
 
@@ -126,6 +130,7 @@ class Scanner:
126
130
 
127
131
  title: Optional[str] = None
128
132
  tags: Optional[list[str]] = None
133
+ synchronized: Optional[bool] = None
129
134
  properties: Optional[dict[str, JsonType]] = None
130
135
 
131
136
  # extract front-matter
@@ -137,6 +142,7 @@ class Scanner:
137
142
  generated_by = generated_by or p.generated_by
138
143
  title = p.title
139
144
  tags = p.tags
145
+ synchronized = p.synchronized
140
146
  properties = p.properties
141
147
 
142
148
  return ScannedDocument(
@@ -145,6 +151,7 @@ class Scanner:
145
151
  generated_by=generated_by,
146
152
  title=title,
147
153
  tags=tags,
154
+ synchronized=synchronized,
148
155
  properties=properties,
149
156
  text=text,
150
157
  )
md2conf/xml.py ADDED
@@ -0,0 +1,70 @@
1
+ from typing import Iterable, Optional, Union
2
+
3
+ import lxml.etree as ET
4
+
5
+
6
+ def _attrs_equal_excluding(attrs1: ET._Attrib, attrs2: ET._Attrib, exclude: set[Union[str, ET.QName]]) -> bool:
7
+ """
8
+ Compares two dictionary objects, excluding keys in the skip set.
9
+ """
10
+
11
+ # create key sets to compare, excluding keys to be skipped
12
+ keys1 = {k for k in attrs1.keys() if k not in exclude}
13
+ keys2 = {k for k in attrs2.keys() if k not in exclude}
14
+ if keys1 != keys2:
15
+ return False
16
+
17
+ # compare values for each key
18
+ for key in keys1:
19
+ if attrs1.get(key) != attrs2.get(key):
20
+ return False
21
+
22
+ return True
23
+
24
+
25
+ class ElementComparator:
26
+ skip_attributes: set[Union[str, ET.QName]]
27
+
28
+ def __init__(self, *, skip_attributes: Optional[Iterable[Union[str, ET.QName]]] = None):
29
+ self.skip_attributes = set(skip_attributes) if skip_attributes else set()
30
+
31
+ def is_equal(self, e1: ET._Element, e2: ET._Element) -> bool:
32
+ """
33
+ Recursively check if two XML elements are equal.
34
+ """
35
+
36
+ if e1.tag != e2.tag:
37
+ return False
38
+
39
+ e1_text = e1.text.strip() if e1.text else ""
40
+ e2_text = e2.text.strip() if e2.text else ""
41
+ if e1_text != e2_text:
42
+ return False
43
+
44
+ e1_tail = e1.tail.strip() if e1.tail else ""
45
+ e2_tail = e2.tail.strip() if e2.tail else ""
46
+ if e1_tail != e2_tail:
47
+ return False
48
+
49
+ if not _attrs_equal_excluding(e1.attrib, e2.attrib, self.skip_attributes):
50
+ return False
51
+ if len(e1) != len(e2):
52
+ return False
53
+ return all(self.is_equal(c1, c2) for c1, c2 in zip(e1, e2))
54
+
55
+
56
+ def is_xml_equal(
57
+ tree1: ET._Element,
58
+ tree2: ET._Element,
59
+ *,
60
+ skip_attributes: Optional[Iterable[Union[str, ET.QName]]] = None,
61
+ ) -> bool:
62
+ """
63
+ Compare two XML documents for equivalence, ignoring leading/trailing whitespace differences and attribute definition order.
64
+
65
+ :param tree1: XML document as an element tree.
66
+ :param tree2: XML document as an element tree.
67
+ :returns: True if equivalent, False otherwise.
68
+ """
69
+
70
+ return ElementComparator(skip_attributes=skip_attributes).is_equal(tree1, tree2)
@@ -1,25 +0,0 @@
1
- markdown_to_confluence-0.4.1.dist-info/licenses/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
2
- md2conf/__init__.py,sha256=K6ZE42N5KJjN5o2GqIFa_lcPZvMMCXPMMRWEkvlmcp0,402
3
- md2conf/__main__.py,sha256=MJm9U75savKWKYm4dLREqlsyBWEqkTtaM4YTWkEeo0E,8388
4
- md2conf/api.py,sha256=RQ_nb0Z0VnhJma1BU9ABeb4MQZvZEfFS5mTXXKcY6bk,37584
5
- md2conf/application.py,sha256=cXYXYdEdmMXwhxF69eUiPPG2Ixt4xtlWHXa28wTq150,7571
6
- md2conf/collection.py,sha256=EAXuIFcIRBO-Giic2hdU2d4Hpj0_ZFBAWI3aKQ2fjrI,775
7
- md2conf/converter.py,sha256=x2LAY1Hpw5mTVFNJE5_Zm-o7p5y6TTds6KfrpdM5qQk,38823
8
- md2conf/emoji.py,sha256=UzDrxqFo59wHmbbJmMNdn0rYFDXbZE4qirOM-_egzXc,2603
9
- md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
10
- md2conf/extra.py,sha256=VuMxuOnnC2Qwy6y52ukIxsaYhrZArRqMmRHRE4QZl8g,687
11
- md2conf/local.py,sha256=MVwGxy_n00uqCInLK8FVGaaVnaOp1nfn28PVrWz3cCQ,3496
12
- md2conf/matcher.py,sha256=izgL_MAMqbXjKPvAz3KpFv5OTDsaJ9GplTJbixrT3oY,4918
13
- md2conf/mermaid.py,sha256=f0x7ISj-41ZMh4zTAFPhIWwr94SDcsVZUc1NWqmH_G4,2508
14
- md2conf/metadata.py,sha256=TxgUrskqsWor_pvlQx-p86C0-0qRJ2aeQhuDcXU9Dpc,886
15
- md2conf/processor.py,sha256=yWVRYtbc9UHSUfRxqyPDsgeVqO7gx0s3RiGL1GzMotE,9405
16
- md2conf/properties.py,sha256=RC1jY_TKVbOv2bJxXn27Fj4fNWzyoNUQt6ltgUyVQAQ,3987
17
- md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
18
- md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
- md2conf/scanner.py,sha256=qXfnJkaEwDbz6G6Z9llqifBp2TLAlrXAIP4qkCbGdWo,4964
20
- markdown_to_confluence-0.4.1.dist-info/METADATA,sha256=rAXtL2mR1LHmc_pwkmnwrGpIDMEw-7kZjIJOnMi-NLA,24864
21
- markdown_to_confluence-0.4.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
22
- markdown_to_confluence-0.4.1.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
23
- markdown_to_confluence-0.4.1.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
24
- markdown_to_confluence-0.4.1.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
25
- markdown_to_confluence-0.4.1.dist-info/RECORD,,