Repository: astropy/pyvo
Pull request #194 (2019-11-13 22:00:27)

Get auth docs building
For some reason, the docstrings for the auth classes aren't
being built into the docs.  Add a link from the main page to
get things generated.


Patch:
diff --git a/.travis.yml b/.travis.yml
index dedf76e..4b12421 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -43,7 +43,7 @@ env:
         - ASTROPY_VERSION=stable
         - MAIN_CMD='python setup.py'
         - SETUP_CMD='test'
-        - PIP_DEPENDENCIES='requests mimeparse requests_mock'
+        - PIP_DEPENDENCIES='pytest-astropy requests mimeparse requests_mock'
         - EVENT_TYPE='pull_request push'
         - CONDA_CHANNELS='astropy'
 
@@ -76,10 +76,10 @@ matrix:
 
           # try all python versions
         - os: linux
-          env: PYTHON_VERSION=3.6 NUMPY_VERSION=1.15
+          env: PYTHON_VERSION=3.6 NUMPY_VERSION=1.15 ASTROPY_VERSION=3.2
                PIP_DEPENDENCIES='requests mimeparse requests_mock contextlib2'
         - os: linux
-          env: PYTHON_VERSION=3.5 NUMPY_VERSION=1.14
+          env: PYTHON_VERSION=3.5 NUMPY_VERSION=1.14 ASTROPY_VERSION=2.0.16
 
         # Do a PEP8 test with flake8
         - os: linux
diff --git a/CHANGES.rst b/CHANGES.rst
index 98d902c..ed9ce8f 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,6 +1,15 @@
 1.1 (unreleased)
 ================
 
