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/api.py CHANGED
@@ -22,7 +22,6 @@ import requests
22
22
  from strong_typing.core import JsonType
23
23
  from strong_typing.serialization import DeserializerOptions, json_dump_string, json_to_object, object_to_json
24
24
 
25
- from .converter import ParseError, sanitize_confluence
26
25
  from .metadata import ConfluenceSiteMetadata
27
26
  from .properties import ArgumentError, ConfluenceConnectionProperties, ConfluenceError, PageError
28
27
 
@@ -55,6 +54,18 @@ def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
55
54
  LOGGER = logging.getLogger(__name__)
56
55
 
57
56
 
57
+ def response_cast(response_type: type[T], response: requests.Response) -> T:
58
+ "Converts a response body into the expected type."
59
+
60
+ if response.text:
61
+ LOGGER.debug("Received HTTP payload:\n%s", response.text)
62
+ response.raise_for_status()
63
+ if response_type is not type(None):
64
+ return _json_to_object(response_type, response.json())
65
+ else:
66
+ return None
67
+
68
+
58
69
  @enum.unique
59
70
  class ConfluenceVersion(enum.Enum):
60
71
  """
@@ -150,7 +161,7 @@ class ConfluenceAttachment:
150
161
  createdAt: datetime.datetime
151
162
  pageId: str
152
163
  mediaType: str
153
- mediaTypeDescription: str
164
+ mediaTypeDescription: Optional[str]
154
165
  comment: Optional[str]
155
166
  fileId: str
156
167
  fileSize: int
@@ -387,8 +398,7 @@ class ConfluenceSession:
387
398
  self.api_url = api_url
388
399
 
389
400
  if not domain or not base_path:
390
- payload = self._invoke(ConfluenceVersion.VERSION_2, "/spaces", {"limit": "1"})
391
- data = json_to_object(ConfluenceResultSet, payload)
401
+ data = self._get(ConfluenceVersion.VERSION_2, "/spaces", ConfluenceResultSet, query={"limit": "1"})
392
402
  base_url = data._links.base
393
403
 
394
404
  _, domain, base_path, _, _, _ = urlparse(base_url)
@@ -425,12 +435,14 @@ class ConfluenceSession:
425
435
  base_url = f"{self.api_url}{version.value}{path}"
426
436
  return build_url(base_url, query)
427
437
 
428
- def _invoke(
438
+ def _get(
429
439
  self,
430
440
  version: ConfluenceVersion,
431
441
  path: str,
442
+ response_type: type[T],
443
+ *,
432
444
  query: Optional[dict[str, str]] = None,
433
- ) -> JsonType:
445
+ ) -> T:
434
446
  "Executes an HTTP request via Confluence API."
435
447
 
436
448
  url = self._build_url(version, path, query)
@@ -438,7 +450,7 @@ class ConfluenceSession:
438
450
  if response.text:
439
451
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
440
452
  response.raise_for_status()
441
- return typing.cast(JsonType, response.json())
453
+ return _json_to_object(response_type, response.json())
442
454
 
443
455
  def _fetch(self, path: str, query: Optional[dict[str, str]] = None) -> list[JsonType]:
444
456
  "Retrieves all results of a REST API v2 paginated result-set."
@@ -462,30 +474,55 @@ class ConfluenceSession:
462
474
 
463
475
  return items
464
476
 
465
- def _save(self, version: ConfluenceVersion, path: str, data: JsonType) -> None:
466
- "Persists data via Confluence REST API."
477
+ def _build_request(self, version: ConfluenceVersion, path: str, body: Any, response_type: type[T]) -> tuple[str, dict[str, str], bytes]:
478
+ "Generates URL, headers and raw payload for a typed request/response."
467
479
 
468
480
  url = self._build_url(version, path)
481
+ if response_type is not type(None):
482
+ headers = {
483
+ "Content-Type": "application/json; charset=utf-8",
484
+ "Accept": "application/json",
485
+ }
486
+ else:
487
+ headers = {
488
+ "Content-Type": "application/json; charset=utf-8",
489
+ }
490
+ data = json_dump_string(object_to_json(body)).encode("utf-8")
491
+ return url, headers, data
492
+
493
+ def _post(self, version: ConfluenceVersion, path: str, body: Any, response_type: type[T]) -> T:
494
+ "Creates a new object via Confluence REST API."
495
+
496
+ url, headers, data = self._build_request(version, path, body, response_type)
497
+ response = self.session.post(
498
+ url,
499
+ data=data,
500
+ headers=headers,
501
+ )
502
+ return response_cast(response_type, response)
503
+
504
+ def _put(self, version: ConfluenceVersion, path: str, body: Any, response_type: type[T]) -> T:
505
+ "Updates an existing object via Confluence REST API."
506
+
507
+ url, headers, data = self._build_request(version, path, body, response_type)
469
508
  response = self.session.put(
470
509
  url,
471
- data=json_dump_string(data),
472
- headers={"Content-Type": "application/json"},
510
+ data=data,
511
+ headers=headers,
473
512
  )
474
- if response.text:
475
- LOGGER.debug("Received HTTP payload:\n%s", response.text)
476
- response.raise_for_status()
513
+ return response_cast(response_type, response)
477
514
 
478
515
  def space_id_to_key(self, id: str) -> str:
479
516
  "Finds the Confluence space key for a space ID."
480
517
 
481
518
  key = self._space_id_to_key.get(id)
482
519
  if key is None:
483
- payload = self._invoke(
520
+ data = self._get(
484
521
  ConfluenceVersion.VERSION_2,
485
522
  "/spaces",
486
- {"ids": id, "status": "current"},
523
+ dict[str, JsonType],
524
+ query={"ids": id, "status": "current"},
487
525
  )
488
- data = typing.cast(dict[str, JsonType], payload)
489
526
  results = typing.cast(list[JsonType], data["results"])
490
527
  if len(results) != 1:
491
528
  raise ConfluenceError(f"unique space not found with id: {id}")
@@ -502,12 +539,12 @@ class ConfluenceSession:
502
539
 
503
540
  id = self._space_key_to_id.get(key)
504
541
  if id is None:
505
- payload = self._invoke(
542
+ data = self._get(
506
543
  ConfluenceVersion.VERSION_2,
507
544
  "/spaces",
508
- {"keys": key, "status": "current"},
545
+ dict[str, JsonType],
546
+ query={"keys": key, "status": "current"},
509
547
  )
510
- data = typing.cast(dict[str, JsonType], payload)
511
548
  results = typing.cast(list[JsonType], data["results"])
512
549
  if len(results) != 1:
513
550
  raise ConfluenceError(f"unique space not found with key: {key}")
@@ -546,9 +583,7 @@ class ConfluenceSession:
546
583
  """
