markdown-to-confluence 0.3.4__py3-none-any.whl → 0.4.0__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
@@ -6,20 +6,27 @@ Copyright 2022-2025, Levente Hunyadi
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
8
8
 
9
+ import datetime
9
10
  import enum
10
11
  import functools
11
12
  import io
12
- import json
13
13
  import logging
14
14
  import mimetypes
15
15
  import typing
16
16
  from dataclasses import dataclass
17
17
  from pathlib import Path
18
18
  from types import TracebackType
19
- from typing import Optional, Union
19
+ from typing import Optional, TypeVar
20
20
  from urllib.parse import urlencode, urlparse, urlunparse
21
21
 
22
22
  import requests
23
+ 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
30
 
24
31
  from .converter import ParseError, sanitize_confluence
25
32
  from .metadata import ConfluenceSiteMetadata
@@ -30,26 +37,55 @@ from .properties import (
30
37
  PageError,
31
38
  )
32
39
 
33
- # a JSON type with possible `null` values
34
- JsonType = Union[
35
- None,
36
- bool,
37
- int,
38
- float,
39
- str,
40
- dict[str, "JsonType"],
41
- list["JsonType"],
42
- ]
40
+ T = TypeVar("T")
41
+
42
+
43
+ def _json_to_object(
44
+ typ: type[T],
45
+ data: JsonType,
46
+ ) -> T:
47
+ return json_to_object(typ, data, options=DeserializerOptions(skip_unassigned=True))
48
+
49
+
50
+ def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
51
+ "Builds a URL with scheme, host, port, path and query string parameters."
52
+
53
+ scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
54
+
55
+ if params:
56
+ raise ValueError("expected: url with no parameters")
57
+ if query_str:
58
+ raise ValueError("expected: url with no query string")
59
+ if fragment:
60
+ raise ValueError("expected: url with no fragment")
61
+
62
+ url_parts = (scheme, netloc, path, None, urlencode(query) if query else None, None)
63
+ return urlunparse(url_parts)
64
+
65
+
66
+ LOGGER = logging.getLogger(__name__)
43
67
 
44
68
 
69
+ @enum.unique
45
70
  class ConfluenceVersion(enum.Enum):
71
+ """
72
+ Confluence REST API version an HTTP request corresponds to.
73
+
74
+ For some operations, Confluence Cloud supports v2 endpoints exclusively. However, for other operations, only v1 endpoints are available via REST API.
75
+ Some versions of Confluence Server and Data Center, unfortunately, don't support v2 endpoints at all.
76
+
77
+ The principal use case for *md2conf* is Confluence Cloud. As such, *md2conf* uses v2 endpoints when available, and resorts to v1 endpoints only when
78
+ necessary.
79
+ """
80
+
46
81
  VERSION_1 = "rest/api"
47
82
  VERSION_2 = "api/v2"
48
83
 
49
84
 
85
+ @enum.unique
50
86
  class ConfluencePageParentContentType(enum.Enum):
51
87
  """
52
- Content types that can be a parent to a Confluence page
88
+ Content types that can be a parent to a Confluence page.
53
89
  """
54
90
 
55
91
  PAGE = "page"
@@ -59,49 +95,208 @@ class ConfluencePageParentContentType(enum.Enum):
59
95
  FOLDER = "folder"
60
96
 
61
97
 
62
- def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
63
- "Builds a URL with scheme, host, port, path and query string parameters."
98
+ @enum.unique
99
+ class ConfluenceRepresentation(enum.Enum):
100
+ STORAGE = "storage"
101
+ ATLAS = "atlas_doc_format"
102
+ WIKI = "wiki"
64
103
 
65
- scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
66
104
 
67
- if params:
68
- raise ValueError("expected: url with no parameters")
69
- if query_str:
70
- raise ValueError("expected: url with no query string")
71
- if fragment:
72
- raise ValueError("expected: url with no fragment")
105
+ @enum.unique
106
+ class ConfluenceStatus(enum.Enum):
107
+ CURRENT = "current"
108
+ DRAFT = "draft"
73
109
 
74
- url_parts = (scheme, netloc, path, None, urlencode(query) if query else None, None)
75
- return urlunparse(url_parts)
76
110
 
111
+ @enum.unique
112
+ class ConfluenceLegacyType(enum.Enum):
113
+ ATTACHMENT = "attachment"
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class ConfluenceLinks:
118
+ next: str
119
+ base: str
77
120
 
