Repository: astropy/pyvo
Pull request #301 (2022-02-14 20:09:40)

DOC: Fix coverage badge
The existing badge is wrong.


Patch:
diff --git a/.github/workflows/cibuild.yml b/.github/workflows/cibuild.yml
index 3b136d8..587fa35 100644
--- a/.github/workflows/cibuild.yml
+++ b/.github/workflows/cibuild.yml
@@ -26,7 +26,7 @@ jobs:
     strategy:
       fail-fast: true
       matrix:
-        python-version: ["3.7", "3.8", "3.9", "3.10"]
+        python-version: ["3.8", "3.9", "3.10"]
     steps:
     - name: Checkout code
       uses: actions/checkout@v2
@@ -61,11 +61,11 @@ jobs:
     - name: Set up Python
       uses: actions/setup-python@v2
       with:
-        python-version: 3.8
+        python-version: 3.9
     - name: Install tox
       run: python -m pip install --upgrade tox
-    - name: Python 3.8 with latest astropy
-      run: tox -e py38-latest
+    - name: Python 3.9 with latest astropy
+      run: tox -e py39-test
 
 
   style_coverage:
diff --git a/.gitignore b/.gitignore
index 3348922..d9c8629 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,6 +42,7 @@ sdist
 develop-eggs
 .installed.cfg
 distribute-*.tar.gz
+pip-wheel-metadata
 
 # Other
 .cache
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index 6a20fa6..0000000
--- a/.gitmodules
+++ /dev/null
@@ -1,3 +0,0 @@
-[submodule "astropy_helpers"]
-	path = astropy_helpers
-	url = https://github.com/astropy/astropy-helpers.git
diff --git a/CHANGES.rst b/CHANGES.rst
index 2976263..e12b2ac 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,6 +1,7 @@
 1.3 (unreleased)
 ================
 