+- Add default for UWS version. [#199]
+
+- Handle description of None when describing a TAP service's tables. [#197]
+
+- Properly handle single string keywords value for regsearch(). [#201]
+
+- Add support for SIAv2. [#206]
+
+- Add kwargs to sia2. [#222]
 
 1.0 (2019-09-20)
 ================
diff --git a/LICENSE.rst b/LICENSE.rst
new file mode 100644
index 0000000..2de6b5e
--- /dev/null
+++ b/LICENSE.rst
@@ -0,0 +1,29 @@
+BSD 3-Clause License
+
+Copyright (c) 2020, Astropy-pyvo Developers 
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/docs/auth/index.rst b/docs/auth/index.rst
new file mode 100644
index 0000000..160ce78
--- /dev/null
+++ b/docs/auth/index.rst
@@ -0,0 +1,16 @@
+******************
+Auth (`pyvo.auth`)
+******************
+This module contains submodules which help handle auth when
+communicating with virtual observatory services.
+
+Reference/API
+=============
+
+.. automodapi:: pyvo.auth
+
+.. automodapi:: pyvo.auth.authsession
+
+.. automodapi:: pyvo.auth.authurls
+
+.. automodapi:: pyvo.auth.credentialstore
diff --git a/docs/index.rst b/docs/index.rst
index 13eca57..26e0539 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -121,3 +121,4 @@ Using `pyvo`
    dal/index
    registry/index
    io/index
+   auth/index
diff --git a/docs/registry/index.rst b/docs/registry/index.rst
index 97f0ac9..c3c0635 100644
--- a/docs/registry/index.rst
+++ b/docs/registry/index.rst
@@ -19,6 +19,10 @@ quasars.
 
 >>> services = regsearch(keywords=['quasar'])
 
+A single keyword can be specified as a single string instead of a list.
+
+>>> services = regsearch(keywords='quasar')
+
 Furthermore the search can be limited to a certain ``servicetype``, one of
 sia, ssa, scs, sla, tap.
 
diff --git a/pyvo/auth/__init__.py b/pyvo/auth/__init__.py
index 9dce85d..01f79b9 100644
--- a/pyvo/auth/__init__.py
+++ b/pyvo/auth/__init__.py
@@ -1,1 +1,7 @@
 # Licensed under a 3-clause BSD style license - see LICENSE.rst
+
+__all__ = ["AuthSession", "AuthURLs", "CredentialStore"]
+
+from .authsession import AuthSession
+from .authurls import AuthURLs
+from .credentialstore import CredentialStore
diff --git a/pyvo/auth/authsession.py b/pyvo/auth/authsession.py
index 841a29a..c518c3d 100644
--- a/pyvo/auth/authsession.py
+++ b/pyvo/auth/authsession.py
@@ -3,6 +3,8 @@ import logging
 from .authurls import AuthURLs
 from .credentialstore import CredentialStore
 
+__all__ = ["AuthSession"]
+
 
 class AuthSession:
     """
diff --git a/pyvo/auth/authurls.py b/pyvo/auth/authurls.py
index 6c5788b..7447939 100644
--- a/pyvo/auth/authurls.py
+++ b/pyvo/auth/authurls.py
@@ -3,6 +3,8 @@ import logging
 
 from . import securitymethods
 
+__all__ = ["AuthURLs"]
+
 
 class AuthURLs():
     """
diff --git a/pyvo/auth/credentialstore.py b/pyvo/auth/credentialstore.py
index e5d4c60..20dbbdd 100644
--- a/pyvo/auth/credentialstore.py
+++ b/pyvo/auth/credentialstore.py
@@ -4,6 +4,8 @@ from pyvo.utils.http import create_session
 
 from . import securitymethods
 
+__all__ = ["CredentialStore"]
+
 
 class CredentialStore(object):
     """
diff --git a/pyvo/dal/__init__.py b/pyvo/dal/__init__.py
index c8262ed..25624a6 100644
--- a/pyvo/dal/__init__.py
+++ b/pyvo/dal/__init__.py
@@ -1,5 +1,6 @@
 # Licensed under a 3-clause BSD style license - see LICENSE.rst
 from .sia import search as imagesearch
+from .sia2 import search as imagesearch2
 from .ssa import search as spectrumsearch
 from .sla import search as linesearch
 from .scs import search as conesearch
@@ -13,13 +14,14 @@ from .sla import SLAService, SLAQuery, SLAResults, SLARecord
 from .scs import SCSService, SCSQuery, SCSResults, SCSRecord
 from .tap import TAPService, TAPQuery, TAPResults, AsyncTAPJob
 
+
 from .exceptions import (
     DALAccessError, DALProtocolError, DALFormatError, DALServiceError,
     DALQueryError)
 
 __all__ = [
     "imagesearch", "spectrumsearch", "linesearch", "conesearch", "tablesearch",
-    "DALService",
+    "DALService", "sia2", "imagesearch2",
     "SIAService", "SSAService", "SLAService", "SCSService", "TAPService",
     "DALQuery",  "SIAQuery", "SSAQuery", "SLAQuery", "SCSQuery", "TAPQuery",
     "DALResults",
diff --git a/pyvo/dal/adhoc.py b/pyvo/dal/adhoc.py
index 8ab69de..388c690 100644
--- a/pyvo/dal/adhoc.py
+++ b/pyvo/dal/adhoc.py
@@ -3,6 +3,9 @@
 Datalink classes and mixins
 """
 import numpy as np
+import warnings
+import copy
+from collections import OrderedDict
 
 from .query import DALResults, DALQuery, DALService, Record
 from .exceptions import DALServiceError
@@ -10,13 +13,28 @@ from .vosi import AvailabilityMixin, CapabilityMixin
 from .params import find_param_by_keyword, get_converter
 
 from astropy.io.votable.tree import Param
+from astropy import units as u
 from astropy.units import Quantity, Unit
 from astropy.units import spectral as spectral_equivalencies
+from pyvo.utils.compat import ASTROPY_LT_4_1
 
 from astropy.io.votable.tree import Resource, Group
 from astropy.utils.collections import HomogeneousList
 
 from ..utils.decorators import stream_decode_content
+from .params import PosQueryParam, IntervalQueryParam, TimeQueryParam,\
+    EnumQueryParam
+from ..dam.obscore import POLARIZATION_STATES
+
+# calls to DataLink from the results pages are batched for performance
+# reasons. This is the size of a batch
+DATALINK_BATCH_CALL_SIZE = 50
+
+SODA_SYNC_IVOID = 'ivo://ivoa.net/std/SODA#sync-1.0'
+DATALINK_IVOID = 'ivo://ivoa.net/std/datalink'
+
+# MIME types
+DATALINK_MIME_TYPE = 'application/x-votable+xml;content=datalink'
 
 
 # monkeypatch astropy with group support in RESOURCE
@@ -113,6 +131,10 @@ class AdhocServiceResultsMixin:
         Resource
             The resource element describing the service.
         """
+        if ASTROPY_LT_4_1 and isinstance(ivo_id, str):
+            ivo_id = ivo_id.encode('utf-8')
+        if not ASTROPY_LT_4_1 and isinstance(ivo_id, bytes):
+            ivo_id = ivo_id.decode('utf-8')
         for adhocservice in self.iter_adhocservices():
             if any(
                     all((
@@ -149,12 +171,59 @@ class DatalinkResultsMixin(AdhocServiceResultsMixin):
     """
     Mixing for datalink functionallity for results classes.
     """
+
     def iter_datalinks(self):
         """
         Iterates over all datalinks in a DALResult.
         """
-        for record in self:
-            yield record.getdatalink()
+        # To reduce the number of calls to the Datalink service, multiple
+        # IDs are sent in batches. The appropriate batch size is not available
+        # as each service has its own criteria for determining the maximum
+        # number of IDs processed per request. To overcome this limitation,
+        # this method initially sends all the available IDs and if the
+        # response is partial, uses the size of the response as the batch
+        # size.
+        if not hasattr(self, '_datalink'):
+            try:
+                self._datalink = self.get_adhocservice_by_ivoid(DATALINK_IVOID)
+            except DALServiceError:
+                self._datalink = None
+        remaining_ids = []  # remaining IDs to processed
+        current_batch = None  # retrieved but not returned yet
+        current_ids = []  # retrieved but not returned
+        processed_ids = []  # retrived and returned IDs
+        batch_size = None  # size of the batch
+
+        for row in self:
+            if self._datalink:
+                if not current_ids:
+                    if batch_size is None:
+                        # first call.
+                        self.query = DatalinkQuery.from_resource(
+                            [_ for _ in self], self._datalink)
+                        remaining_ids = self.query['ID']
+                    if not remaining_ids:
+                        # we are done
+                        return
+                    if batch_size:
+                        # subsequent calls are limitted to batch size
+                        self.query['ID'] = remaining_ids[:batch_size]
+                    current_batch = self.query.execute(post=True)
+                    current_ids = list(OrderedDict.fromkeys(
+                        [_ for _ in current_batch.to_table()['ID']]))
+                    if not current_ids:
+                        raise DALServiceError(
+                            'Could not retrieve datalinks for: {}'.format(
+                                ', '.join([_ for _ in remaining_ids])))
+                    batch_size = len(current_ids)
+                id = current_ids.pop(0)
+                processed_ids.append(id)
+                remaining_ids.remove(id)
+                yield current_batch.clone_byid(id)
+            elif row.access_format == DATALINK_MIME_TYPE:
+                yield DatalinkResults.from_result_url(row.getdataurl())
+            else:
+                yield None
 
 
 class DatalinkRecordMixin:
@@ -165,8 +234,7 @@ class DatalinkRecordMixin:
     """
     def getdatalink(self):
         try:
-            datalink = self._results.get_adhocservice_by_ivoid(
-                b"ivo://ivoa.net/std/datalink")
+            datalink = self._results.get_adhocservice_by_ivoid(DATALINK_IVOID)
 
             query = DatalinkQuery.from_resource(self, datalink)
             return query.execute()
@@ -276,9 +344,9 @@ class DatalinkQuery(DALQuery):
     allowing the caller to take greater control of the result processing.
     """
     @classmethod
-    def from_resource(cls, row, resource, session=None, **kwargs):
+    def from_resource(cls, rows, resource, session=None, **kwargs):
         """
-        Creates a instance from a Record and a Datalink Resource.
+        Creates a instance from a number of records and a Datalink Resource.
 
         XML Hierarchy:
 
@@ -304,7 +372,13 @@ class DatalinkQuery(DALQuery):
         query_params = dict()
         for name, input_param in input_params.items():
             if input_param.ref:
-                query_params[name] = row[input_param.ref]
+                if isinstance(rows, list):
+                    query_params[name] = []
+                    for r in rows:
+                        query_params[name].append(r[input_param.ref])
+                else:
+                    # scalars are also accepted for backwards compatibility
+                    query_params[name] = rows[input_param.ref]
             elif np.isscalar(input_param.value) and input_param.value:
                 query_params[name] = input_param.value
             elif (
@@ -318,6 +392,8 @@ class DatalinkQuery(DALQuery):
         for name, query_param in kwargs.items():
             try:
                 input_param = find_param_by_keyword(name, input_params)
+                if input_param and query_param is None:
+                    del query_params[input_param.name]
                 converter = get_converter(input_param)
                 query_params[input_param.name] = converter.serialize(
                     query_param)
@@ -349,7 +425,7 @@ class DatalinkQuery(DALQuery):
         if responseformat:
             self["RESPONSEFORMAT"] = responseformat
 
-    def execute(self):
+    def execute(self, post=False):
         """
         submit the query and return the results as a DatalinkResults instance
 
@@ -363,7 +439,8 @@ class DatalinkQuery(DALQuery):
         DALFormatError
            for errors parsing the VOTable response
         """
-        return DatalinkResults(self.execute_votable(), url=self.queryurl, session=self._session)
+        return DatalinkResults(self.execute_votable(post),
+                               url=self.queryurl, session=self._session)
 
 
 class DatalinkResults(DatalinkResultsMixin, DALResults):
@@ -453,6 +530,41 @@ class DatalinkResults(DatalinkResultsMixin, DALResults):
             if record.semantics == semantics:
                 yield record
 
+    def clone_byid(self, id):
+        """
+        return a clone of the object with results and corresponding
+         resources matching a given id
+
+        Returns
+        -------
+        Sequence of DatalinkRecord
+            a sequence of dictionary-like wrappers containing the result record
+        """
+
+        copy_tb = copy.deepcopy(self.votable)
+        votable = copy_tb.get_first_table()
+        # find index of ID column
+        id_index = None
+        for index, field in enumerate(votable.fields):
+            if field.name == 'ID':
+                id_index = index
+        rows = [x for x in votable.array if x[id_index] == id]
+        votable.create_arrays(len(rows))
+        for index, row in enumerate(rows):
+            votable.array[index] = row
+        # now remove unreferenced services from resources
+        if ASTROPY_LT_4_1:
+            referenced_serviced = \
+                [x.decode('utf-8') for x in votable.array['service_def'] if x]
+        else:
+            referenced_serviced = \
+                [x for x in votable.array['service_def'] if x]
+        # remove customized that are not referenced by the current results
+        for x in copy_tb.resources:
+            if x.ID and x.ID not in referenced_serviced:
+                copy_tb.resources.remove(x)
+        return DatalinkResults(copy_tb)
+
     def getdataset(self, timeout=None):
         """
         return the first row with the dataset identified by semantics #this
@@ -486,21 +598,20 @@ class DatalinkResults(DatalinkResultsMixin, DALResults):
 
 class SodaRecordMixin:
     """
-    Mixin for soda functionallity for record classes.
+    Mixin for soda functionality for record classes.
     If used, it's result class must have
     `pyvo.dal.datalink.AdhocServiceResultsMixin` mixed in.
     """
     def _get_soda_resource(self):
         try:
-            return self._results.get_adhocservice_by_ivoid(
-                b"ivo://ivoa.net/std/SODA#sync")
+            return self._results.get_adhocservice_by_ivoid(SODA_SYNC_IVOID)
         except DALServiceError:
             pass
 
         # let it count as soda resource
         try:
             return self._results.get_adhocservice_by_ivoid(
-                b"ivo://ivoa.net/std/datalink#links")
+                "ivo://ivoa.net/std/datalink#links")
         except DALServiceError:
             pass
 
@@ -521,7 +632,7 @@ class SodaRecordMixin:
             try:
                 datalink_result = DatalinkResults.from_result_url(dataurl)
                 return datalink_result.get_adhocservice_by_ivoid(
-                    b"ivo://ivoa.net/std/SODA#sync")
+                    SODA_SYNC_IVOID)
             except DALServiceError:
                 pass
 
@@ -672,7 +783,42 @@ class DatalinkRecord(DatalinkRecordMixin, SodaRecordMixin, Record):
         return list(_get_input_params_from_resource(proc_resource).values())
 
 
-class SodaQuery(DatalinkQuery):
+class AxisParamMixin():
+    """
+    Stores the axis parameters (pos, band, time and pol) used in SODA
+    or SIAv2 queries
+    """
+    @property
+    def pos(self):
+        if not hasattr(self, '_pos'):
+            self._pos = PosQueryParam()
+            self['POS'] = self._pos.dal
+        return self._pos
+
+    @property
+    def band(self):
+        if not hasattr(self, '_band'):
+            self._band = IntervalQueryParam(unit=u.meter,
+                                            equivalencies=u.spectral())
+            self['BAND'] = self.band.dal
+        return self._band
+
+    @property
+    def time(self):
+        if not hasattr(self, '_time'):
+            self._time = TimeQueryParam()
+            self['TIME'] = self.time.dal
+        return self._time
+
+    @property
+    def pol(self):
+        if not hasattr(self, '_pol'):
+            self._pol = EnumQueryParam(POLARIZATION_STATES)
+            self['POL'] = self.pol.dal
+        return self._pol
+
+
+class SodaQuery(DatalinkQuery, AxisParamMixin):
     """
     a class for preparing a query to a SODA Service.
     """
@@ -703,25 +849,15 @@ class SodaQuery(DatalinkQuery):
 
     @circle.setter
     def circle(self, circle):
+        if len(circle) != 3:
+            raise ValueError(
+                "Range must be a sequence with exactly three values")
+        self['CIRCLE'] = PosQueryParam().get_dal_format(circle).\
+            replace('CIRCLE ', '')
         setattr(self, '_circle', circle)
         del self.range
         del self.polygon
 
-        if not isinstance(circle, Quantity):
-            valerr = ValueError(
-                "Circle must be a sequence with exactly three values")
-
-            try:
-                # assume degrees
-                circle = circle * Unit('deg')
-                if len(circle) != 3:
-                    raise valerr
-            except (ValueError, TypeError):
-                raise valerr
-
-        self['CIRCLE'] = ' '.join(
-            str(value) for value in circle.to(Unit('deg')).value)
-
     @circle.deleter
     def circle(self):
         if hasattr(self, '_circle'):
@@ -734,31 +870,25 @@ class SodaQuery(DatalinkQuery):
         """
         A rectangular range.
         """
+        warnings.warn(
+            "Use pos attribute instead",
+            DeprecationWarning
+        )
         return getattr(self, '_circle', None)
 
     @range.setter
     def range(self, range):
+        if len(range) != 4:
+            raise ValueError(
+                "Range must be a sequence with exactly four values")
+        self['POS'] = PosQueryParam().get_dal_format(range)
         setattr(self, '_range', range)
         del self.circle
         del self.polygon
 
-        if not isinstance(range, Quantity):
-            valerr = ValueError(
-                "Range must be a sequence with exactly four values")
-
-            try:
-                # assume degrees
-                range = range * Unit('deg')
-                if len(range) != 4:
-                    raise valerr
-            except (ValueError, TypeError):
-                raise valerr
-
-        self['POS'] = 'RANGE ' + ' '.join(
-            str(value) for value in range.to(Unit('deg')).value)
-
     @range.deleter
     def range(self):
+
         if hasattr(self, '_range'):
             delattr(self, '_range')
         if 'POS' in self and self['POS'].startswith('RANGE'):
@@ -774,27 +904,15 @@ class SodaQuery(DatalinkQuery):
 
     @polygon.setter
     def polygon(self, polygon):
+        if len(polygon) < 6:
+            raise ValueError(
+                "Polygon must be a sequence of at least six values")
+        self['POLYGON'] = PosQueryParam().get_dal_format(polygon).\
+            replace('POLYGON ', '')
         setattr(self, '_polygon', polygon)
         del self.circle
         del self.range
 
-        if not isinstance(polygon, Quantity):
-            valerr = ValueError(
-                'Polygon must be a sequence with at least six numeric values, '
-                'expressing pairs of ra and dec in degrees'
-            )
-
-            try:
-                # assume degrees
-                polygon = polygon * Unit('deg')
-                if len(polygon) < 3:
-                    raise valerr
-            except (ValueError, TypeError):
-                raise valerr
-
-        self['POLYGON'] = ' '.join(
-            str(value) for value in polygon.to(Unit('deg')).value)
-
     @polygon.deleter
     def polygon(self):
         if hasattr(self, '_polygon'):
diff --git a/pyvo/dal/params.py b/pyvo/dal/params.py
index d4ffe05..006a48d 100644
--- a/pyvo/dal/params.py
+++ b/pyvo/dal/params.py
@@ -3,7 +3,10 @@
 This file contains functionallity related to VOTABLE Params.
 """
 import numpy as np
+from collections import MutableSet
+import abc
 
+from astropy import units as u
 from astropy.units import Quantity, Unit
 from astropy.time import Time
 from astropy.io.votable.converters import (
@@ -84,7 +87,11 @@ class Converter:
         """
         Serialize for use in DAL Queries
         """
-        return str(value)
+        if isinstance(value, list):
+            # multiple values
+            return [str(_) for _ in value]
+        else:
+            return str(value)
 
 
 class Number(Converter):
@@ -206,3 +213,247 @@ class Polygon(Number):
             raise DALServiceError('Size is not a multiple of 3')
 
         return super().serialize(value)
+
+
+class AbstractDalQueryParam(MutableSet, metaclass=abc.ABCMeta):
+    """
+    Base class for a DAL parameter. In general, a DAL parameter allows
+    for multiple values which are OR-ed by the service. As such, the class
+    behaves like a set that stores all the values.
+
+    When a value is added to an attribute, it is validated and formatted
+    to conform to the using service (SIAv2 or SODA) and value errors might be
+    raised. The `dal` attribute stores the current list of formatted
+    attributes.
+
+    Subclasses must override the `dal_formatter` method that formats values
+    for serialization. That includes unit conversions and string representation
+    Duplicates in the set are determine based on the formatted DAL
+    representation of the value.
+    """
+    def __init__(self, values=()):
+        """
+        Parameters
+        ----------
+        values : sequence, optional
+            An initial set of values.
+        """
+        self.dal = []
+        self._data = []
+        for item in values:
+            self.add(item)
+        super().__init__()
+
+    @abc.abstractmethod
+    def get_dal_format(self, item):
+        """
+        Method to be provided by subclasses
+        """
+        return
+
+    def add(self, item):
+        if item in self:
+            return
+        self._data.append(item)
+        self.dal.append(self.get_dal_format(item))
+
+    def discard(self, item):
+        # relies on the fact that both the raw and the formatted
+        # attribute lists have the items in the same order. It
+        # uses the formatted list (normalized units) to get the index.
+        index = self.dal.index(self.get_dal_format(item))
+        self._data.pop(index)
+        self.dal.pop(index)
+
+    def __iter__(self):
+        return iter(self._data)
+
+    def __len__(self):
+        return len(self._data)
+
+    def __contains__(self, item):
+        # check dal format for duplications since the quantities are known
+        return self.get_dal_format(item) in self.dal
+
+
+class StrQueryParam(AbstractDalQueryParam):
+    """
+    Representation of a unitless, single-value parameter. The formatter is
+    just a str() cast
+    """
+    def get_dal_format(self, val):
+        return str(val)
+
+
+class PosQueryParam(AbstractDalQueryParam):
+    """
+    Representation of a position parameter. Depending on the number
+    of entries, the resulting DAL format is CIRCLE, RANGE or POLYGON.
+    """
+    def get_dal_format(self, val):
+        """
+        formats the tuple values into a string to be sent to the service
+        entries in values are either quantities or assumed to be degrees
+        """
+        self._validate_pos(val)
+        if len(val) == 3:
+            shape = 'CIRCLE'
+        elif len(val) == 4:
+            shape = 'RANGE'
+        elif len(val) > 5 and not len(val) % 2:
+            shape = 'POLYGON'
+        else:
+            raise ValueError(
+                'Invalid shape {}. Tuple with 3 (CIRCLE), 4 (RANGE) or '
+                'even 6 and above (POLYGON) accepted.'.format(val))
+        return '{} {}'.format(shape, ' '.join(
+            [str(val.to(u.deg).value) if isinstance(val, Quantity) else
+             str((val*u.deg).value) for val in val]))
+
+    def _validate_pos(self, pos):
+        """
+        validates position
+
+        This has probably done already somewhere else
+        """
+        if len(pos) == 3:
+            self._validate_ra(pos[0])
+            self._validate_dec(pos[1])
+            if not isinstance(pos[2], Quantity):
+                radius = pos[2] * u.deg
+            else:
+                radius = pos[2]
+            if radius <= 0*u.deg or radius.to(u.deg) > 90*u.deg:
+                raise ValueError('Invalid circle radius: {}'.format(radius))
+        elif len(pos) == 4:
+            ra_min = pos[0] if isinstance(pos[0], Quantity) else pos[0] * u.deg
+            ra_max = pos[1] if isinstance(pos[1], Quantity) else pos[1] * u.deg
+            dec_min = pos[2] if isinstance(pos[2], Quantity) \
+                else pos[2] * u.deg
+            dec_max = pos[3] if isinstance(pos[3], Quantity) \
+                else pos[3] * u.deg
+            self._validate_ra(ra_min)
+            self._validate_ra(ra_max)
+            if ra_max.to(u.deg) < ra_min.to(u.deg):
+                raise ValueError('min > max in ra range: '.format(ra_min,
+                                                                  ra_max))
+            self._validate_dec(dec_min)
+            self._validate_dec(dec_max)
+            if dec_max.to(u.deg) < dec_min.to(u.deg):
+                raise ValueError('min > max in dec range: '.format(dec_min,
+                                                                   dec_max))
+        else:
+            for i, m in enumerate(pos):
+                if i % 2:
+                    self._validate_dec(m)
+                else:
+                    self._validate_ra(m)
+
+    def _validate_ra(self, ra):
+        if not isinstance(ra, Quantity):
+            ra = ra * u.deg
+        if ra.to(u.deg).value < 0 or ra.to(u.deg).value > 360.0:
+            raise ValueError('Invalid ra: {}'.format(ra))
+
+    def _validate_dec(self, dec):
+        if not isinstance(dec, Quantity):
+            dec = dec * u.deg
+        if dec.to(u.deg).value < -90.0 or dec.to(u.deg).value > 90.0:
+            raise ValueError('Invalid dec: {}'.format(dec))
+
+
+class IntervalQueryParam(AbstractDalQueryParam):
+    """
+    Representation of an interval DAL parameter.
+    """
+    def __init__(self, unit=None, equivalencies=None):
+        """
+        Parameters
+        ----------
+        unit : `astropy.unit`
+            Unit this paramter is represented in DAL format
+        equivalencies: list
+            List of equivalencies for unit conversion
+        """
+        self._unit = unit
+        self._equivalencies = equivalencies
+        super().__init__()
+
+    def get_dal_format(self, val):
+        if isinstance(val, (tuple, Quantity)):
+            if len(val) == 1:
+                high = low = val[0]
+            elif len(val) == 2:
+                low = val[0]
+                high = val[1]
+            else:
+                raise ValueError('Too few/many values in interval attribute: '.
+                                 format(val))
+        else:
+            high = low = val
+        if isinstance(low, (int, float)) and isinstance(high, (int, float))\
+                and low > high:
+            raise ValueError('Invalid interval: min({}) > max({})'.format(
+                low, high))
+        if self._unit:
+            if not isinstance(low, Quantity):
+                low = low*self._unit
+            low = low.to(self._unit, equivalencies=self._equivalencies).value
+            if not isinstance(high, Quantity):
+                high = high*self._unit
+            high = high.to(self._unit, equivalencies=self._equivalencies).value
+
+            if low > high:
+                # interval could become invalid during transform (e.g. GHz->m)
+                low, high = high, low
+
+        return '{} {}'.format(low, high)
+
+
+class TimeQueryParam(AbstractDalQueryParam):
+    """
+    Representation of a timestamp parameter.
+    """
+    def get_dal_format(self, val):
+        if isinstance(val, tuple):
+            if len(val) == 1:
+                max_time = min_time = val[0]
+            elif len(val) == 2:
+                min_time = val[0]
+                max_time = val[1]
+            else:
+                raise ValueError('Too few/many members in time attribute: '.
+                                 format(val))
+        else:
+            max_time = min_time = val
+
+        if not isinstance(min_time, Time):
+            min_time = Time(min_time)
+        if not isinstance(max_time, Time):
+            max_time = Time(max_time)
+        if min_time > max_time:
+            raise ValueError('Invalid time interval: min({}) > max({})'.format(
+                min_time, max_time
+            ))
+        return '{} {}'.format(min_time.mjd, max_time.mjd)
+
+
+class EnumQueryParam(AbstractDalQueryParam):
+    """
+    Representation of an enum parameter
+    """
+    def __init__(self, allowed_values):
+        """
+        Parameters
+        ----------
+        allowed_values : sequence
+            Sequence of allowed values for the enum
+        """
+        self._allowed = allowed_values
+        super().__init__()
+
+    def get_dal_format(self, val):
+        if val not in self._allowed:
+            raise ValueError('{} not a valid value from: {}'.
+                             format(val, self._allowed))
+        return str(val)
diff --git a/pyvo/dal/query.py b/pyvo/dal/query.py
index 0ce777d..b3bc2ed 100644
--- a/pyvo/dal/query.py
+++ b/pyvo/dal/query.py
@@ -181,14 +181,14 @@ class DALQuery(dict):
         return out
 
     @stream_decode_content
-    def execute_stream(self):
+    def execute_stream(self, post=False):
         """
         Submit the query and return the raw response as a file stream.
 
         No exceptions are raised here because non-2xx responses might still
         contain payload. They can be raised later by calling ``raise_if_error``
         """
-        response = self.submit()
+        response = self.submit(post=post)
 
         try:
             response.raise_for_status()
@@ -198,17 +198,22 @@ class DALQuery(dict):
         finally:
             return response.raw
 
-    def submit(self):
+    def submit(self, post=False):
         """
         does the actual request
         """
         url = self.queryurl
         params = {k: v for k, v in self.items()}
 
-        response = self._session.get(url, params=params, stream=True)
+        if post:
+            response = self._session.post(url, data=params, stream=True,
+                                          allow_redirects=True)
+        else:
+            response = self._session.get(url, params=params, stream=True,
+                                         allow_redirects=True)
         return response
 
-    def execute_votable(self):
+    def execute_votable(self, post=False):
         """
         Submit the query and return the results as an AstroPy votable instance.
         As this is the level where qualified error messages are available,
@@ -234,7 +239,7 @@ class DALQuery(dict):
         DALQueryError
         """
         try:
-            return votableparse(self.execute_stream().read)
+            return votableparse(self.execute_stream(post=post).read)
         except Exception as e:
             self.raise_if_error()
             raise DALFormatError(e, self.queryurl)
diff --git a/pyvo/dal/sia2.py b/pyvo/dal/sia2.py
new file mode 100644
index 0000000..4faef97
--- /dev/null
+++ b/pyvo/dal/sia2.py
@@ -0,0 +1,1089 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+"""
+A module for searching for images in a remote archive.
+
+A Simple Image Access (SIA) service allows a client to search for
+images based on a number of criteria/parameters. The results are
+represented in `pyvo.dam.obscore.ObsCoreMetadata` format.
+
+The ``SIAService`` class can represent a specific service available at a URL
+endpoint.
+"""
+
+from astropy import units as u
+from astropy import time
+
+from .query import DALResults, DALQuery, DALService, Record
+from .adhoc import DatalinkResultsMixin, AxisParamMixin, SodaRecordMixin,\
+    DatalinkRecordMixin
+from .params import IntervalQueryParam, StrQueryParam, EnumQueryParam
+from .vosi import AvailabilityMixin, CapabilityMixin
+from ..dam import ObsCoreMetadata, CALIBRATION_LEVELS
+
+
+__all__ = ["search", "SIAService", "SIAQuery", "SIAResults", "ObsCoreRecord"]
+
+SIA2_STANDARD_ID = 'ivo://ivoa.net/std/SIA#query-2.0'
+
+
+SIA_PARAMETERS_DESC = """
+pos : single or list of tuples
+    angle units (default: deg)
+    the positional region(s) to be searched for data. Each region can
+    be expressed as a tuple representing a CIRCLE, RANGE or POLYGON as
+    follows:
+    (ra, dec, radius) - for CIRCLE. (angle units - defaults to)
+    (long1, long2, lat1, lat2) - for RANGE (angle units required)
+    (ra, dec, ra, dec, ra, dec ... ) ra/dec points for POLYGON all
+    in angle units
+band : scalar, tuple(interval) or list of tuples
+    (spectral units (default: meter)
+    the energy interval(s) to be searched for data.
+time : single or list of `~astropy.time.Time` or compatible strings
+    the time interval(s) to be searched for data.
+pol : single or list of str from `pyvo.dam.obscore.POLARIZATION_STATES`
+    the polarization state(s) to be searched for data.
+field_of_view : single or list of tuples
+    angle units (default arcsec)
+    the range(s) of field of view (size) to be searched for data
+spatial_resolution : single or list of tuples
+    angle units required
+    the range(s) of spatial resolution to be searched for data
+spectral_resolving_power : single or list of tuples
+    the range(s) of spectral resolving power to be searched for data
+exptime : single or list of tuples
+    time units (default: second)
+    the range(s) of exposure times to be searched for data
+timeres : single of list of tuples
+    time units (default: second)
+    the range(s) of temporal resolution to be searched for data
+publisher_did : single or list of str
+    specifies the unique identifier of dataset(s). It is global because
+    it must include information regarding the publisher
+    (obs_publisher_did in ObsCore)
+collection : single or list of str
+    name of the collection that the data belongs to
+facility : single or list of str
+    specifies the name of the facility (usually telescope) where
+    the data was acquired.
+instrument : single or list of str
+    specifies the name of the instrument with which the data was
+    acquired.
+data_type : 'image'|'cube'
+    specifies the type of the data
+calib_level : single or list from enum
+    `pyvo.dam.obscore.CALIBRATION_LEVELS`
+    specifies the calibration level of the data. Can be a single value
+    or a list of values
+target_name : single or list of str
+    specifies the name of the target (e.g. the intention of the
+    original science program or observation)
+res_format : single or list of strings
+    specifies response format(s).
+max_records : int
+    allows the client to limit the number or records in the response
+**kwargs : custom query parameters
+    single or a list of values (or tuples for intervals) custom query
+    parameters that a specific service accepts. The values of the
+    parameters need to follow the SIAv2 format and represent the
+    appropriate quantities (where applicable).
+"""
+
+
+def search(url, pos=None, band=None, time=None, pol=None,
+           field_of_view=None, spatial_resolution=None,
+           spectral_resolving_power=None, exptime=None,
+           timeres=None, publisher_did=None, facility=None, collection=None,
+           instrument=None, data_type=None, calib_level=None,
+           target_name=None, res_format=None, maxrec=None, session=None,
+           **kwargs):
+    """
+    submit a simple SIA query to a SIAv2 compatible service
+
+    Parameters
+    ----------
+
+    url : str
+       url of the SIA service (base or endpoint)
+    _SIA2_PARAMETERS
+
+    """
+    service = SIAService(url)
+    return service.search(pos=pos, band=band, time=time, pol=pol,
+                          field_of_view=field_of_view,
+                          spatial_resolution=spatial_resolution,
+                          spectral_resolving_power=spectral_resolving_power,
+                          exptime=exptime, timeres=timeres,
+                          publisher_did=publisher_did,
+                          facility=facility, collection=collection,
+                          instrument=instrument, data_type=data_type,
+                          calib_level=calib_level, target_name=target_name,
+                          res_format=res_format, maxrec=maxrec,
+                          session=session, **kwargs)
+
+
+search.__doc__ = search.__doc__.replace('_SIA2_PARAMETERS',
+                                        SIA_PARAMETERS_DESC)
+
+
+def _tolist(value):
+    # return value as a list - is there something in Python to do that?
+    if not value:
+        return []
+    if isinstance(value, list):
+        return value
+    return [value]
+
+
+class SIAService(DALService, AvailabilityMixin, CapabilityMixin):
+    """
+    a representation of an SIA2 service
+    """
+
+    def __init__(self, baseurl, session=None):
+        """
+        instantiate an SIA service
+
+        Parameters
+        ----------
+        url : str
+           url - URL of the SIA service (base or query endpoint)
+        session : object
+           optional session to use for network requests
+        """
+
+        super().__init__(baseurl, session=session)
+
+        # Check if the session has an update_from_capabilities attribute.
+        # This means that the session is aware of IVOA capabilities,
+        # and can use this information in processing network requests.
+        # One such usecase for this is auth.
+        if hasattr(self._session, 'update_from_capabilities'):
+            self._session.update_from_capabilities(self.capabilities)
+
+        self.query_ep = None  # service query end point
+        for cap in self.capabilities:
+            # assumes that the access URL is the same regardless of the
+            # authentication method except BasicAA which is not supported
+            # in pyvo. So pick any access url as long as it's not
+            if cap.standardid.lower() == SIA2_STANDARD_ID.lower():
+                for interface in cap.interfaces:
+                    if interface.accessurls and not \
+                        [m for m in interface.securitymethods if
+                         m.standardid != 'ivo://ivoa.net/sso#BasicAA']:
+                        self.query_ep = interface.accessurls[0].content
+                        break
+
+    def search(self, pos=None, band=None, time=None, pol=None,
+               field_of_view=None, spatial_resolution=None,
+               spectral_resolving_power=None, exptime=None,
+               timeres=None, publisher_did=None, facility=None, collection=None,
+               instrument=None, data_type=None, calib_level=None,
+               target_name=None, res_format=None, maxrec=None, session=None,
+               **kwargs):
+        """
+        Performs a SIAv2 search against a SIAv2 service
+
+        See Also
+        --------
+        pyvo.dal.sia2.SIAQuery
+
+        """
+        return SIAQuery(self.query_ep, pos=pos, band=band,
+                        time=time, pol=pol,
+                        field_of_view=field_of_view,
+                        spatial_resolution=spatial_resolution,
+                        spectral_resolving_power=spectral_resolving_power,
+                        exptime=exptime, timeres=timeres,
+                        publisher_did=publisher_did,
+                        facility=facility, collection=collection,
+                        instrument=instrument, data_type=data_type,
+                        calib_level=calib_level, target_name=target_name,
+                        res_format=res_format, maxrec=maxrec,
+                        session=session, **kwargs).execute()
+
+
+class SIAQuery(DALQuery, AxisParamMixin):
+    """
+    a class very similar to :py:attr:`~pyvo.dal.query.SIAQuery` class but
+    used to interact with SIAv2 services.
+    """
+
+    def __init__(self, url, pos=None, band=None, time=None, pol=None,
+                 field_of_view=None, spatial_resolution=None,
+                 spectral_resolving_power=None, exptime=None,
+                 timeres=None, publisher_did=None,
+                 facility=None, collection=None,
+                 instrument=None, data_type=None, calib_level=None,
+                 target_name=None, res_format=None, maxrec=None,
+                 session=None, **kwargs):
+        """
+        initialize the query object with a url and the given parameters
+
+        Note: The majority of the attributes represent constraints used to
+        query the SIA service and are represented through lists. Multiple value
+        attributes are OR-ed in the query, however the values of different
+        attributes are AND-ed. Intervals are represented with tuples and
+        open-ended intervals should be expressed with float("-inf") or
+        float("inf"). Eg. For all values less than or equal to 600 use
+        (float(-inf), 600)
+
+        Additional attribute constraints can be specified (or removed) after
+        this object has been created using the *.add and *_del methods.
+
+        Parameters
+        ----------
+        url : url where to send the query request to
+        _SIA2_PARAMETERS
+        session : object
+           optional session to use for network requests
+
+        Returns
+        -------
+        SIAResults
+            a container holding a table of matching image records. Records are
+            represented in IVOA ObsCore format
+
+        Raises
+        ------
+        DALServiceError
+            for errors connecting to or communicating with the service
+        DALQueryError
+            if the service responds with an error,
+            including a query syntax error.
+
+        See Also
+        --------
+        SIAResults
+        pyvo.dal.query.DALServiceError
+        pyvo.dal.query.DALQueryError
+
+        """
+        super().__init__(url, session=session)
+
+        for pp in _tolist(pos):
+            self.pos.add(pp)
+
+        for bb in _tolist(band):
+            self.band.add(bb)
+
+        for tt in _tolist(time):
+            self.time.add(tt)
+
+        for pp in _tolist(pol):
+            self.pol.add(pp)
+
+        for ff in _tolist(field_of_view):
+            self.field_of_view.add(ff)
+
+        for sp in _tolist(spatial_resolution):
+            self.spatial_resolution.add(sp)
+
+        for sr in _tolist(spectral_resolving_power):
+            self.spectral_resolving_power.add(sr)
+
+        for et in _tolist(exptime):
+            self.exptime.add(et)
+
+        for tr in _tolist(timeres):
+            self.timeres.add(tr)
+
+        for ii in _tolist(publisher_did):
+            self.publisher_did.add(ii)
+
+        for ff in _tolist(facility):
+            self.facility.add(ff)
+
+        for col in _tolist(collection):
+            self.collection.add(col)
+
+        for inst in _tolist(instrument):
+            self.instrument.add(inst)
+
+        for dt in _tolist(data_type):
+            self.data_type.add(dt)
+
+        for cal in _tolist(calib_level):
+            self.calib_level.add(cal)
+
+        for tt in _tolist(target_name):
+            self.target_name.add(tt)
+
+        for rf in _tolist(res_format):
+            self.res_format.add(rf)
+
+        for name, value in kwargs.items():
+            custom_arg = []
+            for kw in _tolist(value):
+                if isinstance(kw, tuple):
+                    val = '{} {}'.format(kw[0], kw[1])
+                else:
+                    val = str(kw)
+                custom_arg.append(val)
+            self[name] = custom_arg
+
+        self.maxrec = maxrec
+
+    __init__.__doc__ = \
+        __init__.__doc__.replace('_SIA2_PARAMETERS', SIA_PARAMETERS_DESC)
+
+    @property
+    def field_of_view(self):
+        if not hasattr(self, '_fov'):
+            self._fov = IntervalQueryParam(u.deg)
+            self['FOV'] = self._fov.dal
+        return self._fov
+
+    @property
+    def spatial_resolution(self):
+        if not hasattr(self, '_spatres'):
+            self._spatres = IntervalQueryParam(u.arcsec)
+            self['SPATRES'] = self._spatres.dal
+        return self._spatres
+
+    @property
+    def spectral_resolving_power(self):
+        if not hasattr(self, '_specrp'):
+            self._specrp = IntervalQueryParam()
+            self['SPECRP'] = self._specrp.dal
+        return self._specrp
+
+    @property
+    def exptime(self):
+        if not hasattr(self, '_exptime'):
+            self._exptime = IntervalQueryParam(u.second)
+            self['EXPTIME'] = self._exptime.dal
+        return self._exptime
+
+    @property
+    def timeres(self):
+        if not hasattr(self, '_timeres'):
+            self._timeres = IntervalQueryParam(u.second)
+            self['TIMERES'] = self._timeres.dal
+        return self._timeres
+
+    @property
+    def publisher_did(self):
+        if not hasattr(self, '_publisher_did'):
+            self._publisher_did = StrQueryParam()
+            self['ID'] = self._publisher_did.dal
+        return self._publisher_did
+
+    @property
+    def facility(self):
+        if not hasattr(self, '_facility'):
+            self._facility = StrQueryParam()
+            self['FACILITY'] = self._facility.dal
+        return self._facility
+
+    @property
+    def collection(self):
+        if not hasattr(self, '_collection'):
+            self._collection = StrQueryParam()
+            self['COLLECTION'] = self._collection.dal
+        return self._collection
+
+    @property
+    def instrument(self):
+        if not hasattr(self, '_instrument'):
+            self._instrument = StrQueryParam()
+            self['INSTRUMENT'] = self._instrument.dal
+        return self._instrument
+
+    @property
+    def data_type(self):
+        if not hasattr(self, '_data_type'):
+            self._data_type = StrQueryParam()
+            self['DPTYPE'] = self._data_type.dal
+        return self._data_type
+
+    @property
+    def calib_level(self):
+        if not hasattr(self, '_cal'):
+            self._cal = EnumQueryParam(CALIBRATION_LEVELS)
+            self['CALIB'] = self._cal.dal
+        return self._cal
+
+    @property
+    def target_name(self):
+        if not hasattr(self, '_target'):
+            self._target_name = StrQueryParam()
+            self['TARGET'] = self._target_name.dal
+        return self._target_name
+
+    @property
+    def res_format(self):
+        if not hasattr(self, '_res_format'):
+            self._res_format = StrQueryParam()
+            self['FORMAT'] = self._res_format.dal
+        return self._res_format
+
+    @property
+    def maxrec(self):
+        return self._maxrec
+
+    @maxrec.setter
+    def maxrec(self, val):
+        if not val:
+            return
+        if not isinstance(val, int) and val > 0:
+            raise ValueError('maxrec {} must be positive int'.format(val))
+        self._maxrec = val
+        self['MAXREC'] = str(val)
+
+    def execute(self):
+        """
+        submit the query and return the results as a SIAResults instance
+
+        Raises
+        ------
+        DALServiceError
+           for errors connecting to or communicating with the service
+        DALQueryError
+           for errors either in the input query syntax or
+           other user errors detected by the service
+        DALFormatError
+           for errors parsing the VOTable response
+        """
+        return SIAResults(self.execute_votable(), url=self.queryurl, session=self._session)
+
+
+class SIAResults(DatalinkResultsMixin, DALResults):
+    """
+    The list of matching images resulting from an image (SIA) query.
+    Each record contains a set of metadata that describes an available
+    image matching the query constraints.  The number of records in
+    the results is available via the :py:attr:`nrecs` attribute or by
+    passing it to the Python built-in ``len()`` function.
+
+    This class supports iterable semantics; thus,
+    individual records (in the form of
+    :py:class:`~pyvo.dal.sia2.ObsCoreRecord` instances) are typically
+    accessed by iterating over an ``SIAResults`` instance.
+
+    >>> results = pyvo.imagesearch(url, pos=[12.24, -13.1], size=0.1)
+    >>> for image in results:
+    ...     print("{0}: {1}".format(image.title, title.getdataurl()))
+
+    Alternatively, records can be accessed randomly via
+    :py:meth:`getrecord` or through a Python Database API (v2)
+    Cursor (via :py:meth:`~pyvo.dal.query.DALResults.cursor`).
+    Column-based data access is possible via the
+    :py:meth:`~pyvo.dal.query.DALResults.getcolumn` method.
+
+    ``SIAResults`` is essentially a wrapper around an Astropy
+    :py:mod:`~astropy.io.votable`
+    :py:class:`~astropy.io.votable.tree.Table` instance where the
+    columns contain the various metadata describing the images.
+    One can access that VOTable directly via the
+    :py:attr:`~pyvo.dal.query.DALResults.votable` attribute.  Thus,
+    when one retrieves a whole column via
+    :py:meth:`~pyvo.dal.query.DALResults.getcolumn`, the result is
+    a Numpy array.  Alternatively, one can manipulate the results
+    as an Astropy :py:class:`~astropy.table.table.Table` via the
+    following conversion:
+
+    >>> table = results.votable.to_table()
+
+    ``SIAResults`` supports the array item operator ``[...]`` in a
+    read-only context.  When the argument is numerical, the result
+    is an
+    :py:class:`~pyvo.dal.sia2.ObsCoreRecord` instance, representing the
+    record at the position given by the numerical index.  If the
+    argument is a string, it is interpreted as the name of a column,
+    and the data from the column matching that name is returned as
+    a Numpy array.
+    """
+
+    def getrecord(self, index):
+        """
+        return a representation of a sia result record that follows
+        dictionary semantics. The keys of the dictionary are those returned by
+        this instance's fieldnames attribute. The returned record has
+        additional image-specific properties
+
+        Parameters
+        ----------
+        index : int
+           the integer index of the desired record where 0 returns the first
+           record
+
+        Returns
+        -------
+        ObsCoreMetadataRecord
+           a dictionary-like wrapper containing the result record metadata.
+
+        Raises
+        ------
+        IndexError
+           if index is negative or equal or larger than the number of rows in
+           the result table.
+
+        See Also
+        --------
+        Record
+        """
+        return ObsCoreRecord(self, index, session=self._session)
+
+
+class ObsCoreRecord(SodaRecordMixin, DatalinkRecordMixin, Record,
+                    ObsCoreMetadata):
+    """
+    a dictionary-like container for data in a record from the results of an
+    image (SIAv2) search, describing an available image in ObsCore format.
+
+    The commonly accessed metadata which are stadardized by the SIA
+    protocol are available as attributes.  If the metadatum accessible
+    via an attribute is not available, the value of that attribute
+    will be None.  All metadata, including non-standard metadata, are also
+    acessible via the ``get(`` *key* ``)`` function (or the [*key*]
+    operator) where *key* is table column name.
+    """
+
+    #          OBSERVATION INFO
+    @property
+    def dataproduct_type(self):
+        """
+        Data product (file content) primary type. This is coded as a string
+        that conveys a general idea of the content and organization of a
+        dataset.
+        """
+        return self.get('dataproduct_type', decode=True)
+
+    @property
+    def dataproduct_subtype(self):
+        """
+        Data product more specific type
+        """
+        return self.get('dataproduct_subtype', decode=True, default=None)
+
+    @property
+    def calib_level(self):
+        """
+        Calibration level of the observation: in {0, 1, 2, 3, 4}
+        """
+        return self.get('calib_level')
+
+    #          TARGET INFO
+    @property
+    def target_name(self):
+        """
+        The target_name attribute contains the name of the target of the
+        observation, if any. This is typically the name of an astronomical
+        object, but could be the name of a survey field.
+        The target name is most useful for output, to identify the target of
+        an observation to the user. In queries it is generally better to refer
+        to astronomical objects by position, using a name resolver to convert
+        the target name into a coordinate (when possible).
+        """
+        return self.get('target_name', decode=True)
+
+    @property
+    def target_class(self):
+        """
+        This field indicates the type of object that was pointed for this
+        observation. It is a string with possible values defined in a special
+        vocabulary set to be defined: list of object classes (or types) used
+        by the SIMBAD database, NED or defined in another IVOA vocabulary.
+        """
+        return self.get('target_class', decode=True, default=None)
+
+    #          DATA DESCRIPTION
+    @property
+    def obs_id(self):
+        """
+        Collection specific internal ID given by the ObsTAP service
+        """
+        return self.get('obs_id', decode=True)
+
+    @property
+    def obs_title(self):
+        """
+        Brief description of dataset in free format
+        """
+        return self.get('obs_title', decode=True, default=None)
+
+    @property
+    def obs_collection(self):
+        """
+        The name of the collection (DataID.Collection) identifies the data
+        collection to which the data product belongs. A data collection can be
+        any collection of datasets which are alike in some fashion. Typical
+        data collections might be all the data from a particular telescope,
+        instrument, or survey. The value is either the registered shortname
+        for the data collection, the full registered IVOA identifier for the
+        collection, or a data provider defined short name for the collection.
+        Examples: HST/WFPC2, VLT/FORS2, CHANDRA/ACIS-S, etc.
+        """
+        return self.get('obs_collection', decode=True)
+
+    @property
+    def obs_create_date(self):
+        """
+        Date when the dataset was created
+        """
+        cd = self.get('obs_create_date', default=None)
+        return cd if not cd else time.Time(cd)
+
+    @property
+    def obs_creator_name(self):
+        """
+        The name of the institution or entity which created the dataset.
+        """
+        return self.get('obs_creator_name', decode=True, default=None)
+
+    @property
+    def obs_creator_did(self):
+        """
+        IVOA dataset identifier given by its creator.
+        """
+        return self.get('obs_creator_did', decode=True, default=None)
+
+    #         CURATION INFORMATION
+    @property
+    def obs_release_date(self):
+        """
+        Observation release date
+        """
+        rt = self.get('obs_release_date', default=None, decode=True)
+        return rt if not rt else time.Time(rt)
+
+    @property
+    def obs_publisher_did(self):
+        """
+        ID for the Dataset assigned by the publisher. Note that data from
+        a source (creator_did) can be published by multiple publishers
+        and have assigned multiple publisher data IDs.
+        """
+        return self.get('obs_publisher_did', decode=True)
+
+    @property
+    def publisher_id(self):
+        """
+        IVOA-ID for the Publisher. It will also be globally unique since each
+        publisher has a unique registered publisher ID
+        """
+        return self.get('publisher_id', decode=True, default=None)
+
+    @property
+    def bib_reference(self):
+        """
+        URL or bibcode for documentation. This is a forward link to major
+        publications which reference the dataset.
+        """
+        return self.get('bib_reference', decode=True, default=None)
+
+    @property
+    def data_rights(self):
+        """
+        This parameter allows mentioning the availability of a dataset.
+        Possible values are: public, secure, or proprietary.
+        """
+        return self.get('data_rights', decode=True, default=None)
+
+    #           ACCESS INFORMATION
+    @property
+    def access_url(self):
+        """
+        The access_url column contains a URL that can be used to download the
+        data product (as a file of some sort). Access URLs are not guaranteed
+        to remain valid and unchanged indefinitely. To access a specific data
+        product after a period of time (e.g., days or weeks) a query should be
+        performed to obtain a fresh access URL.
+        """
+        return self.get('access_url', decode=True)
+
+    @property
+    def access_format(self):
+        """
+        Content format of the dataset. The value of access_format should be a
+        MIME type, either a standard MIME type, an extended MIME type from
+        the above table, or a new custom MIME type defined by the data
+        provider.
+        """
+        return self.get('access_format', decode=True)
+
+    @property
+    def access_estsize(self):
+        """
+        The approximate size (in kilobytes) of the file available via the
+        access_url. This is used only to gain some idea of the size of a data
+        product before downloading it, hence only an approximate value is
+        required. Provision of dataset size estimates is important whenever it
+        is possible that datasets can be very large.
+        """
+        return self.get('access_estsize')*1000*u.byte
+
+    #           SPATIAL CHARACTERISATION
+    @property
+    def s_ra(self):
+        """
+        ICRS Right Ascension of the center of the observation
+        """
+        return self.get('s_ra')*u.deg
+
+    @property
+    def s_dec(self):
+        """
+        CRS Declination of the center of the observation
+        """
+        return self.get('s_dec')*u.deg
+
+    @property
+    def s_fov(self):
+        """
+        Approximate size of the covered region as the diameter of a containing
+        circle. For most data products the value given should be large enough
+        to include the entire area of the observation; coverage within the
+        bounded region need not be complete, for example if the specified
+        radius encompasses a rotated rectangular region. For observations
+        which do not have a well-defined boundary, e.g. radio or
+        high energy observations, a characteristic value should be given.
+        The radius attribute provides a simple way to characterize and use
+        (e.g. for discovery computations) the approximate spatial coverage of a
+        data product. The spatial coverage of a data product can be more
+        precisely specified using the region attribute.
+        """
+        return self.get('s_fov')*u.deg
+
+    @property
+    def s_region(self):
+        """
+        Sky region covered by the data product (expressed in ICRS frame).
+        It can be used to precisely specify the covered spatial region of a
+        data product.
+        It is often an exact, or almost exact, representation of the
+        illumination region of a given observation defined in a standard way
+        by the concept of Support in the Characterisation data model.
+        """
+        return self.get('s_region', decode=True)
+
+    @property
+    def s_resolution(self):
+        """
+        Spatial resolution of data specifies a reference value chosen by the
+        data provider for the estimated spatial resolution of the data product
+        in arcseconds. This refers to the smallest spatial feature in the
+        observed signal that can be resolved.
+        In cases where the spatial resolution varies across the field the best
+        spatial resolution (smallest resolvable spatial feature) should be
+        specified. In cases where the spatial frequency sampling of an
+        observation is complex (e.g., interferometry) a typical value for
+        spatial resolution estimate should be given; additional
+        characterisation may be necessary to fully specify the spatial
+        characteristics of the data.
+        """
+        return self.get('s_resolution')*u.arcsec
+
+    @property
+    def s_xel1(self):
+        """
+        Number of elements along the first spatial axis
+        """
+        return self.get('s_xel1')
+
+    @property
+    def s_xel2(self):
+        """
+        Number of elements along the second spatial axis
+        """
+        return self.get('s_xel2')
+
+    @property
+    def s_ucd(self):
+        """
+        UCD for the nature of the spatial axis (pos or u,v data)
+        """
+        return self.get('s_ucd', decode=True, default=None)
+
+    @property
+    def s_unit(self):
+        """
+        Unit used for spatial axis
+        """
+        return self.get('s_unit', decode=True, default=None)
+
+    @property
+    def s_resolution_min(self):
+        """
+        Resolution min value on spatial axis (FHWM of PSF)
+        """
+        rmin = self.get('s_resolution_min', default=None)
+        return rmin if not rmin else rmin*u.arcsec
+
+    @property
+    def s_resolution_max(self):
+        """
+        Resolution max value on spatial axis (FHWM of PSF)
+        """
+        rmax = self.get('s_resolution_max', default=None)
+        return rmax if not rmax else rmax * u.arcsec
+
+    @property
+    def s_calib_status(self):
+        """
+        A string to encode the calibration status along the spatial axis
+        (astrometry). Possible values could be {uncalibrated, raw, calibrated}
+        """
+        return self.get('s_calib_status', decode=True, default=None)
+
+    @property
+    def s_stat_error(self):
+        """
+        This parameter gives an estimate of the astrometric statistical error
+        after the astrometric calibration phase.
+        """
+        return self.get('s_stat_error', decode=True, default=None)
+
+    @property
+    def s_pixel_scale(self):
+        """
+        This corresponds to the sampling precision of the data along the
+        spatial axis. It is stored as a real number corresponding to the
+        spatial sampling period, i.e., the distance in world coordinates
+        system units between two pixel centers. It may contain two values if
+        the pixels are rectangular.
+        """
+        return self.get('s_pixel_scale', decode=True, default=None)
+
+    #           TIME CHARACTERISATION
+    @property
+    def t_xel(self):
+        """
+        Number of elements along the time axis
+        """
+        return self.get('t_xel')
+
+    @property
+    def t_ref_pos(self):
+        """
+        Time Axis Reference Position as defined in STC REC, Section 4.4.1.1.1
+        """
+        return self.get('t_ref_pos', decode=True, default=None)
+
+    @property
+    def t_min(self):
+        """
+        The start time of the observation specified in MJD. In case of data
+        products result of the combination of multiple frames, min time must
+        be the minimum of the start times
+        """
+        return time.Time(self.get('t_min'), format='mjd')
+
+    @property
+    def t_max(self):
+        """
+        The stop time of the observation specified in MJD. In case of data
+        products result of the combination of multiple frames, t_max must
+        be the maximum of the stop times
+        """
+        return time.Time(self.get('t_min'), format='mjd')
+
+    @property
+    def t_exptime(self):
+        """
+        Total exposure time. For simple exposures, this is just the time_bounds
+         size expressed in seconds. For data where the detector is not active
+         at all times (e.g. data products made by combining exposures taken at
+         different times), the t_exptime will be smaller than the time_bounds
+         interval. For data where the xptime is not constant over the entire
+         data product, the median exposure time per pixel is a good way to
+         characterize the typical value. In some cases, exptime is generally
+         used as an indicator of the relative sensitivity (depth) within a
+         single data collection (e.g. obs_collection); data providers should
+         supply a suitable relative value when it is not feasible to define or
+         compute the true exposure time.
+
+        In case of targeted observations, on the contrary the exposure time is
+        often adjusted to achieve similar signal to noise ratio for different
+        targets.
+        """
+        return self.get('t_exptime')*u.second
+
+    @property
+    def t_resolution(self):
+        """
+        Estimated or average value of the temporal resolution.
+        """
+        return self.get('t_resolution')*u.second
+
+    @property
+    def t_calib_status(self):
+        """
+        Type of time coordinate calibration. Possible values are principally
+        {uncalibrated, calibrated, raw, relative}. This may be extended for
+        specific time domain collections.
+        """
+        return self.get('t_calib_status', decode=True, default=None)
+
+    @property
+    def t_stat_error(self):
+        """
+        Time coord statistical error on the time measurements in seconds
+        """
+        ter = self.get('t_stat_error', default=None)
+        return ter if not ter else ter*u.second
+
+    #           SPECTRAL CHARACTERISATION
+    @property
+    def em_xel(self):
+        """
+        Number of elements along the spectral axis
+        """
+        return self.get('em_xel')
+
+    @property
+    def em_ucd(self):
+        """
+        Nature of the spectral axis
+        """
+        return self.get('em_ucd', decode=True, default=None)
+
+    @property
+    def em_unit(self):
+        """
+        Units along the spectral axis
+        """
+        return self.get('em_unit', decode=True, default=None)
+
+    @property
+    def em_calib_status(self):
+        """
+        This attribute of the spectral axis indicates the status of the data
+        in terms of spectral calibration. Possible values are defined in the
+        Characterisation Data Model and belong to {uncalibrated , calibrated,
+        relative, absolute}.
+        """
+        return self.get('em_calib_status', decode=True, default=None)
+
+    @property
+    def em_min(self):
+        """
+        Minimum of the spectral interval covered by the observation
+        """
+        return self.get('em_min')*u.meter
+
+    @property
+    def em_max(self):
+        """
+        Maximum of the spectral interval covered by the observation
+        """
+        return self.get('em_max')*u.meter
+
+    @property
+    def em_res_power(self):
+        """
+        Average estimation for the spectral resolution power stored as a
+        double value, with no unit.
+        """
+        return self.get("em_res_power")
+
+    @property
+    def em_res_power_min(self):
+        """
+        Resolving power min value on spectral axis
+        """
+        return self.get('em_res_power_min', None)
+
+    @property
+    def em_res_power_max(self):
+        """
+        Resolving power max value on spectral axis
+        """
+        return self.get('em_res_power_max', None)
+
+    @property
+    def em_resolution(self):
+        """
+        A mean estimate of the resolution, e.g. Full Width at Half Maximum
+        (FWHM) of the Line Spread Function (or LSF). This can be used for
+        narrow range spectra whereas in the majority of cases, the resolution
+        power is preferable due to the LSF variation along the spectral axis.
+        """
+        if 'em_resolution' in self.keys():
+            return self.get('em_resolution')*u.meter
+        return None
+
+    @property
+    def em_stat_error(self):
+        """
+        Spectral coord statistical error (accuracy along the spectral axis)
+        """
+        if 'em_stat_error' in self.keys():
+            return self.get('em_stat_error')*u.meter
+        return None
+
+    #           OBSERVABLE AXIS
+    @property
+    def o_ucd(self):
+        """
+        Nature of the observable axis within the data product
+        """
+        return self.get('o_ucd', decode=True)
+
+    @property
+    def o_unit(self):
+        """
+        Units along the observable axis
+        """
+        return self.get('o_unit', decode=True, default=None)
+
+    @property
+    def o_calib_status(self):
+        """
+        Type of calibration applied on the Flux observed (or other observable
+        quantity).
+        """
+        return self.get('o_calib_status', decode=True, default=None)
+
+    @property
+    def o_stat_error(self):
+        """
+        Statistical error on the Observable axis.
+        Note: the return value has the units defined in unit
+        """
+        return self.get('o_stat_error', decode=True, default=None)
+
+    #           POLARIZATION CHARACTERISATION
+    @property
+    def pol_xel(self):
+        """
+        Number of different polarization states present in the data. The
+        default value is 0, indicating that polarization was not explicitly
+        observed. Corresponding values are stored in the `pol` property
+        """
+        return self.get('pol_xel')
+
+    @property
+    def pol_states(self):
+        """
+        List of polarization states present in the data file. Possible values
+        are: {I Q U V RR LL RL LR XX YY XY YX POLI POLA}. Values in the
+        set are separated by the '/' character. A leading / character must
+        start the list and a trailing / character must end it. It should be
+        ordered following the above list, compatible with the FITS list table
+        for polarization definition.
+        """
+        return self.get('pol_states', decode=True, default=None)
+
+    #           PROVENANCE
+    @property
+    def instrument_name(self):
+        """
+        The name of the instrument used for the acquisition of the data
+        """
+        return self.get('instrument_name', decode=True)
+
+    @property
+    def facility_name(self):
+        """
+        Name of the facility or observatory used to collect the data
+        """
+        return self.get('facility_name', decode=True, default=None)
+
+    @property
+    def proposal_id(self):
+        """
+        Identifier of proposal to which observation belongs
+        """
+        return self.get('proposal_id', default=None, decode=True)
diff --git a/pyvo/dal/tap.py b/pyvo/dal/tap.py
index af512d8..e446fe8 100644
--- a/pyvo/dal/tap.py
+++ b/pyvo/dal/tap.py
@@ -8,6 +8,7 @@ from time import sleep
 from distutils.version import LooseVersion
 
 import requests
+from urllib.parse import urlparse, urljoin
 
 from astropy.io.votable import parse as votableparse
 
@@ -688,7 +689,10 @@ class AsyncTAPJob:
         the uri of the result
         """
         try:
-            return self.result.href
+            uri = self.result.href
+            if not urlparse(uri).netloc:
+                uri = urljoin(self.url, uri)
+            return uri
         except IndexError:
             return None
 
@@ -872,7 +876,7 @@ class TAPQuery(DALQuery):
     def queryurl(self):
         return '{baseurl}/{mode}'.format(baseurl=self.baseurl, mode=self._mode)
 
-    def execute_stream(self):
+    def execute_stream(self, post=False):
         """
         submit the query and return the raw VOTable XML as a file stream
 
@@ -889,7 +893,7 @@ class TAPQuery(DALQuery):
             raise DALServiceError(
                 "Cannot execute a non-synchronous query. Use submit instead")
 
-        return super().execute_stream()
+        return super().execute_stream(post=post)
 
     def execute(self):
         """
@@ -907,7 +911,7 @@ class TAPQuery(DALQuery):
         """
         return TAPResults(self.execute_votable(), url=self.queryurl, session=self._session)
 
-    def submit(self):
+    def submit(self, post=False):
         """
         Does the request part of the TAP query.
         This function is separated from response parsing because async queries
diff --git a/pyvo/dal/vosi.py b/pyvo/dal/vosi.py
index 9ede6af..b116cf1 100644
--- a/pyvo/dal/vosi.py
+++ b/pyvo/dal/vosi.py
@@ -17,7 +17,34 @@ __all__ = [
     'AvailabilityMixin', 'CapabilityMixin', 'VOSITables']
 
 
-class AvailabilityMixin:
+class EndpointMixin():
+    def _get_endpoint(self, endpoint):
+        # finds the endpoint relative to the base url or its parent
+        # and returns its content in raw format
+        if not endpoint:
+            raise AttributeError('endpoint required')
+        ep_urls = [
+            '{baseurl}/{endpoint}'.format(baseurl=self.baseurl,
+                                          endpoint=endpoint),
+            url_sibling(self.baseurl, endpoint)
+        ]
+
+        for ep_url in ep_urls:
+            try:
+                response = self._session.get(ep_url, stream=True)
+                response.raise_for_status()
+                break
+            except requests.RequestException:
+                continue
+        else:
+            raise DALServiceError(
+                "No working {endpoint} endpoint provided".format(
+                    endpoint=endpoint))
+
+        return response.raw
+
+
+class AvailabilityMixin(EndpointMixin):
     """
     Mixing for VOSI availability
     """
@@ -27,16 +54,7 @@ class AvailabilityMixin:
         Service Availability as a
         :py:class:`~pyvo.io.vosi.availability.Availability` object
         """
-        avail_url = '{}/availability'.format(self.baseurl)
-
-        response = self._session.get(avail_url, stream=True)
-
-        try:
-            response.raise_for_status()
-        except requests.RequestException as ex:
-            raise DALServiceError.from_except(ex, avail_url)
-
-        return response.raw
+        return self._get_endpoint('availability')
 
     @lazyproperty
     def availability(self):
@@ -57,7 +75,7 @@ class AvailabilityMixin:
         return self.availability.upsince
 
 
-class CapabilityMixin:
+class CapabilityMixin(EndpointMixin):
     """
     Mixing for VOSI capability
     """
@@ -67,22 +85,7 @@ class CapabilityMixin:
         Returns capabilities as a
         py:class:`~pyvo.io.vosi.availability.Availability` object
         """
-        capa_urls = [
-            '{}/capabilities'.format(self.baseurl),
-            url_sibling(self.baseurl, 'capabilities')
-        ]
-
-        for capa_url in capa_urls:
-            try:
-                response = self._session.get(capa_url, stream=True)
-                response.raise_for_status()
-                break
-            except requests.RequestException:
-                continue
-        else:
-            raise DALServiceError("No working capabilities endpoint provided")
-
-        return response.raw
+        return self._get_endpoint('capabilities')
 
     @lazyproperty
     def capabilities(self):
diff --git a/pyvo/dam/__init__.py b/pyvo/dam/__init__.py
new file mode 100644
index 0000000..ba8c261
--- /dev/null
+++ b/pyvo/dam/__init__.py
@@ -0,0 +1,4 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+from .obscore import *
+
+__all__ = ["ObsCoreMetadata"]
diff --git a/pyvo/dam/obscore.py b/pyvo/dam/obscore.py
new file mode 100644
index 0000000..9d6e3fd
--- /dev/null
+++ b/pyvo/dam/obscore.py
@@ -0,0 +1,105 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+"""
+A module for representing data in ObsCore format
+
+"""
+
+__all__ = ['ObsCoreMetadata', 'POLARIZATION_STATES', 'CALIBRATION_LEVELS']
+
+
+# to be moved to ObsCore
+POLARIZATION_STATES = ['I', 'Q', 'U', 'V', 'RR', 'LL', 'RL', 'LR',
+                       'XX', 'YY', 'XY', 'YX', 'POLI', 'POLA']
+CALIBRATION_LEVELS = [0, 1, 2, 3, 4]
+
+
+class ObsCoreMetadata():
+    """
+    Representation of an ObsCore observation
+
+    TBD setters to do validation and unit check.
+    """
+    def __init__(self):
+
+        #          OBSERVATION INFO
+        self.dataproduct_type = None
+        self.dataproduct_subtype = None
+        self.calib_level = None
+
+        #          TARGET INFO
+        self.target_name = None
+        self.target_class = None
+
+        #           DATA DESCRIPTION
+        self.obs_id = None
+        self.obs_title = None
+        self.obs_collection = None
+        self.obs_create_date = None
+        self.obs_creator_name = None
+        self.obs_creator_did = None
+
+        #          CURATION INFORMATION
+        self.obs_release_date = None
+        self.obs_publisher_did = None
+        self.publisher_id = None
+        self.bib_reference = None
+        self.data_rights = None
+
+        #            ACCESS INFORMATION
+        self.access_url = None
+        self.access_format = None
+        self.access_estsize = None
+
+        #            SPATIAL CHARACTERISATION
+        self.s_ra = None
+        self.s_dec = None
+        self.s_fov = None
+        self.s_region = None
+        self.s_resolution = None
+        self.s_xel1 = None
+        self.s_xel2 = None
+        self.s_ucd = None
+        self.s_unit = None
+        self.s_resolution_min = None
+        self.s_resolution_max = None
+        self.s_calib_status = None
+        self.s_stat_error = None
+        self.s_pixel_scale = None
+
+        #            TIME CHARACTERISATION
+        self.t_xel = None
+        self.t_ref_pos = None
+        self.t_min = None
+        self.t_max = None
+        self.t_exptime = None
+        self.t_resolution = None
+        self.t_calib_status = None
+        self.t_stat_error = None
+
+        #            SPECTRAL CHARACTERISATION
+        self.em_xel = None
+        self.em_ucd = None
+        self.em_unit = None
+        self.em_calib_status = None
+        self.em_min = None
+        self.em_max = None
+        self.em_res_power = None
+        self.em_res_power_min = None
+        self.em_res_power_max = None
+        self.em_resolution = None
+        self.em_stat_error = None
+
+        #            OBSERVABLE AXIS
+        self.o_ucd = None
+        self.o_unit = None
+        self.o_calib_status = None
+        self.o_stat_error = None
+
+        #            POLARIZATION CHARACTERISATION
+        self.pol_xel = None
+        self.pol_states = None
+
+        #            PROVENANCE
+        self.instrument_name = None
+        self.facility_name = None
+        self.proposal_id = None
diff --git a/pyvo/io/uws/endpoint.py b/pyvo/io/uws/endpoint.py
index 30ad0b7..5d2067f 100644
--- a/pyvo/io/uws/endpoint.py
+++ b/pyvo/io/uws/endpoint.py
@@ -108,7 +108,8 @@ class JobFile(JobSummary):
             if start and tag == 'xml':
                 pass
             elif start and tag == 'job':
-                self._version = data.get('version')
+                # version was not required in v1.0, so default to that.
+                self._version = data.get('version', '1.0')
                 break
 
         return super().parse(iterator, config)
diff --git a/pyvo/io/vosi/vodataservice.py b/pyvo/io/vosi/vodataservice.py
index d9666c8..aa37ad6 100644
--- a/pyvo/io/vosi/vodataservice.py
+++ b/pyvo/io/vosi/vodataservice.py
@@ -311,7 +311,10 @@ class Table(Element):
 
     def describe(self):
         print(self.name)
-        print(indent(self.description))
+        if self.description is not None:
+            print(indent(self.description))
+        else:
+            print('No description')
 
         print()
 
@@ -711,7 +714,7 @@ class DataType(ContentMixin, Element):
 
     def __repr__(self):
         return '<DataType arraysize={}>{}</DataType>'.format(
-            self.arraysize, self.value)
+            self.arraysize, self.content)
 
     @xmlattribute
     def arraysize(self):
diff --git a/pyvo/registry/regtap.py b/pyvo/registry/regtap.py
index 648ce5d..8a81faa 100644
--- a/pyvo/registry/regtap.py
+++ b/pyvo/registry/regtap.py
@@ -40,7 +40,7 @@ def search(keywords=None, servicetype=None, waveband=None, datamodel=None):
 
     Parameters
     ----------
-    keywords : list of str
+    keywords : str or list of str
        keyword terms to match to registry records.
        Use this parameter to find resources related to a
        particular topic.
@@ -87,6 +87,9 @@ def search(keywords=None, servicetype=None, waveband=None, datamodel=None):
     wheres = list()
     wheres.append("intf_role = 'std'")
 
+    if isinstance(keywords, str):
+        keywords = [keywords]
+
     if keywords:
         def _unions():
             for i, keyword in enumerate(keywords):
diff --git a/pyvo/utils/__init__.py b/pyvo/utils/__init__.py
index e69de29..9fee358 100644
--- a/pyvo/utils/__init__.py
+++ b/pyvo/utils/__init__.py
@@ -0,0 +1,1 @@
+from .compat import *
diff --git a/pyvo/utils/compat.py b/pyvo/utils/compat.py
new file mode 100644
index 0000000..88bad5f
--- /dev/null
+++ b/pyvo/utils/compat.py
@@ -0,0 +1,10 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+"""
+Placeholder for compatibility constructs
+"""
+from distutils.version import LooseVersion
+import astropy
+
+__all__ = ['ASTROPY_LT_4_1']
+
+ASTROPY_LT_4_1 = LooseVersion(astropy.__version__) < '4.1'
diff --git a/setup.cfg b/setup.cfg
index 1b89f07..63a32ae 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -15,6 +15,7 @@ show-response = 1
 [tools:pytest]
 minversion = 2.2
 norecursedirs = build docs/_build
+remote_data_strict = true
 
 [ah_bootstrap]
 auto_use = True


Test patch:
diff --git a/pyvo/auth/tests/conftest.py b/pyvo/auth/tests/conftest.py
index f6612eb..f8c9b1a 100644
--- a/pyvo/auth/tests/conftest.py
+++ b/pyvo/auth/tests/conftest.py
@@ -21,7 +21,7 @@ class ContextAdapter(requests_mock.Adapter):
             self._matchers.remove(matcher)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope='function')
 def mocker():
     with requests_mock.Mocker(
         adapter=ContextAdapter(case_sensitive=True)
diff --git a/pyvo/dal/tests/conftest.py b/pyvo/dal/tests/conftest.py
index f6612eb..f8c9b1a 100644
--- a/pyvo/dal/tests/conftest.py
+++ b/pyvo/dal/tests/conftest.py
@@ -21,7 +21,7 @@ class ContextAdapter(requests_mock.Adapter):
             self._matchers.remove(matcher)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope='function')
 def mocker():
     with requests_mock.Mocker(
         adapter=ContextAdapter(case_sensitive=True)
diff --git a/pyvo/dal/tests/data/datalink/cutout1.xml b/pyvo/dal/tests/data/datalink/cutout1.xml
new file mode 100644
index 0000000..554c4b5
--- /dev/null
+++ b/pyvo/dal/tests/data/datalink/cutout1.xml
@@ -0,0 +1,217 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<VOTABLE xmlns="http://www.ivoa.net/xml/VOTable/v1.3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.4">
+  <RESOURCE type="results">
+    <TABLE>
+      <FIELD name="ID" datatype="char" arraysize="*" ucd="meta.id;meta.main" />
+      <FIELD name="access_url" datatype="char" arraysize="*" ucd="meta.ref.url" />
+      <FIELD name="service_def" datatype="char" arraysize="*" ucd="meta.ref" />
+      <FIELD name="error_message" datatype="char" arraysize="*" ucd="meta.code.error" />
+      <FIELD name="semantics" datatype="char" arraysize="*" ucd="meta.code" />
+      <FIELD name="description" datatype="char" arraysize="*" ucd="meta.note" />
+      <FIELD name="content_type" datatype="char" arraysize="*" ucd="meta.code.mime" />
+      <FIELD name="content_length" datatype="long" ucd="phys.size;meta.file" unit="byte" />
+      <FIELD name="readable" datatype="boolean">
+        <DESCRIPTION>the caller is allowed to use this link with the current authenticated identity</DESCRIPTION>
+      </FIELD>
+      <DATA>
+        <TABLEDATA>
+          <TR>
+            <TD>ivo://cadc.nrc.ca/MACHO?54150/cal054150r</TD>
+            <TD>https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/data/pub/MACHO/cal054150r.fits.fz?RUNID=e3h6rssb161oyf7j</TD>
+            <TD />
+            <TD />
+            <TD>#this</TD>
+            <TD>download ad:MACHO/cal054150r.fits.fz</TD>
+            <TD>application/fits</TD>
+            <TD>18616320</TD>
+            <TD>true</TD>
+          </TR>
+          <TR>
+            <TD>ivo://cadc.nrc.ca/MACHO?54150/cal054150r</TD>
+            <TD />
+            <TD>soda-146cec58-9031-4568-9aeb-380eabb50942</TD>
+            <TD />
+            <TD>#cutout</TD>
+            <TD>SODA-sync cutout of ad:MACHO/cal054150r.fits.fz</TD>
+            <TD>application/fits</TD>
+            <TD />
+            <TD>true</TD>
+          </TR>
+          <TR>
+            <TD>ivo://cadc.nrc.ca/MACHO?54150/cal054150r</TD>
+            <TD />
+            <TD>soda-142c15fb-f9ff-4ad0-b35f-7efb07d9bfea</TD>
+            <TD />
+            <TD>#cutout</TD>
+            <TD>SODA-async cutout of ad:MACHO/cal054150r.fits.fz</TD>
+            <TD>application/fits</TD>
+            <TD />
+            <TD>true</TD>
+          </TR>
+          <TR>
+            <TD>ivo://cadc.nrc.ca/MACHO?54151/cal054151b</TD>
+            <TD>https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/data/pub/MACHO/cal054151b.fits.fz?RUNID=e3h6rssb161oyf7j</TD>
+            <TD />
+            <TD />
+            <TD>#this</TD>
+            <TD>download ad:MACHO/cal054151b.fits.fz</TD>
+            <TD>application/fits</TD>
+            <TD>21692160</TD>
+            <TD>true</TD>
+          </TR>
+          <TR>
+            <TD>ivo://cadc.nrc.ca/MACHO?54151/cal054151b</TD>
+            <TD />
+            <TD>soda-2d68bb35-50e4-459b-bfb2-ac2b5da1a5d1</TD>
+            <TD />
+            <TD>#cutout</TD>
+            <TD>SODA-sync cutout of ad:MACHO/cal054151b.fits.fz</TD>
+            <TD>application/fits</TD>
+            <TD />
+            <TD>true</TD>
+          </TR>
+          <TR>
+            <TD>ivo://cadc.nrc.ca/MACHO?54151/cal054151b</TD>
+            <TD />
+            <TD>soda-fadba418-47ec-4a3d-b8ab-8368f3081082</TD>
+            <TD />
+            <TD>#cutout</TD>
+            <TD>SODA-async cutout of ad:MACHO/cal054151b.fits.fz</TD>
+            <TD>application/fits</TD>
+            <TD />
+            <TD>true</TD>
+          </TR>
+        </TABLEDATA>
+      </DATA>
+    </TABLE>
+    <INFO name="QUERY_STATUS" value="OK" />
+  </RESOURCE>
+  <RESOURCE type="meta" ID="soda-146cec58-9031-4568-9aeb-380eabb50942" utype="adhoc:service">
+    <PARAM name="resourceIdentifier" datatype="char" arraysize="26" value="ivo://cadc.nrc.ca/caom2ops" />
+    <PARAM name="standardID" datatype="char" arraysize="32" value="ivo://ivoa.net/std/SODA#sync-1.0" />
+    <PARAM name="accessURL" datatype="char" arraysize="58" value="https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/caom2ops/sync" />
+    <GROUP name="inputParams">
+      <PARAM name="ID" datatype="char" arraysize="27" ucd="meta.id;meta.dataset" value="ad:MACHO/cal054150r.fits.fz" />
+      <PARAM name="RUNID" datatype="char" arraysize="16" ucd="" value="e3h6rssb161oyf7j" />
+      <PARAM name="POS" datatype="char" arraysize="*" ucd="obs.field" value="" />
+      <PARAM name="CIRCLE" datatype="double" arraysize="3" ucd="obs.field" unit="deg" xtype="circle" value="">
+        <VALUES>
+          <MAX value="88.77362846593161 -67.78431533365645 0.5122773056987322" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="POLYGON" datatype="double" arraysize="*" ucd="obs.field" unit="deg" xtype="polygon" value="">
+        <VALUES>
+          <MAX value="89.70783791485876 -67.41840881752057 89.74786512306278 -68.14322955012662 88.22311503462467 -68.14813181576002 87.81208257298478 -67.77896886699358 87.82899304985233 -67.4197778334441" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="BAND" datatype="double" arraysize="2" ucd="em.wl;stat.interval" unit="m" xtype="interval" value="">
+        <VALUES>
+          <MIN value="5.800000000000001E-7" />
+          <MAX value="7.8E-7" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="TIME" datatype="double" arraysize="2" ucd="time;stat.interval" unit="d" xtype="interval" value="">
+        <VALUES>
+          <MIN value="50527.522581" />
+          <MAX value="50527.526053" />
+        </VALUES>
+      </PARAM>
+    </GROUP>
+  </RESOURCE>
+  <RESOURCE type="meta" ID="soda-142c15fb-f9ff-4ad0-b35f-7efb07d9bfea" utype="adhoc:service">
+    <PARAM name="resourceIdentifier" datatype="char" arraysize="26" value="ivo://cadc.nrc.ca/caom2ops" />
+    <PARAM name="standardID" datatype="char" arraysize="33" value="ivo://ivoa.net/std/SODA#async-1.0" />
+    <PARAM name="accessURL" datatype="char" arraysize="59" value="https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/caom2ops/async" />
+    <GROUP name="inputParams">
+      <PARAM name="ID" datatype="char" arraysize="27" ucd="meta.id;meta.dataset" value="ad:MACHO/cal054150r.fits.fz" />
+      <PARAM name="RUNID" datatype="char" arraysize="16" ucd="" value="e3h6rssb161oyf7j" />
+      <PARAM name="POS" datatype="char" arraysize="*" ucd="obs.field" value="" />
+      <PARAM name="CIRCLE" datatype="double" arraysize="3" ucd="obs.field" unit="deg" xtype="circle" value="">
+        <VALUES>
+          <MAX value="88.77362846593161 -67.78431533365645 0.5122773056987322" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="POLYGON" datatype="double" arraysize="*" ucd="obs.field" unit="deg" xtype="polygon" value="">
+        <VALUES>
+          <MAX value="89.70783791485876 -67.41840881752057 89.74786512306278 -68.14322955012662 88.22311503462467 -68.14813181576002 87.81208257298478 -67.77896886699358 87.82899304985233 -67.4197778334441" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="BAND" datatype="double" arraysize="2" ucd="em.wl;stat.interval" unit="m" xtype="interval" value="">
+        <VALUES>
+          <MIN value="5.800000000000001E-7" />
+          <MAX value="7.8E-7" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="TIME" datatype="double" arraysize="2" ucd="time;stat.interval" unit="d" xtype="interval" value="">
+        <VALUES>
+          <MIN value="50527.522581" />
+          <MAX value="50527.526053" />
+        </VALUES>
+      </PARAM>
+    </GROUP>
+  </RESOURCE>
+  <RESOURCE type="meta" ID="soda-2d68bb35-50e4-459b-bfb2-ac2b5da1a5d1" utype="adhoc:service">
+    <PARAM name="resourceIdentifier" datatype="char" arraysize="26" value="ivo://cadc.nrc.ca/caom2ops" />
+    <PARAM name="standardID" datatype="char" arraysize="32" value="ivo://ivoa.net/std/SODA#sync-1.0" />
+    <PARAM name="accessURL" datatype="char" arraysize="58" value="https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/caom2ops/sync" />
+    <GROUP name="inputParams">
+      <PARAM name="ID" datatype="char" arraysize="27" ucd="meta.id;meta.dataset" value="ad:MACHO/cal054151b.fits.fz" />
+      <PARAM name="RUNID" datatype="char" arraysize="16" ucd="" value="e3h6rssb161oyf7j" />
+      <PARAM name="POS" datatype="char" arraysize="*" ucd="obs.field" value="" />
+      <PARAM name="CIRCLE" datatype="double" arraysize="3" ucd="obs.field" unit="deg" xtype="circle" value="">
+        <VALUES>
+          <MAX value="79.95903372635773 -70.324541763973 0.5210467418210978" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="POLYGON" datatype="double" arraysize="*" ucd="obs.field" unit="deg" xtype="polygon" value="">
+        <VALUES>
+          <MAX value="81.03835269676311 -69.95434045997953 81.07118698080274 -70.68523150491481 78.84035765138141 -70.68806135194416 78.88155357298983 -69.9513684574919" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="BAND" datatype="double" arraysize="2" ucd="em.wl;stat.interval" unit="m" xtype="interval" value="">
+        <VALUES>
+          <MIN value="4.800000000000001E-7" />
+          <MAX value="5.800000000000001E-7" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="TIME" datatype="double" arraysize="2" ucd="time;stat.interval" unit="d" xtype="interval" value="">
+        <VALUES>
+          <MIN value="50527.527002" />
+          <MAX value="50527.530475" />
+        </VALUES>
+      </PARAM>
+    </GROUP>
+  </RESOURCE>
+  <RESOURCE type="meta" ID="soda-fadba418-47ec-4a3d-b8ab-8368f3081082" utype="adhoc:service">
+    <PARAM name="resourceIdentifier" datatype="char" arraysize="26" value="ivo://cadc.nrc.ca/caom2ops" />
+    <PARAM name="standardID" datatype="char" arraysize="33" value="ivo://ivoa.net/std/SODA#async-1.0" />
+    <PARAM name="accessURL" datatype="char" arraysize="59" value="https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/caom2ops/async" />
+    <GROUP name="inputParams">
+      <PARAM name="ID" datatype="char" arraysize="27" ucd="meta.id;meta.dataset" value="ad:MACHO/cal054151b.fits.fz" />
+      <PARAM name="RUNID" datatype="char" arraysize="16" ucd="" value="e3h6rssb161oyf7j" />
+      <PARAM name="POS" datatype="char" arraysize="*" ucd="obs.field" value="" />
+      <PARAM name="CIRCLE" datatype="double" arraysize="3" ucd="obs.field" unit="deg" xtype="circle" value="">
+        <VALUES>
+          <MAX value="79.95903372635773 -70.324541763973 0.5210467418210978" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="POLYGON" datatype="double" arraysize="*" ucd="obs.field" unit="deg" xtype="polygon" value="">
+        <VALUES>
+          <MAX value="81.03835269676311 -69.95434045997953 81.07118698080274 -70.68523150491481 78.84035765138141 -70.68806135194416 78.88155357298983 -69.9513684574919" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="BAND" datatype="double" arraysize="2" ucd="em.wl;stat.interval" unit="m" xtype="interval" value="">
+        <VALUES>
+          <MIN value="4.800000000000001E-7" />
+          <MAX value="5.800000000000001E-7" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="TIME" datatype="double" arraysize="2" ucd="time;stat.interval" unit="d" xtype="interval" value="">
+        <VALUES>
+          <MIN value="50527.527002" />
+          <MAX value="50527.530475" />
+        </VALUES>
+      </PARAM>
+    </GROUP>
+  </RESOURCE>
+</VOTABLE>
diff --git a/pyvo/dal/tests/data/datalink/cutout2.xml b/pyvo/dal/tests/data/datalink/cutout2.xml
new file mode 100644
index 0000000..1555fc3
--- /dev/null
+++ b/pyvo/dal/tests/data/datalink/cutout2.xml
@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<VOTABLE xmlns="http://www.ivoa.net/xml/VOTable/v1.3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.4">
+  <RESOURCE type="results">
+    <TABLE>
+      <FIELD name="ID" datatype="char" arraysize="*" ucd="meta.id;meta.main" />
+      <FIELD name="access_url" datatype="char" arraysize="*" ucd="meta.ref.url" />
+      <FIELD name="service_def" datatype="char" arraysize="*" ucd="meta.ref" />
+      <FIELD name="error_message" datatype="char" arraysize="*" ucd="meta.code.error" />
+      <FIELD name="semantics" datatype="char" arraysize="*" ucd="meta.code" />
+      <FIELD name="description" datatype="char" arraysize="*" ucd="meta.note" />
+      <FIELD name="content_type" datatype="char" arraysize="*" ucd="meta.code.mime" />
+      <FIELD name="content_length" datatype="long" ucd="phys.size;meta.file" unit="byte" />
+      <FIELD name="readable" datatype="boolean">
+        <DESCRIPTION>the caller is allowed to use this link with the current authenticated identity</DESCRIPTION>
+      </FIELD>
+      <DATA>
+        <TABLEDATA>
+          <TR>
+            <TD>ivo://cadc.nrc.ca/MACHO?54151/cal054151r</TD>
+            <TD>https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/data/pub/MACHO/cal054151r.fits.fz?RUNID=of051krube49ud2e</TD>
+            <TD />
+            <TD />
+            <TD>#this</TD>
+            <TD>download ad:MACHO/cal054151r.fits.fz</TD>
+            <TD>application/fits</TD>
+            <TD>18987840</TD>
+            <TD>true</TD>
+          </TR>
+          <TR>
+            <TD>ivo://cadc.nrc.ca/MACHO?54151/cal054151r</TD>
+            <TD />
+            <TD>soda-40d74211-597b-4aa9-a153-f5ac67b77021</TD>
+            <TD />
+            <TD>#cutout</TD>
+            <TD>SODA-sync cutout of ad:MACHO/cal054151r.fits.fz</TD>
+            <TD>application/fits</TD>
+            <TD />
+            <TD>true</TD>
+          </TR>
+          <TR>
+            <TD>ivo://cadc.nrc.ca/MACHO?54151/cal054151r</TD>
+            <TD />
+            <TD>soda-8e48c445-8f76-4c01-abcf-bdb1c2f7caf5</TD>
+            <TD />
+            <TD>#cutout</TD>
+            <TD>SODA-async cutout of ad:MACHO/cal054151r.fits.fz</TD>
+            <TD>application/fits</TD>
+            <TD />
+            <TD>true</TD>
+          </TR>
+        </TABLEDATA>
+      </DATA>
+    </TABLE>
+    <INFO name="QUERY_STATUS" value="OK" />
+  </RESOURCE>
+  <RESOURCE type="meta" ID="soda-40d74211-597b-4aa9-a153-f5ac67b77021" utype="adhoc:service">
+    <PARAM name="resourceIdentifier" datatype="char" arraysize="26" value="ivo://cadc.nrc.ca/caom2ops" />
+    <PARAM name="standardID" datatype="char" arraysize="32" value="ivo://ivoa.net/std/SODA#sync-1.0" />
+    <PARAM name="accessURL" datatype="char" arraysize="58" value="https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/caom2ops/sync" />
+    <GROUP name="inputParams">
+      <PARAM name="ID" datatype="char" arraysize="27" ucd="meta.id;meta.dataset" value="ad:MACHO/cal054151r.fits.fz" />
+      <PARAM name="RUNID" datatype="char" arraysize="16" ucd="" value="of051krube49ud2e" />
+      <PARAM name="POS" datatype="char" arraysize="*" ucd="obs.field" value="" />
+      <PARAM name="CIRCLE" datatype="double" arraysize="3" ucd="obs.field" unit="deg" xtype="circle" value="">
+        <VALUES>
+          <MAX value="79.96919731538502 -70.32069351443877 0.5124495151081784" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="POLYGON" datatype="double" arraysize="*" ucd="obs.field" unit="deg" xtype="polygon" value="">
+        <VALUES>
+          <MAX value="81.01558843560966 -69.95461093880266 81.06567094624911 -70.67927922356017 79.34988020864029 -70.684376201833 78.89013903241694 -70.31495449099631 78.91029632344201 -69.95565099163903" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="BAND" datatype="double" arraysize="2" ucd="em.wl;stat.interval" unit="m" xtype="interval" value="">
+        <VALUES>
+          <MIN value="5.800000000000001E-7" />
+          <MAX value="7.8E-7" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="TIME" datatype="double" arraysize="2" ucd="time;stat.interval" unit="d" xtype="interval" value="">
+        <VALUES>
+          <MIN value="50527.527002" />
+          <MAX value="50527.530475" />
+        </VALUES>
+      </PARAM>
+    </GROUP>
+  </RESOURCE>
+  <RESOURCE type="meta" ID="soda-8e48c445-8f76-4c01-abcf-bdb1c2f7caf5" utype="adhoc:service">
+    <PARAM name="resourceIdentifier" datatype="char" arraysize="26" value="ivo://cadc.nrc.ca/caom2ops" />
+    <PARAM name="standardID" datatype="char" arraysize="33" value="ivo://ivoa.net/std/SODA#async-1.0" />
+    <PARAM name="accessURL" datatype="char" arraysize="59" value="https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/caom2ops/async" />
+    <GROUP name="inputParams">
+      <PARAM name="ID" datatype="char" arraysize="27" ucd="meta.id;meta.dataset" value="ad:MACHO/cal054151r.fits.fz" />
+      <PARAM name="RUNID" datatype="char" arraysize="16" ucd="" value="of051krube49ud2e" />
+      <PARAM name="POS" datatype="char" arraysize="*" ucd="obs.field" value="" />
+      <PARAM name="CIRCLE" datatype="double" arraysize="3" ucd="obs.field" unit="deg" xtype="circle" value="">
+        <VALUES>
+          <MAX value="79.96919731538502 -70.32069351443877 0.5124495151081784" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="POLYGON" datatype="double" arraysize="*" ucd="obs.field" unit="deg" xtype="polygon" value="">
+        <VALUES>
+          <MAX value="81.01558843560966 -69.95461093880266 81.06567094624911 -70.67927922356017 79.34988020864029 -70.684376201833 78.89013903241694 -70.31495449099631 78.91029632344201 -69.95565099163903" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="BAND" datatype="double" arraysize="2" ucd="em.wl;stat.interval" unit="m" xtype="interval" value="">
+        <VALUES>
+          <MIN value="5.800000000000001E-7" />
+          <MAX value="7.8E-7" />
+        </VALUES>
+      </PARAM>
+      <PARAM name="TIME" datatype="double" arraysize="2" ucd="time;stat.interval" unit="d" xtype="interval" value="">
+        <VALUES>
+          <MIN value="50527.527002" />
+          <MAX value="50527.530475" />
+        </VALUES>
+      </PARAM>
+    </GROUP>
+  </RESOURCE>
+</VOTABLE>
diff --git a/pyvo/dal/tests/data/datalink/datalink-obscore.xml b/pyvo/dal/tests/data/datalink/datalink-obscore.xml
new file mode 100644
index 0000000..214805e
--- /dev/null
+++ b/pyvo/dal/tests/data/datalink/datalink-obscore.xml
@@ -0,0 +1,239 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<VOTABLE xmlns="http://www.ivoa.net/xml/VOTable/v1.3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.4">
+  <RESOURCE type="results">
+    <INFO name="QUERY_STATUS" value="OK" />
+    <INFO name="QUERY_TIMESTAMP" value="2020-03-05T04:21:45.936" />
+    <INFO name="QUERY" value="select * from ivoa.ObsCore" />
+    <TABLE>
+      <FIELD name="calib_level" datatype="int" ucd="meta.code;obs.calib" utype="obscore:ObsDataset.calibLevel">
+        <DESCRIPTION>calibration level (0,1,2,3)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="obs_publisher_did" datatype="char" arraysize="256*" ID="caomPlaneURI" ucd="meta.ref.url;meta.curation" utype="obscore:Curation.PublisherDID">
+        <DESCRIPTION>publisher dataset identifier</DESCRIPTION>
+      </FIELD>
+      <FIELD name="obs_collection" datatype="char" arraysize="128*" ucd="meta.id" utype="obscore:DataID.Collection">
+        <DESCRIPTION>short name for the data colection</DESCRIPTION>
+      </FIELD>
+      <FIELD name="facility_name" datatype="char" arraysize="128*" ucd="meta.id;instr.tel" utype="obscore:Provenance.ObsConfig.Facility.name">
+        <DESCRIPTION>telescope name</DESCRIPTION>
+      </FIELD>
+      <FIELD name="instrument_name" datatype="char" arraysize="128*" ucd="meta.id;instr" utype="obscore:Provenance.ObsConfig.Instrument.name">
+        <DESCRIPTION>instrument name</DESCRIPTION>
+      </FIELD>
+      <FIELD name="obs_id" datatype="char" arraysize="128*" ucd="meta.id" utype="obscore:DataID.observationID">
+        <DESCRIPTION>internal dataset identifier</DESCRIPTION>
+      </FIELD>
+      <FIELD name="dataproduct_type" datatype="char" arraysize="128*" ucd="meta.id" utype="obscore:ObsDataset.dataProductType">
+        <DESCRIPTION>type of product</DESCRIPTION>
+      </FIELD>
+      <FIELD name="obs_release_date" datatype="char" arraysize="23*" ucd="time.release" utype="obscore:Curation.releaseDate" xtype="timestamp">
+        <DESCRIPTION>timestamp of date the data becomes publicly available</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_ra" datatype="double" ucd="pos.eq.ra" unit="deg" utype="obscore:Char.SpatialAxis.Coverage.Location.Coord.Position2D.Value2.C1">
+        <DESCRIPTION>RA of central coordinates</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_dec" datatype="double" ucd="pos.eq.dec" unit="deg" utype="obscore:Char.SpatialAxis.Coverage.Location.Coord.Position2D.Value2.C2">
+        <DESCRIPTION>DEC of central coordinates</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_fov" datatype="double" ucd="phys.angSize;instr.fov" unit="deg" utype="obscore:Char.SpatialAxis.Coverage.Bounds.Extent.diameter">
+        <DESCRIPTION>size of the region covered (~diameter of minimum bounding circle)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_region" datatype="char" arraysize="*" ucd="pos.outline;obs.field" utype="obscore:Char.SpatialAxis.Coverage.Support.Area" xtype="adql:REGION">
+        <DESCRIPTION>region bounded by observation</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_resolution" datatype="double" ucd="pos.angResolution" unit="arcsec" utype="obscore:Char.SpatialAxis.Resolution.refval.value">
+        <DESCRIPTION>typical spatial resolution</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_xel1" datatype="long" ucd="meta.number" utype="obscore:Char.SpatialAxis.numBins1">
+        <DESCRIPTION>dimensions (number of pixels) along one spatial axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="target_name" datatype="char" arraysize="32*" ucd="meta.id;src" utype="obscore:Target.Name">
+        <DESCRIPTION>name of intended target</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_xel2" datatype="long" ucd="meta.number" utype="obscore:Char.SpatialAxis.numBins2">
+        <DESCRIPTION>dimensions (number of pixels) along the other spatial axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_min" datatype="double" ucd="time.start;obs.exposure" unit="d" utype="obscore:Char.TimeAxis.Coverage.Bounds.Limits.StartTime">
+        <DESCRIPTION>start time of observation (MJD)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_max" datatype="double" ucd="time.end;obs.exposure" unit="d" utype="obscore:Char.TimeAxis.Coverage.Bounds.Limits.StopTime">
+        <DESCRIPTION>end time of observation (MJD)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_exptime" datatype="double" ucd="time.duration;obs.exposure" unit="s" utype="obscore:Char.TimeAxis.Coverage.Support.Extent">
+        <DESCRIPTION>exposure time of observation</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_resolution" datatype="double" ucd="time.resolution" unit="s" utype="obscore:Char.TimeAxis.Resolution.refval.value">
+        <DESCRIPTION>typical temporal resolution</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_xel" datatype="long" ucd="meta.number" utype="obscore:Char.TimeAxis.numBins">
+        <DESCRIPTION>dimensions (number of pixels) along the time axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_min" datatype="double" ucd="em.wl;stat.min" unit="m" utype="obscore:Char.SpectralAxis.Coverage.Bounds.Limits.LoLimit">
+        <DESCRIPTION>start spectral coordinate value</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_max" datatype="double" ucd="em.wl;stat.max" unit="m" utype="obscore:Char.SpectralAxis.Coverage.Bounds.Limits.HiLimit">
+        <DESCRIPTION>stop spectral coordinate value</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_res_power" datatype="double" ucd="spect.resolution" utype="obscore:Char.SpectralAxis.Resolution.ResolPower.refval">
+        <DESCRIPTION>typical spectral resolution</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_xel" datatype="long" ucd="meta.number" utype="obscore:Char.SpectralAxis.numBins">
+        <DESCRIPTION>dimensions (number of pixels) along the energy axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="pol_xel" datatype="long" ucd="meta.number" utype="obscore:Char.PolarizationAxis.numBins">
+        <DESCRIPTION>dimensions (number of pixels) along the polarization axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_ucd" datatype="char" arraysize="32*" ucd="meta.ucd" utype="obscore:Char.SpectralAxis.ucd">
+        <DESCRIPTION>UCD describing the spectral axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="pol_states" datatype="char" arraysize="32*" ucd="meta.code;phys.polarization" utype="obscore:Char.PolarizationAxis.stateList">
+        <DESCRIPTION>polarization states present in the data</DESCRIPTION>
+      </FIELD>
+      <FIELD name="access_url" datatype="char" arraysize="*" ucd="meta.ref.url" utype="obscore:Access.Reference" xtype="clob">
+        <DESCRIPTION>URL to download the data</DESCRIPTION>
+      </FIELD>
+      <FIELD name="access_estsize" datatype="long" ucd="phys.size;meta.file" unit="kbyte" utype="obscore:Access.Size">
+        <DESCRIPTION>estimated size of the download</DESCRIPTION>
+      </FIELD>
+      <FIELD name="o_ucd" datatype="char" arraysize="32*" ucd="meta.ucd" utype="obscore:Char.ObservableAxis.ucd">
+        <DESCRIPTION>UCD describing the observable axis (pixel values)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="access_format" datatype="char" arraysize="128*" ucd="meta.code.mime" utype="obscore:Access.Format">
+        <DESCRIPTION>format of the data file(s)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="core_id" datatype="char" arraysize="36" xtype="uuid">
+        <DESCRIPTION>primary key</DESCRIPTION>
+      </FIELD>
+      <FIELD name="lastModified" datatype="char" arraysize="23*" xtype="timestamp">
+        <DESCRIPTION>timestamp of last modification of the metadata</DESCRIPTION>
+      </FIELD>
+      <DATA>
+        <TABLEDATA>
+          <TR>
+            <TD>2</TD>
+            <TD>ivo://cadc.nrc.ca/MACHO?54150/cal054150r</TD>
+            <TD>MACHO</TD>
+            <TD>Mt. Stromlo Observatory 50 inch</TD>
+            <TD>MACHO</TD>
+            <TD>54150</TD>
+            <TD>image</TD>
+            <TD>2008-10-08T00:00:00.000</TD>
+            <TD>88.81762854444203</TD>
+            <TD>-67.77109486072844</TD>
+            <TD>1.0245546113974644</TD>
+            <TD>polygon 89.70783791485876 -67.41840881752057 89.74786512306278 -68.14322955012662 88.22311503462467 -68.14813181576002 87.81208257298478 -67.77896886699358 87.82899304985233 -67.4197778334441</TD>
+            <TD>4.353822892</TD>
+            <TD>4181</TD>
+            <TD>macho000071</TD>
+            <TD>4200</TD>
+            <TD>50527.522581</TD>
+            <TD>50527.526053</TD>
+            <TD>300.0</TD>
+            <TD>300.0</TD>
+            <TD>7</TD>
+            <TD>5.800000000000001E-7</TD>
+            <TD>7.8E-7</TD>
+            <TD>5.799999999999999</TD>
+            <TD>7</TD>
+            <TD />
+            <TD />
+            <TD />
+            <TD>https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/caom2ops/datalink?runid=b7vdfnrcr8kq35a1&amp;ID=ivo%3A%2F%2Fcadc.nrc.ca%2FMACHO%3F54150%2Fcal054150r</TD>
+            <TD />
+            <TD>phot.count</TD>
+            <TD>application/x-votable+xml;content=datalink</TD>
+            <TD>00000000-0000-0000-93cf-fed05e0e60f6</TD>
+            <TD>2019-11-20T19:29:40.929</TD>
+          </TR>
+          <TR>
+            <TD>2</TD>
+            <TD>ivo://cadc.nrc.ca/MACHO?54151/cal054151b</TD>
+            <TD>MACHO</TD>
+            <TD>Mt. Stromlo Observatory 50 inch</TD>
+            <TD>MACHO</TD>
+            <TD>54151</TD>
+            <TD>image</TD>
+            <TD>2008-10-08T00:00:00.000</TD>
+            <TD>79.9564431173079</TD>
+            <TD>-70.32295142824165</TD>
+            <TD>1.0420934836421956</TD>
+            <TD>polygon 81.03835269676311 -69.95434045997953 81.07118698080274 -70.68523150491481 78.84035765138141 -70.68806135194416 78.88155357298983 -69.9513684574919</TD>
+            <TD>5.020573004</TD>
+            <TD>4175</TD>
+            <TD>macho000006</TD>
+            <TD>4168</TD>
+            <TD>50527.527002</TD>
+            <TD>50527.530475</TD>
+            <TD>300.0</TD>
+            <TD>300.0</TD>
+            <TD>8</TD>
+            <TD>4.800000000000001E-7</TD>
+            <TD>5.800000000000001E-7</TD>
+            <TD>5.799999999999999</TD>
+            <TD>8</TD>
+            <TD />
+            <TD />
+            <TD />
+            <TD>https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/caom2ops/datalink?runid=b7vdfnrcr8kq35a1&amp;ID=ivo%3A%2F%2Fcadc.nrc.ca%2FMACHO%3F54151%2Fcal054151b</TD>
+            <TD />
+            <TD>phot.count</TD>
+            <TD>application/x-votable+xml;content=datalink</TD>
+            <TD>00000000-0000-0000-b8fb-fed05e2e4e02</TD>
+            <TD>2019-11-20T19:29:40.960</TD>
+          </TR>
+          <TR>
+            <TD>2</TD>
+            <TD>ivo://cadc.nrc.ca/MACHO?54151/cal054151r</TD>
+            <TD>MACHO</TD>
+            <TD>Mt. Stromlo Observatory 50 inch</TD>
+            <TD>MACHO</TD>
+            <TD>54151</TD>
+            <TD>image</TD>
+            <TD>2008-10-08T00:00:00.000</TD>
+            <TD>80.01859124602015</TD>
+            <TD>-70.30750032450052</TD>
+            <TD>1.0248990302163568</TD>
+            <TD>polygon 81.01558843560966 -69.95461093880266 81.06567094624911 -70.67927922356017 79.34988020864029 -70.684376201833 78.89013903241694 -70.31495449099631 78.91029632344201 -69.95565099163903</TD>
+            <TD>5.020573004</TD>
+            <TD>4181</TD>
+            <TD>macho000006</TD>
+            <TD>4169</TD>
+            <TD>50527.527002</TD>
+            <TD>50527.530475</TD>
+            <TD>300.0</TD>
+            <TD>300.0</TD>
+            <TD>7</TD>
+            <TD>5.800000000000001E-7</TD>
+            <TD>7.8E-7</TD>
+            <TD>5.799999999999999</TD>
+            <TD>7</TD>
+            <TD />
+            <TD />
+            <TD />
+            <TD>https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/caom2ops/datalink?runid=b7vdfnrcr8kq35a1&amp;ID=ivo%3A%2F%2Fcadc.nrc.ca%2FMACHO%3F54151%2Fcal054151r</TD>
+            <TD />
+            <TD>phot.count</TD>
+            <TD>application/x-votable+xml;content=datalink</TD>
+            <TD>00000000-0000-0000-b83a-fed05e27f58e</TD>
+            <TD>2019-11-20T19:29:40.960</TD>
+          </TR>
+        </TABLEDATA>
+      </DATA>
+    </TABLE>
+    <INFO name="QUERY_STATUS" value="OVERFLOW" />
+  </RESOURCE>
+  <RESOURCE type="meta" utype="adhoc:service">
+    <PARAM name="resourceIdentifier" datatype="char" arraysize="*" value="ivo://test.com/caom2ops" />
+    <PARAM name="standardID" datatype="char" arraysize="*" value="ivo://ivoa.net/std/DataLink#links-1.0" />
+    <PARAM name="accessURL" datatype="char" arraysize="62" value="https://example.com/obscore-datalink" />
+    <GROUP name="inputParams">
+      <PARAM name="ID" datatype="char" arraysize="*" ref="caomPlaneURI" value="" />
+      <PARAM name="RESPONSEFORMAT" datatype="char" arraysize="*" value="application/x-votable+xml;content=datalink">
+        <VALUES>
+          <OPTION value="application/x-votable+xml;content=datalink" />
+          <OPTION value="application/x-download-manifest+txt" />
+        </VALUES>
+      </PARAM>
+    </GROUP>
+  </RESOURCE>
+</VOTABLE>
diff --git a/pyvo/dal/tests/data/sia2/capabilities.xml b/pyvo/dal/tests/data/sia2/capabilities.xml
new file mode 100644
index 0000000..2383b83
--- /dev/null
+++ b/pyvo/dal/tests/data/sia2/capabilities.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<vosi:capabilities xmlns:vosi="http://www.ivoa.net/xml/VOSICapabilities/v1.0" xmlns:vs="http://www.ivoa.net/xml/VODataService/v1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+  <capability standardID="ivo://ivoa.net/std/VOSI#capabilities">
+    <interface xsi:type="vs:ParamHTTP">
+      <accessURL use="full">https://example.com/sia/capabilities</accessURL>
+    </interface>
+  </capability>
+  <capability standardID="ivo://ivoa.net/std/VOSI#availability">
+    <interface xsi:type="vs:ParamHTTP">
+      <accessURL use="full">https://example.com/sia/availability</accessURL>
+    </interface>
+  </capability>
+  <!-- SIA-2.0 query capability -->
+  <capability standardID="ivo://ivoa.net/std/SIA#query-2.0">
+    <interface xsi:type="vs:ParamHTTP" role="std" version="2.0">
+      <accessURL use="base">https://example.com/sia/v2query</accessURL>
+    </interface>
+    <interface xsi:type="vs:ParamHTTP" role="std" version="2.0">
+      <accessURL use="base">https://example.com/sia/auth-v2query</accessURL>
+      <securityMethod standardID="ivo://ivoa.net/sso#BasicAA" />
+    </interface>
+    <interface xsi:type="vs:ParamHTTP" role="std" version="2.0">
+      <accessURL use="base">https://example.com/sia/v2query</accessURL>
+      <securityMethod standardID="ivo://ivoa.net/sso#cookie" />
+    </interface>
+    <interface xsi:type="vs:ParamHTTP" role="std" version="2.0">
+      <accessURL use="base">https://example.com/sia/v2query</accessURL>
+      <securityMethod standardID="ivo://ivoa.net/sso#tls-with-certificate" />
+    </interface>
+  </capability>
+</vosi:capabilities>
diff --git a/pyvo/dal/tests/data/sia2/dataset.xml b/pyvo/dal/tests/data/sia2/dataset.xml
new file mode 100644
index 0000000..6bd95ce
--- /dev/null
+++ b/pyvo/dal/tests/data/sia2/dataset.xml
@@ -0,0 +1,167 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<VOTABLE xmlns="http://www.ivoa.net/xml/VOTable/v1.3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.3">
+  <RESOURCE type="results">
+    <INFO name="QUERY_STATUS" value="OK" />
+    <INFO name="QUERY_TIMESTAMP" value="2020-02-07T03:37:36.754" />
+    <INFO name="QUERY" value="SELECT * FROM ivoa.ObsCore WHERE dataproduct_type IN ( 'image', 'cube' )" />
+    <TABLE>
+      <FIELD name="calib_level" datatype="int" ucd="meta.code;obs.calib" utype="obscore:ObsDataset.calibLevel">
+        <DESCRIPTION>calibration level (0,1,2,3)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="obs_publisher_did" datatype="char" arraysize="256*" ID="caomPlaneURI" ucd="meta.ref.url;meta.curation" utype="obscore:Curation.PublisherDID">
+        <DESCRIPTION>publisher dataset identifier</DESCRIPTION>
+      </FIELD>
+      <FIELD name="obs_collection" datatype="char" arraysize="128*" ucd="meta.id" utype="obscore:DataID.Collection">
+        <DESCRIPTION>short name for the data colection</DESCRIPTION>
+      </FIELD>
+      <FIELD name="facility_name" datatype="char" arraysize="128*" ucd="meta.id;instr.tel" utype="obscore:Provenance.ObsConfig.Facility.name">
+        <DESCRIPTION>telescope name</DESCRIPTION>
+      </FIELD>
+      <FIELD name="instrument_name" datatype="char" arraysize="128*" ucd="meta.id;instr" utype="obscore:Provenance.ObsConfig.Instrument.name">
+        <DESCRIPTION>instrument name</DESCRIPTION>
+      </FIELD>
+      <FIELD name="obs_id" datatype="char" arraysize="128*" ucd="meta.id" utype="obscore:DataID.observationID">
+        <DESCRIPTION>internal dataset identifier</DESCRIPTION>
+      </FIELD>
+      <FIELD name="dataproduct_type" datatype="char" arraysize="128*" ucd="meta.id" utype="obscore:ObsDataset.dataProductType">
+        <DESCRIPTION>type of product</DESCRIPTION>
+      </FIELD>
+      <FIELD name="obs_release_date" datatype="char" arraysize="23*" ucd="time.release" utype="obscore:Curation.releaseDate" xtype="timestamp">
+        <DESCRIPTION>timestamp of date the data becomes publicly available</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_ra" datatype="double" ucd="pos.eq.ra" unit="deg" utype="obscore:Char.SpatialAxis.Coverage.Location.Coord.Position2D.Value2.C1">
+        <DESCRIPTION>RA of central coordinates</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_dec" datatype="double" ucd="pos.eq.dec" unit="deg" utype="obscore:Char.SpatialAxis.Coverage.Location.Coord.Position2D.Value2.C2">
+        <DESCRIPTION>DEC of central coordinates</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_fov" datatype="double" ucd="phys.angSize;instr.fov" unit="deg" utype="obscore:Char.SpatialAxis.Coverage.Bounds.Extent.diameter">
+        <DESCRIPTION>size of the region covered (~diameter of minimum bounding circle)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_region" datatype="char" arraysize="*" ucd="pos.outline;obs.field" utype="obscore:Char.SpatialAxis.Coverage.Support.Area" xtype="adql:REGION">
+        <DESCRIPTION>region bounded by observation</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_resolution" datatype="double" ucd="pos.angResolution" unit="arcsec" utype="obscore:Char.SpatialAxis.Resolution.refval.value">
+        <DESCRIPTION>typical spatial resolution</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_xel1" datatype="long" ucd="meta.number" utype="obscore:Char.SpatialAxis.numBins1">
+        <DESCRIPTION>dimensions (number of pixels) along one spatial axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="target_name" datatype="char" arraysize="32*" ucd="meta.id;src" utype="obscore:Target.Name">
+        <DESCRIPTION>name of intended target</DESCRIPTION>
+      </FIELD>
+      <FIELD name="s_xel2" datatype="long" ucd="meta.number" utype="obscore:Char.SpatialAxis.numBins2">
+        <DESCRIPTION>dimensions (number of pixels) along the other spatial axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_min" datatype="double" ucd="time.start;obs.exposure" unit="d" utype="obscore:Char.TimeAxis.Coverage.Bounds.Limits.StartTime">
+        <DESCRIPTION>start time of observation (MJD)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_max" datatype="double" ucd="time.end;obs.exposure" unit="d" utype="obscore:Char.TimeAxis.Coverage.Bounds.Limits.StopTime">
+        <DESCRIPTION>end time of observation (MJD)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_exptime" datatype="double" ucd="time.duration;obs.exposure" unit="s" utype="obscore:Char.TimeAxis.Coverage.Support.Extent">
+        <DESCRIPTION>exposure time of observation</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_resolution" datatype="double" ucd="time.resolution" unit="s" utype="obscore:Char.TimeAxis.Resolution.refval.value">
+        <DESCRIPTION>typical temporal resolution</DESCRIPTION>
+      </FIELD>
+      <FIELD name="t_xel" datatype="long" ucd="meta.number" utype="obscore:Char.TimeAxis.numBins">
+        <DESCRIPTION>dimensions (number of pixels) along the time axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_min" datatype="double" ucd="em.wl;stat.min" unit="m" utype="obscore:Char.SpectralAxis.Coverage.Bounds.Limits.LoLimit">
+        <DESCRIPTION>start spectral coordinate value</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_max" datatype="double" ucd="em.wl;stat.max" unit="m" utype="obscore:Char.SpectralAxis.Coverage.Bounds.Limits.HiLimit">
+        <DESCRIPTION>stop spectral coordinate value</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_res_power" datatype="double" ucd="spect.resolution" utype="obscore:Char.SpectralAxis.Resolution.ResolPower.refval">
+        <DESCRIPTION>typical spectral resolution</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_xel" datatype="long" ucd="meta.number" utype="obscore:Char.SpectralAxis.numBins">
+        <DESCRIPTION>dimensions (number of pixels) along the energy axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="pol_xel" datatype="long" ucd="meta.number" utype="obscore:Char.PolarizationAxis.numBins">
+        <DESCRIPTION>dimensions (number of pixels) along the polarization axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="em_ucd" datatype="char" arraysize="32*" ucd="meta.ucd" utype="obscore:Char.SpectralAxis.ucd">
+        <DESCRIPTION>UCD describing the spectral axis</DESCRIPTION>
+      </FIELD>
+      <FIELD name="pol_states" datatype="char" arraysize="32*" ucd="meta.code;phys.polarization" utype="obscore:Char.PolarizationAxis.stateList">
+        <DESCRIPTION>polarization states present in the data</DESCRIPTION>
+      </FIELD>
+      <FIELD name="access_url" datatype="char" arraysize="*" ucd="meta.ref.url" utype="obscore:Access.Reference" xtype="clob">
+        <DESCRIPTION>URL to download the data</DESCRIPTION>
+      </FIELD>
+      <FIELD name="access_estsize" datatype="long" ucd="phys.size;meta.file" unit="kbyte" utype="obscore:Access.Size">
+        <DESCRIPTION>estimated size of the download</DESCRIPTION>
+      </FIELD>
+      <FIELD name="o_ucd" datatype="char" arraysize="32*" ucd="meta.ucd" utype="obscore:Char.ObservableAxis.ucd">
+        <DESCRIPTION>UCD describing the observable axis (pixel values)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="access_format" datatype="char" arraysize="128*" ucd="meta.code.mime" utype="obscore:Access.Format">
+        <DESCRIPTION>format of the data file(s)</DESCRIPTION>
+      </FIELD>
+      <FIELD name="core_id" datatype="char" arraysize="36" xtype="uuid">
+        <DESCRIPTION>primary key</DESCRIPTION>
+      </FIELD>
+      <FIELD name="lastModified" datatype="char" arraysize="23*" xtype="timestamp">
+        <DESCRIPTION>timestamp of last modification of the metadata</DESCRIPTION>
+      </FIELD>
+      <DATA>
+        <TABLEDATA>
+          <TR>
+            <TD>1</TD>
+            <TD>ivo://cadc.nrc.ca/TEST?C190508_1442_SCI/C190508_1442_SCI</TD>
+            <TD>TEST</TD>
+            <TD>TEST-1.6m</TD>
+            <TD>TEST-INSTR</TD>
+            <TD>TEST-DATASET</TD>
+            <TD>image</TD>
+            <TD>2000-01-01T00:00:00.000</TD>
+            <TD>253.0637009871805</TD>
+            <TD>69.94853437563255</TD>
+            <TD>0.7176383627131244</TD>
+            <TD>polygon 252.33774699999995 70.20847500000002 253.83481099999992 70.19265699999998 253.77173199999996 69.68570399999999 252.31050299999995 69.70114700000002</TD>
+            <TD />
+            <TD>2048</TD>
+            <TD>BYW1651+6953</TD>
+            <TD>2048</TD>
+            <TD>58612.30703703704</TD>
+            <TD>58612.30726851852</TD>
+            <TD>20.295</TD>
+            <TD>0.1</TD>
+            <TD>1</TD>
+            <TD>1.1699999999999998E-6</TD>
+            <TD>1.33E-6</TD>
+            <TD />
+            <TD>1</TD>
+            <TD />
+            <TD />
+            <TD />
+            <TD>https://example.com/datalink?runid=i4oh43a0oky3dyvp&amp;ID=ivo%3A%2F%2Fexample.com%2FTEST%3FC190508_1442_SCI%2FC190508_1442_SCI</TD>
+            <TD />
+            <TD>phot.count</TD>
+            <TD>application/x-votable+xml;content=datalink</TD>
+            <TD>f0a8f0ad-be05-4adf-ab3d-8b13c45b79b1</TD>
+            <TD>2019-09-19T22:17:07.594</TD>
+          </TR>
+        </TABLEDATA>
+      </DATA>
+    </TABLE>
+    <INFO name="QUERY_STATUS" value="OK" />
+  </RESOURCE>
+  <RESOURCE type="meta" utype="adhoc:service">
+    <PARAM name="resourceIdentifier" datatype="char" arraysize="*" value="ivo://test.com/datalink" />
+    <PARAM name="standardID" datatype="char" arraysize="*" value="ivo://ivoa.net/std/DataLink#links-1.0" />
+    <PARAM name="accessURL" datatype="char" arraysize="62" value="https://example.com/caom2ops/datalink" />
+    <GROUP name="inputParams">
+      <PARAM name="ID" datatype="char" arraysize="*" ref="caomPlaneURI" value="" />
+      <PARAM name="RESPONSEFORMAT" datatype="char" arraysize="*" value="application/x-votable+xml;content=datalink">
+        <VALUES>
+          <OPTION value="application/x-votable+xml;content=datalink" />
+          <OPTION value="application/x-download-manifest+txt" />
+        </VALUES>
+      </PARAM>
+    </GROUP>
+  </RESOURCE>
+</VOTABLE>
diff --git a/pyvo/dal/tests/setup_package.py b/pyvo/dal/tests/setup_package.py
index d6114de..903ea19 100644
--- a/pyvo/dal/tests/setup_package.py
+++ b/pyvo/dal/tests/setup_package.py
@@ -11,6 +11,7 @@ def get_package_data():
         os.path.join('data/tap', '*.xml'),
         os.path.join('data/scs', '*.xml'),
         os.path.join('data/sia', '*.xml'),
+        os.path.join('data/sia2', '*.xml'),
         os.path.join('data/sla', '*.xml'),
         os.path.join('data/ssa', '*.xml'),
         os.path.join('data/datalink', '*.xml'),
diff --git a/pyvo/dal/tests/test_adhoc.py b/pyvo/dal/tests/test_adhoc.py
new file mode 100644
index 0000000..7696d02
--- /dev/null
+++ b/pyvo/dal/tests/test_adhoc.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+"""
+Tests for pyvo.dal.adhoc
+"""
+import datetime
+from astropy import units as u
+from astropy.time import Time
+import pytest
+
+from pyvo.dal.adhoc import AxisParamMixin, SodaQuery
+
+
+def test_pos():
+    class TestClass(dict, AxisParamMixin):
+        pass
+    test_obj = TestClass()
+    test_obj.pos.add((1, 2, 3)*u.deg)
+    assert len(test_obj._pos) == 1
+    assert test_obj['POS'] == ['CIRCLE 1.0 2.0 3.0']
+
+    test_obj.pos.add((1, 2, 3, 4))
+    assert len(test_obj._pos) == 2
+    assert test_obj['POS'] == ['CIRCLE 1.0 2.0 3.0', 'RANGE 1.0 2.0 3.0 4.0']
+
+    # duplicates are ignored
+    test_obj.pos.add((1, 2, 3))
+    assert len(test_obj._pos) == 2
+    assert test_obj['POS'] == ['CIRCLE 1.0 2.0 3.0', 'RANGE 1.0 2.0 3.0 4.0']
+
+    # polygon
+    test_obj.pos.add((1, 2, 3, 4, 5, 6))
+    assert len(test_obj._pos) == 3
+    assert test_obj['POS'] == ['CIRCLE 1.0 2.0 3.0', 'RANGE 1.0 2.0 3.0 4.0',
+                               'POLYGON 1.0 2.0 3.0 4.0 5.0 6.0']
+
+    # deletes
+    test_obj.pos.remove((1, 2, 3, 4))
+    assert len(test_obj._pos) == 2
+    assert test_obj['POS'] == ['CIRCLE 1.0 2.0 3.0',
+                               'POLYGON 1.0 2.0 3.0 4.0 5.0 6.0']
+
+    # test borders
+    test_obj.pos.discard((1, 2, 3)*u.deg)
+    test_obj.pos.discard((1, 2, 3, 4, 5, 6))
+    assert(len(test_obj._pos) == 0)
+    test_obj.pos.add((0, 90, 90))
+    assert len(test_obj._pos) == 1
+    assert test_obj['POS'] == ['CIRCLE 0.0 90.0 90.0']
+    test_obj.pos.pop()
+    test_obj.pos.add((360, -90, 1))
+    assert len(test_obj._pos) == 1
+    assert test_obj['POS'] == ['CIRCLE 360.0 -90.0 1.0']
+    test_obj.pos.pop()
+    test_obj.pos.add((0, 360, -90, 90))
+    assert len(test_obj._pos) == 1
+    assert test_obj['POS'] == ['RANGE 0.0 360.0 -90.0 90.0']
+    test_obj.pos.pop()
+    test_obj.pos.add((0, 0, 180, 90, 270, -90))
+    assert len(test_obj._pos) == 1
+    assert test_obj['POS'] == ['POLYGON 0.0 0.0 180.0 90.0 270.0 -90.0']
+
+    # errors
+    test_obj.pos.pop()
+    with pytest.raises(ValueError):
+        test_obj.pos.add(('A', 2, 3))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((-2, 7, 3))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((3, 99, 3))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((2, 7, 91))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((-1, 7, 3, 4))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((2, 1, 3, 4))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((1, 2, 4, 3))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((-2, 7, 5, 9, 10, 10))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((2, 99, 5, 9, 10, 10))
+    with pytest.raises(ValueError):
+        test_obj.pos.add((1, 2, 3, 4, 5, 6, 7))
+
+
+def test_band():
+    class TestClass(dict, AxisParamMixin):
+        pass
+    test_obj = TestClass()
+    assert not hasattr(test_obj, '_band')
+    test_obj.band.add(33)
+    assert 33 in test_obj.band
+    assert test_obj['BAND'] == ['33.0 33.0']
+    test_obj.band.add((50*u.meter, 500))
+    assert 33 in test_obj.band
+    assert (50*u.meter, 500) in test_obj.band
+    assert test_obj['BAND'] == ['33.0 33.0', '50.0 500.0']
+    test_obj.band.discard(33)
+    assert (50*u.meter, 500) in test_obj.band
+    assert test_obj['BAND'] == ['50.0 500.0']
+    test_obj.band.pop()
+    assert not test_obj.band
+    assert not test_obj['BAND']
+    test_obj.band.add((float('-inf'), 33))
+    assert (float('-inf'), 33) in test_obj.band
+    assert test_obj.band.dal == ['-inf 33.0']
+    test_obj.band.clear()
+    test_obj.band.add((33, float('inf')))
+    assert (33, float('inf')) in test_obj.band
+    assert test_obj.band.dal == ['33.0 inf']
+    test_obj.clear()
+
+    # error cases
+    with pytest.raises(ValueError):
+        test_obj.band.add(())
+    with pytest.raises(ValueError):
+        test_obj.band.add((1, 2, 3))
+    with pytest.raises(ValueError):
+        test_obj.band.add(('INVALID', 6))
+    with pytest.raises(ValueError):
+        test_obj.band.add((3, 1))
+
+
+def test_time():
+    class TestClass(dict, AxisParamMixin):
+        pass
+    test_obj = TestClass()
+    assert not hasattr(test_obj, '_time')
+    now = Time(datetime.datetime.now())
+    test_obj.time.add(now)
+    assert now in test_obj.time
+    assert test_obj['TIME'] == ['{now} {now}'.format(now=now.mjd)]
+    min_time = '2010-01-01T00:00:00.000Z'
+    max_time = '2010-01-01T01:00:00.000Z'
+    test_obj.time.add((min_time, max_time))
+    assert now in test_obj.time
+    assert (min_time, max_time) in test_obj.time
+    assert test_obj['TIME'] == ['{now} {now}'.format(now=now.mjd),
+                                '{min} {max}'.format(min=Time(min_time).mjd,
+                                                     max=Time(max_time).mjd)]
+    test_obj.time.discard(now)
+    assert (min_time, max_time) in test_obj.time
+    assert test_obj['TIME'] == ['{min} {max}'.format(min=Time(min_time).mjd,
+                                                     max=Time(max_time).mjd)]
+    test_obj.time.pop()
+    assert not test_obj.time
+    assert not test_obj['TIME']
+
+    # error cases
+    with pytest.raises(ValueError):
+        test_obj.time.add([])
+    with pytest.raises(ValueError):
+        test_obj.time.add([now, min_time, max_time])
+    with pytest.raises(ValueError):
+        test_obj.time.add(['INVALID'])
+    with pytest.raises(ValueError):
+        test_obj.time.add([max_time, min_time])
+
+
+def test_pol():
+    class TestClass(dict, AxisParamMixin):
+        pass
+    test_obj = TestClass()
+    assert not hasattr(test_obj, '_pol')
+    test_obj.pol.add('YY')
+    assert 'YY' in test_obj.pol
+    assert test_obj['POL'] == ['YY']
+    test_obj.pol.add('POLI')
+    assert 'YY' in test_obj.pol
+    assert 'POLI' in test_obj.pol
+    assert test_obj['POL'] == ['YY', 'POLI']
+    # test duplicate
+    test_obj.pol.add('POLI')
+    assert 'YY' in test_obj.pol
+    assert 'POLI' in test_obj.pol
+    assert test_obj['POL'] == ['YY', 'POLI']
+    test_obj.pol.remove('YY')
+    assert 'POLI' in test_obj.pol
+    assert test_obj['POL'] == ['POLI']
+    test_obj.pol.pop()
+    assert not test_obj._pol
+    assert not test_obj['POL']
+
+    # error cases
+    with pytest.raises(ValueError):
+        test_obj.pol.add(None)
+    with pytest.raises(ValueError):
+        test_obj.pol.add(['INVALID'])
+
+
+def test_soda_query():
+    test_obj = SodaQuery(baseurl='some/url')
+    test_obj.circle = (2, 3, 5)
+    assert test_obj._circle == (2, 3, 5)
+    assert test_obj['CIRCLE'] == '2.0 3.0 5.0'
+    assert test_obj._circle
+    assert not hasattr(test_obj, '_polygon')
+    assert not hasattr(test_obj, '_range')
+
+    test_obj.range = (8, 9, 3, 4)*u.deg
+    assert test_obj['POS'] == 'RANGE 8.0 9.0 3.0 4.0'
+    assert test_obj._range is not None
+    assert not hasattr(test_obj, '_polygon')
+    assert not hasattr(test_obj, '_circle')
+
+    test_obj.polygon = (1, 2, 3, 4, 5, 6)
+    assert test_obj['POLYGON'] == '1.0 2.0 3.0 4.0 5.0 6.0'
+    assert test_obj._polygon
+    assert not hasattr(test_obj, '_range')
+    assert not hasattr(test_obj, '_circle')
+
+    del test_obj.polygon
+    assert not hasattr(test_obj, '_polygon')
+    assert not hasattr(test_obj, '_circle')
+    assert not hasattr(test_obj, '_range')
+
+    # error cases
+    with pytest.raises(ValueError):
+        test_obj.circle = ('A', 1, 2)
+    with pytest.raises(ValueError):
+        test_obj.circle = (1, 1, 2, 2)
+    with pytest.raises(ValueError):
+        test_obj.circle = (-1, 1, 2)
+    with pytest.raises(ValueError):
+        test_obj.circle = (1, 99, 2)
+    with pytest.raises(ValueError):
+        test_obj.circle = (1, 1, 91)
+    with pytest.raises(ValueError):
+        test_obj.range = (1, 2, 3)
+    with pytest.raises(ValueError):
+        test_obj.range = (2, 1, 3, 4)
+    with pytest.raises(ValueError):
+        test_obj.range = (1, 2, 4, 3)
+    with pytest.raises(ValueError):
+        test_obj.range = (-1, 2, 3, 4)
+    with pytest.raises(ValueError):
+        test_obj.range = (2, 1000, 3, 4)
+    with pytest.raises(ValueError):
+        test_obj.range = (1, 1, -91, 4)
+    with pytest.raises(ValueError):
+        test_obj.range = (1, 1, 3, 92)
+    with pytest.raises(ValueError):
+        test_obj.polygon = (1, 2, 3, 4)
+    with pytest.raises(ValueError):
+        test_obj.polygon = (2, 1, 3, 4, 5, 6, 7)
diff --git a/pyvo/dal/tests/test_datalink.py b/pyvo/dal/tests/test_datalink.py
index b8f2b63..c771974 100644
--- a/pyvo/dal/tests/test_datalink.py
+++ b/pyvo/dal/tests/test_datalink.py
@@ -32,7 +32,37 @@ def datalink(mocker):
         return get_pkg_data_contents('data/datalink/datalink.xml')
 
     with mocker.register_uri(
-        'GET', 'http://example.com/datalink', content=callback
+        'POST', 'http://example.com/datalink', content=callback
+    ) as matcher:
+        yield matcher
+
+
+@pytest.fixture()
+def obscore_datalink(mocker):
+    def callback(request, context):
+        return get_pkg_data_contents('data/datalink/datalink-obscore.xml')
+
+    with mocker.register_uri(
+        'GET', 'http://example.com/obscore', content=callback
+    ) as matcher:
+        yield matcher
+
+
+@pytest.fixture()
+def res_datalink(mocker):
+
+    first_batch = True
+
+    def callback(request, context):
+        nonlocal first_batch
+        if first_batch:
+            first_batch = False
+            return get_pkg_data_contents('data/datalink/cutout1.xml')
+        else:
+            return get_pkg_data_contents('data/datalink/cutout2.xml')
+
+    with mocker.register_uri(
+        'POST', 'https://example.com/obscore-datalink', content=callback
     ) as matcher:
         yield matcher
 
@@ -59,3 +89,15 @@ def test_datalink():
 
     row = datalink[3]
     assert row.semantics == "#preview"
+
+
+@pytest.mark.usefixtures('obscore_datalink', 'res_datalink')
+@pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W27")
+@pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
+@pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W48")
+@pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.E02")
+def test_datalink_batch():
+    results = vo.dal.imagesearch(
+        'http://example.com/obscore', (30, 30))
+
+    assert len([_ for _ in results.iter_datalinks()]) == 3
diff --git a/pyvo/dal/tests/test_params.py b/pyvo/dal/tests/test_params.py
index 06dd918..196ed74 100644
--- a/pyvo/dal/tests/test_params.py
+++ b/pyvo/dal/tests/test_params.py
@@ -7,7 +7,8 @@ from functools import partial
 from urllib.parse import parse_qsl
 
 from pyvo.dal.adhoc import DatalinkResults
-from pyvo.dal.params import find_param_by_keyword, get_converter
+from pyvo.dal.params import find_param_by_keyword, get_converter,\
+    AbstractDalQueryParam, IntervalQueryParam
 from pyvo.dal.exceptions import DALServiceError
 
 import pytest
@@ -186,3 +187,38 @@ def test_inf():
     proc = datalink[0]
 
     proc.process(band=(6000, +np.inf) * u.Angstrom)
+
+
+def test_dal_query_param():
+    class Test(AbstractDalQueryParam):
+        def get_dal_format(self, item):
+            return str(item)
+    # check test_obs behaves like a set but also holds the dal representation
+    test_obs = Test()
+    test_obs.add(1)
+    assert 1 in test_obs
+    assert test_obs.dal == ['1']
+    test_obs.add(2)
+    test_obs.add(3)
+    assert len(test_obs) == 3
+    assert test_obs.dal == ['1', '2', '3']
+    assert {2, 3} < test_obs
+    assert {1, 2, 3, 4} > test_obs
+    test_obs.clear()
+    assert len(test_obs) == 0
+    assert len(test_obs.dal) == 0
+
+
+def test_dal_format():
+    iqp = IntervalQueryParam(unit=u.m, equivalencies=u.spectral())
+    assert '1.0 1.0' == iqp.get_dal_format(1)
+    assert '1.0 2.0' == iqp.get_dal_format((1, 2))
+    assert '1.0 2.0' == iqp.get_dal_format((100*u.cm, 200*u.cm))
+    assert '1.0 2.0' == iqp.get_dal_format((100, 200)*u.cm)
+    assert '0.14989622900000002 1.0' == iqp.get_dal_format((100*u.cm, 2*u.GHz))
+    assert '14.9896229 29.9792458' == iqp.get_dal_format((0.01, 0.02)*u.GHz)
+    # Quantity intervals are corrected in terms of min and max ..
+    assert '1.0 2.0' == iqp.get_dal_format((2, 1)*u.m)
+    # But unitless intervals are not
+    with pytest.raises(ValueError):
+        iqp.get_dal_format((2, 1))
diff --git a/pyvo/dal/tests/test_query.py b/pyvo/dal/tests/test_query.py
index 44ba7da..aa50f1b 100644
--- a/pyvo/dal/tests/test_query.py
+++ b/pyvo/dal/tests/test_query.py
@@ -19,6 +19,7 @@ import numpy as np
 from pyvo.dal.query import DALService, DALQuery, DALResults, Record
 from pyvo.dal.exceptions import DALServiceError, DALQueryError, DALFormatError
 from pyvo.version import version
+from pyvo.utils.compat import ASTROPY_LT_4_1
 
 from astropy.table import Table
 from astropy.io.votable.tree import VOTableFile, Table as VOTable
@@ -30,7 +31,7 @@ get_pkg_data_contents = partial(
     get_pkg_data_contents, package=__package__, encoding='binary')
 
 
-@pytest.fixture(autouse=True, scope='module')
+@pytest.fixture()
 def register_mocks(mocker):
     with ExitStack() as stack:
         matchers = [
@@ -123,9 +124,13 @@ def _test_results(results):
     assert results['1', 1] == 42
     assert results['1', 2] == 1337
 
-    assert results['2', 0] == b'Illuminatus'
-    assert results['2', 1] == b"Don't panic, and always carry a towel"
-    assert results['2', 2] == b'Elite'
+    truth = b'Illuminatus' if ASTROPY_LT_4_1 else 'Illuminatus'
+    assert results['2', 0] == truth
+    truth = b"Don't panic, and always carry a towel" \
+        if ASTROPY_LT_4_1 else "Don't panic, and always carry a towel"
+    assert results['2', 1] == truth
+    truth = b'Elite' if ASTROPY_LT_4_1 else 'Elite'
+    assert results['2', 2] == truth
 
 
 def _test_records(records):
@@ -135,16 +140,22 @@ def _test_records(records):
     assert all([isinstance(record, Record) for record in records])
 
     assert records[0]['1'] == 23
-    assert records[0]['2'] == b'Illuminatus'
+
+    truth = b'Illuminatus' if ASTROPY_LT_4_1 else 'Illuminatus'
+    assert records[0]['2'] == truth
 
     assert records[1]['1'] == 42
-    assert records[1]['2'] == b"Don't panic, and always carry a towel"
+    truth = b"Don't panic, and always carry a towel" \
+        if ASTROPY_LT_4_1 else "Don't panic, and always carry a towel"
+    assert records[1]['2'] == truth
 
     assert records[2]['1'] == 1337
-    assert records[2]['2'] == b'Elite'
+    truth = b'Elite' if ASTROPY_LT_4_1 else 'Elite'
+    assert records[2]['2'] == truth
 
 
 @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
+@pytest.mark.usefixtures('register_mocks')
 class TestDALService:
     def test_init(self):
         """Test if baseurl if passed correctly"""
@@ -208,6 +219,7 @@ class TestDALService:
 
 
 @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
+@pytest.mark.usefixtures('register_mocks')
 class TestDALQuery:
     def test_url(self):
         queries = (
@@ -245,6 +257,7 @@ class TestDALQuery:
 
 @pytest.mark.filterwarnings('ignore::astropy.io.votable.exceptions.W03')
 @pytest.mark.filterwarnings('ignore::astropy.io.votable.exceptions.W06')
+@pytest.mark.usefixtures('register_mocks')
 class TestDALResults:
     def test_init(self):
         dalresults = DALResults.from_result_url(
@@ -373,16 +386,18 @@ class TestDALResults:
 
 @pytest.mark.filterwarnings('ignore::astropy.io.votable.exceptions.W03')
 @pytest.mark.filterwarnings('ignore::astropy.io.votable.exceptions.W06')
+@pytest.mark.usefixtures('register_mocks')
 class TestRecord:
     def test_itemaccess(self):
         record = DALResults.from_result_url(
             'http://example.com/query/basic')[0]
 
         assert record['1'] == 23
-        assert record['2'] == b'Illuminatus'
+        truth = b'Illuminatus' if ASTROPY_LT_4_1 else 'Illuminatus'
+        assert record['2'] == truth
 
         assert record['_1'] == 23
-        assert record['_2'] == b'Illuminatus'
+        assert record['_2'] == truth
 
     def test_nosuchcolumn(self):
         record = DALResults.from_result_url(
@@ -409,8 +424,8 @@ class TestRecord:
     def test_repr(self):
         record = DALResults.from_result_url(
             'http://example.com/query/basic')[0]
-
-        assert repr(record) == repr((23, b'Illuminatus'))
+        truth = b'Illuminatus' if ASTROPY_LT_4_1 else 'Illuminatus'
+        assert repr(record) == repr((23, truth))
 
     def test_get(self):
         record = DALResults.from_result_url(
@@ -425,7 +440,8 @@ class TestRecord:
         assert record.getbyucd('foo') == 23
         assert record.getbyucd('bar') == 23
 
-        assert record.getbyutype('foobar') == b'Illuminatus'
+        truth = b'Illuminatus' if ASTROPY_LT_4_1 else 'Illuminatus'
+        assert record.getbyutype('foobar') == truth
 
         record.getbyucd('baz') is None
         record.getbyutype('foobaz') is None
diff --git a/pyvo/dal/tests/test_sia.py b/pyvo/dal/tests/test_sia.py
index 734a087..87ccda2 100644
--- a/pyvo/dal/tests/test_sia.py
+++ b/pyvo/dal/tests/test_sia.py
@@ -19,7 +19,7 @@ get_pkg_data_contents = partial(
 sia_re = re.compile('http://example.com/sia.*')
 
 
-@pytest.fixture(autouse=True, scope='module')
+@pytest.fixture()
 def register_mocks(mocker):
     with mocker.register_uri(
         'GET', 'http://example.com/querydata/image.fits',
@@ -43,6 +43,7 @@ def _test_result(result):
 
 
 @pytest.mark.usefixtures('sia')
+@pytest.mark.usefixtures('register_mocks')
 @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
 def test_search():
     results = search('http://example.com/sia', pos=(288, 15))
@@ -53,6 +54,7 @@ def test_search():
 
 class TestSIAService:
     @pytest.mark.usefixtures('sia')
+    @pytest.mark.usefixtures('register_mocks')
     @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
     @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W42")
     @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W49")
diff --git a/pyvo/dal/tests/test_sia2.py b/pyvo/dal/tests/test_sia2.py
new file mode 100644
index 0000000..0bbf228
--- /dev/null
+++ b/pyvo/dal/tests/test_sia2.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+"""
+Tests for pyvo.dal.sia
+"""
+from functools import partial
+import re
+
+import pytest
+
+from pyvo.dal.sia2 import search, SIAService, SIAQuery
+
+import astropy.units as u
+from astropy.utils.data import get_pkg_data_contents
+
+get_pkg_data_contents = partial(
+    get_pkg_data_contents, package=__package__, encoding='binary')
+
+sia_re = re.compile('https://example.com/sia/v2query*')
+capabilities_url = 'https://example.com/sia/capabilities'
+
+
+@pytest.fixture()
+def sia(mocker):
+    with mocker.register_uri(
+        'GET', sia_re, content=get_pkg_data_contents('data/sia2/dataset.xml')
+    ) as matcher:
+        yield matcher
+
+
+@pytest.fixture()
+def capabilities(mocker):
+    with mocker.register_uri(
+        'GET', capabilities_url,
+            content=get_pkg_data_contents('data/sia2/capabilities.xml')
+    ) as matcher:
+        yield matcher
+
+
+def _test_result(record):
+    assert record.obs_collection == 'TEST'
+    assert record.obs_id == 'TEST-DATASET'
+    assert record.instrument_name == 'TEST-INSTR'
+    assert record.facility_name == 'TEST-1.6m'
+
+
+@pytest.mark.usefixtures('sia')
+@pytest.mark.usefixtures('capabilities')
+@pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
+def test_search():
+    results = search('https://example.com/sia',
+                     pos=(33.3*u.deg, 4.2*u.deg, 0.0166*u.deg))
+    result = results[0]
+
+    _test_result(result)
+
+
+class TestSIAService:
+    @pytest.mark.usefixtures('sia')
+    @pytest.mark.usefixtures('capabilities')
+    @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W06")
+    @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W42")
+    @pytest.mark.filterwarnings("ignore::astropy.io.votable.exceptions.W49")
+    def test_search(self):
+        service = SIAService('https://example.com/sia')
+
+        positions = [
+            (2, 4, 0.0166 * u.deg),
+            (12, 12.5, 34, 36),
+            (12.0*u.deg, 34.0*u.deg,
+             14.0*u.deg, 35.0*u.deg,
+             14.0*u.deg, 36.0*u.deg,
+             12.0*u.deg, 35.0*u.deg)]
+
+        # each position
+        for pos in positions:
+            results = service.search(pos=pos)
+            result = results[0]
+            _test_result(result)
+
+        # all positions
+        results = service.search(pos=positions)
+        result = results[0]
+        _test_result(result)
+
+
+class TestSIAQuery():
+
+    def test_query(self):
+        query = SIAQuery('someurl')
+        query.field_of_view.add((10, 20))
+        assert query['FOV'] == ['10.0 20.0']
+        query.field_of_view.add((1*u.rad, 60))
+        assert query['FOV'] == ['10.0 20.0', '57.29577951308232 60.0']
+
+        query.spatial_resolution.add((1*u.arcsec, 2))
+        assert query['SPATRES'] == ['1.0 2.0']
+
+        query.spectral_resolving_power.add((3, 5))
+        assert query['SPECRP'] == ['3 5']
+
+        query.exptime.add((25, 50))
+        assert query['EXPTIME'] == ['25.0 50.0']
+
+        query.timeres.add((1, 3))
+        assert query['TIMERES'] == ['1.0 3.0']
+
+        query.publisher_did.add('ID1')
+        query.publisher_did.add('ID2')
+        assert query['ID'] == ['ID1', 'ID2']
+
+        query.facility.add('TEL1')
+        assert query['FACILITY'] == ['TEL1']
+
+        query.collection.add('ABC')
+        query.collection.add('EFG')
+        assert query['COLLECTION'] == ['ABC', 'EFG']
+
+        query.instrument.add('INST1')
+        assert query['INSTRUMENT'] == ['INST1']
+
+        query.data_type.add('TYPEA')
+        assert query['DPTYPE'] == ['TYPEA']
+
+        query.calib_level.add(0)
+        query.calib_level.add(1)
+        assert query['CALIB'] == ['0', '1']
+
+        query.target_name.add('TARGET1')
+        assert query['TARGET'] == ['TARGET1']
+
+        query.res_format.add('pdf')
+        assert query['FORMAT'] == ['pdf']
+
+        query.maxrec = 1000
+        assert query['MAXREC'] == '1000'
+
+        query = SIAQuery('someurl', custom_param=23)
+        assert query['custom_param'] == ['23']
+
+        query['custom_param'].append('-Inf 0')
+        assert query['custom_param'] == ['23', '-Inf 0']
+
+        query = SIAQuery('someurl', custom_param=[('-Inf', 0), (2, '+Inf')])
+        assert query['custom_param'] == ['-Inf 0', '2 +Inf']
diff --git a/pyvo/dal/tests/test_sia2_remote.py b/pyvo/dal/tests/test_sia2_remote.py
new file mode 100644
index 0000000..d12475d
--- /dev/null
+++ b/pyvo/dal/tests/test_sia2_remote.py
@@ -0,0 +1,224 @@
+#!/usr/bin/env python
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+"""
+Tests for pyvo.dal.sia2 against remote services
+"""
+
+import pytest
+
+from pyvo.dal.sia2 import search, SIAService
+
+import astropy.units as u
+
+CADC_SIA_URL = 'https://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/sia'
+
+
+@pytest.mark.remote_data
+class TestSIACadc():
+    # Tests the SIA2 client against the CADC SIA service
+
+    def test_service(self):
+        cadc = SIAService(baseurl=CADC_SIA_URL)
+        assert cadc.availability
+        assert cadc.availability.available
+        assert cadc.availability.notes
+        assert cadc.availability.notes[0] == 'service is accepting queries'
+        assert cadc.capabilities
+
+    def test_datalink_batch(self):
+        # Maximum batch size in CADC SIA is around 25
+        # Test whether multiple batches can be retrieved
+        results = search(CADC_SIA_URL, pos=(2.8425, 74.4846, 10), maxrec=55)
+        ids = []
+        for i in results.iter_datalinks():
+            assert i.table[0]['ID'] not in ids
+            ids.append(i.table[0]['ID'])
+        assert len(ids) == 55
+
+    def test_pos(self):
+        results = search(CADC_SIA_URL, pos=(2.8425, 74.4846, 0.001))
+        assert len(results) > 10
+
+        # limit results to 5 to expedite tests
+        results = search(CADC_SIA_URL, pos=(2.8425, 74.4846, 0.001), maxrec=5)
+        assert len(results) == 5
+        # check attributes of a record
+        record = results[0]
+        record.dataproduct_type
+        record.dataproduct_subtype
+        record.calib_level
+
+        #          TARGET INFO
+        record.target_name
+        record.target_class
+
+        #           DATA DESCRIPTION
+        record.obs_id
+        record.obs_title
+        record.obs_collection
+        record.obs_create_date
+        record.obs_creator_name
+        record.obs_creator_did
+
+        #          CURATION INFORMATION
+        record.obs_release_date
+        record.obs_publisher_did
+        record.publisher_id
+        record.bib_reference
+        record.data_rights
+
+        #            ACCESS INFORMATION
+        record.access_url
+        record.access_format
+        record.access_estsize
+
+        #            SPATIAL CHARACTERISATION
+        record.s_ra
+        record.s_dec
+        record.s_fov
+        record.s_region
+        record.s_resolution
+        record.s_xel1
+        record.s_xel2
+        record.s_ucd
+        record.s_unit
+        record.s_resolution_min
+        record.s_resolution_max
+        record.s_calib_status
+        record.s_stat_error
+        record.s_pixel_scale
+
+        #            TIME CHARACTERISATION
+        record.t_xel
+        record.t_ref_pos
+        record.t_min
+        record.t_max
+        record.t_exptime
+        record.t_resolution
+        record.t_calib_status
+        record.t_stat_error
+
+        #            SPECTRAL CHARACTERISATION
+        record.em_xel
+        record.em_ucd
+        record.em_unit
+        record.em_calib_status
+        record.em_min
+        record.em_max
+        record.em_res_power
+        record.em_res_power_min
+        record.em_res_power_max
+        record.em_resolution
+        record.em_stat_error
+
+        #            OBSERVABLE AXIS
+        record.o_ucd
+        record.o_unit
+        record.o_calib_status
+        record.o_stat_error
+
+        #            POLARIZATION CHARACTERISATION
+        record.pol_xel
+        record.pol_states
+
+        #            PROVENANCE
+        record.instrument_name
+        record.facility_name
+        record.proposal_id
+
+    def test_band(self):
+        results = search(CADC_SIA_URL, band=(0.0002, 0.0003), maxrec=5)
+        # TODO - correctness
+        assert len(results) == 5
+
+    def test_time(self):
+        results = search(CADC_SIA_URL,
+                         time=('2002-01-01T00:00:00.00',
+                               '2002-01-02T00:00:00.00'),
+                         maxrec=5)
+        assert len(results) == 5
+
+    def test_pol(self):
+        results = search(CADC_SIA_URL, pol=['YY', 'U'], maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert 'YY' in rr.pol_states or 'U' in rr.pol_states
+
+    def test_fov(self):
+        results = search(CADC_SIA_URL, field_of_view=(10, 20), maxrec=5)
+        assert len(results) == 5
+        # how to test values
+
+    def test_spatial_res(self):
+        results = search(CADC_SIA_URL, spatial_resolution=(1, 2), maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert 1*u.arcsec <= rr.s_resolution <= 2*u.arcsec
+
+    def test_spec_resp(self):
+        results = search(CADC_SIA_URL, spectral_resolving_power=(1, 2), maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert 1 <= rr.em_res_power <= 2
+
+    def test_exptime(self):
+        results = search(CADC_SIA_URL, exptime=(1, 2),
+                         maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert 1*u.second <= rr.t_exptime <= 2*u.second
+
+    def test_timeres(self):
+        results = search(CADC_SIA_URL, timeres=(1, 2),
+                         maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert 1 * u.second <= rr.t_resolution <= 2 * u.second
+
+    def test_publisher_did(self):
+        ids = ['ivo://cadc.nrc.ca/CFHT?447231/447231o',
+               'ivo://cadc.nrc.ca/CFHT?447232/447232o']
+        results = search(CADC_SIA_URL, publisher_did=ids)
+        assert len(results) == 2
+        assert results[0].obs_publisher_did in ids
+        assert results[1].obs_publisher_did in ids
+
+    def test_facility(self):
+        results = search(CADC_SIA_URL, facility='JCMT', maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert rr.facility_name == 'JCMT'
+
+    def test_collection(self):
+        results = search(CADC_SIA_URL, collection='CFHT', maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert rr.obs_collection == 'CFHT'
+
+    def test_instrument(self):
+        results = search(CADC_SIA_URL, instrument='SCUBA-2', maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert rr.instrument_name == 'SCUBA-2'
+
+    def test_dataproduct_type(self):
+        results = search(CADC_SIA_URL, data_type='image', maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert rr.dataproduct_type == 'image'
+
+    def test_target_name(self):
+        results = search(CADC_SIA_URL, target_name='OGF:t028', maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert rr.target_name == 'OGF:t028'
+
+    def test_res_format(self):
+        results = search(
+            CADC_SIA_URL,
+            res_format='application/x-votable+xml;content=datalink',
+            maxrec=5)
+        assert len(results) == 5
+        for rr in results:
+            assert rr.access_format == \
+                   'application/x-votable+xml;content=datalink'
diff --git a/pyvo/io/uws/tests/data/job-implicit-v1.0.xml b/pyvo/io/uws/tests/data/job-implicit-v1.0.xml
new file mode 100644
index 0000000..3f886cd
--- /dev/null
+++ b/pyvo/io/uws/tests/data/job-implicit-v1.0.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" ?>
+<job xmlns="http://www.ivoa.net/xml/UWS/v1.0" xmlns:xlink="http://www.w3.org/1999/xlink"
+	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.ivoa.net/xml/UWS/v1.0 http://www.ivoa.net/xml/UWS/v1.0">
+	<jobId>1576511540079_32840</jobId>
+	<ownerId/>
+	<phase>COMPLETED</phase>
+	<startTime>2019-12-16T10:52:20</startTime>
+	<endTime>2019-12-17T10:52:20</endTime>
+	<executionDuration>600</executionDuration>
+	<destruction>2019-12-17T10:52:20</destruction>
+	<parameters>
+		<parameter id="lang">ADQL-2.0</parameter>
+		<parameter id="serviceType">tap</parameter>
+		<parameter id="request">doQuery</parameter>
+		<parameter id="query">
+SELECT ra, dec FROM table
+</parameter>
+	</parameters>
+	<results>
+		<result id="resultsTable" xlink:href="http://example.com/tap/async/1234/results/result"/>
+	</results>
+</job>
diff --git a/pyvo/io/uws/tests/test_job.py b/pyvo/io/uws/tests/test_job.py
index 3168188..2571101 100644
--- a/pyvo/io/uws/tests/test_job.py
+++ b/pyvo/io/uws/tests/test_job.py
@@ -14,3 +14,9 @@ class TestJob:
             "data/job.xml"))
 
         assert job.jobid == '1337'
+        assert job.version == '1.1'
+
+        job = uws.parse_job(get_pkg_data_filename(
+            "data/job-implicit-v1.0.xml"))
+
+        assert job.version == '1.0'
diff --git a/pyvo/io/vosi/tests/data/tables/no_table_description.xml b/pyvo/io/vosi/tests/data/tables/no_table_description.xml
new file mode 100644
index 0000000..b8d4912
--- /dev/null
+++ b/pyvo/io/vosi/tests/data/tables/no_table_description.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href='/static/xsl/vosi.xsl' type='text/xsl'?>
+<vtm:tableset xmlns:vs="http://www.ivoa.net/xml/VODataService/v1.1" xmlns:vtm="http://www.ivoa.net/xml/VOSITables/v1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.ivoa.net/xml/VODataService/v1.1 http://vo.ari.uni-heidelberg.de/docs/schemata/VODataService-v1.1.xsd http://www.ivoa.net/xml/VOSITables/v1.0 http://vo.ari.uni-heidelberg.de/docs/schemata/VOSITables-v1.0.xsd">
+  <schema>
+    <name>test</name>
+    <table>
+      <name>description</name>
+    </table>
+  </schema>
+</vtm:tableset>
diff --git a/pyvo/io/vosi/tests/data/tables/single_table_description.xml b/pyvo/io/vosi/tests/data/tables/single_table_description.xml
new file mode 100644
index 0000000..027ad6a
--- /dev/null
+++ b/pyvo/io/vosi/tests/data/tables/single_table_description.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href='/static/xsl/vosi.xsl' type='text/xsl'?>
+<vtm:tableset xmlns:vs="http://www.ivoa.net/xml/VODataService/v1.1" xmlns:vtm="http://www.ivoa.net/xml/VOSITables/v1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.ivoa.net/xml/VODataService/v1.1 http://vo.ari.uni-heidelberg.de/docs/schemata/VODataService-v1.1.xsd http://www.ivoa.net/xml/VOSITables/v1.0 http://vo.ari.uni-heidelberg.de/docs/schemata/VOSITables-v1.0.xsd">
+  <schema>
+    <name>test</name>
+    <table>
+      <name>description</name>
+      <description>A test table with a single description</description>
+    </table>
+  </schema>
+</vtm:tableset>
diff --git a/pyvo/io/vosi/tests/test_tables.py b/pyvo/io/vosi/tests/test_tables.py
index 811e2ed..e8f4fd5 100644
--- a/pyvo/io/vosi/tests/test_tables.py
+++ b/pyvo/io/vosi/tests/test_tables.py
@@ -3,6 +3,8 @@
 """
 Tests for pyvo.io.vosi
 """
+import contextlib
+import io
 import pytest
 
 import pyvo.io.vosi as vosi
@@ -35,8 +37,9 @@ class TestTables:
         assert col.utype == "utype"
 
         assert type(col.datatype) == vs.TAPType
+        assert str(col.datatype) == "<DataType arraysize=*>VARCHAR</DataType>"
         assert col.datatype.arraysize == "*"
-        assert col.datatype.delim == ';'
+        assert col.datatype.delim == ";"
         assert col.datatype.size == "42"
         assert col.datatype.content == "VARCHAR"
 
@@ -368,3 +371,32 @@ class TestTables:
             vosi.parse_tables(
                 get_pkg_data_filename(
                     "data/tables/wrong_arraysize.xml"))
+
+    def test_no_table_description(self):
+        """Test handling of describing tables with no description
+        """
+        tableset = vosi.parse_tables(
+                get_pkg_data_filename(
+                    "data/tables/no_table_description.xml"))
+        nodesc_table = tableset.get_first_table()
+        assert nodesc_table.description is None
+
+        with io.StringIO() as buf, contextlib.redirect_stdout(buf):
+            nodesc_table.describe()
+            output = buf.getvalue()
+        assert 'No description' in output
+
+    def test_single_table_description(self):
+        """Test describing a table with a single description
+        """
+        tableset = vosi.parse_tables(
+                get_pkg_data_filename(
+                    "data/tables/single_table_description.xml"))
+        onedesc_table = tableset.get_first_table()
+        describe_string = 'A test table with a single description'
+        assert describe_string in onedesc_table.description
+
+        with io.StringIO() as buf, contextlib.redirect_stdout(buf):
+            onedesc_table.describe()
+            output = buf.getvalue()
+        assert describe_string in output
diff --git a/pyvo/registry/tests/conftest.py b/pyvo/registry/tests/conftest.py
index f6612eb..f8c9b1a 100644
--- a/pyvo/registry/tests/conftest.py
+++ b/pyvo/registry/tests/conftest.py
@@ -21,7 +21,7 @@ class ContextAdapter(requests_mock.Adapter):
             self._matchers.remove(matcher)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope='function')
 def mocker():
     with requests_mock.Mocker(
         adapter=ContextAdapter(case_sensitive=True)
diff --git a/pyvo/registry/tests/test_regtap.py b/pyvo/registry/tests/test_regtap.py
index 5d5af7f..f90c0fc 100644
--- a/pyvo/registry/tests/test_regtap.py
+++ b/pyvo/registry/tests/test_regtap.py
@@ -56,6 +56,31 @@ def keywords_fixture(mocker):
         yield matcher
 
 
+@pytest.fixture()
+def single_keyword_fixture(mocker):
+    def keywordstest_callback(request, context):
+        data = dict(parse_qsl(request.body))
+        query = data['QUERY']
+
+        assert "WHERE isub0.res_subject ILIKE '%single%'" in query
+        assert "WHERE 1=ivo_hasword(ires0.res_description, 'single')" in query
+        assert "OR 1=ivo_hasword(ires0.res_title, 'single')" in query
+
+        assert "'ivo://ivoa.net/std/conesearch'" in query
+        assert "'ivo://ivoa.net/std/sia'" in query
+        assert "'ivo://ivoa.net/std/ssa'" in query
+        assert "'ivo://ivoa.net/std/slap'" in query
+        assert "'ivo://ivoa.net/std/tap'" in query
+
+        return get_pkg_data_contents('data/regtap.xml')
+
+    with mocker.register_uri(
+        'POST', 'http://dc.g-vo.org/tap/sync',
+        content=keywordstest_callback
+    ) as matcher:
+        yield matcher
+
+
 @pytest.fixture()
 def servicetype_fixture(mocker):
     def servicetypetest_callback(request, context):
@@ -119,6 +144,12 @@ def test_keywords():
     regsearch(keywords=['vizier', 'pulsar'])
 
 
+@pytest.mark.usefixtures('single_keyword_fixture', 'capabilities')
+def test_single_keyword():
+    regsearch(keywords=['single'])
+    regsearch(keywords='single')
+
+
 @pytest.mark.usefixtures('servicetype_fixture', 'capabilities')
 def test_servicetype():
     regsearch(servicetype='tap')