-
Notifications
You must be signed in to change notification settings - Fork 69
/
Copy pathconfluence.py
407 lines (345 loc) · 15 KB
/
confluence.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
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
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
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
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
import logging
import json
import requests
import os
from urllib.parse import urljoin
API_HEADERS = {
'User-Agent': 'markdown-to-confluence',
}
MULTIPART_HEADERS = {
'X-Atlassian-Token': 'nocheck' # Only need this for form uploads
}
DEFAULT_LABEL_PREFIX = 'global'
log = logging.getLogger(__name__)
class MissingArgumentException(Exception):
def __init__(self, arg):
self.message = 'Missing required argument: {}'.format(arg)
class Confluence():
def __init__(self,
api_url=None,
username=None,
password=None,
headers=None,
dry_run=False,
_client=None):
"""Creates a new Confluence API client.
Arguments:
api_url {str} -- The URL to the Confluence API root (e.g. https://wiki.example.com/api/rest/)
username {str} -- The Confluence service account username
password {str} -- The Confluence service account password
headers {list(str)} -- The HTTP headers which will be set for all requests
dry_run {str} -- The Confluence service account password
"""
# A common gotcha will be given a URL that doesn't end with a /, so we
# can account for this
if not api_url.endswith('/'):
api_url = api_url + '/'
self.api_url = api_url
self.username = username
self.password = password
self.dry_run = dry_run
if _client is None:
_client = requests.Session()
self._session = _client
self._session.auth = (self.username, self.password)
for header in headers or []:
try:
name, value = header.split(':', 1)
except ValueError:
name, value = header, ''
self._session.headers[name] = value.lstrip()
def _require_kwargs(self, kwargs):
"""Ensures that certain kwargs have been provided
Arguments:
kwargs {dict} -- The dict of required kwargs
"""
missing = []
for k, v in kwargs.items():
if not v:
missing.append(k)
if missing:
raise MissingArgumentException(missing)
def _request(self,
method='GET',
path='',
params=None,
files=None,
data=None,
headers=None):
url = urljoin(self.api_url, path)
if not headers:
headers = {}
headers.update(API_HEADERS)
if files:
headers.update(MULTIPART_HEADERS)
if data:
headers.update({'Content-Type': 'application/json'})
if self.dry_run:
log.info('''{method} {url}:
Params: {params}
Data: {data}
Files: {files}'''.format(method=method,
url=url,
params=params,
data=data,
files=files))
if method != 'GET':
return {}
response = self._session.request(method=method,
url=url,
params=params,
json=data,
headers=headers,
files=files)
if not response.ok:
log.info('''{method} {url}: {status_code} {reason}
Params: {params}
Data: {data}
Files: {files}'''.format(method=method,
url=url,
status_code=response.status_code,
reason=response.reason,
params=params,
data=data,
files=files))
print(response.content)
return response.content
# Will probably want to be more robust here, but this should work for now
return response.json()
def get(self, path=None, params=None):
return self._request(method='GET', path=path, params=params)
def post(self, path=None, params=None, data=None, files=None):
return self._request(method='POST',
path=path,
params=params,
data=data,
files=files)
def put(self, path=None, params=None, data=None):
return self._request(method='PUT', path=path, params=params, data=data)
def exists(self, space=None, slug=None, ancestor_id=None):
"""Returns the Confluence page that matches the provided metdata, if it exists.
Specifically, this leverages a Confluence Query Language (CQL) query
against the Confluence API. We assume that each slug is unique, at
least to the provided space/ancestor_id.
Arguments:
space {str} -- The Confluence space to use for filtering posts
slug {str} -- The page slug
ancestor_id {str} -- The ID of the parent page
"""
self._require_kwargs({'slug': slug})
cql_args = []
if slug:
cql_args.append('label={}'.format(slug))
if ancestor_id:
cql_args.append('ancestor={}'.format(ancestor_id))
if space:
cql_args.append('space={!r}'.format(space))
cql = ' and '.join(cql_args)
params = {'expand': 'version', 'cql': cql}
response = self.get(path='content/search', params=params)
if not response.get('size'):
return None
return response['results'][0]
def create_labels(self, page_id=None, slug=None, tags=[]):
"""Creates labels for the page to both assist with searching as well
as categorization.
We specifically require a slug to be provided, since this is how we
determine if a page exists. Any other tags are optional.
Keyword Arguments:
page_id {str} -- The ID of the existing page to which the label should apply
slug {str} -- The page slug to use as the label value
tags {list(str)} -- Any other tags to apply to the post
"""
labels = [{'prefix': DEFAULT_LABEL_PREFIX, 'name': slug}]
if tags is None:
tags = []
for tag in tags:
labels.append({'prefix': DEFAULT_LABEL_PREFIX, 'name': tag})
path = 'content/{page_id}/label'.format(page_id=page_id)
response = self.post(path=path, data=labels)
# Do a sanity check to ensure that the label for the slug appears in
# the results, since that's needed for us to find the page later.
labels = response.get('results', [])
if not labels:
log.error(
'No labels found after attempting to update page {}'.format(
slug))
log.error('Here\'s the response we got:\n{}'.format(response))
return labels
if not any(label['name'] == slug for label in labels):
log.error(
'Returned labels missing the expected slug: {}'.format(slug))
log.error('Here are the labels we got: {}'.format(labels))
return labels
log.info(
'Created the following labels for page {slug}: {labels}'.format(
slug=slug,
labels=', '.join(label['name'] for label in labels)))
return labels
def _create_page_payload(self,
content=None,
title=None,
ancestor_id=None,
attachments=None,
space=None,
type='page'):
return {
'type': type,
'title': title,
'space': {
'key': space
},
'body': {
'storage': {
'representation': 'storage',
'value': content
}
},
'ancestors': [{
'id': str(ancestor_id)
}]
}
def get_attachments(self, post_id):
"""Gets the attachments for a particular Confluence post
Arguments:
post_id {str} -- The Confluence post ID
"""
response = self.get("/content/{}/attachments".format(post_id))
return response.get('results', [])
def upload_attachment(self, post_id=None, attachment_path=None):
"""Uploads an attachment to a Confluence post
Keyword Arguments:
post_id {str} -- The Confluence post ID
attachment_path {str} -- The absolute path to the attachment
"""
path = 'content/{}/child/attachment'.format(post_id)
if not os.path.exists(attachment_path):
log.error('Attachment {} does not exist'.format(attachment_path))
return
log.info(
'Uploading attachment {attachment_path} to post {post_id}'.format(
attachment_path=attachment_path, post_id=post_id))
self.post(path=path,
params={'allowDuplicated': 'true'},
files={'file': open(attachment_path, 'rb')})
log.info('Uploaded {} to post ID {}'.format(attachment_path, post_id))
def get_author(self, username):
"""Returns the Confluence author profile for the provided username,
if it exists.
Arguments:
username {str} -- The Confluence username
"""
log.info('Looking up Confluence user key for {}'.format(username))
response = self.get(path='user', params={'username': username})
if not isinstance(response, dict) or not response.get('userKey'):
log.error('No Confluence user key for {}'.format(username))
return {}
return response
def create(self,
content=None,
space=None,
title=None,
ancestor_id=None,
slug=None,
tags=None,
attachments=None,
type='page'):
"""Creates a new page with the provided content.
If an ancestor_id is specified, then the page will be created as a
child of that ancestor page.
Keyword Arguments:
content {str} -- The HTML content to upload (required)
space {str} -- The Confluence space where the page should reside
title {str} -- The page title
ancestor_id {str} -- The ID of the parent Confluence page
slug {str} -- The unique slug for the page
tags {list(str)} -- The list of tags for the page
attachments {list(str)} -- List of absolute paths to attachments
which should uploaded.
"""
self._require_kwargs({
'content': content,
'slug': slug,
'title': title,
'space': space
})
page = self._create_page_payload(content='Upload in progress...',
title=title,
ancestor_id=ancestor_id,
space=space,
type=type)
response = self.post(path='content/', data=page)
page_id = response['id']
page_url = urljoin(self.api_url, response['_links']['webui'])
log.info('Page "{title}" (id {page_id}) created successfully at {url}'.
format(title=title, page_id=response.get('id'), url=page_url))
# Now that we have the page created, we can just treat the rest of the
# flow like an update.
return self.update(post_id=page_id,
content=content,
space=space,
title=title,
ancestor_id=ancestor_id,
slug=slug,
tags=tags,
page=response,
attachments=attachments)
def update(self,
post_id=None,
content=None,
space=None,
title=None,
ancestor_id=None,
slug=None,
tags=None,
attachments=None,
page=None,
type='page'):
"""Updates an existing page with new content.
This involves updating the attachments stored on Confluence, uploading
the page content, and finally updating the labels.
Keyword Arguments:
post_id {str} -- The ID of the Confluence post
content {str} -- The page represented in Confluence storage format
space {str} -- The Confluence space where the page should reside
title {str} -- The page title
ancestor_id {str} -- The ID of the parent Confluence page
slug {str} -- The unique slug for the page
tags {list(str)} -- The list of tags for the page
attachments {list(str)} -- The list of absolute file paths to any
attachments which should be uploaded
"""
self._require_kwargs({
'content': content,
'slug': slug,
'title': title,
'post_id': post_id,
'space': space
})
# Since the page already has an ID in Confluence, before updating our
# content which references certain attachments, we should make sure
# those attachments have been uploaded.
if attachments is None:
attachments = []
for attachment in attachments:
self.upload_attachment(post_id=post_id, attachment_path=attachment)
# Next, we can create the updated page structure
new_page = self._create_page_payload(content=content,
title=title,
ancestor_id=ancestor_id,
space=space,
type=type)
# Increment the version number, as required by the Confluence API
# https://docs.atlassian.com/ConfluenceServer/rest/7.1.0/#api/content-update
new_version = page['version']['number'] + 1
new_page['version'] = {'number': new_version}
# With the attachments uploaded, and our new page structure created,
# we can upload the final content up to Confluence.
path = 'content/{}'.format(page['id'])
response = self.put(path=path, data=new_page)
print(response)
page_url = urljoin(self.api_url, response['_links']['webui'])
# Finally, we can update the labels on the page
self.create_labels(page_id=post_id, slug=slug, tags=tags)
log.info('Page "{title}" (id {page_id}) updated successfully at {url}'.
format(title=title, page_id=post_id, url=page_url))