+- pyvo deals with non-core terms in datalink.bysemantics again. [#299]
 
 1.2.1 (2022-01-12)
 ==================
diff --git a/MANIFEST.in b/MANIFEST.in
index c3bf88f..4eacd66 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,9 +1,8 @@
 include README.rst
 include CHANGES.rst
 
-include ez_setup.py
-include ah_bootstrap.py
 include setup.cfg
+include pyproject.toml
 
 recursive-include packagename *.pyx *.c *.pxd
 recursive-include docs *
@@ -15,26 +14,4 @@ prune build
 prune docs/_build
 prune docs/api
 
-
-# the next few stanzas are for astropy_helpers.  It's derived from the
-# astropy_helpers/MANIFEST.in, but requires additional includes for the actual
-# package directory and egg-info.
-
-include astropy_helpers/README.rst
-include astropy_helpers/CHANGES.rst
-include astropy_helpers/LICENSE.rst
-recursive-include astropy_helpers/licenses *
-
-include astropy_helpers/ez_setup.py
-include astropy_helpers/ah_bootstrap.py
-
-recursive-include astropy_helpers/astropy_helpers *.py *.pyx *.c *.h
-recursive-include astropy_helpers/astropy_helpers.egg-info *
-# include the sphinx stuff with "*" because there are css/html/rst/etc.
-recursive-include astropy_helpers/astropy_helpers/sphinx *
-
-prune astropy_helpers/build
-prune astropy_helpers/astropy_helpers/tests
-
-
 global-exclude *.pyc *.o
diff --git a/README.rst b/README.rst
index 49da9e4..90d024b 100644
--- a/README.rst
+++ b/README.rst
@@ -9,8 +9,8 @@ PyVO
     :target: https://github.com/astropy/pyvo/workflows/CI/badge.svg?branch=main
     :alt: CI Status
 
-.. image:: https://coveralls.io/repos/github/pyvirtobs/pyvo/badge.svg?branch=main
-    :target: https://coveralls.io/github/pyvirtobs/pyvo?branch=main
+.. image:: https://codecov.io/gh/astropy/pyvo/branch/main/graph/badge.svg?token=Mynyo9xoPZ
+    :target: https://codecov.io/gh/astropy/pyvo
     :alt: Coverage Status
 
 .. image:: https://zenodo.org/badge/10865450.svg
diff --git a/ah_bootstrap.py b/ah_bootstrap.py
deleted file mode 100644
index 512a05a..0000000
--- a/ah_bootstrap.py
+++ /dev/null
@@ -1,1010 +0,0 @@
-"""
-This bootstrap module contains code for ensuring that the astropy_helpers
-package will be importable by the time the setup.py script runs.  It also
-includes some workarounds to ensure that a recent-enough version of setuptools
-is being used for the installation.
-
-This module should be the first thing imported in the setup.py of distributions
-that make use of the utilities in astropy_helpers.  If the distribution ships
-with its own copy of astropy_helpers, this module will first attempt to import
-from the shipped copy.  However, it will also check PyPI to see if there are
-any bug-fix releases on top of the current version that may be useful to get
-past platform-specific bugs that have been fixed.  When running setup.py, use
-the ``--offline`` command-line option to disable the auto-upgrade checks.
-
-When this module is imported or otherwise executed it automatically calls a
-main function that attempts to read the project's setup.cfg file, which it
-checks for a configuration section called ``[ah_bootstrap]`` the presences of
-that section, and options therein, determine the next step taken:  If it
-contains an option called ``auto_use`` with a value of ``True``, it will
-automatically call the main function of this module called
-`use_astropy_helpers` (see that function's docstring for full details).
-Otherwise no further action is taken and by default the system-installed version
-of astropy-helpers will be used (however, ``ah_bootstrap.use_astropy_helpers``
-may be called manually from within the setup.py script).
-
-This behavior can also be controlled using the ``--auto-use`` and
-``--no-auto-use`` command-line flags. For clarity, an alias for
-``--no-auto-use`` is ``--use-system-astropy-helpers``, and we recommend using
-the latter if needed.
-
-Additional options in the ``[ah_boostrap]`` section of setup.cfg have the same
-names as the arguments to `use_astropy_helpers`, and can be used to configure
-the bootstrap script when ``auto_use = True``.
-
-See https://github.com/astropy/astropy-helpers for more details, and for the
-latest version of this module.
-"""
-
-import contextlib
-import errno
-import io
-import locale
-import os
-import re
-import subprocess as sp
-import sys
-
-from distutils import log
-from distutils.debug import DEBUG
-
-from configparser import ConfigParser, RawConfigParser
-
-import pkg_resources
-
-from setuptools import Distribution
-from setuptools.package_index import PackageIndex
-
-# This is the minimum Python version required for astropy-helpers
-__minimum_python_version__ = (3, 5)
-
-# TODO: Maybe enable checking for a specific version of astropy_helpers?
-DIST_NAME = 'astropy-helpers'
-PACKAGE_NAME = 'astropy_helpers'
-UPPER_VERSION_EXCLUSIVE = None
-
-# Defaults for other options
-DOWNLOAD_IF_NEEDED = True
-INDEX_URL = 'https://pypi.python.org/simple'
-USE_GIT = True
-OFFLINE = False
-AUTO_UPGRADE = True
-
-# A list of all the configuration options and their required types
-CFG_OPTIONS = [
-    ('auto_use', bool), ('path', str), ('download_if_needed', bool),
-    ('index_url', str), ('use_git', bool), ('offline', bool),
-    ('auto_upgrade', bool)
-]
-
-# Start off by parsing the setup.cfg file
-
-SETUP_CFG = ConfigParser()
-
-if os.path.exists('setup.cfg'):
-
-    try:
-        SETUP_CFG.read('setup.cfg')
-    except Exception as e:
-        if DEBUG:
-            raise
-
-        log.error(
-            "Error reading setup.cfg: {0!r}\n{1} will not be "
-            "automatically bootstrapped and package installation may fail."
-            "\n{2}".format(e, PACKAGE_NAME, _err_help_msg))
-
-# We used package_name in the package template for a while instead of name
-if SETUP_CFG.has_option('metadata', 'name'):
-    parent_package = SETUP_CFG.get('metadata', 'name')
-elif SETUP_CFG.has_option('metadata', 'package_name'):
-    parent_package = SETUP_CFG.get('metadata', 'package_name')
-else:
-    parent_package = None
-
-if SETUP_CFG.has_option('options', 'python_requires'):
-
-    python_requires = SETUP_CFG.get('options', 'python_requires')
-
-    # The python_requires key has a syntax that can be parsed by SpecifierSet
-    # in the packaging package. However, we don't want to have to depend on that
-    # package, so instead we can use setuptools (which bundles packaging). We
-    # have to add 'python' to parse it with Requirement.
-
-    from pkg_resources import Requirement
-    req = Requirement.parse('python' + python_requires)
-
-    # We want the Python version as a string, which we can get from the platform module
-    import platform
-    # strip off trailing '+' incase this is a dev install of python
-    python_version = platform.python_version().strip('+')
-    # allow pre-releases to count as 'new enough'
-    if not req.specifier.contains(python_version, True):
-        if parent_package is None:
-            message = "ERROR: Python {} is required by this package\n".format(req.specifier)
-        else:
-            message = "ERROR: Python {} is required by {}\n".format(req.specifier, parent_package)
-        sys.stderr.write(message)
-        sys.exit(1)
-
-if sys.version_info < __minimum_python_version__:
-
-    if parent_package is None:
-        message = "ERROR: Python {} or later is required by astropy-helpers\n".format(
-            __minimum_python_version__)
-    else:
-        message = "ERROR: Python {} or later is required by astropy-helpers for {}\n".format(
-            __minimum_python_version__, parent_package)
-
-    sys.stderr.write(message)
-    sys.exit(1)
-
-_str_types = (str, bytes)
-
-
-# What follows are several import statements meant to deal with install-time
-# issues with either missing or misbehaving pacakges (including making sure
-# setuptools itself is installed):
-
-# Check that setuptools 30.3 or later is present
-from distutils.version import LooseVersion
-
-try:
-    import setuptools
-    assert LooseVersion(setuptools.__version__) >= LooseVersion('30.3')
-except (ImportError, AssertionError):
-    sys.stderr.write("ERROR: setuptools 30.3 or later is required by astropy-helpers\n")
-    sys.exit(1)
-
-# typing as a dependency for 1.6.1+ Sphinx causes issues when imported after
-# initializing submodule with ah_boostrap.py
-# See discussion and references in
-# https://github.com/astropy/astropy-helpers/issues/302
-
-try:
-    import typing   # noqa
-except ImportError:
-    pass
-
-
-# Note: The following import is required as a workaround to
-# https://github.com/astropy/astropy-helpers/issues/89; if we don't import this
-# module now, it will get cleaned up after `run_setup` is called, but that will
-# later cause the TemporaryDirectory class defined in it to stop working when
-# used later on by setuptools
-try:
-    import setuptools.py31compat   # noqa
-except ImportError:
-    pass
-
-
-# matplotlib can cause problems if it is imported from within a call of
-# run_setup(), because in some circumstances it will try to write to the user's
-# home directory, resulting in a SandboxViolation.  See
-# https://github.com/matplotlib/matplotlib/pull/4165
-# Making sure matplotlib, if it is available, is imported early in the setup
-# process can mitigate this (note importing matplotlib.pyplot has the same
-# issue)
-try:
-    import matplotlib
-    matplotlib.use('Agg')
-    import matplotlib.pyplot
-except:
-    # Ignore if this fails for *any* reason*
-    pass
-
-
-# End compatibility imports...
-
-
-class _Bootstrapper(object):
-    """
-    Bootstrapper implementation.  See ``use_astropy_helpers`` for parameter
-    documentation.
-    """
-
-    def __init__(self, path=None, index_url=None, use_git=None, offline=None,
-                 download_if_needed=None, auto_upgrade=None):
-
-        if path is None:
-            path = PACKAGE_NAME
-
-        if not (isinstance(path, _str_types) or path is False):
-            raise TypeError('path must be a string or False')
-
-        if not isinstance(path, str):
-            fs_encoding = sys.getfilesystemencoding()
-            path = path.decode(fs_encoding)  # path to unicode
-
-        self.path = path
-
-        # Set other option attributes, using defaults where necessary
-        self.index_url = index_url if index_url is not None else INDEX_URL
-        self.offline = offline if offline is not None else OFFLINE
-
-        # If offline=True, override download and auto-upgrade
-        if self.offline:
-            download_if_needed = False
-            auto_upgrade = False
-
-        self.download = (download_if_needed
-                         if download_if_needed is not None
-                         else DOWNLOAD_IF_NEEDED)
-        self.auto_upgrade = (auto_upgrade
-                             if auto_upgrade is not None else AUTO_UPGRADE)
-
-        # If this is a release then the .git directory will not exist so we
-        # should not use git.
-        git_dir_exists = os.path.exists(os.path.join(os.path.dirname(__file__), '.git'))
-        if use_git is None and not git_dir_exists:
-            use_git = False
-
-        self.use_git = use_git if use_git is not None else USE_GIT
-        # Declared as False by default--later we check if astropy-helpers can be
-        # upgraded from PyPI, but only if not using a source distribution (as in
-        # the case of import from a git submodule)
-        self.is_submodule = False
-
-    @classmethod
-    def main(cls, argv=None):
-        if argv is None:
-            argv = sys.argv
-
-        config = cls.parse_config()
-        config.update(cls.parse_command_line(argv))
-
-        auto_use = config.pop('auto_use', False)
-        bootstrapper = cls(**config)
-
-        if auto_use:
-            # Run the bootstrapper, otherwise the setup.py is using the old
-            # use_astropy_helpers() interface, in which case it will run the
-            # bootstrapper manually after reconfiguring it.
-            bootstrapper.run()
-
-        return bootstrapper
-
-    @classmethod
-    def parse_config(cls):
-
-        if not SETUP_CFG.has_section('ah_bootstrap'):
-            return {}
-
-        config = {}
-
-        for option, type_ in CFG_OPTIONS:
-            if not SETUP_CFG.has_option('ah_bootstrap', option):
-                continue
-
-            if type_ is bool:
-                value = SETUP_CFG.getboolean('ah_bootstrap', option)
-            else:
-                value = SETUP_CFG.get('ah_bootstrap', option)
-
-            config[option] = value
-
-        return config
-
-    @classmethod
-    def parse_command_line(cls, argv=None):
-        if argv is None:
-            argv = sys.argv
-
-        config = {}
-
-        # For now we just pop recognized ah_bootstrap options out of the
-        # arg list.  This is imperfect; in the unlikely case that a setup.py
-        # custom command or even custom Distribution class defines an argument
-        # of the same name then we will break that.  However there's a catch22
-        # here that we can't just do full argument parsing right here, because
-        # we don't yet know *how* to parse all possible command-line arguments.
-        if '--no-git' in argv:
-            config['use_git'] = False
-            argv.remove('--no-git')
-
-        if '--offline' in argv:
-            config['offline'] = True
-            argv.remove('--offline')
-
-        if '--auto-use' in argv:
-            config['auto_use'] = True
-            argv.remove('--auto-use')
-
-        if '--no-auto-use' in argv:
-            config['auto_use'] = False
-            argv.remove('--no-auto-use')
-
-        if '--use-system-astropy-helpers' in argv:
-            config['auto_use'] = False
-            argv.remove('--use-system-astropy-helpers')
-
-        return config
-
-    def run(self):
-        strategies = ['local_directory', 'local_file', 'index']
-        dist = None
-
-        # First, remove any previously imported versions of astropy_helpers;
-        # this is necessary for nested installs where one package's installer
-        # is installing another package via setuptools.sandbox.run_setup, as in
-        # the case of setup_requires
-        for key in list(sys.modules):
-            try:
-                if key == PACKAGE_NAME or key.startswith(PACKAGE_NAME + '.'):
-                    del sys.modules[key]
-            except AttributeError:
-                # Sometimes mysterious non-string things can turn up in
-                # sys.modules
-                continue
-
-        # Check to see if the path is a submodule
-        self.is_submodule = self._check_submodule()
-
-        for strategy in strategies:
-            method = getattr(self, 'get_{0}_dist'.format(strategy))
-            dist = method()
-            if dist is not None:
-                break
-        else:
-            raise _AHBootstrapSystemExit(
-                "No source found for the {0!r} package; {0} must be "
-                "available and importable as a prerequisite to building "
-                "or installing this package.".format(PACKAGE_NAME))
-
-        # This is a bit hacky, but if astropy_helpers was loaded from a
-        # directory/submodule its Distribution object gets a "precedence" of
-        # "DEVELOP_DIST".  However, in other cases it gets a precedence of
-        # "EGG_DIST".  However, when activing the distribution it will only be
-        # placed early on sys.path if it is treated as an EGG_DIST, so always
-        # do that
-        dist = dist.clone(precedence=pkg_resources.EGG_DIST)
-
-        # Otherwise we found a version of astropy-helpers, so we're done
-        # Just active the found distribution on sys.path--if we did a
-        # download this usually happens automatically but it doesn't hurt to
-        # do it again
-        # Note: Adding the dist to the global working set also activates it
-        # (makes it importable on sys.path) by default.
-
-        try:
-            pkg_resources.working_set.add(dist, replace=True)
-        except TypeError:
-            # Some (much) older versions of setuptools do not have the
-            # replace=True option here.  These versions are old enough that all
-            # bets may be off anyways, but it's easy enough to work around just
-            # in case...
-            if dist.key in pkg_resources.working_set.by_key:
-                del pkg_resources.working_set.by_key[dist.key]
-            pkg_resources.working_set.add(dist)
-
-    @property
-    def config(self):
-        """
-        A `dict` containing the options this `_Bootstrapper` was configured
-        with.
-        """
-
-        return dict((optname, getattr(self, optname))
-                    for optname, _ in CFG_OPTIONS if hasattr(self, optname))
-
-    def get_local_directory_dist(self):
-        """
-        Handle importing a vendored package from a subdirectory of the source
-        distribution.
-        """
-
-        if not os.path.isdir(self.path):
-            return
-
-        log.info('Attempting to import astropy_helpers from {0} {1!r}'.format(
-                 'submodule' if self.is_submodule else 'directory',
-                 self.path))
-
-        dist = self._directory_import()
-
-        if dist is None:
-            log.warn(
-                'The requested path {0!r} for importing {1} does not '
-                'exist, or does not contain a copy of the {1} '
-                'package.'.format(self.path, PACKAGE_NAME))
-        elif self.auto_upgrade and not self.is_submodule:
-            # A version of astropy-helpers was found on the available path, but
-            # check to see if a bugfix release is available on PyPI
-            upgrade = self._do_upgrade(dist)
-            if upgrade is not None:
-                dist = upgrade
-
-        return dist
-
-    def get_local_file_dist(self):
-        """
-        Handle importing from a source archive; this also uses setup_requires
-        but points easy_install directly to the source archive.
-        """
-
-        if not os.path.isfile(self.path):
-            return
-
-        log.info('Attempting to unpack and import astropy_helpers from '
-                 '{0!r}'.format(self.path))
-
-        try:
-            dist = self._do_download(find_links=[self.path])
-        except Exception as e:
-            if DEBUG:
-                raise
-
-            log.warn(
-                'Failed to import {0} from the specified archive {1!r}: '
-                '{2}'.format(PACKAGE_NAME, self.path, str(e)))
-            dist = None
-
-        if dist is not None and self.auto_upgrade:
-            # A version of astropy-helpers was found on the available path, but
-            # check to see if a bugfix release is available on PyPI
-            upgrade = self._do_upgrade(dist)
-            if upgrade is not None:
-                dist = upgrade
-
-        return dist
-
-    def get_index_dist(self):
-        if not self.download:
-            log.warn('Downloading {0!r} disabled.'.format(DIST_NAME))
-            return None
-
-        log.warn(
-            "Downloading {0!r}; run setup.py with the --offline option to "
-            "force offline installation.".format(DIST_NAME))
-
-        try:
-            dist = self._do_download()
-        except Exception as e:
-            if DEBUG:
-                raise
-            log.warn(
-                'Failed to download and/or install {0!r} from {1!r}:\n'
-                '{2}'.format(DIST_NAME, self.index_url, str(e)))
-            dist = None
-
-        # No need to run auto-upgrade here since we've already presumably
-        # gotten the most up-to-date version from the package index
-        return dist
-
-    def _directory_import(self):
-        """
-        Import astropy_helpers from the given path, which will be added to
-        sys.path.
-
-        Must return True if the import succeeded, and False otherwise.
-        """
-
-        # Return True on success, False on failure but download is allowed, and
-        # otherwise raise SystemExit
-        path = os.path.abspath(self.path)
-
-        # Use an empty WorkingSet rather than the man
-        # pkg_resources.working_set, since on older versions of setuptools this
-        # will invoke a VersionConflict when trying to install an upgrade
-        ws = pkg_resources.WorkingSet([])
-        ws.add_entry(path)
-        dist = ws.by_key.get(DIST_NAME)
-
-        if dist is None:
-            # We didn't find an egg-info/dist-info in the given path, but if a
-            # setup.py exists we can generate it
-            setup_py = os.path.join(path, 'setup.py')
-            if os.path.isfile(setup_py):
-                # We use subprocess instead of run_setup from setuptools to
-                # avoid segmentation faults - see the following for more details:
-                # https://github.com/cython/cython/issues/2104
-                sp.check_output([sys.executable, 'setup.py', 'egg_info'], cwd=path)
-
-                for dist in pkg_resources.find_distributions(path, True):
-                    # There should be only one...
-                    return dist
-
-        return dist
-
-    def _do_download(self, version='', find_links=None):
-        if find_links:
-            allow_hosts = ''
-            index_url = None
-        else:
-            allow_hosts = None
-            index_url = self.index_url
-
-        # Annoyingly, setuptools will not handle other arguments to
-        # Distribution (such as options) before handling setup_requires, so it
-        # is not straightforward to programmatically augment the arguments which
-        # are passed to easy_install
-        class _Distribution(Distribution):
-            def get_option_dict(self, command_name):
-                opts = Distribution.get_option_dict(self, command_name)
-                if command_name == 'easy_install':
-                    if find_links is not None:
-                        opts['find_links'] = ('setup script', find_links)
-                    if index_url is not None:
-                        opts['index_url'] = ('setup script', index_url)
-                    if allow_hosts is not None:
-                        opts['allow_hosts'] = ('setup script', allow_hosts)
-                return opts
-
-        if version:
-            req = '{0}=={1}'.format(DIST_NAME, version)
-        else:
-            if UPPER_VERSION_EXCLUSIVE is None:
-                req = DIST_NAME
-            else:
-                req = '{0}<{1}'.format(DIST_NAME, UPPER_VERSION_EXCLUSIVE)
-
-        attrs = {'setup_requires': [req]}
-
-        # NOTE: we need to parse the config file (e.g. setup.cfg) to make sure
-        # it honours the options set in the [easy_install] section, and we need
-        # to explicitly fetch the requirement eggs as setup_requires does not
-        # get honored in recent versions of setuptools:
-        # https://github.com/pypa/setuptools/issues/1273
-
-        try:
-
-            context = _verbose if DEBUG else _silence
-            with context():
-                dist = _Distribution(attrs=attrs)
-                try:
-                    dist.parse_config_files(ignore_option_errors=True)
-                    dist.fetch_build_eggs(req)
-                except TypeError:
-                    # On older versions of setuptools, ignore_option_errors
-                    # doesn't exist, and the above two lines are not needed
-                    # so we can just continue
-                    pass
-
-            # If the setup_requires succeeded it will have added the new dist to
-            # the main working_set
-            return pkg_resources.working_set.by_key.get(DIST_NAME)
-        except Exception as e:
-            if DEBUG:
-                raise
-
-            msg = 'Error retrieving {0} from {1}:\n{2}'
-            if find_links:
-                source = find_links[0]
-            elif index_url != INDEX_URL:
-                source = index_url
-            else:
-                source = 'PyPI'
-
-            raise Exception(msg.format(DIST_NAME, source, repr(e)))
-
-    def _do_upgrade(self, dist):
-        # Build up a requirement for a higher bugfix release but a lower minor
-        # release (so API compatibility is guaranteed)
-        next_version = _next_version(dist.parsed_version)
-
-        req = pkg_resources.Requirement.parse(
-            '{0}>{1},<{2}'.format(DIST_NAME, dist.version, next_version))
-
-        package_index = PackageIndex(index_url=self.index_url)
-
-        upgrade = package_index.obtain(req)
-
-        if upgrade is not None:
-            return self._do_download(version=upgrade.version)
-
-    def _check_submodule(self):
-        """
-        Check if the given path is a git submodule.
-
-        See the docstrings for ``_check_submodule_using_git`` and
-        ``_check_submodule_no_git`` for further details.
-        """
-
-        if (self.path is None or
-                (os.path.exists(self.path) and not os.path.isdir(self.path))):
-            return False
-
-        if self.use_git:
-            return self._check_submodule_using_git()
-        else:
-            return self._check_submodule_no_git()
-
-    def _check_submodule_using_git(self):
-        """
-        Check if the given path is a git submodule.  If so, attempt to initialize
-        and/or update the submodule if needed.
-
-        This function makes calls to the ``git`` command in subprocesses.  The
-        ``_check_submodule_no_git`` option uses pure Python to check if the given
-        path looks like a git submodule, but it cannot perform updates.
-        """
-
-        cmd = ['git', 'submodule', 'status', '--', self.path]
-
-        try:
-            log.info('Running `{0}`; use the --no-git option to disable git '
-                     'commands'.format(' '.join(cmd)))
-            returncode, stdout, stderr = run_cmd(cmd)
-        except _CommandNotFound:
-            # The git command simply wasn't found; this is most likely the
-            # case on user systems that don't have git and are simply
-            # trying to install the package from PyPI or a source
-            # distribution.  Silently ignore this case and simply don't try
-            # to use submodules
-            return False
-
-        stderr = stderr.strip()
-
-        if returncode != 0 and stderr:
-            # Unfortunately the return code alone cannot be relied on, as
-            # earlier versions of git returned 0 even if the requested submodule
-            # does not exist
-
-            # This is a warning that occurs in perl (from running git submodule)
-            # which only occurs with a malformatted locale setting which can
-            # happen sometimes on OSX.  See again
-            # https://github.com/astropy/astropy/issues/2749
-            perl_warning = ('perl: warning: Falling back to the standard locale '
-                            '("C").')
-            if not stderr.strip().endswith(perl_warning):
-                # Some other unknown error condition occurred
-                log.warn('git submodule command failed '
-                         'unexpectedly:\n{0}'.format(stderr))
-                return False
-
-        # Output of `git submodule status` is as follows:
-        #
-        # 1: Status indicator: '-' for submodule is uninitialized, '+' if
-        # submodule is initialized but is not at the commit currently indicated
-        # in .gitmodules (and thus needs to be updated), or 'U' if the
-        # submodule is in an unstable state (i.e. has merge conflicts)
-        #
-        # 2. SHA-1 hash of the current commit of the submodule (we don't really
-        # need this information but it's useful for checking that the output is
-        # correct)
-        #
-        # 3. The output of `git describe` for the submodule's current commit
-        # hash (this includes for example what branches the commit is on) but
-        # only if the submodule is initialized.  We ignore this information for
-        # now
-        _git_submodule_status_re = re.compile(
-            r'^(?P<status>[+-U ])(?P<commit>[0-9a-f]{40}) '
-            r'(?P<submodule>\S+)( .*)?$')
-
-        # The stdout should only contain one line--the status of the
-        # requested submodule
-        m = _git_submodule_status_re.match(stdout)
-        if m:
-            # Yes, the path *is* a git submodule
-            self._update_submodule(m.group('submodule'), m.group('status'))
-            return True
-        else:
-            log.warn(
-                'Unexpected output from `git submodule status`:\n{0}\n'
-                'Will attempt import from {1!r} regardless.'.format(
-                    stdout, self.path))
-            return False
-
-    def _check_submodule_no_git(self):
-        """
-        Like ``_check_submodule_using_git``, but simply parses the .gitmodules file
-        to determine if the supplied path is a git submodule, and does not exec any
-        subprocesses.
-
-        This can only determine if a path is a submodule--it does not perform
-        updates, etc.  This function may need to be updated if the format of the
-        .gitmodules file is changed between git versions.
-        """
-
-        gitmodules_path = os.path.abspath('.gitmodules')
-
-        if not os.path.isfile(gitmodules_path):
-            return False
-
-        # This is a minimal reader for gitconfig-style files.  It handles a few of
-        # the quirks that make gitconfig files incompatible with ConfigParser-style
-        # files, but does not support the full gitconfig syntax (just enough
-        # needed to read a .gitmodules file).
-        gitmodules_fileobj = io.StringIO()
-
-        # Must use io.open for cross-Python-compatible behavior wrt unicode
-        with io.open(gitmodules_path) as f:
-            for line in f:
-                # gitconfig files are more flexible with leading whitespace; just
-                # go ahead and remove it
-                line = line.lstrip()
-
-                # comments can start with either # or ;
-                if line and line[0] in (':', ';'):
-                    continue
-
-                gitmodules_fileobj.write(line)
-
-        gitmodules_fileobj.seek(0)
-
-        cfg = RawConfigParser()
-
-        try:
-            cfg.readfp(gitmodules_fileobj)
-        except Exception as exc:
-            log.warn('Malformatted .gitmodules file: {0}\n'
-                     '{1} cannot be assumed to be a git submodule.'.format(
-                         exc, self.path))
-            return False
-
-        for section in cfg.sections():
-            if not cfg.has_option(section, 'path'):
-                continue
-
-            submodule_path = cfg.get(section, 'path').rstrip(os.sep)
-
-            if submodule_path == self.path.rstrip(os.sep):
-                return True
-
-        return False
-
-    def _update_submodule(self, submodule, status):
-        if status == ' ':
-            # The submodule is up to date; no action necessary
-            return
-        elif status == '-':
-            if self.offline:
-                raise _AHBootstrapSystemExit(
-                    "Cannot initialize the {0} submodule in --offline mode; "
-                    "this requires being able to clone the submodule from an "
-                    "online repository.".format(submodule))
-            cmd = ['update', '--init']
-            action = 'Initializing'
-        elif status == '+':
-            cmd = ['update']
-            action = 'Updating'
-            if self.offline:
-                cmd.append('--no-fetch')
-        elif status == 'U':
-            raise _AHBootstrapSystemExit(
-                'Error: Submodule {0} contains unresolved merge conflicts.  '
-                'Please complete or abandon any changes in the submodule so that '
-                'it is in a usable state, then try again.'.format(submodule))
-        else:
-            log.warn('Unknown status {0!r} for git submodule {1!r}.  Will '
-                     'attempt to use the submodule as-is, but try to ensure '
-                     'that the submodule is in a clean state and contains no '
-                     'conflicts or errors.\n{2}'.format(status, submodule,
-                                                        _err_help_msg))
-            return
-
-        err_msg = None
-        cmd = ['git', 'submodule'] + cmd + ['--', submodule]
-        log.warn('{0} {1} submodule with: `{2}`'.format(
-            action, submodule, ' '.join(cmd)))
-
-        try:
-            log.info('Running `{0}`; use the --no-git option to disable git '
-                     'commands'.format(' '.join(cmd)))
-            returncode, stdout, stderr = run_cmd(cmd)
-        except OSError as e:
-            err_msg = str(e)
-        else:
-            if returncode != 0:
-                err_msg = stderr
-
-        if err_msg is not None:
-            log.warn('An unexpected error occurred updating the git submodule '
-                     '{0!r}:\n{1}\n{2}'.format(submodule, err_msg,
-                                               _err_help_msg))
-
-class _CommandNotFound(OSError):
-    """
-    An exception raised when a command run with run_cmd is not found on the
-    system.
-    """
-
-
-def run_cmd(cmd):
-    """
-    Run a command in a subprocess, given as a list of command-line
-    arguments.
-
-    Returns a ``(returncode, stdout, stderr)`` tuple.
-    """
-
-    try:
-        p = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.PIPE)
-        # XXX: May block if either stdout or stderr fill their buffers;
-        # however for the commands this is currently used for that is
-        # unlikely (they should have very brief output)
-        stdout, stderr = p.communicate()
-    except OSError as e:
-        if DEBUG:
-            raise
-
-        if e.errno == errno.ENOENT:
-            msg = 'Command not found: `{0}`'.format(' '.join(cmd))
-            raise _CommandNotFound(msg, cmd)
-        else:
-            raise _AHBootstrapSystemExit(
-                'An unexpected error occurred when running the '
-                '`{0}` command:\n{1}'.format(' '.join(cmd), str(e)))
-
-
-    # Can fail of the default locale is not configured properly.  See
-    # https://github.com/astropy/astropy/issues/2749.  For the purposes under
-    # consideration 'latin1' is an acceptable fallback.
-    try:
-        stdio_encoding = locale.getdefaultlocale()[1] or 'latin1'
-    except ValueError:
-        # Due to an OSX oddity locale.getdefaultlocale() can also crash
-        # depending on the user's locale/language settings.  See:
-        # http://bugs.python.org/issue18378
-        stdio_encoding = 'latin1'
-
-    # Unlikely to fail at this point but even then let's be flexible
-    if not isinstance(stdout, str):
-        stdout = stdout.decode(stdio_encoding, 'replace')
-    if not isinstance(stderr, str):
-        stderr = stderr.decode(stdio_encoding, 'replace')
-
-    return (p.returncode, stdout, stderr)
-
-
-def _next_version(version):
-    """
-    Given a parsed version from pkg_resources.parse_version, returns a new
-    version string with the next minor version.
-
-    Examples
-    ========
-    >>> _next_version(pkg_resources.parse_version('1.2.3'))
-    '1.3.0'
-    """
-
-    if hasattr(version, 'base_version'):
-        # New version parsing from setuptools >= 8.0
-        if version.base_version:
-            parts = version.base_version.split('.')
-        else:
-            parts = []
-    else:
-        parts = []
-        for part in version:
-            if part.startswith('*'):
-                break
-            parts.append(part)
-
-    parts = [int(p) for p in parts]
-
-    if len(parts) < 3:
-        parts += [0] * (3 - len(parts))
-
-    major, minor, micro = parts[:3]
-
-    return '{0}.{1}.{2}'.format(major, minor + 1, 0)
-
-
-class _DummyFile(object):
-    """A noop writeable object."""
-
-    errors = ''  # Required for Python 3.x
-    encoding = 'utf-8'
-
-    def write(self, s):
-        pass
-
-    def flush(self):
-        pass
-
-
-@contextlib.contextmanager
-def _verbose():
-    yield
-
-@contextlib.contextmanager
-def _silence():
-    """A context manager that silences sys.stdout and sys.stderr."""
-
-    old_stdout = sys.stdout
-    old_stderr = sys.stderr
-    sys.stdout = _DummyFile()
-    sys.stderr = _DummyFile()
-    exception_occurred = False
-    try:
-        yield
-    except:
-        exception_occurred = True
-        # Go ahead and clean up so that exception handling can work normally
-        sys.stdout = old_stdout
-        sys.stderr = old_stderr
-        raise
-
-    if not exception_occurred:
-        sys.stdout = old_stdout
-        sys.stderr = old_stderr
-
-
-_err_help_msg = """
-If the problem persists consider installing astropy_helpers manually using pip
-(`pip install astropy_helpers`) or by manually downloading the source archive,
-extracting it, and installing by running `python setup.py install` from the
-root of the extracted source code.
-"""
-
-
-class _AHBootstrapSystemExit(SystemExit):
-    def __init__(self, *args):
-        if not args:
-            msg = 'An unknown problem occurred bootstrapping astropy_helpers.'
-        else:
-            msg = args[0]
-
-        msg += '\n' + _err_help_msg
-
-        super(_AHBootstrapSystemExit, self).__init__(msg, *args[1:])
-
-
-BOOTSTRAPPER = _Bootstrapper.main()
-
-
-def use_astropy_helpers(**kwargs):
-    """
-    Ensure that the `astropy_helpers` module is available and is importable.
-    This supports automatic submodule initialization if astropy_helpers is
-    included in a project as a git submodule, or will download it from PyPI if
-    necessary.
-
-    Parameters
-    ----------
-
-    path : str or None, optional
-        A filesystem path relative to the root of the project's source code
-        that should be added to `sys.path` so that `astropy_helpers` can be
-        imported from that path.
-
-        If the path is a git submodule it will automatically be initialized
-        and/or updated.
-
-        The path may also be to a ``.tar.gz`` archive of the astropy_helpers
-        source distribution.  In this case the archive is automatically
-        unpacked and made temporarily available on `sys.path` as a ``.egg``
-        archive.
-
-        If `None` skip straight to downloading.
-
-    download_if_needed : bool, optional
-        If the provided filesystem path is not found an attempt will be made to
-        download astropy_helpers from PyPI.  It will then be made temporarily
-        available on `sys.path` as a ``.egg`` archive (using the
-        ``setup_requires`` feature of setuptools.  If the ``--offline`` option
-        is given at the command line the value of this argument is overridden
-        to `False`.
-
-    index_url : str, optional
-        If provided, use a different URL for the Python package index than the
-        main PyPI server.
-
-    use_git : bool, optional
-        If `False` no git commands will be used--this effectively disables
-        support for git submodules. If the ``--no-git`` option is given at the
-        command line the value of this argument is overridden to `False`.
-
-    auto_upgrade : bool, optional
-        By default, when installing a package from a non-development source
-        distribution ah_boostrap will try to automatically check for patch
-        releases to astropy-helpers on PyPI and use the patched version over
-        any bundled versions.  Setting this to `False` will disable that
-        functionality. If the ``--offline`` option is given at the command line
-        the value of this argument is overridden to `False`.
-
-    offline : bool, optional
-        If `False` disable all actions that require an internet connection,
-        including downloading packages from the package index and fetching
-        updates to any git submodule.  Defaults to `True`.
-    """
-
-    global BOOTSTRAPPER
-
-    config = BOOTSTRAPPER.config
-    config.update(**kwargs)
-
-    # Create a new bootstrapper with the updated configuration and run it
-    BOOTSTRAPPER = _Bootstrapper(**config)
-    BOOTSTRAPPER.run()
diff --git a/astropy_helpers b/astropy_helpers
deleted file mode 160000
index ba37342..0000000
--- a/astropy_helpers
+++ /dev/null
@@ -1,1 +0,0 @@
-Subproject commit ba3734222a40f4c2864c375c6639f32cd9df06cd
diff --git a/docs/conf.py b/docs/conf.py
index 208be87..7458d46 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -29,23 +29,15 @@ import datetime
 import os
 import sys
 
