markdown-to-confluence 0.3.5__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.
md2conf/api.py CHANGED
@@ -6,42 +6,56 @@ 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
- import functools
11
11
  import io
12
- import json
13
12
  import logging
14
13
  import mimetypes
15
14
  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, Union
18
+ from typing import Any, Optional, TypeVar
20
19
  from urllib.parse import urlencode, urlparse, urlunparse
21
20
 
22
21
  import requests
22
+ from strong_typing.core import JsonType
23
+ from strong_typing.serialization import DeserializerOptions, json_dump_string, json_to_object, object_to_json
23
24
 
24
25
  from .converter import ParseError, sanitize_confluence
25
26
  from .metadata import ConfluenceSiteMetadata
26
- from .properties import (
27
- ArgumentError,
28
- ConfluenceConnectionProperties,
29
- ConfluenceError,
30
- PageError,
31
- )
32
-
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
- ]
27
+ from .properties import ArgumentError, ConfluenceConnectionProperties, ConfluenceError, PageError
43
28
 
29
+ T = TypeVar("T")
44
30
 
31
+
32
+ def _json_to_object(
33
+ typ: type[T],
34
+ data: JsonType,
35
+ ) -> T:
36
+ return json_to_object(typ, data, options=DeserializerOptions(skip_unassigned=True))
37
+
38
+
39
+ def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
40
+ "Builds a URL with scheme, host, port, path and query string parameters."
41
+
42
+ scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
43
+
44
+ if params:
45
+ raise ValueError("expected: url with no parameters")
46
+ if query_str:
47
+ raise ValueError("expected: url with no query string")
48
+ if fragment:
49
+ raise ValueError("expected: url with no fragment")
50
+
51
+ url_parts = (scheme, netloc, path, None, urlencode(query) if query else None, None)
52
+ return urlunparse(url_parts)
53
+
54
+
55
+ LOGGER = logging.getLogger(__name__)
56
+
57
+
58
+ @enum.unique
45
59
  class ConfluenceVersion(enum.Enum):
46
60
  """
47
61
  Confluence REST API version an HTTP request corresponds to.
@@ -57,6 +71,7 @@ class ConfluenceVersion(enum.Enum):
57
71
  VERSION_2 = "api/v2"
58
72
 
59
73
 
74
+ @enum.unique
60
75
  class ConfluencePageParentContentType(enum.Enum):
61
76
  """
62
77
  Content types that can be a parent to a Confluence page.
@@ -69,23 +84,44 @@ class ConfluencePageParentContentType(enum.Enum):
69
84
  FOLDER = "folder"
70
85
 
71
86
 
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."
87
+ @enum.unique
88
+ class ConfluenceRepresentation(enum.Enum):
89
+ STORAGE = "storage"
90
+ ATLAS = "atlas_doc_format"
91
+ WIKI = "wiki"
74
92
 
75
- scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
76
93
 
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")
94
+ @enum.unique
95
+ class ConfluenceStatus(enum.Enum):
96
+ CURRENT = "current"
97
+ DRAFT = "draft"
98
+ ARCHIVED = "archived"
83
99
 
84
- url_parts = (scheme, netloc, path, None, urlencode(query) if query else None, None)
85
- return urlunparse(url_parts)
86
100
 
101
+ @enum.unique
102
+ class ConfluenceLegacyType(enum.Enum):
103
+ ATTACHMENT = "attachment"
87
104
 
88
- LOGGER = logging.getLogger(__name__)
105
+
106
+ @dataclass(frozen=True)
107
+ class ConfluenceLinks:
108
+ next: str
109
+ base: str
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class ConfluenceResultSet:
114
+ results: list[JsonType]
115
+ _links: ConfluenceLinks
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class ConfluenceContentVersion:
120
+ number: int
121
+ minorEdit: bool = False
122
+ createdAt: Optional[datetime.datetime] = None
123
+ message: Optional[str] = None
124
+ authorId: Optional[str] = None
89
125
 
90
126
 