78
- LOGGER = logging.getLogger(__name__)
121
+
122
+ @dataclass(frozen=True)
123
+ class ConfluenceResultSet:
124
+ results: list[JsonType]
125
+ _links: ConfluenceLinks
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class ConfluenceContentVersion:
130
+ number: int
131
+ minorEdit: bool = False
132
+ createdAt: Optional[datetime.datetime] = None
133
+ message: Optional[str] = None
134
+ authorId: Optional[str] = None
79
135
 
80
136
 
81
137
  @dataclass(frozen=True)
82
138
  class ConfluenceAttachment:
139
+ """
140
+ Holds data for an object uploaded to Confluence as a page attachment.
141
+
142
+ :param id: Unique ID for the attachment.
143
+ :param status: Attachment status.
144
+ :param title: Attachment title.
145
+ :param createdAt: Date and time when the attachment was created.
146
+ :param pageId: The Confluence page that the attachment is coupled with.
147
+ :param mediaType: MIME type for the attachment.
148
+ :param mediaTypeDescription: Media type description for the attachment.
149
+ :param comment: Description for the attachment.
150
+ :param fileId: File ID of the attachment, distinct from the attachment ID.
151
+ :param fileSize: Size in bytes.
152
+ :param webuiLink: WebUI link of the attachment.
153
+ :param downloadLink: Download link of the attachment.
154
+ :param version: Version information for the attachment.
155
+ """
156
+
83
157
  id: str
84
- media_type: str
85
- file_size: int
86
- comment: str
158
+ status: ConfluenceStatus
159
+ title: Optional[str]
160
+ createdAt: datetime.datetime
161
+ pageId: str
162
+ mediaType: str
163
+ mediaTypeDescription: str
164
+ comment: Optional[str]
165
+ fileId: str
166
+ fileSize: int
167
+ webuiLink: str
168
+ downloadLink: str
169
+ version: ConfluenceContentVersion
87
170
 
88
171
 
89
172
  @dataclass(frozen=True)
90
- class ConfluencePageMetadata:
173
+ class ConfluencePageProperties:
174
+ """
175
+ Holds Confluence page properties used for page synchronization.
176
+
177
+ :param id: Confluence page ID.
178
+ :param status: Page status.
179
+ :param title: Page title.
180
+ :param spaceId: Confluence space ID.
181
+ :param parentId: Confluence page ID of the immediate parent.
182
+ :param parentType: Identifies the content type of the parent.
183
+ :param position: Position of child page within the given parent page tree.
184
+ :param authorId: The account ID of the user who created this page originally.
185
+ :param ownerId: The account ID of the user who owns this page.
186
+ :param lastOwnerId: The account ID of the user who owned this page previously, or `None` if there is no previous owner.
187
+ :param createdAt: Date and time when the page was created.
188
+ :param version: Page version. Incremented when the page is updated.
189
+ """
190
+
91
191
  id: str
92
- space_id: str
93
- parent_id: str
94
- parent_type: Optional[ConfluencePageParentContentType]
192
+ status: ConfluenceStatus
95
193
  title: str
96
- version: int
194
+ spaceId: str
195
+ parentId: Optional[str]
196
+ parentType: Optional[ConfluencePageParentContentType]
197
+ position: Optional[int]
198
+ authorId: str
199
+ ownerId: str
200
+ lastOwnerId: Optional[str]
201
+ createdAt: datetime.datetime
202
+ version: ConfluenceContentVersion
97
203
 
98
204
 
99
205
  @dataclass(frozen=True)
100
- class ConfluencePage(ConfluencePageMetadata):
101
- content: str
206
+ class ConfluencePageStorage:
207
+ """
208
+ Holds Confluence page content.
209
+
210
+ :param representation: Type of content representation used (e.g. Confluence Storage Format).
211
+ :param value: Body of the content, in the format found in the representation field.
212
+ """
213
+
214
+ representation: ConfluenceRepresentation
215
+ value: str
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class ConfluencePageBody:
220
+ """
221
+ Holds Confluence page content.
222
+
223
+ :param storage: Encapsulates content with meta-information about its representation.
224
+ """
225
+
226
+ storage: ConfluencePageStorage
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class ConfluencePage(ConfluencePageProperties):
231
+ """
232
+ Holds Confluence page data used for page synchronization.
233
+
234
+ :param body: Page content.
235
+ """
236
+
237
+ body: ConfluencePageBody
238
+
239
+ @property
240
+ def content(self) -> str:
241
+ return self.body.storage.value
242
+
243
+
244
+ @dataclass(frozen=True, eq=True, order=True)
245
+ class ConfluenceLabel:
246
+ """
247
+ Holds information about a single label.
248
+
249
+ :param name: Name of the label.
250
+ :param prefix: Prefix of the label.
251
+ """
252
+
253
+ name: str
254
+ prefix: str
255
+
256
+
257
+ @dataclass(frozen=True, eq=True, order=True)
258
+ class ConfluenceIdentifiedLabel(ConfluenceLabel):
259
+ """
260
+ Holds information about a single label.
261
+
262
+ :param id: ID of the label.
263
+ """
264
+
265
+ id: str
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class ConfluenceCreatePageRequest:
270
+ spaceId: str
271
+ status: Optional[ConfluenceStatus]
272
+ title: Optional[str]
273
+ parentId: Optional[str]
274
+ body: ConfluencePageBody
275
+
276
+
277
+ @dataclass(frozen=True)
278
+ class ConfluenceUpdatePageRequest:
279
+ id: str
280
+ status: ConfluenceStatus
281
+ title: str
282
+ body: ConfluencePageBody
283
+ version: ConfluenceContentVersion
284
+
285
+
286
+ @dataclass(frozen=True)
287
+ class ConfluenceUpdateAttachmentRequest:
288
+ id: str
289
+ type: ConfluenceLegacyType
290
+ status: ConfluenceStatus
291
+ title: str
292
+ version: ConfluenceContentVersion
102
293
 
