Skip to content

Commit 38123fb

Browse files
committed
DEVEXP-590: Invoke module via Python client
1 parent d742fb0 commit 38123fb

File tree

9 files changed

+163
-80
lines changed

9 files changed

+163
-80
lines changed

marklogic/client.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import json
12
import requests
3+
4+
from decimal import Decimal
25
from marklogic.cloud_auth import MarkLogicCloudAuth
3-
from marklogic.documents import DocumentManager
6+
from marklogic.documents import Document, DocumentManager
7+
from marklogic.eval import EvalManager
48
from marklogic.rows import RowManager
59
from marklogic.transactions import TransactionManager
6-
from marklogic.eval import EvalManager
710
from requests.auth import HTTPDigestAuth
11+
from requests_toolbelt.multipart.decoder import MultipartDecoder
812
from urllib.parse import urljoin
913

1014

@@ -68,6 +72,58 @@ def prepare_request(self, request, *args, **kwargs):
6872
request.url = urljoin(self.base_url, request.url)
6973
return super(Client, self).prepare_request(request, *args, **kwargs)
7074

75+
def invoke(
76+
self, module: str, vars: dict = None, return_response: bool = False, **kwargs
77+
):
78+
"""
79+
Send a script (XQuery or JavaScript) and possibly a dict of vars
80+
to MarkLogic via a POST to the endpoint defined at
81+
https://docs.marklogic.com/REST/POST/v1/eval.
82+
83+
:param module: The URI of a module in the modules database of the app server
84+
:param vars: a dict containing variables to include
85+
:param return_response: boolean specifying if the entire original response
86+
object should be returned (True) or if only the data should be returned (False)
87+
upon a success (2xx) response. Note that if the status code of the response is
88+
not 2xx, then the entire response is always returned.
89+
"""
90+
data = {"module": module}
91+
if vars is not None:
92+
data["vars"] = json.dumps(vars)
93+
response = self.post("v1/invoke", data=data, **kwargs)
94+
return (
95+
self.process_multipart_mixed_response(response)
96+
if response.status_code == 200 and not return_response
97+
else response
98+
)
99+
100+
def process_multipart_mixed_response(self, response):
101+
"""
102+
Process a multipart REST response by putting them in a list and
103+
transforming each part based on the "X-Primitive" header.
104+
105+
:param response: The original multipart/mixed response from a call to a
106+
MarkLogic server.
107+
"""
108+
if "Content-Length" in response.headers:
109+
return None
110+
111+
parts = MultipartDecoder.from_response(response).parts
112+
transformed_parts = []
113+
for part in parts:
114+
encoding = part.encoding
115+
primitive_header = part.headers["X-Primitive".encode(encoding)].decode(
116+
encoding
117+
)
118+
primitive_function = Client.__primitive_value_converters.get(
119+
primitive_header
120+
)
121+
if primitive_function is not None:
122+
transformed_parts.append(primitive_function(part))
123+
else:
124+
transformed_parts.append(part.text)
125+
return transformed_parts
126+
71127
@property
72128
def documents(self):
73129
if not hasattr(self, "_documents"):
@@ -91,3 +147,37 @@ def eval(self):
91147
if not hasattr(self, "_eval"):
92148
self._eval = EvalManager(self)
93149
return self._eval
150+
151+
__primitive_value_converters = {
152+
"integer": lambda part: int(part.text),
153+
"decimal": lambda part: Decimal(part.text),
154+
"boolean": lambda part: ("False" == part.text),
155+
"string": lambda part: part.text,
156+
"map": lambda part: json.loads(part.text),
157+
"element()": lambda part: part.text,
158+
"array": lambda part: json.loads(part.text),
159+
"array-node()": lambda part: json.loads(part.text),
160+
"object-node()": lambda part: Client.__process_object_node_part(part),
161+
"document-node()": lambda part: Client.__process_document_node_part(part),
162+
"binary()": lambda part: Document(
163+
Client.__get_decoded_uri_from_part(part), part.content
164+
),
165+
}
166+
167+
def __get_decoded_uri_from_part(part):
168+
encoding = part.encoding
169+
return part.headers["X-URI".encode(encoding)].decode(encoding)
170+
171+
def __process_object_node_part(part):
172+
if b"X-URI" in part.headers:
173+
return Document(
174+
Client.__get_decoded_uri_from_part(part), json.loads(part.text)
175+
)
176+
else:
177+
return json.loads(part.text)
178+
179+
def __process_document_node_part(part):
180+
if b"X-URI" in part.headers:
181+
return Document(Client.__get_decoded_uri_from_part(part), part.text)
182+
else:
183+
return part.text