91
127
  @dataclass(frozen=True)
@@ -94,15 +130,33 @@ class ConfluenceAttachment:
94
130
  Holds data for an object uploaded to Confluence as a page attachment.
95
131
 
96
132
  :param id: Unique ID for the attachment.
97
- :param media_type: MIME type for the attachment.
98
- :param file_size: Size in bytes.
133
+ :param status: Attachment status.
134
+ :param title: Attachment title.
135
+ :param createdAt: Date and time when the attachment was created.
136
+ :param pageId: The Confluence page that the attachment is coupled with.
137
+ :param mediaType: MIME type for the attachment.
138
+ :param mediaTypeDescription: Media type description for the attachment.
99
139
  :param comment: Description for the attachment.
140
+ :param fileId: File ID of the attachment, distinct from the attachment ID.
141
+ :param fileSize: Size in bytes.
142
+ :param webuiLink: WebUI link of the attachment.
143
+ :param downloadLink: Download link of the attachment.
144
+ :param version: Version information for the attachment.
100
145
  """
101
146
 
102
147
  id: str
103
- media_type: str
104
- file_size: int
105
- comment: str
148
+ status: ConfluenceStatus
149
+ title: Optional[str]
150
+ createdAt: datetime.datetime
151
+ pageId: str
152
+ mediaType: str
153
+ mediaTypeDescription: str
154
+ comment: Optional[str]
155
+ fileId: str
156
+ fileSize: int
157
+ webuiLink: str
158
+ downloadLink: str
159
+ version: ConfluenceContentVersion
106
160
 
107
161
 
108
162
  @dataclass(frozen=True)
@@ -111,19 +165,55 @@ class ConfluencePageProperties:
111
165
  Holds Confluence page properties used for page synchronization.
112
166
 
113
167
  :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.
168
+ :param status: Page status.
117
169
  :param title: Page title.
170
+ :param spaceId: Confluence space ID.
171
+ :param parentId: Confluence page ID of the immediate parent.
172
+ :param parentType: Identifies the content type of the parent.
173
+ :param position: Position of child page within the given parent page tree.
174
+ :param authorId: The account ID of the user who created this page originally.
175
+ :param ownerId: The account ID of the user who owns this page.
176
+ :param lastOwnerId: The account ID of the user who owned this page previously, or `None` if there is no previous owner.
177
+ :param createdAt: Date and time when the page was created.
118
178
  :param version: Page version. Incremented when the page is updated.
119
179
  """
120
180
 
121
181
  id: str
122
- space_id: str
123
- parent_id: str
124
- parent_type: Optional[ConfluencePageParentContentType]
182
+ status: ConfluenceStatus
125
183
  title: str
126
- version: int
184
+ spaceId: str
185
+ parentId: Optional[str]
186
+ parentType: Optional[ConfluencePageParentContentType]
187
+ position: Optional[int]
188
+ authorId: str
189
+ ownerId: str
190
+ lastOwnerId: Optional[str]
191
+ createdAt: datetime.datetime
192
+ version: ConfluenceContentVersion
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class ConfluencePageStorage:
197
+ """
198
+ Holds Confluence page content.
199
+
200
+ :param representation: Type of content representation used (e.g. Confluence Storage Format).
201
+ :param value: Body of the content, in the format found in the representation field.
202
+ """
203
+
204
+ representation: ConfluenceRepresentation
205
+ value: str
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class ConfluencePageBody:
210
+ """
211
+ Holds Confluence page content.
212
+
213
+ :param storage: Encapsulates content with meta-information about its representation.
214
+ """
215
+
216
+ storage: ConfluencePageStorage
127
217
 
128
218
 
129
219
  @dataclass(frozen=True)
@@ -131,27 +221,102 @@ class ConfluencePage(ConfluencePageProperties):
131
221
  """
132
222
  Holds Confluence page data used for page synchronization.
133
223
 
134
- :param content: Page content in Confluence Storage Format.
224
+ :param body: Page content.
135
225
  """
