diff --git a/marklogic/client.py b/marklogic/client.py index df803b7..72c4071 100644 --- a/marklogic/client.py +++ b/marklogic/client.py @@ -1,14 +1,12 @@ import json import requests -from decimal import Decimal from marklogic.cloud_auth import MarkLogicCloudAuth -from marklogic.documents import Document, DocumentManager -from marklogic.eval import EvalManager +from marklogic.documents import DocumentManager +from marklogic.impl.eval import process_multipart_mixed_response from marklogic.rows import RowManager from marklogic.transactions import TransactionManager from requests.auth import HTTPDigestAuth -from requests_toolbelt.multipart.decoder import MultipartDecoder from urllib.parse import urljoin @@ -72,6 +70,61 @@ 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(session=self) + return self._documents + + @property + def rows(self): + if not hasattr(self, "_rows"): + self._rows = RowManager(session=self) + return self._rows + + @property + def transactions(self): + if not hasattr(self, "_transactions"): + self._transactions = TransactionManager(session=self) + return self._transactions + + def eval( + self, + javascript: str = None, + xquery: str = None, + vars: dict = None, + return_response: bool = False, + **kwargs, + ): + """ + Send a script to MarkLogic via a POST to the endpoint + defined at https://docs.marklogic.com/REST/POST/v1/eval. Must define either + 'javascript' or 'xquery'. + + :param javascript: a JavaScript script + :param xquery: an XQuery script + :param vars: a dict containing variables to include + :param return_response: boolean specifying if the entire original response + object should be returned (True) or if only the data should be returned (False) + upon a success (2xx) response. Note that if the status code of the response is + not 2xx, then the entire response is always returned. + """ + data = {} + if javascript: + data = {"javascript": javascript} + elif xquery: + data = {"xquery": xquery} + else: + raise ValueError("Must define either 'javascript' or 'xquery' argument.") + if vars: + data["vars"] = json.dumps(vars) + response = self.post("v1/eval", data=data, **kwargs) + return ( + process_multipart_mixed_response(response) + if response.status_code == 200 and not return_response + else response + ) + def invoke( self, module: str, vars: dict = None, return_response: bool = False, **kwargs ): @@ -88,97 +141,11 @@ def invoke( not 2xx, then the entire response is always returned. """ data = {"module": module} - if vars is not None: + if vars: data["vars"] = json.dumps(vars) response = self.post("v1/invoke", data=data, **kwargs) return ( - self.process_multipart_mixed_response(response) + process_multipart_mixed_response(response) if response.status_code == 200 and not return_response else response ) - - def process_multipart_mixed_response(self, response): - """ - Process a multipart REST response by putting them in a list and - transforming each part based on the "X-Primitive" header. - - :param response: The original multipart/mixed response from a call to a - MarkLogic server. - """ - if "Content-Length" in response.headers: - return None - - parts = MultipartDecoder.from_response(response).parts - transformed_parts = [] - for part in parts: - encoding = part.encoding - header = part.headers["X-Primitive".encode(encoding)].decode(encoding) - primitive_function = Client.__primitive_value_converters.get(header) - if primitive_function is not None: - transformed_parts.append(primitive_function(part)) - else: - # Return the binary created by requests_toolbelt so we don't get an - # error trying to convert it to something else. - transformed_parts.append(part.content) - return transformed_parts - - @property - def documents(self): - if not hasattr(self, "_documents"): - self._documents = DocumentManager(session=self) - return self._documents - - @property - def rows(self): - if not hasattr(self, "_rows"): - self._rows = RowManager(session=self) - return self._rows - - @property - def transactions(self): - if not hasattr(self, "_transactions"): - self._transactions = TransactionManager(session=self) - return self._transactions - - @property - def eval(self): - if not hasattr(self, "_eval"): - self._eval = EvalManager(session=self) - return self._eval - - __primitive_value_converters = { - "integer": lambda part: int(part.text), - "decimal": lambda part: Decimal(part.text), - "boolean": lambda part: ("False" == part.text), - "string": lambda part: part.text, - "map": lambda part: json.loads(part.text), - "element()": lambda part: part.text, - "array": lambda part: json.loads(part.text), - "array-node()": lambda part: json.loads(part.text), - "object-node()": lambda part: Client.__process_object_node_part(part), - "document-node()": lambda part: Client.__process_document_node_part(part), - # It appears that binary() will only be returned for a binary node retrieved - # from the database, and thus an X-URI will always exist. Have not found a - # scenario that indicates otherwise. - "binary()": lambda part: Document( - Client.__get_decoded_uri_from_part(part), part.content - ), - } - - def __get_decoded_uri_from_part(part): - encoding = part.encoding - return part.headers["X-URI".encode(encoding)].decode(encoding) - - def __process_object_node_part(part): - if b"X-URI" in part.headers: - return Document( - Client.__get_decoded_uri_from_part(part), json.loads(part.text) - ) - else: - return json.loads(part.text) - - def __process_document_node_part(part): - if b"X-URI" in part.headers: - return Document(Client.__get_decoded_uri_from_part(part), part.text) - else: - return part.text diff --git a/marklogic/eval.py b/marklogic/eval.py deleted file mode 100644 index f8a6614..0000000 --- a/marklogic/eval.py +++ /dev/null @@ -1,73 +0,0 @@ -import json - -from requests import Session - -""" -Defines an EvalManager class to simplify usage of the "/v1/eval" REST -endpoint defined at https://docs.marklogic.com/REST/POST/v1/eval. -""" - - -class EvalManager: - """ - Provides a method to simplify sending an XQuery or - JavaScript eval request to the eval endpoint. - """ - - def __init__(self, session: Session): - self._session = session - - def xquery( - self, xquery: str, vars: dict = None, return_response: bool = False, **kwargs - ): - """ - Send an XQuery script to MarkLogic via a POST to the endpoint - defined at https://docs.marklogic.com/REST/POST/v1/eval. - - :param xquery: an XQuery string - :param vars: a dict containing variables to include - :param return_response: boolean specifying if the entire original response - object should be returned (True) or if only the data should be returned (False) - upon a success (2xx) response. Note that if the status code of the response is - not 2xx, then the entire response is always returned. - """ - return self.__send_request({"xquery": xquery}, vars, return_response, **kwargs) - - def javascript( - self, - javascript: str, - vars: dict = None, - return_response: bool = False, - **kwargs - ): - """ - Send a JavaScript script to MarkLogic via a POST to the endpoint - defined at https://docs.marklogic.com/REST/POST/v1/eval. - - :param javascript: a JavaScript string - :param vars: a dict containing variables to include - :param return_response: boolean specifying if the entire original response - object should be returned (True) or if only the data should be returned (False) - upon a success (2xx) response. Note that if the status code of the response is - not 2xx, then the entire response is always returned. - """ - return self.__send_request( - {"javascript": javascript}, vars, return_response, **kwargs - ) - - def __send_request( - self, data: dict, vars: dict = None, return_response: bool = False, **kwargs - ): - """ - Send a script (XQuery or JavaScript) and possibly a dict of vars - to MarkLogic via a POST to the endpoint defined at - https://docs.marklogic.com/REST/POST/v1/eval. - """ - if vars is not None: - data["vars"] = json.dumps(vars) - response = self._session.post("v1/eval", data=data, **kwargs) - return ( - self._session.process_multipart_mixed_response(response) - if response.status_code == 200 and not return_response - else response - ) diff --git a/marklogic/impl/eval.py b/marklogic/impl/eval.py new file mode 100644 index 0000000..9af1b3e --- /dev/null +++ b/marklogic/impl/eval.py @@ -0,0 +1,74 @@ +import json + +from decimal import Decimal +from marklogic.documents import Document +from requests import Response +from requests_toolbelt.multipart.decoder import MultipartDecoder + +""" +Supports working with data returned by the v1/eval and v1/invoke endpoints. +""" + +__primitive_value_converters = { + "integer": lambda part: int(part.text), + "decimal": lambda part: Decimal(part.text), + "boolean": lambda part: ("False" == part.text), + "string": lambda part: part.text, + "map": lambda part: json.loads(part.text), + "element()": lambda part: part.text, + "array": lambda part: json.loads(part.text), + "array-node()": lambda part: json.loads(part.text), + "object-node()": lambda part: __process_object_node_part(part), + "document-node()": lambda part: __process_document_node_part(part), + # It appears that binary() will only be returned for a binary node retrieved + # from the database, and thus an X-URI will always exist. Have not found a + # scenario that indicates otherwise. + "binary()": lambda part: Document(__get_decoded_uri_from_part(part), part.content), +} + + +def process_multipart_mixed_response(response: Response) -> list: + """ + Process a multipart REST response by putting them in a list and + transforming each part based on the "X-Primitive" header. + + :param response: The original multipart/mixed response from a call to a + MarkLogic server. + """ + + # The presence of this header indicates that the call returned an empty sequence. + if "Content-Length" in response.headers: + return [] + + parts = MultipartDecoder.from_response(response).parts + transformed_parts = [] + for part in parts: + encoding = part.encoding + header = part.headers["X-Primitive".encode(encoding)].decode(encoding) + primitive_function = __primitive_value_converters.get(header) + if primitive_function is not None: + transformed_parts.append(primitive_function(part)) + else: + # Return the binary created by requests_toolbelt so we don't get an + # error trying to convert it to something else. + transformed_parts.append(part.content) + return transformed_parts + + +def __get_decoded_uri_from_part(part): + encoding = part.encoding + return part.headers["X-URI".encode(encoding)].decode(encoding) + + +def __process_object_node_part(part): + if b"X-URI" in part.headers: + return Document(__get_decoded_uri_from_part(part), json.loads(part.text)) + else: + return json.loads(part.text) + + +def __process_document_node_part(part): + if b"X-URI" in part.headers: + return Document(__get_decoded_uri_from_part(part), part.text) + else: + return part.text diff --git a/tests/test_eval.py b/tests/test_eval.py index 7ef936c..5e3a687 100644 --- a/tests/test_eval.py +++ b/tests/test_eval.py @@ -1,21 +1,20 @@ import decimal from marklogic.documents import Document +from pytest import raises from requests_toolbelt.multipart.decoder import MultipartDecoder def test_xquery_common_primitives(client): - parts = client.eval.xquery( - """( - 'A', 1, 1.1, fn:false(), fn:doc('/musicians/logo.png')) - """ + parts = client.eval( + xquery="('A', 1, 1.1, fn:false(), fn:doc('/musicians/logo.png'))" ) __verify_common_primitives(parts) def test_javascript_common_primitives(client): - parts = client.eval.javascript( - """xdmp.arrayValues([ + parts = client.eval( + javascript="""xdmp.arrayValues([ 'A', 1, 1.1, false, fn.doc('/musicians/logo.png') ])""" ) @@ -23,8 +22,8 @@ def test_javascript_common_primitives(client): def test_xquery_specific_primitives(client): - parts = client.eval.xquery( - """( + parts = client.eval( + xquery="""( world, object-node {'A': 'a'}, fn:doc('/doc2.xml'), @@ -47,8 +46,8 @@ def test_xquery_specific_primitives(client): def test_javascript_specific_primitives(client): - parts = client.eval.javascript( - """xdmp.arrayValues([ + parts = client.eval( + javascript="""xdmp.arrayValues([ {'A': 'a'}, ['Z', 'Y', 1], fn.head(cts.search('Armstrong')) @@ -72,44 +71,50 @@ def test_javascript_specific_primitives(client): def test_xquery_with_return_response(client): - response = client.eval.xquery("('A', 1, 1.1, fn:false())", return_response=True) + response = client.eval(xquery="('A', 1, 1.1, fn:false())", return_response=True) assert 200 == response.status_code parts = MultipartDecoder.from_response(response).parts assert 4 == len(parts) def test_xquery_vars(client): - vars = {"word1": "hello", "word2": "world"} script = """ xquery version "1.0-ml"; declare variable $word1 as xs:string external; declare variable $word2 as xs:string external; fn:concat($word1, " ", $word2) """ - parts = client.eval.xquery(script, vars) + parts = client.eval(xquery=script, vars={"word1": "hello", "word2": "world"}) assert type(parts[0]) is str assert "hello world" == parts[0] def test_javascript_vars(client): - vars = {"word1": "hello", "word2": "world"} - parts = client.eval.javascript("xdmp.arrayValues([word1, word2])", vars) + parts = client.eval( + javascript="xdmp.arrayValues([word1, word2])", + vars={"word1": "hello", "word2": "world"}, + ) assert type(parts[0]) is str assert "hello" == parts[0] def test_xquery_empty_sequence(client): - parts = client.eval.xquery("()") - assert parts is None + parts = client.eval(xquery="()") + assert [] == parts -def test_javascript_script(client): - parts = client.eval.javascript("[]") +def test_javascript_empty_array(client): + parts = client.eval(javascript="[]") assert [[]] == parts +def test_javascript_empty_sequence(client): + parts = client.eval(javascript="Sequence.from([])") + assert [] == parts + + def test_base64Binary(client): - parts = client.eval.xquery('xs:base64Binary(doc("/musicians/logo.png"))') + parts = client.eval(xquery='xs:base64Binary(doc("/musicians/logo.png"))') assert len(parts) == 1 assert type(parts[0]) is bytes @@ -117,11 +122,18 @@ def test_base64Binary(client): def test_hexBinary(client): # No idea what this value is, found it in a DHF test. b = "3f3c6d78206c657673726f693d6e3122302e20226e656f636964676e223d54552d4622383e3f" - parts = client.eval.xquery(f"xs:hexBinary('{b}')") + parts = client.eval(xquery=f"xs:hexBinary('{b}')") assert len(parts) == 1 assert type(parts[0]) is bytes +def test_no_script(client): + with raises( + ValueError, match="Must define either 'javascript' or 'xquery' argument." + ): + client.eval(vars={}) + + def __verify_common_primitives(parts): assert type(parts[0]) is str assert "A" == parts[0]