markdown-to-confluence 0.4.0__py3-none-any.whl → 0.4.1__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.
@@ -1,15 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-to-confluence
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Publish Markdown files to Confluence wiki
5
- Home-page: https://github.com/hunyadi/md2conf
6
- Author: Levente Hunyadi
7
- Author-email: hunyadi@gmail.com
8
- License: MIT
5
+ Author-email: Levente Hunyadi <hunyadi@gmail.com>
6
+ Maintainer-email: Levente Hunyadi <hunyadi@gmail.com>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/hunyadi/md2conf
9
+ Project-URL: Source, https://github.com/hunyadi/md2conf
10
+ Keywords: markdown,converter,confluence
9
11
  Classifier: Development Status :: 5 - Production/Stable
10
12
  Classifier: Environment :: Console
11
13
  Classifier: Intended Audience :: End Users/Desktop
12
- Classifier: License :: OSI Approved :: MIT License
13
14
  Classifier: Operating System :: OS Independent
14
15
  Classifier: Programming Language :: Python :: 3
15
16
  Classifier: Programming Language :: Python :: 3.9
@@ -17,21 +18,26 @@ Classifier: Programming Language :: Python :: 3.10
17
18
  Classifier: Programming Language :: Python :: 3.11
18
19
  Classifier: Programming Language :: Python :: 3.12
19
20
  Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3 :: Only
20
22
  Classifier: Typing :: Typed
21
23
  Requires-Python: >=3.9
22
24
  Description-Content-Type: text/markdown
23
25
  License-File: LICENSE
24
26
  Requires-Dist: json_strong_typing>=0.3.9
25
- Requires-Dist: lxml>=5.4
26
- Requires-Dist: types-lxml>=2025.3.30
27
+ Requires-Dist: lxml>=6.0
27
28
  Requires-Dist: markdown>=3.8
28
- Requires-Dist: types-markdown>=3.8
29
29
  Requires-Dist: pymdown-extensions>=10.16
30
30
  Requires-Dist: PyYAML>=6.0
31
- Requires-Dist: types-PyYAML>=6.0
32
31
  Requires-Dist: requests>=2.32
33
- Requires-Dist: types-requests>=2.32
34
32
  Requires-Dist: typing_extensions>=4.14; python_version < "3.12"
33
+ Provides-Extra: dev
34
+ Requires-Dist: markdown_doc>=0.1.4; python_version >= "3.10" and extra == "dev"
35
+ Requires-Dist: types-lxml>=2025.3.30; extra == "dev"
36
+ Requires-Dist: types-markdown>=3.8; extra == "dev"
37
+ Requires-Dist: types-PyYAML>=6.0; extra == "dev"
38
+ Requires-Dist: types-requests>=2.32; extra == "dev"
39
+ Requires-Dist: mypy>=1.16; extra == "dev"
40
+ Requires-Dist: ruff>=0.12; extra == "dev"
35
41
  Dynamic: license-file
36
42
 
37
43
  # Publish Markdown files to Confluence wiki
@@ -326,6 +332,22 @@ Any previously assigned labels are discarded. As per Confluence terminology, new
326
332
 
327
333
  If a document has no `tags` attribute, existing Confluence labels are left intact.
328
334
 
335
+ ### Content properties
336
+
337
+ The front-matter attribute `properties` in a Markdown document allows setting Confluence content properties on a page. Confluence content properties are a way to store structured metadata in the form of key-value pairs directly on Confluence content. The values in content properties are represented as JSON objects.
338
+
339
+ Some content properties have special meaning to Confluence. For example, the following properties cause Confluence to display a wiki page with content confined to a fixed width in regular view mode, and taking the full page width in draft mode:
340
+
341
+ ```yaml
342
+ ---
343
+ properties:
344
+ content-appearance-published: fixed-width
345
+ content-appearance-draft: full-width
346
+ ---
347
+ ```
348
+
349
+ The attribute `properties` is parsed as a dictionary with keys of type string and values of type JSON. *md2conf* passes JSON values to Confluence REST API unchanged.
350
+
329
351
  ### Converting diagrams
330
352
 
