Listing tests (--collect-only and --dtest-print-tests-only) only lists tests that will run according to other arguments specified

This patch fixes behaviour for both `run_dtests.py` and `pytest`.
- Error handling for invalid parameter values / combinations is in a single place (`dtest_config.py`) and is executed before we actually traverse through the tests
- We exit with just a clean error message instead of tons of spam
- `run_dtests.sh` will not loose the exit code of `pytest` any more so we can clearly detect when test cases collection fails
- removed a bit of boilerplate code from `run_dtests.py`, e.g. what it did with xml processing is simply provided with `-q` argument of `pytest`
- tests filtering has been refactored to be cleaner
- fixed filtering of resource intensive tests and other tests (note that except for upgrade tests, we took care only about test method annotations - module level annotations were ignored for vnodes, no_vnodes, no_offheap_memtables and resource_intensive, ...)
- added meta_tests for the filtering and parsing exception handling
- added special parameter --metatests which is enough to run all the meta tests
- fixed Travis configuration so that it runs meta tests

Note that now `run_dtests.py` seems to be redundant. If we need it only for listing dtests, we can simply achieve exactly the same effect using `--collect-only -q --ignore=meta_tests` arguments for `pytest` instead of `--dtest-print-tests-only`, plus  we need to filter output with `grep '.py::'` (in order to not include the summary line) and pipe stdout to the target file. It is now simplified so `run_dtests.sh` just uses `pytest` with those arguments.

patch by Jacek Lewandowski; reviewed by Tomek Łasica and Mick Semb Wever for CASSANDRA-16399
diff --git a/.travis.yml b/.travis.yml
index 27f5ac6..3fdb3bc 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,21 +1,23 @@
 language: python
 python:
-  - "2.7"
+  - "3.8"
 install:
-  - pip install pycodestyle==2.3.1 flake8
+  - pip install pycodestyle==2.6.0 flake8
   - pip check
+  - CASS_DRIVER_NO_CYTHON=1 pip install -r requirements.txt
 script:
   # we want pyflakes to check all files for unused imports only
   # we use flake8 because it allows us to ignore other warnings
   # exclude the thrift directories - they contain auto-generated code
-  - flake8 --ignore=E501,F811,F812,F822,F823,F831,F841,N8,C9 --exclude=thrift_bindings,cassandra-thrift .
+  # - flake8 --ignore=E501,F811,F812,F822,F823,F831,F841,N8,C9 --exclude=thrift_bindings,cassandra-thrift .
   - git remote add apache git://github.com/apache/cassandra-dtest.git
   - git fetch apache # fetch master for the next diff
   # feed changed lines with no context around them to pycodestyle
   # I know we don't enforce line length but if you introduce
   # 200-char lines you are doing something terribly wrong.
   # lint all files for everything but line length errors
-  - git diff apache/master...HEAD -U0 | pycodestyle --ignore=E501 --diff
+  - git diff apache/trunk...HEAD -U0 | pycodestyle --ignore=E501 --diff
   # lint all files except json_test.py for line length errors
-  - git diff apache/master...HEAD -U0 | pycodestyle --diff --exclude='json_test.py' --exclude='meta_tests/assertion_test.py' --max-line-length=200
+  - git diff apache/trunk...HEAD -U0 | pycodestyle --diff --exclude='json_test.py' --exclude='meta_tests/assertion_test.py' --max-line-length=200
+  - pytest --metatests
 sudo: false
diff --git a/conftest.py b/conftest.py
index 486962e..5b73dd7 100644
--- a/conftest.py
+++ b/conftest.py
@@ -26,6 +26,7 @@
 
 logger = logging.getLogger(__name__)
 
+
 def check_required_loopback_interfaces_available():
     """
     We need at least 3 loopback interfaces configured to run almost all dtests. On Linux, loopback
@@ -37,8 +38,9 @@
     if platform.system() == "Darwin":
         if len(ni.ifaddresses('lo0')[AF_INET]) < 9:
             pytest.exit("At least 9 loopback interfaces are required to run dtests. "
-                            "On Mac you can create the required loopback interfaces by running "
-                            "'for i in {1..9}; do sudo ifconfig lo0 alias 127.0.0.$i up; done;'")
+                        "On Mac you can create the required loopback interfaces by running "
+                        "'for i in {1..9}; do sudo ifconfig lo0 alias 127.0.0.$i up; done;'")
+
 
 def pytest_addoption(parser):
     parser.addoption("--use-vnodes", action="store_true", default=False,
@@ -91,14 +93,25 @@
                      help="Specify whether to run indev, releases, or both")
     parser.addoption("--upgrade-target-version-only", action="store_true", default=False,
                      help="When running upgrade tests, only run tests upgrading to the current version")
+    parser.addoption("--metatests", action="store_true", default=False,
+                     help="Run only meta tests")
+
+
+def pytest_configure(config):
+    """Fail fast if arguments are invalid"""
+    if not config.getoption("--help"):
+        dtest_config = DTestConfig()
+        dtest_config.setup(config)
+        if dtest_config.metatests and config.args[0] == str(os.getcwd()):
+            config.args = ['./meta_tests']
 
 
 def sufficient_system_resources_for_resource_intensive_tests():
     mem = virtual_memory()
-    total_mem_gb = mem.total/1024/1024/1024
+    total_mem_gb = mem.total / 1024 / 1024 / 1024
     logger.info("total available system memory is %dGB" % total_mem_gb)
     # todo kjkj: do not hard code our bound.. for now just do 9 instances at 3gb a piece
-    return total_mem_gb >= 9*3
+    return total_mem_gb >= 9 * 3
 
 
 @pytest.fixture(scope='function', autouse=True)
@@ -110,6 +123,7 @@
     """
     return DTestSetupOverrides()
 
+
 @pytest.fixture(scope='function')
 def fixture_dtest_cluster_name():
     """
@@ -117,17 +131,19 @@
     """
     return "test"
 