136
226
 
137
- content: str
227
+ body: ConfluencePageBody
138
228
 
229
+ @property
230
+ def content(self) -> str:
231
+ return self.body.storage.value
139
232
 
140
- @dataclass(frozen=True)
233
+
234
+ @dataclass(frozen=True, eq=True, order=True)
141
235
  class ConfluenceLabel:
142
236
  """
143
237
  Holds information about a single label.
144
238
 
145
- :param id: ID of the label.
146
239
  :param name: Name of the label.
147
240
  :param prefix: Prefix of the label.
148
241
  """
149
242
 
150
- id: str
151
243
  name: str
152
244
  prefix: str
153
245
 
154
246
 
247
+ @dataclass(frozen=True, eq=True, order=True)
248
+ class ConfluenceIdentifiedLabel(ConfluenceLabel):
249
+ """
250
+ Holds information about a single label.
251
+
252
+ :param id: ID of the label.
253
+ """
254
+
255
+ id: str
256
+
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
+
293
+ @dataclass(frozen=True)
294
+ class ConfluenceCreatePageRequest:
295
+ spaceId: str
296
+ status: Optional[ConfluenceStatus]
297
+ title: Optional[str]
298
+ parentId: Optional[str]
299
+ body: ConfluencePageBody
300
+
301
+
302
+ @dataclass(frozen=True)
303
+ class ConfluenceUpdatePageRequest:
304
+ id: str
305
+ status: ConfluenceStatus
306
+ title: str
307
+ body: ConfluencePageBody
308
+ version: ConfluenceContentVersion
309
+
310
+
311
+ @dataclass(frozen=True)
312
+ class ConfluenceUpdateAttachmentRequest:
313
+ id: str
314
+ type: ConfluenceLegacyType
315
+ status: ConfluenceStatus
316
+ title: str
317
+ version: ConfluenceContentVersion
318
+
319
+
155
320
  class ConfluenceAPI:
156
321
  """
157
322
  Represents an active connection to a Confluence server.
@@ -160,9 +325,7 @@ class ConfluenceAPI:
160
325
  properties: ConfluenceConnectionProperties
161
326
  session: Optional["ConfluenceSession"] = None
162
327
 
163
- def __init__(
164
- self, properties: Optional[ConfluenceConnectionProperties] = None
165
- ) -> None:
328
+ def __init__(self, properties: Optional[ConfluenceConnectionProperties] = None) -> None:
166
329
  self.properties = properties or ConfluenceConnectionProperties()
167
330
 
168
331
  def __enter__(self) -> "ConfluenceSession":
@@ -170,18 +333,17 @@ class ConfluenceAPI:
170
333
  if self.properties.user_name:
171
334
  session.auth = (self.properties.user_name, self.properties.api_key)
172
335
  else:
173
- session.headers.update(
174
- {"Authorization": f"Bearer {self.properties.api_key}"}
175
- )
336
+ session.headers.update({"Authorization": f"Bearer {self.properties.api_key}"})
176
337
 
177
338
  if self.properties.headers:
178
339
  session.headers.update(self.properties.headers)
179
340
 
180
341
  self.session = ConfluenceSession(
181
342
  session,
182
- self.properties.domain,
183
- self.properties.base_path,
184
- self.properties.space_key,
343
+ api_url=self.properties.api_url,
344
+ domain=self.properties.domain,
345
+ base_path=self.properties.base_path,
346
+ space_key=self.properties.space_key,
185
347
  )
186
348
  return self.session
187
349
 
@@ -202,6 +364,7 @@ class ConfluenceSession:
202
364
  """
203
365
 
204
366
  session: requests.Session
367
+ api_url: str
205
368
  site: ConfluenceSiteMetadata
206
369
 
207
370
  _space_id_to_key: dict[str, str]