547
584
 
548
585
  path = f"/pages/{page_id}/attachments"
549
- query = {"filename": filename}
550
- payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
551
- data = typing.cast(dict[str, JsonType], payload)
586
+ data = self._get(ConfluenceVersion.VERSION_2, path, dict[str, JsonType], query={"filename": filename})
552
587
 
553
588
  results = typing.cast(list[JsonType], data["results"])
554
589
  if len(results) != 1:
@@ -701,7 +736,7 @@ class ConfluenceSession:
701
736
  )
702
737
 
703
738
  LOGGER.info("Updating attachment: %s", attachment_id)
704
- self._save(ConfluenceVersion.VERSION_1, path, object_to_json(request))
739
+ self._put(ConfluenceVersion.VERSION_1, path, request, type(None))
705
740
 
706
741
  def get_page_properties_by_title(
707
742
  self,
@@ -728,8 +763,7 @@ class ConfluenceSession:
728
763
  if space_id is not None:
729
764
  query["space-id"] = space_id
730
765
 
731
- payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
732
- data = typing.cast(dict[str, JsonType], payload)
766
+ data = self._get(ConfluenceVersion.VERSION_2, path, dict[str, JsonType], query=query)
733
767
  results = typing.cast(list[JsonType], data["results"])
734
768
  if len(results) != 1:
735
769
  raise ConfluenceError(f"unique page not found with title: {title}")
@@ -746,9 +780,7 @@ class ConfluenceSession:
746
780
  """
747
781
 
748
782
  path = f"/pages/{page_id}"
749
- query = {"body-format": "storage"}
750
- payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
751
- return _json_to_object(ConfluencePage, payload)
783
+ return self._get(ConfluenceVersion.VERSION_2, path, ConfluencePage, query={"body-format": "storage"})
752
784
 
753
785
  def get_page_properties(self, page_id: str) -> ConfluencePageProperties:
754
786
  """
@@ -759,8 +791,7 @@ class ConfluenceSession:
759
791
  """
760
792
 
761
793
  path = f"/pages/{page_id}"
762
- payload = self._invoke(ConfluenceVersion.VERSION_2, path)
763
- return _json_to_object(ConfluencePageProperties, payload)
794
+ return self._get(ConfluenceVersion.VERSION_2, path, ConfluencePageProperties)
764
795
 
765
796
  def get_page_version(self, page_id: str) -> int:
766
797
  """
@@ -775,39 +806,30 @@ class ConfluenceSession:
775
806
  def update_page(
776
807
  self,
777
808
  page_id: str,
778
- new_content: str,
809
+ content: str,
779
810
  *,
780
- title: Optional[str] = None,
811
+ title: str,
812
+ version: int,
781
813
  ) -> None:
782
814
  """
783
815
  Updates a page via the Confluence API.
784
816
 
785
817
  :param page_id: The Confluence page ID.
786
- :param new_content: Confluence Storage Format XHTML.
818
+ :param content: Confluence Storage Format XHTML.
787
819
  :param title: New title to assign to the page. Needs to be unique within a space.
820
+ :param version: New version to assign to the page.
788
821
  """
789
822
 
790
- page = self.get_page(page_id)
791
- new_title = title or page.title
792
-
793
- try:
794
- old_content = sanitize_confluence(page.content)
795
- if page.title == new_title and old_content == new_content:
796
- LOGGER.info("Up-to-date page: %s", page_id)
797
- return
798
- except ParseError as exc:
799
- LOGGER.warning(exc)
800
-
801
823
  path = f"/pages/{page_id}"
802
824
  request = ConfluenceUpdatePageRequest(
803
825
  id=page_id,
804
826
  status=ConfluenceStatus.CURRENT,
805
- title=new_title,
806
- body=ConfluencePageBody(storage=ConfluencePageStorage(representation=ConfluenceRepresentation.STORAGE, value=new_content)),
807
- version=ConfluenceContentVersion(number=page.version.number + 1, minorEdit=True),
827
+ title=title,
828
+ body=ConfluencePageBody(storage=ConfluencePageStorage(representation=ConfluenceRepresentation.STORAGE, value=content)),
829
+ version=ConfluenceContentVersion(number=version, minorEdit=True),
808
830
  )
809
831
  LOGGER.info("Updating page: %s", page_id)
810
- self._save(ConfluenceVersion.VERSION_2, path, object_to_json(request))
832
+ self._put(ConfluenceVersion.VERSION_2, path, request, type(None))
811
833
 
812
834
  def create_page(
813
835
  self,
@@ -840,9 +862,9 @@ class ConfluenceSession:
840
862
  url = self._build_url(ConfluenceVersion.VERSION_2, path)
841
863
  response = self.session.post(
842
864
  url,
843
- data=json_dump_string(object_to_json(request)),
865
+ data=json_dump_string(object_to_json(request)).encode("utf-8"),
844
866
  headers={
845
- "Content-Type": "application/json",
867
+ "Content-Type": "application/json; charset=utf-8",
846
868
  "Accept": "application/json",
847
869
  },
848
870
  )
@@ -902,7 +924,7 @@ class ConfluenceSession:
902
924
  url,
903
925
  params=query,
904
926
  headers={
905
- "Content-Type": "application/json",
927
+ "Content-Type": "application/json; charset=utf-8",
906
928
  "Accept": "application/json",
907
929
  },
908
930
  )
@@ -954,19 +976,7 @@ class ConfluenceSession:
954
976
  """
955
977
 
956
978
  path = f"/content/{page_id}/label"
957
-
958
- url = self._build_url(ConfluenceVersion.VERSION_1, path)
959
- response = self.session.post(
960
- url,
961
- data=json_dump_string(object_to_json(labels)),
962
- headers={
963
- "Content-Type": "application/json",
964
- "Accept": "application/json",
965
- },
966
- )
967
- if response.text:
968
- LOGGER.debug("Received HTTP payload:\n%s", response.text)
969
- response.raise_for_status()
979
+ self._post(ConfluenceVersion.VERSION_1, path, labels, type(None))
970
980
 
971
981
  def remove_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
972
982
  """
@@ -1028,19 +1038,7 @@ class ConfluenceSession:
1028
1038
  """
1029
1039
 
1030
1040
  path = f"/pages/{page_id}/properties"
1031
- url = self._build_url(ConfluenceVersion.VERSION_2, path)
1032
- response = self.session.post(
1033
- url,
1034
- data=json_dump_string(object_to_json(property)),
1035
- headers={
1036
- "Content-Type": "application/json",
1037
- "Accept": "application/json",
1038
- },
1039
- )
1040
- if response.text:
1041
- LOGGER.debug("Received HTTP payload:\n%s", response.text)
1042
- response.raise_for_status()
1043
- return _json_to_object(ConfluenceIdentifiedContentProperty, response.json())
1041
+ return self._post(ConfluenceVersion.VERSION_2, path, property, ConfluenceIdentifiedContentProperty)
1044
1042
 
1045
1043
  def remove_content_property_from_page(self, page_id: str, property_id: str) -> None:
1046
1044
  """
@@ -1069,24 +1067,16 @@ class ConfluenceSession:
1069
1067
  """
1070
1068
 
1071
1069
  path = f"/pages/{page_id}/properties/{property_id}"
1072
- url = self._build_url(ConfluenceVersion.VERSION_2, path)
1073
- response = self.session.put(
1074
- url,
1075
- data=json_dump_string(
1076
- object_to_json(
1077
- ConfluenceVersionedContentProperty(
1078
- key=property.key,
1079
- value=property.value,
1080
- version=ConfluenceContentVersion(number=version),
1081
- )
1082
- )
1070
+ return self._put(
1071
+ ConfluenceVersion.VERSION_2,
1072
+ path,
1073
+ ConfluenceVersionedContentProperty(
1074
+ key=property.key,
1075
+ value=property.value,
1076
+ version=ConfluenceContentVersion(number=version),
1083
1077
  ),
1084
- headers={"Content-Type": "application/json"},
1078
+ ConfluenceIdentifiedContentProperty,
1085
1079
  )
1086
- if response.text:
1087
- LOGGER.debug("Received HTTP payload:\n%s", response.text)
1088
- response.raise_for_status()
1089
- return json_to_object(ConfluenceIdentifiedContentProperty, response.json())
1090
1080
 
1091
1081
  def update_content_properties_for_page(self, page_id: str, properties: list[ConfluenceContentProperty], *, keep_existing: bool = False) -> None:
1092
1082
  """
md2conf/application.py CHANGED
@@ -11,11 +11,12 @@ from pathlib import Path
11
11
  from typing import Optional
12
12
 
13
13
  from .api import ConfluenceContentProperty, ConfluenceLabel, ConfluenceSession, ConfluenceStatus
14
- from .converter import ConfluenceDocument, ConfluenceDocumentOptions, ConfluencePageID, attachment_name
14
+ from .converter import ConfluenceDocument, ConfluenceDocumentOptions, ConfluencePageID, attachment_name, elements_from_string, get_volatile_attributes
15
15
  from .extra import override, path_relative_to
16
16
  from .metadata import ConfluencePageMetadata
17
17
  from .processor import Converter, DocumentNode, Processor, ProcessorFactory
18
18
  from .properties import PageError
19
+ from .xml import is_xml_equal
19
20
 
20
21
  LOGGER = logging.getLogger(__name__)
21
22
 
@@ -97,6 +98,7 @@ class SynchronizingProcessor(Processor):
97
98
  page_id=page.id,
98
99
  space_key=space_key,
99
100
  title=page.title,
101
+ synchronized=node.synchronized,
100
102
  )