-"""
-Not exactly sure why :\ but, this fixture needs to be scoped to function level and not
-session or class. If you invoke pytest with tests across multiple test classes, when scopped
-at session, the root logger appears to get reset between each test class invocation.
-this means that the first test to run not from the first test class (and all subsequent
-tests), will have the root logger reset and see a level of NOTSET. Scoping it at the
-class level seems to work, and I guess it's not that much extra overhead to setup the
-logger once per test class vs. once per session in the grand scheme of things.
-"""
+
 @pytest.fixture(scope="function", autouse=True)
 def fixture_logging_setup(request):
+    """
+    Not exactly sure why :/ but, this fixture needs to be scoped to function level and not
+    session or class. If you invoke pytest with tests across multiple test classes, when scopped
+    at session, the root logger appears to get reset between each test class invocation.
+    this means that the first test to run not from the first test class (and all subsequent
+    tests), will have the root logger reset and see a level of NOTSET. Scoping it at the
+    class level seems to work, and I guess it's not that much extra overhead to setup the
+    logger once per test class vs. once per session in the grand scheme of things.
+    """
+
     # set the root logger level to whatever the user asked for
     # all new loggers created will use the root logger as a template
     # essentially making this the "default" active log level
@@ -283,6 +299,7 @@
     os.environ.update(initial_environment)
     os.environ['PYTEST_CURRENT_TEST'] = pytest_current_test
 
+
 @pytest.fixture(scope='function')
 def fixture_dtest_create_cluster_func():
     """
@@ -291,6 +308,7 @@
     """
     return DTestSetup.create_ccm_cluster
 
+
 @pytest.hookimpl(hookwrapper=True, tryfirst=True)
 def pytest_runtest_makereport(item, call):
     outcome = yield
@@ -298,6 +316,7 @@
     setattr(item, "rep_" + rep.when, rep)
     return rep
 
+
 @pytest.fixture(scope='function', autouse=False)
 def fixture_dtest_setup(request,
                         dtest_config,
@@ -340,7 +359,7 @@
             if len(errors) > 0:
                 failed = True
                 pytest.fail(msg='Unexpected error found in node logs (see stdout for full details). Errors: [{errors}]'
-                                     .format(errors=str.join(", ", errors)), pytrace=False)
+                            .format(errors=str.join(", ", errors)), pytrace=False)
     finally:
         try:
             # save the logs for inspection
@@ -352,7 +371,7 @@
             dtest_setup.cleanup_cluster(request)
 
 
-#Based on https://bugs.python.org/file25808/14894.patch
+# Based on https://bugs.python.org/file25808/14894.patch
 def loose_version_compare(a, b):
     for i, j in zip_longest(a.version, b.version, fillvalue=''):
         if type(i) != type(j):
@@ -365,7 +384,7 @@
         else:  # i > j
             return 1
 
-    #Longer version strings with equal prefixes are equal, but if one version string is longer than it is greater
+    # Longer version strings with equal prefixes are equal, but if one version string is longer than it is greater
     aLen = len(a.version)
     bLen = len(b.version)
     if aLen == bLen:
@@ -440,6 +459,7 @@
             if skip_msg:
                 pytest.skip(skip_msg)
 
+
 def _skip_ported_msg(current_running_version, ported_from_version):
     if loose_version_compare(current_running_version, ported_from_version) >= 0:
         return "ported to in-JVM from %s >= %s" % (ported_from_version, current_running_version)
@@ -486,6 +506,7 @@
             if skip_msg:
                 pytest.skip(skip_msg)
 
+
 def _skip_ported_msg(current_running_version, ported_from_version):
     if loose_version_compare(current_running_version, ported_from_version) >= 0:
         return "ported to in-JVM from %s >= %s" % (ported_from_version, current_running_version)
@@ -545,7 +566,7 @@
 @pytest.fixture(scope='session')
 def dtest_config(request):
     dtest_config = DTestConfig()
-    dtest_config.setup(request)
+    dtest_config.setup(request.config)
 
     # if we're on mac, check that we have the required loopback interfaces before doing anything!
     check_required_loopback_interfaces_available()
@@ -565,91 +586,88 @@
     return cassandra_dir, cassandra_version
 
 
+def has_mark(item, mark):
+    if item.get_closest_marker(mark) is not None:
+        return True
+    else:
+        for item_module in inspect.getmembers(item.module, inspect.isclass):
+            if hasattr(item_module[1], "pytestmark"):
+                mark_names = [m.name for m in item_module[1].pytestmark]
+                if mark in mark_names:
+                    return True
+
+        return False
+
+
+def _is_skippable(item, mark, include_marked, include_other):
+    if has_mark(item, mark):
+        if include_marked:
+            return False
+        else:
+            logger.info("SKIP: Skipping %s because it is marked with %s" % (item, mark))
+            return True
+    else:
+        if include_other:
+            return False
+        else:
+            logger.info("SKIP: Skipping %s because it is not marked with %s" % (item, mark))
+            return True
+
+
+def is_skippable(item,
+                 include_upgrade_tests,
+                 include_non_upgrade_tests,
+                 include_resource_intensive_tests,
+                 include_non_resource_intensive_tests,
+                 include_vnodes_tests,
+                 include_no_vnodes_tests,
+                 include_no_offheap_memtables_tests):
+
+    skippable = False
+
+    skippable = skippable or _is_skippable(item, "upgrade_test", include_upgrade_tests, include_non_upgrade_tests)
+    skippable = skippable or _is_skippable(item, "resource_intensive", include_resource_intensive_tests, include_non_resource_intensive_tests)
+    skippable = skippable or _is_skippable(item, "vnodes", include_vnodes_tests, True)
+    skippable = skippable or _is_skippable(item, "no_vnodes", include_no_vnodes_tests, True)
+    skippable = skippable or _is_skippable(item, "no_offheap_memtables", include_no_offheap_memtables_tests, True)
+    skippable = skippable or _is_skippable(item, "depends_driver", False, True)
+
+    return skippable
+
+
 def pytest_collection_modifyitems(items, config):
     """
     This function is called upon during the pytest test collection phase and allows for modification
     of the test items within the list
     """