@@ -210,16 +373,36 @@ class ConfluenceSession:
210
373
  def __init__(
211
374
  self,
212
375
  session: requests.Session,
213
- domain: str,
214
- base_path: str,
215
- space_key: Optional[str] = None,
376
+ *,
377
+ api_url: Optional[str],
378
+ domain: Optional[str],
379
+ base_path: Optional[str],
380
+ space_key: Optional[str],
216
381
  ) -> None:
217
382
  self.session = session
218
- self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
219
-
220
383
  self._space_id_to_key = {}
221
384
  self._space_key_to_id = {}
222
385
 
386
+ if api_url:
387
+ self.api_url = api_url
388
+
389
+ if not domain or not base_path:
390
+ payload = self._invoke(ConfluenceVersion.VERSION_2, "/spaces", {"limit": "1"})
391
+ data = json_to_object(ConfluenceResultSet, payload)
392
+ base_url = data._links.base
393
+
394
+ _, domain, base_path, _, _, _ = urlparse(base_url)
395
+ if not base_path.endswith("/"):
396
+ base_path = f"{base_path}/"
397
+
398
+ if not domain:
399
+ raise ArgumentError("Confluence domain not specified and cannot be inferred")
400
+ if not base_path:
401
+ raise ArgumentError("Confluence base path not specified and cannot be inferred")
402
+ self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
403
+ if not api_url:
404
+ self.api_url = f"https://{self.site.domain}{self.site.base_path}"
405
+
223
406
  def close(self) -> None:
224
407
  self.session.close()
225
408
  self.session = requests.Session()
@@ -239,9 +422,7 @@ class ConfluenceSession:
239
422
  :returns: A full URL.
240
423
  """
241
424
 
242
- base_url = (
243
- f"https://{self.site.domain}{self.site.base_path}{version.value}{path}"
244
- )
425
+ base_url = f"{self.api_url}{version.value}{path}"
245
426
  return build_url(base_url, query)
246
427
 
247
428
  def _invoke(
@@ -253,21 +434,19 @@ class ConfluenceSession:
253
434
  "Executes an HTTP request via Confluence API."
254
435
 
255
436
  url = self._build_url(version, path, query)
256
- response = self.session.get(url)
437
+ response = self.session.get(url, headers={"Accept": "application/json"})
257
438
  if response.text:
258
439
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
259
440
  response.raise_for_status()
260
- return response.json()
441
+ return typing.cast(JsonType, response.json())
261
442
 
262
- def _fetch(
263
- self, path: str, query: Optional[dict[str, str]] = None
264
- ) -> list[JsonType]:
443
+ def _fetch(self, path: str, query: Optional[dict[str, str]] = None) -> list[JsonType]:
265
444
  "Retrieves all results of a REST API v2 paginated result-set."
266
445
 
267
446
  items: list[JsonType] = []
268
447
  url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
269
448
  while True:
270
- response = self.session.get(url)
449
+ response = self.session.get(url, headers={"Accept": "application/json"})
271
450
  response.raise_for_status()
272
451
 
273
452
  payload = typing.cast(dict[str, JsonType], response.json())
@@ -289,7 +468,7 @@ class ConfluenceSession:
289
468
  url = self._build_url(version, path)
290
469
  response = self.session.put(
291
470
  url,
292
- data=json.dumps(data),
471
+ data=json_dump_string(data),
293
472
  headers={"Content-Type": "application/json"},
294
473
  )
295
474
  if response.text:
@@ -306,8 +485,8 @@ class ConfluenceSession:
306
485
  "/spaces",
307
486
  {"ids": id, "status": "current"},
308
487
  )
309
- payload = typing.cast(dict[str, JsonType], payload)
310
- results = typing.cast(list[JsonType], payload["results"])
488
+ data = typing.cast(dict[str, JsonType], payload)
489
+ results = typing.cast(list[JsonType], data["results"])
311
490
  if len(results) != 1:
312
491
  raise ConfluenceError(f"unique space not found with id: {id}")
313
492
 
@@ -328,8 +507,8 @@ class ConfluenceSession:
328
507
  "/spaces",
329
508
  {"keys": key, "status": "current"},
330
509
  )
331
- payload = typing.cast(dict[str, JsonType], payload)
332
- results = typing.cast(list[JsonType], payload["results"])
510
+ data = typing.cast(dict[str, JsonType], payload)
511
+ results = typing.cast(list[JsonType], data["results"])
333
512
  if len(results) != 1:
334
513
  raise ConfluenceError(f"unique space not found with key: {key}")
335
514
 
@@ -340,9 +519,7 @@ class ConfluenceSession:
340
519
 
341
520
  return id
342
521
 
343
- def get_space_id(
344
- self, *, space_id: Optional[str] = None, space_key: Optional[str] = None
345
- ) -> Optional[str]:
522
+ def get_space_id(self, *, space_id: Optional[str] = None, space_key: Optional[str] = None) -> Optional[str]:
346
523
  """
