diff --git a/docs/files/cool_archive_cone_service.py b/docs/files/cool_archive_cone_service.py
new file mode 100644
index 0000000..0cb3090
--- /dev/null
+++ b/docs/files/cool_archive_cone_service.py
@@ -0,0 +1,6 @@
+[
+  {'base_name': 'cool_archive_cone',
+   'service_type': 'cone',
+   'access_url': 'http://vo.cool_archive_cone.org/vo/conesearch',
+   }
+]
diff --git a/docs/files/great_archive_tap_service.py b/docs/files/great_archive_tap_service.py
new file mode 100644
index 0000000..3fc1239
--- /dev/null
+++ b/docs/files/great_archive_tap_service.py
@@ -0,0 +1,13 @@
+[
+  {'base_name': 'great_archive_tap',
+  'service_type': 'tap',
+  'access_url': 'http://services.great_archive.net/tap_service',
+  'adql':'''
+  SELECT *
+  FROM ivoa.obscore
+  WHERE
+    1=CONTAINS(POINT('ICRS', s_ra, s_dec),
+              CIRCLE('ICRS', {}, {}, {} ))
+  '''
+  }
+]
diff --git a/docs/files/multiple_services.py b/docs/files/multiple_services.py
new file mode 100644
index 0000000..7ae5c49
--- /dev/null
+++ b/docs/files/multiple_services.py
@@ -0,0 +1,10 @@
+[
+  {'base_name': 'one_service',
+   'service_type': 'cone',
+   'access_url': 'http://vo.cool_archive_cone.org/vo/conesearch',
+   },
+  {'base_name': 'another_service',
+   'service_type': 'cone',
+   'access_url': 'http://www.another_archive.org/big_catalog/conesearch',
+   }
+]
diff --git a/docs/files/sia_service.py b/docs/files/sia_service.py
new file mode 100644
index 0000000..d5035e8
--- /dev/null
+++ b/docs/files/sia_service.py
@@ -0,0 +1,9 @@
+[
+    {'base_name': 'SampleSIAService',
+    'service_type': 'xcone',
+    'adql': '',
+    'access_url': 'https://sia.some_archive.org/'
+    'mean.votable?flatten_response=false&sort_by=distance'
+    '&ra={}&dec={}&radius={}'
+    }
+]
diff --git a/docs/index.rst b/docs/index.rst
index cbdefa2..ec87a5a 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -90,16 +90,9 @@ in a separate file.)
 use `service_type` of `cone`.  This causes the ``ra``, ``dec`` and ``radius`` values from the input file
 to be appended to the ``access_url`` at query time according to the SCS standard.
 
-.. code-block:: python
+.. literalinclude:: files/cool_archive_cone_service.py
   :caption: cool_archive_cone_service.py
 
-  [
-    {'base_name': 'cool_archive_cone',
-     'service_type': 'cone',
-     'access_url': 'http://vo.cool_archive_cone.org//vo/conesearch',
-     }
-  ]
-
 3. Run `sm_query`_.  By default, all `The Output`_ will appear in the ``results`` directory.
 
 .. code-block:: bash
@@ -117,23 +110,9 @@ service hosted by the Great Archive 3 times with the same cones defined in the p
 ``service_type`` is ``tap``.  The ``adql`` value is a template containing 3 `{}`s into
 which the input ``ra``, ``dec`` and  ``radius`` values will be substituted.
 
-.. code-block:: python
+.. literalinclude:: files/great_archive_tap_service.py
   :caption: great_archive_tap_service.py
 
-  [
-    {'base_name': 'great_archive_tap',
-    'service_type': 'tap',
-    'access_url': 'http://services.great_archive.net/tap_service',
-    'adql':'''
-    SELECT *
-    FROM ivoa.obscore
-    WHERE
-      1=CONTAINS(POINT('ICRS', s_ra, s_dec),
-                CIRCLE('ICRS', {}, {}, {} ))
-    '''
-    }
-  ]
-
 2. Run `sm_query`_.  By default, all `The Output`_ will appear in the ``results`` directory.
 
 .. code-block:: bash
@@ -224,46 +203,30 @@ services are assumed to return results as VOTables.
 
   * ``cone`` The query will be constructed as a VO standard Simple Cone Search
     with the RA, DEC and SR parameters being automatically set based per cone.