-    collect_only = config.getoption("--collect-only")
-    cassandra_dir, cassandra_version = cassandra_dir_and_version(config)
-    if not collect_only and cassandra_dir is None:
-        if  cassandra_version is None:
-            raise Exception("Required dtest arguments were missing! You must provide either --cassandra-dir "
-                            "or --cassandra-version. You can also set 'cassandra_dir' in pytest.ini. "
-                            "Refer to the documentation or invoke the help with --help.")
-
-    # Either cassandra_version or cassandra_dir is defined, so figure out the version
-    CASSANDRA_VERSION = cassandra_version or get_version_from_build(cassandra_dir)
-
-    # Check that use_off_heap_memtables is supported in this c* version
-    if config.getoption("--use-off-heap-memtables") and ("3.0" <= CASSANDRA_VERSION < "3.4"):
-        raise Exception("The selected Cassandra version %s doesn't support the provided option "
-                        "--use-off-heap-memtables, see https://issues.apache.org/jira/browse/CASSANDRA-9472 "
-                        "for details" % CASSANDRA_VERSION)
-
+    dtest_config = DTestConfig()
+    dtest_config.setup(config)
 
     selected_items = []
     deselected_items = []
 
-    sufficient_system_resources_resource_intensive = sufficient_system_resources_for_resource_intensive_tests()
-    logger.debug("has sufficient resources? %s" % sufficient_system_resources_resource_intensive)
+    can_run_resource_intensive_tests = dtest_config.force_execution_of_resource_intensive_tests or sufficient_system_resources_for_resource_intensive_tests()
+    if not can_run_resource_intensive_tests:
+        logger.info("Resource intensive tests will be skipped because there is not enough system resource "
+                    "and --force-resource-intensive-tests was not specified")
+
+    include_upgrade_tests = dtest_config.execute_upgrade_tests or dtest_config.execute_upgrade_tests_only
+    include_non_upgrade_tests = not dtest_config.execute_upgrade_tests_only
+    include_resource_intensive_tests = can_run_resource_intensive_tests and not dtest_config.skip_resource_intensive_tests
+    include_non_resource_intensive_tests = not dtest_config.only_resource_intensive_tests
+    include_vnodes_tests = dtest_config.use_vnodes
+    include_no_vnodes_tests = not dtest_config.use_vnodes
+    include_no_offheap_memtables_tests = not dtest_config.use_off_heap_memtables
 
     for item in items:
-        deselect_test = False
-
-        if config.getoption("--execute-upgrade-tests-only"):
-            deselect_test = not item.get_closest_marker("upgrade_test")
-            if deselect_test:
-                logger.info("SKIP: Deselecting non-upgrade test %s because of --execute-upgrade-tests-only" % item.name)
-
-        if item.get_closest_marker("resource_intensive") and not collect_only:
-            force_resource_intensive = config.getoption("--force-resource-intensive-tests")
-            skip_resource_intensive = config.getoption("--skip-resource-intensive-tests")
-            if not force_resource_intensive:
-                if skip_resource_intensive:
-                    deselect_test = True
-                    logger.info("SKIP: Deselecting test %s as test marked resource_intensive. To force execution of "
-                          "this test re-run with the --force-resource-intensive-tests command line argument" % item.name)
-                if not sufficient_system_resources_resource_intensive:
-                    deselect_test = True
-                    logger.info("SKIP: Deselecting resource_intensive test %s due to insufficient system resources" % item.name)
-
-        if not item.get_closest_marker("resource_intensive") and not collect_only:
-            only_resource_intensive = config.getoption("--only-resource-intensive-tests")
-            if only_resource_intensive:
-                deselect_test = True
-                logger.info("SKIP: Deselecting non resource_intensive test %s as --only-resource-intensive-tests specified" % item.name)
-
-        if item.get_closest_marker("no_vnodes"):
-            if config.getoption("--use-vnodes"):
-                deselect_test = True
-                logger.info("SKIP: Deselecting test %s as the test requires vnodes to be disabled. To run this test, "
-                      "re-run without the --use-vnodes command line argument" % item.name)
-
-        if item.get_closest_marker("vnodes"):
-            if not config.getoption("--use-vnodes"):
-                deselect_test = True
-                logger.info("SKIP: Deselecting test %s as the test requires vnodes to be enabled. To run this test, "
-                            "re-run with the --use-vnodes command line argument" % item.name)
-
-        for test_item_class in inspect.getmembers(item.module, inspect.isclass):
-            if not hasattr(test_item_class[1], "pytestmark"):
-                continue
-
-            for module_pytest_mark in test_item_class[1].pytestmark:
-                if module_pytest_mark.name == "upgrade_test":
-                    deselect_test = not _upgrade_testing_enabled(config)
-
-        if item.get_closest_marker("upgrade_test"):
-            deselect_test = not _upgrade_testing_enabled(config)
-
-        if item.get_closest_marker("no_offheap_memtables"):
-            if config.getoption("use_off_heap_memtables"):
-                deselect_test = True
-
-        # deselect cqlsh tests that depend on fixing a driver behavior
-        if item.get_closest_marker("depends_driver"):
-            deselect_test = True
+        deselect_test = is_skippable(item,
+                                     include_upgrade_tests,
+                                     include_non_upgrade_tests,
+                                     include_resource_intensive_tests,
+                                     include_non_resource_intensive_tests,
+                                     include_vnodes_tests,
+                                     include_no_vnodes_tests,
+                                     include_no_offheap_memtables_tests)
 
         if deselect_test:
             deselected_items.append(item)
@@ -658,7 +676,3 @@
 
     config.hook.pytest_deselected(items=deselected_items)
     items[:] = selected_items
-
-
-def _upgrade_testing_enabled(config):
-    return config.getoption("--execute-upgrade-tests") or config.getoption("--execute-upgrade-tests-only")
diff --git a/dtest_config.py b/dtest_config.py
index bb5ce8c..86e8c96 100644
--- a/dtest_config.py
+++ b/dtest_config.py
@@ -1,8 +1,13 @@
 import subprocess
 import os
 import ccmlib.repository
+import logging
 
 from ccmlib.common import is_win, get_version_from_build