103
294
 
104
295
  class ConfluenceAPI:
296
+ """
297
+ Represents an active connection to a Confluence server.
298
+ """
299
+
105
300
  properties: ConfluenceConnectionProperties
106
301
  session: Optional["ConfluenceSession"] = None
107
302
 
@@ -124,9 +319,10 @@ class ConfluenceAPI:
124
319
 
125
320
  self.session = ConfluenceSession(
126
321
  session,
127
- self.properties.domain,
128
- self.properties.base_path,
129
- self.properties.space_key,
322
+ api_url=self.properties.api_url,
323
+ domain=self.properties.domain,
324
+ base_path=self.properties.base_path,
325
+ space_key=self.properties.space_key,
130
326
  )
131
327
  return self.session
132
328
 
@@ -147,6 +343,7 @@ class ConfluenceSession:
147
343
  """
148
344
 
149
345
  session: requests.Session
346
+ api_url: str
150
347
  site: ConfluenceSiteMetadata
151
348
 
152
349
  _space_id_to_key: dict[str, str]
@@ -155,16 +352,42 @@ class ConfluenceSession:
155
352
  def __init__(
156
353
  self,
157
354
  session: requests.Session,
158
- domain: str,
159
- base_path: str,
160
- space_key: Optional[str] = None,
355
+ *,
356
+ api_url: Optional[str],
357
+ domain: Optional[str],
358
+ base_path: Optional[str],
359
+ space_key: Optional[str],
161
360
  ) -> None:
162
361
  self.session = session
163
- self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
164
-
165
362
  self._space_id_to_key = {}
166
363
  self._space_key_to_id = {}
167
364
 
365
+ if api_url:
366
+ self.api_url = api_url
367
+
368
+ 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)
373
+ base_url = data._links.base
374
+
375
+ _, domain, base_path, _, _, _ = urlparse(base_url)
376
+ if not base_path.endswith("/"):
377
+ base_path = f"{base_path}/"
378
+
379
+ if not domain:
380
+ raise ArgumentError(
381
+ "Confluence domain not specified and cannot be inferred"
382
+ )
383
+ if not base_path:
384
+ raise ArgumentError(
385
+ "Confluence base path not specified and cannot be inferred"
386
+ )
387
+ self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
388
+ if not api_url:
389
+ self.api_url = f"https://{self.site.domain}{self.site.base_path}"
390
+
168
391
  def close(self) -> None:
169
392
  self.session.close()
170
393
  self.session = requests.Session()
@@ -184,9 +407,7 @@ class ConfluenceSession:
184
407
  :returns: A full URL.
185
408
  """
186
409
 
187
- base_url = (
188
- f"https://{self.site.domain}{self.site.base_path}{version.value}{path}"
189
- )
410
+ base_url = f"{self.api_url}{version.value}{path}"
190
411
  return build_url(base_url, query)
191
412
 
192
413
  def _invoke(
@@ -195,20 +416,46 @@ class ConfluenceSession:
195
416
  path: str,
196
417
  query: Optional[dict[str, str]] = None,
197
418
  ) -> JsonType:
198
- "Execute an HTTP request via Confluence API."
419
+ "Executes an HTTP request via Confluence API."
199
420
 
200
421
  url = self._build_url(version, path, query)
201
- response = self.session.get(url)
422
+ response = self.session.get(url, headers={"Accept": "application/json"})
202
423
  if response.text:
203
424
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
204
425
  response.raise_for_status()
205
- return response.json()
426
+ return typing.cast(JsonType, response.json())
427
+
428
+ def _fetch(
429
+ self, path: str, query: Optional[dict[str, str]] = None
430
+ ) -> list[JsonType]:
431
+ "Retrieves all results of a REST API v2 paginated result-set."
432
+
433
+ items: list[JsonType] = []
434
+ url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
435
+ while True:
436
+ response = self.session.get(url, headers={"Accept": "application/json"})
437
+ response.raise_for_status()
438
+
439
+ payload = typing.cast(dict[str, JsonType], response.json())
440
+ results = typing.cast(list[JsonType], payload["results"])
441
+ items.extend(results)
442
+
443
+ links = typing.cast(dict[str, JsonType], payload.get("_links", {}))
444
+ link = typing.cast(str, links.get("next", ""))
445
+ if link:
446
+ url = f"https://{self.site.domain}{link}"
447
+ else:
448
+ break
449
+
450
+ return items
451
+
452
+ def _save(self, version: ConfluenceVersion, path: str, data: JsonType) -> None:
453
+ "Persists data via Confluence REST API."
206
454
 
207
- def _save(self, version: ConfluenceVersion, path: str, data: dict) -> None:
208
455
  url = self._build_url(version, path)
209
456
  response = self.session.put(
210
457
  url,
211
- data=json.dumps(data),
458
+ data=json_dump_string(data),
212
459
  headers={"Content-Type": "application/json"},
213
460
  )
214
461
  if response.text:
@@ -225,8 +472,8 @@ class ConfluenceSession:
225
472
  "/spaces",
226
473
  {"ids": id, "status": "current"},
227
474
  )
228
- payload = typing.cast(dict[str, JsonType], payload)
229
- results = typing.cast(list[JsonType], payload["results"])
475
+ data = typing.cast(dict[str, JsonType], payload)
476
+ results = typing.cast(list[JsonType], data["results"])
230
477
  if len(results) != 1:
231
478
  raise ConfluenceError(f"unique space not found with id: {id}")
232
479
 
@@ -247,8 +494,8 @@ class ConfluenceSession:
247
494
  "/spaces",
248
495
  {"keys": key, "status": "current"},
249
496
  )
250
- payload = typing.cast(dict[str, JsonType], payload)
251
- results = typing.cast(list[JsonType], payload["results"])
497
+ data = typing.cast(dict[str, JsonType], payload)
498
+ results = typing.cast(list[JsonType], data["results"])
252
499
  if len(results) != 1:
253
500
  raise ConfluenceError(f"unique space not found with key: {key}")
254
501
 
@@ -263,7 +510,7 @@ class ConfluenceSession:
263
510
  self, *, space_id: Optional[str] = None, space_key: Optional[str] = None
264
511
  ) -> Optional[str]:
265
512
  """
266
- Coalesce a space ID or space key into a space ID, accounting for site default.
513
+ Coalesces a space ID or space key into a space ID, accounting for site default.
267
514
 
268
515
  :param space_id: A Confluence space ID.
269
516
  :param space_key: A Confluence space key.
@@ -285,22 +532,20 @@ class ConfluenceSession:
285
532
  def get_attachment_by_name(
286
533
  self, page_id: str, filename: str
287
534
  ) -> ConfluenceAttachment:
535
+ """
536
+ Retrieves a Confluence page attachment by an unprefixed file name.
537
+ """
538
+
288
539
  path = f"/pages/{page_id}/attachments"
289
540
  query = {"filename": filename}
290
- data = typing.cast(
291
- dict[str, JsonType], self._invoke(ConfluenceVersion.VERSION_2, path, query)
292
- )
541
+ payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
542
+ data = typing.cast(dict[str, JsonType], payload)
293
543
 
294
544
  results = typing.cast(list[JsonType], data["results"])
295
545
  if len(results) != 1:
296
546
  raise ConfluenceError(f"no such attachment on page {page_id}: {filename}")
297
547
  result = typing.cast(dict[str, JsonType], results[0])
298
-
299
- id = typing.cast(str, result["id"])
300
- media_type = typing.cast(str, result["mediaType"])
301
- file_size = typing.cast(int, result["fileSize"])
302
- comment = typing.cast(str, result.get("comment", ""))
303
- return ConfluenceAttachment(id, media_type, file_size, comment)
548
+ return _json_to_object(ConfluenceAttachment, result)
304
549
 
305
550
  def upload_attachment(
306
551
  self,
@@ -313,6 +558,18 @@ class ConfluenceSession:
313
558
  comment: Optional[str] = None,
314
559
  force: bool = False,
315
560
  ) -> None:
561
+ """
562
+ Uploads a new attachment to a Confluence page.
563
+
564
+ :param page_id: Confluence page ID.
565
+ :param attachment_name: Unprefixed name unique to the page.
566
+ :param attachment_path: Path to the file to upload as an attachment.
567
+ :param raw_data: Raw data to upload as an attachment.
568
+ :param content_type: Attachment MIME type.
569
+ :param comment: Attachment description.
570
+ :param force: Overwrite an existing attachment even if there seem to be no changes.
571
+ """
572
+
316
573
  if attachment_path is None and raw_data is None:
317
574
  raise ArgumentError("required: `attachment_path` or `raw_data`")
318
575
 
@@ -333,15 +590,15 @@ class ConfluenceSession:
333
590
  attachment = self.get_attachment_by_name(page_id, attachment_name)
334
591
 
335
592
  if attachment_path is not None:
336
- if not force and attachment.file_size == attachment_path.stat().st_size:
593
+ if not force and attachment.fileSize == attachment_path.stat().st_size:
337
594
  LOGGER.info("Up-to-date attachment: %s", attachment_name)
338
595
  return
339
596
  elif raw_data is not None:
340
- if not force and attachment.file_size == len(raw_data):
597
+ if not force and attachment.fileSize == len(raw_data):
341
598
  LOGGER.info("Up-to-date embedded image: %s", attachment_name)
342
599
  return
343
600
  else:
344
- raise NotImplementedError("never occurs")
601
+ raise NotImplementedError("parameter match not exhaustive")
345
602
 
346
603
  id = attachment.id.removeprefix("att")
347
604
  path = f"/content/{page_id}/child/attachment/{id}/data"
@@ -365,8 +622,11 @@ class ConfluenceSession:
365
622
  LOGGER.info("Uploading attachment: %s", attachment_name)
366
623
  response = self.session.post(
367
624
  url,
368
- files=file_to_upload, # type: ignore
369
- headers={"X-Atlassian-Token": "no-check"},
625
+ files=file_to_upload, # type: ignore[arg-type]
626
+ headers={
627
+ "X-Atlassian-Token": "no-check",
628
+ "Accept": "application/json",
629
+ },
370
630
  )
371
631
  elif raw_data is not None:
372
632
  LOGGER.info("Uploading raw data: %s", attachment_name)
@@ -377,18 +637,21 @@ class ConfluenceSession:
377
637
  "comment": comment,
378
638
  "file": (
379
639
  attachment_name, # will truncate path component
380
- raw_file, # type: ignore
640
+ raw_file, # type: ignore[dict-item]
381
641
  content_type,
382
642
  {"Expires": "0"},
383
643
  ),
384
644
  }
385
645
  response = self.session.post(
386
646
  url,
387
- files=file_to_upload, # type: ignore
388
- headers={"X-Atlassian-Token": "no-check"},
647
+ files=file_to_upload, # type: ignore[arg-type]
648
+ headers={
649
+ "X-Atlassian-Token": "no-check",
650
+ "Accept": "application/json",
651
+ },
389
652
  )
390
653
  else:
391
- raise NotImplementedError("never occurs")
654
+ raise NotImplementedError("parameter match not exhaustive")
392
655
 
393
656
  response.raise_for_status()
394
657
  data = response.json()
@@ -409,29 +672,30 @@ class ConfluenceSession:
409
672
  ) -> None:
410
673
  id = attachment_id.removeprefix("att")
411
674
  path = f"/content/{page_id}/child/attachment/{id}"
412
- data = {
413
- "id": attachment_id,
414
- "type": "attachment",
415
- "status": "current",
416
- "title": attachment_title,
417
- "version": {"minorEdit": True, "number": version},
418
- }
675
+ request = ConfluenceUpdateAttachmentRequest(
676
+ id=attachment_id,
677
+ type=ConfluenceLegacyType.ATTACHMENT,
678
+ status=ConfluenceStatus.CURRENT,
679
+ title=attachment_title,
680
+ version=ConfluenceContentVersion(number=version, minorEdit=True),
681
+ )
419
682
 
420
683
  LOGGER.info("Updating attachment: %s", attachment_id)
421
- self._save(ConfluenceVersion.VERSION_1, path, data)
684
+ self._save(ConfluenceVersion.VERSION_1, path, object_to_json(request))
422
685
 
423
- def get_page_id_by_title(
686
+ def get_page_properties_by_title(
424
687
  self,
425
688
  title: str,
426
689
  *,
427
690
  space_id: Optional[str] = None,
428
691
  space_key: Optional[str] = None,
429
- ) -> str:
692
+ ) -> ConfluencePageProperties:
430
693
  """
