markdown-to-confluence 0.2.7__py3-none-any.whl → 0.3.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
@@ -1,28 +1,27 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2024, Levente Hunyadi
4
+ Copyright 2022-2025, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
8
8
 
9
+ import enum
9
10
  import io
10
11
  import json
11
12
  import logging
12
13
  import mimetypes
13
14
  import typing
14
- from contextlib import contextmanager
15
15
  from dataclasses import dataclass
16
16
  from pathlib import Path
17
17
  from types import TracebackType
18
- from typing import Dict, Generator, List, Optional, Type, Union
18
+ from typing import Optional, Union
19
19
  from urllib.parse import urlencode, urlparse, urlunparse
20
20
 
21
21
  import requests
22
22
 
23
23
  from .converter import ParseError, sanitize_confluence
24
24
  from .properties import ConfluenceError, ConfluenceProperties
25
- from .util import removeprefix
26
25
 
27
26
  # a JSON type with possible `null` values
28
27
  JsonType = Union[
@@ -31,12 +30,17 @@ JsonType = Union[
31
30
  int,
32
31
  float,
33
32
  str,
34
- Dict[str, "JsonType"],
35
- List["JsonType"],
33
+ dict[str, "JsonType"],
34
+ list["JsonType"],
36
35
  ]
37
36
 
38
37
 
39
- def build_url(base_url: str, query: Optional[Dict[str, str]] = None) -> str:
38
+ class ConfluenceVersion(enum.Enum):
39
+ VERSION_1 = "rest/api"
40
+ VERSION_2 = "api/v2"
41
+
42
+
43
+ def build_url(base_url: str, query: Optional[dict[str, str]] = None) -> str:
40
44
  "Builds a URL with scheme, host, port, path and query string parameters."
41
45
 
42
46
  scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
@@ -66,7 +70,7 @@ class ConfluenceAttachment:
66
70
  @dataclass
67
71
  class ConfluencePage:
68
72
  id: str
69
- space_key: str
73
+ space_id: str
70
74
  title: str
71
75
  version: int
72
76
  content: str
@@ -101,7 +105,7 @@ class ConfluenceAPI:
101
105
 
102
106
  def __exit__(
103
107
  self,
104
- exc_type: Optional[Type[BaseException]],
108
+ exc_type: Optional[type[BaseException]],
105
109
  exc_val: Optional[BaseException],
106
110
  exc_tb: Optional[TracebackType],
107
111
  ) -> None:
@@ -114,40 +118,67 @@ class ConfluenceSession:
114
118
  session: requests.Session
115
119
  domain: str
116
120
  base_path: str
117
- space_key: str
121
+ space_key: Optional[str]
122
+
123
+ _space_id_to_key: dict[str, str]
124
+ _space_key_to_id: dict[str, str]
118
125
 
119
126
  def __init__(
120
- self, session: requests.Session, domain: str, base_path: str, space_key: str
127
+ self,
128
+ session: requests.Session,
129
+ domain: str,
130
+ base_path: str,
131
+ space_key: Optional[str],
121
132
  ) -> None:
122
133
  self.session = session
123
134
  self.domain = domain
124
135
  self.base_path = base_path
125
136
  self.space_key = space_key
126
137
 
138
+ self._space_id_to_key = {}
139
+ self._space_key_to_id = {}
140
+
127
141
  def close(self) -> None:
128
142
  self.session.close()
143
+ self.session = requests.Session()
129
144
 
130
- @contextmanager
131
- def switch_space(self, new_space_key: str) -> Generator[None, None, None]:
132
- old_space_key = self.space_key
133
- self.space_key = new_space_key
134
- try:
135
- yield
136
- finally:
137
- self.space_key = old_space_key
145
+ def _build_url(
146
+ self,
147
+ version: ConfluenceVersion,
148
+ path: str,
149
+ query: Optional[dict[str, str]] = None,
150
+ ) -> str:
151
+ """
152
+ Builds a full URL for invoking the Confluence API.
153
+
154
+ :param prefix: A URL path prefix that depends on the Confluence API version.
155
+ :param path: Path of API endpoint to invoke.
156
+ :param query: Query parameters to pass to the API endpoint.
157
+ :returns: A full URL.
158
+ """
138
159
 
139
- def _build_url(self, path: str, query: Optional[Dict[str, str]] = None) -> str:
140
- base_url = f"https://{self.domain}{self.base_path}rest/api{path}"
160
+ base_url = f"https://{self.domain}{self.base_path}{version.value}{path}"
141
161
  return build_url(base_url, query)
142
162
 
143
- def _invoke(self, path: str, query: Dict[str, str]) -> JsonType:
144
- url = self._build_url(path, query)
163
+ def _invoke(
164
+ self,
165
+ version: ConfluenceVersion,
166
+ path: str,
167
+ query: Optional[dict[str, str]] = None,
168
+ ) -> JsonType:
169
+ "Execute an HTTP request via Confluence API."
170
+
171
+ url = self._build_url(version, path, query)
145
172
  response = self.session.get(url)
146
173
  response.raise_for_status()
174
+ if len(response.text) > 240:
175
+ LOGGER.debug("Received HTTP payload (truncated):\n%.240s...", response.text)
176
+ else:
177
+ LOGGER.debug("Received HTTP payload:\n%s", response.text)
147
178
  return response.json()
148
179
 
149
- def _save(self, path: str, data: dict) -> None:
150
- url = self._build_url(path)
180
+ def _save(self, version: ConfluenceVersion, path: str, data: dict) -> None:
181
+ url = self._build_url(version, path)
151
182
  response = self.session.put(
152
183
  url,
153
184
  data=json.dumps(data),
@@ -155,24 +186,68 @@ class ConfluenceSession:
155
186
  )
156
187
  response.raise_for_status()
157
188
 
189
+ def space_id_to_key(self, id: str) -> str:
190
+ "Finds the Confluence space key for a space ID."
191
+
192
+ key = self._space_id_to_key.get(id)
193
+ if key is None:
194
+ payload = self._invoke(
195
+ ConfluenceVersion.VERSION_2,
196
+ "/spaces",
197
+ {"ids": id, "type": "global", "status": "current"},
198
+ )
199
+ payload = typing.cast(dict[str, JsonType], payload)
200
+ results = typing.cast(list[JsonType], payload["results"])
201
+ if len(results) != 1:
202
+ raise ConfluenceError(f"unique space not found with id: {id}")
203
+
204
+ result = typing.cast(dict[str, JsonType], results[0])
205
+ key = typing.cast(str, result["key"])
206
+
207
+ self._space_id_to_key[id] = key
208
+
209
+ return key
210
+
211
+ def space_key_to_id(self, key: str) -> str:
212
+ "Finds the Confluence space ID for a space key."
213
+
214
+ id = self._space_key_to_id.get(key)
215
+ if id is None:
216
+ payload = self._invoke(
217
+ ConfluenceVersion.VERSION_2,
218
+ "/spaces",
219
+ {"keys": key, "type": "global", "status": "current"},
220
+ )
221
+ payload = typing.cast(dict[str, JsonType], payload)
222
+ results = typing.cast(list[JsonType], payload["results"])
223
+ if len(results) != 1:
224
+ raise ConfluenceError(f"unique space not found with key: {key}")
225
+
226
+ result = typing.cast(dict[str, JsonType], results[0])
227
+ id = typing.cast(str, result["id"])
228
+
229
+ self._space_key_to_id[key] = id
230
+
231
+ return id
232
+
158
233
  def get_attachment_by_name(
159
- self, page_id: str, filename: str, *, space_key: Optional[str] = None
234
+ self, page_id: str, filename: str
160
235
  ) -> ConfluenceAttachment:
161
- path = f"/content/{page_id}/child/attachment"
162
- query = {"spaceKey": space_key or self.space_key, "filename": filename}
163
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
236
+ path = f"/pages/{page_id}/attachments"
237
+ query = {"filename": filename}
238
+ data = typing.cast(
239
+ dict[str, JsonType], self._invoke(ConfluenceVersion.VERSION_2, path, query)
240
+ )
164
241
 
165
- results = typing.cast(List[JsonType], data["results"])
242
+ results = typing.cast(list[JsonType], data["results"])
166
243
  if len(results) != 1:
167
244
  raise ConfluenceError(f"no such attachment on page {page_id}: {filename}")
168
- result = typing.cast(Dict[str, JsonType], results[0])
245
+ result = typing.cast(dict[str, JsonType], results[0])
169
246
 
170
247
  id = typing.cast(str, result["id"])
171
- extensions = typing.cast(Dict[str, JsonType], result["extensions"])
172
- media_type = typing.cast(str, extensions["mediaType"])
173
- file_size = typing.cast(int, extensions["fileSize"])
174
- comment = extensions.get("comment", "")
175
- comment = typing.cast(str, comment)
248
+ media_type = typing.cast(str, result["mediaType"])
249
+ file_size = typing.cast(int, result["fileSize"])
250
+ comment = typing.cast(str, result.get("comment", ""))
176
251
  return ConfluenceAttachment(id, media_type, file_size, comment)
177
252
 
178
253
  def upload_attachment(
@@ -184,7 +259,6 @@ class ConfluenceSession:
184
259
  raw_data: Optional[bytes] = None,
185
260
  content_type: Optional[str] = None,
186
261
  comment: Optional[str] = None,
187
- space_key: Optional[str] = None,
188
262
  force: bool = False,
189
263
  ) -> None:
190
264
 
@@ -205,9 +279,7 @@ class ConfluenceSession:
205
279
  raise ConfluenceError(f"file not found: {attachment_path}")
206
280
 
207
281
  try:
208
- attachment = self.get_attachment_by_name(
209
- page_id, attachment_name, space_key=space_key
210
- )
282
+ attachment = self.get_attachment_by_name(page_id, attachment_name)
211
283
 
212
284
  if attachment_path is not None:
213
285
  if not force and attachment.file_size == attachment_path.stat().st_size:
@@ -220,13 +292,13 @@ class ConfluenceSession:
220
292
  else:
221
293
  raise NotImplementedError("never occurs")
222
294
 
223
- id = removeprefix(attachment.id, "att")
295
+ id = attachment.id.removeprefix("att")
224
296
  path = f"/content/{page_id}/child/attachment/{id}/data"
225
297
 
226
298
  except ConfluenceError:
227
299
  path = f"/content/{page_id}/child/attachment"
228
300
 
229
- url = self._build_url(path)
301
+ url = self._build_url(ConfluenceVersion.VERSION_1, path)
230
302
 
231
303
  if attachment_path is not None:
232
304
  with open(attachment_path, "rb") as attachment_file:
@@ -279,32 +351,23 @@ class ConfluenceSession:
279
351
  version = result["version"]["number"] + 1
280
352
 
281
353
  # ensure path component is retained in attachment name
282
- self._update_attachment(
283
- page_id, attachment_id, version, attachment_name, space_key=space_key
284
- )
354
+ self._update_attachment(page_id, attachment_id, version, attachment_name)
285
355
 
286
356
  def _update_attachment(
287
- self,
288
- page_id: str,
289
- attachment_id: str,
290
- version: int,
291
- attachment_title: str,
292
- *,
293
- space_key: Optional[str] = None,
357
+ self, page_id: str, attachment_id: str, version: int, attachment_title: str
294
358
  ) -> None:
295
- id = removeprefix(attachment_id, "att")
359
+ id = attachment_id.removeprefix("att")
296
360
  path = f"/content/{page_id}/child/attachment/{id}"
297
361
  data = {
298
362
  "id": attachment_id,
299
363
  "type": "attachment",
300
364
  "status": "current",
301
365
  "title": attachment_title,
302
- "space": {"key": space_key or self.space_key},
303
366
  "version": {"minorEdit": True, "number": version},
304
367
  }
305
368
 
306
369
  LOGGER.info("Updating attachment: %s", attachment_id)
307
- self._save(path, data)
370
+ self._save(ConfluenceVersion.VERSION_1, path, data)
308
371
 
309
372
  def get_page_id_by_title(
310
373
  self,
@@ -321,97 +384,61 @@ class ConfluenceSession:
321
384
  """
322
385
 
323
386
  LOGGER.info("Looking up page with title: %s", title)
324
- path = "/content"
325
- query = {"title": title, "spaceKey": space_key or self.space_key}
326
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
387
+ path = "/pages"
388
+ query = {
389
+ "title": title,
390
+ }
391
+ coalesced_space_key = space_key or self.space_key
392
+ if coalesced_space_key is not None:
393
+ query["space-id"] = self.space_key_to_id(coalesced_space_key)
327
394
 
328
- results = typing.cast(List[JsonType], data["results"])
395
+ payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
396
+ payload = typing.cast(dict[str, JsonType], payload)
397
+
398
+ results = typing.cast(list[JsonType], payload["results"])
329
399
  if len(results) != 1:
330
- raise ConfluenceError(f"page not found with title: {title}")
400
+ raise ConfluenceError(f"unique page not found with title: {title}")
331
401
 
332
- result = typing.cast(Dict[str, JsonType], results[0])
402
+ result = typing.cast(dict[str, JsonType], results[0])
333
403
  id = typing.cast(str, result["id"])
334
404
  return id
335
405
 
336
- def get_page(
337
- self, page_id: str, *, space_key: Optional[str] = None
338
- ) -> ConfluencePage:
406
+ def get_page(self, page_id: str) -> ConfluencePage:
339
407
  """
340
408
  Retrieve Confluence wiki page details.
341
409
 
342
410
  :param page_id: The Confluence page ID.
343
- :param space_key: The Confluence space key (unless the default space is to be used).
344
411
  :returns: Confluence page info.
345
412
  """
346
413
 
347
- path = f"/content/{page_id}"
348
- query = {
349
- "spaceKey": space_key or self.space_key,
350
- "expand": "body.storage,version",
351
- }
352
-
353
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
354
- version = typing.cast(Dict[str, JsonType], data["version"])
355
- body = typing.cast(Dict[str, JsonType], data["body"])
356
- storage = typing.cast(Dict[str, JsonType], body["storage"])
414
+ path = f"/pages/{page_id}"
415
+ query = {"body-format": "storage"}
416
+ payload = self._invoke(ConfluenceVersion.VERSION_2, path, query)
417
+ data = typing.cast(dict[str, JsonType], payload)
418
+ version = typing.cast(dict[str, JsonType], data["version"])
419
+ body = typing.cast(dict[str, JsonType], data["body"])
420
+ storage = typing.cast(dict[str, JsonType], body["storage"])
357
421
 
358
422
  return ConfluencePage(
359
423
  id=page_id,
360
- space_key=space_key or self.space_key,
424
+ space_id=typing.cast(str, data["spaceId"]),
361
425
  title=typing.cast(str, data["title"]),
362
426
  version=typing.cast(int, version["number"]),
363
427
  content=typing.cast(str, storage["value"]),
364
428
  )
365
429
 
366
- def get_page_ancestors(
367
- self, page_id: str, *, space_key: Optional[str] = None
368
- ) -> Dict[str, str]:
369
- """
370
- Retrieve Confluence wiki page ancestors.
371
-
372
- :param page_id: The Confluence page ID.
373
- :param space_key: The Confluence space key (unless the default space is to be used).
374
- :returns: Dictionary of ancestor page ID to title, with topmost ancestor first.
375
- """
376
-
377
- path = f"/content/{page_id}"
378
- query = {
379
- "spaceKey": space_key or self.space_key,
380
- "expand": "ancestors",
381
- }
382
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
383
- ancestors = typing.cast(List[JsonType], data["ancestors"])
384
-
385
- # from the JSON array of ancestors, extract the "id" and "title"
386
- results: Dict[str, str] = {}
387
- for node in ancestors:
388
- ancestor = typing.cast(Dict[str, JsonType], node)
389
- id = typing.cast(str, ancestor["id"])
390
- title = typing.cast(str, ancestor["title"])
391
- results[id] = title
392
- return results
393
-
394
- def get_page_version(
395
- self,
396
- page_id: str,
397
- *,
398
- space_key: Optional[str] = None,
399
- ) -> int:
430
+ def get_page_version(self, page_id: str) -> int:
400
431
  """
401
432
  Retrieve a Confluence wiki page version.
402
433
 
403
434
  :param page_id: The Confluence page ID.
404
- :param space_key: The Confluence space key (unless the default space is to be used).
405
435
  :returns: Confluence page version.
406
436
  """
407
437
 
408
- path = f"/content/{page_id}"
409
- query = {
410
- "spaceKey": space_key or self.space_key,
411
- "expand": "version",
412
- }
413
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
414
- version = typing.cast(Dict[str, JsonType], data["version"])
438
+ path = f"/pages/{page_id}"
439
+ payload = self._invoke(ConfluenceVersion.VERSION_2, path)
440
+ data = typing.cast(dict[str, JsonType], payload)
441
+ version = typing.cast(dict[str, JsonType], data["version"])
415
442
  return typing.cast(int, version["number"])
416
443
 
417
444
  def update_page(
@@ -419,7 +446,6 @@ class ConfluenceSession:
419
446
  page_id: str,
420
447
  new_content: str,
421
448
  *,
422
- space_key: Optional[str] = None,
423
449
  title: Optional[str] = None,
424
450
  ) -> None:
425
451
  """
@@ -427,11 +453,10 @@ class ConfluenceSession:
427
453
 
428
454
  :param page_id: The Confluence page ID.
429
455
  :param new_content: Confluence Storage Format XHTML.
430
- :param space_key: The Confluence space key (unless the default space is to be used).
431
456
  :param title: New title to assign to the page. Needs to be unique within a space.
432
457
  """
433
458
 
434
- page = self.get_page(page_id, space_key=space_key)
459
+ page = self.get_page(page_id)
435
460
  new_title = title or page.title
436
461
 
437
462
  try:
@@ -442,18 +467,17 @@ class ConfluenceSession:
442
467
  except ParseError as exc:
443
468
  LOGGER.warning(exc)
444
469
 
445
- path = f"/content/{page_id}"
470
+ path = f"/pages/{page_id}"
446
471
  data = {
447
472
  "id": page_id,
448
- "type": "page",
473
+ "status": "current",
449
474
  "title": new_title,
450
- "space": {"key": space_key or self.space_key},
451
475
  "body": {"storage": {"value": new_content, "representation": "storage"}},
452
476
  "version": {"minorEdit": True, "number": page.version + 1},
453
477
  }
454
478
 
455
479
  LOGGER.info("Updating page: %s", page_id)
456
- self._save(path, data)
480
+ self._save(ConfluenceVersion.VERSION_2, path, data)
457
481
 
458
482
  def create_page(
459
483
  self,
@@ -463,18 +487,28 @@ class ConfluenceSession:
463
487
  *,
464
488
  space_key: Optional[str] = None,
465
489
  ) -> ConfluencePage:
466
- path = "/content/"
490
+ """
491
+ Create a new page via Confluence API.
492
+ """
493
+
494
+ coalesced_space_key = space_key or self.space_key
495
+ if coalesced_space_key is None:
496
+ raise ConfluenceError(
497
+ "Confluence space key required for creating a new page"
498
+ )
499
+
500
+ path = "/pages/"
467
501
  query = {
468
- "type": "page",
502
+ "spaceId": self.space_key_to_id(coalesced_space_key),
503
+ "status": "current",
469
504
  "title": title,
470
- "space": {"key": space_key or self.space_key},
505
+ "parentId": parent_page_id,
471
506
  "body": {"storage": {"value": new_content, "representation": "storage"}},
472
- "ancestors": [{"type": "page", "id": parent_page_id}],
473
507
  }
474
508
 
475
509
  LOGGER.info("Creating page: %s", title)
476
510
 
477
- url = self._build_url(path)
511
+ url = self._build_url(ConfluenceVersion.VERSION_2, path)
478
512
  response = self.session.post(
479
513
  url,
480
514
  data=json.dumps(query),
@@ -482,43 +516,66 @@ class ConfluenceSession:
482
516
  )
483
517
  response.raise_for_status()
484
518
 
485
- data = typing.cast(Dict[str, JsonType], response.json())
486
- version = typing.cast(Dict[str, JsonType], data["version"])
487
- body = typing.cast(Dict[str, JsonType], data["body"])
488
- storage = typing.cast(Dict[str, JsonType], body["storage"])
519
+ data = typing.cast(dict[str, JsonType], response.json())
520
+ version = typing.cast(dict[str, JsonType], data["version"])
521
+ body = typing.cast(dict[str, JsonType], data["body"])
522
+ storage = typing.cast(dict[str, JsonType], body["storage"])
489
523
 
490
524
  return ConfluencePage(
491
525
  id=typing.cast(str, data["id"]),
492
- space_key=space_key or self.space_key,
526
+ space_id=typing.cast(str, data["spaceId"]),
493
527
  title=typing.cast(str, data["title"]),
494
528
  version=typing.cast(int, version["number"]),
495
529
  content=typing.cast(str, storage["value"]),
496
530
  )
497
531
 
532
+ def delete_page(self, page_id: str, *, purge: bool = False) -> None:
533
+ """
534
+ Delete a page via Confluence API.
535
+
536
+ :param page_id: The Confluence page ID.
537
+ :param purge: True to completely purge the page, False to move to trash only.
538
+ """
539
+
540
+ path = f"/pages/{page_id}"
541
+
542
+ # move to trash
543
+ url = self._build_url(ConfluenceVersion.VERSION_2, path)
544
+ LOGGER.info("Moving page to trash: %s", page_id)
545
+ response = self.session.delete(url)
546
+ response.raise_for_status()
547
+
548
+ if purge:
549
+ # purge from trash
550
+ query = {"purge": "true"}
551
+ url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
552
+ LOGGER.info("Permanently deleting page: %s", page_id)
553
+ response = self.session.delete(url)
554
+ response.raise_for_status()
555
+
498
556
  def page_exists(
499
557
  self, title: str, *, space_key: Optional[str] = None
500
558
  ) -> Optional[str]:
501
- path = "/content"
502
- query = {
503
- "type": "page",
504
- "title": title,
505
- "spaceKey": space_key or self.space_key,
506
- }
559
+ path = "/pages"
560
+ coalesced_space_key = space_key or self.space_key
561
+ query = {"title": title}
562
+ if coalesced_space_key is not None:
563
+ query["space-id"] = self.space_key_to_id(coalesced_space_key)
507
564
 
508
565
  LOGGER.info("Checking if page exists with title: %s", title)
509
566
 
510
- url = self._build_url(path)
567
+ url = self._build_url(ConfluenceVersion.VERSION_2, path)
511
568
  response = self.session.get(
512
569
  url, params=query, headers={"Content-Type": "application/json"}
513
570
  )
514
571
  response.raise_for_status()
515
572
 
516
- data = typing.cast(Dict[str, JsonType], response.json())
517
- results = typing.cast(List, data["results"])
573
+ data = typing.cast(dict[str, JsonType], response.json())
574
+ results = typing.cast(list[JsonType], data["results"])
518
575
 
519
576
  if len(results) == 1:
520
- page_info = typing.cast(Dict[str, JsonType], results[0])
521
- return typing.cast(str, page_info["id"])
577
+ result = typing.cast(dict[str, JsonType], results[0])
578
+ return typing.cast(str, result["id"])
522
579
  else:
523
580
  return None
524
581