+from pytest import UsageError
+
+logger = logging.getLogger(__name__)
+
 
 class DTestConfig:
     def __init__(self):
@@ -12,6 +17,7 @@
         self.data_dir_count = -1
         self.force_execution_of_resource_intensive_tests = False
         self.skip_resource_intensive_tests = False
+        self.only_resource_intensive_tests = False
         self.cassandra_dir = None
         self.cassandra_version = None
         self.cassandra_version_from_build = None
@@ -23,28 +29,66 @@
         self.keep_failed_test_dir = False
         self.enable_jacoco_code_coverage = False
         self.jemalloc_path = find_libjemalloc()
+        self.metatests = False
 
-    def setup(self, request):
-        self.use_vnodes = request.config.getoption("--use-vnodes")
-        self.use_off_heap_memtables = request.config.getoption("--use-off-heap-memtables")
-        self.num_tokens = request.config.getoption("--num-tokens")
-        self.data_dir_count = request.config.getoption("--data-dir-count-per-instance")
-        self.force_execution_of_resource_intensive_tests = request.config.getoption("--force-resource-intensive-tests")
-        self.skip_resource_intensive_tests = request.config.getoption("--skip-resource-intensive-tests")
-        cassandra_dir = request.config.getoption("--cassandra-dir") or request.config.getini("cassandra_dir")
+    def setup(self, config):
+        """
+        Reads and validates configuration. Throws UsageError if configuration is invalid.
+        """
+        self.metatests = config.getoption("--metatests")
+        if self.metatests:
+            self.cassandra_dir = os.path.join(os.getcwd(), "meta_tests/cassandra-dir-4.0-beta")
+            self.cassandra_version_from_build = self.get_version_from_build()
+            return
+
+        self.use_vnodes = config.getoption("--use-vnodes")
+        self.use_off_heap_memtables = config.getoption("--use-off-heap-memtables")
+        self.num_tokens = config.getoption("--num-tokens")
+        self.data_dir_count = config.getoption("--data-dir-count-per-instance")
+        self.force_execution_of_resource_intensive_tests = config.getoption("--force-resource-intensive-tests")
+        self.skip_resource_intensive_tests = config.getoption("--skip-resource-intensive-tests")
+        self.only_resource_intensive_tests = config.getoption("--only-resource-intensive-tests")
+        cassandra_dir = config.getoption("--cassandra-dir") or config.getini("cassandra_dir")
+        if cassandra_dir is not None and cassandra_dir.strip() == "":
+            cassandra_dir = None
         if cassandra_dir is not None:
             self.cassandra_dir = os.path.expanduser(cassandra_dir)
-        self.cassandra_version = request.config.getoption("--cassandra-version")
+        self.cassandra_version = config.getoption("--cassandra-version")
 
-        self.cassandra_version_from_build = self.get_version_from_build()
+        if self.cassandra_version is not None and self.cassandra_dir is not None:
+            raise UsageError("Please remove --cassandra-version because Cassandra build directory is already "
+                             "defined (by --cassandra-dir or in ini file)")
 
-        self.delete_logs = request.config.getoption("--delete-logs")
-        self.execute_upgrade_tests = request.config.getoption("--execute-upgrade-tests")
-        self.execute_upgrade_tests_only = request.config.getoption("--execute-upgrade-tests-only")
-        self.disable_active_log_watching = request.config.getoption("--disable-active-log-watching")
-        self.keep_test_dir = request.config.getoption("--keep-test-dir")
-        self.keep_failed_test_dir = request.config.getoption("--keep-failed-test-dir")
-        self.enable_jacoco_code_coverage = request.config.getoption("--enable-jacoco-code-coverage")
+        try:
+            self.cassandra_version_from_build = self.get_version_from_build()
+        except FileNotFoundError as fnfe:
+            raise UsageError("The Cassandra directory %s does not seem to be valid: %s" % (self.cassandra_dir, fnfe))
+
+        self.delete_logs = config.getoption("--delete-logs")
+        self.execute_upgrade_tests = config.getoption("--execute-upgrade-tests")
+        self.execute_upgrade_tests_only = config.getoption("--execute-upgrade-tests-only")
+        self.disable_active_log_watching = config.getoption("--disable-active-log-watching")
+        self.keep_test_dir = config.getoption("--keep-test-dir")
+        self.keep_failed_test_dir = config.getoption("--keep-failed-test-dir")
+        self.enable_jacoco_code_coverage = config.getoption("--enable-jacoco-code-coverage")
+
+        if self.cassandra_version is None and self.cassandra_version_from_build is None:
+            raise UsageError("Required dtest arguments were missing! You must provide either --cassandra-dir "
+                             "or --cassandra-version. You can also set 'cassandra_dir' in pytest.ini. "
+                             "Refer to the documentation or invoke the help with --help.")
+
+        version = self.cassandra_version or self.cassandra_version_from_build
+
+        if self.skip_resource_intensive_tests and \
+                (self.only_resource_intensive_tests or self.force_execution_of_resource_intensive_tests):
+            raise UsageError("--skip-resource-intensive-tests does not make any sense with either "
+                             "--only-resource-intensive-tests or --force-resource-intensive-tests.")
+
+        # Check that use_off_heap_memtables is supported in this c* version
+        if self.use_off_heap_memtables and ("3.0" <= version < "3.4"):
+            raise UsageError("The selected Cassandra version %s doesn't support the provided option "
+                             "--use-off-heap-memtables, see https://issues.apache.org/jira/browse/CASSANDRA-9472 "
+                             "for details" % version)
 
     def get_version_from_build(self):
         # There are times when we want to know the C* version we're testing against
@@ -59,7 +103,6 @@
             return get_version_from_build(self.cassandra_dir)
 
 
-
 # Determine the location of the libjemalloc jar so that we can specify it
 # through environment variables when start Cassandra.  This reduces startup
 # time, making the dtests run faster.
diff --git a/dtest_setup.py b/dtest_setup.py
index f613ab2..d04fb00 100644
--- a/dtest_setup.py
+++ b/dtest_setup.py
@@ -16,7 +16,7 @@
 from cassandra.cluster import NoHostAvailable
 from cassandra.cluster import EXEC_PROFILE_DEFAULT
 from cassandra.policies import WhiteListRoundRobinPolicy