+sys.path.insert(0, os.path.abspath('..'))
 try:
-    import astropy_helpers
+    from sphinx_astropy.conf.v1 import *  # noqa
 except ImportError:
-    # Building from inside the docs/ directory?
-    if os.path.basename(os.getcwd()) == 'docs':
-        a_h_path = os.path.abspath(os.path.join('..', 'astropy_helpers'))
-        if os.path.isdir(a_h_path):
-            sys.path.insert(1, a_h_path)
-
-# Load all of the global Astropy configuration
-from sphinx_astropy.conf import *
+    print('ERROR: the documentation requires the sphinx-astropy package to be installed')
+    sys.exit(1)
 
 # Get configuration information from setup.cfg
-try:
-    from ConfigParser import ConfigParser
-except ImportError:
-    from configparser import ConfigParser
+from configparser import ConfigParser
 conf = ConfigParser()
 conf.read([os.path.join(os.path.dirname(__file__), '..', 'setup.cfg')])
 setup_cfg = dict(conf.items('metadata'))
@@ -77,7 +69,7 @@ rst_epilog += """
 # -- Project information ------------------------------------------------------
 
 # This does not *have* to match the package name, but typically does
-project = setup_cfg['package_name']
+project = setup_cfg['name']
 author = setup_cfg['author']
 copyright = '{}, {}'.format(
     datetime.datetime.now().year, setup_cfg['author'])
@@ -86,8 +78,8 @@ copyright = '{}, {}'.format(
 # |version| and |release|, also used in various other places throughout the
 # built documents.
 
-__import__(setup_cfg['package_name'])
-package = sys.modules[setup_cfg['package_name']]
+__import__(project)
+package = sys.modules[project]
 
 # The short X.Y version.
 version = package.__version__.split('-', 1)[0]
@@ -153,7 +145,7 @@ man_pages = [('index', project.lower(), project + ' Documentation',
 if eval(setup_cfg.get('edit_on_github')):
     extensions += ['astropy.sphinx.ext.edit_on_github']
 
-    versionmod = __import__(setup_cfg['package_name'] + '.version')
+    versionmod = __import__(project + '.version')
     edit_on_github_project = setup_cfg['github_project']
     if versionmod.release:
         edit_on_github_branch = "v" + versionmod.version
diff --git a/pyproject.toml b/pyproject.toml
index 9987957..9cf2b64 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,4 +1,11 @@
 [tool.astropy-bot]
-
 [tool.astropy-bot.changelog_checker]
 filename = "CHANGES.rst"
+
+[build-system]
+
+requires = ["setuptools",
+            "setuptools_scm",
+            "wheel"]
+
+build-backend = 'setuptools.build_meta'
diff --git a/pyvo/__init__.py b/pyvo/__init__.py
index 1e36c24..5a76fc4 100644
--- a/pyvo/__init__.py
+++ b/pyvo/__init__.py
@@ -33,12 +33,11 @@ from ._astropy_init import *
 # ----------------------------------------------------------------------------
 
 # For egg_info test builds to pass, put package imports here.
-if not _ASTROPY_SETUP_:
-    from . import registry
-    from .dal import ssa, sia, sla, scs, tap
-    from . import auth
-    from .registry import search as regsearch
-    from .dal import (
-        imagesearch, spectrumsearch, conesearch, linesearch, tablesearch,
-        DALAccessError, DALProtocolError, DALFormatError, DALServiceError,
-        DALQueryError)
+from . import registry
+from .dal import ssa, sia, sla, scs, tap
+from . import auth
+from .registry import search as regsearch
+from .dal import (
+    imagesearch, spectrumsearch, conesearch, linesearch, tablesearch,
+    DALAccessError, DALProtocolError, DALFormatError, DALServiceError,
+    DALQueryError)
diff --git a/pyvo/_astropy_init.py b/pyvo/_astropy_init.py
index 236cdbc..bd45324 100644
--- a/pyvo/_astropy_init.py
+++ b/pyvo/_astropy_init.py
@@ -1,29 +1,13 @@
 # Licensed under a 3-clause BSD style license - see LICENSE.rst
+import os
 
-__all__ = ['__version__', '__githash__']
-
-# this indicates whether or not we are in the package's setup.py
-try:
-    _ASTROPY_SETUP_
-except NameError:
-    import builtins
-    builtins._ASTROPY_SETUP_ = False
+__all__ = ['__version__', 'test']
 
 try:
     from .version import version as __version__
 except ImportError:
     __version__ = ''
-try:
-    from .version import githash as __githash__
-except ImportError:
-    __githash__ = ''
-
-
-if not _ASTROPY_SETUP_:  # noqa
-    import os
 
-    # Create the test function for self test
-    from astropy.tests.runner import TestRunner
-    test = TestRunner.make_test_runner_in(os.path.dirname(__file__))
-    test.__test__ = False
-    __all__ += ['test']
+# Create the test function for self test
+from astropy.tests.runner import TestRunner
+test = TestRunner.make_test_runner_in(os.path.dirname(__file__))
diff --git a/pyvo/dal/adhoc.py b/pyvo/dal/adhoc.py
index ce5ca90..12215a3 100644
--- a/pyvo/dal/adhoc.py
+++ b/pyvo/dal/adhoc.py
@@ -525,10 +525,10 @@ class DatalinkResults(DatalinkResultsMixin, DALResults):
         ----------
         semantics: str or list
             One or more term(s) from the datalink vocabulary
-            (http://www.ivoa.net/rdf/datalink/core).  Terms may be
-            passed in with or without a leading hash.  Note that at
-            this point non-datalink/core URIs (i.e., full resource
-            URIs) are not supported (and will be butchered).
+            (http://www.ivoa.net/rdf/datalink/core).  datalink/core
+            terms may be passed in with or without a leading hash.
+            Free URIs may also be passed an and will be compared literally,
+            i.e., without any URI normalisation.
         include_narrower: boolean
             If true, the result will include matches for any term
             that is narrower than the term passed in.
@@ -538,19 +538,33 @@ class DatalinkResults(DatalinkResultsMixin, DALResults):
         Sequence of DatalinkRecord
             a sequence of dictionary-like wrappers containing the result record
         """
