markdown-to-confluence 0.3.5__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,18 +37,36 @@ 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."
43
52
 
53
+ scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
44
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__)
67
+
68
+
69
+ @enum.unique
45
70
  class ConfluenceVersion(enum.Enum):
46
71
  """
47
72
  Confluence REST API version an HTTP request corresponds to.
@@ -57,6 +82,7 @@ class ConfluenceVersion(enum.Enum):
57
82
  VERSION_2 = "api/v2"
58
83
 
59
84
 
85
+ @enum.unique
60
86
  class ConfluencePageParentContentType(enum.Enum):
61
87
  """
62
88
  Content types that can be a parent to a Confluence page.
@@ -69,23 +95,43 @@ class ConfluencePageParentContentType(enum.Enum):
69
95
  FOLDER = "folder"
70
96
 
71
97
 
72
- def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
73
- "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"
74
103
 
75
- scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
76
104
 
77
- if params:
78
- raise ValueError("expected: url with no parameters")
79
- if query_str:
80
- raise ValueError("expected: url with no query string")
81
- if fragment:
82
- raise ValueError("expected: url with no fragment")
105
+ @enum.unique
106
+ class ConfluenceStatus(enum.Enum):
107
+ CURRENT = "current"
108
+ DRAFT = "draft"
83
109
 
84
- url_parts = (scheme, netloc, path, None, urlencode(query) if query else None, None)
85
- return urlunparse(url_parts)
86
110
 
111
+ @enum.unique
112
+ class ConfluenceLegacyType(enum.Enum):
113
+ ATTACHMENT = "attachment"
87
114
 
88
- LOGGER = logging.getLogger(__name__)
115
+
116
+ @dataclass(frozen=True)
117
+ class ConfluenceLinks:
118
+ next: str
119
+ base: str
120
+
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
89
135
 
90
136
 
91
137
  @dataclass(frozen=True)
@@ -94,15 +140,33 @@ class ConfluenceAttachment:
94
140
  Holds data for an object uploaded to Confluence as a page attachment.
95
141
 
96
142
  :param id: Unique ID for the attachment.
97
- :param media_type: MIME type for the attachment.
98
- :param file_size: Size in bytes.
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.
99
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.
100
155
  """
101
156
 
102
157
  id: str
103
- media_type: str
104
- file_size: int
105
- 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
106
170
 
107
171
 
108
172
  @dataclass(frozen=True)
@@ -111,19 +175,55 @@ class ConfluencePageProperties:
111
175
  Holds Confluence page properties used for page synchronization.
112
176
 
113
177
  :param id: Confluence page ID.
114
- :param space_id: Confluence space ID.
115
- :param parent_id: Confluence page ID of the immediate parent.
116
- :param parent_type: Identifies the content type of the parent.
178
+ :param status: Page status.
117
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.
118
188
  :param version: Page version. Incremented when the page is updated.
119
189
  """
120
190
 
121
191
  id: str
122
- space_id: str
123
- parent_id: str
124
- parent_type: Optional[ConfluencePageParentContentType]
192
+ status: ConfluenceStatus
125
193
  title: str
126
- 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
203
+
204
+
205
+ @dataclass(frozen=True)
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
127
227
 
128
228
 
129
229
  @dataclass(frozen=True)
@@ -131,27 +231,67 @@ class ConfluencePage(ConfluencePageProperties):
131
231
  """
132
232
  Holds Confluence page data used for page synchronization.
133
233
 
134
- :param content: Page content in Confluence Storage Format.
234
+ :param body: Page content.
135
235
  """
136
236
 
137
- content: str
237
+ body: ConfluencePageBody
138
238
 
239
+ @property
240
+ def content(self) -> str:
241
+ return self.body.storage.value
139
242
 
140
- @dataclass(frozen=True)
243
+
244
+ @dataclass(frozen=True, eq=True, order=True)
141
245
  class ConfluenceLabel:
142
246
  """
143
247
  Holds information about a single label.
144
248
 
145
- :param id: ID of the label.
146
249
  :param name: Name of the label.
147
250
  :param prefix: Prefix of the label.
148
251
  """
149
252
 
150
- id: str
151
253
  name: str
152
254
  prefix: str
153
255
 