-from ccmlib.common import get_version_from_build, is_win
+from ccmlib.common import is_win
 from ccmlib.cluster import Cluster
 
 from dtest import (get_ip_from_node, make_execution_profile, get_auth_provider, get_port_from_node,
@@ -364,7 +364,7 @@
                         self.stop_active_log_watch()
                 finally:
                     logger.debug("removing ccm cluster {name} at: {path}".format(name=self.cluster.name,
-                                                                          path=self.test_path))
+                                                                                 path=self.test_path))
                     self.cluster.remove()
 
                     logger.debug("clearing ssl stores from [{0}] directory".format(self.test_path))
@@ -459,7 +459,6 @@
         else:
             logger.debug("Jacoco agent not found or is not file. Execution will not be recorded.")
 
-
     @staticmethod
     def create_ccm_cluster(dtest_setup):
         logger.info("cluster ccm directory: " + dtest_setup.test_path)
@@ -520,5 +519,3 @@
         version that may not be compatible with the existing configuration options
         """
         self.init_default_config()
-
-
diff --git a/meta_tests/cassandra-dir-3.2/0.version.txt b/meta_tests/cassandra-dir-3.2/0.version.txt
new file mode 100644
index 0000000..4fe5631
--- /dev/null
+++ b/meta_tests/cassandra-dir-3.2/0.version.txt
@@ -0,0 +1 @@
+3.2
\ No newline at end of file
diff --git a/meta_tests/cassandra-dir-4.0-beta/0.version.txt b/meta_tests/cassandra-dir-4.0-beta/0.version.txt
new file mode 100644
index 0000000..d494f03
--- /dev/null
+++ b/meta_tests/cassandra-dir-4.0-beta/0.version.txt
@@ -0,0 +1 @@
+4.0-beta
\ No newline at end of file
diff --git a/meta_tests/cassandra-dir-4.0-beta/bin/.do-not-delete b/meta_tests/cassandra-dir-4.0-beta/bin/.do-not-delete
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/meta_tests/cassandra-dir-4.0-beta/bin/.do-not-delete
diff --git a/meta_tests/cassandra-dir-4.0-beta/conf/cassandra.yaml b/meta_tests/cassandra-dir-4.0-beta/conf/cassandra.yaml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/meta_tests/cassandra-dir-4.0-beta/conf/cassandra.yaml
diff --git a/meta_tests/conftest_test.py b/meta_tests/conftest_test.py
new file mode 100644
index 0000000..935f657
--- /dev/null
+++ b/meta_tests/conftest_test.py
@@ -0,0 +1,73 @@
+from unittest import TestCase
+
+from conftest import is_skippable
+from mock import Mock
+
+
+def _mock_responses(responses, default_response=None):
+    return lambda arg: responses[arg] if arg in responses else default_response
+
+
+def _is_skippable(item,
+                  include_upgrade_tests=True,
+                  include_non_upgrade_tests=True,
+                  include_resource_intensive_tests=True,
+                  include_non_resource_intensive_tests=True,
+                  include_vnodes_tests=True,
+                  include_no_vnodes_tests=True,
+                  include_no_offheap_memtables_tests=True):
+    return is_skippable(item,
+                        include_upgrade_tests,
+                        include_non_upgrade_tests,
+                        include_resource_intensive_tests,
+                        include_non_resource_intensive_tests,
+                        include_vnodes_tests,
+                        include_no_vnodes_tests,
+                        include_no_offheap_memtables_tests)
+
+
+class ConfTestTest(TestCase):
+    regular_test = Mock(name="regular_test_mock")
+    upgrade_test = Mock(name="upgrade_test_mock")
+    resource_intensive_test = Mock(name="resource_intensive_test_mock")
+    vnodes_test = Mock(name="vnodes_test_mock")
+    no_vnodes_test = Mock(name="no_vnodes_test_mock")
+    no_offheap_memtables_test = Mock(name="no_offheap_memtables_test_mock")
+    depends_driver_test = Mock(name="depends_driver_test_mock")
+
+    def setup_method(self, method):
+        self.regular_test.get_closest_marker.side_effect = _mock_responses({})
+        self.upgrade_test.get_closest_marker.side_effect = _mock_responses({"upgrade_test": True})
+        self.resource_intensive_test.get_closest_marker.side_effect = _mock_responses({"resource_intensive": True})
+        self.vnodes_test.get_closest_marker.side_effect = _mock_responses({"vnodes": True})
+        self.no_vnodes_test.get_closest_marker.side_effect = _mock_responses({"no_vnodes": True})
+        self.no_offheap_memtables_test.get_closest_marker.side_effect = _mock_responses({"no_offheap_memtables": True})
+        self.depends_driver_test.get_closest_marker.side_effect = _mock_responses({"depends_driver": True})
+
+    def test_regular_test(self):
+        assert not _is_skippable(item=self.regular_test)
+        assert _is_skippable(item=self.regular_test, include_non_upgrade_tests=False)
+        assert _is_skippable(item=self.regular_test, include_non_resource_intensive_tests=False)
+
+    def test_upgrade_test(self):
+        assert not _is_skippable(item=self.upgrade_test)
+        assert _is_skippable(item=self.upgrade_test, include_upgrade_tests=False)
+
+    def test_resource_intensive_test(self):
+        assert not _is_skippable(item=self.resource_intensive_test)
+        assert _is_skippable(item=self.resource_intensive_test, include_resource_intensive_tests=False)
+
+    def test_vnodes_test(self):
+        assert not _is_skippable(item=self.vnodes_test)
+        assert _is_skippable(item=self.vnodes_test, include_vnodes_tests=False)
+
+    def test_no_vnodes_test(self):
+        assert not _is_skippable(item=self.no_vnodes_test)
+        assert _is_skippable(item=self.no_vnodes_test, include_no_vnodes_tests=False)
+
+    def test_no_offheap_memtables_test(self):
+        assert not _is_skippable(item=self.no_offheap_memtables_test)
+        assert _is_skippable(item=self.no_offheap_memtables_test, include_no_offheap_memtables_tests=False)
+
+    def test_depends_driver_test(self):
+        assert _is_skippable(item=self.depends_driver_test)
diff --git a/meta_tests/dtest_config_test.py b/meta_tests/dtest_config_test.py
new file mode 100644
index 0000000..0a01b68
--- /dev/null
+++ b/meta_tests/dtest_config_test.py
@@ -0,0 +1,121 @@
+import os
+from re import search
+from unittest import TestCase
+
+from dtest_config import DTestConfig
+from mock import Mock, patch
+from pytest import UsageError, raises
+import ccmlib.repository
+import ccmlib.common
+
+
+def _mock_responses(responses, default_response=None):
+    return lambda input: responses[input] if input in responses else \
+        "%s/meta_tests/cassandra-dir-4.0-beta" % os.getcwd() if input == "--cassandra-dir" else default_response
+
+
+def _check_with_params(params):
+    config = Mock()
+    config.getoption.side_effect = _mock_responses(params)
+    config.getini.side_effect = _mock_responses({})
+
+    dTestConfig = DTestConfig()
+    dTestConfig.setup(config)
+    return dTestConfig
+
+
+def _check_with_params_expect(params, pattern):
+    with raises(UsageError, match=pattern):
+        _check_with_params(params)
+
+
+class DTestConfigTest(TestCase):
+
+    def test_invalid_cass_dir_no_version(self):
+        _check_with_params_expect({
+            '--cassandra-dir': 'blah'
+        }, "The Cassandra directory blah does not seem to be valid")
+
+    def test_cass_dir_and_version(self):
+        _check_with_params_expect({
+            '--cassandra-version': '3.11'
+        }, "Cassandra build directory is already defined")
+
+    def test_no_cass_dir(self):
+        with patch.object(ccmlib.repository, "setup") as mocked_setup:
+            mocked_setup.side_effect = _mock_responses({'3.2': ("%s/meta_tests/cassandra-dir-3.2" % os.getcwd(), '3.2.0')})
+            c = _check_with_params({
+                '--cassandra-dir': None,
+                '--cassandra-version': '3.2'
+            })
+            assert c.cassandra_version == '3.2'
+            assert search("^3.2", str(c.cassandra_version_from_build))
+
+    def test_valid_cass_dir_no_version(self):
+        c = _check_with_params({
+        })
+        assert c.cassandra_version is None
+        assert c.cassandra_version_from_build == '4.0-beta'
+
+    def test_no_cass_dir_no_version(self):
+        _check_with_params_expect({
+            '--cassandra-dir': None
+        }, "You must provide either --cassandra-dir or --cassandra-version")
+
+    def test_illegal_args_combinations_for_resource_intensive_tests(self):
+        _check_with_params_expect({
+            '--only-resource-intensive-tests': True,
+            '--skip-resource-intensive-tests': True
+        }, 'does not make any sense')
+
+        _check_with_params_expect({
+            '--force-resource-intensive-tests': True,
+            '--skip-resource-intensive-tests': True
+        }, 'does not make any sense')
+
+        _check_with_params_expect({
+            '--only-resource-intensive-tests': True,
+            '--force-resource-intensive-tests': True,
+            '--skip-resource-intensive-tests': True
+        }, 'does not make any sense')
+
+    def test_legal_args_combinations_for_resource_intensive_tests(self):
+        c = _check_with_params({
+            '--only-resource-intensive-tests': True
+        })
+        assert c.only_resource_intensive_tests
+        assert not c.skip_resource_intensive_tests
+        assert not c.force_execution_of_resource_intensive_tests
+
+        c = _check_with_params({
+            '--only-resource-intensive-tests': True,
+            '--force-resource-intensive-tests': True
+        })
+        assert c.only_resource_intensive_tests
+        assert not c.skip_resource_intensive_tests
+        assert c.force_execution_of_resource_intensive_tests
+
+        c = _check_with_params({
+            '--skip-resource-intensive-tests': True
+        })
+        assert not c.only_resource_intensive_tests
+        assert c.skip_resource_intensive_tests
+        assert not c.force_execution_of_resource_intensive_tests
+
+        c = _check_with_params({
+        })
+        assert not c.only_resource_intensive_tests
+        assert not c.skip_resource_intensive_tests
+        assert not c.force_execution_of_resource_intensive_tests
+
+    def off_heap_memtables_not_supported(self):
+        _check_with_params_expect({
+            '--cassandra-dir': "%s/meta_tests/cassandra-dir-3.2" % os.getcwd(),
+            '--use-off-heap-memtables': True
+        }, "The selected Cassandra version 3.2 doesn't support the provided option")
+
+    def off_heap_memtables_supported(self):
+        c = _check_with_params({
+            '--use-off-heap-memtables': True
+        })
+        assert c.use_off_heap_memtables
diff --git a/run_dtests.py b/run_dtests.py
index 44969b2..34dd5af 100755
--- a/run_dtests.py
+++ b/run_dtests.py
@@ -1,20 +1,21 @@
 #!/usr/bin/env python
 """