101
103
  self.page_metadata.add(node.absolute_path, data)
102
104
 
@@ -143,7 +145,20 @@ class SynchronizingProcessor(Processor):
143
145
  conflicting_page_id,
144
146
  )
145
147
 
146
- self.api.update_page(page_id.page_id, content, title=title)
148
+ # fetch existing page
149
+ page = self.api.get_page(page_id.page_id)
150
+ if not title: # empty or `None`
151
+ title = page.title
152
+
153
+ # check if page has any changes
154
+ if page.title != title or not is_xml_equal(
155
+ document.root,
156
+ elements_from_string(page.content),
157
+ skip_attributes=get_volatile_attributes(),
158
+ ):
159
+ self.api.update_page(page_id.page_id, content, title=title, version=page.version.number + 1)
160
+ else:
161
+ LOGGER.info("Up-to-date page: %s", page_id)
147
162
 
148
163
  if document.labels is not None:
149
164
  self.api.update_labels(
md2conf/collection.py CHANGED
@@ -7,25 +7,31 @@ Copyright 2022-2025, Levente Hunyadi
7
7
  """
8
8
 
9
9
  from pathlib import Path
10
- from typing import Iterable, Optional
10
+ from typing import Generic, Iterable, Optional, TypeVar
11
11
 
12
12
  from .metadata import ConfluencePageMetadata
13
13
 
14
+ K = TypeVar("K")
15
+ V = TypeVar("V")
14
16
 
15
- class ConfluencePageCollection:
16
- _metadata: dict[Path, ConfluencePageMetadata]
17
+
18
+ class KeyValueCollection(Generic[K, V]):
19
+ _collection: dict[K, V]
17
20
 
18
21
  def __init__(self) -> None:
19
- self._metadata = {}
22
+ self._collection = {}
20
23
 
21
24
  def __len__(self) -> int:
22
- return len(self._metadata)
25
+ return len(self._collection)
26
+
27
+ def add(self, key: K, data: V) -> None:
28
+ self._collection[key] = data
29
+
30
+ def get(self, key: K) -> Optional[V]:
31
+ return self._collection.get(key)
23
32
 
24
- def add(self, path: Path, data: ConfluencePageMetadata) -> None:
25
- self._metadata[path] = data
33
+ def items(self) -> Iterable[tuple[K, V]]:
34
+ return self._collection.items()
26
35
 
27
- def get(self, path: Path) -> Optional[ConfluencePageMetadata]:
28
- return self._metadata.get(path)
29
36
 
30
- def items(self) -> Iterable[tuple[Path, ConfluencePageMetadata]]:
31
- return self._metadata.items()
37
+ class ConfluencePageCollection(KeyValueCollection[Path, ConfluencePageMetadata]): ...