forked from cloudant/python-cloudant
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdocument.py
More file actions
493 lines (430 loc) · 17.7 KB
/
Copy pathdocument.py
File metadata and controls
493 lines (430 loc) · 17.7 KB
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
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
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
#!/usr/bin/env python
# Copyright (c) 2015 IBM. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
API module/class for interacting with a document in a database.
"""
import json
import posixpath
import requests
from requests.exceptions import HTTPError
from ._2to3 import url_quote, url_quote_plus
from .error import CloudantDocumentException
class Document(dict):
"""
Encapsulates a JSON document. A Document object is instantiated with a
reference to a database and used to manipulate document content
in a CouchDB or Cloudant database instance.
In addition to basic CRUD style operations, a Document object also provides
a convenient context manager. This context manager removes having to
explicitly :func:`~cloudant.document.Document.fetch` the document from the
remote database before commencing work on it as well as explicitly having
to :func:`~cloudant.document.Document.save` the document once work is
complete.
For example:
.. code-block:: python
# Upon entry into the document context, fetches the document from the
# remote database, if it exists. Upon exit from the context, saves the
# document to the remote database with changes made within the context.
with Document(database, 'julia006') as document:
# The document is fetched from the remote database
# Changes are made locally
document['name'] = 'Julia'
document['age'] = 6
# The document is saved to the remote database
:param database: A database instance used by the Document. Can be
either a ``CouchDatabase`` or ``CloudantDatabase`` instance.
:param str document_id: Optional document id used to identify the document.
"""
def __init__(self, database, document_id=None):
super(Document, self).__init__()
self._client = database.client
self._database = database
self._database_host = self._client.server_url
self._database_name = database.database_name
self._document_id = document_id
if self._document_id is not None:
self['_id'] = self._document_id
self.encoder = self._client.encoder
@property
def r_session(self):
"""
Returns the database instance ``r_session`` used by the document.
:returns: Client ``r_session``
"""
return self._client.r_session
@property
def document_url(self):
"""
Constructs and returns the document URL.
:returns: Document URL
"""
if self._document_id is None:
return None
# handle design document url
if self._document_id.startswith('_design/'):
return posixpath.join(
self._database_host,
url_quote_plus(self._database_name),
'_design',
url_quote(self._document_id[8:], safe='')
)
# handle document url
return posixpath.join(
self._database_host,
url_quote_plus(self._database_name),
url_quote(self._document_id, safe='')
)
def exists(self):
"""
Retrieves whether the document exists in the remote database or not.
:returns: True if the document exists in the remote database,
otherwise False
"""
if self._document_id is None:
return False
else:
resp = self.r_session.head(self.document_url)
if resp.status_code not in [200, 404]:
resp.raise_for_status()
return resp.status_code == 200
def json(self):
"""
Retrieves the JSON string representation of the current locally cached
document object, encoded by the encoder specified in the associated
client object.
:returns: Encoded JSON string containing the document data
"""
return json.dumps(dict(self), cls=self.encoder)
def create(self):
"""
Creates the current document in the remote database and if successful,
updates the locally cached Document object with the ``_id``
and ``_rev`` returned as part of the successful response.
"""
if self._document_id is not None:
self['_id'] = self._document_id
# Ensure that an existing document will not be "updated"
doc = dict(self)
if doc.get('_rev') is not None:
doc.__delitem__('_rev')
headers = {'Content-Type': 'application/json'}
resp = self.r_session.post(
self._database.database_url,
headers=headers,
data=json.dumps(doc, cls=self.encoder)
)
resp.raise_for_status()
data = resp.json()
self._document_id = data['id']
super(Document, self).__setitem__('_id', data['id'])
super(Document, self).__setitem__('_rev', data['rev'])
return
def fetch(self):
"""
Retrieves the content of the current document from the remote database
and populates the locally cached Document object with that content.
A call to fetch will overwrite any dictionary content currently in
the locally cached Document object.
"""
if self.document_url is None:
raise CloudantDocumentException(101)
resp = self.r_session.get(self.document_url)
resp.raise_for_status()
self.clear()
self.update(resp.json())
def save(self):
"""
Saves changes made to the locally cached Document object's data
structures to the remote database. If the document does not exist
remotely then it is created in the remote database. If the object
does exist remotely then the document is updated remotely. In either
case the locally cached Document object is also updated accordingly
based on the successful response of the operation.
"""
headers = {}
headers.setdefault('Content-Type', 'application/json')
if not self.exists():
self.create()
return
put_resp = self.r_session.put(
self.document_url,
data=self.json(),
headers=headers
)
put_resp.raise_for_status()
data = put_resp.json()
super(Document, self).__setitem__('_rev', data['rev'])
return
# Update Actions
# These are handy functions to use with update_field below.
@staticmethod
def list_field_append(doc, field, value):
"""
Appends a value to a list field in a locally cached Document object.
If a field does not exist it will be created first.
:param Document doc: Locally cached Document object that can be a
Document, DesignDocument or dict.
:param str field: Name of the field list to append to.
:param value: Value to append to the field list.
"""
if doc.get(field) is None:
doc[field] = []
if not isinstance(doc[field], list):
raise CloudantDocumentException(102, field)
if value is not None:
doc[field].append(value)
@staticmethod
def list_field_remove(doc, field, value):
"""
Removes a value from a list field in a locally cached Document object.
:param Document doc: Locally cached Document object that can be a
Document, DesignDocument or dict.
:param str field: Name of the field list to remove from.
:param value: Value to remove from the field list.
"""
if not isinstance(doc[field], list):
raise CloudantDocumentException(102, field)
doc[field].remove(value)
@staticmethod
def field_set(doc, field, value):
"""
Sets or replaces a value for a field in a locally cached Document
object. To remove the field set the ``value`` to None.
:param Document doc: Locally cached Document object that can be a
Document, DesignDocument or dict.
:param str field: Name of the field to set.
:param value: Value to set the field to.
"""
if value is None:
doc.__delitem__(field)
else:
doc[field] = value
def _update_field(self, action, field, value, max_tries, tries=0):
"""
Private update_field method. Wrapped by Document.update_field.
Tracks a "tries" var to help limit recursion.
"""
# Refresh our view of the document.
self.fetch()
# Update the field.
action(self, field, value)
# Attempt to save, retrying conflicts up to max_tries.
try:
self.save()
except requests.HTTPError as ex:
if tries < max_tries and ex.response.status_code == 409:
return self._update_field(
action, field, value, max_tries, tries=tries+1)
raise
def update_field(self, action, field, value, max_tries=10):
"""
Updates a field in the remote document. If a conflict exists,
the document is re-fetched from the remote database and the update
is retried. This is performed up to ``max_tries`` number of times.
Use this method when you want to update a single field in a document,
and don't want to risk clobbering other people's changes to
the document in other fields, but also don't want the caller
to implement logic to deal with conflicts.
For example:
.. code-block:: python
# Append the string 'foo' to the 'words' list of Document doc.
doc.update_field(
action=doc.list_field_append,
field='words',
value='foo'
)
:param callable action: A routine that takes a Document object,
a field name, and a value. The routine should attempt to
update a field in the locally cached Document object with the
given value, using whatever logic is appropriate.
Valid actions are
:func:`~cloudant.document.Document.list_field_append`,
:func:`~cloudant.document.Document.list_field_remove`,
:func:`~cloudant.document.Document.field_set`
:param str field: Name of the field to update
:param value: Value to update the field with
:param int max_tries: In the case of a conflict, the number of retries
to attempt
"""
self._update_field(action, field, value, max_tries)
def delete(self):
"""
Removes the document from the remote database and clears the content of
the locally cached Document object with the exception of the ``_id``
field. In order to successfully remove a document from the remote
database, a ``_rev`` value must exist in the locally cached Document
object.
"""
if not self.get("_rev"):
raise CloudantDocumentException(103)
del_resp = self.r_session.delete(
self.document_url,
params={"rev": self["_rev"]},
)
del_resp.raise_for_status()
self.clear()
self.__setitem__('_id', self._document_id)
return
def __enter__(self):
"""
Supports context like editing of document fields. Handles context
entry logic. Executes a Document.fetch() upon entry.
"""
# We don't want to raise an exception if the document is not found
# because upon __exit__ the save() call will create the document
# if necessary.
try:
self.fetch()
except HTTPError as error:
if error.response.status_code != 404:
raise
return self
def __exit__(self, *args):
"""
Support context like editing of document fields. Handles context exit
logic. Executes a Document.save() upon exit.
"""
self.save()
def __setitem__(self, key, value):
"""
Sets the _document_id when setting the '_id' field.
The _document_id is used to construct the document url.
"""
if key == '_id':
self._document_id = value
super(Document, self).__setitem__(key, value)
def __delitem__(self, key):
"""
Sets the _document_id to None when deleting the '_id' field.
"""
if key == '_id':
self._document_id = None
super(Document, self).__delitem__(key)
def get_attachment(
self,
attachment,
headers=None,
write_to=None,
attachment_type=None):
"""
Retrieves a document's attachment and optionally writes it to a file.
If the content_type of the attachment is 'application/json' then the
data returned will be in JSON format otherwise the response content will
be returned as text or binary.
:param str attachment: Attachment file name used to identify the
attachment.
:param dict headers: Optional, additional headers to be sent
with request.
:param file write_to: Optional file handler to write the attachment to.
The write_to file must be opened for writing prior to including it
as an argument for this method.
:param str attachment_type: Optional setting to define how to handle the
attachment when returning its contents from this method. Valid
values are ``'text'``, ``'json'``, and ``'binary'`` If
omitted then the returned content will be based on the
response Content-Type.
:returns: The attachment content
"""
# need latest rev
self.fetch()
attachment_url = posixpath.join(self.document_url, attachment)
if headers is None:
headers = {'If-Match': self['_rev']}
else:
headers['If-Match'] = self['_rev']
resp = self.r_session.get(attachment_url, headers=headers)
resp.raise_for_status()
if attachment_type is None:
if resp.headers['Content-Type'].startswith('text/'):
attachment_type = 'text'
elif resp.headers['Content-Type'] == 'application/json':
attachment_type = 'json'
else:
attachment_type = 'binary'
if write_to is not None:
if attachment_type == 'text' or attachment_type == 'json':
write_to.write(resp.text)
else:
write_to.write(resp.content)
if attachment_type == 'text':
return resp.text
elif attachment_type == 'json':
return resp.json()
return resp.content
def delete_attachment(self, attachment, headers=None):
"""
Removes an attachment from a remote document and refreshes the locally
cached document object.
:param str attachment: Attachment file name used to identify the
attachment.
:param dict headers: Optional, additional headers to be sent
with request.
:returns: Attachment deletion status in JSON format
"""
# need latest rev
self.fetch()
attachment_url = posixpath.join(self.document_url, attachment)
if headers is None:
headers = {'If-Match': self['_rev']}
else:
headers['If-Match'] = self['_rev']
resp = self.r_session.delete(
attachment_url,
headers=headers
)
resp.raise_for_status()
super(Document, self).__setitem__('_rev', resp.json()['rev'])
# Execute logic only if attachment metadata exists locally
if self.get('_attachments'):
# Remove the attachment metadata for the specified attachment
if self['_attachments'].get(attachment):
self['_attachments'].__delitem__(attachment)
# Remove empty attachment metadata from the local dictionary
if not self['_attachments']:
super(Document, self).__delitem__('_attachments')
return resp.json()
def put_attachment(self, attachment, content_type, data, headers=None):
"""
Adds a new attachment, or updates an existing attachment, to
the remote document and refreshes the locally cached
Document object accordingly.
:param attachment: Attachment file name used to identify the
attachment.
:param content_type: The http ``Content-Type`` of the attachment used
as an additional header.
:param data: Attachment data defining the attachment content.
:param headers: Optional, additional headers to be sent
with request.
:returns: Attachment addition/update status in JSON format
"""
# need latest rev
self.fetch()
attachment_url = posixpath.join(self.document_url, attachment)
if headers is None:
headers = {
'If-Match': self['_rev'],
'Content-Type': content_type
}
else:
headers['If-Match'] = self['_rev']
headers['Content-Type'] = content_type
resp = self.r_session.put(
attachment_url,
data=data,
headers=headers
)
resp.raise_for_status()
self.fetch()
return resp.json()