-usage: run_dtests.py [-h] [--use-vnodes] [--use-off-heap-memtables] [--num-tokens NUM_TOKENS] [--data-dir-count-per-instance DATA_DIR_COUNT_PER_INSTANCE] [--force-resource-intensive-tests]
-                     [--skip-resource-intensive-tests] [--cassandra-dir CASSANDRA_DIR] [--cassandra-version CASSANDRA_VERSION] [--delete-logs] [--execute-upgrade-tests] [--execute-upgrade-tests-only] [--disable-active-log-watching]
-                     [--keep-test-dir] [--enable-jacoco-code-coverage] [--dtest-enable-debug-logging] [--dtest-print-tests-only] [--dtest-print-tests-output DTEST_PRINT_TESTS_OUTPUT]
-                     [--pytest-options PYTEST_OPTIONS] [--dtest-tests DTEST_TESTS]
+usage: run_dtests.py [-h] [--use-vnodes] [--use-off-heap-memtables] [--num-tokens=NUM_TOKENS] [--data-dir-count-per-instance=DATA_DIR_COUNT_PER_INSTANCE]
+                     [--force-resource-intensive-tests] [--skip-resource-intensive-tests] [--cassandra-dir=CASSANDRA_DIR] [--cassandra-version=CASSANDRA_VERSION]
+                     [--delete-logs] [--execute-upgrade-tests] [--execute-upgrade-tests-only] [--disable-active-log-watching] [--keep-test-dir]
+                     [--enable-jacoco-code-coverage] [--dtest-enable-debug-logging] [--dtest-print-tests-only] [--dtest-print-tests-output=DTEST_PRINT_TESTS_OUTPUT]
+                     [--pytest-options=PYTEST_OPTIONS] [--dtest-tests=DTEST_TESTS]
 
 optional arguments:
   -h, --help                                                 show this help message and exit
   --use-vnodes                                               Determines wither or not to setup clusters using vnodes for tests (default: False)
   --use-off-heap-memtables                                   Enable Off Heap Memtables when creating test clusters for tests (default: False)
