markdown-to-confluence 0.1.13__py3-none-any.whl → 0.2.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,459 +1,517 @@
1
- import json
2
- import logging
3
- import mimetypes
4
- import sys
5
- import typing
6
- from contextlib import contextmanager
7
- from dataclasses import dataclass
8
- from pathlib import Path
9
- from types import TracebackType
10
- from typing import Dict, Generator, List, Optional, Type, Union
11
- from urllib.parse import urlencode, urlparse, urlunparse
12
-
13
- import requests
14
-
15
- from .converter import ParseError, sanitize_confluence
16
- from .properties import ConfluenceError, ConfluenceProperties
17
-
18
- # a JSON type with possible `null` values
19
- JsonType = Union[
20
- None,
21
- bool,
22
- int,
23
- float,
24
- str,
25
- Dict[str, "JsonType"],
26
- List["JsonType"],
27
- ]
28
-
29
-
30
- def build_url(base_url: str, query: Optional[Dict[str, str]] = None) -> str:
31
- "Builds a URL with scheme, host, port, path and query string parameters."
32
-
33
- scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
34
-
35
- if params:
36
- raise ValueError("expected: url with no parameters")
37
- if query_str:
38
- raise ValueError("expected: url with no query string")
39
- if fragment:
40
- raise ValueError("expected: url with no fragment")
41
-
42
- url_parts = (scheme, netloc, path, None, urlencode(query) if query else None, None)
43
- return urlunparse(url_parts)
44
-
45
-
46
- if sys.version_info >= (3, 9):
47
-
48
- def removeprefix(string: str, prefix: str) -> str:
49
- "If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
50
-
51
- return string.removeprefix(prefix)
52
-
53
- else:
54
-
55
- def removeprefix(string: str, prefix: str) -> str:
56
- "If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
57
-
58
- if string.startswith(prefix):
59
- prefix_len = len(prefix)
60
- return string[prefix_len:]
61
- else:
62
- return string
63
-
64
-
65
- LOGGER = logging.getLogger(__name__)
66
-
67
-
68
- @dataclass
69
- class ConfluenceAttachment:
70
- id: str
71
- media_type: str
72
- file_size: int
73
- comment: str
74
-
75
-
76
- @dataclass
77
- class ConfluencePage:
78
- id: str
79
- space_key: str
80
- title: str
81
- version: int
82
- content: str
83
-
84
-
85
- class ConfluenceAPI:
86
- properties: ConfluenceProperties
87
- session: Optional["ConfluenceSession"] = None
88
-
89
- def __init__(self, properties: Optional[ConfluenceProperties] = None) -> None:
90
- self.properties = properties or ConfluenceProperties()
91
-
92
- def __enter__(self) -> "ConfluenceSession":
93
- session = requests.Session()
94
- if self.properties.user_name:
95
- session.auth = (self.properties.user_name, self.properties.api_key)
96
- else:
97
- session.headers.update(
98
- {"Authorization": f"Bearer {self.properties.api_key}"}
99
- )
100
- self.session = ConfluenceSession(
101
- session,
102
- self.properties.domain,
103
- self.properties.base_path,
104
- self.properties.space_key,
105
- )
106
- return self.session
107
-
108
- def __exit__(
109
- self,
110
- exc_type: Optional[Type[BaseException]],
111
- exc_val: Optional[BaseException],
112
- exc_tb: Optional[TracebackType],
113
- ) -> None:
114
- if self.session is not None:
115
- self.session.close()
116
- self.session = None
117
-
118
-
119
- class ConfluenceSession:
120
- session: requests.Session
121
- domain: str
122
- base_path: str
123
- space_key: str
124
-
125
- def __init__(
126
- self, session: requests.Session, domain: str, base_path: str, space_key: str
127
- ) -> None:
128
- self.session = session
129
- self.domain = domain
130
- self.base_path = base_path
131
- self.space_key = space_key
132
-
133
- def close(self) -> None:
134
- self.session.close()
135
-
136
- @contextmanager
137
- def switch_space(self, new_space_key: str) -> Generator[None, None, None]:
138
- old_space_key = self.space_key
139
- self.space_key = new_space_key
140
- try:
141
- yield
142
- finally:
143
- self.space_key = old_space_key
144
-
145
- def _build_url(self, path: str, query: Optional[Dict[str, str]] = None) -> str:
146
- base_url = f"https://{self.domain}{self.base_path}rest/api{path}"
147
- return build_url(base_url, query)
148
-
149
- def _invoke(self, path: str, query: Dict[str, str]) -> JsonType:
150
- url = self._build_url(path, query)
151
- response = self.session.get(url)
152
- response.raise_for_status()
153
- return response.json()
154
-
155
- def _save(self, path: str, data: dict) -> None:
156
- url = self._build_url(path)
157
- response = self.session.put(
158
- url,
159
- data=json.dumps(data),
160
- headers={"Content-Type": "application/json"},
161
- )
162
- response.raise_for_status()
163
-
164
- def get_attachment_by_name(
165
- self, page_id: str, filename: str, *, space_key: Optional[str] = None
166
- ) -> ConfluenceAttachment:
167
- path = f"/content/{page_id}/child/attachment"
168
- query = {"spaceKey": space_key or self.space_key, "filename": filename}
169
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
170
-
171
- results = typing.cast(List[JsonType], data["results"])
172
- if len(results) != 1:
173
- raise ConfluenceError(f"no such attachment on page {page_id}: {filename}")
174
- result = typing.cast(Dict[str, JsonType], results[0])
175
-
176
- id = typing.cast(str, result["id"])
177
- extensions = typing.cast(Dict[str, JsonType], result["extensions"])
178
- media_type = typing.cast(str, extensions["mediaType"])
179
- file_size = typing.cast(int, extensions["fileSize"])
180
- comment = extensions.get("comment", "")
181
- comment = typing.cast(str, comment)
182
- return ConfluenceAttachment(id, media_type, file_size, comment)
183
-
184
- def upload_attachment(
185
- self,
186
- page_id: str,
187
- attachment_path: Path,
188
- attachment_name: str,
189
- comment: Optional[str] = None,
190
- *,
191
- space_key: Optional[str] = None,
192
- force: bool = False,
193
- ) -> None:
194
- content_type = mimetypes.guess_type(attachment_path, strict=True)[0]
195
-
196
- if not attachment_path.is_file():
197
- raise ConfluenceError(f"file not found: {attachment_path}")
198
-
199
- try:
200
- attachment = self.get_attachment_by_name(
201
- page_id, attachment_name, space_key=space_key
202
- )
203
-
204
- if not force and attachment.file_size == attachment_path.stat().st_size:
205
- LOGGER.info("Up-to-date attachment: %s", attachment_name)
206
- return
207
-
208
- id = removeprefix(attachment.id, "att")
209
- path = f"/content/{page_id}/child/attachment/{id}/data"
210
-
211
- except ConfluenceError:
212
- path = f"/content/{page_id}/child/attachment"
213
-
214
- url = self._build_url(path)
215
-
216
- with open(attachment_path, "rb") as attachment_file:
217
- file_to_upload = {
218
- "comment": comment,
219
- "file": (
220
- attachment_name, # will truncate path component
221
- attachment_file,
222
- content_type,
223
- {"Expires": "0"},
224
- ),
225
- }
226
- LOGGER.info("Uploading attachment: %s", attachment_name)
227
- response = self.session.post(
228
- url,
229
- files=file_to_upload, # type: ignore
230
- headers={"X-Atlassian-Token": "no-check"},
231
- )
232
-
233
- response.raise_for_status()
234
- data = response.json()
235
-
236
- if "results" in data:
237
- result = data["results"][0]
238
- else:
239
- result = data
240
-
241
- attachment_id = result["id"]
242
- version = result["version"]["number"] + 1
243
-
244
- # ensure path component is retained in attachment name
245
- self._update_attachment(
246
- page_id, attachment_id, version, attachment_name, space_key=space_key
247
- )
248
-
249
- def _update_attachment(
250
- self,
251
- page_id: str,
252
- attachment_id: str,
253
- version: int,
254
- attachment_title: str,
255
- *,
256
- space_key: Optional[str] = None,
257
- ) -> None:
258
- id = removeprefix(attachment_id, "att")
259
- path = f"/content/{page_id}/child/attachment/{id}"
260
- data = {
261
- "id": attachment_id,
262
- "type": "attachment",
263
- "status": "current",
264
- "title": attachment_title,
265
- "space": {"key": space_key or self.space_key},
266
- "version": {"minorEdit": True, "number": version},
267
- }
268
-
269
- LOGGER.info("Updating attachment: %s", attachment_id)
270
- self._save(path, data)
271
-
272
- def get_page_id_by_title(
273
- self,
274
- title: str,
275
- *,
276
- space_key: Optional[str] = None,
277
- ) -> str:
278
- """
279
- Look up a Confluence wiki page ID by title.
280
-
281
- :param title: The page title.
282
- :param space_key: The Confluence space key (unless the default space is to be used).
283
- :returns: Confluence page ID.
284
- """
285
-
286
- LOGGER.info("Looking up page with title: %s", title)
287
- path = "/content"
288
- query = {"title": title, "spaceKey": space_key or self.space_key}
289
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
290
-
291
- results = typing.cast(List[JsonType], data["results"])
292
- if len(results) != 1:
293
- raise ConfluenceError(f"page not found with title: {title}")
294
-
295
- result = typing.cast(Dict[str, JsonType], results[0])
296
- id = typing.cast(str, result["id"])
297
- return id
298
-
299
- def get_page(
300
- self, page_id: str, *, space_key: Optional[str] = None
301
- ) -> ConfluencePage:
302
- """
303
- Retrieve Confluence wiki page details.
304
-
305
- :param page_id: The Confluence page ID.
306
- :param space_key: The Confluence space key (unless the default space is to be used).
307
- :returns: Confluence page info.
308
- """
309
-
310
- path = f"/content/{page_id}"
311
- query = {
312
- "spaceKey": space_key or self.space_key,
313
- "expand": "body.storage,version",
314
- }
315
-
316
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
317
- version = typing.cast(Dict[str, JsonType], data["version"])
318
- body = typing.cast(Dict[str, JsonType], data["body"])
319
- storage = typing.cast(Dict[str, JsonType], body["storage"])
320
-
321
- return ConfluencePage(
322
- id=page_id,
323
- space_key=space_key or self.space_key,
324
- title=typing.cast(str, data["title"]),
325
- version=typing.cast(int, version["number"]),
326
- content=typing.cast(str, storage["value"]),
327
- )
328
-
329
- def get_page_version(
330
- self,
331
- page_id: str,
332
- *,
333
- space_key: Optional[str] = None,
334
- ) -> int:
335
- """
336
- Retrieve a Confluence wiki page version.
337
-
338
- :param page_id: The Confluence page ID.
339
- :param space_key: The Confluence space key (unless the default space is to be used).
340
- :returns: Confluence page version.
341
- """
342
-
343
- path = f"/content/{page_id}"
344
- query = {
345
- "spaceKey": space_key or self.space_key,
346
- "expand": "version",
347
- }
348
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
349
- version = typing.cast(Dict[str, JsonType], data["version"])
350
- return typing.cast(int, version["number"])
351
-
352
- def update_page(
353
- self,
354
- page_id: str,
355
- new_content: str,
356
- *,
357
- space_key: Optional[str] = None,
358
- ) -> None:
359
- page = self.get_page(page_id, space_key=space_key)
360
-
361
- try:
362
- old_content = sanitize_confluence(page.content)
363
- if old_content == new_content:
364
- LOGGER.info("Up-to-date page: %s", page_id)
365
- return
366
- except ParseError as exc:
367
- LOGGER.warning(exc)
368
-
369
- path = f"/content/{page_id}"
370
- data = {
371
- "id": page_id,
372
- "type": "page",
373
- "title": page.title, # title needs to be unique within a space so the original title is maintained
374
- "space": {"key": space_key or self.space_key},
375
- "body": {"storage": {"value": new_content, "representation": "storage"}},
376
- "version": {"minorEdit": True, "number": page.version + 1},
377
- }
378
-
379
- LOGGER.info("Updating page: %s", page_id)
380
- self._save(path, data)
381
-
382
- def create_page(
383
- self,
384
- parent_page_id: str,
385
- title: str,
386
- new_content: str,
387
- *,
388
- space_key: Optional[str] = None,
389
- ) -> ConfluencePage:
390
- path = "/content/"
391
- query = {
392
- "type": "page",
393
- "title": title,
394
- "space": {"key": space_key or self.space_key},
395
- "body": {"storage": {"value": new_content, "representation": "storage"}},
396
- "ancestors": [{"type": "page", "id": parent_page_id}],
397
- }
398
-
399
- LOGGER.info("Creating page: %s", title)
400
-
401
- url = self._build_url(path)
402
- response = self.session.post(
403
- url,
404
- data=json.dumps(query),
405
- headers={"Content-Type": "application/json"},
406
- )
407
- response.raise_for_status()
408
-
409
- data = typing.cast(Dict[str, JsonType], response.json())
410
- version = typing.cast(Dict[str, JsonType], data["version"])
411
- body = typing.cast(Dict[str, JsonType], data["body"])
412
- storage = typing.cast(Dict[str, JsonType], body["storage"])
413
-
414
- return ConfluencePage(
415
- id=typing.cast(str, data["id"]),
416
- space_key=space_key or self.space_key,
417
- title=typing.cast(str, data["title"]),
418
- version=typing.cast(int, version["number"]),
419
- content=typing.cast(str, storage["value"]),
420
- )
421
-
422
- def page_exists(
423
- self, title: str, *, space_key: Optional[str] = None
424
- ) -> Optional[str]:
425
- path = "/content"
426
- query = {
427
- "type": "page",
428
- "title": title,
429
- "spaceKey": space_key or self.space_key,
430
- }
431
-
432
- LOGGER.info("Checking if page exists with title: %s", title)
433
-
434
- url = self._build_url(path)
435
- response = self.session.get(
436
- url, params=query, headers={"Content-Type": "application/json"}
437
- )
438
- response.raise_for_status()
439
-
440
- data = typing.cast(Dict[str, JsonType], response.json())
441
- results = typing.cast(List, data["results"])
442
-
443
- if len(results) == 1:
444
- page_info = typing.cast(Dict[str, JsonType], results[0])
445
- return typing.cast(str, page_info["id"])
446
- else:
447
- return None
448
-
449
- def get_or_create_page(
450
- self, title: str, parent_id: str, *, space_key: Optional[str] = None
451
- ) -> ConfluencePage:
452
- page_id = self.page_exists(title)
453
-
454
- if page_id is not None:
455
- LOGGER.debug("Retrieving existing page: %d", page_id)
456
- return self.get_page(page_id)
457
- else:
458
- LOGGER.debug("Creating new page with title: %s", title)
459
- return self.create_page(parent_id, title, "", space_key=space_key)
1
+ import io
2
+ import json
3
+ import logging
4
+ import mimetypes
5
+ import sys
6
+ import typing
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from types import TracebackType
11
+ from typing import Dict, Generator, List, Optional, Type, Union
12
+ from urllib.parse import urlencode, urlparse, urlunparse
13
+
14
+ import requests
15
+
16
+ from .converter import ParseError, sanitize_confluence
17
+ from .properties import ConfluenceError, ConfluenceProperties
18
+
19
+ # a JSON type with possible `null` values
20
+ JsonType = Union[
21
+ None,
22
+ bool,
23
+ int,
24
+ float,
25
+ str,
26
+ Dict[str, "JsonType"],
27
+ List["JsonType"],
28
+ ]
29
+
30
+
31
+ def build_url(base_url: str, query: Optional[Dict[str, str]] = None) -> str:
32
+ "Builds a URL with scheme, host, port, path and query string parameters."
33
+
34
+ scheme, netloc, path, params, query_str, fragment = urlparse(base_url)
35
+
36
+ if params:
37
+ raise ValueError("expected: url with no parameters")
38
+ if query_str:
39
+ raise ValueError("expected: url with no query string")
40
+ if fragment:
41
+ raise ValueError("expected: url with no fragment")
42
+
43
+ url_parts = (scheme, netloc, path, None, urlencode(query) if query else None, None)
44
+ return urlunparse(url_parts)
45
+
46
+
47
+ if sys.version_info >= (3, 9):
48
+
49
+ def removeprefix(string: str, prefix: str) -> str:
50
+ "If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
51
+
52
+ return string.removeprefix(prefix)
53
+
54
+ else:
55
+
56
+ def removeprefix(string: str, prefix: str) -> str:
57
+ "If the string starts with the prefix, return the string without the prefix; otherwise, return the original string."
58
+
59
+ if string.startswith(prefix):
60
+ prefix_len = len(prefix)
61
+ return string[prefix_len:]
62
+ else:
63
+ return string
64
+
65
+
66
+ LOGGER = logging.getLogger(__name__)
67
+
68
+
69
+ @dataclass
70
+ class ConfluenceAttachment:
71
+ id: str
72
+ media_type: str
73
+ file_size: int
74
+ comment: str
75
+
76
+
77
+ @dataclass
78
+ class ConfluencePage:
79
+ id: str
80
+ space_key: str
81
+ title: str
82
+ version: int
83
+ content: str
84
+
85
+
86
+ class ConfluenceAPI:
87
+ properties: ConfluenceProperties
88
+ session: Optional["ConfluenceSession"] = None
89
+
90
+ def __init__(self, properties: Optional[ConfluenceProperties] = None) -> None:
91
+ self.properties = properties or ConfluenceProperties()
92
+
93
+ def __enter__(self) -> "ConfluenceSession":
94
+ session = requests.Session()
95
+ if self.properties.user_name:
96
+ session.auth = (self.properties.user_name, self.properties.api_key)
97
+ else:
98
+ session.headers.update(
99
+ {"Authorization": f"Bearer {self.properties.api_key}"}
100
+ )
101
+
102
+ if self.properties.headers:
103
+ session.headers.update(self.properties.headers)
104
+
105
+ self.session = ConfluenceSession(
106
+ session,
107
+ self.properties.domain,
108
+ self.properties.base_path,
109
+ self.properties.space_key,
110
+ )
111
+ return self.session
112
+
113
+ def __exit__(
114
+ self,
115
+ exc_type: Optional[Type[BaseException]],
116
+ exc_val: Optional[BaseException],
117
+ exc_tb: Optional[TracebackType],
118
+ ) -> None:
119
+ if self.session is not None:
120
+ self.session.close()
121
+ self.session = None
122
+
123
+
124
+ class ConfluenceSession:
125
+ session: requests.Session
126
+ domain: str
127
+ base_path: str
128
+ space_key: str
129
+
130
+ def __init__(
131
+ self, session: requests.Session, domain: str, base_path: str, space_key: str
132
+ ) -> None:
133
+ self.session = session
134
+ self.domain = domain
135
+ self.base_path = base_path
136
+ self.space_key = space_key
137
+
138
+ def close(self) -> None:
139
+ self.session.close()
140
+
141
+ @contextmanager
142
+ def switch_space(self, new_space_key: str) -> Generator[None, None, None]:
143
+ old_space_key = self.space_key
144
+ self.space_key = new_space_key
145
+ try:
146
+ yield
147
+ finally:
148
+ self.space_key = old_space_key
149
+
150
+ def _build_url(self, path: str, query: Optional[Dict[str, str]] = None) -> str:
151
+ base_url = f"https://{self.domain}{self.base_path}rest/api{path}"
152
+ return build_url(base_url, query)
153
+
154
+ def _invoke(self, path: str, query: Dict[str, str]) -> JsonType:
155
+ url = self._build_url(path, query)
156
+ response = self.session.get(url)
157
+ response.raise_for_status()
158
+ return response.json()
159
+
160
+ def _save(self, path: str, data: dict) -> None:
161
+ url = self._build_url(path)
162
+ response = self.session.put(
163
+ url,
164
+ data=json.dumps(data),
165
+ headers={"Content-Type": "application/json"},
166
+ )
167
+ response.raise_for_status()
168
+
169
+ def get_attachment_by_name(
170
+ self, page_id: str, filename: str, *, space_key: Optional[str] = None
171
+ ) -> ConfluenceAttachment:
172
+ path = f"/content/{page_id}/child/attachment"
173
+ query = {"spaceKey": space_key or self.space_key, "filename": filename}
174
+ data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
175
+
176
+ results = typing.cast(List[JsonType], data["results"])
177
+ if len(results) != 1:
178
+ raise ConfluenceError(f"no such attachment on page {page_id}: {filename}")
179
+ result = typing.cast(Dict[str, JsonType], results[0])
180
+
181
+ id = typing.cast(str, result["id"])
182
+ extensions = typing.cast(Dict[str, JsonType], result["extensions"])
183
+ media_type = typing.cast(str, extensions["mediaType"])
184
+ file_size = typing.cast(int, extensions["fileSize"])
185
+ comment = extensions.get("comment", "")
186
+ comment = typing.cast(str, comment)
187
+ return ConfluenceAttachment(id, media_type, file_size, comment)
188
+
189
+ def upload_attachment(
190
+ self,
191
+ page_id: str,
192
+ attachment_path: Path,
193
+ attachment_name: str,
194
+ raw_data: Optional[bytes] = None,
195
+ comment: Optional[str] = None,
196
+ *,
197
+ space_key: Optional[str] = None,
198
+ force: bool = False,
199
+ ) -> None:
200
+ content_type = mimetypes.guess_type(attachment_path, strict=True)[0]
201
+
202
+ if not raw_data and not attachment_path.is_file():
203
+ raise ConfluenceError(f"file not found: {attachment_path}")
204
+
205
+ try:
206
+ attachment = self.get_attachment_by_name(
207
+ page_id, attachment_name, space_key=space_key
208
+ )
209
+
210
+ if not raw_data:
211
+ if not force and attachment.file_size == attachment_path.stat().st_size:
212
+ LOGGER.info("Up-to-date attachment: %s", attachment_name)
213
+ return
214
+ else:
215
+ if not force and attachment.file_size == len(raw_data):
216
+ LOGGER.info("Up-to-date embedded image: %s", attachment_name)
217
+ return
218
+
219
+ id = removeprefix(attachment.id, "att")
220
+ path = f"/content/{page_id}/child/attachment/{id}/data"
221
+
222
+ except ConfluenceError:
223
+ path = f"/content/{page_id}/child/attachment"
224
+
225
+ url = self._build_url(path)
226
+
227
+ if not raw_data:
228
+ with open(attachment_path, "rb") as attachment_file:
229
+ file_to_upload = {
230
+ "comment": comment,
231
+ "file": (
232
+ attachment_name, # will truncate path component
233
+ attachment_file,
234
+ content_type,
235
+ {"Expires": "0"},
236
+ ),
237
+ }
238
+ LOGGER.info("Uploading attachment: %s", attachment_name)
239
+ response = self.session.post(
240
+ url,
241
+ files=file_to_upload, # type: ignore
242
+ headers={"X-Atlassian-Token": "no-check"},
243
+ )
244
+ else:
245
+ LOGGER.info("Uploading raw data: %s", attachment_name)
246
+
247
+ file_to_upload = {
248
+ "comment": comment,
249
+ "file": (
250
+ attachment_name, # will truncate path component
251
+ io.BytesIO(raw_data), # type: ignore
252
+ content_type,
253
+ {"Expires": "0"},
254
+ ),
255
+ }
256
+
257
+ response = self.session.post(
258
+ url,
259
+ files=file_to_upload, # type: ignore
260
+ headers={"X-Atlassian-Token": "no-check"},
261
+ )
262
+
263
+ response.raise_for_status()
264
+ data = response.json()
265
+
266
+ if "results" in data:
267
+ result = data["results"][0]
268
+ else:
269
+ result = data
270
+
271
+ attachment_id = result["id"]
272
+ version = result["version"]["number"] + 1
273
+
274
+ # ensure path component is retained in attachment name
275
+ self._update_attachment(
276
+ page_id, attachment_id, version, attachment_name, space_key=space_key
277
+ )
278
+
279
+ def _update_attachment(
280
+ self,
281
+ page_id: str,
282
+ attachment_id: str,
283
+ version: int,
284
+ attachment_title: str,
285
+ *,
286
+ space_key: Optional[str] = None,
287
+ ) -> None:
288
+ id = removeprefix(attachment_id, "att")
289
+ path = f"/content/{page_id}/child/attachment/{id}"
290
+ data = {
291
+ "id": attachment_id,
292
+ "type": "attachment",
293
+ "status": "current",
294
+ "title": attachment_title,
295
+ "space": {"key": space_key or self.space_key},
296
+ "version": {"minorEdit": True, "number": version},
297
+ }
298
+
299
+ LOGGER.info("Updating attachment: %s", attachment_id)
300
+ self._save(path, data)
301
+
302
+ def get_page_id_by_title(
303
+ self,
304
+ title: str,
305
+ *,
306
+ space_key: Optional[str] = None,
307
+ ) -> str:
308
+ """
309
+ Look up a Confluence wiki page ID by title.
310
+
311
+ :param title: The page title.
312
+ :param space_key: The Confluence space key (unless the default space is to be used).
313
+ :returns: Confluence page ID.
314
+ """
315
+
316
+ LOGGER.info("Looking up page with title: %s", title)
317
+ path = "/content"
318
+ query = {"title": title, "spaceKey": space_key or self.space_key}
319
+ data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
320
+
321
+ results = typing.cast(List[JsonType], data["results"])
322
+ if len(results) != 1:
323
+ raise ConfluenceError(f"page not found with title: {title}")
324
+
325
+ result = typing.cast(Dict[str, JsonType], results[0])
326
+ id = typing.cast(str, result["id"])
327
+ return id
328
+
329
+ def get_page(
330
+ self, page_id: str, *, space_key: Optional[str] = None
331
+ ) -> ConfluencePage:
332
+ """
333
+ Retrieve Confluence wiki page details.
334
+
335
+ :param page_id: The Confluence page ID.
336
+ :param space_key: The Confluence space key (unless the default space is to be used).
337
+ :returns: Confluence page info.
338
+ """
339
+
340
+ path = f"/content/{page_id}"
341
+ query = {
342
+ "spaceKey": space_key or self.space_key,
343
+ "expand": "body.storage,version",
344
+ }
345
+
346
+ data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
347
+ version = typing.cast(Dict[str, JsonType], data["version"])
348
+ body = typing.cast(Dict[str, JsonType], data["body"])
349
+ storage = typing.cast(Dict[str, JsonType], body["storage"])
350
+
351
+ return ConfluencePage(
352
+ id=page_id,
353
+ space_key=space_key or self.space_key,
354
+ title=typing.cast(str, data["title"]),
355
+ version=typing.cast(int, version["number"]),
356
+ content=typing.cast(str, storage["value"]),
357
+ )
358
+
359
+ def get_page_ancestors(
360
+ self, page_id: str, *, space_key: Optional[str] = None
361
+ ) -> Dict[str, str]:
362
+ """
363
+ Retrieve Confluence wiki page ancestors.
364
+
365
+ :param page_id: The Confluence page ID.
366
+ :param space_key: The Confluence space key (unless the default space is to be used).
367
+ :returns: Dictionary of ancestor page ID to title, with topmost ancestor first.
368
+ """
369
+
370
+ path = f"/content/{page_id}"
371
+ query = {
372
+ "spaceKey": space_key or self.space_key,
373
+ "expand": "ancestors",
374
+ }
375
+ data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
376
+ ancestors = typing.cast(List[JsonType], data["ancestors"])
377
+
378
+ # from the JSON array of ancestors, extract the "id" and "title"
379
+ results: Dict[str, str] = {}
380
+ for node in ancestors:
381
+ ancestor = typing.cast(Dict[str, JsonType], node)
382
+ id = typing.cast(str, ancestor["id"])
383
+ title = typing.cast(str, ancestor["title"])
384
+ results[id] = title
385
+ return results
386
+
387
+ def get_page_version(
388
+ self,
389
+ page_id: str,
390
+ *,
391
+ space_key: Optional[str] = None,
392
+ ) -> int:
393
+ """
394
+ Retrieve a Confluence wiki page version.
395
+
396
+ :param page_id: The Confluence page ID.
397
+ :param space_key: The Confluence space key (unless the default space is to be used).
398
+ :returns: Confluence page version.
399
+ """
400
+
401
+ path = f"/content/{page_id}"
402
+ query = {
403
+ "spaceKey": space_key or self.space_key,
404
+ "expand": "version",
405
+ }
406
+ data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
407
+ version = typing.cast(Dict[str, JsonType], data["version"])
408
+ return typing.cast(int, version["number"])
409
+
410
+ def update_page(
411
+ self,
412
+ page_id: str,
413
+ new_content: str,
414
+ *,
415
+ space_key: Optional[str] = None,
416
+ ) -> None:
417
+ page = self.get_page(page_id, space_key=space_key)
418
+
419
+ try:
420
+ old_content = sanitize_confluence(page.content)
421
+ if old_content == new_content:
422
+ LOGGER.info("Up-to-date page: %s", page_id)
423
+ return
424
+ except ParseError as exc:
425
+ LOGGER.warning(exc)
426
+
427
+ path = f"/content/{page_id}"
428
+ data = {
429
+ "id": page_id,
430
+ "type": "page",
431
+ "title": page.title, # title needs to be unique within a space so the original title is maintained
432
+ "space": {"key": space_key or self.space_key},
433
+ "body": {"storage": {"value": new_content, "representation": "storage"}},
434
+ "version": {"minorEdit": True, "number": page.version + 1},
435
+ }
436
+
437
+ LOGGER.info("Updating page: %s", page_id)
438
+ self._save(path, data)
439
+
440
+ def create_page(
441
+ self,
442
+ parent_page_id: str,
443
+ title: str,
444
+ new_content: str,
445
+ *,
446
+ space_key: Optional[str] = None,
447
+ ) -> ConfluencePage:
448
+ path = "/content/"
449
+ query = {
450
+ "type": "page",
451
+ "title": title,
452
+ "space": {"key": space_key or self.space_key},
453
+ "body": {"storage": {"value": new_content, "representation": "storage"}},
454
+ "ancestors": [{"type": "page", "id": parent_page_id}],
455
+ }
456
+
457
+ LOGGER.info("Creating page: %s", title)
458
+
459
+ url = self._build_url(path)
460
+ response = self.session.post(
461
+ url,
462
+ data=json.dumps(query),
463
+ headers={"Content-Type": "application/json"},
464
+ )
465
+ response.raise_for_status()
466
+
467
+ data = typing.cast(Dict[str, JsonType], response.json())
468
+ version = typing.cast(Dict[str, JsonType], data["version"])
469
+ body = typing.cast(Dict[str, JsonType], data["body"])
470
+ storage = typing.cast(Dict[str, JsonType], body["storage"])
471
+
472
+ return ConfluencePage(
473
+ id=typing.cast(str, data["id"]),
474
+ space_key=space_key or self.space_key,
475
+ title=typing.cast(str, data["title"]),
476
+ version=typing.cast(int, version["number"]),
477
+ content=typing.cast(str, storage["value"]),
478
+ )
479
+
480
+ def page_exists(
481
+ self, title: str, *, space_key: Optional[str] = None
482
+ ) -> Optional[str]:
483
+ path = "/content"
484
+ query = {
485
+ "type": "page",
486
+ "title": title,
487
+ "spaceKey": space_key or self.space_key,
488
+ }
489
+
490
+ LOGGER.info("Checking if page exists with title: %s", title)
491
+
492
+ url = self._build_url(path)
493
+ response = self.session.get(
494
+ url, params=query, headers={"Content-Type": "application/json"}
495
+ )
496
+ response.raise_for_status()
497
+
498
+ data = typing.cast(Dict[str, JsonType], response.json())
499
+ results = typing.cast(List, data["results"])
500
+
501
+ if len(results) == 1:
502
+ page_info = typing.cast(Dict[str, JsonType], results[0])
503
+ return typing.cast(str, page_info["id"])
504
+ else:
505
+ return None
506
+
507
+ def get_or_create_page(
508
+ self, title: str, parent_id: str, *, space_key: Optional[str] = None
509
+ ) -> ConfluencePage:
510
+ page_id = self.page_exists(title)
511
+
512
+ if page_id is not None:
513
+ LOGGER.debug("Retrieving existing page: %d", page_id)
514
+ return self.get_page(page_id)
515
+ else:
516
+ LOGGER.debug("Creating new page with title: %s", title)
517
+ return self.create_page(parent_id, title, "", space_key=space_key)