+
+  .. literalinclude:: files/cool_archive_cone_service.py
+
   * ``xcone`` A non-standard cone search.  The **access_url** is assumed to contain
     three {}s (open/close braces).  The RA, Dec and Radius for each cone will be
     substituted for those 3 braces in order.
 
+  .. literalinclude:: files/sia_service.py
+    :caption: service_type 'xcone' can be used for an SIA service
+  
+  * ``tap`` A Table Access Protocol (TAP) service.  The **adql** value is a template
+    template for the TAP query to be performed.
+
+  .. literalinclude:: files/great_archive_tap_service.py
+    :caption: Sample TAP service.
+  
 * **access_url** - The access URL for the service.
 * **adql** - For the ``tap`` *service_type*, this is the ADQL query. For other types,
   this key must exist, but the value will be ignored. The ADQL query is assumed
   to contain three {}s (open/close braces).  The ra, dec and radius for each cone
   will be substituted for those 3 braces in order.
 
-.. code-block:: python
-  :caption: Example service file:
-
-    [
-        {'base_name': '2MASS_STScI',
-         'service_type': 'cone',
-         'adql': '',
-         'access_url': 'http://gsss.stsci.edu/webservices/vo/ConeSearch.aspx?CAT=2MASS',
-         },
-
-        {'base_name': 'PanSTARRS',
-         'service_type': 'xcone',
-         'adql': '',
-         'access_url': 'https://catalogs.mast.stsci.edu/api/v0.1/panstarrs/dr2/'
-         'mean.votable?flatten_response=false&raw=false&sort_by=distance'
-         '&ra={}&dec={}&radius={}'
-         },
-
-        {'base_name': 'PanSTARRS',
-         'service_type': 'tap',
-         'access_url': 'http://vao.stsci.edu/PS1DR2/tapservice.aspx',
-         'adql':'''
-       SELECT objID, RAMean, DecMean, nDetections, ng, nr, ni, nz, ny, gMeanPSFMag,
-       rMeanPSFMag, iMeanPSFMag, zMeanPSFMag, yMeanPSFMag
-       FROM dbo.MeanObjectView
-       WHERE
-       CONTAINS(POINT('ICRS', RAMean, DecMean),CIRCLE('ICRS',{},{},{}))=1
-         '''
-         }
-    ]
+.. literalinclude:: files/multiple_services.py
+  :caption: **Multiple services** are allowed, but it is recommended that all service_type values are the same.
 
 **********
 The Output
diff --git a/servicemon/plugin_support.py b/servicemon/plugin_support.py
index e989977..c7be188 100644
--- a/servicemon/plugin_support.py
+++ b/servicemon/plugin_support.py
@@ -12,12 +12,13 @@ class SmPluginSupport(ABC):
     _subclasses = {}
     _default_user_plugin_dir = 'plugins'
 
+    @classmethod
     def __init_subclass__(cls, plugin_name=None, description='', **kwargs):
         super().__init_subclass__(**kwargs)
         if plugin_name is None:
             plugin_name = cls.__name__
-        cls._subclasses[plugin_name] = {'cls': cls,
-                                        'description': description}
+        cls._subclasses[plugin_name] = SmPluginDesc(plugin_name, cls, description)
+        # {'cls': cls, 'description': description}
 
     @classmethod
     def load_builtin_plugins(cls):
@@ -61,7 +62,7 @@ class SmPluginSupport(ABC):
         plugin = cls.get_plugin(parsed['plugin_name'])
         if plugin is None:
             raise ValueError(f'No plugin found for spec: {spec}')
-        full_plugin = {**parsed, **plugin}  # combine the dicts
+        full_plugin = SmPluginDesc(plugin.name, plugin.cls, plugin.description, **parsed['kwargs'])
         return full_plugin
 
     @classmethod
@@ -115,6 +116,14 @@ class SmPluginSupport(ABC):
         return is_python_file
 
 
+class SmPluginDesc():
+    def __init__(self, name, cls, description, **kwargs):
+        self.name = name
+        self.cls = cls
+        self.description = description
+        self.kwargs = kwargs
+
+
 class AbstractResultWriter(SmPluginSupport):
 
     _subclasses = {}
