Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions marklogic/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import requests
from marklogic.cloud_auth import MarkLogicCloudAuth
from marklogic.documents import DocumentManager
from requests.auth import HTTPDigestAuth
from urllib.parse import urljoin

Expand Down Expand Up @@ -63,3 +64,9 @@ def prepare_request(self, request, *args, **kwargs):
"""
request.url = urljoin(self.base_url, request.url)
return super(Client, self).prepare_request(request, *args, **kwargs)

@property
def documents(self):
if not hasattr(self, "_documents"):
self._documents = DocumentManager(self)
return self._documents
125 changes: 125 additions & 0 deletions marklogic/documents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import json
from requests import Session
from urllib3.fields import RequestField
from urllib3.filepost import encode_multipart_formdata


class Document:
"""
:param uri: the URI of the document; can be None when relying on MarkLogic to
generate a URI.
:param content: the content of the document.
:param content_type: the MIME type of the document; use when MarkLogic cannot
determine the MIME type based on the URI.
:param extension: specifies a suffix for a URI generated by MarkLogic.
:param directory: specifies a prefix for a URI generated by MarkLogic.
:param repair: for an XML document, the level of XML repair to perform; can be
"full" or "none", with "none" being the default.
:param version_id: affects updates when optimistic locking is enabled; see
https://docs.marklogic.com/REST/POST/v1/documents for more information.
:param temporal_document: the logical document URI for a document written to a
temporal collection; requires that a "temporal-collection" parameter be included in
the request.
"""

def __init__(
self,
uri: str,
content,
content_type: str = None,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thought on this design - we should be able to reuse this class for reading documents - i.e. getting back an array of Document objects. But the args starting with content_type are all specific to writing a set of documents, and they won't be populated when reading a document. We could capture that in the docstring for the constructor, but I think they're still potentially confusing in that they're only for writing a document.

An alternative would be to use **kwargs and clearly document what keyword args can be passed in based on what you plan to do with a Document. That might be worth changing to after doing the "Read documents" story.

extension: str = None,
directory: str = None,
repair: str = None,
extract: str = None,
version_id: str = None,
temporal_document: str = None,
):
self.uri = uri
self.content = content
self.content_type = content_type
self.extension = extension
self.directory = directory
self.repair = repair
self.extract = extract
self.version_id = version_id
self.temporal_document = temporal_document

def to_request_field(self) -> RequestField:
data = self.content
if type(data) is dict:
data = json.dumps(data)
field = RequestField(name=self.uri, data=data, filename=self.uri)
field.make_multipart(
content_disposition=self._make_disposition(),
content_type=self.content_type,
)
return field

def _make_disposition(self) -> str:
disposition = "attachment"

if not self.uri:
disposition = "inline"
if self.extension:
disposition = f"{disposition};extension={self.extension}"
if self.directory:
disposition = f"{disposition};directory={self.directory}"

if self.repair:
disposition = f"{disposition};repair={self.repair}"

if self.extract:
disposition = f"{disposition};extract={self.extract}"

if self.version_id:
disposition = f"{disposition};versionId={self.version_id}"

if self.temporal_document:
disposition = f"{disposition};temporal-document={self.temporal_document}"

return disposition


class DocumentManager:
def __init__(self, session: Session):
self._session = session

def write(self, documents: list[Document], **kwargs):
fields = [self._make_default_metadata_field()]
for doc in documents:
fields.append(doc.to_request_field())

data, content_type = encode_multipart_formdata(fields)

headers = kwargs.pop("headers", {})
headers["Content-Type"] = "".join(
("multipart/mixed",) + content_type.partition(";")[1:]
)
if not headers.get("Accept"):
headers["Accept"] = "application/json"

return self._session.post("/v1/documents", data=data, headers=headers, **kwargs)

def _make_default_metadata_field(self):
"""
Temporary method to ensure the test user can see written documents. Will be
removed when this feature is implemented for real.
"""
metadata_field = RequestField(
name="request-metadata",
data=json.dumps(
{
"permissions": [
{
"role-name": "python-tester",
"capabilities": ["read", "update"],
}
]
}
),
)
metadata_field.make_multipart(
content_disposition="inline; category=metadata",
content_type="application/json",
)
return metadata_field
1 change: 1 addition & 0 deletions test-app/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.gradle
gradle-local.properties
build
23 changes: 23 additions & 0 deletions test-app/src/main/ml-config/security/roles/python-tester.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"role-name": "python-tester",
"role": [
"rest-extension-user"
],
"privilege": [
{
"privilege-name": "rest-reader",
"action": "http://marklogic.com/xdmp/privileges/rest-reader",
"kind": "execute"
},
{
"privilege-name": "rest-writer",
"action": "http://marklogic.com/xdmp/privileges/rest-writer",
"kind": "execute"
},
{
"privilege-name": "xdbc:eval",
"action": "http://marklogic.com/xdmp/privileges/xdbc-eval",
"kind": "execute"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"user-name": "python-test-admin",
"password": "password",
"role": [
"admin"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
"user-name": "python-test-user",
"password": "password",
"role": [
"rest-evaluator",
"rest-reader",
"rest-writer",
"python-tester",
"qconsole-user"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"axis-name": "system",
"axis-start": {
"element-reference": {
"namespace-uri": "",
"localname": "systemStart"
}
},
"axis-end": {
"element-reference": {
"namespace-uri": "",
"localname": "systemEnd"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"axis-name": "valid",
"axis-start": {
"element-reference": {
"namespace-uri": "",
"localname": "validStart"
}
},
"axis-end": {
"element-reference": {
"namespace-uri": "",
"localname": "validEnd"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"collection-name": "temporal-collection",
"system-axis": "system",
"valid-axis": "valid",
"option": [
"updates-admin-override"
]
}
2 changes: 1 addition & 1 deletion test-app/src/main/ml-data/permissions.properties
Original file line number Diff line number Diff line change
@@ -1 +1 @@
*=rest-reader,read,rest-writer,update
*=python-tester,read,python-tester,update
3 changes: 3 additions & 0 deletions test-app/src/main/ml-modules/rest-properties.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"update-policy": "VERSION_OPTIONAL"
}
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ def client():
return Client("http://localhost:8030", digest=("python-test-user", "password"))


@pytest.fixture
def admin_client():
return Client("http://localhost:8030", digest=("python-test-admin", "password"))


@pytest.fixture
def basic_client():
# requests allows a tuple to be passed when doing basic authentication.
Expand Down
Loading