154
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
293
+
294
+
155
295
  class ConfluenceAPI:
156
296
  """
157
297
  Represents an active connection to a Confluence server.
@@ -179,9 +319,10 @@ class ConfluenceAPI:
179
319
 
180
320
  self.session = ConfluenceSession(
181
321
  session,
182
- self.properties.domain,
183
- self.properties.base_path,
184
- 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,
185
326
  )
186
327
  return self.session
187
328
 
@@ -202,6 +343,7 @@ class ConfluenceSession:
202
343
  """
203
344
 
204
345
  session: requests.Session
346
+ api_url: str
205
347
  site: ConfluenceSiteMetadata
206
348
 
207
349
  _space_id_to_key: dict[str, str]
@@ -210,16 +352,42 @@ class ConfluenceSession:
210
352
  def __init__(
211
353
  self,
212
354
  session: requests.Session,
213
- domain: str,
214
- base_path: str,
215
- 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],
216
360
  ) -> None:
217
361
  self.session = session
218
- self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
219
-
220
362
  self._space_id_to_key = {}
221
363
  self._space_key_to_id = {}
222
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
+
223
391
  def close(self) -> None:
224
392
  self.session.close()
225
393
  self.session = requests.Session()
@@ -239,9 +407,7 @@ class ConfluenceSession:
239
407
  :returns: A full URL.