@@ -122,13 +131,26 @@ class AbstractResultWriter(SmPluginSupport):
     @abstractmethod
     def begin(self, args, **kwargs):
         """
-        args is the result of an argparse.ArgumentParser's parse_args().
-        kwargs come from the plug-in specification.
+        **args** : argparse.Namespace
+            the result of an argparse.ArgumentParser's parse_args().
+
+        **kwargs** : dict
+            keyword args from the plug-in specification.
         """
         pass
 
     @abstractmethod
     def one_result(self, stats):
+        """
+        **stats** : obj
+            an object with the following methods:
+
+            **columns()** : list of str
+                list of output column names
+
+            **row_values()** : dict
+                dict of output values, one key per column name
+        """
         pass
 
     @abstractmethod
diff --git a/servicemon/query.py b/servicemon/query.py
index ddba08a..3f626df 100644
--- a/servicemon/query.py
+++ b/servicemon/query.py
@@ -1,9 +1,8 @@
 import os
-import sys
 import pathlib
 import warnings
 import traceback
-
+import logging
 import html
 import requests
 
@@ -145,7 +144,7 @@ class Query():
         if trace:
             traceback.print_exc()
         else:
-            print(msg, file=sys.stderr, flush=True)
+            logging.error(msg)
 
     def gather_response_metadata(self, response):
         """
@@ -261,13 +260,10 @@ class Query():
 
         return name
 
-    def getval_old(self, obj, key, default=None):
-        val = getattr(obj, key, None)
-        if val is None:
-            val = obj.get(key, default)
-        return val
-
     def getval(self, obj, key, default=None):
+        """
+        Gets the value as either an attribute val or key val.
+        """
         val = getattr(obj, key, None)
         if val is None:
             try:
diff --git a/servicemon/query_runner.py b/servicemon/query_runner.py
index 7e1acac..f54d06e 100644
--- a/servicemon/query_runner.py
+++ b/servicemon/query_runner.py
@@ -4,6 +4,8 @@ import csv
 import signal
 import faulthandler
 import platform
+import logging
+import warnings
 
 from argparse import ArgumentParser
 from datetime import datetime
@@ -66,6 +68,7 @@ class QueryRunner():
         self._verbose = args.verbose
         self._writer_specs = args.writers
 
+        self._writers_descs = []
         self._writers = []
         self._load_plugins(args)
 
@@ -93,14 +96,18 @@ class QueryRunner():
             spec_with_time = now.strftime(spec)
 
             plugin = AbstractResultWriter.get_plugin_from_spec(spec_with_time)
-            self._writers.append(plugin)
+            self._writers_descs.append(plugin)
 
     def run(self):
         """
         """
-        for w in self._writers:
-            w['instance'] = w['cls']()
-            w['instance'].begin(self._args, **w['kwargs'])
+
+        self._validate_services(self._services)
+
+        for wdesc in self._writers_descs:
+            w = wdesc.cls()
+            w.begin(self._args, **wdesc.kwargs)
+            self._writers.append(w)
 
         if self._cones is not None:
             self._run_with_cones()
@@ -108,7 +115,19 @@ class QueryRunner():
             self._run_services_only()
 
         for w in self._writers:
-            w['instance'].end()
+            w.end()
+
+    def _validate_services(self, services):
+        if len(services) == 0:
+            warnings.warn('Service list is empty.  Nothing will be timed.')
+        else:
+            first_stype = self.getval(services[0], 'service_type')
+            for service in services[1:]:
+                stype = self.getval(service, 'service_type')
+                if stype != first_stype:
+                    warnings.warn('Differing service_type values found in service list.'
+                                  '  Some result writers may fail.')
+                    break
 
     def _run_with_cones(self):
         cone_index = 0
@@ -169,7 +188,7 @@ class QueryRunner():
 
     def _output_stats_row(self, stats):
         for w in self._writers:
-            w['instance'].one_result(stats)
+            w.one_result(stats)
 
     def _output_stats_row_to_file(self, stats, stat_file):
         writer = csv.DictWriter(stat_file, dialect='excel',
@@ -193,6 +212,18 @@ class QueryRunner():
                 val = Table.read(obj, format='ascii.csv')
         return val
 
+    def getval(self, obj, key, default=None):
+        """
+        Gets the value as either an attribute val or key val.
+        """
+        val = getattr(obj, key, None)
+        if val is None:
+            try:
+                val = obj[key]
+            except KeyError:
+                val = default
+        return val
+
 
 ################################################################################
 
@@ -213,20 +244,8 @@ def sm_query(input_args=None):
 
     args = _parse_query(input_args)
 
-    # Print arg info and maybe exit if specified.
-    if args.verbose or args.norun:
-
-        # print args
-        import pprint
-        pp = pprint.PrettyPrinter(width=100, stream=sys.stdout, compact=True)
-        s = pp.pformat(vars(args))
-        print(f'Argument values after parsing and applying defaults:\n{s}')
-
-        if args.norun:
-            exit(0)
-
-    if args.verbose:
-        enable_requests_logging()
+    _init_logging(args)
+    _print_arg_info(args)
 
     # Get the input from the specified file or have it autogenerated.
     if args.cone_file is None:
@@ -274,23 +293,29 @@ def sm_replay(input_args=None):
 
     args = _parse_replay(input_args)
 
-    # Print arg info and maybe exit if specified.
-    if args.verbose or args.norun:
+    _init_logging(args)
+    _print_arg_info(args)
 
-        # print args
+    # Run the queries
+    qr = QueryRunner(args)
+    qr.run()
+
+
+def _print_arg_info(args):
+
+    # Print arg info and exit if --norun specified
+    if args.verbose or args.norun:
+        # print arg info
         import pprint
         pp = pprint.PrettyPrinter(width=100, stream=sys.stdout, compact=True)
-        s = pp.pformat(vars(args))
-        print(f'Argument values after parsing and applying defaults:\n{s}')
+        arg_values = pp.pformat(vars(args))
+        msg = f'Argument values after parsing and applying defaults:\n{arg_values}'
+
         if args.norun:
+            print(msg)
             exit(0)
-
-    if args.verbose:
-        enable_requests_logging()
-
-    # Run the queries
-    qr = QueryRunner(args)
-    qr.run()
+        else:
+            logging.info(msg)
 
 
 def _parse_replay(input_args):
@@ -348,7 +373,7 @@ def _create_query_argparser():
                         'performing any actions')
     parser.add_argument('-v', '--verbose', dest='verbose',
                         action='store_true',
-                        help='Print additional information to stdout')
+                        help='Print additional information to stderr')
 
     # Add cone arguments.
     cone_types = parser.add_mutually_exclusive_group()
@@ -413,7 +438,7 @@ def _create_replay_argparser():
                         'performing any actions')
     parser.add_argument('-v', '--verbose', dest='verbose',
                         action='store_true',
-                        help='Print additional information to stdout')
+                        help='Print additional information to stderr')
 
     # Add cone arguments.
     parser.add_argument(
@@ -431,15 +456,15 @@ def _create_replay_argparser():
 def receiveSignal(signalNumber, frame):
     now = datetime.now()
     dtstr = now.strftime('%Y-%m-%d-%H:%M:%S.%f')
-    print(f'Received signal {signalNumber} at {dtstr}', file=sys.stderr,
-          flush=True)
+    logging.warning(f'Received signal {signalNumber} at {dtstr}', file=sys.stderr,
+                    flush=True)
 
 
 def receiveSIGTERM(signalNumber, frame):
     now = datetime.now()
     dtstr = now.strftime('%Y-%m-%d-%H:%M:%S.%f')
-    print(f'Received signal {signalNumber} at {dtstr}', file=sys.stderr,
-          flush=True)
+    logging.warning(f'Received signal {signalNumber} at {dtstr}', file=sys.stderr,
+                    flush=True)
     faulthandler.dump_traceback()
 
 
@@ -457,18 +482,18 @@ def catch_signals():
         try:
             signal.signal(signal.SIGHUP, receiveSignal)
         except AttributeError as e:
-            print(f'Warning: unable to add signal.SIGHUP handler: {repr(e)}')
+            logging.warning(f'Warning: unable to add signal.SIGHUP handler: {repr(e)}')
 
         try:
             signal.signal(signal.SIGQUIT, receiveSignal)
         except AttributeError as e:
-            print(f'Warning: unable to add signal.SIGQUIT handler: {repr(e)}')
+            logging.warning(f'Warning: unable to add signal.SIGQUIT handler: {repr(e)}')
 
     # SIGTERM should be available on Windows.
     try:
         signal.signal(signal.SIGTERM, receiveSignal)
     except AttributeError as e:
-        print(f'Warning: unable to add signal.SIGTERM handler: {repr(e)}')
+        logging.warning(f'Warning: unable to add signal.SIGTERM handler: {repr(e)}')
 
 
 def apply_query_defaults(parsed_args, defaults):
@@ -477,14 +502,22 @@ def apply_query_defaults(parsed_args, defaults):
             setattr(parsed_args, k, defaults[k])
 
 
-def enable_requests_logging():
-    import logging
+def _enable_requests_logging():
     import http.client as http_client
 
     http_client.HTTPConnection.debuglevel = 1
-
-    logging.basicConfig()
-    logging.getLogger().setLevel(logging.DEBUG)
     requests_log = logging.getLogger("requests.packages.urllib3")
     requests_log.setLevel(logging.DEBUG)
     requests_log.propagate = True
+
+
+def _init_logging(args):
+    level = logging.WARNING
+
+    if (args.verbose):
+        level = logging.DEBUG
+        _enable_requests_logging()
+
+    logging.basicConfig(format='%(levelname)s: (%(asctime)s)    %(message)s',
+                        level=level,
+                        datefmt='%Y-%m-%d-%H:%M:%S')

Replace most print statements with logging statements as appropriate.
diff --git a/servicemon/tests/test_csv_writer.py b/servicemon/tests/test_csv_writer.py
new file mode 100644
index 0000000..cb79abb
--- /dev/null
+++ b/servicemon/tests/test_csv_writer.py
@@ -0,0 +1,157 @@
+import os
+import shutil
+import pytest
+from pathlib import Path
+from datetime import datetime
+
+from astropy.table import Table
+from servicemon.plugin_support import SmPluginSupport, AbstractResultWriter
+from servicemon.query_runner import _parse_query
+
+
+def errstr(capsys):
+    """
+    A shortcut with a side effect:  Resets captured stdout and stderr.
+    """
+    captured = capsys.readouterr()
+    result_err = str(captured.err)
+    result_out = str(captured.out)
+    return result_err, result_out
+
+
+@pytest.fixture
+def cleandirs():
+    # Clean before
+    shutil.rmtree(Path('my_fake_result_dir'), ignore_errors=True)
+    shutil.rmtree(Path('my_fake_result_dir2'), ignore_errors=True)
+    yield
+    # And after
+    shutil.rmtree(Path('my_fake_result_dir'), ignore_errors=True)
+    shutil.rmtree(Path('my_fake_result_dir2'), ignore_errors=True)
+
+
+def test_end(capsys):
+    SmPluginSupport.load_builtin_plugins()
+
+    # Test with no input args.
+    cw = begin_no_outfile()
+    cw.end()
+
+
+@pytest.mark.usefixtures("cleandirs")
+def test_begin(capsys):
+    SmPluginSupport.load_builtin_plugins()
+    now = datetime.now()
+    dtstr = now.strftime('%Y-%m-%d')
+
+    # Test with no input args.
+    cw = begin_no_outfile()
+    assert isinstance(cw._outfile_path, Path)
+    assert cw._outfile_path.stem.startswith(f'fake_services_file_{dtstr}')
+    assert cw._outfile_path.parent.name == 'my_fake_result_dir'
+    os.rmdir('my_fake_result_dir')
+
+    # Test with outfile specified with fixed dstr.  Dstring substitutions are not done at this level.
+    cw = begin_w_outfile(dtstr)
+    assert isinstance(cw._outfile_path, Path)
+    assert cw._outfile_path.name == f'myout-{dtstr}.csv'
+    assert cw._outfile_path.parent.name == 'my_fake_result_dir2'
+    os.rmdir('my_fake_result_dir2')
+
+    # Test with outfile as stdout
+    cw = begin_outfile_stdout()
+    assert cw._outfile_path is None
+    assert not Path('my_fake_result_dir3').exists()
+
+
+@pytest.mark.usefixtures("cleandirs")
+def test_one_result(capsys):
+    SmPluginSupport.load_builtin_plugins()
+
+    # Test with specified outfile.
+    cw = begin_w_outfile('nodate')
+    resfile = write_some_results(cw)
+
+    # Read and check results.
+    t = Table.read(resfile, format='csv')
+    assert_table_vals(t)
+
+    # Test with stdout.
+    cw = begin_outfile_stdout()
+    resfile = write_some_results(cw)
+    _, out = errstr(capsys)
+    t = Table.read(out, format='csv')
+    assert_table_vals(t)
+
+
+def assert_table_vals(t):
+    assert len(t) == 2
+    assert t['a'][0] == '1'
+    assert t['a'][1] == 'vala'
+    assert t['b'][0] == '2'
+    assert t['b'][1] == 'valb'
+    assert t['c'][0] == '3'
+    assert t['c'][1] == 'valc'
+
+
+def write_some_results(cw):
+    resfile = Path('my_fake_result_dir2') / Path('myout-nodate.csv')
+    results = Results(['a', 'b', 'c'])
+    results.set_values({'a': 1, 'b': 2, 'c': 3})
+    cw.one_result(results)
+    results.set_values({'a': 'vala', 'b': 'valb', 'c': 'valc'})
+    cw.one_result(results)
+    return resfile
+
+
+def begin_outfile_stdout():
+    args = _parse_query([
+        'fake_services_file.py',
+        '--cone_file', 'my_cones.py',
+        '--result_dir', 'my_fake_result_dir3'
+    ])
+    cw_plugin = AbstractResultWriter.get_plugin_from_spec('csv_writer:outfile=stdout')
+    cw = cw_plugin.cls()
+    cw.begin(args, **cw_plugin.kwargs)
+    return cw
+
+
+def begin_w_outfile(dtstr):
+    args = _parse_query([
+        'fake_services_file.py',
+        '--cone_file', 'my_cones.py',
+        '--result_dir', 'my_fake_result_dir2'
+    ])
+    cw_plugin = AbstractResultWriter.get_plugin_from_spec(f'csv_writer:outfile=my_fake_result_dir2/myout-{dtstr}.csv')
+    cw = cw_plugin.cls()
+    cw.begin(args, **cw_plugin.kwargs)
+    return cw
+
+
+def begin_no_outfile():
+    args = _parse_query([
+        'fake_services_file.py',
+        '--cone_file', 'my_cones.py',
+        '--result_dir', 'my_fake_result_dir'
+    ])
+    cw_plugin = AbstractResultWriter.get_plugin_from_spec('csv_writer')
+    cw = cw_plugin.cls()
+    cw.begin(args, **cw_plugin.kwargs)
+    return cw
+
+
+class Results():
+    def __init__(self, cols):
+        self._cols = cols
+        self._vals = [None] * len(self._cols)
+
+    def set_values(self, vals):
+        if len(vals) != len(self._cols):
+            raise ValueError('values must have same length as columns.')
+        self._vals = vals
+
+    def columns(self):
+        return self._cols
+
+    def row_values(self):
+        return self._vals
diff --git a/servicemon/tests/test_plugin_support.py b/servicemon/tests/test_plugin_support.py
index aa9a2e8..8c105f0 100644
--- a/servicemon/tests/test_plugin_support.py
+++ b/servicemon/tests/test_plugin_support.py
@@ -40,36 +40,36 @@ def test_load_plugins(capsys):
 
     # Check built-ins
     pi = AbstractResultWriter.get_plugin_from_spec('csv_writer:outfile=somedir/somefile.csv')
-    assert 'Writes results to a csv file.' in pi['description']
-    assert pi['cls'].__name__ == 'CsvResultWriter'
-    assert pi['kwargs']['outfile'] == 'somedir/somefile.csv'
+    assert 'Writes results to a csv file.' in pi.description
+    assert pi.cls.__name__ == 'CsvResultWriter'
+    assert pi.kwargs['outfile'] == 'somedir/somefile.csv'
 
     # Check user default dir
     AbstractResultWriter.list_plugins()
 
     pi = AbstractResultWriter.get_plugin_from_spec('defwriter2')
-    assert 'Another writer in the default plug-in dir' in pi['description']
-    assert pi['cls'].__name__ == 'DefWriter2'
-    assert pi['kwargs'] == {}
-    _ = pi['cls']()   # Instantiates fine since it implements the abstract methods.
+    assert 'Another writer in the default plug-in dir' in pi.description
+    assert pi.cls.__name__ == 'DefWriter2'
+    assert pi.kwargs == {}
+    _ = pi.cls()   # Instantiates fine since it implements the abstract methods.
 
     pi = AbstractTimedQuery.get_plugin_from_spec('defquery1:arg1=abc,arg2=def')
-    assert 'A query plug-in ' in pi['description']
-    assert pi['cls'].__name__ == 'DefQuery1'
-    assert pi['kwargs']['arg1'] == 'abc'
-    assert pi['kwargs']['arg2'] == 'def'
+    assert 'A query plug-in ' in pi.description
+    assert pi.cls.__name__ == 'DefQuery1'
+    assert pi.kwargs['arg1'] == 'abc'
+    assert pi.kwargs['arg2'] == 'def'
 
     # Check user plugin dir
     pi = AbstractTimedQuery.get_plugin_from_spec('userquery2')
-    assert pi['cls'].__name__ == 'UserQuery2'
+    assert pi.cls.__name__ == 'UserQuery2'
 
     pi = AbstractResultWriter.get_plugin_from_spec('cannot_make_reader_instance')
-    assert pi['cls'].__name__ == 'CannotInstantiate'
+    assert pi.cls.__name__ == 'CannotInstantiate'
     with pytest.raises(TypeError) as record:
-        _ = pi['cls']()
+        _ = pi.cls()
         assert ("Can't instantiate abstract class CannotInstantiate with abstract methods end"
                 in str(record[0].message))
 
     # Check user file
     pi = AbstractResultWriter.get_plugin_from_spec('sample-writer')
-    assert pi['cls'].__name__ == 'SampleResultWriter'
+    assert pi.cls.__name__ == 'SampleResultWriter'
diff --git a/servicemon/tests/test_query_runner.py b/servicemon/tests/test_query_runner.py
index 28fda59..4521919 100644
--- a/servicemon/tests/test_query_runner.py
+++ b/servicemon/tests/test_query_runner.py
@@ -1,4 +1,5 @@
 import pytest
+from servicemon.query_runner import QueryRunner
 
 from servicemon.query_runner import (
     _parse_query, _parse_replay,
@@ -190,3 +191,36 @@ def test_global_replay_arg_errors(capsys):
                           '--cone_file', 'some_cone_file.py'])
     assert ('unrecognized arguments: --cone_file'
             in errstr(capsys))
+
+
+def test_service_validation(capsys):
+    args = _parse_query([
+        'fake_services_file',
+        '--cone_file', 'my_cones.py'
+    ])
+    args.services = []
+    args.cone_file = []
+    qr = QueryRunner(args)
+    with pytest.warns(UserWarning, match='Service list is empty') as record:
+        qr._validate_services(args.services)
+    assert len(record) == 1
+
+    args.services = [
+        {'service_type': 'tap'},
+        {'service_type': 'tap'},
+        {'service_type': 'tap'},
+    ]
+    with pytest.warns(None) as record:
+        qr._validate_services(args.services)
+    assert len(record) == 0
+
+    args.services = [
+        {'service_type': 'tap'},
+        {'service_type': 'cone'},
+        {'service_type': 'tap'},
+    ]
+    with pytest.warns(UserWarning,
+                      match='Differing service_type values found in service list.'
+                      '  Some result writers may fail.') as record:
+        qr._validate_services(args.services)
+    assert len(record) == 1

pip install -e .[test]
parse_log_pytest
pytest
pytest
3.7
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
astropy==4.3.1
astroquery==0.4.7
attrs @ file:///croot/attrs_1668696182826/work
beautifulsoup4==4.13.3
certifi @ file:///croot/certifi_1671487769961/work/certifi
cffi==1.15.1
charset-normalizer==3.4.1
codetiming==1.4.0
coverage==7.2.7
cryptography==44.0.2
exceptiongroup==1.2.2
flit_core @ file:///opt/conda/conda-bld/flit-core_1644941570762/work/source/flit_core
html5lib==1.1
hypothesis==6.79.4
idna==3.10
importlib-metadata==6.7.0
importlib-resources==5.12.0
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
jaraco.classes==3.2.3
jeepney==0.9.0
keyring==24.1.1
more-itertools==9.1.0
numpy==1.21.6
packaging @ file:///croot/packaging_1671697413597/work
pluggy @ file:///tmp/build/80754af9/pluggy_1648042572264/work
py @ file:///opt/conda/conda-bld/py_1644396412707/work
pycparser==2.21
pyerfa==2.0.0.3
pytest==7.1.2
pytest-arraydiff==0.6.1
pytest-astropy==0.11.0
pytest-astropy-header==0.2.2
pytest-cov==4.1.0
pytest-doctestplus==1.0.0
pytest-filter-subpackage==0.1.2
pytest-mock==3.11.1
pytest-remotedata==0.4.1
pyvo==1.2.1
requests==2.31.0
SecretStorage==3.3.3
-e git+https://github.com/NASA-NAVO/servicemon.git@8f312001d238bc9d447a8d4481b5b3494b68b7ac#egg=servicemon
six==1.17.0
sortedcontainers==2.4.0
soupsieve==2.4.1
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work
typing_extensions @ file:///croot/typing_extensions_1669924550328/work
urllib3==2.0.7
webencodings==0.5.1
zipp @ file:///croot/zipp_1672387121353/work

name: servicemon
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=22.1.0=py37h06a4308_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - flit-core=3.6.0=pyhd3eb1b0_0
  - importlib_metadata=4.11.3=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=22.0=py37h06a4308_0
  - pip=22.3.1=py37h06a4308_0
  - pluggy=1.0.0=py37h06a4308_1
  - py=1.11.0=pyhd3eb1b0_0
  - pytest=7.1.2=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py37h06a4308_0
  - typing_extensions=4.4.0=py37h06a4308_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.11.0=py37h06a4308_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - astropy==4.3.1
      - astroquery==0.4.7
      - beautifulsoup4==4.13.3
      - cffi==1.15.1
      - charset-normalizer==3.4.1
      - codetiming==1.4.0
      - coverage==7.2.7
      - cryptography==44.0.2
      - exceptiongroup==1.2.2
      - html5lib==1.1
      - hypothesis==6.79.4
      - idna==3.10
      - importlib-metadata==6.7.0
      - importlib-resources==5.12.0
      - jaraco-classes==3.2.3
      - jeepney==0.9.0
      - keyring==24.1.1
      - more-itertools==9.1.0
      - numpy==1.21.6
      - pycparser==2.21
      - pyerfa==2.0.0.3
      - pytest-arraydiff==0.6.1
      - pytest-astropy==0.11.0
      - pytest-astropy-header==0.2.2
      - pytest-cov==4.1.0
      - pytest-doctestplus==1.0.0
      - pytest-filter-subpackage==0.1.2
      - pytest-mock==3.11.1
      - pytest-remotedata==0.4.1
      - pyvo==1.2.1
      - requests==2.31.0
      - secretstorage==3.3.3
      - servicemon==0.3rc4.dev13+g8f31200
      - six==1.17.0
      - sortedcontainers==2.4.0
      - soupsieve==2.4.1
      - urllib3==2.0.7
      - webencodings==0.5.1
prefix: /opt/conda/envs/servicemon

servicemon/tests/test_csv_writer.py::test_end servicemon/tests/test_csv_writer.py::test_begin servicemon/tests/test_csv_writer.py::test_one_result servicemon/tests/test_plugin_support.py::test_load_plugins servicemon/tests/test_query_runner.py::test_service_validation
servicemon/tests/test_query_runner.py::test_global_query_args servicemon/tests/test_query_runner.py::test_global_query_arg_errors servicemon/tests/test_query_runner.py::test_global_replay_args servicemon/tests/test_query_runner.py::test_global_replay_arg_errors
docs/files/multiple_services.py docs/files/cool_archive_cone_service.py servicemon/query_runner.py docs/files/great_archive_tap_service.py servicemon/query.py servicemon/plugin_support.py docs/index.rst docs/files/sia_service.py
docs/files/multiple_services.py docs/files/cool_archive_cone_service.py servicemon/query_runner.py docs/files/great_archive_tap_service.py servicemon/query.py servicemon/plugin_support.py docs/index.rst docs/files/sia_service.py