Repository: OCHA-DAP/ocha-anticipy (language: python)

Issue:
COD AB manipulation tools
Since we use the COD ABs a lot, it would be useful to have an easy way to download & manipulate them. A class is already started in `utils` but should probably be moved to `datasources` and expanded. 


Discussion / hints:

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 19575d87..00fa1e4f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -35,6 +35,7 @@ repos:
         additional_dependencies:
           - types-PyYAML==5.4.10
           - types-setuptools==57.4.0
+        exclude: tests
   - repo: https://github.com/igorshubovych/markdownlint-cli
     rev: v0.29.0
     hooks:
@@ -45,20 +46,24 @@ repos:
       - id: build-docs
         additional_dependencies:
           - sphinxcontrib-apidoc
-        args: ['--cache-dir', 'docs/build/doctrees', '--html-dir', 'docs/build/html', '--source-dir', 'docs/source']
+        args: ['--cache-dir', 'docs/build/doctrees', '--html-dir',
+               'docs/build/html', '--source-dir', 'docs/source']
         language_version: python3
   - repo: https://github.com/jazzband/pip-tools
     rev: 6.3.0
     hooks:
       - id: pip-compile
         name: pip-compile requirements.txt
-        files: setup.cfg|requirements/requirements.txt
-        args: [setup.cfg, -q, --no-header, --output-file=requirements/requirements.txt]
+        files: setup.cfg
+        args: [setup.cfg, -q, --no-header,
+               -o, requirements/requirements.txt]
       - id: pip-compile
         name: pip-compile requirements-dev.txt
-        files: requirements/(dev.in|requirements-dev.txt)
-        args: [requirements/dev.in, -q, --no-header, --output-file=requirements/requirements-dev.txt]
+        files: requirements/(dev.in|tests.in|requirements.txt)
+        args: [requirements/dev.in, -q, --no-header,
+               -o, requirements/requirements-dev.txt]
       - id: pip-compile
         name: pip-compile requirements-tests.txt
-        files: requirements/(tests.in|requirements-tests.txt)
-        args: [requirements/tests.in, -q, --no-header, --output-file=requirements/requirements-tests.txt]
+        files: requirements/tests.in
+        args: [requirements/tests.in, -q, --no-header,
+               -o, requirements/requirements-tests.txt]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc154d8f..98714244 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,10 +13,20 @@ and this project adheres to
 
 ### Added
 
