diff --git a/CHANGES.rst b/CHANGES.rst index a1f2cb43..f9f5477e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,42 @@ Changelog ========= +0.18.0 (2021-03-29) +~~~~~~~~~~~~~~~~~~~ + +New features +------------ + ++ `#76`_, `#78`_: add client side support for searching for multiple + fields introduced in icat.server 4.11.0. Add support for building + the corresponding queries in the in class :class:`icat.query.Query`. + +Incompatible changes and deprecations +------------------------------------- + ++ Since :class:`icat.query.Query` now also accepts a list of attribute + names rather then only a single one, the corresponding keyword + argument `attribute` has been renamed to `attributes` (in the + plural). Accordingly, the method + :meth:`icat.query.Query.setAttribute` has been renamed to + :meth:`icat.query.Query.setAttributes`. The old names are retained + as aliases, but are deprecated. + +Bug fixes and minor changes +--------------------------- + ++ `#79`_: fix an encoding issue in :attr:`icat.client.Client.apiversion`, + only relevant with Python 2. + ++ `#80`_: add :exc:`TypeError` as additional ancestor of + :exc:`icat.exception.EntityTypeError`. + +.. _#76: https://github.com/icatproject/python-icat/pull/76 +.. _#78: https://github.com/icatproject/python-icat/issues/78 +.. _#79: https://github.com/icatproject/python-icat/pull/79 +.. _#80: https://github.com/icatproject/python-icat/pull/80 + + 0.17.0 (2020-04-30) ~~~~~~~~~~~~~~~~~~~ diff --git a/README.rst b/README.rst index 25681365..912e1bf1 100644 --- a/README.rst +++ b/README.rst @@ -223,7 +223,7 @@ when it is incompatible with PEP 440. Copyright and License --------------------- -Copyright 2013–2020 +Copyright 2013–2021 Helmholtz-Zentrum Berlin für Materialien und Energie GmbH Licensed under the `Apache License`_, Version 2.0 (the "License"); you diff --git a/doc/src/conf.py b/doc/src/conf.py index 68effaf7..f7cd3821 100644 --- a/doc/src/conf.py +++ b/doc/src/conf.py @@ -11,7 +11,7 @@ # -- Project information ----------------------------------------------------- project = 'python-icat' -copyright = ('2013–2020, ' +copyright = ('2013–2021, ' 'Helmholtz-Zentrum Berlin für Materialien und Energie GmbH') author = 'Rolf Krahl' diff --git a/doc/src/exception.rst b/doc/src/exception.rst index 454d9903..b36cb28a 100644 --- a/doc/src/exception.rst +++ b/doc/src/exception.rst @@ -169,7 +169,8 @@ The class hierarchy for the exceptions is:: | +-- IDSNotImplementedError +-- InternalError +-- ConfigError - +-- EntityTypeError + +-- TypeError + | +-- EntityTypeError +-- VersionMethodError +-- SearchResultError | +-- SearchAssertionError @@ -182,5 +183,6 @@ The class hierarchy for the exceptions is:: +-- DeprecationWarning +-- ICATDeprecationWarning -Here, :exc:`Exception`, :exc:`Warning`, and :exc:`DeprecationWarning` -are build-in exceptions from the Python standard library. +Here, :exc:`Exception`, :exc:`TypeError`, :exc:`Warning`, and +:exc:`DeprecationWarning` are build-in exceptions from the Python +standard library. diff --git a/doc/src/tutorial-search.rst b/doc/src/tutorial-search.rst index fd7eeac4..9ce92496 100644 --- a/doc/src/tutorial-search.rst +++ b/doc/src/tutorial-search.rst @@ -367,12 +367,23 @@ Instead of returning a list of the matching objects, we may also request single attributes. The result will be a list of the attribute values of the matching objects. Listing the names of all datasets:: - >>> query = Query(client, "Dataset", attribute="name") + >>> query = Query(client, "Dataset", attributes="name") >>> print(query) SELECT o.name FROM Dataset o >>> client.search(query) [e201215, e201216, e208339, e208341, e208342, e208945, e208946, e208947] +As the name of that keyword argument suggests, we may also search for +multiple attributes at once. The result will be a list of attribute +values rather then a single value for each object found in the query. +This requires an ICAT server version 4.11 or newer though:: + + >>> query = Query(client, "Dataset", attributes=["investigation.name", "name", "complete", "type.name"]) + >>> print(query) + SELECT i.name, o.name, o.complete, t.name FROM Dataset o JOIN o.investigation AS i JOIN o.type AS t + >>> client.search(query) + [[08100122-EF, e201215, False, raw], [08100122-EF, e201216, False, raw], [10100601-ST, e208339, False, raw], [10100601-ST, e208341, False, raw], [10100601-ST, e208342, False, raw], [12100409-ST, e208945, False, raw], [12100409-ST, e208946, False, raw], [12100409-ST, e208947, True, analyzed]] + There are also some aggregate functions that may be applied to search results. Let's count all datasets:: @@ -395,7 +406,7 @@ average magnetic field applied in the measurements:: ... "type.name": "= 'Magnetic field'", ... "type.units": "= 'T'", ... } - >>> query = Query(client, "DatasetParameter", conditions=conditions, attribute="numericValue") + >>> query = Query(client, "DatasetParameter", conditions=conditions, attributes="numericValue") >>> print(query) SELECT o.numericValue FROM DatasetParameter o JOIN o.dataset AS ds JOIN ds.investigation AS i JOIN o.type AS t WHERE i.name = '10100601-ST' AND t.name = 'Magnetic field' AND t.units = 'T' >>> client.search(query) diff --git a/icat/client.py b/icat/client.py index 9257237e..cc622846 100644 --- a/icat/client.py +++ b/icat/client.py @@ -20,9 +20,10 @@ from icat.entities import getTypeMap from icat.query import Query from icat.exception import * +from icat.helper import (simpleqp_unquote, parse_attr_val, + ms_timestamp, disable_logger) from icat.ids import * from icat.sslcontext import create_ssl_context, HTTPSTransport -from icat.helper import simpleqp_unquote, parse_attr_val, ms_timestamp __all__ = ['Client'] @@ -139,7 +140,7 @@ def __init__(self, url, idsurl=None, proxy = {} kwargs['transport'] = HTTPSTransport(self.sslContext, proxy=proxy) super(Client, self).__init__(self.url, **kwargs) - apiversion = self.getApiVersion() + apiversion = str(self.getApiVersion()) # Translate a version having a trailing '-SNAPSHOT' into # something that StrictVersion would accept. apiversion = re.sub(r'-SNAPSHOT$', 'a1', apiversion) @@ -208,6 +209,12 @@ def clone(self): return Class(self.url, **self.kwargs) + def _has_wsdl_type(self, name): + """Check if this client's WSDL defines a particular type name. + """ + with disable_logger("suds.resolver"): + return self.factory.resolver.find(name) + def new(self, obj, **kwargs): """Instantiate a new :class:`icat.entity.Entity` object. @@ -280,16 +287,19 @@ def getEntityClass(self, name): def getEntity(self, obj): """Get the corresponding :class:`icat.entity.Entity` for an object. - If obj is a Suds instance object, create a new object with - :meth:`~icat.client.Client.new`. Otherwise do nothing and - return obj unchanged. + if obj is a `fieldSet`, return the list of fields. If obj is + any other Suds instance object, create a new entity object + with :meth:`~icat.client.Client.new`. Otherwise do nothing + and return obj unchanged. :param obj: either a Suds instance object or anything. :type obj: :class:`suds.sudsobject.Object` or any type :return: the new entity object or obj. - :rtype: :class:`icat.entity.Entity` or any type + :rtype: :class:`list` or :class:`icat.entity.Entity` or any type """ - if isinstance(obj, suds.sudsobject.Object): + if obj.__class__.__name__ == 'fieldSet': + return obj.fields + elif isinstance(obj, suds.sudsobject.Object): return self.new(obj) else: return obj @@ -507,10 +517,10 @@ def searchChunked(self, query, skip=0, count=None, chunksize=100): server. There are a few subtle differences though: the query must not contain a LIMIT clause (use the skip and count arguments instead) and should contain an ORDER BY clause. The - return value is an iterator over the items in the search - result rather then a list. The individual search calls are - done lazily, e.g. they are not done until needed to yield the - next item from the iterator. + return value is a generator yielding successively the items in + the search result rather then a list. The individual search + calls are done lazily, e.g. they are not done until needed to + yield the next item from the generator. .. note:: The result may be defective (omissions, duplicates) if the @@ -555,7 +565,7 @@ def searchChunked(self, query, skip=0, count=None, chunksize=100): call. This is an internal tuning parameter and does not affect the result. :type chunksize: :class:`int` - :return: a generator that iterates over the items in the + :return: a generator that successively yields the items in the search result. :rtype: generator """ diff --git a/icat/exception.py b/icat/exception.py index d26df8f6..876f2c65 100644 --- a/icat/exception.py +++ b/icat/exception.py @@ -346,8 +346,12 @@ def __init__(self, feature, version=None): % (feature, icatstr)) super(ICATDeprecationWarning, self).__init__(msg) -class EntityTypeError(_BaseException): - """An invalid entity type has been used.""" +class EntityTypeError(_BaseException, TypeError): + """An invalid entity type has been used. + + .. versionchanged:: 0.18.0 + Inherit from :exc:`TypeError`. + """ pass class VersionMethodError(_BaseException): diff --git a/icat/helper.py b/icat/helper.py index 6174c97e..ee143511 100644 --- a/icat/helper.py +++ b/icat/helper.py @@ -34,7 +34,9 @@ """ import sys +from contextlib import contextmanager import datetime +import logging import suds.sax.date @@ -219,3 +221,14 @@ def ms_timestamp(dt): dt = dt.replace(tzinfo=None) - offs ts = 1000 * (dt - datetime.datetime(1970, 1, 1)).total_seconds() return int(ts) + + +@contextmanager +def disable_logger(name): + """Context manager to temporarily disable a logger. + """ + logger = logging.getLogger(name) + sav_state = logger.disabled + logger.disabled = True + yield + logger.disabled = sav_state diff --git a/icat/query.py b/icat/query.py index e046d26d..d68eb2f5 100644 --- a/icat/query.py +++ b/icat/query.py @@ -58,8 +58,8 @@ class Query(object): :param entity: the type of objects to search for. This may either be an :class:`icat.entity.Entity` subclass or the name of an entity type. - :param attribute: the attribute that the query shall return. See - the :meth:`~icat.query.Query.setAttribute` method for details. + :param attributes: the attributes that the query shall return. See + the :meth:`~icat.query.Query.setAttributes` method for details. :param aggregate: the aggregate function to be applied in the SELECT clause, if any. See the :meth:`~icat.query.Query.setAggregate` method for details. @@ -75,11 +75,22 @@ class Query(object): :param limit: a tuple (skip, count) to be used in the LIMIT clause. See the :meth:`~icat.query.Query.setLimit` method for details. + :param attribute: alias for `attributes`, retained for + compatibility. Deprecated, use `attributes` instead. + :raise TypeError: if `entity` is not a valid entity type or if + both `attributes` and `attribute` are provided. + :raise ValueError: if any of the keyword arguments is not valid, + see the corresponding method for details. + + .. versionchanged:: 0.18.0 + add support for queries requesting a list of attributes rather + then a single one. Consequently, the keyword argument + `attribute` has been renamed to `attributes` (in the plural). """ - def __init__(self, client, entity, - attribute=None, aggregate=None, order=None, - conditions=None, includes=None, limit=None): + def __init__(self, client, entity, + attributes=None, aggregate=None, order=None, + conditions=None, includes=None, limit=None, attribute=None): """Initialize the query. """ @@ -99,7 +110,14 @@ def __init__(self, client, entity, else: raise EntityTypeError("Invalid entity type '%s'." % type(entity)) - self.setAttribute(attribute) + if attribute is not None: + if attributes: + raise TypeError("cannot use both, attribute and attributes") + warn("The attribute keyword argument is deprecated and will be " + "removed in python-icat 1.0.", DeprecationWarning, 2) + attributes = attribute + + self.setAttributes(attributes) self.setAggregate(aggregate) self.conditions = dict() self.addConditions(conditions) @@ -172,21 +190,39 @@ def _dosubst(self, obj, subst, addas=True): n += " AS %s" % (subst[obj]) return n - def setAttribute(self, attribute): - """Set the attribute that the query shall return. + def setAttributes(self, attributes): + """Set the attributes that the query shall return. - :param attribute: the name of the attribute. The result of - the query will be a list of attribute values for the - matching entity objects. If attribute is :const:`None`, + :param attributes: the names of the attributes. This can + either be a single name or a list of names. The result of + the search will be a list with either a single attribute + value or a list of attribute values respectively for each + matching entity object. If attributes is :const:`None`, the result will be the list of matching objects instead. - :type attribute: :class:`str` - :raise ValueError: if `attribute` is not valid. + :type attributes: :class:`str` or :class:`list` of :class:`str` + :raise ValueError: if any name in `attributes` is not valid or + if multiple attributes are provided, but the ICAT server + does not support this. + + .. versionchanged:: 0.18.0 + also accept a list of attribute names. Renamed from + :meth:`setAttribute` to :meth:`setAttributes` (in the + plural). """ - if attribute is not None: - # Get the attribute path only to verify that the attribute is valid. - for (pattr, attrInfo, rclass) in self._attrpath(attribute): - pass - self.attribute = attribute + self.attributes = [] + if attributes: + if isinstance(attributes, str): + attributes = [ attributes ] + if (len(attributes) > 1 and + not self.client._has_wsdl_type('fieldSet')): + raise ValueError("This ICAT server does not support queries " + "searching for multiple attributes") + for attr in attributes: + # Get the attribute path only to verify that the + # attribute is valid. + for (pattr, attrInfo, rclass) in self._attrpath(attr): + pass + self.attributes.append(attr) def setAggregate(self, function): """Set the aggregate function to be applied to the result. @@ -206,7 +242,7 @@ def setAggregate(self, function): ":DISTINCT", may be appended to "COUNT", "AVG", and "SUM" to combine the respective function with "DISTINCT". :type function: :class:`str` - :raise ValueError: if `function` is not a valid. + :raise ValueError: if `function` is not valid. """ if function: if function not in aggregate_fcts: @@ -342,12 +378,12 @@ def setLimit(self, limit): def __repr__(self): """Return a formal representation of the query. """ - return ("%s(%s, %s, attribute=%s, aggregate=%s, order=%s, " + return ("%s(%s, %s, attributes=%s, aggregate=%s, order=%s, " "conditions=%s, includes=%s, limit=%s)" - % (self.__class__.__name__, - repr(self.client), repr(self.entity.BeanName), - repr(self.attribute), repr(self.aggregate), - repr(self.order), repr(self.conditions), + % (self.__class__.__name__, + repr(self.client), repr(self.entity.BeanName), + repr(self.attributes), repr(self.aggregate), + repr(self.order), repr(self.conditions), repr(self.includes), repr(self.limit))) def __str__(self): @@ -362,17 +398,20 @@ def __str__(self): usefulness over formal correctness. For Python 3, there is no distinction between Unicode and string objects anyway. """ - joinattrs = { a for a, d in self.order } | set(self.conditions.keys()) - if self.attribute: - joinattrs.add(self.attribute) + joinattrs = ( { a for a, d in self.order } | + set(self.conditions.keys()) | + set(self.attributes) ) subst = self._makesubst(joinattrs) - if self.attribute: - if self.client.apiversion >= "4.7.0": - res = self._dosubst(self.attribute, subst, False) - else: - # Old versions of icat.server do not accept - # substitution in the SELECT clause. - res = "o.%s" % self.attribute + if self.attributes: + attrs = [] + for a in self.attributes: + if self.client.apiversion >= "4.7.0": + attrs.append(self._dosubst(a, subst, False)) + else: + # Old versions of icat.server do not accept + # substitution in the SELECT clause. + attrs.append("o.%s" % a) + res = ", ".join(attrs) else: res = "o" if self.aggregate: @@ -424,10 +463,20 @@ def copy(self): """Return an independent clone of this query. """ q = Query(self.client, self.entity) - q.attribute = self.attribute + q.attributes = list(self.attributes) q.aggregate = self.aggregate q.order = list(self.order) q.conditions = self.conditions.copy() q.includes = self.includes.copy() q.limit = self.limit return q + + def setAttribute(self, attribute): + """Alias for :meth:`setAttributes`. + + .. deprecated:: 0.18.0 + use :meth:`setAttributes` instead. + """ + warn("setAttribute() is deprecated " + "and will be removed in python-icat 1.0.", DeprecationWarning, 2) + self.setAttributes(attribute) diff --git a/icatdump.py b/icatdump.py index c6b28454..ecba054c 100755 --- a/icatdump.py +++ b/icatdump.py @@ -44,7 +44,7 @@ dumpfile.writedata(getAuthQueries(client)) dumpfile.writedata(getStaticQueries(client)) # Dump the investigations each in their own chunk - investsearch = Query(client, "Investigation", attribute="id", + investsearch = Query(client, "Investigation", attributes="id", order=["facility.name", "name", "visitId"]) for i in client.searchChunked(investsearch): # We fetch Dataset including DatasetParameter. This may lead diff --git a/setup.py b/setup.py index 9356dbf9..fc1b462d 100755 --- a/setup.py +++ b/setup.py @@ -182,6 +182,7 @@ def run(self): "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", ], cmdclass = { diff --git a/tests/test_05_dumpfile.py b/tests/test_05_dumpfile.py index 5721c98c..8749a10b 100644 --- a/tests/test_05_dumpfile.py +++ b/tests/test_05_dumpfile.py @@ -86,7 +86,7 @@ def icatdump(client, f, backend): with open_dumpfile(client, f, backend, 'w') as dumpfile: dumpfile.writedata(getAuthQueries(client)) dumpfile.writedata(getStaticQueries(client)) - investsearch = Query(client, "Investigation", attribute="id", + investsearch = Query(client, "Investigation", attributes="id", order=["facility.name", "name", "visitId"]) for i in client.searchChunked(investsearch): dumpfile.writedata(getInvestigationQueries(client, i), chunksize=5) diff --git a/tests/test_06_client.py b/tests/test_06_client.py index e45afdc5..c4412709 100644 --- a/tests/test_06_client.py +++ b/tests/test_06_client.py @@ -8,10 +8,11 @@ from __future__ import print_function try: # Python 3.3 and newer - from collections.abc import Iterable, Callable + from collections.abc import Iterable, Callable, Sequence except ImportError: # Python 2 - from collections import Iterable, Callable + from collections import Iterable, Callable, Sequence +import datetime import pytest import icat import icat.config @@ -41,6 +42,47 @@ def test_logout_no_session_error(client): with tmpSessionId(client, "-=- Invalid -=-"): client.logout() +# ======================== test search() =========================== + +try: + # datetime.timezone has been added in Python 3.2 + cet = datetime.timezone(datetime.timedelta(hours=1)) + cest = datetime.timezone(datetime.timedelta(hours=2)) +except AttributeError: + # Old Python + cet = None + cest = None + +@pytest.mark.parametrize(("query", "result"), [ + pytest.param("SELECT o.name, o.title, o.startDate FROM Investigation o", + [["08100122-EF", "Durol single crystal", + datetime.datetime(2008, 3, 13, 11, 39, 42, tzinfo=cet)], + ["10100601-ST", "Ni-Mn-Ga flat cone", + datetime.datetime(2010, 9, 30, 12, 27, 24, tzinfo=cest)], + ["12100409-ST", "NiO SC OF1 JUH HHL", + datetime.datetime(2012, 7, 26, 17, 44, 24, tzinfo=cest)]], + marks=pytest.mark.skipif("cet is None", + reason="require datetime.timezone")), + ("SELECT i.name, ds.name FROM Dataset ds JOIN ds.investigation AS i " + "WHERE i.startDate < '2011-01-01'", + [["08100122-EF", "e201215"], + ["08100122-EF", "e201216"], + ["10100601-ST", "e208339"], + ["10100601-ST", "e208341"], + ["10100601-ST", "e208342"]]), +]) +def test_search_mulitple_fields(client, query, result): + """Search for mutliple fields. + + Newer versions of icat.server allow to select multiple fields in a + search expression (added in icatproject/icat.server#246). Test + client side support for this. + """ + if not client._has_wsdl_type('fieldSet'): + pytest.skip("search for multiple fields not supported by this server") + r = client.search(query) + assert r == result + # ==================== test assertedSearch() ======================= def test_assertedSearch_unique(client): @@ -98,6 +140,20 @@ def test_assertedSearch_range_exact_query(client): assert len(objs) == 3 assert objs[0].BeanName == "User" +@pytest.mark.skipif(cet is None, reason="require datetime.timezone") +def test_assertedSearch_unique_mulitple_fields(client): + """Search for some attributes of a unique object with assertedSearch(). + """ + if not client._has_wsdl_type('fieldSet'): + pytest.skip("search for multiple fields not supported by this server") + query = ("SELECT i.name, i.title, i.startDate FROM Investigation i " + "WHERE i.name = '08100122-EF'") + result = ["08100122-EF", "Durol single crystal", + datetime.datetime(2008, 3, 13, 11, 39, 42, tzinfo=cet)] + r = client.assertedSearch(query)[0] + assert isinstance(r, Sequence) + assert r == result + # ===================== test searchChunked() ======================= # Try different type of queries: query strings using concise syntax, @@ -210,7 +266,7 @@ def test_searchChunked_id(client, query): works around this for the standard formulation of the query (9901ec6). """ - refq = Query(client, "Investigation", attribute="id", limit=(0,1), + refq = Query(client, "Investigation", attributes="id", limit=(0,1), conditions={"name": "= '08100122-EF'"}) id = client.assertedSearch(refq)[0] # The search by id must return exactly one result. The broken @@ -253,6 +309,25 @@ def test_searchChunked_limit_bug_chunksize(client): assert count == 1 assert count == 1 +def test_searchChunked_mulitple_fields(client): + """A simple search with searchChunked(). + """ + if not client._has_wsdl_type('fieldSet'): + pytest.skip("search for multiple fields not supported by this server") + query = "SELECT u.name, u.fullName, u.email from User u" + # Do a normal search as a reference first, the result from + # searchChunked() should be the same. + user_attrs = client.search(query) + res_gen = client.searchChunked(query) + # Note that searchChunked() returns a generator, not a list. Be + # somewhat less specific in the test, only assume the result to + # be iterable. + assert isinstance(res_gen, Iterable) + # turn it to a list so we can inspect the acual search result. + res = list(res_gen) + assert isinstance(res[0], Sequence) + assert res == user_attrs + # ==================== test searchUniqueKey() ====================== diff --git a/tests/test_06_query.py b/tests/test_06_query.py index 758b69d5..6552e801 100644 --- a/tests/test_06_query.py +++ b/tests/test_06_query.py @@ -2,8 +2,10 @@ """ from __future__ import print_function -import sys import datetime +from distutils.version import StrictVersion as Version +import re +import sys import pytest import icat import icat.config @@ -403,7 +405,23 @@ def test_query_attribute_datafile_name(client): Querying attributes rather then entire objects is a new feature added in Issue #28. """ - query = Query(client, "Datafile", attribute="name", order=True, + query = Query(client, "Datafile", attributes="name", order=True, + conditions={ "dataset.investigation.id": + "= %d" % investigation.id }) + print(str(query)) + res = client.search(query) + assert len(res) == 4 + for n in res: + assert not isinstance(n, icat.entity.Entity) + +@pytest.mark.dependency(depends=['get_investigation']) +def test_query_attribute_datafile_name_list(client): + """The datafiles names related to a given investigation in natural order. + + Same as last test, but pass the attribute as a list having one + single element. + """ + query = Query(client, "Datafile", attributes=["name"], order=True, conditions={ "dataset.investigation.id": "= %d" % investigation.id }) print(str(query)) @@ -419,7 +437,7 @@ def test_query_related_obj_attribute(client): This requires icat.server 4.5 or newer to work. """ require_icat_version("4.5.0", "SELECT related object's attribute") - query = Query(client, "Datafile", attribute="datafileFormat.name", + query = Query(client, "Datafile", attributes="datafileFormat.name", conditions={ "dataset.investigation.id": "= %d" % investigation.id }) print(str(query)) @@ -428,6 +446,79 @@ def test_query_related_obj_attribute(client): for n in res: assert n in ['other', 'NeXus'] +def test_query_mulitple_attributes(client): + """Query multiple attributes in the SELECT clause. + """ + if not client._has_wsdl_type('fieldSet'): + pytest.skip("search for multiple fields not supported by this server") + + results = [["08100122-EF", "Durol single crystal", + datetime.datetime(2008, 3, 13, 10, 39, 42, tzinfo=tzinfo)], + ["10100601-ST", "Ni-Mn-Ga flat cone", + datetime.datetime(2010, 9, 30, 10, 27, 24, tzinfo=tzinfo)], + ["12100409-ST", "NiO SC OF1 JUH HHL", + datetime.datetime(2012, 7, 26, 15, 44, 24, tzinfo=tzinfo)]] + query = Query(client, "Investigation", + attributes=["name", "title", "startDate"], order=True) + print(str(query)) + res = client.search(query) + assert res == results + +def test_query_mulitple_attributes_related_obj(client): + """Query multiple attributes including attributes of related objects. + """ + if not client._has_wsdl_type('fieldSet'): + pytest.skip("search for multiple fields not supported by this server") + + results = [["08100122-EF", "e201215"], + ["08100122-EF", "e201216"], + ["10100601-ST", "e208339"], + ["10100601-ST", "e208341"], + ["10100601-ST", "e208342"]] + query = Query(client, "Dataset", + attributes=["investigation.name", "name"], order=True, + conditions={"investigation.startDate": "< '2011-01-01'"}) + print(str(query)) + res = client.search(query) + assert res == results + +def test_query_mulitple_attributes_oldicat_valueerror(client): + """Query class should raise ValueError if multiple attributes are + requested, but the ICAT server is too old to support this. + """ + if client._has_wsdl_type('fieldSet'): + pytest.skip("search for multiple fields is supported by this server") + + with pytest.raises(ValueError) as err: + query = Query(client, "Investigation", attributes=["name", "title"]) + err_pattern = r"\bICAT server\b.*\bnot support\b.*\bmultiple attributes\b" + assert re.search(err_pattern, str(err.value)) + +@pytest.mark.skipif(Version(pytest.__version__) < "3.9.0", + reason="pytest.deprecated_call() does not work properly") +def test_query_deprecated_kwarg_attribute(client): + """the keyword argument `attribute` to :class:`icat.query.Query` + is deprecated since 0.18.0. + """ + # create a reference using the new keyword argument + ref_query = Query(client, "Datafile", attributes="name") + with pytest.deprecated_call(): + query = Query(client, "Datafile", attribute="name") + assert str(query) == str(ref_query) + +@pytest.mark.skipif(Version(pytest.__version__) < "3.9.0", + reason="pytest.deprecated_call() does not work properly") +def test_query_deprecated_method_setAttribute(client): + """:meth:`icat.query.Query.setAttribute` is deprecated since 0.18.0. + """ + # create a reference using the new method + ref_query = Query(client, "Datafile") + ref_query.setAttributes("name") + with pytest.deprecated_call(): + query = Query(client, "Datafile") + query.setAttribute("name") + assert str(query) == str(ref_query) + @pytest.mark.dependency(depends=['get_investigation']) def test_query_aggregate_distinct_attribute(client): """Test DISTINCT on an attribute in the search result. @@ -437,7 +528,7 @@ def test_query_aggregate_distinct_attribute(client): """ require_icat_version("4.7.0", "SELECT DISTINCT in queries") query = Query(client, "Datafile", - attribute="datafileFormat.name", + attributes="datafileFormat.name", conditions={ "dataset.investigation.id": "= %d" % investigation.id }) print(str(query)) @@ -457,7 +548,7 @@ def test_query_aggregate_distinct_related_obj(client): """ require_icat_version("4.7.0", "SELECT DISTINCT in queries") query = Query(client, "Datafile", - attribute="datafileFormat", + attributes="datafileFormat", conditions={ "dataset.investigation.id": "= %d" % investigation.id }) print(str(query)) @@ -507,7 +598,8 @@ def test_query_aggregate_misc(client, attribute, aggregate, expected): require_icat_version("4.5.0", "SELECT related object's attribute") if "DISTINCT" in aggregate: require_icat_version("4.7.0", "SELECT DISTINCT in queries") - query = Query(client, "Datafile", attribute=attribute, aggregate=aggregate, + query = Query(client, "Datafile", + attributes=attribute, aggregate=aggregate, conditions={ "dataset.investigation.id": "= %d" % investigation.id }) print(str(query)) diff --git a/tests/test_09_deprecations.py b/tests/test_09_deprecations.py index 9052d0a7..f6465922 100644 --- a/tests/test_09_deprecations.py +++ b/tests/test_09_deprecations.py @@ -18,6 +18,8 @@ # - Predefined configuration variable configDir. # It's easier to test this in the setting of the test_01_config.py # module. +# - The attribute keyword argument and the setAttribute() method in +# class Query is tested in in the test_06_query.py module. @pytest.mark.skipif(sys.version_info >= (3, 4), reason="this Python version is not deprecated")