+        # If the URL juggling gets any more complicated here, we ought
+        # to bite the bullet and only deal with full URLs.  Sigh.
         if isinstance(semantics, str):
             semantics = [semantics]
-        semantics = [term.lstrip("#") for term in semantics]
+
+        core_terms, other_terms = [], []
+        for term in semantics:
+            if ":" in term:
+                # it's a full URI, see if it's ours
+                if term.startswith("http://www.ivoa.net/rdf/datalink/core#"):
+                    core_terms.append(term.split("#", 1)[-1])
+                else:
+                    other_terms.append(term)
+            else:
+                # it's a local term
+                core_terms.append(term.lstrip("#"))
 
         if include_narrower:
             additional_terms = []
             voc = vocabularies.get_vocabulary("datalink/core")
-            for term in semantics:
+            for term in core_terms:
                 if term in voc["terms"]:
                     additional_terms.extend(voc["terms"][term]["narrower"])
-            semantics = semantics+additional_terms
+            core_terms = core_terms+additional_terms
 
-        semantics = set("#"+term for term in semantics)
+        semantics = set("#"+term for term in core_terms
+            ) | set(other_terms)
         for record in self:
             if record.semantics in semantics:
                 yield record
diff --git a/pyvo/dal/tap.py b/pyvo/dal/tap.py
index 3fe5786..375a5ed 100644
--- a/pyvo/dal/tap.py
+++ b/pyvo/dal/tap.py
@@ -5,7 +5,7 @@ A module for accessing remote source and observation catalogs
 from functools import partial
 from datetime import datetime
 from time import sleep