331
353
  You can include [Mermaid diagrams](https://mermaid.js.org/) in your Markdown documents to create visual representations of systems, processes, and relationships. When a Markdown document contains a code block with the language specifier `mermaid`, *md2conf* offers two options to publish the diagram:
@@ -0,0 +1,25 @@
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,,
md2conf/__init__.py CHANGED
@@ -5,7 +5,7 @@ Parses Markdown files, converts Markdown content into the Confluence Storage For
5
5
  Confluence API endpoints to upload images and content.
6
6
  """
7
7
 
8
- __version__ = "0.4.0"
8
+ __version__ = "0.4.1"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2025, Levente Hunyadi"
11
11
  __license__ = "MIT"
md2conf/__main__.py CHANGED
@@ -26,11 +26,7 @@ from .converter import ConfluenceDocumentOptions, ConfluencePageID
26
26
  from .extra import override
27
27
  from .local import LocalConverter
28
28
  from .metadata import ConfluenceSiteMetadata
29
- from .properties import (
30
- ArgumentError,
31
- ConfluenceConnectionProperties,
32
- ConfluenceSiteProperties,
33
- )
29
+ from .properties import ArgumentError, ConfluenceConnectionProperties, ConfluenceSiteProperties
34
30
 
35
31
 
36
32
  class Arguments(argparse.Namespace):
@@ -71,7 +67,7 @@ class KwargsAppendAction(argparse.Action):
71
67
  raise argparse.ArgumentError(
72
68
  self,
73
69
  f'Could not parse argument "{values}". It should follow the format: k1=v1 k2=v2 ...',
74
- )
70
+ ) from None
75
71
  setattr(namespace, self.dest, d)
76
72
 
77
73
 
@@ -79,13 +75,9 @@ def main() -> None:
79
75
  parser = argparse.ArgumentParser()
80
76
  parser.prog = os.path.basename(os.path.dirname(__file__))
81
77
  parser.add_argument("--version", action="version", version=__version__)
82
- parser.add_argument(
83
- "mdpath", help="Path to Markdown file or directory to convert and publish."
84
- )
78
+ parser.add_argument("mdpath", help="Path to Markdown file or directory to convert and publish.")
85
79
  parser.add_argument("-d", "--domain", help="Confluence organization domain.")
86
- parser.add_argument(
87
- "-p", "--path", help="Base path for Confluence (default: '/wiki/')."
88
- )
80
+ parser.add_argument("-p", "--path", help="Base path for Confluence (default: '/wiki/').")
89
81
  parser.add_argument(
90
82
  "--api-url",
91
83
  dest="api_url",
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,16 @@ 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
- )
23
+ from strong_typing.serialization import DeserializerOptions, json_dump_string, json_to_object, object_to_json
30
24
 
31
25
  from .converter import ParseError, sanitize_confluence
32
26
  from .metadata import ConfluenceSiteMetadata
33
- from .properties import (
34
- ArgumentError,
35
- ConfluenceConnectionProperties,
36
- ConfluenceError,
37
- PageError,
38
- )
27
+ from .properties import ArgumentError, ConfluenceConnectionProperties, ConfluenceError, PageError
39
28
 
40
29
  T = TypeVar("T")
41
30
 
@@ -106,6 +95,7 @@ class ConfluenceRepresentation(enum.Enum):
106
95
  class ConfluenceStatus(enum.Enum):
107
96
  CURRENT = "current"
108
97
  DRAFT = "draft"
98
+ ARCHIVED = "archived"
109
99
 
110
100
 
111
101
  @enum.unique
@@ -265,6 +255,41 @@ class ConfluenceIdentifiedLabel(ConfluenceLabel):
265
255
  id: str
266
256
 
267
257
 
258
+ @dataclass(frozen=True)
259
+ class ConfluenceContentProperty:
260
+ """
261
+ Represents a content property.
262
+
263
+ :param key: Property key.
264
+ :param value: Property value as JSON.
265
+ """
266
+
267
+ key: str
268
+ value: JsonType
269
+
270
+
271
+ @dataclass(frozen=True)
272
+ class ConfluenceVersionedContentProperty(ConfluenceContentProperty):
273
+ """
274
+ Represents a content property.
275
+
276
+ :param version: Version information about the property.
277
+ """
278
+
279
+ version: ConfluenceContentVersion
280
+
281
+
282
+ @dataclass(frozen=True)
283
+ class ConfluenceIdentifiedContentProperty(ConfluenceVersionedContentProperty):
284
+ """
285
+ Represents a content property.
286
+
287
+ :param id: Property ID.
288
+ """
289
+
290
+ id: str
291
+
292
+
268
293
  @dataclass(frozen=True)
269
294
  class ConfluenceCreatePageRequest:
270
295
  spaceId: str
@@ -300,9 +325,7 @@ class ConfluenceAPI:
300
325
  properties: ConfluenceConnectionProperties
301
326
  session: Optional["ConfluenceSession"] = None
302
327
 
303
- def __init__(
304
- self, properties: Optional[ConfluenceConnectionProperties] = None
305
- ) -> None:
328
+ def __init__(self, properties: Optional[ConfluenceConnectionProperties] = None) -> None:
306
329
  self.properties = properties or ConfluenceConnectionProperties()
307
330
 
308
331
  def __enter__(self) -> "ConfluenceSession":
@@ -310,9 +333,7 @@ class ConfluenceAPI:
310
333
  if self.properties.user_name:
311
334
  session.auth = (self.properties.user_name, self.properties.api_key)
312
335
  else:
313
- session.headers.update(
314
- {"Authorization": f"Bearer {self.properties.api_key}"}
315
- )
336
+ session.headers.update({"Authorization": f"Bearer {self.properties.api_key}"})
316
337
 
317
338
  if self.properties.headers:
318
339
  session.headers.update(self.properties.headers)
@@ -366,9 +387,7 @@ class ConfluenceSession:
366
387
  self.api_url = api_url
367
388
 
368
389
  if not domain or not base_path:
369
- payload = self._invoke(
370
- ConfluenceVersion.VERSION_2, "/spaces", {"limit": "1"}
371
- )
390
+ payload = self._invoke(ConfluenceVersion.VERSION_2, "/spaces", {"limit": "1"})
372
391
  data = json_to_object(ConfluenceResultSet, payload)
373
392
  base_url = data._links.base
374
393
 
@@ -377,13 +396,9 @@ class ConfluenceSession:
377
396
  base_path = f"{base_path}/"
378
397
 
379
398
  if not domain:
380
- raise ArgumentError(
381
- "Confluence domain not specified and cannot be inferred"
382
- )
399
+ raise ArgumentError("Confluence domain not specified and cannot be inferred")
383
400
  if not base_path:
384
- raise ArgumentError(
385
- "Confluence base path not specified and cannot be inferred"
386
- )
401
+ raise ArgumentError("Confluence base path not specified and cannot be inferred")
387
402
  self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
388
403
  if not api_url:
389
404
  self.api_url = f"https://{self.site.domain}{self.site.base_path}"
@@ -425,9 +440,7 @@ class ConfluenceSession:
425
440
  response.raise_for_status()
426
441
  return typing.cast(JsonType, response.json())
427
442
 
428
- def _fetch(
429
- self, path: str, query: Optional[dict[str, str]] = None
430
- ) -> list[JsonType]:
443
+ def _fetch(self, path: str, query: Optional[dict[str, str]] = None) -> list[JsonType]:
431
444
  "Retrieves all results of a REST API v2 paginated result-set."
432
445
 
433
446
  items: list[JsonType] = []
@@ -506,9 +519,7 @@ class ConfluenceSession:
506
519
 
507
520
  return id
508
521
 
509
- def get_space_id(
510
- self, *, space_id: Optional[str] = None, space_key: Optional[str] = None
511
- ) -> Optional[str]:
522
+ def get_space_id(self, *, space_id: Optional[str] = None, space_key: Optional[str] = None) -> Optional[str]:
512
523
  """
513
524
  Coalesces a space ID or space key into a space ID, accounting for site default.
514
525
 
@@ -529,9 +540,7 @@ class ConfluenceSession:
529
540
  # space ID and key are unset, and no default space is configured
530
541
  return None
531
542
 
532
- def get_attachment_by_name(
533
- self, page_id: str, filename: str
534
- ) -> ConfluenceAttachment:
543
+ def get_attachment_by_name(self, page_id: str, filename: str) -> ConfluenceAttachment:
535
544
  """
536
545
  Retrieves a Confluence page attachment by an unprefixed file name.
537
546
  """
@@ -583,6 +592,9 @@ class ConfluenceSession:
583
592
  name = attachment_name
584
593
  content_type, _ = mimetypes.guess_type(name, strict=True)
585
594
 
595
+ if content_type is None:
596
+ content_type = "application/octet-stream"
597
+
586
598
  if attachment_path is not None and not attachment_path.is_file():
587
599
  raise PageError(f"file not found: {attachment_path}")
588
600
 
@@ -610,8 +622,13 @@ class ConfluenceSession:
610
622
 
611
623
  if attachment_path is not None:
612
624
  with open(attachment_path, "rb") as attachment_file:
613
- file_to_upload = {
614
- "comment": comment,
625
+ file_to_upload: dict[str, tuple[Optional[str], Any, str, dict[str, str]]] = {
626
+ "comment": (
627
+ None,
628
+ comment,
629
+ "text/plain; charset=utf-8",
630
+ {},
631
+ ),
615
632
  "file": (
616
633
  attachment_name, # will truncate path component
617
634
  attachment_file,
@@ -622,7 +639,7 @@ class ConfluenceSession:
622
639
  LOGGER.info("Uploading attachment: %s", attachment_name)
623
640
  response = self.session.post(
624
641
  url,
625
- files=file_to_upload, # type: ignore[arg-type]
642
+ files=file_to_upload,
626
643
  headers={
627
644
  "X-Atlassian-Token": "no-check",
628
645
  "Accept": "application/json",
@@ -634,17 +651,22 @@ class ConfluenceSession:
634
651
  raw_file = io.BytesIO(raw_data)
635
652
  raw_file.name = attachment_name
636
653
  file_to_upload = {
637
- "comment": comment,
654
+ "comment": (
655
+ None,
656
+ comment,
657
+ "text/plain; charset=utf-8",
658
+ {},
659
+ ),
638
660
  "file": (
639
661
  attachment_name, # will truncate path component
640
- raw_file, # type: ignore[dict-item]
662
+ raw_file,
641
663
  content_type,
642
664
  {"Expires": "0"},
643
665
  ),
644
666
  }
645
667
  response = self.session.post(
646
668
  url,
647
- files=file_to_upload, # type: ignore[arg-type]
669
+ files=file_to_upload,
648
670
  headers={
649
671
  "X-Atlassian-Token": "no-check",
650
672
  "Accept": "application/json",
@@ -667,9 +689,7 @@ class ConfluenceSession:
667
689
  # ensure path component is retained in attachment name
668
690
  self._update_attachment(page_id, attachment_id, version, attachment_name)
669
691
 
670
- def _update_attachment(
671
- self, page_id: str, attachment_id: str, version: int, attachment_title: str
672
- ) -> None:
692
+ def _update_attachment(self, page_id: str, attachment_id: str, version: int, attachment_title: str) -> None:
673
693
  id = attachment_id.removeprefix("att")
674
694
  path = f"/content/{page_id}/child/attachment/{id}"
675
695
  request = ConfluenceUpdateAttachmentRequest(
@@ -730,7 +750,6 @@ class ConfluenceSession:
730
750
  payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
731
751
  return _json_to_object(ConfluencePage, payload)
732
752
 
733
- @functools.cache
734
753
  def get_page_properties(self, page_id: str) -> ConfluencePageProperties:
735
754
  """
736
755
  Retrieves Confluence wiki page details.
@@ -784,14 +803,8 @@ class ConfluenceSession:
784
803
  id=page_id,
785
804
  status=ConfluenceStatus.CURRENT,
786
805
  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
- ),
806
+ body=ConfluencePageBody(storage=ConfluencePageStorage(representation=ConfluenceRepresentation.STORAGE, value=new_content)),
807
+ version=ConfluenceContentVersion(number=page.version.number + 1, minorEdit=True),
795
808
  )
796
809
  LOGGER.info("Updating page: %s", page_id)
797
810
  self._save(ConfluenceVersion.VERSION_2, path, object_to_json(request))
@@ -973,7 +986,7 @@ class ConfluenceSession:
973
986
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
974
987
  response.raise_for_status()
975
988
 
976
- def update_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
989
+ def update_labels(self, page_id: str, labels: list[ConfluenceLabel], *, keep_existing: bool = False) -> None:
977
990
  """
978
991
  Assigns the specified labels to a Confluence page. Existing labels are removed.
979
992
 
@@ -982,10 +995,7 @@ class ConfluenceSession:
982
995
  """
983
996
 
984
997
  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
- )
998
+ old_labels = set(ConfluenceLabel(name=label.name, prefix=label.prefix) for label in self.get_labels(page_id))
989
999
 
990
1000
  add_labels = list(new_labels - old_labels)
991
1001
  remove_labels = list(old_labels - new_labels)
@@ -993,6 +1003,123 @@ class ConfluenceSession:
993
1003
  if add_labels:
994
1004
  add_labels.sort()
995
1005
  self.add_labels(page_id, add_labels)
996
- if remove_labels:
1006
+ if not keep_existing and remove_labels:
997
1007
  remove_labels.sort()
998
1008
  self.remove_labels(page_id, remove_labels)
1009
+
1010
+ def get_content_properties_for_page(self, page_id: str) -> list[ConfluenceIdentifiedContentProperty]:
1011
+ """
1012
+ Retrieves content properties for a Confluence page.
1013
+
1014
+ :param page_id: The Confluence page ID.
1015
+ :returns: A list of content properties.
1016
+ """
1017
+
1018
+ path = f"/pages/{page_id}/properties"
1019
+ results = self._fetch(path)
1020
+ return _json_to_object(list[ConfluenceIdentifiedContentProperty], results)
1021
+
1022
+ def add_content_property_to_page(self, page_id: str, property: ConfluenceContentProperty) -> ConfluenceIdentifiedContentProperty:
1023
+ """
1024
+ Adds a new content property to a Confluence page.
1025
+
1026
+ :param page_id: The Confluence page ID.
1027
+ :param property: Content property to add.
1028
+ """
1029
+
1030
+ 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())
1044
+
1045
+ def remove_content_property_from_page(self, page_id: str, property_id: str) -> None:
1046
+ """
1047
+ Removes a content property from a Confluence page.
1048
+
1049
+ :param page_id: The Confluence page ID.
1050
+ :param property_id: Property ID, which uniquely identifies the property.
1051
+ """
1052
+
1053
+ path = f"/pages/{page_id}/properties/{property_id}"
1054
+ url = self._build_url(ConfluenceVersion.VERSION_2, path)
1055
+ response = self.session.delete(url)
1056
+ response.raise_for_status()
1057
+
1058
+ def update_content_property_for_page(
1059
+ self, page_id: str, property_id: str, version: int, property: ConfluenceContentProperty
1060
+ ) -> ConfluenceIdentifiedContentProperty:
1061
+ """
1062
+ Updates an existing content property associated with a Confluence page.
1063
+
1064
+ :param page_id: The Confluence page ID.
1065
+ :param property_id: Property ID, which uniquely identifies the property.
1066
+ :param version: Version number to assign.
1067
+ :param property: Content property data to assign.
1068
+ :returns: Updated content property data.
1069
+ """
1070
+
1071
+ 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
+ )
1083
+ ),
1084
+ headers={"Content-Type": "application/json"},
1085
+ )
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
+
1091
+ def update_content_properties_for_page(self, page_id: str, properties: list[ConfluenceContentProperty], *, keep_existing: bool = False) -> None:
1092
+ """
1093
+ Updates content properties associated with a Confluence page.
1094
+
1095
+ :param page_id: The Confluence page ID.
1096
+ :param properties: A list of content property data to update.
1097
+ :param keep_existing: Whether to keep content property data whose key is not included in the list of properties passed as an argument.
1098
+ """
1099
+
1100
+ old_mapping = {p.key: p for p in self.get_content_properties_for_page(page_id)}
1101
+ new_mapping = {p.key: p for p in properties}
1102
+
1103
+ new_props = set(p.key for p in properties)
1104
+ old_props = set(old_mapping.keys())
1105
+
1106
+ add_props = list(new_props - old_props)
1107
+ remove_props = list(old_props - new_props)
1108
+ update_props = list(old_props & new_props)
1109
+
1110
+ if add_props:
1111
+ add_props.sort()
1112
+ for key in add_props:
1113
+ self.add_content_property_to_page(page_id, new_mapping[key])
1114
+ if not keep_existing and remove_props:
1115
+ remove_props.sort()
1116
+ for key in remove_props:
1117
+ self.remove_content_property_from_page(page_id, old_mapping[key].id)
1118
+ if update_props:
1119
+ update_props.sort()
1120
+ for key in update_props:
1121
+ old_prop = old_mapping[key]
1122
+ new_prop = new_mapping[key]
1123
+ if old_prop.value == new_prop.value:
1124
+ continue
1125
+ self.update_content_property_for_page(page_id, old_prop.id, old_prop.version.number + 1, new_prop)