347
524
  Coalesces a space ID or space key into a space ID, accounting for site default.
348
525
 
@@ -363,29 +540,21 @@ class ConfluenceSession:
363
540
  # space ID and key are unset, and no default space is configured
364
541
  return None
365
542
 
366
- def get_attachment_by_name(
367
- self, page_id: str, filename: str
368
- ) -> ConfluenceAttachment:
543
+ def get_attachment_by_name(self, page_id: str, filename: str) -> ConfluenceAttachment:
369
544
  """
370
545
  Retrieves a Confluence page attachment by an unprefixed file name.
371
546
  """
372
547
 
373
548
  path = f"/pages/{page_id}/attachments"
374
549
  query = {"filename": filename}
375
- data = typing.cast(
376
- dict[str, JsonType], self._invoke(ConfluenceVersion.VERSION_2, path, query)
377
- )
550
+ payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
551
+ data = typing.cast(dict[str, JsonType], payload)
378
552
 
379
553
  results = typing.cast(list[JsonType], data["results"])
380
554
  if len(results) != 1:
381
555
  raise ConfluenceError(f"no such attachment on page {page_id}: {filename}")
382
556
  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)
557
+ return _json_to_object(ConfluenceAttachment, result)
389
558
 
390
559
  def upload_attachment(
391
560
  self,
@@ -423,6 +592,9 @@ class ConfluenceSession:
423
592
  name = attachment_name
424
593
  content_type, _ = mimetypes.guess_type(name, strict=True)
425
594
 
595
+ if content_type is None:
596
+ content_type = "application/octet-stream"
597
+
426
598
  if attachment_path is not None and not attachment_path.is_file():
427
599
  raise PageError(f"file not found: {attachment_path}")
428
600
 
@@ -430,15 +602,15 @@ class ConfluenceSession:
430
602
  attachment = self.get_attachment_by_name(page_id, attachment_name)
431
603
 
432
604
  if attachment_path is not None:
433
- if not force and attachment.file_size == attachment_path.stat().st_size:
605
+ if not force and attachment.fileSize == attachment_path.stat().st_size:
434
606
  LOGGER.info("Up-to-date attachment: %s", attachment_name)
435
607
  return
436
608
  elif raw_data is not None:
437
- if not force and attachment.file_size == len(raw_data):
609
+ if not force and attachment.fileSize == len(raw_data):
438
610
  LOGGER.info("Up-to-date embedded image: %s", attachment_name)
439
611
  return
440
612
  else:
441
- raise NotImplementedError("never occurs")
613
+ raise NotImplementedError("parameter match not exhaustive")
442
614
 
443
615
  id = attachment.id.removeprefix("att")
444
616
  path = f"/content/{page_id}/child/attachment/{id}/data"
@@ -450,8 +622,13 @@ class ConfluenceSession:
450
622
 
451
623
  if attachment_path is not None:
452
624
  with open(attachment_path, "rb") as attachment_file:
453
- file_to_upload = {
454
- "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
+ ),
455
632
  "file": (
456
633
  attachment_name, # will truncate path component
457
634
  attachment_file,
@@ -462,8 +639,11 @@ class ConfluenceSession:
462
639
  LOGGER.info("Uploading attachment: %s", attachment_name)
463
640
  response = self.session.post(
464
641
  url,
465
- files=file_to_upload, # type: ignore
466
- headers={"X-Atlassian-Token": "no-check"},
642
+ files=file_to_upload,
643
+ headers={
644
+ "X-Atlassian-Token": "no-check",
645
+ "Accept": "application/json",
646
+ },
467
647
  )
468
648
  elif raw_data is not None:
469
649
  LOGGER.info("Uploading raw data: %s", attachment_name)
@@ -471,21 +651,29 @@ class ConfluenceSession:
471
651
  raw_file = io.BytesIO(raw_data)
472
652
  raw_file.name = attachment_name
473
653
  file_to_upload = {
474
- "comment": comment,
654
+ "comment": (
655
+ None,
656
+ comment,
657
+ "text/plain; charset=utf-8",
658
+ {},
659
+ ),
475
660
  "file": (
476
661
  attachment_name, # will truncate path component
477
- raw_file, # type: ignore
662
+ raw_file,
478
663
  content_type,
479
664
  {"Expires": "0"},
480
665
  ),
481
666
  }
482
667
  response = self.session.post(
483
668
  url,
484
- files=file_to_upload, # type: ignore
485
- headers={"X-Atlassian-Token": "no-check"},
669
+ files=file_to_upload,
670
+ headers={
671
+ "X-Atlassian-Token": "no-check",
672
+ "Accept": "application/json",
673
+ },
486
674
  )
487
675
  else:
488
- raise NotImplementedError("never occurs")
676
+ raise NotImplementedError("parameter match not exhaustive")
489
677
 
490
678
  response.raise_for_status()
491
679
  data = response.json()
@@ -501,29 +689,27 @@ class ConfluenceSession:
501
689
  # ensure path component is retained in attachment name
502
690
  self._update_attachment(page_id, attachment_id, version, attachment_name)
503
691
 
504
- def _update_attachment(
505
- self, page_id: str, attachment_id: str, version: int, attachment_title: str
506
- ) -> None:
692
+ def _update_attachment(self, page_id: str, attachment_id: str, version: int, attachment_title: str) -> None:
507
693
  id = attachment_id.removeprefix("att")
508
694
  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
- }
695
+ request = ConfluenceUpdateAttachmentRequest(
696
+ id=attachment_id,
697
+ type=ConfluenceLegacyType.ATTACHMENT,
698
+ status=ConfluenceStatus.CURRENT,
699
+ title=attachment_title,
700
+ version=ConfluenceContentVersion(number=version, minorEdit=True),
701
+ )
516
702
 
517
703
  LOGGER.info("Updating attachment: %s", attachment_id)
518
- self._save(ConfluenceVersion.VERSION_1, path, data)
704
+ self._save(ConfluenceVersion.VERSION_1, path, object_to_json(request))
519
705
 
520
- def get_page_id_by_title(
706
+ def get_page_properties_by_title(
521
707
  self,
522
708
  title: str,
523
709
  *,
524
710
  space_id: Optional[str] = None,
525
711
  space_key: Optional[str] = None,
526
- ) -> str:
712
+ ) -> ConfluencePageProperties:
527
713
  """
528
714
  Looks up a Confluence wiki page ID by title.
529
715
 
@@ -543,15 +729,13 @@ class ConfluenceSession:
543
729
  query["space-id"] = space_id
544
730
 
545
731
  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"])
732
+ data = typing.cast(dict[str, JsonType], payload)
733
+ results = typing.cast(list[JsonType], data["results"])
549
734
  if len(results) != 1:
550
735
  raise ConfluenceError(f"unique page not found with title: {title}")
551
736
 
552
- result = typing.cast(dict[str, JsonType], results[0])
553
- id = typing.cast(str, result["id"])
554
- return id
737
+ page = _json_to_object(ConfluencePageProperties, results[0])
738
+ return page
555
739
 
556
740
  def get_page(self, page_id: str) -> ConfluencePage:
557
741
  """
@@ -564,26 +748,8 @@ class ConfluenceSession:
564
748
  path = f"/pages/{page_id}"
565
749
  query = {"body-format": "storage"}
566
750
  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"])
751
+ return _json_to_object(ConfluencePage, payload)
571
752
 
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
- )
585
-
586
- @functools.cache
587
753
  def get_page_properties(self, page_id: str) -> ConfluencePageProperties:
588
754
  """
589
755
  Retrieves Confluence wiki page details.
@@ -594,21 +760,7 @@ class ConfluenceSession:
594
760
 
595
761
  path = f"/pages/{page_id}"
596
762
  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
- )
763
+ return _json_to_object(ConfluencePageProperties, payload)
612
764
 
613
765
  def get_page_version(self, page_id: str) -> int:
614
766
  """
@@ -618,11 +770,7 @@ class ConfluenceSession:
618
770
  :returns: Confluence page version.
619
771
  """
620
772
 
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"])
773
+ return self.get_page_properties(page_id).version.number
626
774
 
627
775
  def update_page(
628
776
  self,
@@ -651,16 +799,15 @@ class ConfluenceSession:
651
799
  LOGGER.warning(exc)
652
800
 
653
801
  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
-
802
+ request = ConfluenceUpdatePageRequest(
803
+ id=page_id,
804
+ status=ConfluenceStatus.CURRENT,
805
+ title=new_title,
806
+ body=ConfluencePageBody(storage=ConfluencePageStorage(representation=ConfluenceRepresentation.STORAGE, value=new_content)),
807
+ version=ConfluenceContentVersion(number=page.version.number + 1, minorEdit=True),
808
+ )
662
809
  LOGGER.info("Updating page: %s", page_id)
663
- self._save(ConfluenceVersion.VERSION_2, path, data)
810
+ self._save(ConfluenceVersion.VERSION_2, path, object_to_json(request))
664
811
 
665
812
  def create_page(
666
813
  self,
@@ -672,44 +819,35 @@ class ConfluenceSession:
672
819
  Creates a new page via Confluence API.
673
820
  """