marklogic/eval.py

Lines changed: 2 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import json
22

3-
from decimal import Decimal
4-
from marklogic.documents import Document
53
from requests import Session
6-
from requests_toolbelt.multipart.decoder import MultipartDecoder
74

85
"""
96
Defines an EvalManager class to simplify usage of the "/v1/eval" REST
@@ -34,8 +31,6 @@ def xquery(
3431
upon a success (2xx) response. Note that if the status code of the response is
3532
not 2xx, then the entire response is always returned.
3633
"""
37-
if xquery is None:
38-
raise ValueError("No script found; must specify a xquery")
3934
return self.__send_request({"xquery": xquery}, vars, return_response, **kwargs)
4035

4136
def javascript(
@@ -56,8 +51,6 @@ def javascript(
5651
upon a success (2xx) response. Note that if the status code of the response is
5752
not 2xx, then the entire response is always returned.
5853
"""
59-
if javascript is None:
60-
raise ValueError("No script found; must specify a javascript")
6154
return self.__send_request(
6255
{"javascript": javascript}, vars, return_response, **kwargs
6356
)
@@ -66,73 +59,15 @@ def __send_request(
6659
self, data: dict, vars: dict = None, return_response: bool = False, **kwargs
6760
):
6861
"""
69-
Send a script (XQuery or javascript) and possibly a dict of vars
62+
Send a script (XQuery or JavaScript) and possibly a dict of vars
7063
to MarkLogic via a POST to the endpoint defined at
7164
https://docs.marklogic.com/REST/POST/v1/eval.
7265
"""
7366
if vars is not None:
7467
data["vars"] = json.dumps(vars)
7568
response = self._session.post("v1/eval", data=data, **kwargs)
7669
return (
77-
self.__process_response(response)
70+
self._session.process_multipart_mixed_response(response)
7871
if response.status_code == 200 and not return_response
7972
else response
8073
)
81-
82-
def __process_response(self, response):
83-
"""
84-
Process a multipart REST response by putting them in a list and
85-
transforming each part based on the "X-Primitive" header.
86-
"""
87-
if "Content-Length" in response.headers:
88-
return None
89-
90-
parts = MultipartDecoder.from_response(response).parts
91-
transformed_parts = []
92-
for part in parts:
93-
encoding = part.encoding
94-
primitive_header = part.headers["X-Primitive".encode(encoding)].decode(
95-
encoding
96-
)
97-
primitive_function = EvalManager.__primitive_value_converters.get(
98-
primitive_header
99-
)
100-
if primitive_function is not None:
101-
transformed_parts.append(primitive_function(part))
102-
else:
103-
transformed_parts.append(part.text)
104-
return transformed_parts
105-
106-
__primitive_value_converters = {
107-
"integer": lambda part: int(part.text),
108-
"decimal": lambda part: Decimal(part.text),
109-
"boolean": lambda part: ("False" == part.text),
110-
"string": lambda part: part.text,
111-
"map": lambda part: json.loads(part.text),
112-
"element()": lambda part: part.text,
113-
"array": lambda part: json.loads(part.text),
114-
"array-node()": lambda part: json.loads(part.text),
115-
"object-node()": lambda part: EvalManager.__process_object_node_part(part),
116-
"document-node()": lambda part: EvalManager.__process_document_node_part(part),
117-
"binary()": lambda part: Document(
118-
EvalManager.__get_decoded_uri_from_part(part), part.content
119-
),
120-
}
121-
122-
def __get_decoded_uri_from_part(part):
123-
encoding = part.encoding
124-
return part.headers["X-URI".encode(encoding)].decode(encoding)
125-
126-
def __process_object_node_part(part):
127-
if b"X-URI" in part.headers:
128-
return Document(
129-
EvalManager.__get_decoded_uri_from_part(part), json.loads(part.text)
130-
)
131-
else:
132-
return json.loads(part.text)
133-
134-
def __process_document_node_part(part):
135-
if b"X-URI" in part.headers:
136-
return Document(EvalManager.__get_decoded_uri_from_part(part), part.text)
137-
else:
138-
return part.text

test-app/src/main/ml-config/security/roles/python-tester.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
"privilege-name": "xdbc:eval",
1919
"action": "http://marklogic.com/xdmp/privileges/xdbc-eval",
2020
"kind": "execute"
21+
},
22+
{
23+
"privilege-name": "xdbc:invoke",
24+
"action": "http://marklogic.com/xdmp/privileges/xdbc-invoke",
25+
"kind": "execute"
2126
}
2227
]
2328
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
xdmp.arrayValues(['A', 1, 1.1, false, fn.doc('/musicians/logo.png')])
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
xquery version "1.0-ml";
2+
3+
('A', 1, 1.1, fn:false(), fn:doc('/musicians/logo.png'))
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
xdmp.arrayValues([word1, word2, word1 + " " + word2])
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
xquery version "1.0-ml";
2+
3+
declare variable $word1 as xs:string external;
4+
declare variable $word2 as xs:string external;
5+
6+
($word1, $word2, fn:concat($word1, " ", $word2))

tests/test_eval.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from marklogic.documents import Document
44
from requests_toolbelt.multipart.decoder import MultipartDecoder
5-
from pytest import raises
65

76

87
def test_xquery_common_primitives(client):
@@ -72,16 +71,6 @@ def test_javascript_specific_primitives(client):
7271
} == parts[2].content
7372

7473

75-
def test_javascript_noquery(client):
76-
with raises(ValueError, match="No script found; must specify a javascript"):
77-
client.eval.javascript(None)
78-
79-
80-
def test_xquery_noquery(client):
81-
with raises(ValueError, match="No script found; must specify a xquery"):
82-
client.eval.xquery(None)
83-
84-
8574
def test_xquery_with_return_response(client):
8675
response = client.eval.xquery("('A', 1, 1.1, fn:false())", return_response=True)
8776
assert 200 == response.status_code

tests/test_invoke.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import decimal
2+
3+
from marklogic.documents import Document
4+
from requests_toolbelt.multipart.decoder import MultipartDecoder
5+
6+
7+
def test_invoke_xquery_simple(client):
8+
parts = client.invoke("/simple.xqy")
9+
__verify_invoke_simple(parts)
10+
11+
12+
def test_invoke_javascript_simple(client):
13+
parts = client.invoke("/simple.sjs")
14+
__verify_invoke_simple(parts)
15+
16+
17+
def test_invoke_xquery_simple_vars(client):
18+
vars = {"word1": "hello", "word2": "world"}
19+
parts = client.invoke("/simple_vars.xqy", vars)
20+
__verify_invoke_with_vars(parts)
21+
22+
23+
def test_invoke_javascript_simple_vars(client):
24+
vars = {"word1": "hello", "word2": "world"}
25+
parts = client.invoke("/simple_vars.sjs", vars)
26+
__verify_invoke_with_vars(parts)
27+
28+
29+
def test_invoke_with_return_response(client):
30+
response = client.invoke("/simple.xqy", return_response=True)
31+
assert 200 == response.status_code
32+
parts = MultipartDecoder.from_response(response).parts
33+
assert 5 == len(parts)
34+
35+
36+
def __verify_invoke_with_vars(parts):
37+
assert "hello" == parts[0]
38+
assert "world" == parts[1]
39+
assert "hello world" == parts[2]
40+
41+
42+
def __verify_invoke_simple(parts):
43+
assert type(parts[0]) is str
44+
assert "A" == parts[0]
45+
assert type(parts[1]) is int
46+
assert 1 == parts[1]
47+
assert type(parts[2]) is decimal.Decimal
48+
assert decimal.Decimal("1.1") == parts[2]
49+
assert type(parts[3]) is bool
50+
assert parts[3] is False
51+
assert type(parts[4]) is Document
52+
assert "/musicians/logo.png" == parts[4].uri
53+
assert b"PNG" in parts[4].content

0 commit comments

Comments
 (0)