-- Pre-commit hook to check Sphinx documentation can be built
+- pre-commit hook to check Sphinx documentation can be built
 - [`pip-compile`](https://github.com/jazzband/pip-tools#version-control-integration)
   pre-commit hook to update requirements files
-- version number is now specified in `src/aatoolbox/_version.py`
+- Version number is now specified in `src/aatoolbox/_version.py`
+- `DataSource` class as a base for all data sources
+- `CodAB` data source class for manipulating COD administrative boundaries
+- `Pipeline` class to serve as main API
+- Configuration files for:
+  - Bangladesh
+  - Ethiopia
+  - Malawi
+  - Nepal
+- HDX API utility
+- Caching decorator in IO utility
 
 ### Changed
 
diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt
index 0244051f..2668ce3d 100644
--- a/requirements/requirements-dev.txt
+++ b/requirements/requirements-dev.txt
@@ -1,17 +1,65 @@
+appdirs==1.4.4
+    # via
+    #   -r requirements/requirements.txt
+    #   requests-cache
 attrs==21.2.0
     # via
     #   -r requirements/requirements.txt
+    #   cattrs
     #   fiona
     #   pytest
+    #   requests-cache
 backports.entry-points-selectable==1.1.0
     # via virtualenv
+basicauth==0.4.1
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-utilities
+beautifulsoup4==4.10.0
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-utilities
+boto3==1.19.1
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
+botocore==1.22.1
+    # via
+    #   -r requirements/requirements.txt
+    #   boto3
+    #   s3transfer
+cattrs==1.8.0
+    # via
+    #   -r requirements/requirements.txt
+    #   requests-cache
+cchardet==2.1.7
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
 certifi==2021.10.8
     # via
     #   -r requirements/requirements.txt
     #   fiona
     #   pyproj
+    #   requests
+cffi==1.15.0
+    # via
+    #   -r requirements/requirements.txt
+    #   cryptography
 cfgv==3.3.1
     # via pre-commit
+chardet==4.0.0
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
+charset-normalizer==2.0.7
+    # via
+    #   -r requirements/requirements.txt
+    #   requests
+ckanapi==4.6
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-api
 click==8.0.3
     # via
     #   -r requirements/requirements.txt
@@ -19,6 +67,7 @@ click==8.0.3
     #   cligj
     #   fiona
     #   pip-tools
+    #   tabulator
 click-plugins==1.1.1
     # via
     #   -r requirements/requirements.txt
@@ -27,10 +76,43 @@ cligj==0.7.2
     # via
     #   -r requirements/requirements.txt
     #   fiona
+colorlog==6.5.0
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-utilities
 coverage[toml]==6.0.2
     # via pytest-cov
+cryptography==35.0.0
+    # via
+    #   -r requirements/requirements.txt
+    #   pyopenssl
+decorator==5.1.0
+    # via
+    #   -r requirements/requirements.txt
+    #   jsonpath-ng
 distlib==0.3.3
     # via virtualenv
+dnspython==2.1.0
+    # via
+    #   -r requirements/requirements.txt
+    #   email-validator
+docopt==0.6.2
+    # via
+    #   -r requirements/requirements.txt
+    #   ckanapi
+    #   num2words
+email-validator==1.1.3
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-utilities
+et-xmlfile==1.1.0
+    # via
+    #   -r requirements/requirements.txt
+    #   openpyxl
+exchangerates==0.3.4
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-country
 filelock==3.3.0
     # via virtualenv
 fiona==1.8.20
@@ -39,20 +121,88 @@ fiona==1.8.20
     #   geopandas
 geopandas==0.10.1
     # via -r requirements/requirements.txt
+greenlet==1.1.2
+    # via
+    #   -r requirements/requirements.txt
+    #   sqlalchemy
+hdx-python-api==5.3.2
+    # via -r requirements/requirements.txt
+hdx-python-country==3.0.2
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-api
+hdx-python-utilities==3.0.3
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-country
+html5lib==1.1
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-utilities
 identify==2.3.0
     # via pre-commit
+idna==3.3
+    # via
+    #   -r requirements/requirements.txt
+    #   email-validator
+    #   requests
+ijson==3.1.4
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
+inflect==5.3.0
+    # via
+    #   -r requirements/requirements.txt
+    #   quantulum3
 iniconfig==1.1.1
     # via pytest
+jmespath==0.10.0
+    # via
+    #   -r requirements/requirements.txt
+    #   boto3
+    #   botocore
+jsonlines==2.0.0
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
+jsonpath-ng==1.5.3
+    # via
+    #   -r requirements/requirements.txt
+    #   libhxl
+libhxl==4.24.1
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-country
+linear-tsv==1.1.0
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
+lxml==4.6.3
+    # via
+    #   -r requirements/requirements.txt
+    #   exchangerates
 munch==2.5.0
     # via
     #   -r requirements/requirements.txt
     #   fiona
+ndg-httpsclient==0.5.1
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-api
 nodeenv==1.6.0
     # via pre-commit
+num2words==0.5.10
+    # via
+    #   -r requirements/requirements.txt
+    #   quantulum3
 numpy==1.21.2
     # via
     #   -r requirements/requirements.txt
     #   pandas
+openpyxl==3.0.9
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
 packaging==21.0
     # via pytest
 pandas==1.3.3
@@ -67,14 +217,37 @@ platformdirs==2.4.0
     # via virtualenv
 pluggy==1.0.0
     # via pytest
+ply==3.11
+    # via
+    #   -r requirements/requirements.txt
+    #   jsonpath-ng
+    #   libhxl
 pre-commit==2.15.0
     # via -r requirements/dev.in
 py==1.10.0
     # via pytest
+pyasn1==0.4.8
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-api
+    #   ndg-httpsclient
+pycparser==2.20
+    # via
+    #   -r requirements/requirements.txt
+    #   cffi
 pydantic==1.8.2
     # via -r requirements/requirements.txt
+pyopenssl==21.0.0
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-api
+    #   ndg-httpsclient
 pyparsing==2.4.7
     # via packaging
+pyphonetics==0.5.3
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-country
 pyproj==3.2.1
     # via
     #   -r requirements/requirements.txt
@@ -88,13 +261,63 @@ pytest-cov==3.0.0
 python-dateutil==2.8.2
     # via
     #   -r requirements/requirements.txt
+    #   botocore
+    #   hdx-python-utilities
+    #   libhxl
     #   pandas
+python-io-wrapper==0.2
+    # via
+    #   -r requirements/requirements.txt
+    #   libhxl
+python-slugify==5.0.2
+    # via
+    #   -r requirements/requirements.txt
+    #   ckanapi
 pytz==2021.3
     # via
     #   -r requirements/requirements.txt
     #   pandas
-pyyaml==5.4.1
-    # via pre-commit
+pyyaml==6.0
+    # via
+    #   -r requirements/requirements.txt
+    #   pre-commit
+quantulum3==0.7.9
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-api
+ratelimit==2.2.1
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-utilities
+requests==2.26.0
+    # via
+    #   -r requirements/requirements.txt
+    #   ckanapi
+    #   exchangerates
+    #   libhxl
+    #   requests-cache
+    #   requests-file
+    #   tabulator
+requests-cache==0.8.1
+    # via
+    #   -r requirements/requirements.txt
+    #   libhxl
+requests-file==1.5.1
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-utilities
+ruamel.yaml==0.17.16
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-utilities
+ruamel.yaml.clib==0.2.6
+    # via
+    #   -r requirements/requirements.txt
+    #   ruamel.yaml
+s3transfer==0.5.0
+    # via
+    #   -r requirements/requirements.txt
+    #   boto3
 shapely==1.7.1
     # via
     #   -r requirements/requirements.txt
@@ -102,10 +325,36 @@ shapely==1.7.1
 six==1.16.0
     # via
     #   -r requirements/requirements.txt
+    #   basicauth
+    #   ckanapi
+    #   exchangerates
     #   fiona
+    #   html5lib
+    #   jsonpath-ng
+    #   linear-tsv
     #   munch
+    #   pyopenssl
     #   python-dateutil
+    #   requests-file
+    #   tabulator
+    #   url-normalize
     #   virtualenv
+soupsieve==2.2.1
+    # via
+    #   -r requirements/requirements.txt
+    #   beautifulsoup4
+sqlalchemy==1.4.26
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
+tabulator[cchardet]==1.53.5
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-utilities
+text-unidecode==1.3
+    # via
+    #   -r requirements/requirements.txt
+    #   python-slugify
 toml==0.10.2
     # via
     #   pre-commit
@@ -118,10 +367,44 @@ typing-extensions==3.10.0.2
     # via
     #   -r requirements/requirements.txt
     #   pydantic
+unicodecsv==0.14.1
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
+unidecode==1.3.2
+    # via
+    #   -r requirements/requirements.txt
+    #   libhxl
+    #   pyphonetics
+url-normalize==1.4.3
+    # via
+    #   -r requirements/requirements.txt
+    #   requests-cache
+urllib3==1.26.7
+    # via
+    #   -r requirements/requirements.txt
+    #   botocore
+    #   requests
+    #   requests-cache
 virtualenv==20.8.1
     # via pre-commit
+webencodings==0.5.1
+    # via
+    #   -r requirements/requirements.txt
+    #   html5lib
 wheel==0.37.0
-    # via pip-tools
+    # via
+    #   -r requirements/requirements.txt
+    #   libhxl
+    #   pip-tools
+xlrd==2.0.1
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
+xlrd3==1.1.0
+    # via
+    #   -r requirements/requirements.txt
+    #   libhxl
 
 # The following packages are considered to be unsafe in a requirements file:
 # pip
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index 87e66aeb..4c528199 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -1,47 +1,219 @@
+appdirs==1.4.4
+    # via requests-cache
 attrs==21.2.0
-    # via fiona
+    # via
+    #   cattrs
+    #   fiona
+    #   requests-cache
+basicauth==0.4.1
+    # via hdx-python-utilities
+beautifulsoup4==4.10.0
+    # via hdx-python-utilities
+boto3==1.19.1
+    # via tabulator
+botocore==1.22.1
+    # via
+    #   boto3
+    #   s3transfer
+cattrs==1.8.0
+    # via requests-cache
+cchardet==2.1.7
+    # via tabulator
 certifi==2021.10.8
     # via
     #   fiona
     #   pyproj
+    #   requests
+cffi==1.15.0
+    # via cryptography
+chardet==4.0.0
+    # via tabulator
+charset-normalizer==2.0.7
+    # via requests
+ckanapi==4.6
+    # via hdx-python-api
 click==8.0.3
     # via
     #   click-plugins
     #   cligj
     #   fiona
+    #   tabulator
 click-plugins==1.1.1
     # via fiona
 cligj==0.7.2
     # via fiona
+colorlog==6.5.0
+    # via hdx-python-utilities
+cryptography==35.0.0
+    # via pyopenssl
+decorator==5.1.0
+    # via jsonpath-ng
+dnspython==2.1.0
+    # via email-validator
+docopt==0.6.2
+    # via
+    #   ckanapi
+    #   num2words
+email-validator==1.1.3
+    # via hdx-python-utilities
+et-xmlfile==1.1.0
+    # via openpyxl
+exchangerates==0.3.4
+    # via hdx-python-country
 fiona==1.8.20
     # via geopandas
 geopandas==0.10.1
     # via aa-toolbox (setup.cfg)
+greenlet==1.1.2
+    # via sqlalchemy
+hdx-python-api==5.3.2
+    # via aa-toolbox (setup.cfg)
+hdx-python-country==3.0.2
+    # via hdx-python-api
+hdx-python-utilities==3.0.3
+    # via hdx-python-country
+html5lib==1.1
+    # via hdx-python-utilities
+idna==3.3
+    # via
+    #   email-validator
+    #   requests
+ijson==3.1.4
+    # via tabulator
+inflect==5.3.0
+    # via quantulum3
+jmespath==0.10.0
+    # via
+    #   boto3
+    #   botocore
+jsonlines==2.0.0
+    # via tabulator
+jsonpath-ng==1.5.3
+    # via libhxl
+libhxl==4.24.1
+    # via hdx-python-country
+linear-tsv==1.1.0
+    # via tabulator
+lxml==4.6.3
+    # via exchangerates
 munch==2.5.0
     # via fiona
+ndg-httpsclient==0.5.1
+    # via hdx-python-api
+num2words==0.5.10
+    # via quantulum3
 numpy==1.21.2
     # via
     #   aa-toolbox (setup.cfg)
     #   pandas
+openpyxl==3.0.9
+    # via tabulator
 pandas==1.3.3
     # via geopandas
+ply==3.11
+    # via
+    #   jsonpath-ng
+    #   libhxl
+pyasn1==0.4.8
+    # via
+    #   hdx-python-api
+    #   ndg-httpsclient
+pycparser==2.20
+    # via cffi
 pydantic==1.8.2
     # via aa-toolbox (setup.cfg)
+pyopenssl==21.0.0
+    # via
+    #   hdx-python-api
+    #   ndg-httpsclient
+pyphonetics==0.5.3
+    # via hdx-python-country
 pyproj==3.2.1
     # via geopandas
 python-dateutil==2.8.2
-    # via pandas
+    # via
+    #   botocore
+    #   hdx-python-utilities
+    #   libhxl
+    #   pandas
+python-io-wrapper==0.2
+    # via libhxl
+python-slugify==5.0.2
+    # via ckanapi
 pytz==2021.3
     # via pandas
+pyyaml==6.0
+    # via aa-toolbox (setup.cfg)
+quantulum3==0.7.9
+    # via hdx-python-api
+ratelimit==2.2.1
+    # via hdx-python-utilities
+requests==2.26.0
+    # via
+    #   ckanapi
+    #   exchangerates
+    #   libhxl
+    #   requests-cache
+    #   requests-file
+    #   tabulator
+requests-cache==0.8.1
+    # via libhxl
+requests-file==1.5.1
+    # via hdx-python-utilities
+ruamel.yaml==0.17.16
+    # via hdx-python-utilities
+ruamel.yaml.clib==0.2.6
+    # via ruamel.yaml
+s3transfer==0.5.0
+    # via boto3
 shapely==1.7.1
     # via geopandas
 six==1.16.0
     # via
+    #   basicauth
+    #   ckanapi
+    #   exchangerates
     #   fiona
+    #   html5lib
+    #   jsonpath-ng
+    #   linear-tsv
     #   munch
+    #   pyopenssl
     #   python-dateutil
+    #   requests-file
+    #   tabulator
+    #   url-normalize
+soupsieve==2.2.1
+    # via beautifulsoup4
+sqlalchemy==1.4.26
+    # via tabulator
+tabulator[cchardet]==1.53.5
+    # via hdx-python-utilities
+text-unidecode==1.3
+    # via python-slugify
 typing-extensions==3.10.0.2
     # via pydantic
+unicodecsv==0.14.1
+    # via tabulator
+unidecode==1.3.2
+    # via
+    #   libhxl
+    #   pyphonetics
+url-normalize==1.4.3
+    # via requests-cache
+urllib3==1.26.7
+    # via
+    #   botocore
+    #   requests
+    #   requests-cache
+webencodings==0.5.1
+    # via html5lib
+wheel==0.37.0
+    # via libhxl
+xlrd==2.0.1
+    # via tabulator
+xlrd3==1.1.0
+    # via libhxl
 
 # The following packages are considered to be unsafe in a requirements file:
 # setuptools
diff --git a/setup.cfg b/setup.cfg
index e84e419d..29f50cab 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -21,9 +21,11 @@ package_dir = = src
 include_package_data = true
 python_requires = >= 3.6
 install_requires =
+    hdx-python-api
     numpy
     geopandas
     pydantic
+    pyyaml
 
 [options.packages.find]
 where = src
diff --git a/src/aatoolbox/config/aatoolboxbase.py b/src/aatoolbox/config/aatoolboxbase.py
deleted file mode 100644
index c0a2b862..00000000
--- a/src/aatoolbox/config/aatoolboxbase.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""Base class for aatoolbox data manipulation."""
-from pathlib import Path
-
-from aatoolbox.config.config import Config
-
-
-class AaToolboxBase(object):
-    """
-    Base class object that contains path convenience functions.
-
-    Parameters
-    ----------
-    iso3 : str
-        Country ISO3
-    module_base_dir : str
-        Module directory name (usually correspond to data source)
-    """
-
-    def __init__(self, iso3: str, module_base_dir: str):
-
-        self.config = Config(iso3)
-        self.module_base_dir = module_base_dir
-
-    def _get_base_dir(self, is_public=False, is_raw=False):
-        public_dir = (
-            self.config.path.public if is_public else self.config.path.private
-        )
-        raw_dir = (
-            self.config.path.raw if is_raw else self.config.path.processed
-        )
-        return (
-            self.config.path.base
-            / public_dir
-            / raw_dir
-            / self.config.country.iso3
-            / self.module_base_dir
-        )
-
-    def get_public_raw_base_dir(self) -> Path:
-        """Get the data source public raw directory."""
-        return self._get_base_dir(is_public=True, is_raw=True)
diff --git a/src/aatoolbox/config/config.py b/src/aatoolbox/config/config.py
deleted file mode 100644
index 13a76faa..00000000
--- a/src/aatoolbox/config/config.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Main runtime configuration."""
-import os
-from pathlib import Path
-
-from aatoolbox.config import country
-
-
-class Config(object):
-    """
-    Base configuration object.
-
-    Parameters
-    ----------
-    iso3 : str
-        Country ISO3, must be exactly 3 characters long
-    """
-
-    def __init__(self, iso3: str):
-        self.country = country.get_country_config(iso3)
-        self.path = _PathConfig()
-
-
-class _PathConfig(object):
-    """Global directory parameters."""
-
-    BASE_DIR_ENV = "AA_DATA_DIR"
-
-    def __init__(self):
-        self.base = Path(os.environ[self.BASE_DIR_ENV])
-        self.public = "public"
-        self.private = "private"
-        self.raw = "raw"
-        self.processed = "processed"
diff --git a/src/aatoolbox/config/countries/bgd.yaml b/src/aatoolbox/config/countries/bgd.yaml
new file mode 100644
index 00000000..d8d98fd2
--- /dev/null
+++ b/src/aatoolbox/config/countries/bgd.yaml
@@ -0,0 +1,6 @@
+iso3: bgd
+codab:
+  hdx_address: administrative-boundaries-of-bangladesh-as-of-2015
+  hdx_dataset_name: bgd_adm_bbs_20201113_SHP.zip
+  layer_base_name: bgd_adm_bbs_20201113_SHP/bgd_admbnda_adm{admin_level}_bbs_20201113.shp
+  admin_level_max: 4
diff --git a/src/aatoolbox/config/countries/eth.yaml b/src/aatoolbox/config/countries/eth.yaml
new file mode 100644
index 00000000..b1fe1596
--- /dev/null
+++ b/src/aatoolbox/config/countries/eth.yaml
@@ -0,0 +1,6 @@
+iso3: eth
+codab:
+  hdx_address: ethiopia-cod-ab
+  hdx_dataset_name: ETH Administrative Divisions Shapefiles.zip
+  layer_base_name: eth_admbnda_adm{admin_level}_csa_bofed_20201008.shp
+  admin_level_max: 2
diff --git a/src/aatoolbox/config/countries/mwi.yaml b/src/aatoolbox/config/countries/mwi.yaml
new file mode 100644
index 00000000..f7a103d1
--- /dev/null
+++ b/src/aatoolbox/config/countries/mwi.yaml
@@ -0,0 +1,6 @@
+iso3: mwi
+codab:
+  hdx_address: malawi-administrative-level-0-3-boundaries
+  hdx_dataset_name: mwi_adm_nso_20181016_SHP.zip
+  layer_base_name: mwi_admbnda_adm{admin_level}_nso_20181016.shp
+  admin_level_max: 3
diff --git a/src/aatoolbox/config/countries/npl.yaml b/src/aatoolbox/config/countries/npl.yaml
index 5f4ebcf6..4ee6d914 100644
--- a/src/aatoolbox/config/countries/npl.yaml
+++ b/src/aatoolbox/config/countries/npl.yaml
@@ -1,6 +1,8 @@
 iso3: npl
 codab:
-  base_dir: npl_admbnda_ocha_20201117
-  base_zip: npl_admbnda_nd_20201117_shp.zip
-  admin0:
-    base_layer: npl_admbnda_adm0_nd_20201117.shp
+  hdx_address: administrative-bounadries-of-nepal
+  hdx_dataset_name: npl_admbnda_nd_20201117_SHP.zip
+  layer_base_name: npl_admbnda_adm{admin_level}_nd_20201117.shp
+  admin_level_max: 2
+  custom_layer_names:
+    - npl_admbnda_districts_nd_20201117
diff --git a/src/aatoolbox/config/country.py b/src/aatoolbox/config/country.py
deleted file mode 100644
index f42d1e77..00000000
--- a/src/aatoolbox/config/country.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""Country configuration setting base class."""
-from pathlib import Path
-
-from pydantic import BaseModel
-
-from aatoolbox.utils.io import parse_yaml
-
-
-class _CodAdmin(BaseModel):
-    base_layer: str
-
-
-class _CodABConfig(BaseModel):
-    base_dir: str
-    base_zip: str
-    admin0: _CodAdmin
-
-
-class CountryConfig(BaseModel):
-    """Country configuration."""
-
-    iso3: str
-    codab: _CodABConfig
-
-
-def get_country_config(iso3: str):
-    """
-    Return a country configuration object.
-
-    Parameters
-    ----------
-    iso3 : str
-        Country ISO3, must e exactly 3 characters long
-
-    Returns
-    -------
-    CountryConfig instance
-    """
-    # TODO: validate iso3
-    parameters = parse_yaml(
-        Path(__file__).parent.resolve() / f"countries/{iso3}.yaml"
-    )
-    return CountryConfig(**parameters)
diff --git a/src/aatoolbox/config/countryconfig.py b/src/aatoolbox/config/countryconfig.py
new file mode 100644
index 00000000..f30fd945
--- /dev/null
+++ b/src/aatoolbox/config/countryconfig.py
@@ -0,0 +1,62 @@
+"""Country configuration setting base class."""
+from pathlib import Path
+from typing import Optional
+
+from pydantic import BaseModel
+
+from aatoolbox.utils.io import parse_yaml
+
+
+class CodABConfig(BaseModel):
+    """COD AB configuration.
+
+    Parameters
+    ----------
+    hdx_address: str
+        The page where the COD AB dataset resides on on HDX. Can be found
+        by taking the portion of the url after ``data.humdata.org/dataset/``
+    hdx_dataset_name: str
+        COD AB dataset name on HDX. Can be found by taking the filename as it
+        appears on the dataset page.
+    layer_base_name: str
+        The base name of the different admin layers, that presumably only
+        change by a single custom_layer_number depending on the level. Should
+        contain {admin_level} in place of the custom_layer_number.
+    admin_level_max: int
+        The maximum admin level available in the shapefile.
+    custom_layer_names: list, optional
+        Any additional layer names that don't fit into the admin level paradigm
+    """
+
+    hdx_address: str
+    hdx_dataset_name: str
+    layer_base_name: str  # TODO: validate that it has {admin_level}
+    admin_level_max: int
+    custom_layer_names: Optional[list]
+
+
+class CountryConfig(BaseModel):
+    """Country configuration."""
+
+    iso3: str
+    codab: CodABConfig
+
+
+def get_country_config(iso3: str) -> CountryConfig:
+    """
+    Return a country configuration object.
+
+    Parameters
+    ----------
+    iso3 : str
+        Country ISO3, must e exactly 3 characters long
+
+    Returns
+    -------
+    CountryConfig instance
+    """
+    # TODO: validate iso3
+    parameters = parse_yaml(
+        Path(__file__).parent.resolve() / f"countries/{iso3}.yaml"
+    )
+    return CountryConfig(**parameters)
diff --git a/src/aatoolbox/config/pathconfig.py b/src/aatoolbox/config/pathconfig.py
new file mode 100644
index 00000000..0b79fb80
--- /dev/null
+++ b/src/aatoolbox/config/pathconfig.py
@@ -0,0 +1,22 @@
+"""Path configuration."""
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+_BASE_DIR_ENV = "AA_DATA_DIR"
+
+
+@dataclass
+class PathConfig:
+    """Global directory parameters."""
+
+    base_dir_env: str = _BASE_DIR_ENV
+    public: str = "public"
+    private: str = "private"
+    raw: str = "raw"
+    processed: str = "processed"
+
+    def __post_init__(self):
+        """Set the base path based on the environemnt variable."""
+        self.base_path = Path(os.environ[self.base_dir_env])
diff --git a/src/aatoolbox/datasources/codab/__init__.py b/src/aatoolbox/datasources/codab/__init__.py
new file mode 100644
index 00000000..60fc74f9
--- /dev/null
+++ b/src/aatoolbox/datasources/codab/__init__.py
@@ -0,0 +1,11 @@
+"""Methods pertaining to the COD administrative boundaries.
+
+`Common Operational Datasets <https://cod.unocha.org>`
+(CODs) are definitive reference datasets governed by OCHA Field Information
+Section (FIS) and designed
+to support decision making during a humanitarian response.
+The Administrative Boundary (AB) CODs are geospatial datasets that
+delineate a country's borders and internal regions.
+A key feature of the COD AB datasets are P-codes, which are unique
+alphanumeric identifiers for each geographic region.
+"""
diff --git a/src/aatoolbox/datasources/codab/codab.py b/src/aatoolbox/datasources/codab/codab.py
new file mode 100644
index 00000000..b7560b12
--- /dev/null
+++ b/src/aatoolbox/datasources/codab/codab.py
@@ -0,0 +1,98 @@
+"""Download and manipulate COD administrative boundaries."""
+from pathlib import Path
+
+import geopandas as gpd
+
+from aatoolbox.datasources.datasource import DataSource
+from aatoolbox.utils.hdx_api import load_dataset_from_hdx
+from aatoolbox.utils.io import check_file_existence
+
+_MODULE_BASENAME = "cod_ab"
+
+
+class CodAB(DataSource):
+    """
+    Work with COD AB (administrative boundaries).
+
+    Parameters
+    ----------
+    iso3 : (str)
+        country iso3
+    """
+
+    def __init__(self, iso3: str):
+        super().__init__(
+            iso3=iso3, module_base_dir=_MODULE_BASENAME, is_public=True
+        )
+        self._raw_filepath = (
+            self._raw_base_dir / f"{self._iso3}_{_MODULE_BASENAME}.shp.zip"
+        )
+
+    def download(
+        self, hdx_address: str, hdx_dataset_name: str, clobber: bool = False
+    ) -> Path:
+        """
+        Download COD AB file from HDX.
+
+        Parameters
+        ----------
+        hdx_address: str
+            URL suffix of dataset page on HDX
+        hdx_dataset_name: str
+            Name of dataset on HDX
+        clobber : bool, default = False
+            If True, overwrites existing COD AB files
+
+        Returns
+        -------
+        The downloaded filepath
+
+        Examples
+        --------
+        >>> from aatoolbox.datasources.codab.codab import CodAB
+        >>> # Download COD administrative boundaries for Nepal
+        >>> codab = CodAB("npl")
+        >>> npl_cod_shapefile = codab.download(
+        ...    hdx_address="administrative-bounadries-of-nepal",
+        ...    hdx_dataset_name="npl_admbnda_nd_20201117_SHP.zip")
+        """
+        return self._download(
+            filepath=self._raw_filepath,
+            hdx_address=hdx_address,
+            hdx_dataset_name=hdx_dataset_name,
+            clobber=clobber,
+        )
+
+    @staticmethod
+    @check_file_existence
+    def _download(
+        filepath: Path, hdx_address: str, hdx_dataset_name: str, clobber: bool
+    ):
+        return load_dataset_from_hdx(
+            hdx_address=hdx_address,
+            hdx_dataset_name=hdx_dataset_name,
+            output_filepath=filepath,
+        )
+
+    def load_admin_layer(self, layer_name: str) -> gpd.GeoDataFrame:
+        """
+        Get an admin level by layer name.
+
+        Parameters
+        ----------
+        layer_name: str
+            The admin layer name
+
+        Returns
+        -------
+        geopandas dataframe with COD AB admin information
+
+        Examples
+        --------
+        >>> from aatoolbox.datasources.codab.codab import CodAB
+        >>> # Retrieve admin 0 boundaries for Nepal
+        >>> codab = CodAB("npl")
+        >>> npl_admin0 = codab.load_admin_layer(
+        ...     layer_name="npl_admbnda_adm2_20201117.shp")
+        """
+        return gpd.read_file(f"zip:///{self._raw_filepath / layer_name}")
diff --git a/src/aatoolbox/datasources/datasource.py b/src/aatoolbox/datasources/datasource.py
new file mode 100644
index 00000000..b75cb444
--- /dev/null
+++ b/src/aatoolbox/datasources/datasource.py
@@ -0,0 +1,51 @@
+"""Base class for aatoolbox data source."""
+from pathlib import Path
+
+from aatoolbox.config.pathconfig import PathConfig
+
+
+class DataSource:
+    """
+    Base class object that contains path convenience functions.
+
+    Parameters
+    ----------
+    iso3: str
+        Country ISO3
+    module_base_dir : str
+        Module directory name (usually correspond to data source)
+    is_public: bool, default = False
+        Whether the dataset is public or private. Determines top-level
+        directory structure.
+    """
+
+    def __init__(
+        self, iso3: str, module_base_dir: str, is_public: bool = False
+    ):
+
+        self._iso3 = iso3
+        self._module_base_dir = module_base_dir
+        self._path_config = PathConfig()
+        self._raw_base_dir = self._get_base_dir(
+            is_public=is_public, is_raw=True
+        )
+        self._processed_base_dir = self._get_base_dir(
+            is_public=is_public, is_raw=False
+        )
+
+    def _get_base_dir(self, is_public: bool, is_raw: bool) -> Path:
+        permission_dir = (
+            self._path_config.public
+            if is_public
+            else self._path_config.private
+        )
+        state_dir = (
+            self._path_config.raw if is_raw else self._path_config.processed
+        )
+        return (
+            self._path_config.base_path
+            / permission_dir
+            / state_dir
+            / self._iso3
+            / self._module_base_dir
+        )
diff --git a/src/aatoolbox/pipeline.py b/src/aatoolbox/pipeline.py
new file mode 100644
index 00000000..5ecc2dab
--- /dev/null
+++ b/src/aatoolbox/pipeline.py
@@ -0,0 +1,103 @@
+"""Pipeline initializer."""
+import geopandas as gpd
+
+from aatoolbox.config.countryconfig import get_country_config
+from aatoolbox.datasources.codab.codab import CodAB
+
+
+class Pipeline:
+    """
+    Initialize a configured pipeline.
+
+    Parameters
+    ----------
+    iso3_unvalidated: str
+        Country _iso3 passed in by the user
+    """
+
+    def __init__(self, iso3_unvalidated: str):
+        self._config = get_country_config(iso3_unvalidated)
+        self._codab = CodAB(self._config.iso3)
+
+    def load_codab(
+        self, admin_level: int, clobber: bool = False
+    ) -> gpd.GeoDataFrame:
+        """
+        Get the COD AB data by admin level.
+
+        Parameters
+        ----------
+        admin_level: int
+            The administrative level
+        clobber: bool
+            If true, overwrite existing COD AB files
+
+        Returns
+        -------
+        COD AB geodataframe with specified admin level
+
+        Examples
+        --------
+        >>> from aatoolbox.pipeline import Pipeline
+        >>> # Get admin 0 boundaries for Nepal
+        >>> pipeline = Pipeline("npl")
+        >>> npl_admin0 = pipeline.load_codab(admin_level=2)
+        """
+        admin_level_max = self._config.codab.admin_level_max
+        if admin_level > admin_level_max:
+            raise AttributeError(
+                f"Admin level {admin_level} requested, but maximum set to "
+                f"{admin_level_max} in {self._config.iso3.upper()} config file"
+            )
+        return self._load_codab(
+            layer_name=self._config.codab.layer_base_name.format(
+                admin_level=admin_level
+            ),
+            clobber=clobber,
+        )
+
+    def load_codab_custom(
+        self, custom_layer_number: int = 0, clobber: bool = False
+    ):
+        """
+        Get the COD AB data from a custom (non-level) layer.
+
+        Parameters
+        ----------
+        custom_layer_number: int
+            The 0-indexed number of the layer listed in the custom_layer_names
+            parameter of the country's config file
+        clobber: bool
+            If true, overwrite existing COD AB files
+
+        Returns
+        -------
+        COD AB geodataframe with custom admin level
+
+        Examples
+        --------
+        >>> from aatoolbox.pipeline import Pipeline
+        >>> # Get district boundaries for Nepal
+        >>> pipeline = Pipeline("npl")
+        >>> npl_admin0 = pipeline.load_codab_custom(custom_layer_number=0)
+        """
+        try:
+            # Ignore mypy for this line because custom_layer_names could be
+            # None, but this is handled by the caught exceptions
+            layer_name = self._config.codab.custom_layer_names[
+                custom_layer_number
+            ]  # type: ignore
+        except (IndexError, TypeError):
+            raise AttributeError(
+                f"{custom_layer_number}th custom layer requested but not "
+                f"available in {self._config.iso3.upper()} config file"
+            )
+        return self._load_codab(layer_name=layer_name, clobber=clobber)
+
+    def _load_codab(self, layer_name: str, clobber: bool):
+        self._codab.download(
+            hdx_address=self._config.codab.hdx_address,
+            hdx_dataset_name=self._config.codab.hdx_dataset_name,
+            clobber=clobber,
+        )
+        return self._codab.load_admin_layer(layer_name=layer_name)
diff --git a/src/aatoolbox/utils/codab.py b/src/aatoolbox/utils/codab.py
deleted file mode 100644
index 0a64bdec..00000000
--- a/src/aatoolbox/utils/codab.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""Retrieve COD administrative boundaries."""
-import geopandas as gpd
-
-from aatoolbox.config.aatoolboxbase import AaToolboxBase
-
-MODULE_BASE_DIR = "cod_ab"
-
-
-class CodAB(AaToolboxBase):
-    """
-    Work with COD AB (administrative boundaries).
-
-    Parameters
-    ----------
-    iso3 : (str)
-        country iso3
-    """
-
-    def __init__(self, iso3: str):
-        super().__init__(iso3=iso3, module_base_dir=MODULE_BASE_DIR)
-
-    def get_admin0(self):
-        """
-        Get the admin level 0 COD AB for a country.
-
-        Returns
-        -------
-        geopandas dataframe with COD AB admin 0
-
-        Examples
-        --------
-        >>> from aatoolbox.utils.codab import CodAB
-        >>> # Get admin 0 boundaries for Nepal
-        >>> codab = CodAB("npl")
-        >>> npl_admin0 = codab.get_admin0()
-        """
-        shapefile = (
-            self.get_public_raw_base_dir()
-            / self.config.country.codab.base_dir
-            / f"{self.config.country.codab.base_zip}"
-            f"!{self.config.country.codab.admin0.base_layer}"
-        )
-        return gpd.read_file(f"zip://{shapefile}")
diff --git a/src/aatoolbox/utils/hdx_api.py b/src/aatoolbox/utils/hdx_api.py
new file mode 100644
index 00000000..ebe2f1ad
--- /dev/null
+++ b/src/aatoolbox/utils/hdx_api.py
@@ -0,0 +1,52 @@
+"""Use HDX python API to download data."""
+import logging
+import shutil
+import tempfile
+from pathlib import Path
+
+from hdx.data.dataset import Dataset
+from hdx.hdx_configuration import Configuration
+
+USER_AGENT = "aa-toolbox"
+
+logger = logging.getLogger(__name__)
+Configuration.create(user_agent=USER_AGENT, hdx_read_only=True)
+
+
+def load_dataset_from_hdx(
+    hdx_address: str, hdx_dataset_name: str, output_filepath: Path
+) -> Path:
+    """
+    Use the HDX API to download a dataset based on the address and dataset ID.
+
+    Parameters
+    ----------
+    hdx_address : str
+        The page where the dataset resides on on HDX. Can be found
+        by taking the portion of the url after ``data.humdata.org/dataset/``
+    hdx_dataset_name : str
+        Dataset name on HDX. Can be found by taking the filename as it
+        appears on the dataset page.
+    output_filepath : Path
+        Target filepath for the dataset
+
+    Returns
+    -------
+    The full path of the downloaded dataset
+
+    """
+    logger.info(f"Querying HDX API for dataset {hdx_address}")
+    resources = Dataset.read_from_hdx(hdx_address).get_resources()
+    for resource in resources:
+        if resource["name"] == hdx_dataset_name:
+            logger.info(f"Downloading dataset {hdx_dataset_name}")
+            with tempfile.TemporaryDirectory() as tempdir:
+                _, downloaded_filepath = resource.download(folder=tempdir)
+                output_filepath.parent.mkdir(parents=True, exist_ok=True)
+                shutil.copy(downloaded_filepath, output_filepath)
+            logger.info(f"Saved to {output_filepath}")
+            return Path(output_filepath)
+    raise FileNotFoundError(
+        f'HDX dataset with address "{hdx_address}" and name '
+        f'"{hdx_dataset_name}" not found'
+    )
diff --git a/src/aatoolbox/utils/io.py b/src/aatoolbox/utils/io.py
index a1d42da0..03ada5e4 100644
--- a/src/aatoolbox/utils/io.py
+++ b/src/aatoolbox/utils/io.py
@@ -1,9 +1,15 @@
 """Function for I/O."""
+import logging
 from pathlib import Path
-from typing import Union
+from typing import Any, Callable, TypeVar, Union, cast
 
 import yaml
 
+logger = logging.getLogger(__name__)
+
+# For typing the decorator
+F = TypeVar("F", bound=Callable[..., Any])
+
 
 def parse_yaml(filename: Union[str, Path]) -> dict:
     """
@@ -21,3 +27,37 @@ def parse_yaml(filename: Union[str, Path]) -> dict:
     with open(file=filename, mode="r") as stream:
         config = yaml.safe_load(stream)
     return config
+
+
+def check_file_existence(func: F) -> F:
+    """
+    Don't overwrite existing data.
+
+    Avoid recreating data if it already exists and if clobber not
+    toggled by user. Can be used to wrap a function whose first parameter
+    is a filepath.
+
+    Parameters
+    ----------
+    func : function
+        The function to wrap. The first parameter of this function must
+        be the filepath (of type Path), and it can also have an optional
+        "clobber" boolean keyword parameter.
+
+    Returns
+    -------
+    If filepath exists, returns filepath. Otherwise, returns the result of
+    the decorated function.
+
+    """
+
+    def wrapper(filepath: Path, *args, **kwargs):
+        if filepath.exists() and not kwargs.get("clobber", False):
+            logger.info(
+                f"File {filepath} exists and clobber set to False, "
+                f"using existing files"
+            )
+            return filepath
+        return func(filepath, *args, **kwargs)
+
+    return cast(F, wrapper)


Test patch:
diff --git a/tests/pipeline/conftest.py b/tests/pipeline/conftest.py
new file mode 100644
index 00000000..bb73c731
--- /dev/null
+++ b/tests/pipeline/conftest.py
@@ -0,0 +1,28 @@
+"""Fixtures for all pipeline-related tests."""
+import pytest
+
+from aatoolbox.pipeline import Pipeline
+from aatoolbox.utils.io import parse_yaml
+
+CONFIG_FILE = "tests/pipeline/fake_config.yaml"
+FAKE_AA_DATA_DIR = "fake_aa_dir"
+ISO3 = "abc"
+
+
+@pytest.fixture(scope="session", autouse=True)
+def mock_aa_data_dir(session_mocker):
+    """Mock out the AA_DATA_DIR environment variable."""
+    session_mocker.patch.dict(
+        "aatoolbox.config.pathconfig.os.environ",
+        {"AA_DATA_DIR": FAKE_AA_DATA_DIR},
+    )
+
+
+@pytest.fixture
+def pipeline(mocker):
+    """Fixture for pipeline with test config params."""
+    mocker.patch(
+        "aatoolbox.config.countryconfig.parse_yaml",
+        return_value=parse_yaml(CONFIG_FILE),
+    )
+    return Pipeline(iso3_unvalidated=ISO3)
diff --git a/tests/pipeline/fake_config.yaml b/tests/pipeline/fake_config.yaml
new file mode 100644
index 00000000..4fb95db4
--- /dev/null
+++ b/tests/pipeline/fake_config.yaml
@@ -0,0 +1,6 @@
+iso3: abc
+codab:
+  hdx_address: fake_hdx_address
+  hdx_dataset_name: fake_hdx_dataset_name
+  layer_base_name: fake_layer_base_name_level{admin_level}
+  admin_level_max: 2
diff --git a/tests/pipeline/test_codab.py b/tests/pipeline/test_codab.py
new file mode 100644
index 00000000..da74e11e
--- /dev/null
+++ b/tests/pipeline/test_codab.py
@@ -0,0 +1,73 @@
+"""Test COD AB methods as called from the pipeline."""
+from pathlib import Path
+
+import pytest
+from conftest import FAKE_AA_DATA_DIR, ISO3
+
+from aatoolbox.datasources.codab.codab import _MODULE_BASENAME
+
+
+@pytest.fixture(autouse=True)
+def downloader(mocker):
+    """Mock the HDX download function."""
+    return mocker.patch(
+        "aatoolbox.datasources.codab.codab.load_dataset_from_hdx"
+    )
+
+
+@pytest.fixture(autouse=True)
+def gpd_read_file(mocker):
+    """Mock GeoPandas file reading function."""
+    return mocker.patch("aatoolbox.datasources.codab.codab.gpd.read_file")
+
+
+def test_codab_download(pipeline, downloader):
+    """Test that load_codab calls the HDX API to download."""
+    pipeline.load_codab(admin_level=2)
+    downloader.assert_called_with(
+        hdx_address=pipeline._config.codab.hdx_address,
+        hdx_dataset_name=pipeline._config.codab.hdx_dataset_name,
+        output_filepath=Path(FAKE_AA_DATA_DIR)
+        / f"public/raw/{ISO3}/{_MODULE_BASENAME}/"
+        f"{ISO3}_{_MODULE_BASENAME}.shp.zip",
+    )
+
+
+def test_codab_load_admin_level(pipeline, gpd_read_file):
+    """Test that load_codab retrieves expected file and layer name."""
+    admin_level = 2
+    expected_layer_name = pipeline._config.codab.layer_base_name.format(
+        admin_level=admin_level
+    )
+
+    pipeline.load_codab(admin_level=admin_level)
+
+    gpd_read_file.assert_called_with(
+        f"zip:///{FAKE_AA_DATA_DIR}/public/raw/{ISO3}/{_MODULE_BASENAME}/"
+        f"{ISO3}_{_MODULE_BASENAME}.shp.zip/{expected_layer_name}"
+    )
+
+
+def test_codab_too_high_admin_level(pipeline):
+    """Test raised error when too high admin level requested."""
+    with pytest.raises(AttributeError):
+        pipeline.load_codab(admin_level=10)
+
+
+def test_codab_custom(pipeline, gpd_read_file):
+    """Test that load_codab_custom retrieves expected file and layer name."""
+    custom_layer_number = 1
+    custom_layer_name_list = ["custom_layer_A", "custom_layer_B"]
+    pipeline._config.codab.custom_layer_names = custom_layer_name_list
+    pipeline.load_codab_custom(custom_layer_number)
+    gpd_read_file.assert_called_with(
+        f"zip:///{FAKE_AA_DATA_DIR}/public/raw/{ISO3}/{_MODULE_BASENAME}/"
+        f"{ISO3}_{_MODULE_BASENAME}.shp.zip/"
+        f"{custom_layer_name_list[custom_layer_number]}"
+    )
+
+
+def test_codab_custom_missing(pipeline, gpd_read_file):
+    """Test raised error when custom COD AB missing."""
+    with pytest.raises(AttributeError):
+        pipeline.load_codab_custom(0)