-from distutils.version import LooseVersion
+from packaging.version import Version
 
 import requests
 from urllib.parse import urlparse, urljoin
@@ -793,7 +793,7 @@ class AsyncTAPJob:
                 break
 
             # fallback for uws 1.0
-            if LooseVersion(self._job.version) < LooseVersion("1.1"):
+            if Version(self._job.version) < Version("1.1"):
                 sleep(interval)
                 interval = min(120, interval * increment)
 
diff --git a/pyvo/utils/compat.py b/pyvo/utils/compat.py
index 88bad5f..6c6a492 100644
--- a/pyvo/utils/compat.py
+++ b/pyvo/utils/compat.py
@@ -2,9 +2,9 @@
 """
 Placeholder for compatibility constructs
 """
-from distutils.version import LooseVersion
+from packaging.version import Version
 import astropy
 
 __all__ = ['ASTROPY_LT_4_1']
 
-ASTROPY_LT_4_1 = LooseVersion(astropy.__version__) < '4.1'
+ASTROPY_LT_4_1 = Version(astropy.__version__) < Version('4.1')
diff --git a/setup.cfg b/setup.cfg
index 50f55e7..e1bd4e0 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,29 +1,17 @@
-[build_sphinx]
-source-dir = docs
-build-dir = docs/_build
-all_files = 1
-
-[build_docs]
-source-dir = docs
-build-dir = docs/_build
-all_files = 1
-
-[upload_docs]
-upload-dir = docs/_build/html
-show-response = 1
-
 [tools:pytest]