674
821
 
822
+ LOGGER.info("Creating page: %s", title)
823
+
675
824
  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
825
 
685
- LOGGER.info("Creating page: %s", title)
826
+ path = "/pages/"
827
+ request = ConfluenceCreatePageRequest(
828
+ spaceId=parent_page.spaceId,
829
+ status=ConfluenceStatus.CURRENT,
830
+ title=title,
831
+ parentId=parent_id,
832
+ body=ConfluencePageBody(
833
+ storage=ConfluencePageStorage(
834
+ representation=ConfluenceRepresentation.STORAGE,
835
+ value=new_content,
836
+ )
837
+ ),
838
+ )
686
839
 
687
840
  url = self._build_url(ConfluenceVersion.VERSION_2, path)
688
841
  response = self.session.post(
689
842
  url,
690
- data=json.dumps(query),
691
- headers={"Content-Type": "application/json"},
843
+ data=json_dump_string(object_to_json(request)),
844
+ headers={
845
+ "Content-Type": "application/json",
846
+ "Accept": "application/json",
847
+ },
692
848
  )
693
849
  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
- )
850
+ return _json_to_object(ConfluencePage, response.json())
713
851
 
714
852
  def delete_page(self, page_id: str, *, purge: bool = False) -> None:
715
853
  """
@@ -761,16 +899,19 @@ class ConfluenceSession:
761
899
 
762
900
  url = self._build_url(ConfluenceVersion.VERSION_2, path)
763
901
  response = self.session.get(
764
- url, params=query, headers={"Content-Type": "application/json"}
902
+ url,
903
+ params=query,
904
+ headers={
905
+ "Content-Type": "application/json",
906
+ "Accept": "application/json",
907
+ },
765
908
  )
766
909
  response.raise_for_status()
767
-
768
910
  data = typing.cast(dict[str, JsonType], response.json())
769
- results = typing.cast(list[JsonType], data["results"])
911
+ results = _json_to_object(list[ConfluencePageProperties], data["results"])
770
912
 
771
913
  if len(results) == 1:
772
- result = typing.cast(dict[str, JsonType], results[0])
773
- return typing.cast(str, result["id"])
914
+ return results[0].id
774
915
  else:
775
916
  return None
776
917
 
@@ -783,7 +924,7 @@ class ConfluenceSession:
783
924
  """
