markdown-to-confluence 0.4.0__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
@@ -8,7 +8,6 @@ Copyright 2022-2025, Levente Hunyadi
8
8
 
9
9
  import datetime
10
10
  import enum
11
- import functools
12
11
  import io
13
12
  import logging
14
13
  import mimetypes
@@ -16,26 +15,15 @@ import typing
16
15
  from dataclasses import dataclass
17
16
  from pathlib import Path
18
17
  from types import TracebackType
19
- from typing import Optional, TypeVar
18
+ from typing import Any, Optional, TypeVar
20
19
  from urllib.parse import urlencode, urlparse, urlunparse
21
20
 
22
21
  import requests
23
22
  from strong_typing.core import JsonType
24
- from strong_typing.serialization import (
25
- DeserializerOptions,
26
- json_dump_string,
27
- json_to_object,
28
- object_to_json,
29
- )
30
-
31
- from .converter import ParseError, sanitize_confluence
23
+ from strong_typing.serialization import DeserializerOptions, json_dump_string, json_to_object, object_to_json
24
+
32
25
  from .metadata import ConfluenceSiteMetadata
33
- from .properties import (
34
- ArgumentError,
35
- ConfluenceConnectionProperties,
36
- ConfluenceError,
37
- PageError,
38
- )
26
+ from .properties import ArgumentError, ConfluenceConnectionProperties, ConfluenceError, PageError
39
27
 
40
28
  T = TypeVar("T")
41
29
 
@@ -66,6 +54,18 @@ def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
66
54
  LOGGER = logging.getLogger(__name__)
67
55
 
68
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
+
69
69
  @enum.unique
70
70
  class ConfluenceVersion(enum.Enum):
