Repository: wryfi/cfitall

Issue:
requesting a list via config.get has issues with environment variables
There is currently no bug-free way to specify a single-element list as an environment variable. 

Setting the environment variable to a string, and explicitly asking the config manager for a list, produces this:

```
> export TEST__FOO__ALIST=abc
> export TEST__FOO__BLIST=abc,def
> ipython
...
In [1]: from cfitall.config import ConfigManager

In [2]: config = ConfigManager("test")

In [3]: config.get("foo.alist", list)
Out[3]: ['a', 'b', 'c']

In [4]: config.get("foo.blist", list)
Out[4]: ['abc', 'def']
```

Appending a comma to the environment variable returns a two-item list, with an empty second item:

```
> export TEST__FOO__ALIST=abc,
> ipython
...
In [1]: from cfitall.config import ConfigManager

In [2]: config = ConfigManager("test")

In [3]: config.get("foo.alist", list)
Out[3]: ['abc', '']
```

Please devise a better syntax that accommodates single-element lists as environment variables.

Discussion:

Patch:
diff --git a/.gitignore b/.gitignore
index 249a33c..c0b7332 100644
--- a/.gitignore
+++ b/.gitignore
@@ -108,6 +108,7 @@ instance/
 
 # Sphinx documentation
 docs/_build/
+docs/_autosummary
 
 # PyBuilder
 target/
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 626b977..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-language: python
-python:
-  - 3.6
-  - 3.7
-  - 3.8
-install:
-  - 'pip install pipenv'
-  - 'pipenv install'
-script: 'python -m unittest'
-
-
diff --git a/ChangeLog b/ChangeLog
index 599795a..75f64e4 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,6 +1,22 @@
 CHANGES
 =======
 
+* documentation
+* fix circular imports when running unit tests from root directory
+* pluggable providers
+* type utils
+* new modular architecture
+* modularize environment variable management
+* README
+* update README and tests
+* use brackets for lists in env vars
+* add test for bug
+
+v1.0.5
+------
+
+* changelog
+* update Pipfile.lock
 * reformatted all code w/ black
 * update deprecated import and reformat w/ black
 * Bump bleach from 3.2.1 to 3.3.0
diff --git a/Pipfile b/Pipfile
index edfe6c6..9c97c06 100644
--- a/Pipfile
+++ b/Pipfile
@@ -8,6 +8,12 @@ pbr = "*"
 cfitall = {editable = true,path = "."}
 ipython = "*"
 twine = "*"
+coverage = "*"
+mypy = "*"
+sphinx = "*"
+sphinx-autodoc-typehints = "*"
+sphinx-rtd-theme = "*"
 
 [packages]
 pyyaml = "*"
+types-pyyaml = "*"
diff --git a/Pipfile.lock b/Pipfile.lock
index 11082fb..e7ac2cb 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "6391e145e4408223cfdd542ac98555a6d09484ea9db3a487bfd1ee1f60043259"
+            "sha256": "4f1c1090d1581b14f12bd00b8a9c02635415292ff27bda6805f94a36e4887902"
         },
         "pipfile-spec": 6,
         "requires": {},
@@ -52,16 +52,31 @@
             ],
             "index": "pypi",
             "version": "==6.0"