240
408
  """
241
409
 
242
- base_url = (
243
- f"https://{self.site.domain}{self.site.base_path}{version.value}{path}"
244
- )
410
+ base_url = f"{self.api_url}{version.value}{path}"
245
411
  return build_url(base_url, query)
246
412
 
247
413
  def _invoke(
@@ -253,11 +419,11 @@ class ConfluenceSession:
253
419
  "Executes an HTTP request via Confluence API."
254
420
 
255
421
  url = self._build_url(version, path, query)
256
- response = self.session.get(url)
422
+ response = self.session.get(url, headers={"Accept": "application/json"})
257
423
  if response.text:
258
424
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
259
425
  response.raise_for_status()
260
- return response.json()
426
+ return typing.cast(JsonType, response.json())
261
427
 
262
428
  def _fetch(
263
429
  self, path: str, query: Optional[dict[str, str]] = None
@@ -267,7 +433,7 @@ class ConfluenceSession:
267
433
  items: list[JsonType] = []
268
434
  url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
269
435
  while True:
270
- response = self.session.get(url)
436
+ response = self.session.get(url, headers={"Accept": "application/json"})
271
437
  response.raise_for_status()
272
438
 
273
439
  payload = typing.cast(dict[str, JsonType], response.json())
@@ -289,7 +455,7 @@ class ConfluenceSession:
289
455
  url = self._build_url(version, path)
290
456
  response = self.session.put(
291
457
  url,
292
- data=json.dumps(data),
458
+ data=json_dump_string(data),
293
459
  headers={"Content-Type": "application/json"},
294
460
  )
295
461
  if response.text:
@@ -306,8 +472,8 @@ class ConfluenceSession:
306
472
  "/spaces",
307
473
  {"ids": id, "status": "current"},
308
474
  )
309
- payload = typing.cast(dict[str, JsonType], payload)
310
- results = typing.cast(list[JsonType], payload["results"])
475
+ data = typing.cast(dict[str, JsonType], payload)
476
+ results = typing.cast(list[JsonType], data["results"])
311
477
  if len(results) != 1:
312
478
  raise ConfluenceError(f"unique space not found with id: {id}")
313
479
 
@@ -328,8 +494,8 @@ class ConfluenceSession:
328
494
  "/spaces",
329
495
  {"keys": key, "status": "current"},
330
496
  )
331
- payload = typing.cast(dict[str, JsonType], payload)
332
- results = typing.cast(list[JsonType], payload["results"])
497
+ data = typing.cast(dict[str, JsonType], payload)
498
+ results = typing.cast(list[JsonType], data["results"])
333
499
  if len(results) != 1:
334
500
  raise ConfluenceError(f"unique space not found with key: {key}")
335
501
 
@@ -372,20 +538,14 @@ class ConfluenceSession:
372
538
 
373
539
  path = f"/pages/{page_id}/attachments"
374
540
  query = {"filename": filename}
375
- data = typing.cast(
376
- dict[str, JsonType], self._invoke(ConfluenceVersion.VERSION_2, path, query)
377
- )
541
+ payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
542
+ data = typing.cast(dict[str, JsonType], payload)
378
543
 
379
544
  results = typing.cast(list[JsonType], data["results"])
380
545
  if len(results) != 1:
381
546
  raise ConfluenceError(f"no such attachment on page {page_id}: {filename}")
382
547
  result = typing.cast(dict[str, JsonType], results[0])
383
-
384
- id = typing.cast(str, result["id"])
385
- media_type = typing.cast(str, result["mediaType"])
386
- file_size = typing.cast(int, result["fileSize"])
387
- comment = typing.cast(str, result.get("comment", ""))
388
- return ConfluenceAttachment(id, media_type, file_size, comment)
548
+ return _json_to_object(ConfluenceAttachment, result)
389
549
 
390
550
  def upload_attachment(
391
551
  self,
@@ -430,15 +590,15 @@ class ConfluenceSession:
430
590
  attachment = self.get_attachment_by_name(page_id, attachment_name)
431
591
 
432
592
  if attachment_path is not None:
433
- if not force and attachment.file_size == attachment_path.stat().st_size:
593
+ if not force and attachment.fileSize == attachment_path.stat().st_size:
434
594
  LOGGER.info("Up-to-date attachment: %s", attachment_name)
435
595
  return
436
596
  elif raw_data is not None:
437
- if not force and attachment.file_size == len(raw_data):
597
+ if not force and attachment.fileSize == len(raw_data):
438
598
  LOGGER.info("Up-to-date embedded image: %s", attachment_name)
439
599
  return
440
600
  else:
441
- raise NotImplementedError("never occurs")
601
+ raise NotImplementedError("parameter match not exhaustive")
442
602
 
443
603
  id = attachment.id.removeprefix("att")
444
604
  path = f"/content/{page_id}/child/attachment/{id}/data"
@@ -462,8 +622,11 @@ class ConfluenceSession:
462
622
  LOGGER.info("Uploading attachment: %s", attachment_name)
463
623
  response = self.session.post(
464
624
  url,
465
- files=file_to_upload, # type: ignore
466
- 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
+ },
467
630
  )
468
631
  elif raw_data is not None:
469
632
  LOGGER.info("Uploading raw data: %s", attachment_name)
@@ -474,18 +637,21 @@ class ConfluenceSession:
474
637
  "comment": comment,
475
638
  "file": (
476
639
  attachment_name, # will truncate path component
477
- raw_file, # type: ignore
640
+ raw_file, # type: ignore[dict-item]
478
641
  content_type,
479
642
  {"Expires": "0"},
480
643
  ),
481
644
  }
482
645
  response = self.session.post(
483
646
  url,
484
- files=file_to_upload, # type: ignore
485
- 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
+ },
486
652
  )
487
653
  else:
488
- raise NotImplementedError("never occurs")
654
+ raise NotImplementedError("parameter match not exhaustive")
489
655
 
490
656
  response.raise_for_status()
491
657
  data = response.json()
@@ -506,24 +672,24 @@ class ConfluenceSession:
506
672
  ) -> None:
507
673
  id = attachment_id.removeprefix("att")
508
674
  path = f"/content/{page_id}/child/attachment/{id}"
509
- data: JsonType = {
510
- "id": attachment_id,
511
- "type": "attachment",
512
- "status": "current",
513
- "title": attachment_title,
514
- "version": {"minorEdit": True, "number": version},
515
- }
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
+ )
516
682
 
517
683
  LOGGER.info("Updating attachment: %s", attachment_id)
518
- self._save(ConfluenceVersion.VERSION_1, path, data)
684
+ self._save(ConfluenceVersion.VERSION_1, path, object_to_json(request))
519
685
 
520
- def get_page_id_by_title(
686
+ def get_page_properties_by_title(
521
687
  self,
522
688
  title: str,
523
689
  *,
524
690
  space_id: Optional[str] = None,
525
691
  space_key: Optional[str] = None,
526
- ) -> str:
692
+ ) -> ConfluencePageProperties:
527
693
  """
528
694
  Looks up a Confluence wiki page ID by title.
529
695
 