71
71
  """
@@ -106,6 +106,7 @@ class ConfluenceRepresentation(enum.Enum):
106
106
  class ConfluenceStatus(enum.Enum):
107
107
  CURRENT = "current"
108
108
  DRAFT = "draft"
109
+ ARCHIVED = "archived"
109
110
 
110
111
 
111
112
  @enum.unique
@@ -160,7 +161,7 @@ class ConfluenceAttachment:
160
161
  createdAt: datetime.datetime
161
162
  pageId: str
162
163
  mediaType: str
163
- mediaTypeDescription: str
164
+ mediaTypeDescription: Optional[str]
164
165
  comment: Optional[str]
165
166
  fileId: str
166
167
  fileSize: int
@@ -265,6 +266,41 @@ class ConfluenceIdentifiedLabel(ConfluenceLabel):
265
266
  id: str
266
267
 
267
268
 
269
+ @dataclass(frozen=True)
270
+ class ConfluenceContentProperty:
271
+ """
272
+ Represents a content property.
273
+
274
+ :param key: Property key.
275
+ :param value: Property value as JSON.
276
+ """
277
+
278
+ key: str
279
+ value: JsonType
280
+
281
+
282
+ @dataclass(frozen=True)
283
+ class ConfluenceVersionedContentProperty(ConfluenceContentProperty):
284
+ """
285
+ Represents a content property.
286
+
287
+ :param version: Version information about the property.
288
+ """
289
+
290
+ version: ConfluenceContentVersion
291
+
292
+
293
+ @dataclass(frozen=True)
294
+ class ConfluenceIdentifiedContentProperty(ConfluenceVersionedContentProperty):
295
+ """
296
+ Represents a content property.
297
+
298
+ :param id: Property ID.
299
+ """
300
+
301
+ id: str
302
+
303
+
268
304
  @dataclass(frozen=True)
269
305
  class ConfluenceCreatePageRequest:
270
306
  spaceId: str
@@ -300,9 +336,7 @@ class ConfluenceAPI:
300
336
  properties: ConfluenceConnectionProperties
301
337
  session: Optional["ConfluenceSession"] = None
302
338
 
303
- def __init__(
304
- self, properties: Optional[ConfluenceConnectionProperties] = None
305
- ) -> None:
339
+ def __init__(self, properties: Optional[ConfluenceConnectionProperties] = None) -> None:
306
340
  self.properties = properties or ConfluenceConnectionProperties()
307
341
 
308
342
  def __enter__(self) -> "ConfluenceSession":
@@ -310,9 +344,7 @@ class ConfluenceAPI:
310
344
  if self.properties.user_name:
311
345
  session.auth = (self.properties.user_name, self.properties.api_key)
312
346
  else:
313
- session.headers.update(
314
- {"Authorization": f"Bearer {self.properties.api_key}"}
315
- )
347
+ session.headers.update({"Authorization": f"Bearer {self.properties.api_key}"})
316
348
 
317
349
  if self.properties.headers:
318
350
  session.headers.update(self.properties.headers)
@@ -366,10 +398,7 @@ class ConfluenceSession:
366
398
  self.api_url = api_url
367
399
 
368
400
  if not domain or not base_path:
369
- payload = self._invoke(
370
- ConfluenceVersion.VERSION_2, "/spaces", {"limit": "1"}
371
- )
372
- data = json_to_object(ConfluenceResultSet, payload)
401
+ data = self._get(ConfluenceVersion.VERSION_2, "/spaces", ConfluenceResultSet, query={"limit": "1"})
373
402
  base_url = data._links.base
374
403
 
375
404
  _, domain, base_path, _, _, _ = urlparse(base_url)
@@ -377,13 +406,9 @@ class ConfluenceSession:
377
406
  base_path = f"{base_path}/"
378
407
 
379
408
  if not domain:
380
- raise ArgumentError(
381
- "Confluence domain not specified and cannot be inferred"
382
- )
409
+ raise ArgumentError("Confluence domain not specified and cannot be inferred")
383
410
  if not base_path:
384
- raise ArgumentError(
385
- "Confluence base path not specified and cannot be inferred"
386
- )
411
+ raise ArgumentError("Confluence base path not specified and cannot be inferred")
387
412
  self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
388
413
  if not api_url:
389
414
  self.api_url = f"https://{self.site.domain}{self.site.base_path}"
@@ -410,12 +435,14 @@ class ConfluenceSession:
410
435
  base_url = f"{self.api_url}{version.value}{path}"
411
436
  return build_url(base_url, query)
412
437
 
413
- def _invoke(
438
+ def _get(
414
439
  self,
415
440
  version: ConfluenceVersion,
416
441
  path: str,
442
+ response_type: type[T],
443
+ *,
417
444
  query: Optional[dict[str, str]] = None,
418
- ) -> JsonType:
445
+ ) -> T:
419
446
  "Executes an HTTP request via Confluence API."
420
447
 
421
448
  url = self._build_url(version, path, query)
@@ -423,11 +450,9 @@ class ConfluenceSession:
423
450
  if response.text:
424
451
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
425
452
  response.raise_for_status()
426
- return typing.cast(JsonType, response.json())
453
+ return _json_to_object(response_type, response.json())
427
454
 
428
- def _fetch(
429
- self, path: str, query: Optional[dict[str, str]] = None
430
- ) -> list[JsonType]:
455
+ def _fetch(self, path: str, query: Optional[dict[str, str]] = None) -> list[JsonType]:
431
456
  "Retrieves all results of a REST API v2 paginated result-set."
432
457
 
433
458
  items: list[JsonType] = []
@@ -449,30 +474,55 @@ class ConfluenceSession:
449
474
 
450
475
  return items
451
476
 
452
- def _save(self, version: ConfluenceVersion, path: str, data: JsonType) -> None:
453
- "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."
454
479
 
455
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)
456
508
  response = self.session.put(
457
509
  url,
458
- data=json_dump_string(data),
459
- headers={"Content-Type": "application/json"},
510
+ data=data,
511
+ headers=headers,
460
512
  )
461
- if response.text:
462
- LOGGER.debug("Received HTTP payload:\n%s", response.text)
463
- response.raise_for_status()
513
+ return response_cast(response_type, response)
464
514
 
465
515
  def space_id_to_key(self, id: str) -> str:
466
516
  "Finds the Confluence space key for a space ID."
467
517
 
468
518
  key = self._space_id_to_key.get(id)
469
519
  if key is None:
470
- payload = self._invoke(
520
+ data = self._get(
471
521
  ConfluenceVersion.VERSION_2,
472
522
  "/spaces",
473
- {"ids": id, "status": "current"},
523
+ dict[str, JsonType],
524
+ query={"ids": id, "status": "current"},
474
525
  )
475
- data = typing.cast(dict[str, JsonType], payload)
476
526
  results = typing.cast(list[JsonType], data["results"])
477
527
  if len(results) != 1:
478
528
  raise ConfluenceError(f"unique space not found with id: {id}")
@@ -489,12 +539,12 @@ class ConfluenceSession:
489
539
 
490
540
  id = self._space_key_to_id.get(key)
491
541
  if id is None:
492
- payload = self._invoke(
542
+ data = self._get(
493
543
  ConfluenceVersion.VERSION_2,
494
544
  "/spaces",
495
- {"keys": key, "status": "current"},
545
+ dict[str, JsonType],
546
+ query={"keys": key, "status": "current"},
496
547
  )
497
- data = typing.cast(dict[str, JsonType], payload)
498
548
  results = typing.cast(list[JsonType], data["results"])
499
549
  if len(results) != 1:
500
550
  raise ConfluenceError(f"unique space not found with key: {key}")
@@ -506,9 +556,7 @@ class ConfluenceSession:
506
556
 
507
557
  return id
508
558
 
509
- def get_space_id(
510
- self, *, space_id: Optional[str] = None, space_key: Optional[str] = None
511
- ) -> Optional[str]:
559
+ def get_space_id(self, *, space_id: Optional[str] = None, space_key: Optional[str] = None) -> Optional[str]:
512
560
  """