431
- Look up a Confluence wiki page ID by title.
694
+ Looks up a Confluence wiki page ID by title.
432
695
 
433
696
  :param title: The page title.
434
- :param space_key: The Confluence space key (unless the default space is to be used).
697
+ :param space_id: The Confluence space ID (unless the default space is to be used). Exclusive with space key.
698
+ :param space_key: The Confluence space key (unless the default space is to be used). Exclusive with space ID.
435
699
  :returns: Confluence page ID.
436
700
  """
437
701
 
@@ -445,19 +709,17 @@ class ConfluenceSession:
445
709
  query["space-id"] = space_id
446
710
 
447
711
  payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
448
- payload = typing.cast(dict[str, JsonType], payload)
449
-
450
- results = typing.cast(list[JsonType], payload["results"])
712
+ data = typing.cast(dict[str, JsonType], payload)
713
+ results = typing.cast(list[JsonType], data["results"])
451
714
  if len(results) != 1:
452
715
  raise ConfluenceError(f"unique page not found with title: {title}")
453
716
 
454
- result = typing.cast(dict[str, JsonType], results[0])
455
- id = typing.cast(str, result["id"])
456
- return id
717
+ page = _json_to_object(ConfluencePageProperties, results[0])
718
+ return page
457
719
 
458
720
  def get_page(self, page_id: str) -> ConfluencePage:
459
721
  """
460
- Retrieve Confluence wiki page details and content.
722
+ Retrieves Confluence wiki page details and content.
461
723
 
462
724
  :param page_id: The Confluence page ID.
463
725
  :returns: Confluence page info and content.
@@ -466,29 +728,12 @@ class ConfluenceSession:
466
728
  path = f"/pages/{page_id}"
467
729
  query = {"body-format": "storage"}
468
730
  payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
469
- data = typing.cast(dict[str, JsonType], payload)
470
- version = typing.cast(dict[str, JsonType], data["version"])
471
- body = typing.cast(dict[str, JsonType], data["body"])
472
- storage = typing.cast(dict[str, JsonType], body["storage"])
473
-
474
- return ConfluencePage(
475
- id=page_id,
476
- space_id=typing.cast(str, data["spaceId"]),
477
- parent_id=typing.cast(str, data["parentId"]),
478
- parent_type=(
479
- ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
480
- if data["parentType"] is not None
481
- else None
482
- ),
483
- title=typing.cast(str, data["title"]),
484
- version=typing.cast(int, version["number"]),
485
- content=typing.cast(str, storage["value"]),
486
- )
731
+ return _json_to_object(ConfluencePage, payload)
487
732
 
488
733
  @functools.cache
489
- def get_page_metadata(self, page_id: str) -> ConfluencePageMetadata:
734
+ def get_page_properties(self, page_id: str) -> ConfluencePageProperties:
490
735
  """
491
- Retrieve Confluence wiki page details.
736
+ Retrieves Confluence wiki page details.
492
737
 
493
738
  :param page_id: The Confluence page ID.
494
739
  :returns: Confluence page info.
@@ -496,35 +741,17 @@ class ConfluenceSession:
496
741
 
497
742
  path = f"/pages/{page_id}"
498
743
  payload = self._invoke(ConfluenceVersion.VERSION_2, path)
499
- data = typing.cast(dict[str, JsonType], payload)
500
- version = typing.cast(dict[str, JsonType], data["version"])
501
-
502
- return ConfluencePageMetadata(
503
- id=page_id,
504
- space_id=typing.cast(str, data["spaceId"]),
505
- parent_id=typing.cast(str, data["parentId"]),
506
- parent_type=(
507
- ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
508
- if data["parentType"] is not None
509
- else None
510
- ),
511
- title=typing.cast(str, data["title"]),
512
- version=typing.cast(int, version["number"]),
513
- )
744
+ return _json_to_object(ConfluencePageProperties, payload)
514
745
 
515
746
  def get_page_version(self, page_id: str) -> int:
516
747
  """
517
- Retrieve a Confluence wiki page version.
748
+ Retrieves a Confluence wiki page version.
518
749
 
519
750
  :param page_id: The Confluence page ID.
520
751
  :returns: Confluence page version.