-minversion = 2.2
+minversion = 6.0
 norecursedirs = build docs/_build
+testpaths = "pyvo" "docs"
+astropy_header = true
+doctest_plus = enabled
+text_file_format = rst
+addopts = --doctest-rst
 remote_data_strict = true
 
-[ah_bootstrap]
-auto_use = True
-
 [flake8]
 max-line-length = 110
 max-doc-length = 79
-exclude = __init__.py, setup_package.py, conf.py, setup.py, ah_bootstrap.py, astropy_helpers, version.py, ci-helpers conftest.py
+exclude = __init__.py, setup_package.py, conf.py, setup.py, version.py, conftest.py
 
 [pycodestyle]
 max-line-length = 110
@@ -31,29 +19,47 @@ max-doc-length = 79
 exclude = extern,sphinx,*parsetab.py
 
 [metadata]
-package_name = pyvo
+name = pyvo
 description = Astropy affiliated package for accessing Virtual Observatory data and services
-long_description =
-author = the IVOA community
-author_email = sbecker@ari.uni-heidelberg.de
+long_description = file: README.rst
+author = the PyVO Developers
 license = BSD
+license_file = LICENSE.rst
 url = https://github.com/astropy/pyvo
 edit_on_github = False
 github_project = astropy/pyvo
-install_requires = astropy>=4.0 requests
-# version should be PEP440 compatible (http://www.python.org/dev/peps/pep-0440)
-version = 1.3.dev
+
+[options]
+setup_requires = setuptools_scm
+install_requires =
+    astropy>=4.0
+    requests
+python_requires = >=3.8
+
+[options.extras_require]
+all =
+    mimeparse
+test =
+    pytest-astropy
+docs =
+    sphinx-astropy
 
 [coverage:run]
 omit =
-  pyvo/__init__*
-  pyvo/conftest.py
-  pyvo/*setup*
-  pyvo/*/tests/*
-  */pyvo/__init__*
-  */pyvo/conftest.py
-  */pyvo/*setup*
-  */pyvo/*/tests/*
+    pyvo/_astropy_init*
+    pyvo/conftest.py
+    pyvo/*setup_package*
+    pyvo/tests/*
+    pyvo/*/tests/*
+    pyvo/extern/*
+    pyvo/version*
+    */pyvo/_astropy_init*
+    */pyvo/conftest.py
+    */pyvo/*setup_package*
+    */pyvo/tests/*
+    */pyvo/*/tests/*
+    */pyvo/extern/*
+    */pyvo/version*
 
 [coverage:report]
 exclude_lines =