513
561
  Coalesces a space ID or space key into a space ID, accounting for site default.
514
562
 
@@ -529,17 +577,13 @@ class ConfluenceSession:
529
577
  # space ID and key are unset, and no default space is configured
530
578
  return None
531
579
 
532
- def get_attachment_by_name(
533
- self, page_id: str, filename: str
534
- ) -> ConfluenceAttachment:
580
+ def get_attachment_by_name(self, page_id: str, filename: str) -> ConfluenceAttachment:
535
581
  """
536
582
  Retrieves a Confluence page attachment by an unprefixed file name.
537
583
  """
538
584
 
539
585
  path = f"/pages/{page_id}/attachments"
540
- query = {"filename": filename}
541
- payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
542
- data = typing.cast(dict[str, JsonType], payload)
586
+ data = self._get(ConfluenceVersion.VERSION_2, path, dict[str, JsonType], query={"filename": filename})
543
587
 
544
588
  results = typing.cast(list[JsonType], data["results"])
545
589
  if len(results) != 1:
@@ -583,6 +627,9 @@ class ConfluenceSession:
583
627
  name = attachment_name
584
628
  content_type, _ = mimetypes.guess_type(name, strict=True)
585
629
 
630
+ if content_type is None:
631
+ content_type = "application/octet-stream"
632
+
586
633
  if attachment_path is not None and not attachment_path.is_file():
587
634
  raise PageError(f"file not found: {attachment_path}")
588
635
 
@@ -610,8 +657,13 @@ class ConfluenceSession:
610
657
 
611
658
  if attachment_path is not None:
612
659
  with open(attachment_path, "rb") as attachment_file:
613
- file_to_upload = {
614
- "comment": comment,
660
+ file_to_upload: dict[str, tuple[Optional[str], Any, str, dict[str, str]]] = {
661
+ "comment": (
662
+ None,
663
+ comment,
664
+ "text/plain; charset=utf-8",
665
+ {},
666
+ ),
615
667
  "file": (
616
668
  attachment_name, # will truncate path component
617
669
  attachment_file,
@@ -622,7 +674,7 @@ class ConfluenceSession:
622
674
  LOGGER.info("Uploading attachment: %s", attachment_name)
623
675
  response = self.session.post(
624
676
  url,
625
- files=file_to_upload, # type: ignore[arg-type]
677
+ files=file_to_upload,
626
678
  headers={
627
679
  "X-Atlassian-Token": "no-check",
628
680
  "Accept": "application/json",
@@ -634,17 +686,22 @@ class ConfluenceSession:
634
686
  raw_file = io.BytesIO(raw_data)
635
687
  raw_file.name = attachment_name
636
688
  file_to_upload = {
637
- "comment": comment,
689
+ "comment": (
690
+ None,
691
+ comment,
692
+ "text/plain; charset=utf-8",
693
+ {},
694
+ ),
638
695
  "file": (
639
696
  attachment_name, # will truncate path component
640
- raw_file, # type: ignore[dict-item]
697
+ raw_file,
641
698
  content_type,
642
699
  {"Expires": "0"},
643
700
  ),
644
701
  }
645
702
  response = self.session.post(
646
703
  url,
647
- files=file_to_upload, # type: ignore[arg-type]
704
+ files=file_to_upload,
648
705
  headers={
649
706
  "X-Atlassian-Token": "no-check",
650
707
  "Accept": "application/json",
@@ -667,9 +724,7 @@ class ConfluenceSession:
667
724
  # ensure path component is retained in attachment name
668
725
  self._update_attachment(page_id, attachment_id, version, attachment_name)
669
726
 
670
- def _update_attachment(
671
- self, page_id: str, attachment_id: str, version: int, attachment_title: str
672
- ) -> None:
727
+ def _update_attachment(self, page_id: str, attachment_id: str, version: int, attachment_title: str) -> None:
673
728
  id = attachment_id.removeprefix("att")
674
729
  path = f"/content/{page_id}/child/attachment/{id}"
675
730
  request = ConfluenceUpdateAttachmentRequest(
@@ -681,7 +736,7 @@ class ConfluenceSession:
681
736
  )
682
737
 
683
738
  LOGGER.info("Updating attachment: %s", attachment_id)
684
- self._save(ConfluenceVersion.VERSION_1, path, object_to_json(request))
739
+ self._put(ConfluenceVersion.VERSION_1, path, request, type(None))
685
740
 
686
741
  def get_page_properties_by_title(
687
742
  self,
@@ -708,8 +763,7 @@ class ConfluenceSession:
708
763
  if space_id is not None:
709
764
  query["space-id"] = space_id
710
765
 
711
- payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
712
- data = typing.cast(dict[str, JsonType], payload)
766
+ data = self._get(ConfluenceVersion.VERSION_2, path, dict[str, JsonType], query=query)
713
767
  results = typing.cast(list[JsonType], data["results"])
714
768
  if len(results) != 1:
715
769
  raise ConfluenceError(f"unique page not found with title: {title}")
@@ -726,11 +780,8 @@ class ConfluenceSession:
726
780
  """
727
781
 
728
782
  path = f"/pages/{page_id}"
729
- query = {"body-format": "storage"}
730
- payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
731
- return _json_to_object(ConfluencePage, payload)
783
+ return self._get(ConfluenceVersion.VERSION_2, path, ConfluencePage, query={"body-format": "storage"})
732
784
 
733
- @functools.cache
734
785
  def get_page_properties(self, page_id: str) -> ConfluencePageProperties:
735
786
  """
736
787
  Retrieves Confluence wiki page details.
@@ -740,8 +791,7 @@ class ConfluenceSession:
740
791
  """
741
792
 
742
793
  path = f"/pages/{page_id}"
743
- payload = self._invoke(ConfluenceVersion.VERSION_2, path)
744
- return _json_to_object(ConfluencePageProperties, payload)
794
+ return self._get(ConfluenceVersion.VERSION_2, path, ConfluencePageProperties)
745
795
 
746
796
  def get_page_version(self, page_id: str) -> int:
747
797
  """
@@ -756,45 +806,30 @@ class ConfluenceSession:
756
806
  def update_page(
757
807
  self,
758
808
  page_id: str,
759
- new_content: str,
809
+ content: str,
760
810
  *,
761
- title: Optional[str] = None,
811
+ title: str,
812
+ version: int,
762
813
  ) -> None:
763
814
  """
764
815
  Updates a page via the Confluence API.
765
816
 
766
817
  :param page_id: The Confluence page ID.
767
- :param new_content: Confluence Storage Format XHTML.
818
+ :param content: Confluence Storage Format XHTML.
768
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.
769
821
  """
770
822
 
771
- page = self.get_page(page_id)
772
- new_title = title or page.title
773
-
774
- try:
775
- old_content = sanitize_confluence(page.content)
776
- if page.title == new_title and old_content == new_content:
777
- LOGGER.info("Up-to-date page: %s", page_id)
778
- return
779
- except ParseError as exc:
780
- LOGGER.warning(exc)
781
-
782
823
  path = f"/pages/{page_id}"
783
824
  request = ConfluenceUpdatePageRequest(
784
825
  id=page_id,
785
826
  status=ConfluenceStatus.CURRENT,
786
- title=new_title,
787
- body=ConfluencePageBody(
788
- storage=ConfluencePageStorage(
789
- representation=ConfluenceRepresentation.STORAGE, value=new_content
790
- )
791
- ),
792
- version=ConfluenceContentVersion(
793
- number=page.version.number + 1, minorEdit=True
794
- ),
827
+ title=title,
828
+ body=ConfluencePageBody(storage=ConfluencePageStorage(representation=ConfluenceRepresentation.STORAGE, value=content)),
829
+ version=ConfluenceContentVersion(number=version, minorEdit=True),
795
830
  )
796
831
  LOGGER.info("Updating page: %s", page_id)
797
- self._save(ConfluenceVersion.VERSION_2, path, object_to_json(request))
832
+ self._put(ConfluenceVersion.VERSION_2, path, request, type(None))
798
833
 
799
834
  def create_page(
800
835
  self,
@@ -827,9 +862,9 @@ class ConfluenceSession:
827
862
  url = self._build_url(ConfluenceVersion.VERSION_2, path)
828
863
  response = self.session.post(
829
864
  url,
830
- data=json_dump_string(object_to_json(request)),
865
+ data=json_dump_string(object_to_json(request)).encode("utf-8"),
831
866
  headers={
832
- "Content-Type": "application/json",
867
+ "Content-Type": "application/json; charset=utf-8",
833
868
  "Accept": "application/json",
834
869
  },
835
870
  )
@@ -889,7 +924,7 @@ class ConfluenceSession:
889
924
  url,
890
925
  params=query,
891
926
  headers={
892
- "Content-Type": "application/json",
927
+ "Content-Type": "application/json; charset=utf-8",
893
928
  "Accept": "application/json",
894
929
  },
895
930
  )
@@ -941,19 +976,7 @@ class ConfluenceSession:
941
976
  """
942
977
 
943
978
  path = f"/content/{page_id}/label"
944
-
945
- url = self._build_url(ConfluenceVersion.VERSION_1, path)
946
- response = self.session.post(
947
- url,
948
- data=json_dump_string(object_to_json(labels)),
949
- headers={
950
- "Content-Type": "application/json",
951
- "Accept": "application/json",
952
- },
953
- )
954
- if response.text:
955
- LOGGER.debug("Received HTTP payload:\n%s", response.text)
956
- response.raise_for_status()
979
+ self._post(ConfluenceVersion.VERSION_1, path, labels, type(None))
957
980
 
958
981
  def remove_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
959
982
  """
@@ -973,7 +996,7 @@ class ConfluenceSession:
973
996
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
974
997
  response.raise_for_status()
975
998
 
976
- def update_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
999
+ def update_labels(self, page_id: str, labels: list[ConfluenceLabel], *, keep_existing: bool = False) -> None:
977
1000
  """
978
1001
  Assigns the specified labels to a Confluence page. Existing labels are removed.
979
1002
 
@@ -982,10 +1005,7 @@ class ConfluenceSession:
982
1005
  """
983
1006
 
984
1007
  new_labels = set(labels)
985
- old_labels = set(
986
- ConfluenceLabel(name=label.name, prefix=label.prefix)
987
- for label in self.get_labels(page_id)
988
- )
1008
+ old_labels = set(ConfluenceLabel(name=label.name, prefix=label.prefix) for label in self.get_labels(page_id))
989
1009
 
990
1010
  add_labels = list(new_labels - old_labels)
991
1011
  remove_labels = list(old_labels - new_labels)
@@ -993,6 +1013,103 @@ class ConfluenceSession:
993
1013
  if add_labels:
994
1014
  add_labels.sort()
995
1015
  self.add_labels(page_id, add_labels)
996
- if remove_labels:
1016
+ if not keep_existing and remove_labels:
997
1017
  remove_labels.sort()
998
1018
  self.remove_labels(page_id, remove_labels)
1019
+
1020
+ def get_content_properties_for_page(self, page_id: str) -> list[ConfluenceIdentifiedContentProperty]:
1021
+ """
1022
+ Retrieves content properties for a Confluence page.
1023
+
1024
+ :param page_id: The Confluence page ID.
1025
+ :returns: A list of content properties.
1026
+ """
1027
+
1028
+ path = f"/pages/{page_id}/properties"
1029
+ results = self._fetch(path)
1030
+ return _json_to_object(list[ConfluenceIdentifiedContentProperty], results)
1031
+
1032
+ def add_content_property_to_page(self, page_id: str, property: ConfluenceContentProperty) -> ConfluenceIdentifiedContentProperty:
1033
+ """
1034
+ Adds a new content property to a Confluence page.
1035
+
1036
+ :param page_id: The Confluence page ID.
1037
+ :param property: Content property to add.
1038
+ """
1039
+
1040
+ path = f"/pages/{page_id}/properties"
1041
+ return self._post(ConfluenceVersion.VERSION_2, path, property, ConfluenceIdentifiedContentProperty)
1042
+
1043
+ def remove_content_property_from_page(self, page_id: str, property_id: str) -> None:
1044
+ """
1045
+ Removes a content property from a Confluence page.
1046
+
1047
+ :param page_id: The Confluence page ID.
1048
+ :param property_id: Property ID, which uniquely identifies the property.
1049
+ """
1050
+
1051
+ path = f"/pages/{page_id}/properties/{property_id}"
1052
+ url = self._build_url(ConfluenceVersion.VERSION_2, path)
1053
+ response = self.session.delete(url)
1054
+ response.raise_for_status()
1055
+
1056
+ def update_content_property_for_page(
1057
+ self, page_id: str, property_id: str, version: int, property: ConfluenceContentProperty
1058
+ ) -> ConfluenceIdentifiedContentProperty:
1059
+ """
1060
+ Updates an existing content property associated with a Confluence page.
1061
+
1062
+ :param page_id: The Confluence page ID.
1063
+ :param property_id: Property ID, which uniquely identifies the property.
1064
+ :param version: Version number to assign.
1065
+ :param property: Content property data to assign.
1066
+ :returns: Updated content property data.
1067
+ """
1068
+
1069
+ path = f"/pages/{page_id}/properties/{property_id}"
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),
1077
+ ),
1078
+ ConfluenceIdentifiedContentProperty,
1079
+ )
1080
+
1081
+ def update_content_properties_for_page(self, page_id: str, properties: list[ConfluenceContentProperty], *, keep_existing: bool = False) -> None:
1082
+ """
1083
+ Updates content properties associated with a Confluence page.
1084
+
1085
+ :param page_id: The Confluence page ID.
1086
+ :param properties: A list of content property data to update.
1087
+ :param keep_existing: Whether to keep content property data whose key is not included in the list of properties passed as an argument.
1088
+ """
1089
+
1090
+ old_mapping = {p.key: p for p in self.get_content_properties_for_page(page_id)}
1091
+ new_mapping = {p.key: p for p in properties}
1092
+
1093
+ new_props = set(p.key for p in properties)
1094
+ old_props = set(old_mapping.keys())
1095
+
1096
+ add_props = list(new_props - old_props)
1097
+ remove_props = list(old_props - new_props)
1098
+ update_props = list(old_props & new_props)
1099
+
1100
+ if add_props:
1101
+ add_props.sort()
1102
+ for key in add_props:
1103
+ self.add_content_property_to_page(page_id, new_mapping[key])
1104
+ if not keep_existing and remove_props:
1105
+ remove_props.sort()
1106
+ for key in remove_props:
1107
+ self.remove_content_property_from_page(page_id, old_mapping[key].id)
1108
+ if update_props:
1109
+ update_props.sort()
1110
+ for key in update_props:
1111
+ old_prop = old_mapping[key]
1112
+ new_prop = new_mapping[key]
1113
+ if old_prop.value == new_prop.value:
1114
+ continue
1115
+ self.update_content_property_for_page(page_id, old_prop.id, old_prop.version.number + 1, new_prop)