markdown-to-confluence 0.1.11__py3-none-any.whl → 0.1.13__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,458 +1,459 @@
1
- import json
2
- import logging
3
- import mimetypes
4
- import os
5
- import os.path
6
- import sys
7
- import typing
8
- from contextlib import contextmanager
9
- from dataclasses import dataclass
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
- self.session = ConfluenceSession(
102
- session,
103
- self.properties.domain,
104
- self.properties.base_path,
105
- self.properties.space_key,
106
- )
107
- return self.session
108
-
109
- def __exit__(
110
- self,
111
- exc_type: Optional[Type[BaseException]],
112
- exc_val: Optional[BaseException],
113
- exc_tb: Optional[TracebackType],
114
- ) -> None:
115
- if self.session is not None:
116
- self.session.close()
117
- self.session = None
118
-
119
-
120
- class ConfluenceSession:
121
- session: requests.Session
122
- domain: str
123
- base_path: str
124
- space_key: str
125
-
126
- def __init__(
127
- self, session: requests.Session, domain: str, base_path: str, space_key: str
128
- ) -> None:
129
- self.session = session
130
- self.domain = domain
131
- self.base_path = base_path
132
- self.space_key = space_key
133
-
134
- def close(self) -> None:
135
- self.session.close()
136
-
137
- @contextmanager
138
- def switch_space(self, new_space_key: str) -> Generator[None, None, None]:
139
- old_space_key = self.space_key
140
- self.space_key = new_space_key
141
- try:
142
- yield
143
- finally:
144
- self.space_key = old_space_key
145
-
146
- def _build_url(self, path: str, query: Optional[Dict[str, str]] = None) -> str:
147
- base_url = f"https://{self.domain}{self.base_path}rest/api{path}"
148
- return build_url(base_url, query)
149
-
150
- def _invoke(self, path: str, query: Dict[str, str]) -> JsonType:
151
- url = self._build_url(path, query)
152
- response = self.session.get(url)
153
- response.raise_for_status()
154
- return response.json()
155
-
156
- def _save(self, path: str, data: dict) -> None:
157
- url = self._build_url(path)
158
- response = self.session.put(
159
- url,
160
- data=json.dumps(data),
161
- headers={"Content-Type": "application/json"},
162
- )
163
- response.raise_for_status()
164
-
165
- def get_attachment_by_name(
166
- self, page_id: str, filename: str, *, space_key: Optional[str] = None
167
- ) -> ConfluenceAttachment:
168
- path = f"/content/{page_id}/child/attachment"
169
- query = {"spaceKey": space_key or self.space_key, "filename": filename}
170
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
171
-
172
- results = typing.cast(List[JsonType], data["results"])
173
- if len(results) != 1:
174
- raise ConfluenceError(f"no such attachment on page {page_id}: {filename}")
175
- result = typing.cast(Dict[str, JsonType], results[0])
176
-
177
- id = typing.cast(str, result["id"])
178
- extensions = typing.cast(Dict[str, JsonType], result["extensions"])
179
- media_type = typing.cast(str, extensions["mediaType"])
180
- file_size = typing.cast(int, extensions["fileSize"])
181
- comment = typing.cast(str, extensions["comment"])
182
- return ConfluenceAttachment(id, media_type, file_size, comment)
183
-
184
- def upload_attachment(
185
- self,
186
- page_id: str,
187
- attachment_path: str,
188
- attachment_name: str,
189
- comment: Optional[str] = None,
190
- *,
191
- space_key: Optional[str] = None,
192
- ) -> None:
193
- content_type = mimetypes.guess_type(attachment_path, strict=True)[0]
194
-
195
- if not os.path.isfile(attachment_path):
196
- raise ConfluenceError(f"file not found: {attachment_path}")
197
-
198
- try:
199
- attachment = self.get_attachment_by_name(
200
- page_id, attachment_name, space_key=space_key
201
- )
202
-
203
- if attachment.file_size == os.path.getsize(attachment_path):
204
- LOGGER.info("Up-to-date attachment: %s", attachment_name)
205
- return
206
-
207
- id = removeprefix(attachment.id, "att")
208
- path = f"/content/{page_id}/child/attachment/{id}/data"
209
-
210
- except ConfluenceError:
211
- path = f"/content/{page_id}/child/attachment"
212
-
213
- url = self._build_url(path)
214
-
215
- with open(attachment_path, "rb") as attachment_file:
216
- file_to_upload = {
217
- "comment": comment,
218
- "file": (
219
- attachment_name, # will truncate path component
220
- attachment_file,
221
- content_type,
222
- {"Expires": "0"},
223
- ),
224
- }
225
- LOGGER.info("Uploading attachment: %s", attachment_name)
226
- response = self.session.post(
227
- url,
228
- files=file_to_upload, # type: ignore
229
- headers={"X-Atlassian-Token": "no-check"},
230
- )
231
-
232
- response.raise_for_status()
233
- data = response.json()
234
-
235
- if "results" in data:
236
- result = data["results"][0]
237
- else:
238
- result = data
239
-
240
- attachment_id = result["id"]
241
- version = result["version"]["number"] + 1
242
-
243
- # ensure path component is retained in attachment name
244
- self._update_attachment(
245
- page_id, attachment_id, version, attachment_name, space_key=space_key
246
- )
247
-
248
- def _update_attachment(
249
- self,
250
- page_id: str,
251
- attachment_id: str,
252
- version: int,
253
- attachment_title: str,
254
- *,
255
- space_key: Optional[str] = None,
256
- ) -> None:
257
- id = removeprefix(attachment_id, "att")
258
- path = f"/content/{page_id}/child/attachment/{id}"
259
- data = {
260
- "id": attachment_id,
261
- "type": "attachment",
262
- "status": "current",
263
- "title": attachment_title,
264
- "space": {"key": space_key or self.space_key},
265
- "version": {"minorEdit": True, "number": version},
266
- }
267
-
268
- LOGGER.info("Updating attachment: %s", attachment_id)
269
- self._save(path, data)
270
-
271
- def get_page_id_by_title(
272
- self,
273
- title: str,
274
- *,
275
- space_key: Optional[str] = None,
276
- ) -> str:
277
- """
278
- Look up a Confluence wiki page ID by title.
279
-
280
- :param title: The page title.
281
- :param space_key: The Confluence space key (unless the default space is to be used).
282
- :returns: Confluence page ID.
283
- """
284
-
285
- LOGGER.info("Looking up page with title: %s", title)
286
- path = "/content"
287
- query = {"title": title, "spaceKey": space_key or self.space_key}
288
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
289
-
290
- results = typing.cast(List[JsonType], data["results"])
291
- if len(results) != 1:
292
- raise ConfluenceError(f"page not found with title: {title}")
293
-
294
- result = typing.cast(Dict[str, JsonType], results[0])
295
- id = typing.cast(str, result["id"])
296
- return id
297
-
298
- def get_page(
299
- self, page_id: str, *, space_key: Optional[str] = None
300
- ) -> ConfluencePage:
301
- """
302
- Retrieve Confluence wiki page details.
303
-
304
- :param page_id: The Confluence page ID.
305
- :param space_key: The Confluence space key (unless the default space is to be used).
306
- :returns: Confluence page info.
307
- """
308
-
309
- path = f"/content/{page_id}"
310
- query = {
311
- "spaceKey": space_key or self.space_key,
312
- "expand": "body.storage,version",
313
- }
314
-
315
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
316
- version = typing.cast(Dict[str, JsonType], data["version"])
317
- body = typing.cast(Dict[str, JsonType], data["body"])
318
- storage = typing.cast(Dict[str, JsonType], body["storage"])
319
-
320
- return ConfluencePage(
321
- id=page_id,
322
- space_key=space_key or self.space_key,
323
- title=typing.cast(str, data["title"]),
324
- version=typing.cast(int, version["number"]),
325
- content=typing.cast(str, storage["value"]),
326
- )
327
-
328
- def get_page_version(
329
- self,
330
- page_id: str,
331
- *,
332
- space_key: Optional[str] = None,
333
- ) -> int:
334
- """
335
- Retrieve a Confluence wiki page version.
336
-
337
- :param page_id: The Confluence page ID.
338
- :param space_key: The Confluence space key (unless the default space is to be used).
339
- :returns: Confluence page version.
340
- """
341
-
342
- path = f"/content/{page_id}"
343
- query = {
344
- "spaceKey": space_key or self.space_key,
345
- "expand": "version",
346
- }
347
- data = typing.cast(Dict[str, JsonType], self._invoke(path, query))
348
- version = typing.cast(Dict[str, JsonType], data["version"])
349
- return typing.cast(int, version["number"])
350
-
351
- def update_page(
352
- self,
353
- page_id: str,
354
- new_content: str,
355
- *,
356
- space_key: Optional[str] = None,
357
- ) -> None:
358
- page = self.get_page(page_id, space_key=space_key)
359
-
360
- try:
361
- old_content = sanitize_confluence(page.content)
362
- if old_content == new_content:
363
- LOGGER.info("Up-to-date page: %s", page_id)
364
- return
365
- except ParseError as exc:
366
- LOGGER.warning(exc)
367
-
368
- path = f"/content/{page_id}"
369
- data = {
370
- "id": page_id,
371
- "type": "page",
372
- "title": page.title, # title needs to be unique within a space so the original title is maintained
373
- "space": {"key": space_key or self.space_key},
374
- "body": {"storage": {"value": new_content, "representation": "storage"}},
375
- "version": {"minorEdit": True, "number": page.version + 1},
376
- }
377
-
378
- LOGGER.info("Updating page: %s", page_id)
379
- self._save(path, data)
380
-
381
- def create_page(
382
- self,
383
- parent_page_id: str,
384
- title: str,
385
- new_content: str,
386
- *,
387
- space_key: Optional[str] = None,
388
- ) -> ConfluencePage:
389
- path = "/content/"
390
- query = {
391
- "type": "page",
392
- "title": title,
393
- "space": {"key": space_key or self.space_key},
394
- "body": {"storage": {"value": new_content, "representation": "storage"}},
395
- "ancestors": [{"type": "page", "id": parent_page_id}],
396
- }
397
-
398
- LOGGER.info("Creating page: %s", title)
399
-
400
- url = self._build_url(path)
401
- response = self.session.post(
402
- url,
403
- data=json.dumps(query),
404
- headers={"Content-Type": "application/json"},
405
- )
406
- response.raise_for_status()
407
-
408
- data = typing.cast(Dict[str, JsonType], response.json())
409
- version = typing.cast(Dict[str, JsonType], data["version"])
410
- body = typing.cast(Dict[str, JsonType], data["body"])
411
- storage = typing.cast(Dict[str, JsonType], body["storage"])
412
-
413
- return ConfluencePage(
414
- id=typing.cast(str, data["id"]),
415
- space_key=space_key or self.space_key,
416
- title=typing.cast(str, data["title"]),
417
- version=typing.cast(int, version["number"]),
418
- content=typing.cast(str, storage["value"]),
419
- )
420
-
421
- def page_exists(
422
- self, title: str, *, space_key: Optional[str] = None
423
- ) -> Optional[str]:
424
- path = "/content"
425
- query = {
426
- "type": "page",
427
- "title": title,
428
- "spaceKey": space_key or self.space_key,
429
- }
430
-
431
- LOGGER.info("Checking if page exists with title: %s", title)
432
-
433
- url = self._build_url(path)
434
- response = self.session.get(
435
- url, params=query, headers={"Content-Type": "application/json"}
436
- )
437
- response.raise_for_status()
438
-
439
- data = typing.cast(Dict[str, JsonType], response.json())
440
- results = typing.cast(List, data["results"])
441
-
442
- if len(results) == 1:
443
- page_info = typing.cast(Dict[str, JsonType], results[0])
444
- return typing.cast(str, page_info["id"])
445
- else:
446
- return None
447
-
448
- def get_or_create_page(
449
- self, title: str, parent_id: str, *, space_key: Optional[str] = None
450
- ) -> ConfluencePage:
451
- page_id = self.page_exists(title)
452
-
453
- if page_id is not None:
454
- LOGGER.debug("Retrieving existing page: %d", page_id)
455
- return self.get_page(page_id)
456
- else:
457
- LOGGER.debug("Creating new page with title: %s", title)
458
- return self.create_page(parent_id, title, "", space_key=space_key)
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)