@@ -543,15 +709,13 @@ class ConfluenceSession:
543
709
  query["space-id"] = space_id
544
710
 
545
711
  payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
546
- payload = typing.cast(dict[str, JsonType], payload)
547
-
548
- results = typing.cast(list[JsonType], payload["results"])
712
+ data = typing.cast(dict[str, JsonType], payload)
713
+ results = typing.cast(list[JsonType], data["results"])
549
714
  if len(results) != 1:
550
715
  raise ConfluenceError(f"unique page not found with title: {title}")
551
716
 
552
- result = typing.cast(dict[str, JsonType], results[0])
553
- id = typing.cast(str, result["id"])
554
- return id
717
+ page = _json_to_object(ConfluencePageProperties, results[0])
718
+ return page
555
719
 
556
720
  def get_page(self, page_id: str) -> ConfluencePage:
557
721
  """
@@ -564,24 +728,7 @@ class ConfluenceSession:
564
728
  path = f"/pages/{page_id}"
565
729
  query = {"body-format": "storage"}
566
730
  payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
567
- data = typing.cast(dict[str, JsonType], payload)
568
- version = typing.cast(dict[str, JsonType], data["version"])
569
- body = typing.cast(dict[str, JsonType], data["body"])
570
- storage = typing.cast(dict[str, JsonType], body["storage"])
571
-
572
- return ConfluencePage(
573
- id=page_id,
574
- space_id=typing.cast(str, data["spaceId"]),
575
- parent_id=typing.cast(str, data["parentId"]),
576
- parent_type=(
577
- ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
578
- if data["parentType"] is not None
579
- else None
580
- ),
581
- title=typing.cast(str, data["title"]),
582
- version=typing.cast(int, version["number"]),
583
- content=typing.cast(str, storage["value"]),
584
- )
731
+ return _json_to_object(ConfluencePage, payload)
585
732
 
586
733
  @functools.cache
587
734
  def get_page_properties(self, page_id: str) -> ConfluencePageProperties:
@@ -594,21 +741,7 @@ class ConfluenceSession:
594
741
 
595
742
  path = f"/pages/{page_id}"
596
743
  payload = self._invoke(ConfluenceVersion.VERSION_2, path)
597
- data = typing.cast(dict[str, JsonType], payload)
598
- version = typing.cast(dict[str, JsonType], data["version"])
599
-
600
- return ConfluencePageProperties(
601
- id=page_id,
602
- space_id=typing.cast(str, data["spaceId"]),
603
- parent_id=typing.cast(str, data["parentId"]),
604
- parent_type=(
605
- ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
606
- if data["parentType"] is not None
607
- else None
608
- ),
609
- title=typing.cast(str, data["title"]),
610
- version=typing.cast(int, version["number"]),
611
- )
744
+ return _json_to_object(ConfluencePageProperties, payload)
612
745
 
613
746
  def get_page_version(self, page_id: str) -> int:
614
747
  """
@@ -618,11 +751,7 @@ class ConfluenceSession:
618
751
  :returns: Confluence page version.
619
752
  """
620
753
 
621
- path = f"/pages/{page_id}"
622
- payload = self._invoke(ConfluenceVersion.VERSION_2, path)
623
- data = typing.cast(dict[str, JsonType], payload)
624
- version = typing.cast(dict[str, JsonType], data["version"])
625
- return typing.cast(int, version["number"])
754
+ return self.get_page_properties(page_id).version.number
626
755
 
627
756
  def update_page(
628
757
  self,
@@ -651,16 +780,21 @@ class ConfluenceSession:
651
780
  LOGGER.warning(exc)
652
781
 
653
782
  path = f"/pages/{page_id}"
654
- data: JsonType = {
655
- "id": page_id,
656
- "status": "current",
657
- "title": new_title,
658
- "body": {"storage": {"value": new_content, "representation": "storage"}},
659
- "version": {"minorEdit": True, "number": page.version + 1},
660
- }
661
-
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
+ )
662
796
  LOGGER.info("Updating page: %s", page_id)
663
- self._save(ConfluenceVersion.VERSION_2, path, data)
797
+ self._save(ConfluenceVersion.VERSION_2, path, object_to_json(request))
664
798
 
665
799
  def create_page(
666
800
  self,
@@ -672,44 +806,35 @@ class ConfluenceSession:
672
806
  Creates a new page via Confluence API.
673
807
  """