diff --git a/setup.py b/setup.py
index 1a4c3b4..ec3bb82 100644
--- a/setup.py
+++ b/setup.py
@@ -1,127 +1,78 @@
 #!/usr/bin/env python
 # Licensed under a 3-clause BSD style license - see LICENSE.rst
 
-import glob
+# NOTE: The configuration for the package, including the name, version, and
+# other information are set in the setup.cfg file.
+
 import os
 import sys
 
-import ah_bootstrap
 from setuptools import setup
 
-# A dirty hack to get around some early import/configurations ambiguities
-import builtins
-builtins._ASTROPY_SETUP_ = True
-
-from astropy_helpers.setup_helpers import (register_commands, get_debug_option,
-                                           get_package_info)
-from astropy_helpers.git_helpers import get_git_devstr
-from astropy_helpers.version_helpers import generate_version_py
-
-# Get some values from the setup.cfg
-from configparser import ConfigParser
-conf = ConfigParser()
-conf.read(['setup.cfg'])
-metadata = dict(conf.items('metadata'))
-
-PACKAGENAME = metadata.get('package_name', 'pyvo')
-DESCRIPTION = metadata.get('description', 'Astropy affiliated package')
-AUTHOR = metadata.get('author', '')
-AUTHOR_EMAIL = metadata.get('author_email', '')
-LICENSE = metadata.get('license', 'unknown')
-URL = metadata.get('url', '')
-
-# order of priority for long_description:
-#   (1) set in setup.cfg,
-#   (2) load LONG_DESCRIPTION.rst,
-#   (3) load README.rst,
-#   (4) package docstring
-readme_glob = 'README*'
-_cfg_long_description = metadata.get('long_description', '')
-if _cfg_long_description:
-    LONG_DESCRIPTION = _cfg_long_description
-
-elif os.path.exists('LONG_DESCRIPTION.rst'):
-    with open('LONG_DESCRIPTION.rst') as f:
-        LONG_DESCRIPTION = f.read()
-
-elif len(glob.glob(readme_glob)) > 0:
-    with open(glob.glob(readme_glob)[0]) as f:
-        LONG_DESCRIPTION = f.read()
-
-else:
-    # Get the long description from the package's docstring
-    __import__(PACKAGENAME)
-    package = sys.modules[PACKAGENAME]
-    LONG_DESCRIPTION = package.__doc__
-
-# Store the package name in a built-in variable so it's easy
-# to get from other parts of the setup infrastructure
-builtins._ASTROPY_PACKAGE_NAME_ = PACKAGENAME
-
-# VERSION should be PEP440 compatible (http://www.python.org/dev/peps/pep-0440)
-VERSION = metadata.get('version', '0.0.dev')
-
-# Indicates if this version is a release version
-RELEASE = 'dev' not in VERSION
-
-if not RELEASE:
-    VERSION += get_git_devstr(False)
-
-# Populate the dict of setup command overrides; this should be done before
-# invoking any other functionality from distutils since it can potentially
-# modify distutils' behavior.
-cmdclassd = register_commands(PACKAGENAME, VERSION, RELEASE)
-
-# Freeze build information in version.py
-generate_version_py(PACKAGENAME, VERSION, RELEASE,
-                    get_debug_option(PACKAGENAME))
-
-# Get configuration information from all of the various subpackages.
-# See the docstring for setup_helpers.update_package_files for more
-# details.
-package_info = get_package_info()
-
-# Add the project-global data
-package_info['package_data'].setdefault(PACKAGENAME, [])
-package_info['package_data'][PACKAGENAME].append('data/*')
-
-# Define entry points for command-line scripts
-entry_points = {'console_scripts': []}
-
-entry_point_list = conf.items('entry_points')
-for entry_point in entry_point_list:
-    entry_points['console_scripts'].append('{} = {}'.format(entry_point[0],
-                                                              entry_point[1]))
-
-# Include all .c files, recursively, including those generated by
-# Cython, since we can not do this in MANIFEST.in with a "dynamic"
-# directory name.
-c_files = []
-for root, dirs, files in os.walk(PACKAGENAME):
-    for filename in files:
-        if filename.endswith('.c'):
-            c_files.append(
-                os.path.join(
-                    os.path.relpath(root, PACKAGENAME), filename))
-package_info['package_data'][PACKAGENAME].extend(c_files)
-
-# Note that requires and provides should not be included in the call to
-# ``setup``, since these are now deprecated. See this link for more details:
-# https://groups.google.com/forum/#!topic/astropy-dev/urYO8ckB2uM
-
-setup(name=PACKAGENAME,
-      version=VERSION,
-      description=DESCRIPTION,
-      python_requires='>=3.7',
-      install_requires=metadata.get('install_requires', 'astropy').strip().split(),
-      author=AUTHOR,
-      author_email=AUTHOR_EMAIL,
-      license=LICENSE,
-      url=URL,
-      long_description=LONG_DESCRIPTION,
-      cmdclass=cmdclassd,
-      zip_safe=False,
-      use_2to3=False,
-      entry_points=entry_points,
-      **package_info
-      )
+
+# First provide helpful messages if contributors try and run legacy commands
+# for tests or docs.
+
+TEST_HELP = """
+Note: running tests is no longer done using 'python setup.py test'. Instead
+you will need to run:
+
+    tox -e test
+
+If you don't already have tox installed, you can install it with:
+
+    pip install tox
+
+If you only want to run part of the test suite, you can also use pytest
+directly with::
+
+    pip install -e .[test]
+    pytest
+
+For more information, see:
+
+  http://docs.astropy.org/en/latest/development/testguide.html#running-tests
+"""
+
+if 'test' in sys.argv:
+    print(TEST_HELP)
+    sys.exit(1)
+
+DOCS_HELP = """
+Note: building the documentation is no longer done using
+'python setup.py build_docs'. Instead you will need to run:
+
+    tox -e build_docs
+
+If you don't already have tox installed, you can install it with:
+
+    pip install tox
+
+You can also build the documentation with Sphinx directly using::
+
+    pip install -e .[docs]
+    cd docs
+    make html
+
+For more information, see:
+
+  http://docs.astropy.org/en/latest/install.html#builddocs
+"""
+
+if 'build_docs' in sys.argv or 'build_sphinx' in sys.argv:
+    print(DOCS_HELP)
+    sys.exit(1)
+
+VERSION_TEMPLATE = """
+# Note that we need to fall back to the hard-coded version if either
+# setuptools_scm can't be imported or setuptools_scm can't determine the
+# version, so we catch the generic 'Exception'.
+try:
+    from setuptools_scm import get_version
+    version = get_version(root='..', relative_to=__file__)
+except Exception:
+    version = '{version}'
+""".lstrip()
+
+setup(use_scm_version={'write_to': os.path.join('pyvo', 'version.py'),
+                       'write_to_template': VERSION_TEMPLATE})
diff --git a/tox.ini b/tox.ini
index 1770d4b..db26d3b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,11 +3,12 @@
 # as oldestastropy and devastropy might not support the full python range
 # listed here
 envlist =
-    py{37,38,39,310}-test{,-oldestastropy, -devastropy}
+    py{38,39,310}-test{,-oldestastropy, -devastropy}
     egg_info
     cov
     linkcheck
     codestyle
+    build_docs
 
 requires =
     setuptools >= 30.3.0
@@ -20,6 +21,14 @@ commands =
     pip freeze
     pytest --pyargs pyvo
 
+# tox environments are constructed with so-called 'factors' (or terms)
+# separated by hyphens, e.g. test-devdeps-cov. Lines below starting with factor:
+# will only take effect if that factor is included in the environment name. To
+# see a list of example environments that can be run, along with a description,
+# run:
+#
+#     tox -l -v
+#
 description =
     run tests
     oldestdeps: with astropy 4.0.*
@@ -28,23 +37,11 @@ description =
 deps =
     devastropy: git+https://github.com/astropy/astropy.git#egg=astropy
     oldestdeps: astropy==4.0
-    astropy
-    requests
     mimeparse
     pytest-astropy
     requests_mock
     pillow
 
-    astropy40: astropy==4.0.*
-    astropy41: astropy==4.1.*
-    latest: astropy
-    astropydev: git+https://github.com/astropy/astropy.git#egg=astropy
-
-#    # Linkcheck is broken with cryptic error with Sphinx 3.1.0
-#    # https://github.com/astropy/astropy/issues/10489
-#    # It also emits a warning from BLS docstring that we cannot banish with numpydoc 1.1.0
-    linkcheck: sphinx<3.1
-    linkcheck: numpydoc<1.1.0
 
 [testenv:egg_info]
 description = ensure egg_info works without dependencies