784
925
 
785
926
  parent_page = self.get_page_properties(parent_id)
786
- page_id = self.page_exists(title, space_id=parent_page.space_id)
927
+ page_id = self.page_exists(title, space_id=parent_page.spaceId)
787
928
 
788
929
  if page_id is not None:
789
930
  LOGGER.debug("Retrieving existing page: %s", page_id)
@@ -792,7 +933,7 @@ class ConfluenceSession:
792
933
  LOGGER.debug("Creating new page with title: %s", title)
793
934
  return self.create_page(parent_id, title, "")
794
935
 
795
- def get_labels(self, page_id: str) -> list[ConfluenceLabel]:
936
+ def get_labels(self, page_id: str) -> list[ConfluenceIdentifiedLabel]:
796
937
  """
797
938
  Retrieves labels for a Confluence page.
798
939
 
@@ -800,13 +941,185 @@ class ConfluenceSession:
800
941
  :returns: A list of page labels.
801
942
  """
802
943
 
803
- items: list[ConfluenceLabel] = []
804
944
  path = f"/pages/{page_id}/labels"
805
945
  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
946
+ return _json_to_object(list[ConfluenceIdentifiedLabel], results)
947
+
948
+ def add_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
949
+ """
950
+ Adds labels to a Confluence page.
951
+
952
+ :param page_id: The Confluence page ID.
953
+ :param labels: A list of page labels to add.
954
+ """
955
+
956
+ path = f"/content/{page_id}/label"
957
+
958
+ url = self._build_url(ConfluenceVersion.VERSION_1, path)
959
+ response = self.session.post(
960
+ url,
961
+ data=json_dump_string(object_to_json(labels)),
962
+ headers={
963
+ "Content-Type": "application/json",
964
+ "Accept": "application/json",
965
+ },
966
+ )
967
+ if response.text:
968
+ LOGGER.debug("Received HTTP payload:\n%s", response.text)
969
+ response.raise_for_status()
970
+
971
+ def remove_labels(self, page_id: str, labels: list[ConfluenceLabel]) -> None:
972
+ """
973
+ Removes labels from a Confluence page.
974
+
975
+ :param page_id: The Confluence page ID.
976
+ :param labels: A list of page labels to remove.
977
+ """
978
+
979
+ path = f"/content/{page_id}/label"
980
+ for label in labels:
981
+ query = {"name": label.name}
982
+
983
+ url = self._build_url(ConfluenceVersion.VERSION_1, path, query)
984
+ response = self.session.delete(url)
985
+ if response.text:
986
+ LOGGER.debug("Received HTTP payload:\n%s", response.text)
987
+ response.raise_for_status()
988
+
989
+ def update_labels(self, page_id: str, labels: list[ConfluenceLabel], *, keep_existing: bool = False) -> None:
990
+ """
991
+ Assigns the specified labels to a Confluence page. Existing labels are removed.
992
+
993
+ :param page_id: The Confluence page ID.
994
+ :param labels: A list of page labels to assign.
995
+ """
996
+
997
+ new_labels = set(labels)
998
+ old_labels = set(ConfluenceLabel(name=label.name, prefix=label.prefix) for label in self.get_labels(page_id))
999
+
1000
+ add_labels = list(new_labels - old_labels)
1001
+ remove_labels = list(old_labels - new_labels)
1002
+
1003
+ if add_labels:
1004
+ add_labels.sort()
1005
+ self.add_labels(page_id, add_labels)
1006
+ if not keep_existing and remove_labels:
1007
+ remove_labels.sort()
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)