markdown-to-confluence 0.1.12__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.
- {markdown_to_confluence-0.1.12.dist-info → markdown_to_confluence-0.1.13.dist-info}/LICENSE +21 -21
- {markdown_to_confluence-0.1.12.dist-info → markdown_to_confluence-0.1.13.dist-info}/METADATA +168 -168
- markdown_to_confluence-0.1.13.dist-info/RECORD +16 -0
- {markdown_to_confluence-0.1.12.dist-info → markdown_to_confluence-0.1.13.dist-info}/WHEEL +1 -1
- {markdown_to_confluence-0.1.12.dist-info → markdown_to_confluence-0.1.13.dist-info}/zip-safe +1 -1
- md2conf/__init__.py +13 -13
- md2conf/__main__.py +139 -140
- md2conf/api.py +459 -458
- md2conf/application.py +154 -154
- md2conf/converter.py +626 -624
- md2conf/entities.dtd +537 -537
- md2conf/processor.py +91 -91
- md2conf/properties.py +52 -52
- markdown_to_confluence-0.1.12.dist-info/RECORD +0 -16
- {markdown_to_confluence-0.1.12.dist-info → markdown_to_confluence-0.1.13.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.1.12.dist-info → markdown_to_confluence-0.1.13.dist-info}/top_level.txt +0 -0
md2conf/api.py
CHANGED
|
@@ -1,458 +1,459 @@
|
|
|
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 =
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
"
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
"
|
|
262
|
-
"
|
|
263
|
-
"
|
|
264
|
-
"
|
|
265
|
-
"
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
:param
|
|
282
|
-
:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
:param
|
|
306
|
-
:
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
"
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
:param
|
|
339
|
-
:
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
"
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
"
|
|
372
|
-
"
|
|
373
|
-
"
|
|
374
|
-
"
|
|
375
|
-
"
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
"
|
|
393
|
-
"
|
|
394
|
-
"
|
|
395
|
-
"
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
"
|
|
428
|
-
"
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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)
|