@@ -57,8 +54,6 @@ description = determine the code coverage
 deps:
      # had to pin this
     coverage==4.5.4
-    #-rpip-requirements
-    requests
     mimeparse
     pytest-astropy
     requests_mock
@@ -76,18 +71,26 @@ extras = docs
 deps = sphinx_astropy
 commands =
     pip freeze
-    python setup.py build_docs -w
+    sphinx-build -W -b html . _build/html
 
 
 [testenv:linkcheck]
 changedir = docs
-deps = sphinx_astropy
+deps = 
+    sphinx_astropy
 description = check the links in the HTML docs
 extras = docs
 commands =
-    pip freeze
     sphinx-build -W -b linkcheck . _build/html
 
+[testenv:build_docs]
+changedir = docs
+description = invoke sphinx-build to build the HTML docs
+extras = docs
+requires = sphinx
+commands =
+    sphinx-build -W -b html . _build/html
+
 [testenv:codestyle]
 # We list the warnings/errors to check for here rather than in setup.cfg because
 # we don't want these options to apply whenever anyone calls flake8 from the


Test patch:
diff --git a/pyvo/conftest.py b/pyvo/conftest.py
index 3561940..c7ab0be 100644
--- a/pyvo/conftest.py
+++ b/pyvo/conftest.py
@@ -1,53 +1,37 @@
-# Licensed under a 3-clause BSD style license - see LICENSE.rst
-import os
-from distutils.version import LooseVersion
-# this contains imports plugins that configure py.test for astropy tests.
-# by importing them here in conftest.py they are discoverable by py.test
-# no matter how it is invoked within the source tree.
+"""Configure Test Suite.
 
-from astropy.version import version as astropy_version
-
-if LooseVersion(astropy_version) < LooseVersion('2.0.3'):
-    # Astropy is not compatible with the standalone plugins prior this while
-    # astroquery requires them, so we need this workaround. This will mess
-    # up the test header, but everything else will work.
-    from astropy.tests.pytest_plugins import (PYTEST_HEADER_MODULES,
-                                              enable_deprecations_as_exceptions,
-                                              TESTED_VERSIONS)
-elif astropy_version < '3.0':
-    # With older versions of Astropy, we actually need to import the pytest
-    # plugins themselves in order to make them discoverable by pytest.
-    from astropy.tests.pytest_plugins import *
-else:
-    # As of Astropy 3.0, the pytest plugins provided by Astropy are
-    # automatically made available when Astropy is installed. This means it's
-    # not necessary to import them here, but we still need to import global
-    # variables that are used for configuration.
-    from pytest_astropy_header.display import PYTEST_HEADER_MODULES, TESTED_VERSIONS
+This file is used to configure the behavior of pytest when using the Astropy
+test infrastructure. It needs to live inside the package in order for it to
+get picked up when running the tests inside an interpreter using
+`pyvo.test()`.
 
-from astropy.tests.helper import enable_deprecations_as_exceptions
+"""
 
-# Add astropy to test header information and remove unused packages.
-# Pytest header customisation was introduced in astropy 1.0.
+from astropy.version import version as astropy_version
 
 try:
-    PYTEST_HEADER_MODULES['Astropy'] = 'astropy'
-    del PYTEST_HEADER_MODULES['h5py']
-    del PYTEST_HEADER_MODULES['Scipy']
-    del PYTEST_HEADER_MODULES['Matplotlib']
-    del PYTEST_HEADER_MODULES['Pandas']
-except (NameError, KeyError):
-    pass
+    from pytest_astropy_header.display import PYTEST_HEADER_MODULES, TESTED_VERSIONS
+    ASTROPY_HEADER = True
+except ImportError:
+    ASTROPY_HEADER = False
+
 
-enable_deprecations_as_exceptions()
+def pytest_configure(config):
+    """Configure Pytest with Astropy.
 
+    Parameters
+    ----------
+    config : pytest configuration
 
+    """
+    if ASTROPY_HEADER:
 
-# This is to figure out the affiliated package version, rather than
-# using Astropy's
-from .version import version, astropy_helpers_version
+        config.option.astropy_header = True
 
+        # Customize the following lines to add/remove entries from the list of
+        # packages for which version numbers are displayed when running the tests.
+        PYTEST_HEADER_MODULES.pop('Pandas', None)
+        PYTEST_HEADER_MODULES['scikit-image'] = 'skimage'
 
-packagename = os.path.basename(os.path.dirname(__file__))
-TESTED_VERSIONS[packagename] = version
-TESTED_VERSIONS['astropy_helpers'] = astropy_helpers_version
+        from . import __version__
+        TESTED_VERSIONS['pyvo'] = __version__
diff --git a/pyvo/dal/tests/data/datalink/proc.xml b/pyvo/dal/tests/data/datalink/proc.xml
index 78e1c60..76d74ce 100644
--- a/pyvo/dal/tests/data/datalink/proc.xml
+++ b/pyvo/dal/tests/data/datalink/proc.xml
@@ -71,6 +71,16 @@
             <TD>image/jpeg</TD>
             <TD>-1</TD>
           </TR>
+          <TR>
+            <TD>ivo://org.gavo.dc/s/data/gds_big/v6a/2010/GDS_0644-0035/i_s/eq010000ms/20100927.comb_avg.0001.fits.fz</TD>
+            <TD>http://example.edu/when-will-it-be-back</TD>
+            <TD/>
+            <TD/>
+            <TD>Something requiring custom semantics.</TD>
+            <TD>urn:example:rdf/dlext#oracle</TD>
+            <TD>text/x-prediction</TD>
+            <TD>-1</TD>
+          </TR>
         </TABLEDATA>
       </DATA>
     </TABLE>
diff --git a/pyvo/dal/tests/test_datalink.py b/pyvo/dal/tests/test_datalink.py
index f22e82a..0b60830 100644
--- a/pyvo/dal/tests/test_datalink.py
+++ b/pyvo/dal/tests/test_datalink.py
@@ -162,3 +162,24 @@ class TestSemanticsRetrieval:
         assert len(res)==2
         assert res[0].endswith("eq010000ms/20100927.comb_avg.0001.fits.fz")
         assert res[1].endswith("http://dc.zah.uni-heidelberg.de/wider.dat")
+
+    def test_with_full_url(self):
+        datalink = DatalinkResults.from_result_url('http://example.com/proc')
+        res = [_debytify(r["access_url"])
+            for r in datalink.bysemantics("urn:example:rdf/dlext#oracle")]
+        assert len(res)==1
+        assert res[0].endswith("when-will-it-be-back")
+
+    def test_all_mixed(self):
+        datalink = DatalinkResults.from_result_url('http://example.com/proc')
+        res = [_debytify(r["access_url"])
+            for r in datalink.bysemantics([
+                "urn:example:rdf/dlext#oracle",
+                'http://www.ivoa.net/rdf/datalink/core#preview',
+                '#this',
+                'non-existing-term'])]
+        assert len(res)==4
+        assert res[0].endswith("eq010000ms/20100927.comb_avg.0001.fits.fz")
+        assert res[1].endswith("comb_avg.0001.fits.fz?preview=True")
+        assert res[2].endswith("http://dc.zah.uni-heidelberg.de/wider.dat")
+        assert res[3].endswith("when-will-it-be-back")
diff --git a/pyvo/tests/coveragerc b/pyvo/tests/coveragerc
deleted file mode 100644
index bec7c29..0000000
--- a/pyvo/tests/coveragerc
+++ /dev/null
@@ -1,31 +0,0 @@
-[run]
-source = {packagename}
-omit =
-   {packagename}/_astropy_init*
-   {packagename}/conftest*
-   {packagename}/cython_version*
-   {packagename}/setup_package*
-   {packagename}/*/setup_package*
-   {packagename}/*/*/setup_package*
-   {packagename}/tests/*
-   {packagename}/*/tests/*
-   {packagename}/*/*/tests/*
-   {packagename}/version*
-
-[report]
-exclude_lines =
-   # Have to re-enable the standard pragma
-   pragma: no cover
-
-   # Don't complain about packages we have installed
-   except ImportError
-
-   # Don't complain if tests don't hit assertions
-   raise AssertionError
-   raise NotImplementedError
-
-   # Don't complain about script hooks
-   def main\(.*\):
-
-   # Ignore branches that don't pertain to this version of Python
-   pragma: py{ignore_python_version}
\ No newline at end of file