-  --num-tokens NUM_TOKENS                                    Number of tokens to set num_tokens yaml setting to when creating instances with vnodes enabled (default: 256)
-  --data-dir-count-per-instance DATA_DIR_COUNT_PER_INSTANCE  Control the number of data directories to create per instance (default: 3)
+  --num-tokens=NUM_TOKENS                                    Number of tokens to set num_tokens yaml setting to when creating instances with vnodes enabled (default: 256)
+  --data-dir-count-per-instance=DATA_DIR_COUNT_PER_INSTANCE  Control the number of data directories to create per instance (default: 3)
   --force-resource-intensive-tests                           Forces the execution of tests marked as resource_intensive (default: False)
   --skip-resource-intensive-tests                            Skip all tests marked as resource_intensive (default: False)
-  --cassandra-dir CASSANDRA_DIR
-  --cassandra-version CASSANDRA_VERSION
+  --cassandra-dir=CASSANDRA_DIR
+  --cassandra-version=CASSANDRA_VERSION
   --delete-logs
   --execute-upgrade-tests                                    Execute Cassandra Upgrade Tests (e.g. tests annotated with the upgrade_test mark) (default: False)
   --execute-upgrade-tests-only                               Execute Cassandra Upgrade Tests without running any other tests (e.g. tests annotated with the upgrade_test mark) (default: False)
@@ -24,9 +25,9 @@
   --enable-jacoco-code-coverage                              Enable JaCoCo Code Coverage Support (default: False)
   --dtest-enable-debug-logging                               Enable debug logging (for this script, pytest, and during execution of test functions) (default: False)
   --dtest-print-tests-only                                   Print list of all tests found eligible for execution given the provided options. (default: False)