521
752
  """
522
753
 
523
- path = f"/pages/{page_id}"
524
- payload = self._invoke(ConfluenceVersion.VERSION_2, path)
525
- data = typing.cast(dict[str, JsonType], payload)
526
- version = typing.cast(dict[str, JsonType], data["version"])
527
- return typing.cast(int, version["number"])
754
+ return self.get_page_properties(page_id).version.number
528
755
 
529
756
  def update_page(
530
757
  self,
@@ -534,7 +761,7 @@ class ConfluenceSession:
534
761
  title: Optional[str] = None,
535
762
  ) -> None:
536
763
  """
537
- Update a page via the Confluence API.
764
+ Updates a page via the Confluence API.
538
765
 
539
766
  :param page_id: The Confluence page ID.
540
767
  :param new_content: Confluence Storage Format XHTML.
@@ -553,16 +780,21 @@ class ConfluenceSession:
553
780
  LOGGER.warning(exc)
554
781
 
555
782
  path = f"/pages/{page_id}"
556
- data = {
557
- "id": page_id,
558
- "status": "current",
559
- "title": new_title,
560
- "body": {"storage": {"value": new_content, "representation": "storage"}},
561
- "version": {"minorEdit": True, "number": page.version + 1},
562
- }
563
-
783
+ request = ConfluenceUpdatePageRequest(
784
+ id=page_id,
785
+ 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
+ ),
795
+ )
564
796
  LOGGER.info("Updating page: %s", page_id)
565
- self._save(ConfluenceVersion.VERSION_2, path, data)
797
+ self._save(ConfluenceVersion.VERSION_2, path, object_to_json(request))
566
798
 
567
799
  def create_page(
568
800
  self,
@@ -571,54 +803,45 @@ class ConfluenceSession:
571
803
  new_content: str,
572
804
  ) -> ConfluencePage:
573
805
  """
574
- Create a new page via Confluence API.
806
+ Creates a new page via Confluence API.
575
807
  """
576
808
 
577
- parent_page = self.get_page_metadata(parent_id)
578
- path = "/pages/"
579
- query = {
580
- "spaceId": parent_page.space_id,
581
- "status": "current",
582
- "title": title,
583
- "parentId": parent_id,
584
- "body": {"storage": {"value": new_content, "representation": "storage"}},
585
- }
586
-
587
809
  LOGGER.info("Creating page: %s", title)
588
810
 
811
+ parent_page = self.get_page_properties(parent_id)
812
+
813
+ path = "/pages/"
814
+ request = ConfluenceCreatePageRequest(
815
+ spaceId=parent_page.spaceId,
816
+ status=ConfluenceStatus.CURRENT,
817
+ title=title,
818
+ parentId=parent_id,
819
+ body=ConfluencePageBody(
820
+ storage=ConfluencePageStorage(
821
+ representation=ConfluenceRepresentation.STORAGE,
822
+ value=new_content,
823
+ )
824
+ ),
825
+ )
826
+
589
827
  url = self._build_url(ConfluenceVersion.VERSION_2, path)
590
828
  response = self.session.post(
591
829
  url,
592
- data=json.dumps(query),
593
- headers={"Content-Type": "application/json"},
830
+ data=json_dump_string(object_to_json(request)),
831
+ headers={
832
+ "Content-Type": "application/json",
833
+ "Accept": "application/json",
834
+ },
594
835
  )
595
836
  response.raise_for_status()
596
-
597
- data = typing.cast(dict[str, JsonType], response.json())
598
- version = typing.cast(dict[str, JsonType], data["version"])
599
- body = typing.cast(dict[str, JsonType], data["body"])
600
- storage = typing.cast(dict[str, JsonType], body["storage"])
601
-
602
- return ConfluencePage(
603
- id=typing.cast(str, data["id"]),
604
- space_id=typing.cast(str, data["spaceId"]),
605
- parent_id=typing.cast(str, data["parentId"]),
606
- parent_type=(
607
- ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
608
- if data["parentType"] is not None
609
- else None
610
- ),
611
- title=typing.cast(str, data["title"]),
612
- version=typing.cast(int, version["number"]),
613
- content=typing.cast(str, storage["value"]),
614
- )
837
+ return _json_to_object(ConfluencePage, response.json())
615
838
 
616
839
  def delete_page(self, page_id: str, *, purge: bool = False) -> None:
617
840
  """
618
- Delete a page via Confluence API.
841
+ Deletes a page via Confluence API.
619
842
 
620
843
  :param page_id: The Confluence page ID.
621
- :param purge: True to completely purge the page, False to move to trash only.
844
+ :param purge: `True` to completely purge the page, `False` to move to trash only.
622
845
  """
623
846
 
624
847
  path = f"/pages/{page_id}"
@@ -645,10 +868,12 @@ class ConfluenceSession:
645
868
  space_key: Optional[str] = None,