674
808
 
809
+ LOGGER.info("Creating page: %s", title)
810
+
675
811
  parent_page = self.get_page_properties(parent_id)
676
- path = "/pages/"
677
- query = {
678
- "spaceId": parent_page.space_id,
679
- "status": "current",
680
- "title": title,
681
- "parentId": parent_id,
682
- "body": {"storage": {"value": new_content, "representation": "storage"}},
683
- }
684
812
 
685
- LOGGER.info("Creating page: %s", title)
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
+ )
686
826
 
687
827
  url = self._build_url(ConfluenceVersion.VERSION_2, path)
688
828
  response = self.session.post(
689
829
  url,
690
- data=json.dumps(query),
691
- 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
+ },
692
835
  )
693
836
  response.raise_for_status()
694
-
695
- data = typing.cast(dict[str, JsonType], response.json())
696
- version = typing.cast(dict[str, JsonType], data["version"])
697
- body = typing.cast(dict[str, JsonType], data["body"])
698
- storage = typing.cast(dict[str, JsonType], body["storage"])
699
-
700
- return ConfluencePage(
701
- id=typing.cast(str, data["id"]),
702
- space_id=typing.cast(str, data["spaceId"]),
703
- parent_id=typing.cast(str, data["parentId"]),
704
- parent_type=(
705
- ConfluencePageParentContentType(typing.cast(str, data["parentType"]))
706
- if data["parentType"] is not None
707
- else None
708
- ),
709
- title=typing.cast(str, data["title"]),
710
- version=typing.cast(int, version["number"]),
711
- content=typing.cast(str, storage["value"]),
712
- )
837
+ return _json_to_object(ConfluencePage, response.json())
713
838
 
714
839
  def delete_page(self, page_id: str, *, purge: bool = False) -> None:
715
840
  """
@@ -761,16 +886,19 @@ class ConfluenceSession:
761
886
 
762
887
  url = self._build_url(ConfluenceVersion.VERSION_2, path)
763
888
  response = self.session.get(
764
- 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
+ },
765
895
  )
766
896
  response.raise_for_status()
767
-
768
897
  data = typing.cast(dict[str, JsonType], response.json())
769
- results = typing.cast(list[JsonType], data["results"])
898
+ results = _json_to_object(list[ConfluencePageProperties], data["results"])
770
899
 
771
900
  if len(results) == 1:
772
- result = typing.cast(dict[str, JsonType], results[0])
773
- return typing.cast(str, result["id"])
901
+ return results[0].id
774
902
  else:
775
903
  return None
776
904
 
@@ -783,7 +911,7 @@ class ConfluenceSession:
783
911
  """
784
912
 
785
913
  parent_page = self.get_page_properties(parent_id)
786
- page_id = self.page_exists(title, space_id=parent_page.space_id)
914
+ page_id = self.page_exists(title, space_id=parent_page.spaceId)
787
915
 
788
916
  if page_id is not None:
789
917
  LOGGER.debug("Retrieving existing page: %s", page_id)
@@ -792,7 +920,7 @@ class ConfluenceSession:
792
920
  LOGGER.debug("Creating new page with title: %s", title)
793
921
  return self.create_page(parent_id, title, "")
794
922
 
795
- def get_labels(self, page_id: str) -> list[ConfluenceLabel]:
923
+ def get_labels(self, page_id: str) -> list[ConfluenceIdentifiedLabel]:
796
924
  """
797
925
  Retrieves labels for a Confluence page.
798
926
 
@@ -800,13 +928,71 @@ class ConfluenceSession:
800
928
  :returns: A list of page labels.
801
929
  """
802
930
 
803
- items: list[ConfluenceLabel] = []
804
931
  path = f"/pages/{page_id}/labels"
805
932
  results = self._fetch(path)
806
- for r in results:
807
- result = typing.cast(dict[str, JsonType], r)
808
- id = typing.cast(str, result["id"])
809
- name = typing.cast(str, result["name"])
810
- prefix = typing.cast(str, result["prefix"])
811
- items.append(ConfluenceLabel(id, name, prefix))
812
- return items
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)