-  --dtest-print-tests-output DTEST_PRINT_TESTS_OUTPUT        Path to file where the output of --dtest-print-tests-only should be written to (default: False)
-  --pytest-options PYTEST_OPTIONS                            Additional command line arguments to proxy directly thru when invoking pytest. (default: None)
-  --dtest-tests DTEST_TESTS                                  Comma separated list of test files, test classes, or test methods to execute. (default: None)
+  --dtest-print-tests-output=DTEST_PRINT_TESTS_OUTPUT        Path to file where the output of --dtest-print-tests-only should be written to (default: False)
+  --pytest-options=PYTEST_OPTIONS                            Additional command line arguments to proxy directly thru when invoking pytest. (default: None)
+  --dtest-tests=DTEST_TESTS                                  Comma separated list of test files, test classes, or test methods to execute. (default: None)
 """
 import subprocess
 import sys
@@ -36,13 +37,11 @@
 
 from os import getcwd
 from tempfile import NamedTemporaryFile
-from bs4 import BeautifulSoup
 
 from _pytest.config.argparsing import Parser
 import argparse
 
 from conftest import pytest_addoption
-from ccmlib.common import get_version_from_build
 
 logger = logging.getLogger(__name__)
 
@@ -87,19 +86,6 @@
 
         args = parser.parse_args()
 
-        if not args.dtest_print_tests_only:
-            if args.cassandra_dir is None and args.cassandra_version is None:
-                raise Exception("Required dtest arguments were missing! You must provide either --cassandra-dir "
-                                "or --cassandra-version. Refer to the documentation or invoke the help with --help.")
-
-            # Either cassandra_version or cassandra_dir is defined, so figure out the version
-            CASSANDRA_VERSION = args.cassandra_version or get_version_from_build(args.cassandra_dir)
-
-            if args.use_off_heap_memtables and ("3.0" <= CASSANDRA_VERSION < "3.4"):
-                raise Exception("The selected Cassandra version %s doesn't support the provided option "
-                                "--use-off-heap-memtables, see https://issues.apache.org/jira/browse/CASSANDRA-9472 "
-                                "for details" % CASSANDRA_VERSION)
-
         if args.dtest_enable_debug_logging:
             logging.root.setLevel(logging.DEBUG)
             logger.setLevel(logging.DEBUG)
@@ -111,7 +97,6 @@
             handler.setFormatter(formatter)
             logging.root.addHandler(handler)
 
-
         # Get dictionaries corresponding to each point in the configuration matrix
         # we want to run, then generate a config object for each of them.
         logger.debug('Generating configurations from the following matrix:\n\t{}'.format(args))
@@ -125,11 +110,14 @@
 
         if args.dtest_print_tests_only:
             args_to_invoke_pytest.append("'--collect-only'")
+            args_to_invoke_pytest.append("'-q'")
 
         if args.dtest_tests:
             for test in args.dtest_tests.split(","):
                 args_to_invoke_pytest.append("'{test_name}'".format(test_name=test))
 
+        args_to_invoke_pytest.append("'--ignore=meta_tests'")
+
         original_raw_cmd_args = ", ".join(args_to_invoke_pytest)
 
         logger.debug("args to call with: [%s]" % original_raw_cmd_args)
@@ -139,14 +127,12 @@
         # but for now just leaving it as is, because it does the job (although
         # certainly is still pretty complicated code and has a hacky feeling)
         to_execute = (
-                "import pytest\n" +
-                (
-                "pytest.main([{options}])\n").format(options=original_raw_cmd_args)
-        )
+            "import pytest\n"
+            "import sys\n"
+            "sys.exit(pytest.main([{options}]))\n".format(options=original_raw_cmd_args))
         temp = NamedTemporaryFile(dir=getcwd())
         logger.debug('Writing the following to {}:'.format(temp.name))
 
-        logger.debug('```\n{to_execute}```\n'.format(to_execute=to_execute))
         temp.write(to_execute.encode("utf-8"))
         temp.flush()
 
@@ -163,20 +149,19 @@
         if args.dtest_print_tests_only:
             stdout, stderr = sp.communicate()
 
-            if stderr:
+            if sp.returncode != 0:
                 print(stderr.decode("utf-8"))
                 result = sp.returncode
                 exit(result)
 
             all_collected_test_modules = collect_test_modules(stdout)
             joined_test_modules = "\n".join(all_collected_test_modules)
-            #print("Collected %d Test Modules" % len(all_collected_test_modules))
+            print("Collected %d Test Modules" % len(all_collected_test_modules))
             if args.dtest_print_tests_output is not None:
                 collected_tests_output_file = open(args.dtest_print_tests_output, "w")
                 collected_tests_output_file.write(joined_test_modules)
                 collected_tests_output_file.close()
 
-            print(joined_test_modules)
         else:
             while True:
                 stdout_output = sp.stdout.readline()
@@ -197,96 +182,14 @@
 
 
 def collect_test_modules(stdout):
-    """
-    Takes the xml-ish (no, it's not actually xml so we need to format it a bit) --collect-only output as printed
-    by pytest to stdout and normalizes it to get a list of all collected tests in a human friendly format
-    :param stdout: the stdout from pytest (should have been invoked with the --collect-only cmdline argument)
-    :return: a formatted list of collected test modules in format test_file.py::TestClass::test_function
-    """
-    # unfortunately, pytest emits xml like output -- but it's not actually xml, so we'll fail to parse
-    # if we try. first step is to fix up the pytest output to create well formatted xml
-    xml_line_regex_pattern = re.compile(r"^([\s])*<(Module|Class|Function|Instance) '(.*)'>")
-    is_first_module = True
-    is_first_class = True
-    has_closed_class = False
-    section_has_instance = False
-    section_has_class = False
-    test_collect_xml_lines = []
-
-    test_collect_xml_lines.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
-    test_collect_xml_lines.append("<Modules>")
-    for line in stdout.decode("utf-8").split('\n'):
-        re_ret = re.search(xml_line_regex_pattern, line)
-        if re_ret:
-            if not is_first_module and re_ret.group(2) == "Module":
-                if section_has_instance:
-                    test_collect_xml_lines.append("      </Instance>")
-                if section_has_class:
-                    test_collect_xml_lines.append("    </Class>")
-
-                test_collect_xml_lines.append("  </Module>")
-                is_first_class = True
-                has_closed_class = False
-                section_has_instance = False
-                section_has_class = False
-                is_first_module = False
-            elif is_first_module and re_ret.group(2) == "Module":
-                if not has_closed_class and section_has_instance:
-                    test_collect_xml_lines.append("      </Instance>")
-                if not has_closed_class and section_has_class:
-                    test_collect_xml_lines.append("    </Class>")
-
-                is_first_class = True
-                is_first_module = False
-                has_closed_class = False
-                section_has_instance = False
-                section_has_class = False
-            elif re_ret.group(2) == "Instance":
-                section_has_instance = True
-            elif not is_first_class and re_ret.group(2) == "Class":
-                if section_has_instance:
-                    test_collect_xml_lines.append("      </Instance>")
-                if section_has_class:
-                    test_collect_xml_lines.append("    </Class>")
-                has_closed_class = True
-                section_has_class = True
-            elif re_ret.group(2) == "Class":
-                is_first_class = False
-                section_has_class = True
-                has_closed_class = False
-
-            if re_ret.group(2) == "Function":
-                test_collect_xml_lines.append("          <Function name=\"{name}\"></Function>"
-                                              .format(name=re_ret.group(3)))
-            elif re_ret.group(2) == "Class":
-                test_collect_xml_lines.append("    <Class name=\"{name}\">".format(name=re_ret.group(3)))
-            elif re_ret.group(2) == "Module":
-                test_collect_xml_lines.append("  <Module name=\"{name}\">".format(name=re_ret.group(3)))
-            elif re_ret.group(2) == "Instance":
-                test_collect_xml_lines.append("      <Instance name=\"\">".format(name=re_ret.group(3)))
-            else:
-                test_collect_xml_lines.append(line)
-
-    test_collect_xml_lines.append("      </Instance>")
-    test_collect_xml_lines.append("    </Class>")
-    test_collect_xml_lines.append("  </Module>")
-    test_collect_xml_lines.append("</Modules>")
-
+    test_regex_pattern = re.compile(r".+::.+::.+")
     all_collected_test_modules = []
-
-    # parse the now valid xml
-    print("\n".join(test_collect_xml_lines))
-    test_collect_xml = BeautifulSoup("\n".join(test_collect_xml_lines), "lxml-xml")
-
-    # find all Modules (followed by classes in those modules, and then finally functions)
-    for pytest_module in test_collect_xml.findAll("Module"):
-        for test_class_name in pytest_module.findAll("Class"):
-            for function_name in test_class_name.findAll("Function"):
-                # adds to test list in format like test_file.py::TestClass::test_function for every test function found
-                all_collected_test_modules.append("{module_name}::{class_name}::{function_name}"
-                                                  .format(module_name=pytest_module.attrs['name'],
-                                                          class_name=test_class_name.attrs['name'],
-                                                          function_name=function_name.attrs['name']))
+    for line in stdout.decode("utf-8").split('\n'):
+        re_ret = re.search(test_regex_pattern, line)
+        if re_ret:
+            all_collected_test_modules.append(line)
+        elif line.strip() != "":
+            print(line)
 
     return all_collected_test_modules