646
869
  ) -> Optional[str]:
647
870
  """
648
- Check if a Confluence page exists with the given title.
871
+ Checks if a Confluence page exists with the given title.
649
872
 
650
873
  :param title: Page title. Pages in the same Confluence space must have a unique title.
651
874
  :param space_key: Identifies the Confluence space.
875
+
876
+ :returns: Confluence page ID of a matching page (if found), or `None`.
652
877
  """
653
878
 
654
879
  space_id = self.get_space_id(space_id=space_id, space_key=space_key)
@@ -661,29 +886,32 @@ class ConfluenceSession:
661
886
 
662
887
  url = self._build_url(ConfluenceVersion.VERSION_2, path)
663
888
  response = self.session.get(
664
- url, params=query, headers={"Content-Type": "application/json"}
889
+ url,
890
+ params=query,
891
+ headers={
892
+ "Content-Type": "application/json",
893
+ "Accept": "application/json",
894
+ },
665
895
  )
666
896
  response.raise_for_status()
667
-
668
897
  data = typing.cast(dict[str, JsonType], response.json())
669
- results = typing.cast(list[JsonType], data["results"])
898
+ results = _json_to_object(list[ConfluencePageProperties], data["results"])
670
899
 
671
900
  if len(results) == 1:
672
- result = typing.cast(dict[str, JsonType], results[0])
673
- return typing.cast(str, result["id"])
901
+ return results[0].id
674
902
  else:
675
903
  return None
676
904
 
677
905
  def get_or_create_page(self, title: str, parent_id: str) -> ConfluencePage:
678
906
  """
679
- Find a page with the given title, or create a new page if no such page exists.
907
+ Finds a page with the given title, or creates a new page if no such page exists.
680
908
 
681
909
  :param title: Page title. Pages in the same Confluence space must have a unique title.
682
910
  :param parent_id: Identifies the parent page for a new child page.
683
911
  """
684
912
 
685
- parent_page = self.get_page_metadata(parent_id)
686
- page_id = self.page_exists(title, space_id=parent_page.space_id)
913
+ parent_page = self.get_page_properties(parent_id)
914
+ page_id = self.page_exists(title, space_id=parent_page.spaceId)
687
915
 
688
916
  if page_id is not None:
689
917
  LOGGER.debug("Retrieving existing page: %s", page_id)
@@ -691,3 +919,80 @@ class ConfluenceSession:
691
919
  else:
692
920
  LOGGER.debug("Creating new page with title: %s", title)
693
921
  return self.create_page(parent_id, title, "")
922
+
923
+ def get_labels(self, page_id: str) -> list[ConfluenceIdentifiedLabel]:
924
+ """
925
+ Retrieves labels for a Confluence page.
926
+
927
+ :param page_id: The Confluence page ID.
928
+ :returns: A list of page labels.
929
+ """
930
+
931
+ path = f"/pages/{page_id}/labels"
932
+ results = self._fetch(path)
933
+ return _json_to_object(list[ConfluenceIdentifiedLabel], results)
934
+
935
+ def add_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
936
+ """
937
+ Adds labels to a Confluence page.
938
+
939
+ :param page_id: The Confluence page ID.
940
+ :param labels: A list of page labels to add.
941
+ """
942
+
943
+ 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()
957
+
958
+ def remove_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
959
+ """
960
+ Removes labels from a Confluence page.
961
+
962
+ :param page_id: The Confluence page ID.
963
+ :param labels: A list of page labels to remove.
964
+ """
965
+
966
+ path = f"/content/{page_id}/label"
967
+ for label in labels:
968
+ query = {"name": label.name}
969
+
970
+ url = self._build_url(ConfluenceVersion.VERSION_1, path, query)
971
+ response = self.session.delete(url)
972
+ if response.text:
973
+ LOGGER.debug("Received HTTP payload:\n%s", response.text)
974
+ response.raise_for_status()
975
+
976
+ def update_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
977
+ """
978
+ Assigns the specified labels to a Confluence page. Existing labels are removed.
979
+
980
+ :param page_id: The Confluence page ID.
981
+ :param labels: A list of page labels to assign.
982
+ """
983
+
984
+ 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
+ )
989
+
990
+ add_labels = list(new_labels - old_labels)
991
+ remove_labels = list(old_labels - new_labels)
992
+
993
+ if add_labels:
994
+ add_labels.sort()
995
+ self.add_labels(page_id, add_labels)
996
+ if remove_labels:
997
+ remove_labels.sort()
998
+ self.remove_labels(page_id, remove_labels)