Skip to content

Commit bbcf2b9

Browse files
committed
DEVEXP-496 Can now write a batch of documents
I'm going to do docs in a separate PR, after all the metadata stuff is supported too. There are only a lot of new files here because I needed them in the test app to test out things like optimistic locking and temporal writes.
1 parent d3fa6b7 commit bbcf2b9

File tree

13 files changed

+393
-4
lines changed

13 files changed

+393
-4
lines changed

marklogic/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import requests
22
from marklogic.cloud_auth import MarkLogicCloudAuth
3+
from marklogic.documents import DocumentManager
34
from requests.auth import HTTPDigestAuth
45
from urllib.parse import urljoin
56

@@ -63,3 +64,9 @@ def prepare_request(self, request, *args, **kwargs):
6364
"""
6465
request.url = urljoin(self.base_url, request.url)
6566
return super(Client, self).prepare_request(request, *args, **kwargs)
67+
68+
@property
69+
def documents(self):
70+
if not hasattr(self, "_documents"):
71+
self._documents = DocumentManager(self)
72+
return self._documents

marklogic/documents.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import json
2+
from requests import Session
3+
from urllib3.fields import RequestField
4+
from urllib3.filepost import encode_multipart_formdata
5+
6+
7+
class Document:
8+
"""
9+
:param uri: the URI of the document; can be None when relying on MarkLogic to
10+
generate a URI.
11+
:param content: the content of the document.
12+
:param content_type: the MIME type of the document; use when MarkLogic cannot
13+
determine the MIME type based on the URI.
14+
:param extension: specifies a suffix for a URI generated by MarkLogic.
15+
:param directory: specifies a prefix for a URI generated by MarkLogic.
16+
:param repair: for an XML document, the level of XML repair to perform; can be
17+
"full" or "none", with "none" being the default.
18+
:param version_id: affects updates when optimistic locking is enabled; see
19+
https://docs.marklogic.com/REST/POST/v1/documents for more information.
20+
:param temporal_document: the logical document URI for a document written to a
21+
temporal collection; requires that a "temporal-collection" parameter be included in
22+
the request.
23+
"""
24+
25+
def __init__(
26+
self,
27+
uri: str,
28+
content,
29+
content_type: str = None,
30+
extension: str = None,
31+
directory: str = None,
32+
repair: str = None,
33+
extract: str = None,
34+
version_id: str = None,
35+
temporal_document: str = None,
36+
):
37+
self.uri = uri
38+
self.content = content
39+
self.content_type = content_type
40+
self.extension = extension
41+
self.directory = directory
42+
self.repair = repair
43+
self.extract = extract
44+
self.version_id = version_id
45+
self.temporal_document = temporal_document
46+
47+
def to_request_field(self) -> RequestField:
48+
data = self.content
49+
if type(data) is dict:
50+
data = json.dumps(data)
51+
field = RequestField(name=self.uri, data=data, filename=self.uri)
52+
field.make_multipart(
53+
content_disposition=self._make_disposition(),
54+
content_type=self.content_type,
55+
)
56+
return field
57+
58+
def _make_disposition(self) -> str:
59+
disposition = "attachment"
60+
61+
if not self.uri:
62+
disposition = "inline"
63+
if self.extension:
64+
disposition = f"{disposition};extension={self.extension}"
65+
if self.directory:
66+
disposition = f"{disposition};directory={self.directory}"
67+
68+
if self.repair:
69+
disposition = f"{disposition};repair={self.repair}"
70+
71+
if self.extract:
72+
disposition = f"{disposition};extract={self.extract}"
73+
74+
if self.version_id:
75+
disposition = f"{disposition};versionId={self.version_id}"
76+
77+
if self.temporal_document:
78+
disposition = f"{disposition};temporal-document={self.temporal_document}"
79+
80+
return disposition
81+
82+
83+
class DocumentManager:
84+
def __init__(self, session: Session):
85+
self._session = session
86+
87+
def write(self, documents: list[Document], **kwargs):
88+
fields = [self._make_default_metadata_field()]
89+
for doc in documents:
90+
fields.append(doc.to_request_field())
91+
92+
data, content_type = encode_multipart_formdata(fields)
93+
94+
headers = kwargs.pop("headers", {})
95+
headers["Content-Type"] = "".join(
96+
("multipart/mixed",) + content_type.partition(";")[1:]
97+
)
98+
if not headers.get("Accept"):
99+
headers["Accept"] = "application/json"
100+
101+
return self._session.post("/v1/documents", data=data, headers=headers, **kwargs)
102+
103+
def _make_default_metadata_field(self):
104+
"""
105+
Temporary method to ensure the test user can see written documents. Will be
106+
removed when this feature is implemented for real.
107+
"""
108+
metadata_field = RequestField(
109+
name="request-metadata",
110+
data=json.dumps(
111+
{
112+
"permissions": [
113+
{
114+
"role-name": "python-tester",
115+
"capabilities": ["read", "update"],
116+
}
117+
]
118+
}
119+
),
120+
)
121+
metadata_field.make_multipart(
122+
content_disposition="inline; category=metadata",
123+
content_type="application/json",
124+
)
125+
return metadata_field

test-app/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
.gradle
22
gradle-local.properties
3+
build
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"role-name": "python-tester",
3+
"role": [
4+
"rest-extension-user"
5+
],
6+
"privilege": [
7+
{
8+
"privilege-name": "rest-reader",
9+
"action": "http://marklogic.com/xdmp/privileges/rest-reader",
10+
"kind": "execute"
11+
},
12+
{
13+
"privilege-name": "rest-writer",
14+
"action": "http://marklogic.com/xdmp/privileges/rest-writer",
15+
"kind": "execute"
16+
},
17+
{
18+
"privilege-name": "xdbc:eval",
19+
"action": "http://marklogic.com/xdmp/privileges/xdbc-eval",
20+
"kind": "execute"
21+
}
22+
]
23+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"user-name": "python-test-admin",
3+
"password": "password",
4+
"role": [
5+
"admin"
6+
]
7+
}

test-app/src/main/ml-config/security/users/python-test-user.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
"user-name": "python-test-user",
33
"password": "password",
44
"role": [
5-
"rest-evaluator",
6-
"rest-reader",
7-
"rest-writer",
5+
"python-tester",
86
"qconsole-user"
97
]
108
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"axis-name": "system",
3+
"axis-start": {
4+
"element-reference": {
5+
"namespace-uri": "",
6+
"localname": "systemStart"
7+
}
8+
},
9+
"axis-end": {
10+
"element-reference": {
11+
"namespace-uri": "",
12+
"localname": "systemEnd"
13+
}
14+
}
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"axis-name": "valid",
3+
"axis-start": {
4+
"element-reference": {
5+
"namespace-uri": "",
6+
"localname": "validStart"
7+
}
8+
},
9+
"axis-end": {
10+
"element-reference": {
11+
"namespace-uri": "",
12+
"localname": "validEnd"
13+
}
14+
}
15+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"collection-name": "temporal-collection",
3+
"system-axis": "system",
4+
"valid-axis": "valid",
5+
"option": [
6+
"updates-admin-override"
7+
]
8+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
*=rest-reader,read,rest-writer,update
1+
*=python-tester,read,python-tester,update

0 commit comments

Comments
 (0)