diff --git a/.ci/unit_tests.sh b/.ci/unit_tests.sh
new file mode 100644
index 0000000000000000000000000000000000000000..384b352f558b6cfd875be95043194e4712e2a5fe
--- /dev/null
+++ b/.ci/unit_tests.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+conda create                                                      \
+      -c https://fsl.fmrib.ox.ac.uk/fsldownloads/fslconda/public/ \
+      -c conda-forge                                              \
+      -p ./test.env                                               \
+      "python=${PYTHON_VERSION}"                                  \
+      pytest                                                      \
+      coverage                                                    \
+      pytest-cov
+
+source activate ./test.env
+
+pip install --no-deps -e .
+
+pytest
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7df784efd57a9522a46712e4dc278de5ebb0fcd5
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,20 @@
+stages:
+ - test
+
+
+.test: &test_template
+  stage: test
+  image: continuumio/miniconda3
+  tags:
+    - docker
+  except:
+    - tags
+    - merge_requests
+  script:
+    - bash ./.ci/unit_tests.sh
+
+
+test:3.11:
+  variables:
+    PYTHON_VERSION : "3.11"
+  <<: *test_template
diff --git a/bip/tests/__init__.py b/bip/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc9623cc080ddfb54f39f6f50597381f170ea0ac
--- /dev/null
+++ b/bip/tests/__init__.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python
+#
+# Utility functions available for use by tests.
+#
+
+
+import            contextlib
+import            os
+import os.path as op
+import            tempfile
+
+
+def touch(fpath):
+    """Create a dummy file at fpath."""
+    with open(fpath, 'wt') as f:
+        f.write(fpath)
+
+
+@contextlib.contextmanager
+def tempdir():
+    """Create and change into a temp directory, deleting it afterwards."""
+    prevdir = os.getcwd()
+    with tempfile.TemporaryDirectory() as td:
+        os.chdir(td)
+        try:
+            yield td
+        finally:
+            os.chdir(prevdir)
+
+
+@contextlib.contextmanager
+def mock_directory(contents):
+    """Create a temp directory with dummy contents."""
+    with tempdir() as td:
+        for c in contents:
+            touch(c)
+        yield td
+
+
+def dicts_equal(da, db):
+    """Compare two dicts, ignoring order."""
+    da = {k : da[k] for k in sorted(da.keys())}
+    db = {k : db[k] for k in sorted(db.keys())}
+    return da == db
diff --git a/bip/tests/test_bip_utils_config.py b/bip/tests/test_bip_utils_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..4b531e0af8214529e0ee1beabc813e73fa7ebcdb
--- /dev/null
+++ b/bip/tests/test_bip_utils_config.py
@@ -0,0 +1,376 @@
+#!/usr/bin/env python
+
+import os.path  as op
+import textwrap as tw
+
+import unittest
+
+import pytest
+
+import bip.utils.config as config
+
+from bip.tests import touch, tempdir, mock_directory, dicts_equal
+
+
+def test_nested_lookup():
+    # (dictionary, key, expected_value)
+    tests = [
+        ({'a' : {'b' : {'c' : {'d' : 'e'}}}}, ['a'],
+         {'b' : {'c' : {'d' : 'e'}}}),
+        ({'a' : {'b' : {'c' : {'d' : 'e'}}}}, ['a', 'b'],
+         {'c' : {'d' : 'e'}}),
+        ({'a' : {'b' : {'c' : {'d' : 'e'}}}}, ['a', 'b', 'c'],
+         {'d' : 'e'}),
+        ({'a' : {'b' : {'c' : {'d' : 'e'}}}}, ['a', 'b', 'c', 'd'],
+         'e'),
+    ]
+
+    for d, k, exp in tests:
+        assert config.nested_lookup(d, k) == exp
+
+
+def test_flatten_dictionary():
+    # (indict, nlevels, expdict)
+    tests = [
+        ({'a' : {'b' : 'c', 'd' : 'e'}}, 1,
+         {'a_b' : 'c', 'a_d' : 'e'}),
+
+        ({'a' : 'b', 'c' : {'d' : 'e', 'f' : 'g'}}, 1,
+         {'a' : 'b', 'c_d' : 'e', 'c_f' : 'g'}),
+
+        ({'a' : 'b', 'c' : {'d' : 'e', 'f' : {'g' : 'h', 'i' : 'j'}}}, 1,
+         {'a' : 'b', 'c_d' : 'e', 'c_f' : {'g' : 'h', 'i' : 'j'}}),
+
+        ({'a' : 'b', 'c' : {'d' : 'e', 'f' : {'g' : 'h', 'i' : 'j'}}}, 2,
+         {'a' : 'b', 'c_d' : 'e', 'c_f_g' : 'h', 'c_f_i' : 'j'}),
+    ]
+
+    for indict, nlevels, expdict in tests:
+        assert config.flatten_dictionary(indict, nlevels) == expdict
+
+
+def test_parse_override_value():
+    # (template value, input value, expected)
+    tests = [
+        ('a',  'b', 'b'),
+        (None, 'b', 'b'),
+
+        (1,   '1',   1),
+        (1.5, '1',   1),
+        (1,   '1.5', 1.5),
+        (1.5, '1.5', 1.5),
+
+        (True, 'false', False),
+        (True, 'true',  True),
+
+        ([], '[1,2,3,4]', [1, 2, 3, 4]),
+        ({}, '{"a" = 1, "b" = 2}', {'a' : 1, 'b' : 2})
+    ]
+
+    for origval, val, expect in tests:
+        assert config.parse_override_value('param', origval, val) == expect
+
+
+def test_Config_config_file_identifier():
+    # (filename, expected)
+    tests = [
+        ('config.toml',          'config'),
+        ('T1_struct.toml',       'T1_struct'),
+        ('01.T1_struct.toml',    'T1_struct'),
+        ('01.02.T1_struct.toml', 'T1_struct'),
+    ]
+
+    for filename, expected in tests:
+        assert config.Config.config_file_identifier(filename) == expected
+
+
+def test_Config_list_config_files():
+    contents = ['config.toml', 'config.json', 'config.cfg',
+                'abcde.toml', 'bcdef.toml', 'cdefg.toml',
+                '02.defgh.toml', 'some_file.json']
+    # files should be in alphabetical
+    # order, with config.toml last
+    expect = ['02.defgh.toml', 'abcde.toml', 'bcdef.toml',
+              'cdefg.toml', 'config.toml']
+    with mock_directory(contents) as mdir:
+        expect = [op.join(e) for e in expect]
+        assert config.Config.list_config_files(mdir)
+
+
+def test_Config_resolve_selectors():
+
+    indict1 = {'param1' : '1',
+               'param2' : '2',
+               'subject' : {'12345' : {'param1' : '3', 'param2' : '4'},
+                            '56789' : {'param1' : '5', 'param2' : '6'}}}
+
+    indict2 = {'param1' : '1',
+               'param2' : '2',
+               'subject' : {'12345' : {'param1' : '3',
+                                       'visit'  : {'2' : {'param1' : '3.2',
+                                                          'param2' : '4.2'}}},
+                            '56789' : {'param1' : '5',
+                                       'visit'  : {'2' : {'param1' : '5.2',
+                                                          'param2' : '6.2'}}}}}
+
+    # (input dict, selectors, expected output)
+    tests = [
+        (indict1, {},                    {'param1' : '1',   'param2' : '2'}),
+        (indict1, {'subject' : '12345'}, {'param1' : '3',   'param2' : '4'}),
+        (indict1, {'subject' : '56789'}, {'param1' : '5',   'param2' : '6'}),
+
+        (indict2, {},                    {'param1' : '1',   'param2' : '2'}),
+        (indict2, {'subject' : '12345'}, {'param1' : '3',   'param2' : '2'}),
+        (indict2, {'subject' : '56789'}, {'param1' : '5',   'param2' : '2'}),
+        (indict2, {'visit'   : '2'},     {'param1' : '1',   'param2' : '2'}),
+        (indict2, {'subject' : '12345',
+                   'visit'   : '1'},     {'param1' : '3',   'param2' : '2'}),
+        (indict2, {'subject' : '56789',
+                   'visit'   : '1'},     {'param1' : '5',   'param2' : '2'}),
+        (indict2, {'subject' : '12345',
+                   'visit'   : '2'},     {'param1' : '3.2', 'param2' : '4.2'}),
+        (indict2, {'subject' : '56789',
+                   'visit'   : '2'},     {'param1' : '5.2', 'param2' : '6.2'}),
+    ]
+
+    for indict, selectors, expdict in tests:
+        result = config.Config.resolve_selectors(indict, selectors)
+        for k, v in expdict.items():
+            assert result[k] == v
+
+
+    # selector/setting conflict - probably
+    # invalid, but function shouold not crash
+    indict    = {'subject' : '12345', 'param1' : '1'}
+    selectors = {'subject' : '12345'}
+    result    = config.Config.resolve_selectors(indict, selectors)
+    assert dicts_equal(indict, result)
+
+
+def test_Config_load_config_file():
+
+    configtoml = tw.dedent("""
+    param1                       = 1
+    param2                       = 2
+    subject.12345.param1         = 3
+    subject.56789.param1         = 5
+    subject.12345.visit.2.param1 = 3.2
+    subject.12345.visit.2.param2 = 4.2
+    subject.56789.visit.2.param1 = 5.2
+    subject.56789.visit.2.param2 = 6.2
+    """).strip()
+    # (selectors, expected output)
+    tests = [
+        ({},                    {'param1' : 1,   'param2' : 2}),
+        ({'subject' : '12345'}, {'param1' : 3,   'param2' : 2}),
+        ({'subject' : '56789'}, {'param1' : 5,   'param2' : 2}),
+        ({'visit'   : '2'},     {'param1' : 1,   'param2' : 2}),
+        ({'subject' : '12345',
+          'visit'   : '1'},     {'param1' : 3,   'param2' : 2}),
+        ({'subject' : '56789',
+          'visit'   : '1'},     {'param1' : 5,   'param2' : 2}),
+        ({'subject' : '12345',
+          'visit'   : '2'},     {'param1' : 3.2, 'param2' : 4.2}),
+        ({'subject' : '56789',
+          'visit'   : '2'},     {'param1' : 5.2, 'param2' : 6.2}),
+    ]
+
+    with tempdir():
+        fnames = ['config.toml', 'abc.toml']
+        for fname in fnames:
+            open(fname, 'wt').write(configtoml)
+            for selectors, expect in tests:
+                result = config.Config.load_config_file(fname, selectors)
+                for k, v in expect.items():
+
+                    if fname != 'config.toml':
+                        ident = config.Config.config_file_identifier(fname)
+                        k     = f'{ident}_{k}'
+                    assert result[k] == v
+
+
+def test_Config_load_config_file_main_relabelling():
+    configtoml = tw.dedent("""
+    param1 = 1
+    param2 = 2
+    [abc]
+    param3 = 3
+    param4 = 4
+    [def]
+    param5 = 5
+    param6 = 6
+    """).strip()
+
+    expect = {
+        'param1'     : 1,
+        'param2'     : 2,
+        'abc_param3' : 3,
+        'abc_param4' : 4,
+        'def_param5' : 5,
+        'def_param6' : 6
+    }
+
+    with tempdir():
+        open('config.toml', 'wt').write(configtoml)
+        result = config.Config.load_config_file('config.toml')
+        assert result == expect
+
+
+def test_Config_create():
+    configtoml = tw.dedent("""
+    param1     = 0.5
+    abc_param1 = 0.4
+    def_param1 = 0.3
+    """).strip()
+    abctoml = tw.dedent("""
+    param1 = 0.6
+    param2 = 0.7
+    """).strip()
+    def01toml = tw.dedent("""
+    param1 = 0.8
+    param2 = 0.9
+    """).strip()
+    def02toml = tw.dedent("""
+    param1 = 1.0
+    param2 = 1.1
+    """).strip()
+
+    exp = {
+        'param1'     : 0.5,
+        'abc_param1' : 0.4,
+        'def_param1' : 0.3,
+        'abc_param2' : 0.7,
+        'def_param2' : 1.1,
+    }
+
+    with tempdir():
+        open('config.toml', 'wt').write(configtoml)
+        open('abc.toml',    'wt').write(abctoml)
+        open('01.def.toml', 'wt').write(def01toml)
+        open('02.def.toml', 'wt').write(def02toml)
+        cfg = config.Config('.')
+
+        for k, v in exp.items():
+            assert cfg[k] == v
+
+    with tempdir():
+        cfg = config.Config('.')
+        assert len(cfg) == 0
+
+
+def test_Config_overrides():
+    configtoml = tw.dedent("""
+    param1 = 1
+    param2 = 'abc'
+    param3 = [1, 2, 3]
+    [abc]
+    param1 = true
+    param2 = 0.4
+    """).strip()
+
+    # (overrides, expected)
+    pass_tests = [
+        ({},
+         {'param1'     : 1,
+          'param2'     : 'abc',
+          'param3'     : [1, 2, 3],
+          'abc_param1' : True,
+          'abc_param2' : 0.4}),
+        ({'param2'     : 'def',
+          'abc_param2' : '0.7'},
+         {'param1'     : 1,
+          'param2'     : 'def',
+          'param3'     : [1, 2, 3],
+          'abc_param1' : True,
+          'abc_param2' : 0.7}),
+        ({'param1'     : '8',
+          'abc_param1' : 'false'},
+         {'param1'     : 8,
+          'param2'     : 'abc',
+          'param3'     : [1, 2, 3],
+          'abc_param1' : False,
+          'abc_param2' : 0.4}),
+        ({'param4'     : 'ghi',
+          'param5'     : '[1,2,3,4]',
+          'abc_param3' : '7.5'},
+         {'param1'     : 1,
+          'param2'     : 'abc',
+          'param3'     : [1, 2, 3],
+          'param4'     : 'ghi',
+          'param5'     : [1, 2, 3, 4],
+          'abc_param1' : True,
+          'abc_param2' : 0.4,
+          'abc_param3' : 7.5}),
+    ]
+
+    # Overrides should be rejected when
+    # their type (as inferred by tomllib
+    # parsing) does not match the type of
+    # the stored value.
+    fail_tests = [
+        {'param1'     : 'abc'},
+        {'param3'     : '1.5'},
+        {'abc_param1' : 'abc'},
+        {'abc_param2' : 'abc'},
+    ]
+
+    with tempdir():
+        open('config.toml', 'wt').write(configtoml)
+
+        for overrides, expected in pass_tests:
+            cfg = config.Config('.', overrides=overrides)
+            assert dicts_equal(cfg, expected)
+
+        for overrides in fail_tests:
+            with pytest.raises(ValueError):
+                config.Config('.', overrides=overrides)
+
+
+def test_Config_access():
+    configtoml = tw.dedent("""
+    param1 = 1
+    param2 = 'abc'
+    param3 = [1, 2, 3]
+    [abc]
+    param1 = true
+    param2 = 0.4
+    """).strip()
+
+    exp = {
+        'param1'     : 1,
+        'param2'     : 'abc',
+        'param3'     : [1, 2, 3],
+        'abc_param1' : True,
+        'abc_param2' : 0.4,
+    }
+
+    with tempdir() as td:
+        open('config.toml', 'wt').write(configtoml)
+
+        cfg = config.Config('.')
+
+        assert cfg.cfgdir == td
+        assert len(cfg) == len(exp)
+
+        assert sorted(cfg.keys())  == sorted(exp.keys())
+        assert sorted(cfg.items()) == sorted(exp.items())
+
+        # Order of exp is intentional, to
+        # match config construction order.
+        assert [v1 == v2 for v1, v2 in zip(cfg.values(), exp.values())]
+
+        for k, v in exp.items():
+            assert k in cfg
+            assert cfg[k]          == v
+            assert getattr(cfg, k) == v
+            assert cfg.get(k)      == v
+
+        assert cfg.getall(param1=0, param2=1, param4=3) == \
+            {'param1' : 1, 'param2' : 'abc', 'param4' : 3}
+
+        assert cfg.gettuple(param1=0, param2=1, param4=3) == (1, 'abc', 3)
+
+        assert cfg.getall('abc', param1=0, param2=1, param3=3) == \
+            {'param1' : True, 'param2' : 0.4, 'param3' : 3}
+        assert cfg.gettuple('abc', param1=0, param2=1, param4=3) == \
+            (True, 0.4, 3)
diff --git a/bip/tests/test_import_all.py b/bip/tests/test_import_all.py
new file mode 100644
index 0000000000000000000000000000000000000000..dde12ee1e65603104d156b462310ccf54068d338
--- /dev/null
+++ b/bip/tests/test_import_all.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+
+
+
+import pkgutil
+import importlib
+import bip
+
+
+def test_importall():
+
+    def recurse(module):
+
+        path    = module.__path__
+        name    = module.__name__
+        submods = list(pkgutil.iter_modules(path, f'{name}.'))
+
+        for i, (spath, smodname, ispkg) in enumerate(submods):
+
+            submod = importlib.import_module(smodname)
+
+            if ispkg:
+                recurse(submod)
+
+    recurse(bip)
diff --git a/bip/utils/__init__.py b/bip/utils/__init__.py
index 4cbd17a5c5f00a3154e8a5e3f3c9c8d9ecdb6a74..e97e10008947ae74a4c5693d70acae53da762d64 100644
--- a/bip/utils/__init__.py
+++ b/bip/utils/__init__.py
@@ -1,13 +1,45 @@
-import shutil
-from fsl.wrappers.wrapperutils import cmdwrapper
-
-installed_sienax = shutil.which('bb_sienax')
-
-if installed_sienax:
-    @cmdwrapper
-    def bb_sienax(input, output=None, **kwargs):
-        return ['bb_sienax', input, output]
-else:
-    def bb_sienax(input, output=None, **kwargs):
-        from . import sienax
-        sienax.bb_sienax(input, output)
+#!/usr/bin/env python
+"""The bip.utils package contains a range of miscellaneous utilities. """
+
+
+import            contextlib
+import            logging
+import os.path as op
+import            os
+import            time
+
+
+log = logging.getLogger(__name__)
+
+
+@contextlib.contextmanager
+def lockdir(dirname, delay=5):
+    """Lock a directory for exclusive access.
+
+    Primitive mechanism by which concurrent access to a directory can be
+    prevented. Attempts to create a semaphore file in the directory, but waits
+    if that file already exists. Removes the file when finished.
+    """
+
+    lockfile = op.join(dirname, '.fsl_ci.lockdir')
+
+    while True:
+        try:
+            log.debug(f'Attempting to lock %s for exclusive access.', dirname)
+            fd = os.open(lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
+            break
+        except OSError as e:
+            if e.errno != errno.EEXIST:
+                raise e
+            log.debug('%s is already locked - trying again '
+                      'in %s seconds ...', dirname, delay)
+        time.sleep(10)
+
+    log.debug('Exclusive access acquired for %s ...', dirname)
+    try:
+        yield
+
+    finally:
+        log.debug('Relinquishing lock on %s', dirname)
+        os.close( fd)
+        os.unlink(lockfile)
diff --git a/bip/utils/config.py b/bip/utils/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..317b8463387dba88b92cb4fdbc2feff7fba8cb7f
--- /dev/null
+++ b/bip/utils/config.py
@@ -0,0 +1,476 @@
+#!/usr/bin/env python
+"""This module provides the BIP Config class. The Config class is used
+throughout BIP for accessing settings stored in BIP configuration files.
+
+
+# Configuration file format
+
+TODO
+
+# Selectors - alternate setting values for specific scenarios
+
+TODO
+
+# Type validation
+
+TODO
+"""
+
+
+import              copy
+import              glob
+import functools as ft
+import itertools as it
+import              logging
+import              os
+import os.path   as op
+import              operator
+
+
+# python >= 3.11
+try:
+    import tomllib
+
+# python < 3.11
+except ImportError:
+    import tomli as tomllib
+
+import bip
+
+
+log = logging.getLogger(__name__)
+
+
+MAIN_CONFIG_FILE_NAME = 'config.toml'
+"""Name of the primary BIP configuration file. This file contains global
+settings, and may contain pipeline-specific settings which take precedence
+over equivalent settings in secondary configuration files.
+"""
+
+
+DEFAULT_CONFIG_DIRECTORY = op.join(op.dirname(bip.__file__), 'data', 'config')
+"""Default configuration directory. Used when a Config object is created with
+specifying a directory.
+"""
+
+
+def nested_lookup(d, key):
+    """Look up a value in a nested dictionary based on the given key.
+    For example, imagine we have a dictionary d:
+
+        {'a' : {'b' : {'c' : {'d' : 'e'}}}}
+
+    We can retrieve 'e' like so:
+
+        nested_lookup(d, ['a', 'b', 'c', 'd'])
+    """
+
+    if key[0] not in d:
+        raise KeyError()
+    if not isinstance(d, dict):
+        return d
+
+    d = d[key[0]]
+
+    if len(key) == 1:
+        return d
+
+    return nested_lookup(d, key[1:])
+
+
+def flatten_dictionary(d, nlevels=1):
+    """Flattens a nested dictionary.
+
+    Given a dictionary such as:
+
+        {
+            'k1' : 'v1',
+            'k2' : {
+                'sk1' : 'sv1',
+                'sk2' : 'sv2'
+            }
+        }
+
+    this function will adjust the dictionary to have structure:
+        {
+            'k1'     : 'v1',
+            'k2_sk1' : 'sv1'
+            'k2_sk2' : 'sv2'
+        }
+
+    If nlevels is 1 (the default), the function will only flatten
+    sub-dictionaries that are one-level deep.
+    """
+
+    d = copy.deepcopy(d)
+
+    for key, val in list(d.items()):
+        if not isinstance(val, dict):
+            continue
+
+        if nlevels > 1:
+            val = flatten_dictionary(val, nlevels - 1)
+
+        for subkey, subval in val.items():
+            subkey    = f'{key}_{subkey}'
+            d[subkey] = subval
+
+        d.pop(key)
+
+    return d
+
+
+def parse_override_value(name, origval, val):
+    """Use tomllib to coerce a string to a suitable type."""
+
+    # If the configuration file value is a string,
+    # return the override value unchanged
+    if isinstance(origval, str):
+        return val
+
+    # Otherwise use tomllib to convert the value.
+    # Leave as a string on failures, but emit
+    # a warning.
+    try:
+        return tomllib.loads(f'val = {val}')['val']
+    except Exception:
+        log.warning('Cannot parse override value for %s (%s) - '
+                    'leaving value as a string.', name, val)
+        return val
+
+
+class Config:
+    """The Config class is a dictionary containing BIP settings. A Config
+    can be created by specifying the directory which contains all BIP
+    configuration files, e.g.:
+
+        cfg = Config('path/to/config/dir/')
+    """
+
+
+    @staticmethod
+    def config_file_identifier(fname):
+        """Return an identifier for the given BIP configuration file.
+
+        BIP configuration files are named like so:
+
+            [<prefix>.]<identifier>.toml
+
+        For example:
+            config.toml         # identifier: "config"
+            T1_struct.toml      # identifier: "T1_struct"
+            01.fMRI_task.toml   # identifier: "fMRI_task", prefix: "01"
+            02.fMRI_task.toml   # identifier: "fMRI_task", prefix: "02"
+            03.fMRI_rest.toml   # identifier: "fMRI_rest", prefix: "03"
+
+        BIP settings may be grouped according to the file identifier - the file
+        prefix is used solely for enforcing a particular ordering.
+        """
+        base = op.basename(fname)
+        if base == MAIN_CONFIG_FILE_NAME:
+            return 'config'
+        base = base.removesuffix('.toml')
+        # Drop file prefix if present
+        return base.split('.')[-1]
+
+
+    @staticmethod
+    def list_config_files(cfgdir):
+        """Return a list of all BIP configuration files contained in cfgdir.
+        The files are sorted according to the order in which they should be
+        loaded.
+
+        A BIP configuration is a directory containing one or more .toml files.
+
+        BIP configuration files are intended to be loaded in alphabetical
+        order, with settings in later files overwriting settings in earlier
+        files.
+
+        The "config.toml" file must be loaded last, so that settings
+        contained within it take precedence over all other files.
+        """
+
+        cfgfiles = glob.glob(op.join(cfgdir, '*.toml'))
+        cfgfiles = sorted(cfgfiles, key=str.lower)
+        maincfg  = op.join(cfgdir, MAIN_CONFIG_FILE_NAME)
+
+        # make sure main config is loaded last
+        if maincfg in cfgfiles:
+            cfgfiles.remove(maincfg)
+            cfgfiles.append(maincfg)
+
+        return cfgfiles
+
+
+    @staticmethod
+    def resolve_selectors(settings, selectors):
+        """Resolves and applies any "selector" configuration settings.
+
+        A BIP configuration file may contain one default value for a
+        setting, but may also contain alternate values for that setting
+        which should be used in certain scenarios. Each alternate value
+        is associated with a set of "selector" parameters, which are
+        just key-value pairs.
+
+        For example, a file may contain a default value for a setting called
+        "param1", and an alternate value for param1 to be used when the
+        "subject" selector parameter is set to "12345":
+
+            param1               = 0.5
+            subject.12345.param1 = 0.4
+
+        If resolve_selectors is given these settings along with subject=12345,
+        the default value for param1 will be replaced with the selector value.
+
+        Multiple selector parameters may be specified - in the configuration
+        file, the selector parameters must be ordered alphabetically. For
+        example, if we have selector parameters "subject" and "visit", the
+        alternate values for subject=123 and visit=2 must be specified as:
+
+            param1                     = 0.5
+            subject.123.visit.2.param1 = 0.3
+
+        But cannot be specified as:
+
+            param1                     = 0.5
+            visit.2.subject.123.param1 = 0.3
+        """
+
+        settings = copy.deepcopy(settings)
+
+        # Take the selector parameters, and generate all possible
+        # candidate keys - e.g. {'subject' : '123', 'visit' : '1'}
+        # will result in:
+        #
+        #   ['subject', '123']
+        #   ['visit',   '1']
+        #   ['subject', '123', 'visit', '1']
+        kvps     = [[str(k), str(v)] for k, v in selectors.items()]
+        patterns = [it.combinations(kvps, i) for i in range(1, len(kvps) + 1)]
+        patterns = it.chain(*patterns)
+        patterns = [ft.reduce(operator.add, p) for p in patterns]
+
+        for pat in patterns:
+            try:
+                val = nested_lookup(settings, pat)
+            except KeyError:
+                continue
+
+            # If an entry with a selector name as key is
+            # present, and is not a dictionary (e.g.
+            # "subject = 1"), this is probably an error
+            # in the config file, or the selectors the
+            # user has provided. Warn and carry on
+            if not isinstance(val, dict):
+                log.warning('Ignoring primitive selector value (%s = %s)',
+                            pat, val)
+                continue
+
+            log.debug('Updating settings from selector: %s', pat)
+            for k, v in val.items():
+
+                # If we have a configuration like:
+                #
+                #   subject.123.visit.1.param = 0.5
+                #   visit.2.param             = 0.5
+                #
+                # and we are processing ['subject', '123'], we
+                # do not want to clobber the original 'visit'.
+                if k not in selectors:
+                    settings[k] = v
+
+        return settings
+
+
+    @staticmethod
+    def apply_overrides(settings, overrides=None):
+        """Override some settings with override values.
+
+        Any value read from the configuration files can be overridden.
+
+        If an override value has an incompatible type with the value from the
+        configuration file, a ValueError is raised.
+        """
+
+        def types_match(old, new):
+            """Return True if old and new are of compatible types."""
+            if isinstance(old, (float, int)):
+                return isinstance(new, (float, int))
+            else:
+                return isinstance(new, type(old))
+
+        settings = copy.deepcopy(settings)
+
+        for key, val in overrides.items():
+            origval = settings.get(key, None)
+
+            # Coerce strings to a toml type
+            if isinstance(val, str):
+                val = parse_override_value(key, origval, val)
+
+            # Reject the new value if it has a
+            # different type to the original value
+            if origval is not None and not types_match(origval, val):
+                raise ValueError(f'{key}: override value has wrong type (got '
+                                 f'{type(val)}, expected {type(origval)}')
+
+            log.debug('Overriding %s (%s -> %s)', key, origval, val)
+            settings[key] = val
+
+        return settings
+
+
+    @staticmethod
+    def load_config_file(cfgfile, selectors=None):
+        """Load a BIP configuration file. The file is assumed to be a TOML
+        file, named as described in the config_file_identifier documentation.
+
+        All settings contained in the file are re-labelled with the file
+        identifier. For example, if the file is called "T1_struct.toml", and
+        contains:
+
+            bet_f        = 0.5
+            fast_classes = 3
+
+        the loaded dictionary will contain:
+
+            'T1_struct_bet_f'        : 0.5
+            'T1_struct_fast_classes' : 3
+
+        Re-labelling is not applied to the main "config.toml" configuration
+        file.
+        """
+
+        if selectors is None:
+            selectors = {}
+
+        with open(cfgfile, 'rb') as f:
+            settings = tomllib.load(f)
+
+        ident     = Config.config_file_identifier(cfgfile)
+        selectors = {k : selectors[k] for k in sorted(selectors.keys())}
+        settings  = Config.resolve_selectors(settings, selectors)
+
+        # Any tables in the main config file are relabelled
+        # to "<tablename>_<setting>.  For example, if the
+        # main config.toml contains:
+        #
+        #     some_global_param = 75
+        #
+        #     [T1_struct]
+        #     bet_f = 0.2
+        #
+        # bet_f is relabelled to T1_struct_bet_f
+        if ident == 'config':
+            settings = flatten_dictionary(settings, 1)
+
+        # All settings in secondary configuration files
+        # are relabelled to "<ident>_<setting>". For example,
+        # if T1_struct.toml contains:
+        #
+        #     bet_f = 0.5
+        #
+        # bet_f is relabelled to T1_struct_bet_f
+        else:
+            ident    = f'{ident}_'
+            settings = {f'{ident}{k}' : v for k, v in settings.items()}
+
+        return settings
+
+
+    def __init__(self, cfgdir=None, selectors=None, overrides=None):
+        """Create a Config object. Read configuration files from cfgdir.
+
+        Selectors are applied using Config.resolve_selectors.
+        Override values are applied using Config.apply_overrides.
+        """
+
+        if selectors is None: selectors = {}
+        if overrides is None: overrides = {}
+        if cfgdir    is None: cfgdir    = DEFAULT_CONFIG_DIRECTORY
+
+        cfgfiles = Config.list_config_files(cfgdir)
+
+        if len(cfgfiles) == 0:
+            log.warning('No BIP configuration files found in %s', cfgdir)
+
+        settings = {}
+
+        for fname in cfgfiles:
+            log.debug('Loading settings from %s', fname)
+            settings.update(Config.load_config_file(fname, selectors))
+
+        settings = Config.apply_overrides(settings, overrides)
+
+        self.__settings = settings
+        self.__cfgdir   = op.abspath(cfgdir)
+
+
+    @property
+    def cfgdir(self):
+        """Return the directory that the config files were loaded from. """
+        return self.__cfgdir
+
+
+    def __len__(self):
+        """Return the number of entries in this Config. """
+        return len(self.__settings)
+
+
+    def __contains__(self, key):
+        """Return True if a configuration item with key exists. """
+        return key in self.__settings
+
+
+    def __getitem__(self, key):
+        """Return the configuration item with the specified key. """
+        return self.__settings[key]
+
+
+    def __getattr__(self, key):
+        """Return the configuration item with the specified key. """
+        return self[key]
+
+
+    def get(self, key, default=None):
+        """Return value associated with key.
+        Return default if there is no value associated with key.
+        """
+        if key not in self:
+            return default
+        return self[key]
+
+
+    def getall(self, prefix=None, **kwargs):
+        """Return all requested values with given key prefix.
+        The specified default value is used for any keys which are not present.
+        The values are returned as a dictionary, with the keys un-prefixed.
+        """
+        if prefix is None: prefix = ''
+        else:              prefix = f'{prefix}_'
+
+        values = {}
+        for k, v in kwargs.items():
+            values[k] = self.get(f'{prefix}{k}', v)
+
+        return values
+
+
+    def gettuple(self, prefix=None, **kwargs):
+        """Same as getall, but values are returned as a tuple. """
+        return tuple(self.getall(prefix, **kwargs).values())
+
+
+    def keys(self):
+        """Return all keys present in the config. """
+        return self.__settings.keys()
+
+
+    def values(self):
+        """Return all values present in the config. """
+        return self.__settings.values()
+
+
+    def items(self):
+        """Return all key-value pairs present in the config. """
+        return self.__settings.items()
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..d6008ea4b6545a2dd51f306cb81f2f0e24fab6d2
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,7 @@
+[tool:pytest]
+testpaths = bip/tests
+addopts   = -v --cov=bip
+
+[coverage:run]
+source = bip
+omit   = bip/tests/*
\ No newline at end of file