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
355 lines (292 loc) · 10.6 KB
/
Copy pathdocument.py
File metadata and controls
355 lines (292 loc) · 10.6 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
#!/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.
"""
_document_
API class for interacting with a document in a database
"""
import json
import posixpath
import urllib
import requests
from requests.exceptions import HTTPError
from .errors import CloudantException
class Document(dict):
"""
_Document_
JSON document object, used to manipulate the documents
in a couch or cloudant database. In addition to basic CRUD
style operations this provides a context to edit the document:
with document:
document['x'] = 'y'
:param database: CouchDatabase or CloudantDatabase instance
that the document belongs to
:param document_id: optional document ID
"""
def __init__(self, database, document_id=None):
super(Document, self).__init__()
self._cloudant_account = database.cloudant_account
self._cloudant_database = database
self._database_host = self._cloudant_account.cloudant_url
self._database_name = database.database_name
self.r_session = database.r_session
self._document_id = document_id
self._encoder = self._cloudant_account.encoder
document_url = property(
lambda x: posixpath.join(
x._database_host,
urllib.quote_plus(x._database_name),
x._document_id
)
)
def exists(self):
"""
:returns: True if the document exists in the database, otherwise False
"""
resp = self.r_session.get(self.document_url)
return resp.status_code == 200
def json(self):
"""
:returns: JSON string containing the document data, encoded
with the encoder specified in the owning account
"""
return json.dumps(dict(self), cls=self._encoder)
def create(self):
"""
_create_
Create this document on the database server,
update the _id and _rev fields with those of the newly
created document
"""
if self._document_id is not None:
self['_id'] = self._document_id
headers = {'Content-Type': 'application/json'}
resp = self.r_session.post(
self._cloudant_database.database_url,
headers=headers,
data=self.json()
)
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):
"""
_fetch_
Fetch the content of this document from the database and update
self with whatever it finds
"""
resp = self.r_session.get(self.document_url)
resp.raise_for_status()
self.update(resp.json())
def save(self):
"""
_save_
Save changes made to this objects data structures back to the
database document, essentially an update CRUD call but we
dont want to conflict with dict.update
"""
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 field_append(doc, field, value):
"""Append a value to a field in a doc."""
doc[field].append(value)
@staticmethod
def field_remove(doc, field, value):
"""Remove a value from a field in a doc."""
doc[field].remove(value)
@staticmethod
def field_replace(doc, field, value):
"""Replace a field in a doc with a value."""
doc[field] = value
def _update_field(self, action, field, value, max_tries, tries=0):
"""
Private update_field method. Wrapped by CloudantDocument.update.
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):
"""
_update_field_
Update a field in the document. If a conflict exists, re-fetch
the document, and retry the update.
Use this 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.
@param action callable: A routine that takes three arguments:
A doc, a field, and a value. The routine should attempt to
update a field in the doc with the given value, using
whatever logic is appropraite. See this class's
update_actions property for examples.
@param field str: the name of the field to update
@param value: the value to update the field with.
@param max_tries: in the case of a conflict, give up after this
number of retries.
For example, the following will append the string "foo" to the
"words" list in a Cloudant Document.
doc.update_field(
action=doc.field_append,
field="words",
value="foo"
)
"""
self._update_field(action, field, value, max_tries)
def delete(self):
"""
_delete_
Delete the document on the remote db.
"""
if not self.get("_rev"):
raise CloudantException(
u"Attempting to delete a doc with no _rev. Try running "
u".fetch first!"
)
del_resp = self.r_session.delete(
self.document_url,
params={"rev": self["_rev"]},
)
del_resp.raise_for_status()
return
def __enter__(self):
"""
support context like editing of document fields
"""
# 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):
self.save()
def get_attachment(
self,
attachment,
headers=None,
write_to=None,
attachment_type="json"):
"""
_get_attachment_
Retrieve a document's attachment
:param str attachment: the attachment file name
:param dict headers: Extra headers to be sent with request
:param str write_to: File handler to write the attachment to,
if None do not write. write_to file must be also be opened
for writing.
:param str attachment_type: Describes the data format of the attachment
'json' and 'binary' are currently the only expected values.
"""
attachment_url = posixpath.join(self.document_url, attachment)
# need latest rev
doc_resp = self.r_session.get(self.document_url)
doc_resp.raise_for_status()
doc_json = doc_resp.json()
if headers is None:
headers = {'If-Match': doc_json['_rev']}
else:
headers['If-Match'] = doc_json['_rev']
resp = self.r_session.get(
attachment_url,
headers=headers
)
resp.raise_for_status()
if write_to is not None:
write_to.write(resp.raw)
if attachment_type == 'json':
return resp.json()
return resp.content
def delete_attachment(self, attachment, headers=None):
"""
_delete_attachment_
Delete an attachment from a document
:param str attachment: the attachment file name
:param dict headers: Extra headers to be sent with request
"""
attachment_url = posixpath.join(self.document_url, attachment)
# need latest rev
doc_resp = self.r_session.get(self.document_url)
doc_resp.raise_for_status()
doc_json = doc_resp.json()
if headers is None:
headers = {'If-Match': doc_json['_rev']}
else:
headers['If-Match'] = doc_json['_rev']
resp = self.r_session.delete(
attachment_url,
headers=headers
)
resp.raise_for_status()
return resp.json()
def put_attachment(self, attachment, content_type, data, headers=None):
"""
_put_attachment_
Add a new attachment, or update existing, to
specified document
:param attachment: name of attachment to be added/updated
:param content_type: http 'Content-Type' of the attachment
:param data: attachment data
:param headers: headers to send with request
"""
attachment_url = posixpath.join(self.document_url, attachment)
# need latest rev
doc_resp = self.r_session.get(self.document_url)
doc_resp.raise_for_status()
doc_json = doc_resp.json()
if headers is None:
headers = {
'If-Match': doc_json['_rev'],
'Content-Type': content_type
}
else:
headers['If-Match'] = doc_json['_rev']
headers['Content-Type'] = content_type
resp = self.r_session.put(
attachment_url,
data=data,
headers=headers
)
resp.raise_for_status()
return resp.json()