+        },
+        "types-pyyaml": {
+            "hashes": [
+                "sha256:2fd21310870addfd51db621ad9f3b373f33ee3cbb81681d70ef578760bd22d35",
+                "sha256:464e050914f3d1d83a8c038e1cf46da5cb96b7cd02eaa096bcaa03675edd8a2e"
+            ],
+            "index": "pypi",
+            "version": "==6.0.5"
         }
     },
     "develop": {
+        "alabaster": {
+            "hashes": [
+                "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359",
+                "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"
+            ],
+            "version": "==0.7.12"
+        },
         "appnope": {
             "hashes": [
-                "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442",
-                "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"
+                "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24",
+                "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"
             ],
             "markers": "sys_platform == 'darwin'",
-            "version": "==0.1.2"
+            "version": "==0.1.3"
         },
         "asttokens": {
             "hashes": [
@@ -70,6 +85,14 @@
             ],
             "version": "==2.0.5"
         },
+        "babel": {
+            "hashes": [
+                "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9",
+                "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==2.9.1"
+        },
         "backcall": {
             "hashes": [
                 "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e",
@@ -77,42 +100,13 @@
             ],
             "version": "==0.2.0"
         },
-        "black": {
-            "hashes": [
-                "sha256:07e5c049442d7ca1a2fc273c79d1aecbbf1bc858f62e8184abe1ad175c4f7cc2",
-                "sha256:0e21e1f1efa65a50e3960edd068b6ae6d64ad6235bd8bfea116a03b21836af71",
-                "sha256:1297c63b9e1b96a3d0da2d85d11cd9bf8664251fd69ddac068b98dc4f34f73b6",
-                "sha256:228b5ae2c8e3d6227e4bde5920d2fc66cc3400fde7bcc74f480cb07ef0b570d5",
-                "sha256:2d6f331c02f0f40aa51a22e479c8209d37fcd520c77721c034517d44eecf5912",
-                "sha256:2ff96450d3ad9ea499fc4c60e425a1439c2120cbbc1ab959ff20f7c76ec7e866",
-                "sha256:3524739d76b6b3ed1132422bf9d82123cd1705086723bc3e235ca39fd21c667d",
-                "sha256:35944b7100af4a985abfcaa860b06af15590deb1f392f06c8683b4381e8eeaf0",
-                "sha256:373922fc66676133ddc3e754e4509196a8c392fec3f5ca4486673e685a421321",
-                "sha256:5fa1db02410b1924b6749c245ab38d30621564e658297484952f3d8a39fce7e8",
-                "sha256:6f2f01381f91c1efb1451998bd65a129b3ed6f64f79663a55fe0e9b74a5f81fd",
-                "sha256:742ce9af3086e5bd07e58c8feb09dbb2b047b7f566eb5f5bc63fd455814979f3",
-                "sha256:7835fee5238fc0a0baf6c9268fb816b5f5cd9b8793423a75e8cd663c48d073ba",
-                "sha256:8871fcb4b447206904932b54b567923e5be802b9b19b744fdff092bd2f3118d0",
-                "sha256:a7c0192d35635f6fc1174be575cb7915e92e5dd629ee79fdaf0dcfa41a80afb5",
-                "sha256:b1a5ed73ab4c482208d20434f700d514f66ffe2840f63a6252ecc43a9bc77e8a",
-                "sha256:c8226f50b8c34a14608b848dc23a46e5d08397d009446353dad45e04af0c8e28",
-                "sha256:ccad888050f5393f0d6029deea2a33e5ae371fd182a697313bdbd835d3edaf9c",
-                "sha256:dae63f2dbf82882fa3b2a3c49c32bffe144970a573cd68d247af6560fc493ae1",
-                "sha256:e2f69158a7d120fd641d1fa9a921d898e20d52e44a74a6fbbcc570a62a6bc8ab",
-                "sha256:efbadd9b52c060a8fc3b9658744091cb33c31f830b3f074422ed27bad2b18e8f",
-                "sha256:f5660feab44c2e3cb24b2419b998846cbb01c23c7fe645fee45087efa3da2d61",
-                "sha256:fdb8754b453fb15fad3f72cd9cad3e16776f0964d67cf30ebcbf10327a3777a3"
-            ],
-            "markers": "python_full_version >= '3.6.2'",
-            "version": "==22.1.0"
-        },
         "bleach": {
             "hashes": [
-                "sha256:0900d8b37eba61a802ee40ac0061f8c2b5dee29c1927dd1d233e075ebf5a71da",
-                "sha256:4d2651ab93271d1129ac9cbc679f524565cc8a1b791909c4a51eac4446a15994"
+                "sha256:08a1fe86d253b5c88c92cc3d810fd8048a16d15762e1e5b74d502256e5926aa1",
+                "sha256:c6d6cc054bdc9c83b48b8083e236e5f00f238428666d2ce2e083eaa5fd568565"
             ],
-            "markers": "python_version >= '3.6'",
-            "version": "==4.1.0"
+            "markers": "python_version >= '3.7'",
+            "version": "==5.0.0"
         },
         "certifi": {
             "hashes": [
@@ -133,21 +127,59 @@
             "markers": "python_version >= '3'",
             "version": "==2.0.12"
         },
-        "click": {
+        "commonmark": {
             "hashes": [
-                "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1",
-                "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"
+                "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60",
+                "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"
             ],
-            "markers": "python_version >= '3.6'",
-            "version": "==8.0.4"
+            "version": "==0.9.1"
         },
-        "colorama": {
-            "hashes": [
-                "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
-                "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
+        "coverage": {
+            "hashes": [
+                "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9",
+                "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d",
+                "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf",
+                "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7",
+                "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6",
+                "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4",
+                "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059",
+                "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39",
+                "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536",
+                "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac",
+                "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c",
+                "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903",
+                "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d",
+                "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05",
+                "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684",
+                "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1",
+                "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f",
+                "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7",
+                "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca",
+                "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad",
+                "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca",
+                "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d",
+                "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92",
+                "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4",
+                "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf",
+                "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6",
+                "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1",
+                "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4",
+                "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359",
+                "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3",
+                "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620",
+                "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512",
+                "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69",
+                "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2",
+                "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518",
+                "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0",
+                "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa",
+                "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4",
+                "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e",
+                "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1",
+                "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"
             ],
-            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
-            "version": "==0.4.4"
+            "index": "pypi",
+            "version": "==6.3.2"
         },
         "decorator": {
             "hashes": [
@@ -159,18 +191,18 @@
         },
         "docutils": {
             "hashes": [
-                "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c",
-                "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"
+                "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125",
+                "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"
             ],
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
-            "version": "==0.18.1"
+            "version": "==0.17.1"
         },
         "executing": {
             "hashes": [
-                "sha256:32fc6077b103bd19e6494a72682d66d5763cf20a106d5aa7c5ccbea4e47b0df7",
-                "sha256:c23bf42e9a7b9b212f185b1b2c3c91feb895963378887bb10e64a2e612ec0023"
+                "sha256:c6554e21c6b060590a6d3be4b82fb78f8f0194d809de5ea7df1c093763311501",
+                "sha256:d1eef132db1b83649a3905ca6dd8897f71ac6f8cac79a7e58a1a09cf137546c9"
             ],
-            "version": "==0.8.2"
+            "version": "==0.8.3"
         },
         "idna": {
             "hashes": [
@@ -180,21 +212,29 @@
             "markers": "python_version >= '3'",
             "version": "==3.3"
         },
+        "imagesize": {
+            "hashes": [
+                "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c",
+                "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==1.3.0"
+        },
         "importlib-metadata": {
             "hashes": [
-                "sha256:175f4ee440a0317f6e8d81b7f8d4869f93316170a65ad2b007d2929186c8052c",
-                "sha256:e0bc84ff355328a4adfc5240c4f211e0ab386f80aa640d1b11f0618a1d282094"
+                "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6",
+                "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"
             ],
             "markers": "python_version >= '3.7'",
-            "version": "==4.11.1"
+            "version": "==4.11.3"
         },
         "ipython": {
             "hashes": [
-                "sha256:ab564d4521ea8ceaac26c3a2c6e5ddbca15c8848fd5a5cc325f960da88d42974",
-                "sha256:c503a0dd6ccac9c8c260b211f2dd4479c042b49636b097cc9a0d55fe62dff64c"
+                "sha256:1b672bfd7a48d87ab203d9af8727a3b0174a4566b4091e9447c22fb63ea32857",
+                "sha256:70e5eb132cac594a34b5f799bd252589009905f05104728aea6a403ec2519dc1"
             ],
             "index": "pypi",
-            "version": "==8.0.1"
+            "version": "==8.2.0"
         },
         "jedi": {
             "hashes": [
@@ -204,6 +244,14 @@
             "markers": "python_version >= '3.6'",
             "version": "==0.18.1"
         },
+        "jinja2": {
+            "hashes": [
+                "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119",
+                "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==3.1.1"
+        },
         "keyring": {
             "hashes": [
                 "sha256:9012508e141a80bd1c0b6778d5c610dd9f8c464d75ac6774248500503f972fb9",
@@ -212,6 +260,52 @@
             "markers": "python_version >= '3.7'",
             "version": "==23.5.0"
         },
+        "markupsafe": {
+            "hashes": [
+                "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003",
+                "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88",
+                "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5",
+                "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7",
+                "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a",
+                "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603",
+                "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1",
+                "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135",
+                "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247",
+                "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6",
+                "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601",
+                "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77",
+                "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02",
+                "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e",
+                "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63",
+                "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f",
+                "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980",
+                "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b",
+                "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812",
+                "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff",
+                "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96",
+                "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1",
+                "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925",
+                "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a",
+                "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6",
+                "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e",
+                "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f",
+                "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4",
+                "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f",
+                "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3",
+                "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c",
+                "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a",
+                "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417",
+                "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a",
+                "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a",
+                "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37",
+                "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452",
+                "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933",
+                "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a",
+                "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==2.1.1"
+        },
         "matplotlib-inline": {
             "hashes": [
                 "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee",
@@ -220,6 +314,35 @@
             "markers": "python_version >= '3.5'",
             "version": "==0.1.3"
         },
+        "mypy": {
+            "hashes": [
+                "sha256:0e2dd88410937423fba18e57147dd07cd8381291b93d5b1984626f173a26543e",
+                "sha256:10daab80bc40f84e3f087d896cdb53dc811a9f04eae4b3f95779c26edee89d16",
+                "sha256:17e44649fec92e9f82102b48a3bf7b4a5510ad0cd22fa21a104826b5db4903e2",
+                "sha256:1a0459c333f00e6a11cbf6b468b870c2b99a906cb72d6eadf3d1d95d38c9352c",
+                "sha256:246e1aa127d5b78488a4a0594bd95f6d6fb9d63cf08a66dafbff8595d8891f67",
+                "sha256:2b184db8c618c43c3a31b32ff00cd28195d39e9c24e7c3b401f3db7f6e5767f5",
+                "sha256:2bc249409a7168d37c658e062e1ab5173300984a2dada2589638568ddc1db02b",
+                "sha256:3841b5433ff936bff2f4dc8d54cf2cdbfea5d8e88cedfac45c161368e5770ba6",
+                "sha256:4c3e497588afccfa4334a9986b56f703e75793133c4be3a02d06a3df16b67a58",
+                "sha256:5bf44840fb43ac4074636fd47ee476d73f0039f4f54e86d7265077dc199be24d",
+                "sha256:64235137edc16bee6f095aba73be5334677d6f6bdb7fa03cfab90164fa294a17",
+                "sha256:6776e5fa22381cc761df53e7496a805801c1a751b27b99a9ff2f0ca848c7eca0",
+                "sha256:6ce34a118d1a898f47def970a2042b8af6bdcc01546454726c7dd2171aa6dfca",
+                "sha256:6f6ad963172152e112b87cc7ec103ba0f2db2f1cd8997237827c052a3903eaa6",
+                "sha256:6f7106cbf9cc2f403693bf50ed7c9fa5bb3dfa9007b240db3c910929abe2a322",
+                "sha256:7742d2c4e46bb5017b51c810283a6a389296cda03df805a4f7869a6f41246534",
+                "sha256:9521c1265ccaaa1791d2c13582f06facf815f426cd8b07c3a485f486a8ffc1f3",
+                "sha256:a1b383fe99678d7402754fe90448d4037f9512ce70c21f8aee3b8bf48ffc51db",
+                "sha256:b840cfe89c4ab6386c40300689cd8645fc8d2d5f20101c7f8bd23d15fca14904",
+                "sha256:d8d3ba77e56b84cd47a8ee45b62c84b6d80d32383928fe2548c9a124ea0a725c",
+                "sha256:dcd955f36e0180258a96f880348fbca54ce092b40fbb4b37372ae3b25a0b0a46",
+                "sha256:e865fec858d75b78b4d63266c9aff770ecb6a39dfb6d6b56c47f7f8aba6baba8",
+                "sha256:edf7237137a1a9330046dbb14796963d734dd740a98d5e144a3eb1d267f5f9ee"
+            ],
+            "index": "pypi",
+            "version": "==0.942"
+        },
         "mypy-extensions": {
             "hashes": [
                 "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
@@ -243,13 +366,6 @@
             "markers": "python_version >= '3.6'",
             "version": "==0.8.3"
         },
-        "pathspec": {
-            "hashes": [
-                "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a",
-                "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"
-            ],
-            "version": "==0.9.0"
-        },
         "pbr": {
             "hashes": [
                 "sha256:27108648368782d07bbf1cb468ad2e2eeef29086affd14087a6d04b7de8af4ec",
@@ -280,21 +396,13 @@
             ],
             "version": "==1.8.2"
         },
-        "platformdirs": {
-            "hashes": [
-                "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d",
-                "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"
-            ],
-            "markers": "python_version >= '3.7'",
-            "version": "==2.5.1"
-        },
         "prompt-toolkit": {
             "hashes": [
-                "sha256:30129d870dcb0b3b6a53efdc9d0a83ea96162ffd28ffe077e94215b233dc670c",
-                "sha256:9f1cd16b1e86c2968f2519d7fb31dd9d669916f515612c269d14e9ed52b51650"
+                "sha256:62291dad495e665fca0bda814e342c69952086afb0f4094d0893d357e5c78752",
+                "sha256:bd640f60e8cecd74f0dc249713d433ace2ddc62b65ee07f96d358e0b152b6ea7"
             ],
             "markers": "python_full_version >= '3.6.2'",
-            "version": "==3.0.28"
+            "version": "==3.0.29"
         },
         "ptyprocess": {
             "hashes": [
@@ -320,19 +428,26 @@
         },
         "pyparsing": {
             "hashes": [
-                "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea",
-                "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"
+                "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954",
+                "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"
             ],
-            "markers": "python_version >= '3.6'",
-            "version": "==3.0.7"
+            "markers": "python_full_version >= '3.6.8'",
+            "version": "==3.0.8"
+        },
+        "pytz": {
+            "hashes": [
+                "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7",
+                "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"
+            ],
+            "version": "==2022.1"
         },
         "readme-renderer": {
             "hashes": [
-                "sha256:a50a0f2123a4c1145ac6f420e1a348aafefcc9211c846e3d51df05fe3d865b7d",
-                "sha256:b512beafa6798260c7d5af3e1b1f097e58bfcd9a575da7c4ddd5e037490a5b85"
+                "sha256:262510fe6aae81ed4e94d8b169077f325614c0b1a45916a80442c6576264a9c2",
+                "sha256:dfb4d17f21706d145f7473e0b61ca245ba58e810cf9b2209a48239677f82e5b0"
             ],
             "markers": "python_version >= '3.6'",
-            "version": "==32.0"
+            "version": "==34.0"
         },
         "requests": {
             "hashes": [
@@ -357,13 +472,21 @@
             "markers": "python_version >= '3.7'",
             "version": "==2.0.0"
         },
+        "rich": {
+            "hashes": [
+                "sha256:c50f3d253bc6a9bb9c79d61a26d510d74abdf1b16881260fab5edfc3edfb082f",
+                "sha256:ea74bc9dad9589d8eea3e3fd0b136d8bf6e428888955f215824c2894f0da8b47"
+            ],
+            "markers": "python_version < '4' and python_full_version >= '3.6.3'",
+            "version": "==12.2.0"
+        },
         "setuptools": {
             "hashes": [
-                "sha256:2347b2b432c891a863acadca2da9ac101eae6169b1d3dfee2ec605ecd50dbfe5",
-                "sha256:e4f30b9f84e5ab3decf945113119649fec09c1fc3507c6ebffec75646c56e62b"
+                "sha256:7999cbd87f1b6e1f33bf47efa368b224bed5e27b5ef2c4d46580186cbcb1a86a",
+                "sha256:a65e3802053e99fc64c6b3b29c11132943d5b8c8facbcc461157511546510967"
             ],
             "markers": "python_version >= '3.7'",
-            "version": "==60.9.3"
+            "version": "==62.0.0"
         },
         "six": {
             "hashes": [
@@ -373,6 +496,85 @@
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
             "version": "==1.16.0"
         },
+        "snowballstemmer": {
+            "hashes": [
+                "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1",
+                "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"
+            ],
+            "version": "==2.2.0"
+        },
+        "sphinx": {
+            "hashes": [
+                "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6",
+                "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"
+            ],
+            "index": "pypi",
+            "version": "==4.5.0"
+        },
+        "sphinx-autodoc-typehints": {
+            "hashes": [
+                "sha256:081daf53077b4ae1c28347d6d858e13e63aefe3b4aacef79fd717dd60687b470",
+                "sha256:51c7b3f5cb9ccd15d0b52088c62df3094f1abd9612930340365c26def8629a14"
+            ],
+            "index": "pypi",
+            "version": "==1.17.0"
+        },
+        "sphinx-rtd-theme": {
+            "hashes": [
+                "sha256:4d35a56f4508cfee4c4fb604373ede6feae2a306731d533f409ef5c3496fdbd8",
+                "sha256:eec6d497e4c2195fa0e8b2016b337532b8a699a68bcb22a512870e16925c6a5c"
+            ],
+            "index": "pypi",
+            "version": "==1.0.0"
+        },
+        "sphinxcontrib-applehelp": {
+            "hashes": [
+                "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a",
+                "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.0.2"
+        },
+        "sphinxcontrib-devhelp": {
+            "hashes": [
+                "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e",
+                "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.0.2"
+        },
+        "sphinxcontrib-htmlhelp": {
+            "hashes": [
+                "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07",
+                "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==2.0.0"
+        },
+        "sphinxcontrib-jsmath": {
+            "hashes": [
+                "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",
+                "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.0.1"
+        },
+        "sphinxcontrib-qthelp": {
+            "hashes": [
+                "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72",
+                "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.0.3"
+        },
+        "sphinxcontrib-serializinghtml": {
+            "hashes": [
+                "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd",
+                "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.1.5"
+        },
         "stack-data": {
             "hashes": [
                 "sha256:45692d41bd633a9503a5195552df22b583caf16f0b27c4e58c98d88c8b648e12",
@@ -388,14 +590,6 @@
             "markers": "python_version >= '3.7'",
             "version": "==2.0.1"
         },
-        "tqdm": {
-            "hashes": [
-                "sha256:8dd278a422499cd6b727e6ae4061c40b48fce8b76d1ccbf5d34fca9b7f925b0c",
-                "sha256:d359de7217506c9851b7869f3708d8ee53ed70a1b8edbba4dbcb47442592920d"
-            ],
-            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==4.62.3"
-        },
         "traitlets": {
             "hashes": [
                 "sha256:059f456c5a7c1c82b98c2e8c799f39c9b8128f6d0d46941ee118daace9eb70c7",
@@ -406,19 +600,27 @@
         },
         "twine": {
             "hashes": [
-                "sha256:8efa52658e0ae770686a13b675569328f1fba9837e5de1867bfe5f46a9aefe19",
-                "sha256:d0550fca9dc19f3d5e8eadfce0c227294df0a2a951251a4385797c8a6198b7c8"
+                "sha256:6f7496cf14a3a8903474552d5271c79c71916519edb42554f23f42a8563498a9",
+                "sha256:817aa0c0bdc02a5ebe32051e168e23c71a0608334e624c793011f120dbbc05b7"
             ],
             "index": "pypi",
-            "version": "==3.8.0"
+            "version": "==4.0.0"
+        },
+        "typing-extensions": {
+            "hashes": [
+                "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42",
+                "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==4.1.1"
         },
         "urllib3": {
             "hashes": [
-                "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed",
-                "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"
+                "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14",
+                "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"
             ],
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
-            "version": "==1.26.8"
+            "version": "==1.26.9"
         },
         "wcwidth": {
             "hashes": [
@@ -436,11 +638,11 @@
         },
         "zipp": {
             "hashes": [
-                "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d",
-                "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"
+                "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad",
+                "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"
             ],
             "markers": "python_version >= '3.7'",
-            "version": "==3.7.0"
+            "version": "==3.8.0"
         }
     }
 }
diff --git a/README.rst b/README.rst
index 98aed13..70608a7 100644
--- a/README.rst
+++ b/README.rst
@@ -1,26 +1,52 @@
 cfitall :: configure it all
 ===========================
 
-.. image:: https://travis-ci.org/wryfi/cfitall.svg?branch=develop
-    :target: https://travis-ci.org/wryfi/cfitall
-
 cfitall (configure it all) is a configuration management library for
-python applications. It's inspired by and loosely modeled on the
-excellent `viper <https://github.com/spf13/viper>`__ library for go,
-though it doesn't have quite as many features (yet).
-
-It does cover the basics of configuring your application from a combination
-of sources, with a predictable inheritance hierarchy. It does this by
-creating a configuration registry for your application, merging data from the
-following sources to retrieve a requested value:
-
--  default values provided by the developer
--  YAML or JSON configuration file (values override defaults)
+python applications. It's inspired by the
+`viper <https://github.com/spf13/viper>`__ library for go.
+
+cfitall provides a registry for configuring your application from a
+combination of providers, with a configurable inheritance hierarchy, merging
+data from each provider to retrieve a requested value.
+
+The registry itself holds the default configuration values and
+developer-overridden values, and manages additional configuration providers in
+between.
+
+This package includes two such configuration providers:
+
+- The ``EnvironmentProvider``, which parses environment variables for configuration data
+- The ``FilesystemProvider``, which parses json or yaml files for configuration data
+
+Any provider implementing ``cfitall.providers.base.ConfigProviderBase`` can be
+added to the registry by calling ``<registry>.providers.register(<Provider>)``.
+Providers are merged into the final configuration in the order they are
+registered.
+
+The ``ConfigurationRegistry`` requires a single argument: a name that will be
+used to identify and namespace the configuration. With no additional arguments,
+the constructor will return an instance with a FilesystemProvider and an
+EnvironmentProvider configured with default values. The assembled configuration
+from the default configuration will consider the following configuration sources
+in order:
+
+-  default values provided by the developer via ``set_default()``
+-  the first JSON or YAML configuration file found in
+   ``$HOME/.local/etc/{name}/{name}.(json|yaml|yml)`` or
+   ``/etc/{name}/{name}.(json|yaml|yml)`` (values override defaults)
 -  environment variables (override configuration file values & defaults)
 -  ``set()`` calls made by the developer (override everything)
 
-(Support for command-line and k/v store data sources is intended for the future;
-pull requests welcome.)
+To disable the ``EnvironmentProvider`` or ``FilesystemProvider``, or to
+customize the providers at instance construction time, you can pass
+the constructor an optional list of providers. For example,
+``cf = ConfigurationRegistry("test", providers=[])`` will create a
+configuration registry that only provides default values and
+developer overrides.
+
+Alternatively, call the ``providers.deregister(<provider_name>)`` method on
+the registry.
+
 
 Install
 -------
@@ -41,62 +67,62 @@ Example
 
 This example is for a contrived application called ``myapp``.
 
-First, set up a ``config`` module for myapp. Notice that we name our
-config object ``myapp``.
+Since we chose ``myapp`` as our config manager name, our
+configuration file is also named ``myapp.(json|yaml|yml)``. Create a
+configuration file in YAML or JSON and put it in place:
 
 ::
 
-    # myapp/config.py
+    # ~/.local/etc/myapp/myapp.yml
+    global:
+      bar: foo
+      things:
+        - one
+        - two
+        - three
+      person:
+        name: joe
+        hair: brown
+    network:
+      port: 9000
+      listen: '*'
+
+
+Next, set up a ``ConfigurationRegistry`` for myapp. Notice that we name our
+registry ``myapp``.
+
+::
 
-    from cfitall.config import ConfigManager
+    # myapp/__init__.py
+
+    from cfitall.registry import ConfigurationRegistry
 
     # create a configuration registry for myapp
-    config = ConfigManager('myapp')
+    config = ConfigurationRegistry('myapp')
 
     # set some default configuration values
     config.set_default('global.name', 'my fancy application')
-    config.values['defaults']['global']['foo'] = 'bar'
+    config.set_default('global.foo', 'bar')
     config.set_default('network.listen', '127.0.0.1')
 
-    # add a path to search for configuration files
-    config.add_config_path('/Users/wryfi/.config/myapp')
+    # add an additional path to search for configuration files
+    config.providers.filesystem.path.append('/etc/somewhere')
 
-    # read data from first config file found (myapp.json, myapp.yaml, or myapp.yml)
-    config.read_config()
+    # read/update data from enabled providers
+    config.update()
 
 Since we named our config object ``myapp``, environment variables
-beginning with ``MYAPP__`` are searched for values by cfitall.
-Environment variables containing commas are interpreted as
+beginning with ``MYAPP__`` are searched for values by ``EnvironmentProvider``.
+Environment variable values in square brackets are by default parsed as
 comma-delimited lists. Export some environment variables to see this in
 action:
 
 ::
 
     export MYAPP__GLOBAL__NAME="my app from bash"
-    export MYAPP__GLOBAL__THINGS="four,five,six"
+    export MYAPP__GLOBAL__THINGS="[four, five, six]"
     export MYAPP__NETWORK__PORT=8080
 
-Again, since we chose ``myapp`` as our config object name, our
-configuration file is also named ``myapp.(json|yaml|yml)``. Create a
-configuration file in YAML or JSON and put it in one of the paths you
-added to your config registry:
-
-::
-
-    # ~/.config/myapp/myapp.yml
-    global:
-      bar: foo
-      things:
-        - one
-        - two
-        - three
-      person:
-        name: joe
-        hair: brown
-    network:
-      port: 9000
-      listen: '*'
-
 Now you can use your config object to get the configuration data you
 need. You can access the merged configuration data by its configuration
 key (dotted path notation), or you can just grab the entire merged
@@ -106,19 +132,19 @@ dictionary via the ``dict`` property.
 
     # myapp/logic.py
 
-    from config import config
+    from myapp import config
 
-    # prints $MYAPP__GLOBAL__THINGS because env var overrides config file
-    print(config.get('global.things', list))
+    # prints ['four', 'five', 'six'] because env var overrides config file
+    print(config.get('global.things'))
 
-    # prints $MYAPP__NETWORK__PORT because env var overrides config file
-    print(config.get('network.port', int))
+    # prints 8080 because env var overrides config file
+    print(config.get_int('network.port'))
 
-    # prints '*' from myapp.yml because config file overrides default
-    print(config.get('network.listen', str))
+    # prints * because config file overrides default set by set_default()
+    print(config.get_str('network.listen'))
 
     # prints 'joe' from myapp.yml because it is only defined there
-    print(config.get('global.person.name', str))
+    print(config.get('global.person.name'))
 
     # alternate way to print joe through the config dict property
     print(config.dict['global']['person']['name'])
@@ -138,28 +164,48 @@ Running ``logic.py`` should go something like this:
     joe
     {'global': {'name': 'my app from bash', 'foo': 'bar', 'bar': 'foo', 'things': ['four', 'five', 'six'], 'person': {'name': 'joe', 'hair': 'brown'}}, 'network': {'listen': '*', 'port': '8080'}}
 
-Notes
------
+EnvironmentProvider
+-------------------
+
+Environment variables matching the pattern ``MYAPP__.*`` are
+automatically read into the configuration, where ``MYAPP`` refers to
+the uppercased ``name`` given to your registry at creation.
+
+-  You can customize this behavior by creating your own instance of an
+   ``EnvironmentProvider``.
+
+By default ``__`` (double-underscore) is parsed as a hierarchical separator.
+After stripping the application prefix from the variable name, the ``__``
+is effectively equivalent to a ``.`` in dotted-path notation e.g.
+``MYAPP__GLOBAL__THINGS`` is equivalent to ``global.things``.
 
--  Avoid using ``__`` (double-underscore) in your configuration variable
-   keys (names), as cfitall uses ``__`` as a hierarchical delimiter when
-   parsing environment variables.
+-  You can customize the string used as hierarchical separator,
+   replacing ``__`` with a string of your choosing, by passing
+   a ``level_separator`` keyword argument to your ``EnvironmentProvider``,
+   e.g.
+   ``provider = EnvironmentProvider(level_separator='____')`` (four underscores).
+   Bear in mind that environment variable keys are limited to alphanumeric
+   ASCII characters and underscores (no hyphens, dots, or other punctuation),
+   and must start with a letter.
 
-   -  If you must use ``__`` in variable keys, you can pass an
-      ``env_separator`` argument with a different string to the
-      ConfigManager constructor, e.g.
-      ``config = ConfigManager(env_separator='____')``.
+-  NOTE: Avoid using the value of ``level_separator`` in your configuration
+   keys (names), as this will confuse the provider's parsing.
 
--  Environment variables matching the pattern ``MYAPP__.*`` are
-   automatically read into the configuration, where ``MYAPP`` refers to
-   the uppercase ``name`` given to your ConfigManager at creation.
+String values of "true" or "false" (in any combination of upper/lower case)
+are cast to python booleans by default.
 
-   -  You can customize this behavior by passing an ``env_prefix`` value
-      and/or ``env_separator`` as kwargs to the ConfigManager constructor.
+- To disable this behavior, pass ``cast_bool=False`` to the ``EnvironmentManager``
+  constructor.
 
-Development
------------
+Values that are enclosed in square brackets are parsed as comma-separated
+lists by default. For example, if you ``export MYAPP__FOO="[a, b, c]"`` the
+parsed value of foo will be a python list, ``['a', 'b', 'c']``.
 
-cfitall uses modern python tooling with the pipenv dependency/environment
-manager and pbr packaging system.
+- You can disable list parsing by passing ``value_split=False`` to
+  to the ``EnvironmentProvider`` constructor, in which case the above would return a
+  python string, ``"[a, b, c]"``.
 
+- You can customize the value separator by passing a ``value_separator``
+  keyword to the ``EnvironmentProvider`` constructor. The separator is treated as a
+  regex, so you can use e.g. ``value_separator=r'\s+'`` to split on
+  whitespace instead of the default comma.
diff --git a/cfitall/__init__.py b/cfitall/__init__.py
index e69de29..07aeea0 100644
--- a/cfitall/__init__.py
+++ b/cfitall/__init__.py
@@ -0,0 +1,4 @@
+from decimal import Decimal
+from typing import Union
+
+ConfigValueType = Union[bool, Decimal, float, int, list, str]
diff --git a/cfitall/config.py b/cfitall/config.py
deleted file mode 100644
index dfc2853..0000000
--- a/cfitall/config.py
+++ /dev/null
@@ -1,273 +0,0 @@
-from decimal import Decimal
-import logging
-import json
-import re
-import os
-import yaml
-
-from cfitall import utils
-
-
-logger = logging.getLogger(__name__)
-
-
-class ConfigManager(object):
-    def __init__(
-        self,
-        name,
-        env_prefix=None,
-        env_path_sep="__",
-        env_value_split=True,
-        env_value_split_space=False,
-        env_bool=True,
-        defaults={},
-    ):
-        """
-        The configuration registry holds configuration data from different sources
-        and reconciles it for retrieval.
-
-        :param str name: name of registry (cannot contain env_separator string)
-        :param str env_prefix: prefix for environment variables (defaults to uppercase name)
-        :param str env_path_sep: string for separating config hierarchies in env vars (default '__')
-        :param bool env_value_split: split env var values into python list (on comma)
-        :param bool env_value_split_space: split env var values into python list (on whitespace)
-        :param bool env_bool: convert 'true' and 'false' strings in env vars to python bools
-        :param dict defaults: dictionary of default configuration settings
-        """
-        self.name = name
-        self.config_file = None
-        self.config_path = []
-        self.values = {"super": {}, "cli": {}, "cfgfile": {}, "defaults": defaults}
-        self.env_path_sep = env_path_sep
-        self.env_value_split = env_value_split
-        self.env_value_split_space = env_value_split_space
-        self.env_bool = env_bool
-        if env_prefix:
-            self.env_prefix = env_prefix.upper()
-        else:
-            self.env_prefix = self.name.upper()
-
-    @property
-    def config_keys(self):
-        """
-        Returns a list of configuration keys as dotted paths, for
-        use with the get() or set() methods.
-
-        :return: list of configuration keys as dotted paths
-        :rtype: list
-        """
-        config_keys = [key for key, value in self.flattened.items()]
-        return sorted(config_keys)
-
-    @property
-    def dict(self):
-        """
-        Returns a dict of merged configuration data
-
-        :return: merged dictionary of configuration data
-        :rtype: dict
-        """
-        return self._merge_configs()
-
-    @property
-    def env_vars(self):
-        """
-        Returns a list of environment variables known from config files and defaults
-
-        :return: list of environment variables that will be read
-        :rtype: list
-        """
-        prefix = self.env_prefix + self.env_path_sep
-        keys = [key.upper() for key, value in self.flattened.items()]
-        keys = [prefix + key.replace(".", self.env_path_sep) for key in keys]
-        return sorted(keys)
-
-    @property
-    def flattened(self):
-        """
-        Returns a "flattened" dictionary of merged config values,
-        condensing hierarchies into dotted paths and returning simple
-        key-value pairs.
-
-        :return: flattened dictionary of merged config values
-        :rtype: dict
-        """
-        return utils.flatten_dict(self.dict)
-
-    @property
-    def json(self):
-        return json.dumps(self.dict, indent=4, sort_keys=True)
-
-    @property
-    def yaml(self):
-        return yaml.dump(self.dict)
-
-    def add_config_path(self, path):
-        """
-        Adds a path to search for a configuration file.  Currently
-        limited to the local filesystem, s3 integration envisioned.
-
-        :param path: filesystem path to search for config files
-        :return: None
-        """
-        self.config_path.append(path)
-
-    def get(self, config_key, rtype=None):
-        """
-        Get a configuration value by its dotted path key.  There
-        must be an exact match for the value you request.
-
-        :param config_key: dotted path key in the config registry
-        :param rtype: requested return type (list, str, int, Decimal, float)
-        :return: value from config registry corresponding to key
-        """
-        try:
-            value = self.flattened[config_key]
-        except KeyError:
-            return None
-        value_type = type(value)
-        if rtype == list:
-            return list(value)
-        if rtype == str:
-            if value_type == list:
-                return ",".join(value)
-            return str(value)
-        elif rtype == int:
-            return int(value)
-        elif rtype == Decimal:
-            return Decimal(value)
-        elif rtype == float:
-            return float(value)
-        return value
-
-    def set(self, config_key, value):
-        """
-        Explicitly set a configuration key via dotted key path.
-        Configurations set this way take precedence over all other
-        configuration sources.
-
-        :param config_key: dotted path key to set
-        :param value: value to set
-        """
-        flat_dict = {config_key: value}
-        expanded = utils.expand_flattened_dict(flat_dict)
-        utils.merge_dicts(expanded, self.values["super"])
-
-    def set_default(self, config_key, value):
-        """
-        Set a default value in the registry via dotted key path.
-        Configurations set this way are the first to be overriden by other
-        configuration sources.
-
-        :param config_key: dotted path key to set
-        :param value: value to set
-        :return:
-        """
-        flat_dict = {config_key: value}
-        expanded = utils.expand_flattened_dict(flat_dict)
-        utils.merge_dicts(expanded, self.values["defaults"])
-
-    def read_config(self):
-        """
-        Search through the available paths in config_path and read the first
-        suitable configuration file found.
-
-        :return: True if a configuration file was read, else False
-        :rtype: bool
-        """
-        for path in self.config_path:
-            if os.path.isdir(path):
-                for file in os.listdir(path):
-                    if re.match("{}.json".format(self.name.lower()), file):
-                        self.config_file = os.path.join(path, file)
-                        self._read_json_file(self.config_file)
-                        return True
-                    if re.match("{}.ya*ml".format(self.name.lower()), file):
-                        self.config_file = os.path.join(path, file)
-                        self._read_yaml_file(self.config_file)
-                        return True
-        return False
-
-    def _read_yaml_file(self, path):
-        """
-        Opens path as a yaml file and attempts to safely load it into the
-        cfgfile value dictionary.
-
-        :param path: path to yaml file
-        """
-        with open(path, "r") as fp:
-            data = yaml.safe_load(fp.read())
-        for key, value in data.items():
-            self.values["cfgfile"][key.lower()] = value
-
-    def _read_json_file(self, path):
-        """
-        Opens path as a json file and attempts to load it into the
-        cfgfile value dictionary.
-
-        :param path: path to json file
-        """
-        with open(path, "r") as fp:
-            data = json.loads(fp.read())
-        for key, value in data.items():
-            self.values["cfgfile"][key.lower()] = value
-
-    def _read_environment(self):
-        """
-        Reads all environment variables beginning with env_prefix and loads
-        them into a dictionary using env_separator as a hierarchical path
-        separator.
-
-        :return: config dictionary read from environment variables
-        :rtype: dict
-        """
-        output = {}
-        prefix = self.env_prefix + self.env_path_sep
-        for key, value in os.environ.items():
-            if key.startswith(prefix):
-                key = key.replace(prefix, "", 1).lower()
-                value = self._split_value(value)
-                if self.env_bool:
-                    if type(value) == str and value.lower() == "true":
-                        value = True
-                    if type(value) == str and value.lower() == "false":
-                        value = False
-                    if type(value) == list:
-                        value = [
-                            True if type(val) == str and val.lower() == "true" else val
-                            for val in value
-                        ]
-                        value = [
-                            False
-                            if type(val) == str and val.lower() == "false"
-                            else val
-                            for val in value
-                        ]
-                output[key] = value
-        return utils.expand_flattened_dict(output, separator=self.env_path_sep)
-
-    def _merge_configs(self):
-        """
-        Merges all of the configuration data together in the appropriate order.
-
-        :return: merged configuration data
-        :rtype: dict
-        """
-        config = utils.merge_dicts(self.values["defaults"], {})
-        config = utils.merge_dicts(self.values["cfgfile"], config)
-        config = utils.merge_dicts(self._read_environment(), config)
-        config = utils.merge_dicts(self.values["super"], config)
-        return config
-
-    def _split_value(self, value):
-        """
-        For now, env_value_split_space always overrides env_value_split;
-        this may change in a future release.
-        """
-        if self.env_value_split and not self.env_value_split_space:
-            if re.match(r".*,(.*,)*.*", value):
-                return value.split(",")
-        elif self.env_value_split_space:
-            if re.match(r".*\s+(.*\s+)*.*", value):
-                return re.split(r"\s+", value)
-        return value
diff --git a/cfitall/manager.py b/cfitall/manager.py
new file mode 100644
index 0000000..b582bca
--- /dev/null
+++ b/cfitall/manager.py
@@ -0,0 +1,82 @@
+"""
+The manager module implements a ProviderManager, which manages configuration
+providers for a ConfigurationRegistry.
+"""
+
+import logging
+from typing import Union, Optional, List
+
+from cfitall.providers.base import ConfigProviderBase
+
+logger = logging.getLogger(__name__)
+
+
+class ProviderManager:
+    #: list determining the order in which providers are merged
+    ordering: List[str]
+
+    def __init__(self, providers: Optional[List[ConfigProviderBase]] = None) -> None:
+        """
+        The ProviderManager manages configuration providers, handling registration,
+        deregistration and ordering. It is attached to a registry's
+        ``providers`` attribute.
+
+        :param providers: optional list of preconfigured providers to manage
+        """
+        self.ordering: List[str] = []
+        if not providers:
+            providers = []
+        for provider in providers:
+            self.register(provider)
+
+    def __repr__(self) -> str:
+        return str(self.ordering)
+
+    def get(self, name: str) -> Union[ConfigProviderBase, None]:
+        """
+        Retrieves a reference to the named provider from the manager.
+
+        :param name: friendly name of a registered provider
+        :return: provider instance or None
+        """
+        try:
+            return getattr(self, name)
+        except AttributeError:
+            return None
+
+    def register(self, provider: ConfigProviderBase) -> None:
+        """
+        Registers a provider with the manager.
+
+        :param provider: an instance of a configured provider
+        """
+        if not hasattr(self, provider.provider_name):
+            setattr(self, provider.provider_name, provider)
+            self.ordering.append(provider.provider_name)
+        else:
+            logger.error(
+                f"there is already a provider named {provider.provider_name} registered!"
+            )
+
+    def deregister(self, provider_name: str) -> None:
+        """
+        Deregisters a provider from the manager
+
+        :param provider_name:
+        """
+        if hasattr(self, provider_name):
+            delattr(self, provider_name)
+        self.ordering = [prov for prov in self.ordering if prov != provider_name]
+
+    def update_all(self) -> None:
+        """
+        Triggers each registered provider to run its update() function, updating
+        the values it will return.
+        """
+        for provider_name in self.ordering:
+            try:
+                provider: Optional[ConfigProviderBase] = self.get(provider_name)
+                if provider and not provider.update():
+                    logger.error(f"provider {provider} failed to update!")
+            except AttributeError:
+                logger.error(f"could not find provider {provider_name}")
diff --git a/cfitall/providers/__init__.py b/cfitall/providers/__init__.py
new file mode 100644
index 0000000..f4b90e9
--- /dev/null
+++ b/cfitall/providers/__init__.py
@@ -0,0 +1,4 @@
+"""
+The providers package contains modules for providing configuration data to a
+ConfigurationRegistry. Providers are managed by the ProviderManager.
+"""
diff --git a/cfitall/providers/base.py b/cfitall/providers/base.py
new file mode 100644
index 0000000..1f76702
--- /dev/null
+++ b/cfitall/providers/base.py
@@ -0,0 +1,28 @@
+"""
+describes the ConfigProviderBase class for providers to implement
+"""
+
+from abc import ABC, abstractmethod
+
+
+class ConfigProviderBase(ABC):
+    #: each provider must provide a unique provider_name
+    provider_name: str = "not_implemented"
+
+    @property
+    @abstractmethod
+    def dict(self) -> dict:
+        """
+        The dict property should return a dictionary of the configuration values
+        obtained by the provider. This dict is then combined and reconciled with
+        that of the other providers to produce the final configuration.
+        """
+        raise NotImplementedError
+
+    @abstractmethod
+    def update(self) -> bool:
+        """
+        The update() method signals the provider to read/update its internal data
+        and should return True on successful update or False on failure.
+        """
+        raise NotImplementedError
diff --git a/cfitall/providers/environment.py b/cfitall/providers/environment.py
new file mode 100644
index 0000000..255fe99
--- /dev/null
+++ b/cfitall/providers/environment.py
@@ -0,0 +1,110 @@
+"""
+implements an EnvironmentProvider for reading values from env vars
+"""
+
+import os
+import re
+from typing import Union, List
+
+from cfitall import utils
+from cfitall.providers.base import ConfigProviderBase
+
+
+class EnvironmentProvider(ConfigProviderBase):
+    #: whether to cast "true" and "false" strings to boolean values
+    cast_bool: bool
+    #: string used to separate dictionary keys in variable names
+    level_separator: str
+    #: namespace for parsing variable names
+    prefix: str
+    #: character used to separate delimited values
+    value_separator: str
+    #: whether to split values on value_separator
+    value_split: bool
+
+    def __init__(
+        self,
+        prefix: str,
+        cast_bool: bool = True,
+        level_separator: str = "__",
+        provider_name: str = "environment",
+        value_separator: str = ",",
+        value_split: bool = True,
+    ):
+        """
+        EnvironmentProvider attempts to read configuration values from environment
+        variables.
+
+        :param prefix: namespace prefix for environment variables (e.g. "myapp")
+        :param cast_bool: attempt to cast "true" and "false" strings as booleans (True)
+        :param level_separator: hierarchical separator in env variable name ("__")
+        :param provider_name: friendly name for the provider ("environment")
+        :param value_separator: string or regex to split lists on (",")
+        :param value_split: whether to split values enclosed in square brackets (True)
+        """
+        self.provider_name = provider_name
+        self.level_separator = level_separator
+        self.value_separator = value_separator
+        self.cast_bool = cast_bool
+        self.value_split = value_split
+        self.prefix = f"{prefix.upper()}{level_separator}"
+
+    def _read_environment(self) -> dict:
+        """
+        Reads all environment variables beginning with prefix and loads them into
+        a dictionary using level_separator as a hierarchical path separator.
+        """
+        output = {}
+        for key, value in os.environ.items():
+            if key.startswith(self.prefix):
+                key = key.replace(self.prefix, "", 1).lower()
+                split_value: Union[List, str, bool] = self._split_value(value)
+                if self.cast_bool:
+                    if type(split_value) == str and split_value.lower() == "true":
+                        split_value = True
+                    if type(split_value) == str and split_value.lower() == "false":
+                        split_value = False
+                    if type(split_value) == list:
+                        split_value = [
+                            True if type(val) == str and val.lower() == "true" else val
+                            for val in split_value
+                        ]
+                        split_value = [
+                            False
+                            if type(val) == str and val.lower() == "false"
+                            else val
+                            for val in split_value
+                        ]
+                output[key] = split_value
+        return output
+
+    def _split_value(self, value: str) -> Union[List[str], str]:
+        """
+        If self.value_split is True, split value by self.value_separator,
+        where value is a string of values enclosed in square brackets and separated
+        by self.value_separator.
+        """
+        if self.value_split:
+            if value.startswith("[") and value.endswith("]"):
+                values = []
+                for val in re.split(self.value_separator, value[1:-1]):
+                    if val := val.strip():
+                        values.append(val)
+                return values
+        return value
+
+    @property
+    def dict(self) -> dict:
+        """
+        Returns the provider's configuration data from environment variables.
+        """
+        return utils.expand_flattened_dict(
+            self._read_environment(), separator=self.level_separator
+        )
+
+    def update(self) -> bool:
+        """
+        This is a no-op for the EnvironmentProvider, which always reads
+        environment variables in realtime as doing so is a non-blocking call.
+        """
+        return True
diff --git a/cfitall/providers/filesystem.py b/cfitall/providers/filesystem.py
new file mode 100644
index 0000000..18e8096
--- /dev/null
+++ b/cfitall/providers/filesystem.py
@@ -0,0 +1,97 @@
+"""
+implements a FilesystemProvider for reading configs from disk
+"""
+
+import json
+import logging
+import os
+from typing import Union, List
+
+import yaml
+
+from cfitall.providers.base import ConfigProviderBase
+
+logger = logging.getLogger(__name__)
+
+
+class FilesystemProvider(ConfigProviderBase):
+    #: list of filesystem locations to search for config files
+    path: List[str]
+    #: namespace for locating files
+    prefix: str
+
+    def __init__(
+        self, path: list[str], prefix: str, provider_name: str = "filesystem"
+    ) -> None:
+        """
+        FilesystemProvider attempts to read json or yaml configuration files
+        from disk.
+
+        :param path: list of filesystem paths to search for config files
+        :param prefix: base name of file to look for (e.g. f"{prefix}.yml")
+        :param provider_name: friendly name for the provider ("filesystem")
+        """
+        self.path = path
+        self.prefix = prefix
+        self.provider_name = provider_name
+        self.config_file: Union[str, None] = None
+        self.config_file_type: Union[str, None] = None
+        self._set_config_file()
+        self._data: dict = {}
+
+    def _read_config_file(self) -> None:
+        """
+        Attempts to read and parse self.config_file, storing the results
+        in the self._data dictionary.
+        """
+        if self.config_file and os.path.isfile(self.config_file):
+            try:
+                with open(self.config_file, "r") as file_:
+                    data = {}
+                    if self.config_file_type == "yaml":
+                        data = yaml.safe_load(file_.read())
+                    elif self.config_file_type == "json":
+                        data = json.loads(file_.read())
+            except Exception as ex:
+                logger.error(f"error opening file: {self.config_file}: {ex}")
+            self._data = {key.lower(): value for key, value in data.items()}
+        else:
+            logger.warning("config_file not set or file does not exist")
+
+    def _set_config_file(self) -> bool:
+        """
+        Iterates through the directories in self.path, looking for json or yaml
+        configuration files, and configuring the object to use the first found.
+        Returns True if a file is found, else returns False.
+        """
+        for path in self.path:
+            if os.path.isdir(path):
+                for file in sorted(os.listdir(path)):
+                    if file == f"{self.prefix.lower()}.json":
+                        self.config_file = os.path.join(path, file)
+                        self.config_file_type = "json"
+                        return True
+                    elif (
+                        file == f"{self.prefix.lower()}.yaml"
+                        or file == f"{self.prefix.lower()}.yml"
+                    ):
+                        self.config_file = os.path.join(path, file)
+                        self.config_file_type = "yaml"
+                        return True
+        return False
+
+    def update(self) -> bool:
+        """
+        Updates self._data from the contents of self.config_file.
+        """
+        if self._set_config_file():
+            self._read_config_file()
+            return True
+        return False
+
+    @property
+    def dict(self) -> dict:
+        """
+        Returns the configuration dictionary from self._data.
+        """
+        return self._data
diff --git a/cfitall/registry.py b/cfitall/registry.py
new file mode 100644
index 0000000..2d154ed
--- /dev/null
+++ b/cfitall/registry.py
@@ -0,0 +1,236 @@
+"""
+The registry module implements the ConfigurationRegistry, the core structure
+and entry point for cfitall.
+"""
+
+from decimal import Decimal
+import logging
+import json
+from typing import Union, Dict, List, Optional
+import os
+
+import yaml
+
+from cfitall import utils, ConfigValueType
+from cfitall.manager import ProviderManager
+from cfitall.providers.base import ConfigProviderBase
+from cfitall.providers.environment import EnvironmentProvider
+from cfitall.providers.filesystem import FilesystemProvider
+
+logger = logging.getLogger(__name__)
+
+
+class ConfigurationRegistry(object):
+    #: The providers attribute holds the ProviderManager instance for the Registry.
+    providers: ProviderManager
+    #: Dictionary containing defaults and overrides: ``{"defaults": {}, "super": {}}``
+    values: Dict
+
+    def __init__(
+        self,
+        name: str,
+        defaults: Dict = None,
+        providers: Optional[List[ConfigProviderBase]] = None,
+    ) -> None:
+        """
+        The configuration registry holds configuration data from different sources
+        and reconciles it for retrieval. If the defaults dict is provided, it
+        will be used to seed the default configuration values for the registry,
+        equivalent to calling set_default() for each configuration key in defaults.
+
+        :param name: namespace for configuration registry
+        :param defaults: default configuration values
+        :param providers: providers to add to the registry
+        """
+        if not defaults:
+            defaults = {}
+        self.name = name
+        self.values = {"super": {}, "defaults": defaults}
+        if providers is not None:
+            self.providers = ProviderManager(providers=providers)
+        else:
+            self.providers = ProviderManager()
+            path = [os.path.join("/etc", name)]
+            if home := os.getenv("HOME"):
+                path.insert(0, os.path.join(home, ".local", "etc", name))
+            self.providers.register(FilesystemProvider(path, name))
+            self.providers.register(EnvironmentProvider(name))
+
+    @property
+    def all(self) -> Dict:
+        """
+        Returns a dictionary of all the configuration data that is considered
+        for merging, before it is merged into the final configuration.
+        """
+        values = self.values.copy()
+        for provider_name in self.providers.ordering:
+            try:
+                if provider := self.providers.get(provider_name):
+                    values[provider.provider_name] = provider.dict
+            except (KeyError, ValueError):
+                logger.error(f"error reading values from provider {provider_name}")
+        return values
+
+    @property
+    def config_keys(self) -> list[str]:
+        """
+        Returns a list of currently used configuration keys as dotted paths, for
+        use with the get() or set() methods.
+        """
+        config_keys = [key for key, value in self.flattened.items()]
+        return sorted(config_keys)
+
+    @property
+    def dict(self) -> Dict:
+        """
+        Returns a dict of merged configuration data
+        """
+        return self._merge_configs()
+
+    @property
+    def env_vars(self) -> list[str]:
+        """
+        Returns a list of environment variables known from config files and defaults
+        """
+        if env_provider := self.providers.get("environment"):
+            keys = [key.upper() for key, value in self.flattened.items()]
+            keys = [
+                env_provider.prefix + key.replace(".", env_provider.level_separator)  # type: ignore
+                for key in keys
+            ]
+            return sorted(keys)
+        return []
+
+    @property
+    def flattened(self) -> Dict:
+        """
+        Returns a "flattened" dictionary of merged config values,
+        condensing hierarchies into dotted paths and returning simple
+        key-value pairs.
+        """
+        return utils.flatten_dict(self.dict)
+
+    @property
+    def json(self) -> str:
+        """
+        Returns json representation of merged configuration.
+        """
+        return json.dumps(self.dict, indent=4, sort_keys=True)
+
+    @property
+    def yaml(self) -> str:
+        """
+        Returns yaml representation of merged configuration.
+        """
+        return yaml.dump(self.dict)
+
+    def get(self, config_key: str) -> Union[ConfigValueType, None]:
+        """
+        Get a configuration value by its dotted path key; returns the requested
+        value as its native type stored in the registry.
+        """
+        try:
+            return self.flattened[config_key]
+        except (KeyError, TypeError):
+            return None
+
+    def get_bool(self, config_key: str) -> Union[bool, None]:
+        """
+        Get a configuration value by its dotted path key; attempts to return
+        the requested value as a boolean or raises TypeError.
+        """
+        try:
+            return bool(self.flattened[config_key])
+        except KeyError:
+            return None
+
+    def get_decimal(self, config_key: str) -> Union[Decimal, None]:
+        """
+        Get a configuration value by its dotted path key; attempts to return
+        the requested value as a Decimal or raises TypeError.
+        """
+        try:
+            return Decimal(self.flattened[config_key])
+        except KeyError:
+            return None
+
+    def get_float(self, config_key: str) -> Union[float, None]:
+        """
+        Get a configuration value by its dotted path key; attempts to return
+        the requested value as a float or raises TypeError.
+        """
+        try:
+            return float(self.flattened[config_key])
+        except KeyError:
+            return None
+
+    def get_int(self, config_key: str) -> Union[int, None]:
+        """
+        Get a configuration value by its dotted path key; attempts to return
+        the requested value as an int or raises TypeError.
+        """
+        try:
+            return int(self.flattened[config_key])
+        except KeyError:
+            return None
+
+    def get_list(self, config_key: str, csv: bool = True) -> Union[list, None]:
+        """
+        Get a configuration value by its dotted path key; attempts to return
+        the requested value as a list or raises TypeError. If csv is True
+        (default), split value on commas.
+        """
+        try:
+            value = self.flattened[config_key]
+            if type(value) != list and csv is True:
+                split = value.split(",")
+                return [val.strip() for val in split]
+            return list(value)
+        except KeyError:
+            return None
+
+    def get_string(self, config_key: str) -> Union[str, None]:
+        """
+        Get a configuration value by its dotted path key; attempts to return
+        the requested value as a string or raises TypeError.
+        """
+        try:
+            return str(self.flattened[config_key])
+        except KeyError:
+            return None
+
+    def set(self, config_key: str, value: ConfigValueType) -> None:
+        """
+        Explicitly set config_key (a dotted key string) to value. Values set
+        via this method take precedence over all other configuration sources.
+        """
+        flat_dict = {config_key: value}
+        expanded = utils.expand_flattened_dict(flat_dict)
+        utils.merge_dicts(expanded, self.values["super"])
+
+    def set_default(self, config_key: str, value: ConfigValueType) -> None:
+        """
+        Set the default value for config_key (a dotted key string) to value.
+        Values set via this method will be overridden by any configuration
+        provider containing a matching config_key.
+        """
+        flat_dict = {config_key: value}
+        expanded = utils.expand_flattened_dict(flat_dict)
+        utils.merge_dicts(expanded, self.values["defaults"])
+
+    def update(self) -> None:
+        """
+        Updates configuration values from all providers.
+        """
+        self.providers.update_all()
+
+    def _merge_configs(self) -> Dict:
+        """
+        Merges configuration from all configured providers into final config.
+        """
+        config = utils.merge_dicts(self.values["defaults"], {})
+        for provider_name in self.providers.ordering:
+            if provider := self.providers.get(provider_name):
+                config = utils.merge_dicts(provider.dict, config)
+        config = utils.merge_dicts(self.values["super"], config)
+        return config
diff --git a/cfitall/utils.py b/cfitall/utils.py
index 685eb90..aac6ce5 100644
--- a/cfitall/utils.py
+++ b/cfitall/utils.py
@@ -11,17 +11,18 @@ As an "expanded" or "nested" dict, the same data would be:
 """
 
 from collections.abc import Mapping
+from typing import Dict
 
+from cfitall import ConfigValueType
 
-def add_keys(destdict, srclist, value=None):
+
+def add_keys(destdict: dict, srclist: list, value: ConfigValueType = None) -> dict:
     """
     Nests keys from srclist into destdict, with optional value set on the final key.
 
-    :param dict destdict: the dict to update with values from srclist
-    :param list srclist: list of keys to add to destdict
-    :param value: final value to set
-    :return: destination dictionary
-    :rtype: dict
+    :param destdict: dictionary to add keys to
+    :param srclist: list to add keys from
+    :param value: final key's value
     """
     if len(srclist) > 1:
         destdict[srclist[0]] = {}
@@ -32,30 +33,28 @@ def add_keys(destdict, srclist, value=None):
     return destdict
 
 
-def expand_flattened_path(flattened_path, value=None, separator="."):
+def expand_flattened_path(
+    flattened_path: str, value: ConfigValueType = None, separator: str = "."
+) -> dict:
     """
     Expands a dotted path into a nested dict; if value is set, the
     final key in the path will be set to value.
 
-    :param str flattened_path: flattened path to expand
-    :param str separator: character(s) separating path components
-    :param value: set final key to this value
-    :return: nested dictionary
-    :rtype: dict
+    :param flattened_path: the dotted path to expand to a nested dict
+    :param value: final key's value
+    :param separator: separator between dict keys in flattened_path
     """
     split_list = flattened_path.split(separator)
     return add_keys({}, split_list, value)
 
 
-def flatten_dict(nested):
+def flatten_dict(nested: dict) -> dict:
     """
     Flattens a deeply nested dictionary into a flattened dictionary.
     For example `{'foo': {'bar': 'baz'}}` would be flattened to
     `{'foo.bar': 'baz'}`.
 
-    :param dict nested: nested dictionary of configuration data
-    :rtype: dict
-    :return: dict of key-value pairs
+    :param nested: dictionary to flatten
     """
     flattened = {}
     for key, value in nested.items():
@@ -69,20 +68,19 @@ def flatten_dict(nested):
     mappings = [isinstance(value, Mapping) for key, value in flattened.items()]
     if len(set(mappings)) == 1 and set(mappings).pop() is False:
         return flattened
-    else:
+    elif len(set(mappings)) > 0:
         return flatten_dict(flattened)
+    return {}
 
 
-def merge_dicts(source, destination):
+def merge_dicts(source: Mapping, destination: dict) -> dict:
     """
     Performs a deep merge of two nested dicts by expanding all Mapping objects
     until they reach a non-mapping value (e.g. a list, string, int, etc.) and
     copying these from the source to the destination.
 
-    :param dict source: the source dictionary to copy values from
-    :param dict destination: the dictionary to update with values from source
-    :return: destination
-    :rtype: dict
+    :param source: source dictionary to copy from
+    :param destination: destination dict to merge into
     """
     for key, value in source.items():
         key = key.lower() if isinstance(key, str) else key
@@ -94,16 +92,15 @@ def merge_dicts(source, destination):
     return destination
 
 
-def expand_flattened_dict(flattened, separator="."):
+def expand_flattened_dict(flattened: dict, separator: str = ".") -> dict:
     """
-    Expands a flattened dict into a nested dict.
+    Expands a flattened dict into a nested dict, e.g. {'foo.bar': 'baz'} to
+    {'foo': {'bar': 'baz'}}.
 
-    :param dict flattened: the flattened dict to expand
-    :param str separator: character used for separating paths in flattened dict
-    :return: nested dict
-    :rtype: dict
+    :param flattened: dictionary with flattened keys to expand
+    :param separator: separator between dict keys in flattened_path
     """
-    merged = {}
+    merged: Dict[str, ConfigValueType] = {}
     for key, value in flattened.items():
         expanded = expand_flattened_path(key, value=value, separator=separator)
         merged = merge_dicts(merged, expanded)
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..d4bb2cb
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS    ?=
+SPHINXBUILD   ?= sphinx-build
+SOURCEDIR     = .
+BUILDDIR      = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/_templates/custom-class-template.rst b/docs/_templates/custom-class-template.rst
new file mode 100644
index 0000000..f73eda5
--- /dev/null
+++ b/docs/_templates/custom-class-template.rst
@@ -0,0 +1,34 @@
+{{ fullname | escape | underline}}
+
+.. currentmodule:: {{ module }}
+
+.. autoclass:: {{ objname }}
+   :members:
+   :show-inheritance:
+   :inherited-members:
+   :special-members: __call__, __add__, __mul__
+
+   {% block methods %}
+   {% if methods %}
+   .. rubric:: {{ _('Methods') }}
+
+   .. autosummary::
+      :nosignatures:
+   {% for item in methods %}
+      {%- if not item.startswith('_') %}
+      ~{{ name }}.{{ item }}
+      {%- endif -%}
+   {%- endfor %}
+   {% endif %}
+   {% endblock %}
+
+   {% block attributes %}
+   {% if attributes %}
+   .. rubric:: {{ _('Attributes') }}
+
+   .. autosummary::
+   {% for item in attributes %}
+      ~{{ name }}.{{ item }}
+   {%- endfor %}
+   {% endif %}
+   {% endblock %}
diff --git a/docs/_templates/custom-module-template.rst b/docs/_templates/custom-module-template.rst
new file mode 100644
index 0000000..d066d0e
--- /dev/null
+++ b/docs/_templates/custom-module-template.rst
@@ -0,0 +1,66 @@
+{{ fullname | escape | underline}}
+
+.. automodule:: {{ fullname }}
+
+   {% block attributes %}
+   {% if attributes %}
+   .. rubric:: Module attributes
+
+   .. autosummary::
+      :toctree:
+   {% for item in attributes %}
+      {{ item }}
+   {%- endfor %}
+   {% endif %}
+   {% endblock %}
+
+   {% block functions %}
+   {% if functions %}
+   .. rubric:: {{ _('Functions') }}
+
+   .. autosummary::
+      :toctree:
+      :nosignatures:
+   {% for item in functions %}
+      {{ item }}
+   {%- endfor %}
+   {% endif %}
+   {% endblock %}
+
+   {% block classes %}
+   {% if classes %}
+   .. rubric:: {{ _('Classes') }}
+
+   .. autosummary::
+      :toctree:
+      :template: custom-class-template.rst
+      :nosignatures:
+   {% for item in classes %}
+      {{ item }}
+   {%- endfor %}
+   {% endif %}
+   {% endblock %}
+
+   {% block exceptions %}
+   {% if exceptions %}
+   .. rubric:: {{ _('Exceptions') }}
+
+   .. autosummary::
+      :toctree:
+   {% for item in exceptions %}
+      {{ item }}
+   {%- endfor %}
+   {% endif %}
+   {% endblock %}
+
+{% block modules %}
+{% if modules %}
+.. autosummary::
+   :toctree:
+   :template: custom-module-template.rst
+   :recursive:
+{% for item in modules %}
+   {{ item }}
+{%- endfor %}
+{% endif %}
+{% endblock %}
diff --git a/docs/api.rst b/docs/api.rst
new file mode 100644
index 0000000..27a361a
--- /dev/null
+++ b/docs/api.rst
@@ -0,0 +1,6 @@
+.. autosummary::
+   :toctree: _autosummary
+   :template: custom-module-template.rst
+   :recursive:
+
+   cfitall
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..2f296b1
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,87 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+import os
+import sys
+
+here = os.path.dirname(os.path.abspath(__file__))
+cfitall_path = os.path.join(here, "..", "cfitall")
+sys.path.insert(0, cfitall_path)
+print(sys.path)
+
+
+# -- Project information -----------------------------------------------------
+
+project = "cfitall"
+copyright = "2022, Chris Haumesser"
+author = "Chris Haumesser"
+
+
+# -- General configuration ---------------------------------------------------
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+    "sphinx.ext.autodoc",
+    "sphinx.ext.autosummary",
+    "sphinx.ext.intersphinx",  # Link to other project's documentation (see mapping below)
+    "sphinx_autodoc_typehints",  # Automatically document param types (less noise in class signature)
+    "IPython.sphinxext.ipython_console_highlighting",
+]
+
+autosummary_generate = True
+autoclass_content = "both"  # Add __init__ doc (i.e. params) to class summaries
+html_show_sourcelink = (
+    False  # Remove 'view source code' from top of page (for html, not python)
+)
+autodoc_inherit_docstrings = True  # If no docstring, inherit from base class
+set_type_checking_flag = True  # Enable 'expensive' imports for sphinx_autodoc_typehints
+add_module_names = False  # Remove namespaces from class/method signatures
+
+intersphinx_mapping = {
+    "python": ("https://docs.python.org/3/", None),
+}
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ["_templates"]
+
+autodoc_mock_imports = [
+    "cfitall.tests",
+]
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
+
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = "alabaster"
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ["_static"]
+
+# Readthedocs theme
+# on_rtd is whether on readthedocs.org, this line of code grabbed from docs.readthedocs.org...
+on_rtd = os.environ.get("READTHEDOCS", None) == "True"
+if not on_rtd:  # only import and set the theme if we're building docs locally
+    import sphinx_rtd_theme
+
+    html_theme = "sphinx_rtd_theme"
+    html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
diff --git a/docs/datastructures.rst b/docs/datastructures.rst
new file mode 100644
index 0000000..32e5505
--- /dev/null
+++ b/docs/datastructures.rst
@@ -0,0 +1,50 @@
+Data Structures
+===============
+
+cfitall manages data stored in dictionaries.
+
+The :py:class:`~cfitall.registry.ConfigurationRegistry` contains an internal
+:py:attr:`~cfitall.registry.ConfigurationRegistry.values`
+dictionary, where it holds configuration data for default values and
+developer-overridden values. Likewise,
+:py:class:`providers <cfitall.providers.base.ConfigProviderBase>` are
+expected to provide a dictionary of values to the registry via their
+:py:attr:`~cfitall.providers.base.ConfigProviderBase.dict` property.
+
+When a configuration value is requested, the dictionaries are merged together
+in a deterministic order, each overriding the last:
+
+- First, the ``defaults`` from the registry's values dictionary are considered
+- Then each provider's ``dict`` attribute, in the order listed in
+  the :py:class:`manager's <cfitall.manager.ProviderManager>`
+  :py:attr:`~cfitall.manager.ProviderManager.ordering` attribute
+- Finally ``super`` values from the registry's values dictionary
+
+The merged configuration dictionary can be accessed directly via the registry's
+:py:attr:`~cfitall.registry.ConfigurationRegistry.dict` property; individual
+values can also be retrieved by passing the registry's
+:py:meth:`~cfitall.registry.ConfigurationRegistry.get` method (or one of its
+typed variants) a valid configuration key.
+
+
+Dotted Paths / Configuration Keys
+*********************************
+
+For convenience, dictionary keys are "flattened" into a *dotted path notation*,
+also referred to as a *configuration key*, in a number of contexts throughout
+the library:
+
+::
+
+    from cfitall.registry import ConfigurationRegistry
+    cf = ConfigurationRegistry("app")
+
+    # services.database.port is a configuration key / dotted path notation
+    cf.set_default("services.database.port", 5432)
+
+    # the configuration key is a concatenation of dictionary keys
+    assert cf.get("services.database.port") == cf.dict["services"]["database"]["port"]
+
+You can view a list of configuration keys (that the registry is aware of) by
+inspecting the registry's :py:attr:`~cfitall.registry.ConfigurationRegistry.config_keys`
+property.
\ No newline at end of file
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..c05ccac
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,150 @@
+.. cfitall documentation master file, created by
+   sphinx-quickstart on Sun Apr 10 07:34:48 2022.
+   You can adapt this file completely to your liking, but it should at least
+   contain the root `toctree` directive.
+
+Configure It All
+================
+
+.. toctree::
+   :hidden:
+
+   Home <self>
+   Data Structures <datastructures>
+   Configuration Registry <registry>
+   Configuration Providers <providers>
+   Provider Manager <manager>
+   Reference <_autosummary/cfitall>
+
+cfitall (configure it all) is a configuration library for Python applications,
+loosely inspired by the `viper <https://github.com/spf13/viper>`__ library
+for Go.
+
+cfitall provides a *registry* for configuring your application from a series of
+*providers*, which are managed by a *manager*. When requesting a configuration
+value, data from each provider is merged into the next to resolve the requested
+value. Providers can be easily written and registered to extend cfitall's
+functionality.
+
+Included with the package are a
+:py:class:`~cfitall.providers.filesystem.FilesystemProvider`
+and an :py:class:`~cfitall.providers.environment.EnvironmentProvider` for
+reading configuration data from the filesystem or environment variables,
+respectively.
+
+Quick Start / Example
+*********************
+
+First, install cfitall from PyPi via your favorite package manager:
+
+::
+
+   pip install 'cfitall>=2.0'
+
+For this example, we will configure a contrived application called
+``myapp``, which we will also use as our registry name. The
+FilesystemProvider and EnvironmentProvider both use this value as a namespace.
+
+Our configuration file will therefore be named ``myapp.(json|yaml|yml)``. By
+default, the FilesystemProvider will search for a matching file first in
+``$HOME/.local/etc/myapp`` and then in ``/etc/myapp``. Let's create a
+configuration file in YAML format and put it in place:
+
+::
+
+    # ~/.local/etc/myapp/myapp.yml
+
+    global:
+      bar: foo
+      things:
+        - one
+        - two
+        - three
+      person:
+        name: joe
+        hair: brown
+    network:
+      port: 9000
+      listen: '*'
+
+The EnvironmentProvider also uses the registry name as a namespace, searching
+environment variables beginning with ``MYAPP__`` for values. Let's export some
+environment variables to see this provider in action:
+
+::
+
+    export MYAPP__GLOBAL__NAME="my app from bash"
+    export MYAPP__GLOBAL__THINGS="[four, five, six]"
+    export MYAPP__NETWORK__PORT=8080
+
+Next, set up a ``ConfigurationRegistry`` for myapp, naming it ``myapp``.
+
+::
+
+    # myapp/__init__.py
+
+    from cfitall.registry import ConfigurationRegistry
+
+
+    # create a configuration registry for myapp
+    config = ConfigurationRegistry('myapp')
+
+    # set some default configuration values
+    config.set_default('global.name', 'my fancy application')
+    config.set_default('global.foo', 'bar')
+    config.set_default('network.listen', '127.0.0.1')
+
+    # read/update data from enabled providers
+    config.update()
+
+
+Now you can use your registry instance to get the configuration data you
+need. You can access the merged configuration data by its configuration
+key (dictionary keys separated by ``.``), or you can just grab the entire
+merged dictionary via the ``dict`` property.
+
+::
+
+    # myapp/logic.py
+
+    from myapp import config
+
+    # prints ['four', 'five', 'six'] because env var overrides config file
+    print(config.get('global.things'))
+
+    # prints 8080 because env var overrides config file
+    print(config.get_int('network.port'))
+
+    # prints * because config file overrides default set by set_default()
+    print(config.get_str('network.listen'))
+
+    # prints 'joe' from myapp.yml because it is only defined there
+    print(config.get('global.person.name'))
+
+    # alternate way to print joe through the config dict property
+    print(config.dict['global']['person']['name'])
+
+    # prints the entire assembled config as dictionary
+    print(config.dict)
+
+Running ``logic.py`` should go something like this:
+
+::
+
+    $ python logic.py
+    ['four', 'five', 'six']
+    8080
+    *
+    joe
+    joe
+    {'global': {'name': 'my app from bash', 'foo': 'bar', 'bar': 'foo', 'things': ['four', 'five', 'six'], 'person': {'name': 'joe', 'hair': 'brown'}}, 'network': {'listen': '*', 'port': '8080'}}
+
+
+Indices and tables
+******************
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
+
+
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..32bb245
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+	echo.installed, then set the SPHINXBUILD environment variable to point
+	echo.to the full path of the 'sphinx-build' executable. Alternatively you
+	echo.may add the Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.https://www.sphinx-doc.org/
+	exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/docs/manager.rst b/docs/manager.rst
new file mode 100644
index 0000000..06b0e0f
--- /dev/null
+++ b/docs/manager.rst
@@ -0,0 +1,35 @@
+Provider Manager
+================
+
+Each instance of a :py:class:`~cfitall.registry.ConfigurationRegistry` has a
+:py:class:`~cfitall.manager.ProviderManager` as its
+:py:attr:`~cfitall.registry.ConfigurationRegistry.providers` attribute. The
+manager keeps track of the providers that the registry will use to resolve its final
+configuration values.
+
+To add a provider, call the :py:meth:`~cfitall.manager.ProviderManager.register`
+method of the manager, passing in an object that implements
+:py:class:`~cfitall.providers.base.ConfigProviderBase`.
+
+Registering a provider does two things:
+
+* Adds the provider instance as an attribute of the manager, named
+  after the provider's
+  :py:attr:`~cfitall.providers.base.ConfigProviderBase.provider_name`.
+* Appends the the ``provider_name`` to the manager's
+  :py:attr:`~cfitall.manager.ProviderManager.ordering` attribute
+
+To remove a provider, simply call :py:meth:`~cfitall.manager.ProviderManager.deregister`
+with the ``provider_name``, and the reverse process will be performed.
+
+Merge Order
+***********
+
+When assembling the final configuration dictionary, the manager's
+:py:attr:`~cfitall.manager.ProviderManager.ordering` property
+determines the order in which providers are merged. By default the most recently
+registered provider has the highest precedence.
+
+You can update the :py:attr:`~cfitall.manager.ProviderManager.ordering`
+attribute to change the order in which provider dictionaries are merged (just
+like any other Python list).
diff --git a/docs/providers.rst b/docs/providers.rst
new file mode 100644
index 0000000..c3efd8d
--- /dev/null
+++ b/docs/providers.rst
@@ -0,0 +1,90 @@
+Configuration Providers
+=======================
+
+A Configuration Provider implements
+:py:class:`~cfitall.providers.base.ConfigProviderBase`, allowing it to be
+registered by a :py:class:`~cfitall.manager.ProviderManager` and thereby
+included in a :py:class:`~cfitall.registry.ConfigurationRegistry`.
+
+Two such Providers are included in this package:
+
+- The :py:class:`~cfitall.providers.environment.EnvironmentProvider` parses
+  environment variables for configuration data.
+- The :py:class:`~cfitall.providers.filesystem.FilesystemProvider` parses json
+  or yaml files for configuration data.
+
+Any provider implementing :py:class:`~cfitall.providers.base.ConfigProviderBase`
+can be added to the registry by calling the
+:py:meth:`~cfitall.manager.ProviderManager.register` method on the registry's
+:py:attr:`manager <cfitall.registry.ConfigurationRegistry.providers>`.
+
+Environment Provider
+********************
+
+The :py:class:`~cfitall.providers.environment.EnvironmentProvider` attempts to
+automatically parse configuration data for your application from environment
+variables.
+
+Environment variables matching the pattern ``APP__.*`` are
+automatically read into the configuration, where ``APP`` refers to
+the uppercased ``name`` given to your registry at creation.
+
+*  You can customize this behavior by creating your own instance of an
+   :py:class:`~cfitall.providers.environment.EnvironmentProvider`.
+
+By default ``__`` (double-underscore) is parsed as a hierarchical separator.
+After stripping the application prefix from the variable name, the ``__``
+is effectively equivalent to a ``.`` in dotted-path notation e.g.
+``APP__GLOBAL__THINGS`` is equivalent to ``global.things``, which is the same
+as ``{"global": {"things": {}}}``.
+
+*  You can customize the string used as hierarchical separator,
+   replacing ``__`` with a string of your choosing, by passing
+   a ``level_separator`` keyword argument to your
+   :py:class:`~cfitall.providers.environment.EnvironmentProvider`,
+   e.g.
+   ``provider = EnvironmentProvider(level_separator='____')`` (four underscores).
+   Bear in mind that environment variable keys are limited to alphanumeric
+   ASCII characters and underscores (no hyphens, dots, or other punctuation),
+   and must start with a letter.
+
+*  NOTE: Avoid using the value of ``level_separator`` in your configuration
+   keys (names), as this will confuse the provider's parsing.
+
+String values of "true" or "false" (in any combination of upper/lower case)
+are cast to python booleans by default.
+
+* To disable this behavior, pass ``cast_bool=False`` to the
+  :py:class:`~cfitall.providers.environment.EnvironmentProvider` constructor.
+
+Values that are enclosed in square brackets are parsed as comma-separated
+lists by default. For example, if you ``export APP__FOO="[a, b, c]"`` the
+parsed value of foo will be a python list, ``['a', 'b', 'c']``.
+
+* You can disable list parsing by passing ``value_split=False`` to
+  to the :py:class:`~cfitall.providers.environment.EnvironmentProvider` constructor,
+  in which case the above would return a python string: ``"[a, b, c]"``.
+
+* You can customize the value separator by passing a ``value_separator``
+  keyword to the
+  :py:class:`~cfitall.providers.environment.EnvironmentProvider` constructor.
+  The separator is treated as a regex, so you can use e.g. ``value_separator=r'\s+'``
+  to split on whitespace instead of the default comma.
+
+
+Filesystem Provider
+*******************
+
+The :py:class:`~cfitall.providers.filesystem.FilesystemProvider` searches a list
+of filesystem paths for JSON or YAML configuration files, parses the first one
+that it finds, and stores the configuration in memory until its ``update()`` method
+is called again.
+
+If a list of paths is not specified, the provider will search for files as follows:
+
+* ``$HOME/.local/etc/{prefix}/{prefix}.(json|yaml|yml)``
+* ``/etc/{prefix}/{prefix}.(json|yaml|yml)``
+
+The list of paths is stored as a list on the provider's
+:py:attr:`~cfitall.providers.filesystem.FilesystemProvider.path` attribute, and
+can be manipulated just like any other list to add or remove paths to search.
diff --git a/docs/registry.rst b/docs/registry.rst
new file mode 100644
index 0000000..63163c2
--- /dev/null
+++ b/docs/registry.rst
@@ -0,0 +1,80 @@
+Configuration Registry
+======================
+
+The :py:class:`~cfitall.registry.ConfigurationRegistry` is the main entry point
+into the library and the class developers will interact with most frequently.
+
+Default Behavior
+****************
+
+Its constructor requires a single argument: a name that will be
+used to identify and namespace the configuration (the name of your application
+is usually a good choice).
+
+With no additional arguments, the constructor will
+return a registry with a
+:py:class:`~cfitall.providers.filesystem.FilesystemProvider` and an
+:py:class:`~cfitall.providers.environment.EnvironmentProvider` configured to
+sane defaults, which will consider the following configuration sources in order:
+
+* default values provided by the developer:
+
+  * ``defaults`` passed in to the :py:class:`~cfitall.registry.ConfigurationRegistry` constructor
+  * programmatic calls to the registry's :py:meth:`~cfitall.registry.ConfigurationRegistry.set_default` method
+
+* the first JSON or YAML configuration file found from:
+
+  * ``$HOME/.local/etc/{name}/{name}.(json|yaml|yml)``
+  * ``/etc/{name}/{name}.(json|yaml|yml)``
+
+* environment variables in the ``{NAME}__`` namespace
+* developer overrides via the registry's :py:meth:`~cfitall.registry.ConfigurationRegistry.set` method
+
+Providers
+*********
+
+The registry merges configurations from a set of providers. These providers are
+managed by a :py:class:`~cfitall.manager.ProviderManager`, which is attached to
+the registry as its :py:attr:`~cfitall.registry.ConfigurationRegistry.providers`
+attribute.
+
+To disable or customize the default providers, you can pass the registry constructor
+your own list of providers (which can be empty). For example,
+``cf = ConfigurationRegistry("app", providers=[])`` will create a configuration
+registry that only manages default values and developer overrides.
+
+Alternatively, you can call the :py:meth:`~cfitall.manager.ProviderManager.register`
+or :py:meth:`~cfitall.manager.ProviderManager.deregister` methods on the registry's
+:py:attr:`provider manager <cfitall.registry.ConfigurationRegistry.providers>` to
+add or remove a provider (e.g. ``cf.providers.deregister("environment")``).
+
+Provider Values
+---------------
+
+Each provider is required to implement an
+:py:meth:`~cfitall.providers.base.ConfigProviderBase.update` method that will
+trigger it to read its configuration from the source and update itself
+accordingly.
+
+In the case of the :py:class:`~cfitall.providers.filesystem.FilesystemProvider`,
+for example, the :py:meth:`~cfitall.providers.filesystem.FilesystemProvider.update`
+method causes the provider to read the first configuration file it finds from
+the filesystem.
+
+Always call :py:meth:`~cfitall.registry.ConfigurationRegistry.update` on your
+registry after constructing it. This will call each provider's ``update()``
+method, which some providers require.
+
+
+Getting Values
+**************
+
+A dictionary of merged configuration values is available as the registry's
+:py:attr:`~cfitall.registry.ConfigurationRegistry.dict` property.
+
+An individual value can be retrieved using the
+:py:meth:`~cfitall.registry.ConfigurationRegistry.get` method, which takes a
+configuration key as its argument and returns the value in its stored type.
+
+Additional helper functions to cast the value to various types are included
+(e.g. :py:meth:`~cfitall.registry.ConfigurationRegistry.get_bool`).
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..7b12a48
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,26 @@
+alabaster==0.7.12
+Babel==2.9.1
+certifi==2021.10.8
+charset-normalizer==2.0.12
+docutils==0.17.1
+idna==3.3
+imagesize==1.3.0
+Jinja2==3.1.1
+MarkupSafe==2.1.1
+packaging==21.3
+Pygments==2.11.2
+pyparsing==3.0.8
+pytz==2022.1
+requests==2.27.1
+snowballstemmer==2.2.0
+Sphinx==4.5.0
+sphinx-autodoc-typehints==1.17.0
+sphinx-rtd-theme==1.0.0
+sphinxcontrib-applehelp==1.0.2
+sphinxcontrib-devhelp==1.0.2
+sphinxcontrib-htmlhelp==2.0.0
+sphinxcontrib-jsmath==1.0.1
+sphinxcontrib-qthelp==1.0.3
+sphinxcontrib-serializinghtml==1.1.5
+urllib3==1.26.9
+


Test patch:
diff --git a/cfitall/tests/json/cfitall.json b/cfitall/tests/json/cfitall.json
new file mode 100644
index 0000000..5f09bc3
--- /dev/null
+++ b/cfitall/tests/json/cfitall.json
@@ -0,0 +1,21 @@
+{
+  "global": {
+    "name": "cfitjson",
+    "authors": [
+      "john doe",
+      "jane deer"
+    ]
+  },
+  "search": [
+    "/Users/wryfi/foo",
+    "/Users/wryfi/bar"
+  ],
+  "foo": {
+    "bar": "baz",
+    "bat": [
+      "bald",
+      "bam",
+      "bolt"
+    ]
+  }
+}
diff --git a/cfitall/tests/json/cfitall.yml b/cfitall/tests/json/cfitall.yml
new file mode 100644
index 0000000..4f29334
--- /dev/null
+++ b/cfitall/tests/json/cfitall.yml
@@ -0,0 +1,2 @@
+global:
+  name: nothing_to_see_here
\ No newline at end of file
diff --git a/cfitall/tests/test_config.py b/cfitall/tests/test_config.py
index 3830abb..1599ec4 100644
--- a/cfitall/tests/test_config.py
+++ b/cfitall/tests/test_config.py
@@ -1,14 +1,17 @@
+import decimal
 import os
 import unittest
 
-from cfitall.config import ConfigManager
+from cfitall.registry import ConfigurationRegistry
+from cfitall.providers.environment import EnvironmentProvider
+from cfitall.providers.filesystem import FilesystemProvider
 
 
-class TestConfigManager(unittest.TestCase):
+class TestConfigRegistry(unittest.TestCase):
     def setUp(self):
         super().setUp()
         os.environ["CFITALL__GLOBAL__NAME"] = "cfitall"
-        os.environ["CFITALL__GLOBAL__PATH"] = "/Users/wryfi,/Users/wryfi/tmp"
+        os.environ["CFITALL__GLOBAL__PATH"] = "[/Users/wryfi, /Users/wryfi/tmp]"
         os.environ["CFITALL__FOO__BANG"] = "WHAMMY!"
 
     def tearDown(self):
@@ -17,105 +20,255 @@ class TestConfigManager(unittest.TestCase):
             if key.startswith("CFITALL"):
                 del os.environ[key]
 
-    def test_create_object(self):
-        _cf = ConfigManager("cfitall")
-
-    def test_environment_variables(self):
-        cf = ConfigManager("cfitall")
-        self.assertEqual(cf.get("global.name"), "cfitall")
+    def test_create_registry_default(self):
+        cf = ConfigurationRegistry("test")
+        self.assertEqual(cf.name, "test")
+        self.assertEqual(cf.values["defaults"], {})
+        self.assertEqual(cf.values["super"], {})
+        self.assertEqual(cf.providers.ordering, ["filesystem", "environment"])
+        if home := os.getenv("HOME"):
+            self.assertEqual(
+                cf.providers.filesystem.path,
+                [
+                    os.path.join(home, ".local", "etc", "test"),
+                    os.path.join("/etc", "test"),
+                ],
+            )
+        else:
+            self.assertEqual(cf.providers.filesystem.path, os.path.join("/etc", "test"))
         self.assertEqual(
-            cf.get("global.path", list), ["/Users/wryfi", "/Users/wryfi/tmp"]
+            {"defaults": {}, "filesystem": {}, "environment": {}, "super": {}}, cf.all
         )
-        self.assertEqual(cf.get("foo.bang"), "WHAMMY!")
 
-    def test_config_file(self):
-        cf = ConfigManager("cfitall")
-        cf.add_config_path(os.path.dirname(os.path.abspath(__file__)))
-        cf.read_config()
-        self.assertEqual(cf.get("global.name"), "cfitall")
+    def test_config_keys(self):
+        cf = ConfigurationRegistry("cfitall")
+        cf.set_default("hello", "nurse")
+        cf.set("goodbye.cruel", "world")
         self.assertEqual(
-            cf.get("global.path", list), ["/Users/wryfi", "/Users/wryfi/tmp"]
+            cf.config_keys,
+            ["foo.bang", "global.name", "global.path", "goodbye.cruel", "hello"],
         )
-        self.assertEqual(cf.get("foo.bang"), "WHAMMY!")
-        self.assertEqual(cf.get("global.authors", list), ["john doe", "jane deer"])
+
+    def test_comma_list(self):
+        cf = ConfigurationRegistry("cfitall")
+        cf.set_default("test.list.withcommas", ["flenderson, toby", "martin, angela"])
         self.assertEqual(
-            cf.get("search", list), ["/Users/wryfi/foo", "/Users/wryfi/bar"]
+            cf.get("test.list.withcommas"), ["flenderson, toby", "martin, angela"]
         )
-        self.assertEqual(cf.get("foo.bar"), "baz")
 
     def test_config_order(self):
-        cf = ConfigManager("cfitall")
+        cf = ConfigurationRegistry("cfitall")
         cf.set_default("global.authors", ["michael scott", "jim halpert"])
         self.assertEqual(
-            cf.get("global.authors", list), ["michael scott", "jim halpert"]
+            cf.get_list("global.authors"), ["michael scott", "jim halpert"]
         )
-        cf.add_config_path(os.path.dirname(os.path.abspath(__file__)))
-        cf.read_config()
-        self.assertEqual(cf.get("global.authors", list), ["john doe", "jane deer"])
-        os.environ["CFITALL__GLOBAL__AUTHORS"] = "holly flax,robert california"
+        cf.providers.filesystem.path.append(
+            os.path.join(os.path.dirname(os.path.abspath(__file__)), "yaml")
+        ),
+        cf.update()
+        self.assertEqual(cf.get_list("global.authors"), ["john doe", "jane deer"])
+        os.environ["CFITALL__GLOBAL__AUTHORS"] = "[holly flax,robert california]"
         self.assertEqual(
-            cf.get("global.authors", list), ["holly flax", "robert california"]
+            cf.get_list("global.authors"), ["holly flax", "robert california"]
         )
         cf.set("global.authors", ["dwight schrute", "pam beesly"])
         self.assertEqual(
-            cf.get("global.authors", list), ["dwight schrute", "pam beesly"]
+            cf.get_list("global.authors"), ["dwight schrute", "pam beesly"]
         )
 
-    def test_env_bool(self):
-        cf = ConfigManager("cfitall")
-        os.environ["CFITALL__TEST__BOOLEAN__TRUE"] = "true"
-        os.environ["CFITALL__TEST__BOOLEAN__FALSE"] = "False"
-        os.environ[
-            "CFITALL__TEST__BOOLEAN__LIST"
-        ] = "true,false,True,this,False,is,nice"
-        self.assertEqual(cf.get("test.boolean.true"), True)
-        self.assertEqual(cf.get("test.boolean.false"), False)
+    def test_different_keys_same_value(self):
+        cf = ConfigurationRegistry("cfitall")
+        cf.set_default("test.string", "hello, world")
+        cf.set_default("test.string_2", "hello, world")
+        self.assertEqual(cf.get("test.string"), cf.get("test.string_2"))
+
+    def test_env_vars_list(self):
+        cf = ConfigurationRegistry("cfitall")
+        cf.set_default("hello", "nurse")
+        cf.set("goodbye.cruel", "nurse ratched")
         self.assertEqual(
-            cf.get("test.boolean.list"),
-            [True, False, True, "this", False, "is", "nice"],
+            cf.env_vars,
+            [
+                "CFITALL__FOO__BANG",
+                "CFITALL__GLOBAL__NAME",
+                "CFITALL__GLOBAL__PATH",
+                "CFITALL__GOODBYE__CRUEL",
+                "CFITALL__HELLO",
+            ],
         )
-        cf.env_bool = False
-        self.assertEqual(cf.get("test.boolean.true"), "true")
-        self.assertEqual(cf.get("test.boolean.false"), "False")
+
+    def test_get(self):
+        cf = ConfigurationRegistry("cfitall")
+        cf.set_default("test.bool", True)
+        cf.set_default("test.decimal", decimal.Decimal(123.45))
+        cf.set_default("test.float", 101.5)
+        cf.set_default("test.integer", 42)
+        self.assertEqual(type(cf.get("test.bool")), bool)
+        self.assertEqual(type(cf.get("test.decimal")), decimal.Decimal)
+        self.assertEqual(type(cf.get("test.float")), float)
+        self.assertEqual(type(cf.get("test.integer")), int)
+        self.assertEqual(type(cf.get("global.path")), list)
+        self.assertEqual(type(cf.get("global.name")), str)
+
+    def test_get_bool(self):
+        cf = ConfigurationRegistry("test")
+        cf.set_default("bool.true", True)
+        cf.set_default("bool.false", False)
+        cf.set_default("bool.int.true", 1)
+        cf.set_default("bool.int.false", 0)
+        self.assertTrue(cf.get_bool("bool.true"))
+        self.assertFalse(cf.get_bool("bool.false"))
+        self.assertEqual(type(cf.get_bool("bool.int.true")), bool)
+        self.assertTrue(cf.get_bool("bool.int.true"))
+        self.assertEqual(type(cf.get_bool("bool.int.false")), bool)
+        self.assertFalse(cf.get_bool("bool.int.false"))
+
+    def test_get_decimal(self):
+        cf = ConfigurationRegistry("cfitall")
+        cf.set_default("decimal.string", "3.14159")
+        cf.set_default("decimal.int", 5)
+        cf.set_default("decimal.float", 1 / 255.0)
+        cf.set_default("decimal.letters", "asdf")
+        self.assertEqual(cf.get_decimal("decimal.string"), decimal.Decimal("3.14159"))
+        self.assertEqual(cf.get_decimal("decimal.int"), decimal.Decimal(5))
         self.assertEqual(
-            cf.get("test.boolean.list"),
-            ["true", "false", "True", "this", "False", "is", "nice"],
+            cf.get_decimal("decimal.float"),
+            decimal.Decimal(0.00392156862745098033773416545955114997923374176025390625),
         )
+        with self.assertRaises(decimal.InvalidOperation):
+            cf.get_decimal("decimal.letters")
+        with self.assertRaises(ValueError):
+            cf.get_decimal("global.path")
 
-    def test_comma_list(self):
-        cf = ConfigManager("cfitall")
-        cf.set_default("test.list.withcommas", ["flenderson, toby", "martin, angela"])
+    def test_get_int(self):
+        cf = ConfigurationRegistry("cfitall")
+        cf.set_default("int.bool.false", False)
+        cf.set_default("int.bool.true", True)
+        cf.set_default("int.float", 3.14159)
+        cf.set_default("int.int", 5)
+        cf.set_default("int.letters", "asdf")
+        cf.set_default("int.string", "3.14159")
+        self.assertEqual(cf.get_int("int.bool.false"), 0)
+        self.assertEqual(cf.get_int("int.bool.true"), 1)
+        self.assertEqual(cf.get_int("int.float"), 3)
+        self.assertEqual(cf.get_int("int.int"), 5)
+        with self.assertRaises(ValueError):
+            cf.get_int("int.string")
+        with self.assertRaises(ValueError):
+            cf.get_int("int.letters")
+        with self.assertRaises(TypeError):
+            cf.get_int("global.path")
+
+    def test_get_list(self):
+        cf = ConfigurationRegistry("cfitall")
+        cf.set_default("csv.string", "hello, world")
+        self.assertEqual(cf.get_list("csv.string"), ["hello", "world"])
         self.assertEqual(
-            cf.get("test.list.withcommas"), ["flenderson, toby", "martin, angela"]
+            cf.get_list("csv.string", csv=False),
+            ["h", "e", "l", "l", "o", ",", " ", "w", "o", "r", "l", "d"],
         )
-        os.environ["CFITALL__TEST__LIST"] = "hello,world,melting"
-        self.assertEqual(cf.get("test.list"), ["hello", "world", "melting"])
-        cf.env_value_split = False
-        self.assertEqual(cf.get("test.list"), "hello,world,melting")
-
-    def test_space_list(self):
-        cf = ConfigManager("cfitall")
-        cf.set_default("test.list.withspaces", ["flenderson, toby", "martin, angela"])
         self.assertEqual(
-            cf.get("test.list.withcommas"), ["flenderson, toby", "martin, angela"]
+            cf.get_list("global.path"), ["/Users/wryfi", "/Users/wryfi/tmp"]
         )
-        os.environ[
-            "CFITALL__TEST__LIST"
-        ] = "hello world    melting	 antarctica   broadway"
-        cf.env_value_split_space = True
+
+    def test_get_string(self):
+        cf = ConfigurationRegistry("cfitall")
+        cf.set_default("string.bool", True)
+        cf.set_default("string.decimal", decimal.Decimal("1.5"))
+        cf.set_default("string.float", 1 / 3)
+        cf.set_default("string.int", 42)
+        self.assertEqual(cf.get_string("string.bool"), "True")
+        self.assertEqual(cf.get_string("string.decimal"), "1.5")
+        self.assertEqual(cf.get_string("string.float"), "0.3333333333333333")
+        self.assertEqual(cf.get_string("string.int"), "42")
+        self.assertEqual(cf.get_string("global.name"), "cfitall")
         self.assertEqual(
-            cf.get("test.list"), ["hello", "world", "melting", "antarctica", "broadway"]
+            cf.get_string("global.path"), "['/Users/wryfi', '/Users/wryfi/tmp']"
         )
-        cf.env_value_split_space = False
-        self.assertEqual(
-            cf.get("test.list"), "hello world    melting	 antarctica   broadway"
+
+    def test_set(self):
+        cf = ConfigurationRegistry("test")
+        cf.set("foo.bar", 42)
+        self.assertEqual(cf.values["super"]["foo"]["bar"], 42)
+        cf.set_default("foo.bar", 420)
+        self.assertEqual(cf.get("foo.bar"), 42)
+
+    def test_set_default(self):
+        cf = ConfigurationRegistry("test")
+        cf.set_default("foo.bar", 42)
+        self.assertEqual(cf.values["defaults"]["foo"]["bar"], 42)
+        self.assertEqual(cf.get("foo.bar"), 42)
+
+    def test_providers_empty_list(self):
+        cf = ConfigurationRegistry("cfitall", providers=[])
+        with self.assertRaises(KeyError):
+            _ = cf.values["environment"]
+        with self.assertRaises(KeyError):
+            _ = cf.values["filesystem"]
+        with self.assertRaises(AttributeError):
+            _ = cf.providers.filesystem
+        with self.assertRaises(AttributeError):
+            _ = cf.providers.environment
+        self.assertEqual(cf.providers.ordering, [])
+        self.assertIsNone(cf.get("global.name"))
+        self.assertIsNone(cf.get("foo.bar"))
+
+    def test_providers_env_only(self):
+        cf = ConfigurationRegistry(
+            "cfitall", providers=[EnvironmentProvider("cfitall")]
         )
+        with self.assertRaises(KeyError):
+            _ = cf.values["filesystem"]
+        with self.assertRaises(AttributeError):
+            _ = cf.providers.filesystem
+        self.assertEqual(cf.providers.ordering, ["environment"])
+        self.assertEqual(cf.get("global.name"), "cfitall")
+        self.assertIsNone(cf.get("foo.bar"))
 
-    def test_different_keys_same_value(self):
-        cf = ConfigManager("cfitall")
-        cf.set_default("test.string", "hello, world")
-        cf.set_default("test.string_2", "hello, world")
-        self.assertEqual(cf.get("test.string"), cf.get("test.string_2"))
+    def test_providers_filesystem_only(self):
+        cf = ConfigurationRegistry(
+            "cfitall",
+            providers=[
+                FilesystemProvider(
+                    [os.path.join(os.path.dirname(os.path.abspath(__file__)), "yaml")],
+                    "cfitall",
+                )
+            ],
+        )
+        cf.update()
+        with self.assertRaises(KeyError):
+            _ = cf.values["environment"]
+        with self.assertRaises(AttributeError):
+            _ = cf.providers.environment
+        self.assertEqual(cf.providers.ordering, ["filesystem"])
+        self.assertEqual(cf.get("global.name"), "cfityaml")
+        self.assertEqual(cf.get("foo.bar"), "baz")
+        self.assertIsNone(cf.get("foo.bang"))
+
+    def test_providers_env_extra(self):
+        cf = ConfigurationRegistry("cfitall")
+        cf.providers.register(EnvironmentProvider("extra", provider_name="env_extra"))
+        os.environ["CFITALL__STUFF"] = "cfitall_stuff"
+        os.environ["EXTRA__STUFF"] = "extra_stuff"
+        self.assertEqual(cf.get("stuff"), "extra_stuff")
+
+    def test_providers_filesystem_extra(self):
+        cf = ConfigurationRegistry("cfitall")
+        cf.providers.register(
+            FilesystemProvider(
+                [os.path.join(os.path.dirname(os.path.abspath(__file__)), "yaml")],
+                "extra",
+                provider_name="filesystem_extra",
+            ),
+        )
+        cf.providers.filesystem.path.append(
+            os.path.join(os.path.dirname(os.path.abspath(__file__)), "yaml")
+        ),
+        cf.update()
+        os.environ["EXTRA__STUFF"] = "extra_env"
+        self.assertEqual(cf.get("stuff"), "extra_yaml")
+        self.assertEqual(cf.get("foo.bar"), "baz")
+        self.assertEqual(cf.get("foo.bang"), "WHAMMY!")
 
 
 if __name__ == "__main__":
diff --git a/cfitall/tests/test_manager.py b/cfitall/tests/test_manager.py
new file mode 100644
index 0000000..39d8cf5
--- /dev/null
+++ b/cfitall/tests/test_manager.py
@@ -0,0 +1,66 @@
+import unittest
+
+from cfitall.manager import ProviderManager
+from cfitall.providers.environment import EnvironmentProvider
+from cfitall.providers.filesystem import FilesystemProvider
+
+
+class ProviderManagerTests(unittest.TestCase):
+    def test_init_empty(self):
+        manager = ProviderManager()
+        self.assertEqual(manager.ordering, [])
+
+    def test_init_with_providers(self):
+        providers = [
+            FilesystemProvider(["/etc/foo"], "foo"),
+            EnvironmentProvider("foo"),
+        ]
+        manager = ProviderManager(providers=providers)
+        self.assertEqual(len(manager.ordering), 2)
+        self.assertEqual("filesystem", manager.ordering[0])
+        self.assertEqual("environment", manager.ordering[1])
+        self.assertTrue(hasattr(manager, "environment"))
+        self.assertTrue(hasattr(manager, "filesystem"))
+
+    def test_get(self):
+        envprovider = EnvironmentProvider("foo")
+        manager = ProviderManager(providers=[envprovider])
+        self.assertEqual(manager.get("environment"), envprovider)
+
+    def test_get_none(self):
+        manager = ProviderManager()
+        self.assertIsNone(manager.get("filesystem"))
+
+    def test_register_provider(self):
+        manager = ProviderManager()
+        manager.register(EnvironmentProvider("foo"))
+        self.assertIn("environment", manager.ordering)
+        self.assertTrue(hasattr(manager, "environment"))
+
+    def test_register_duplicate_provider(self):
+        manager = ProviderManager()
+        manager.register(EnvironmentProvider("foo"))
+        self.assertIn("environment", manager.ordering)
+        self.assertTrue(hasattr(manager, "environment"))
+        with self.assertLogs(level="ERROR"):
+            manager.register(EnvironmentProvider("foo"))
+        self.assertEqual(len(manager.ordering), 1)
+
+    def test_deregister_provider(self):
+        manager = ProviderManager()
+        manager.register(EnvironmentProvider("foo"))
+        self.assertIn("environment", manager.ordering)
+        self.assertTrue(hasattr(manager, "environment"))
+        manager.deregister("environment")
+        self.assertNotIn("environment", manager.ordering)
+        self.assertFalse(hasattr(manager, "environment"))
+
+    def test_deregister_unregistered(self):
+        manager = ProviderManager()
+        manager.register(FilesystemProvider(["/etc/foo"], "foo"))
+        manager.deregister("environment")
+        self.assertIn("filesystem", manager.ordering)
+        self.assertEqual(len(manager.ordering), 1)
+        self.assertTrue(hasattr(manager, "filesystem"))
+        self.assertNotIn("environment", manager.ordering)
+        self.assertNotEqual(len(manager.ordering), 2)
diff --git a/cfitall/tests/test_provider_environment.py b/cfitall/tests/test_provider_environment.py
new file mode 100644
index 0000000..9035707
--- /dev/null
+++ b/cfitall/tests/test_provider_environment.py
@@ -0,0 +1,83 @@
+import os
+import unittest
+
+from cfitall.providers.environment import EnvironmentProvider
+
+
+class EnvironmentProviderTests(unittest.TestCase):
+    def setUp(self):
+        os.environ["CFITALL__GLOBAL__NAME"] = "cfitall"
+        os.environ["CFITALL__GLOBAL__PATH"] = "[/Users/wryfi, /Users/wryfi/tmp]"
+        os.environ["CFITALL__FOO__BANG"] = "WHAMMY!"
+        os.environ["CFITALL__MAGIC__ENABLE"] = "true"
+        os.environ["CFITALL__BOOL_LIST"] = "[true, false, false, true]"
+        os.environ[
+            "CFITALL__SPACE_LIST"
+        ] = "[one two \t\t three \t four             five six]"
+        super().setUp()
+
+    def tearDown(self):
+        for key, value in os.environ.items():
+            if key.startswith("CFITALL__"):
+                del os.environ[key]
+        super().tearDown()
+
+    def test_dict(self):
+        provider = EnvironmentProvider("cfitall")
+        self.assertEqual(
+            {
+                "global": {
+                    "name": "cfitall",
+                    "path": ["/Users/wryfi", "/Users/wryfi/tmp"],
+                },
+                "foo": {"bang": "WHAMMY!"},
+                "magic": {"enable": True},
+                "bool_list": [True, False, False, True],
+                "space_list": ["one two \t\t three \t four             five six"],
+            },
+            provider.dict,
+        )
+
+    def test_read_variable(self):
+        provider = EnvironmentProvider("cfitall")
+        self.assertEqual(provider.dict["global"]["name"], "cfitall")
+        self.assertEqual(
+            provider.dict["global"]["path"], ["/Users/wryfi", "/Users/wryfi/tmp"]
+        )
+        self.assertEqual(provider.dict["foo"]["bang"], "WHAMMY!")
+
+    def test_cast_bool(self):
+        provider = EnvironmentProvider("cfitall")
+        self.assertTrue(provider.dict["magic"]["enable"])
+
+    def test_cast_bool_disabled(self):
+        provider = EnvironmentProvider("cfitall", cast_bool=False)
+        self.assertEqual("true", provider.dict["magic"]["enable"])
+
+    def test_cast_bool_list(self):
+        provider = EnvironmentProvider("cfitall")
+        self.assertEqual([True, False, False, True], provider.dict["bool_list"])
+
+    def test_cast_bool_disabled_list(self):
+        provider = EnvironmentProvider("cfitall", cast_bool=False)
+        self.assertEqual(["true", "false", "false", "true"], provider.dict["bool_list"])
+
+    def test_list_whitespace(self):
+        provider = EnvironmentProvider("cfitall", value_separator=r"\s+")
+        self.assertEqual(
+            ["one", "two", "three", "four", "five", "six"], provider.dict["space_list"]
+        )
+
+    def test_single_element_list(self):
+        os.environ["CFITALL__ONELIST"] = "[onething]"
+        provider = EnvironmentProvider("cfitall")
+        self.assertEqual(provider.dict["onelist"], ["onething"])
+
+    def test_list_trailing_comma(self):
+        os.environ["CFITALL__EXTRA_COMMAS"] = "[,onething,,twothing,]"
+        provider = EnvironmentProvider("cfitall")
+        self.assertEqual(provider.dict["extra_commas"], ["onething", "twothing"])
+
+    def test_update(self):
+        provider = EnvironmentProvider("cfitall")
+        self.assertTrue(provider.update())
diff --git a/cfitall/tests/test_provider_filesystem.py b/cfitall/tests/test_provider_filesystem.py
new file mode 100644
index 0000000..46e61d4
--- /dev/null
+++ b/cfitall/tests/test_provider_filesystem.py
@@ -0,0 +1,70 @@
+import os
+import unittest
+
+from cfitall.providers.filesystem import FilesystemProvider
+
+
+class FilesystemProviderTests(unittest.TestCase):
+    def test_init_empty_provider(self):
+        provider = FilesystemProvider([], "test")
+        self.assertEqual(provider.path, [])
+        self.assertEqual(provider.prefix, "test")
+        self.assertEqual(provider.provider_name, "filesystem")
+        self.assertEqual(provider.dict, {})
+        self.assertIsNone(provider.config_file)
+        self.assertIsNone(provider.config_file_type)
+
+    def test_set_config_file_json_first(self):
+        json_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "json")
+        provider = FilesystemProvider([json_path], "cfitall")
+        provider._set_config_file()
+        self.assertEqual(provider.config_file, os.path.join(json_path, "cfitall.json"))
+        self.assertEqual(provider.config_file_type, "json")
+
+    def test_set_config_file_multipath(self):
+        json_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "json")
+        yaml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "yaml")
+        provider = FilesystemProvider([yaml_path, json_path], "cfitall")
+        provider._set_config_file()
+        self.assertEqual(provider.config_file, os.path.join(yaml_path, "cfitall.yml"))
+        self.assertEqual(provider.config_file_type, "yaml")
+
+    def test_read_config_file_none(self):
+        provider = FilesystemProvider([], "test")
+        provider._read_config_file()
+        self.assertEqual(provider.dict, {})
+
+    def test_read_config_file_json(self):
+        json_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "json")
+        provider = FilesystemProvider([json_path], "cfitall")
+        provider._set_config_file()
+        self.assertEqual(provider.dict, {})
+        provider._read_config_file()
+        self.assertEqual(sorted(provider.dict.keys()), ["foo", "global", "search"])
+        self.assertEqual(provider.dict["global"]["name"], "cfitjson")
+        self.assertEqual(
+            provider.dict["search"], ["/Users/wryfi/foo", "/Users/wryfi/bar"]
+        )
+
+    def test_read_config_file_yaml(self):
+        yaml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "yaml")
+        provider = FilesystemProvider([yaml_path], "cfitall")
+        provider._set_config_file()
+        self.assertEqual(provider.dict, {})
+        provider._read_config_file()
+        self.assertEqual(
+            sorted(provider.dict.keys()), ["foo", "global", "search", "stuff"]
+        )
+        self.assertEqual(provider.dict["global"]["name"], "cfityaml")
+        self.assertEqual(provider.dict["foo"]["bar"], "baz")
+
+    def test_update(self):
+        yaml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "yaml")
+        provider = FilesystemProvider([yaml_path], "cfitall")
+        self.assertEqual(provider.dict, {})
+        self.assertTrue(provider.update())
+        self.assertEqual(
+            sorted(provider.dict.keys()), ["foo", "global", "search", "stuff"]
+        )
+        self.assertEqual(provider.dict["global"]["name"], "cfityaml")
+        self.assertEqual(provider.dict["foo"]["bar"], "baz")
diff --git a/cfitall/tests/test_utils.py b/cfitall/tests/test_utils.py
index 6129781..3396de2 100644
--- a/cfitall/tests/test_utils.py
+++ b/cfitall/tests/test_utils.py
@@ -1,4 +1,3 @@
-import os
 import unittest
 from cfitall import utils
 
diff --git a/cfitall/tests/cfitall.yml b/cfitall/tests/yaml/cfitall.yml
similarity index 84%
rename from cfitall/tests/cfitall.yml
rename to cfitall/tests/yaml/cfitall.yml
index 890061b..cbcb53c 100644
--- a/cfitall/tests/cfitall.yml
+++ b/cfitall/tests/yaml/cfitall.yml
@@ -11,4 +11,5 @@ foo:
   bat:
     - bald
     - bam
-    - bolt
\ No newline at end of file
+    - bolt
+stuff: default_yaml
\ No newline at end of file
diff --git a/cfitall/tests/yaml/extra.yaml b/cfitall/tests/yaml/extra.yaml
new file mode 100644
index 0000000..70cd889
--- /dev/null
+++ b/cfitall/tests/yaml/extra.yaml
@@ -0,0 +1,1 @@
+stuff: extra_yaml
\ No newline at end of file