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

Issue:
Raster module
Working towards the IRI implementation in the toolbox, the code relied on 3 out of the 4 functions within `raster_manipulation.py` so wanted to start a raster module here. There is still lots of work to do here, but I wanted to put this PR out there now for a bit of review and to get y'alls thoughts on what's missing, things to improve on, things I've missed, or anything else relevant.

I also want to agree on a clear way forward before proceeding. For myself, I think the most natural way would actually to build in a class here and make the module functions already there as methods. This would also be expanded to methods to download the data and otherwise manipulate which the IRI could be slightly refactored to inherit from/use, as could the ARC2 scripts I've worked on, and maybe others. But obviously would be good to agree on a common way forward there.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/publish-to-pypi.yaml b/.github/workflows/publish-to-pypi.yaml
new file mode 100644
index 00000000..a6ca9b87
--- /dev/null
+++ b/.github/workflows/publish-to-pypi.yaml
@@ -0,0 +1,35 @@
+name: PyPI
+on:
+  push:
+    branches:
+      - main
+      - release
+      - develop
+jobs:
+  build-and-publish:
+    name: Build and publish to PyPI and TestPyPI
+    runs-on: ubuntu-20.04
+    steps:
+    - uses: actions/checkout@v2
+    - name: Get history and tags for SCM versioning to work
+      run: |
+        git fetch --prune --unshallow
+        git fetch --depth=1 origin +refs/tags/*:refs/tags/*
+    - name: Set up Python 3.9
+      uses: actions/setup-python@v1
+      with:
+        python-version: 3.9
+    - name: Install pypa/build
+      run: python -m pip install build --user
+    - name: Build a binary wheel and a source tarball
+      run: python -m build --sdist --wheel --outdir dist/ .
+    - name: Publish to Test PyPI
+      uses: pypa/gh-action-pypi-publish@master
+      with:
+        password: ${{ secrets.TEST_PYPI_API_TOKEN }}
+        repository_url: https://test.pypi.org/legacy/
+    - name: Publish to PyPI
+      if: startsWith(github.ref, 'refs/tags')
+      uses: pypa/gh-action-pypi-publish@master
+      with:
+        password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
index c9f89c5c..dbaa1ac5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -131,3 +131,9 @@ dmypy.json
 
 # PyCharm
 .idea/
+
+# Mac DSStore
+.DS_Store
+
+# build files
+src/aatoolbox/_version.py
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index fd31a09a..09341cec 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -25,6 +25,7 @@ repos:
           - flake8-deprecated==1.3
           - flake8-docstrings==1.6.0
           - flake8-implicit-str-concat==0.2.0
+          - flake8-keyword-arguments==0.1.0
           - flake8-print==4.0.0
           - flake8-sfs==0.0.3
   - repo: https://github.com/pre-commit/mirrors-mypy
@@ -34,7 +35,32 @@ repos:
         additional_dependencies:
           - types-PyYAML==5.4.10
           - types-setuptools==57.4.0
-  - repo: https://github.com/jumanjihouse/pre-commit-hooks
-    rev: 2.1.5
+          - types-requests==2.25.9
+        exclude: tests
+  - repo: https://github.com/igorshubovych/markdownlint-cli
+    rev: v0.29.0
     hooks:
       - id: markdownlint
+  - repo: https://github.com/jazzband/pip-tools
+    rev: 6.3.0
+    hooks:
+      - id: pip-compile
+        name: pip-compile requirements.txt
+        files: setup.cfg
+        args: [setup.cfg, -q, --no-header,
+               -o, requirements/requirements.txt]
+      - id: pip-compile
+        name: pip-compile requirements-docs.txt
+        files: requirements/docs.in
+        args: [ requirements/docs.in, -q, --no-header,
+                -o, requirements/requirements-docs.txt ]
+      - id: pip-compile
+        name: pip-compile requirements-tests.txt
+        files: requirements/tests.in
+        args: [requirements/tests.in, -q, --no-header,
+               -o, requirements/requirements-tests.txt]
+      - id: pip-compile
+        name: pip-compile requirements-dev.txt
+        files: requirements/(dev.in|docs.in|tests.in|requirements.txt)
+        args: [requirements/dev.in, -q, --no-header,
+               -o, requirements/requirements-dev.txt]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a09003d0..ecfc478b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,48 @@
+<!-- markdownlint-disable-file MD024 -->
+
 # Changelog
 
-All notable changes to `aa-tools` will be documented in this file.
+All notable changes to `aa-toolbox` will be documented in this file.
 
 The format is based on
 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to
 [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [0.3.0] - 2022-01-06
+
+### Added
+
+- `Pipeline` class to serve as main API
+- `DataSource` class as a base for all data sources
+- `CodAB` data source class for manipulating COD administrative boundaries
+- Functionality to download and save FewsNet data
+- Raster processing module
+- HDX API utility
+- Caching decorator in IO utility
+- Configuration files for:
+  - Bangladesh
+  - Ethiopia
+  - Malawi
+  - Nepal
+- [`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`
+- GitHub actions to run unit tests (using `tox.ini`) and push to PyPI
+
+### Changed
+
+- markdownlint pre-commit hook [switched to Node.js source](https://github.com/DavidAnson/markdownlint)
+- `requirements.txt` moved to `requirements` directory
+- `Area` class moved to utils
+- Switched from `pbr` to `setuptools_scm` for automated git tag versioning
+- Documentation to be generated using `sphinx-build` rather than through `setup.py`
+
+### Removed
+
+- `Makefile` for generating requirements files
+- `setup.py` as it was only required for `scm`
+
 ## [0.2.1] - 2021-10-15
 
 ### Fixed
@@ -22,7 +58,6 @@ and this project adheres to
 
 ## [0.1.0] - 2021-10-12
 
-<!-- markdownlint-disable MD024 -->
 ### Added
 
 - CDS Area module
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 9a889f57..00000000
--- a/Makefile
+++ /dev/null
@@ -1,17 +0,0 @@
-# For updating the requirements files using pip-tools
-
-BASE = "requirements"
-
-all: base dev docs tests
-
-base:
-	pip-compile --output-file requirements.txt
-
-dev:
-	pip-compile ${BASE}/dev.in --output-file ${BASE}/${BASE}-dev.txt
-
-docs:
-	pip-compile ${BASE}/docs.in --output-file ${BASE}/${BASE}-docs.txt
-
-tests:
-	pip-compile ${BASE}/tests.in --output-file ${BASE}/${BASE}-tests.txt
diff --git a/README.md b/README.md
index dc8cdf86..0c880673 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,9 @@
 # aa-toolbox: Toolbox for anticipatory action
 
 [![license](https://img.shields.io/github/license/OCHA-DAP/pa-aa-toolbox.svg)](https://github.com/OCHA-DAP/pa-aa-toolbx/blob/main/LICENSE)
+[![Test Status](https://github.com/OCHA-DAP/pa-aa-toolbox/workflows/tests/badge.svg)](https://github.com/OCHA-DAP/pa-aa-toolbox/actions?query=workflow%3Atests)
+[![PyPI Status](https://github.com/OCHA-DAP/pa-aa-toolbox/workflows/PyPI/badge.svg)](https://github.com/OCHA-DAP/pa-aa-toolbox/actions?query=workflow%3APyPI)
+[![Coverage Status](https://codecov.io/gh/OCHA-DAP/pa-aa-toolbox/branch/main/graph/badge.svg?token=JpWZc5js4y)](https://codecov.io/gh/OCHA-DAP/pa-aa-toolbox)
 [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
 [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/)
 
@@ -10,56 +13,62 @@ Currently under development
 
 ### Environment
 
-To setup the development environment, please install all packages from `requirements/requirements-dev.txt`:
+To set up the development environment, please install all packages
+from `requirements/requirements-dev.txt`:
 
-```
+```shell
 pip install -r requirements/requirements-dev.txt
 ```
 
-### pre-commit
-
-All code is formatted according to
-[black](https://github.com/psf/black) and
-[flake8](https://flake8.pycqa.org/en/latest/) guidelines.
-The repo is set-up to use
-[pre-commit](https://github.com/pre-commit/pre-commit).
-So please run `pre-commit install` the first time you are editing.
-Thereafter all commits will be checked against black and flake8 guidelines
-
-To check if your changes pass pre-commit without committing, run:
-
-```
-pre-commit run --all-files
-```
+`aa-toolbox` makes use of [`geopandas`](https://geopandas.org/en/stable/),
+which depends on [`Fiona`](https://github.com/Toblerity/Fiona),
+so you will need to have [`GDAL`](https://github.com/Toblerity/Fiona#installation)
+installed.
 
 ### Installation
 
-To install, execute:
+To install in editable mode for development, execute:
 
-```
-python setup.py develop
+```shell
+pip install -e .
 ```
 
 ### Testing
 
 To run the tests and view coverage, execute:
 
-```
+```shell
 pytest --cov=aatoolbox
 ```
 
-Note that you first need to install the aatoolbox package.
-
 ### Documentation
 
-To build the docs, execute:
+All public modules, classes and methods should be documented with
+[numpy-style](https://numpydoc.readthedocs.io/en/latest/format.html)
+docstrings, which can be rendered into HTML using the following command:
 
-```
-python setup.py build_sphinx
+```shell
+sphinx-build -b html -d docs/build/doctrees docs/source docs/build/html
 ```
 
 To view the docs, open up `docs/build/html/index.html` in your browser.
 
+### pre-commit
+
+All code is formatted according to
+[black](https://github.com/psf/black) and
+[flake8](https://flake8.pycqa.org/en/latest/) guidelines.
+The repo is set-up to use
+[pre-commit](https://github.com/pre-commit/pre-commit).
+So please run `pre-commit install` the first time you are editing.
+Thereafter all commits will be checked against black and flake8 guidelines
+
+To check if your changes pass pre-commit without committing, run:
+
+```shell
+pre-commit run --all-files
+```
+
 ### Packages
 
 [`pip-tools`](https://github.com/jazzband/pip-tools)
@@ -71,9 +80,35 @@ version constraints.
 For adding packages for development, documentation, or tests,
 add them to the relevant `.in` file in the `requirements` directory.
 When you modify any of these lists, please try to keep them alphabetical!
+Any changes to the `requirements*.txt` files will be generated with `pre-commit`.
 
-After adding any new packages, please execute `make` in the top-level directory,
-which will update all the `requirements*.txt` files.
+To run this without commiting, execute:
+
+```shell
+pre-commit run pip-compile --all-files
+```
 
 For other functionality such as updating specific package versions, refer to the
 `pip-tools` documentation.
+
+### Package Release
+
+Features are developed on our `develop` branch, with changes tracked in the
+"Unreleased" section at the top of `CHANGELOG.md`. Upon release, the `develop`
+branch is merged  to `main` and the release is tagged according to
+[semantic versioning](https://semver.org/spec/v2.0.0.html).
+
+Versioning is handled by
+[`setuptools_scm`](https://github.com/pypa/setuptools_scm),
+and the configuration for this can be found in `pyproject.toml`
+
+The `aa-toolbox` package is built and published to
+[PyPI](https://pypi.org/project/aa-toolbox/)
+whenever a new tag is pushed.
+With each new commit, a development version is pushed to
+[TestPyPI](https://test.pypi.org/project/aa-toolbox)
+and is available to install for testing purposes by running:
+
+```shell
+pip install -i https://test.pypi.org/simple/ aa-toolbox==$VERSION
+```
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 06814463..0f828b46 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -15,7 +15,6 @@
 
 sys.path.insert(0, os.path.abspath("../.."))
 
-
 # -- Project information -----------------------------------------------------
 
 copyright = "2021, Centre for Humanitarian Data Predictive Analytics Team"
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 9fa3db8e..66157b45 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -7,7 +7,7 @@ Welcome to aatoolbox's documentation!
 =====================================
 
 .. toctree::
-   :maxdepth: 2
+   :maxdepth: 6
    :caption: Contents:
 
    modules
diff --git a/pyproject.toml b/pyproject.toml
index d84cc51b..9747b1d6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,12 @@
+[build-system]
+requires = ["setuptools>=45", "wheel", "setuptools_scm>=6.2"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools_scm]
+write_to = "src/aatoolbox/_version.py"
+local_scheme = "no-local-version"
+version_scheme = "release-branch-semver"
+
 [tool.black]
 line-length = 79
 
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index fc535c1a..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,78 +0,0 @@
-#
-# This file is autogenerated by pip-compile with python 3.8
-# To update, run:
-#
-#    pip-compile --output-file=requirements.txt
-#
-attrs==21.2.0
-    # via
-    #   aa-toolbox (setup.py)
-    #   fiona
-certifi==2021.10.8
-    # via
-    #   aa-toolbox (setup.py)
-    #   fiona
-    #   pyproj
-click==8.0.3
-    # via
-    #   aa-toolbox (setup.py)
-    #   click-plugins
-    #   cligj
-    #   fiona
-click-plugins==1.1.1
-    # via
-    #   aa-toolbox (setup.py)
-    #   fiona
-cligj==0.7.2
-    # via
-    #   aa-toolbox (setup.py)
-    #   fiona
-fiona==1.8.20
-    # via
-    #   aa-toolbox (setup.py)
-    #   geopandas
-geopandas==0.10.1
-    # via aa-toolbox (setup.py)
-munch==2.5.0
-    # via
-    #   aa-toolbox (setup.py)
-    #   fiona
-numpy==1.21.2
-    # via
-    #   aa-toolbox (setup.py)
-    #   pandas
-pandas==1.3.3
-    # via
-    #   aa-toolbox (setup.py)
-    #   geopandas
-pydantic==1.8.2
-    # via aa-toolbox (setup.py)
-pyproj==3.2.1
-    # via
-    #   aa-toolbox (setup.py)
-    #   geopandas
-python-dateutil==2.8.2
-    # via
-    #   aa-toolbox (setup.py)
-    #   pandas
-pytz==2021.3
-    # via
-    #   aa-toolbox (setup.py)
-    #   pandas
-shapely==1.7.1
-    # via
-    #   aa-toolbox (setup.py)
-    #   geopandas
-six==1.16.0
-    # via
-    #   aa-toolbox (setup.py)
-    #   fiona
-    #   munch
-    #   python-dateutil
-typing-extensions==3.10.0.2
-    # via
-    #   aa-toolbox (setup.py)
-    #   pydantic
-
-# The following packages are considered to be unsafe in a requirements file:
-# setuptools
diff --git a/requirements/dev.in b/requirements/dev.in
index e9bc53ee..2e3a504e 100644
--- a/requirements/dev.in
+++ b/requirements/dev.in
@@ -1,4 +1,5 @@
 -r docs.in
+-r requirements.txt
 -r tests.in
 pip-tools
 pre-commit
diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt
index 6aa68877..7e17dfef 100644
--- a/requirements/requirements-dev.txt
+++ b/requirements/requirements-dev.txt
@@ -1,88 +1,413 @@
-#
-# This file is autogenerated by pip-compile with python 3.8
-# To update, run:
-#
-#    pip-compile --output-file=requirements/requirements-dev.txt requirements/dev.in
-#
+affine==2.3.0
+    # via
+    #   -r requirements/requirements.txt
+    #   rasterio
 alabaster==0.7.12
     # via sphinx
+appdirs==1.4.4
+    # via
+    #   -r requirements/requirements.txt
+    #   requests-cache
 attrs==21.2.0
-    # via pytest
+    # via
+    #   -r requirements/requirements.txt
+    #   cattrs
+    #   fiona
+    #   pytest
+    #   rasterio
+    #   requests-cache
 babel==2.9.1
     # via sphinx
-backports.entry-points-selectable==1.1.0
+backports.entry-points-selectable==1.1.1
     # 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.20.8
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
+botocore==1.23.8
+    # 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 requests
+    # via
+    #   -r requirements/requirements.txt
+    #   fiona
+    #   pyproj
+    #   rasterio
+    #   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 requests
+    # via
+    #   -r requirements/requirements.txt
+    #   requests
+ckanapi==4.6
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-api
 click==8.0.3
-    # via pip-tools
-coverage[toml]==6.0.2
+    # via
+    #   -r requirements/requirements.txt
+    #   click-plugins
+    #   cligj
+    #   fiona
+    #   pip-tools
+    #   rasterio
+    #   tabulator
+click-plugins==1.1.1
+    # via
+    #   -r requirements/requirements.txt
+    #   fiona
+    #   rasterio
+cligj==0.7.2
+    # via
+    #   -r requirements/requirements.txt
+    #   fiona
+    #   rasterio
+colorlog==6.6.0
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-utilities
+coverage[toml]==6.1.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
 docutils==0.17.1
     # via
     #   sphinx
     #   sphinx-rtd-theme
-filelock==3.3.0
-    # via virtualenv
-identify==2.3.0
+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.4.0
+    # via
+    #   tox
+    #   virtualenv
+fiona==1.8.20
+    # via
+    #   -r requirements/requirements.txt
+    #   geopandas
+geopandas==0.10.2
+    # via -r requirements/requirements.txt
+greenlet==1.1.2
+    # via
+    #   -r requirements/requirements.txt
+    #   sqlalchemy
+hdx-python-api==5.4.9
+    # via -r requirements/requirements.txt
+hdx-python-country==3.0.6
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-api
+hdx-python-utilities==3.0.7
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-country
+html5lib==1.1
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-utilities
+identify==2.3.7
     # via pre-commit
-idna==3.2
-    # via requests
-imagesize==1.2.0
+idna==3.3
+    # via
+    #   -r requirements/requirements.txt
+    #   email-validator
+    #   requests
+ijson==3.1.4
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
+imagesize==1.3.0
     # via sphinx
+inflect==5.3.0
+    # via
+    #   -r requirements/requirements.txt
+    #   quantulum3
 iniconfig==1.1.1
     # via pytest
-jinja2==3.0.2
+jinja2==3.0.3
     # via sphinx
+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.4
+    # via
+    #   -r requirements/requirements.txt
+    #   exchangerates
 markupsafe==2.0.1
     # via jinja2
+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
-packaging==21.0
+num2words==0.5.10
+    # via
+    #   -r requirements/requirements.txt
+    #   quantulum3
+numpy==1.21.4
     # via
+    #   -r requirements/requirements.txt
+    #   pandas
+    #   rasterio
+    #   snuggs
+    #   xarray
+openpyxl==3.0.9
+    # via
+    #   -r requirements/requirements.txt
+    #   tabulator
+packaging==21.3
+    # via
+    #   -r requirements/requirements.txt
     #   pytest
+    #   rioxarray
     #   sphinx
-pbr==5.6.0
+    #   tox
+pandas==1.3.4
+    # via
+    #   -r requirements/requirements.txt
+    #   geopandas
+    #   xarray
+pbr==5.8.0
     # via sphinxcontrib-apidoc
-pep517==0.11.0
+pep517==0.12.0
     # via pip-tools
-pip-tools==6.3.1
+pip-tools==6.4.0
     # via -r requirements/dev.in
 platformdirs==2.4.0
     # via virtualenv
 pluggy==1.0.0
-    # via pytest
+    # via
+    #   pytest
+    #   tox
+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
+py==1.11.0
+    # via
+    #   pytest
+    #   tox
+pyasn1==0.4.8
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-api
+    #   ndg-httpsclient
+pycparser==2.21
+    # via
+    #   -r requirements/requirements.txt
+    #   cffi
+pydantic==1.8.2
+    # via -r requirements/requirements.txt
 pygments==2.10.0
     # via sphinx
-pyparsing==2.4.7
-    # via packaging
+pyopenssl==21.0.0
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-api
+    #   ndg-httpsclient
+pyparsing==3.0.6
+    # via
+    #   -r requirements/requirements.txt
+    #   packaging
+    #   snuggs
+pyphonetics==0.5.3
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-country
+pyproj==3.3.0
+    # via
+    #   -r requirements/requirements.txt
+    #   geopandas
+    #   rioxarray
 pytest==6.2.5
     # via
     #   -r requirements/tests.in
     #   pytest-cov
+    #   pytest-mock
 pytest-cov==3.0.0
     # via -r requirements/tests.in
+pytest-mock==3.6.1
+    # via -r requirements/tests.in
+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 babel
-pyyaml==5.4.1
-    # via pre-commit
+    # via
+    #   -r requirements/requirements.txt
+    #   babel
+    #   pandas
+pyyaml==6.0
+    # via
+    #   -r requirements/requirements.txt
+    #   pre-commit
+quantulum3==0.7.9
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-api
+rasterio==1.2.10
+    # via
+    #   -r requirements/requirements.txt
+    #   rioxarray
+ratelimit==2.2.1
+    # via
+    #   -r requirements/requirements.txt
+    #   hdx-python-utilities
 requests==2.26.0
-    # via sphinx
+    # via
+    #   -r requirements/requirements.txt
+    #   ckanapi
+    #   exchangerates
+    #   libhxl
+    #   requests-cache
+    #   requests-file
+    #   sphinx
+    #   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
+rioxarray==0.8.0
+    # via -r requirements/requirements.txt
+ruamel.yaml==0.17.17
+    # 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.8.0
+    # via
+    #   -r requirements/requirements.txt
+    #   geopandas
 six==1.16.0
-    # via virtualenv
-snowballstemmer==2.1.0
+    # via
+    #   -r requirements/requirements.txt
+    #   basicauth
+    #   ckanapi
+    #   exchangerates
+    #   fiona
+    #   html5lib
+    #   jsonpath-ng
+    #   linear-tsv
+    #   munch
+    #   pyopenssl
+    #   python-dateutil
+    #   requests-file
+    #   tabulator
+    #   tox
+    #   url-normalize
+    #   virtualenv
+snowballstemmer==2.2.0
     # via sphinx
-sphinx==4.2.0
+snuggs==1.4.7
+    # via
+    #   -r requirements/requirements.txt
+    #   rasterio
+soupsieve==2.3.1
+    # via
+    #   -r requirements/requirements.txt
+    #   beautifulsoup4
+sphinx==4.3.0
     # via
     #   -r requirements/docs.in
     #   sphinx-rtd-theme
@@ -103,20 +428,77 @@ sphinxcontrib-qthelp==1.0.3
     # via sphinx
 sphinxcontrib-serializinghtml==1.1.5
     # via sphinx
+sqlalchemy==1.4.27
+    # 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
     #   pytest
-tomli==1.2.1
+    #   tox
+tomli==1.2.2
     # via
     #   coverage
     #   pep517
+tox==3.24.5
+    # via -r requirements/tests.in
+typing-extensions==4.0.0
+    # 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 requests
-virtualenv==20.8.1
-    # via pre-commit
+    # via
+    #   -r requirements/requirements.txt
+    #   botocore
+    #   requests
+    #   requests-cache
+virtualenv==20.10.0
+    # via
+    #   pre-commit
+    #   tox
+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
+xarray==0.20.1
+    # via
+    #   -r requirements/requirements.txt
+    #   rioxarray
+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-docs.txt b/requirements/requirements-docs.txt
index 3c0b0043..2b2ee886 100644
--- a/requirements/requirements-docs.txt
+++ b/requirements/requirements-docs.txt
@@ -1,9 +1,3 @@
-#
-# This file is autogenerated by pip-compile with python 3.8
-# To update, run:
-#
-#    pip-compile --output-file=requirements/requirements-docs.txt requirements/docs.in
-#
 alabaster==0.7.12
     # via sphinx
 babel==2.9.1
@@ -16,29 +10,29 @@ docutils==0.17.1
     # via
     #   sphinx
     #   sphinx-rtd-theme
-idna==3.2
+idna==3.3
     # via requests
-imagesize==1.2.0
+imagesize==1.3.0
     # via sphinx
-jinja2==3.0.2
+jinja2==3.0.3
     # via sphinx
 markupsafe==2.0.1
     # via jinja2
-packaging==21.0
+packaging==21.3
     # via sphinx
-pbr==5.6.0
+pbr==5.8.0
     # via sphinxcontrib-apidoc
 pygments==2.10.0
     # via sphinx
-pyparsing==2.4.7
+pyparsing==3.0.6
     # via packaging
 pytz==2021.3
     # via babel
 requests==2.26.0
     # via sphinx
-snowballstemmer==2.1.0
+snowballstemmer==2.2.0
     # via sphinx
-sphinx==4.2.0
+sphinx==4.3.0
     # via
     #   -r requirements/docs.in
     #   sphinx-rtd-theme
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
new file mode 100644
index 00000000..6b299765
--- /dev/null
+++ b/requirements/requirements.txt
@@ -0,0 +1,254 @@
+affine==2.3.0
+    # via rasterio
+appdirs==1.4.4
+    # via requests-cache
+attrs==21.2.0
+    # via
+    #   cattrs
+    #   fiona
+    #   rasterio
+    #   requests-cache
+basicauth==0.4.1
+    # via hdx-python-utilities
+beautifulsoup4==4.10.0
+    # via hdx-python-utilities
+boto3==1.20.8
+    # via tabulator
+botocore==1.23.8
+    # via
+    #   boto3
+    #   s3transfer
+cattrs==1.8.0
+    # via requests-cache
+cchardet==2.1.7
+    # via tabulator
+certifi==2021.10.8
+    # via
+    #   fiona
+    #   pyproj
+    #   rasterio
+    #   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
+    #   rasterio
+    #   tabulator
+click-plugins==1.1.1
+    # via
+    #   fiona
+    #   rasterio
+cligj==0.7.2
+    # via
+    #   fiona
+    #   rasterio
+colorlog==6.6.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.2
+    # via aa-toolbox (setup.cfg)
+greenlet==1.1.2
+    # via sqlalchemy
+hdx-python-api==5.4.9
+    # via aa-toolbox (setup.cfg)
+hdx-python-country==3.0.6
+    # via hdx-python-api
+hdx-python-utilities==3.0.7
+    # 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.4
+    # 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.4
+    # via
+    #   aa-toolbox (setup.cfg)
+    #   pandas
+    #   rasterio
+    #   snuggs
+    #   xarray
+openpyxl==3.0.9
+    # via tabulator
+packaging==21.3
+    # via rioxarray
+pandas==1.3.4
+    # via
+    #   geopandas
+    #   xarray
+ply==3.11
+    # via
+    #   jsonpath-ng
+    #   libhxl
+pyasn1==0.4.8
+    # via
+    #   hdx-python-api
+    #   ndg-httpsclient
+pycparser==2.21
+    # via cffi
+pydantic==1.8.2
+    # via aa-toolbox (setup.cfg)
+pyopenssl==21.0.0
+    # via
+    #   hdx-python-api
+    #   ndg-httpsclient
+pyparsing==3.0.6
+    # via
+    #   packaging
+    #   snuggs
+pyphonetics==0.5.3
+    # via hdx-python-country
+pyproj==3.3.0
+    # via
+    #   geopandas
+    #   rioxarray
+python-dateutil==2.8.2
+    # 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
+rasterio==1.2.10
+    # via rioxarray
+ratelimit==2.2.1
+    # via hdx-python-utilities
+requests==2.26.0
+    # via
+    #   aa-toolbox (setup.cfg)
+    #   ckanapi
+    #   exchangerates
+    #   libhxl
+    #   requests-cache
+    #   requests-file
+    #   tabulator
+requests-cache==0.8.1
+    # via libhxl
+requests-file==1.5.1
+    # via hdx-python-utilities
+rioxarray==0.8.0
+    # via aa-toolbox (setup.cfg)
+ruamel.yaml==0.17.17
+    # via hdx-python-utilities
+ruamel.yaml.clib==0.2.6
+    # via ruamel.yaml
+s3transfer==0.5.0
+    # via boto3
+shapely==1.8.0
+    # 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
+snuggs==1.4.7
+    # via rasterio
+soupsieve==2.3.1
+    # via beautifulsoup4
+sqlalchemy==1.4.27
+    # via tabulator
+tabulator[cchardet]==1.53.5
+    # via hdx-python-utilities
+text-unidecode==1.3
+    # via python-slugify
+typing-extensions==4.0.0
+    # via
+    #   aa-toolbox (setup.cfg)
+    #   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
+xarray==0.20.1
+    # via
+    #   aa-toolbox (setup.cfg)
+    #   rioxarray
+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 f6b07cd3..66c38a42 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,6 @@
 [metadata]
 name = aa-toolbox
+version = attr: aatoolbox.__version__
 author = Centre for Humanitarian Data Predictive Analytics Team
 author_email = centrehumdata@un.org
 summary = Toolbox for anticipatory action
@@ -18,11 +19,17 @@ classifier =
 packages = find:
 package_dir = = src
 include_package_data = true
-python_requires = >= 3.6
+python_requires = >= 3.6, < 3.10
 install_requires =
-    numpy
     geopandas
+    hdx-python-api>=5.4.9
+    numpy
     pydantic
+    requests
+    pyyaml
+    rioxarray
+    typing-extensions
+    xarray
 
 [options.packages.find]
 where = src
@@ -32,10 +39,7 @@ builders = html
 source-dir = docs/source
 build-dir = docs/build
 
-[pbr]
-skip_changelog = True
-skip_authors = True
-
 [flake8]
 extend-ignore = SFS301 # Allow f-strings
 docstring-convention = numpy
+max-pos-args = 2
diff --git a/setup.py b/setup.py
deleted file mode 100644
index b08c39e2..00000000
--- a/setup.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""Setup for aatoolbox."""
-
-from setuptools import setup
-
-setup(setup_requires=["pbr"], pbr=True)
diff --git a/src/aatoolbox/__init__.py b/src/aatoolbox/__init__.py
index ba20e25c..851e1e98 100644
--- a/src/aatoolbox/__init__.py
+++ b/src/aatoolbox/__init__.py
@@ -4,6 +4,4 @@
 downloading and processing the data required for disaster-related
 anticipatory action.
 """
-import pkg_resources
-
-__version__ = pkg_resources.get_distribution("aa-toolbox").version
+from ._version import version as __version__  # noqa: F401
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/cds/__init__.py b/src/aatoolbox/datasources/cds/__init__.py
deleted file mode 100644
index 95e182e2..00000000
--- a/src/aatoolbox/datasources/cds/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-"""CDS API wrappers to download data from various sources."""
diff --git a/src/aatoolbox/datasources/cds/area.py b/src/aatoolbox/datasources/cds/area.py
deleted file mode 100644
index 38ffbab1..00000000
--- a/src/aatoolbox/datasources/cds/area.py
+++ /dev/null
@@ -1,166 +0,0 @@
-"""Geographic area for the CDS API.
-
-Create geographic area for input to the CDS API, which requires the
-coordinate parameters to be in a specific format:
-
-* ECMWF: rounded to the nearest degree
-* GloFAS: Rounded to the nearest degree and offset by 0.05 degrees
-
-It is possible to create an ``Area`` object either from a dictionary of
-stations, or from a shapefile that has been read in with geopandas.
-
-The ``Area`` object should then be passed to the CDS ``download`` method.
-"""
-from collections import namedtuple
-from typing import Dict, List, Union
-
-import geopandas as gpd
-import numpy as np
-
-Station = namedtuple("Station", "lon lat")
-Station.__doc__ = """\
-    The coordinates of a station, used to determine the boundaries of the
-    area passed to the CDS API.
-
-    Parameters
-    ----------
-    lon: float
-        The station longitude in degrees
-    lat: float
-        The station latitude in degrees
-"""
-
-
-class Area:
-    """Create a geographic formatted for input to the CDS API.
-
-    Parameters
-    ----------
-    north : float
-        The northern latitude boundary of the area (degrees)
-    south : float
-        The southern latitude boundary of the area (degrees)
-    east : float
-        The easternmost longitude boundary of the area (degrees)
-    west : float
-        The westernmost longitude boundary of the area (degrees)
-    """
-
-    def __init__(self, north: float, south: float, east: float, west: float):
-        self.north = north
-        self.south = south
-        self.east = east
-        self.west = west
-
-    def list_for_api(
-        self,
-        round_coords: bool = True,
-        offset_val: float = 0.0,
-    ) -> List[float]:
-        """
-        List the coordinates in the order that they're needed for the API.
-
-        Parameters
-        ----------
-        round_coords: bool, default = False
-            Whether or not to round the coordinates. In general this should be
-            done, but this option has been left in for some legacy data.
-        offset_val: float, default = 0.0
-            Offset the coordinates by this factor. Some CDS datasets require
-            coordinates that end in 0.5.
-
-        Returns
-        -------
-        List of coordinates in the correct order for the API (north, west,
-        south, east)
-        """
-        if not round_coords:
-            return [self.north, self.west, self.south, self.east]
-
-        return [
-            self._round_coord(
-                coord=getattr(self, coordinate),
-                direction=direction,
-                offset_val=offset_val,
-            )
-            for coordinate, direction in {
-                "north": "up",
-                "west": "down",
-                "south": "down",
-                "east": "up",
-            }.items()
-        ]
-
-    @staticmethod
-    def _round_coord(
-        coord: float,
-        direction: str,
-        offset_val: float = 0.0,
-        round_val: int = 1,
-    ) -> float:
-        if direction == "up":
-            function = np.ceil
-            offset_factor = 1
-        elif direction == "down":
-            function = np.floor
-            offset_factor = -1
-        return (
-            function(coord / round_val) * round_val
-            + offset_factor * offset_val
-        )
-
-
-class AreaFromStations(Area):
-    """
-    Create an area input for the CDS API based on station coordinates.
-
-    Parameters
-    ----------
-    stations : dict
-        dictionary of the form ``{station_name: Station}``
-    buffer : float, default = 0.2
-        degrees above / below the maximum station lat / lon used
-        to query the CDS API
-
-    Examples
-    --------
-    >>> stations = {"station1": Station(lon=1.0, lat=2.0),
-    >>>             "station2": Station(lon=3.0, lat=4.0)}
-    >>> area = AreaFromStations(stations)
-    """
-
-    def __init__(self, stations: Dict[str, Station], buffer: float = 0.2):
-        lon_list = [station.lon for station in stations.values()]
-        lat_list = [station.lat for station in stations.values()]
-        super().__init__(
-            north=max(lat_list) + buffer,
-            south=min(lat_list) - buffer,
-            east=max(lon_list) + buffer,
-            west=min(lon_list) - buffer,
-        )
-
-
-class AreaFromShape(Area):
-    """
-    Create an area input for the CDS API based on a shapefile.
-
-    Parameters
-    ----------
-    shape : geopandas.GeoSeries, geopandas.GeoDataFrame
-        A shape whose bounds will be used to generate the area
-        queried by the CDS API
-
-    Examples
-    --------
-    >>> import geopandas as gpd
-    >>> df_admin_boundaries = gpd.read_file("admin0_boundaries.gpkg")
-    >>> area = AreaFromShape(df_admin_boundaries.iloc[0].geometry)
-    """
-
-    def __init__(self, shape: Union[gpd.GeoSeries, gpd.GeoDataFrame]):
-        super().__init__(
-            north=shape.total_bounds[3],
-            south=shape.total_bounds[1],
-            east=shape.total_bounds[2],
-            west=shape.total_bounds[0],
-        )
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/datasources/fewsnet/__init__.py b/src/aatoolbox/datasources/fewsnet/__init__.py
new file mode 100644
index 00000000..f762ee00
--- /dev/null
+++ b/src/aatoolbox/datasources/fewsnet/__init__.py
@@ -0,0 +1,1 @@
+"""Functionality to process fewsnet data."""
diff --git a/src/aatoolbox/datasources/fewsnet/fewsnet.py b/src/aatoolbox/datasources/fewsnet/fewsnet.py
new file mode 100644
index 00000000..6e30946c
--- /dev/null
+++ b/src/aatoolbox/datasources/fewsnet/fewsnet.py
@@ -0,0 +1,269 @@
+"""
+FEWS NET processing.
+
+Download and save the data provided by FEWS NET as provided on .
+"""
+
+import logging
+import zipfile
+from datetime import date, datetime
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import Optional, Union
+
+from typing_extensions import Literal
+
+from aatoolbox.utils.io import download_url, unzip
+
+logger = logging.getLogger(__name__)
+_DATE_FORMAT = "%Y-%m-%d"
+_BASE_URL_COUNTRY = (
+    "https://fdw.fews.net/api/ipcpackage/"
+    "?country_code={iso2}&collection_date={YYYY}-{MM}-01"
+)
+_BASE_URL_REGION = (
+    "https://fews.net/data_portal_download/download"
+    "?data_file_path=http://shapefiles.fews.net.s3.amazonaws.com/"
+    "HFIC/{region_code}/{region_name}{YYYY}{MM}.zip"
+)
+
+_VALID_REGION_NAMES = [
+    "caribbean-central-america",
+    "central-asia",
+    "east-africa",
+    "southern-africa",
+    "west-africa",
+]
+_VALID_REGION_CODES = ["LAC", "EA", "CA", "SA", "WA"]
+
+
+def download_fewsnet(
+    date_pub: Union[date, str],
+    iso2: str,
+    region_name: Literal[
+        "caribbean-central-america",
+        "central-asia",
+        "east-africa",
+        "southern-africa",
+        "west-africa",
+    ],
+    region_code: Literal["LAC", "EA", "CA", "SA", "WA"],
+    output_dir: Union[Path, str],
+    use_cache=True,
+) -> Path:
+    """
+    Retrieve the raw fewsnet data.
+
+    Depending on the region and date, this data is published per region or
+    per country. This function retrieves the country data
+    if it exists, and else the regional data for `date_pub` and `iso2`.
+
+    Parameters
+    ----------
+    date_pub : date or str
+        date for which the data should be downloaded
+        only the year and month part are used
+        this commonly refers to the month of the Current Situation period
+        if str, it should be in ISO 8601 format, e.g. '2021-10-01'
+    iso2 : str
+        iso2 code of the country of interest.
+        See https://fews.net/ for a list of countries that are
+        included by FewsNet
+    region_name : {'caribbean-central-america', 'central-asia',
+        'east-africa', 'southern-africa', 'west-africa'}
+        name of the region to which the `iso2` belongs.
+        Only regions for which FewsNet provides data can be given as input
+    region_code : {'LAC','EA','CA','SA','WA'}
+        code that refers to the `region_name`
+        These are defined by FewsNet
+    output_dir : Path or str
+        path to dir to which the data should be written
+    use_cache : bool, default True
+        if True, don't download if output_dir already exists
+
+    Examples
+    --------
+    >>> download_fewsnet(date_pub="2020-10-01",iso2="et",
+    ... region_code="east-africa", region_name="EA",
+    ... output_dir="tmp",use_cache=False)
+    """
+    if region_name not in _VALID_REGION_NAMES:
+        raise ValueError(f"Invalid region name {region_name}")
+    if region_code not in _VALID_REGION_CODES:
+        raise ValueError(f"Invalid region code {region_code}")
+
+    # convert to datetime if str
+    if not isinstance(date_pub, date):
+        date_pub = datetime.strptime(date_pub, _DATE_FORMAT)
+    # convert to path object if str
+    if isinstance(output_dir, str):
+        output_dir = Path(output_dir)
+
+    # we prefer the country data as this more nicely structured
+    # thus first check if that is available
+    country_path = _download_fewsnet_country(
+        date_pub=date_pub,
+        iso2=iso2,
+        output_dir=output_dir,
+        use_cache=use_cache,
+    )
+    if country_path is not None:
+        return country_path
+    else:
+        region_path = _download_fewsnet_region(
+            date_pub=date_pub,
+            region_name=region_name,
+            region_code=region_code,
+            output_dir=output_dir,
+            use_cache=use_cache,
+        )
+    if region_path is not None:
+        return region_path
+    else:
+        raise RuntimeError(f"No data found for {date_pub.strftime('%Y-%m')}")
+
+
+def _download_fewsnet_country(
+    date_pub: date,
+    iso2: str,
+    output_dir: Path,
+    use_cache: bool,
+) -> Optional[Path]:
+    """
+    Download fewsnet data that covers the iso2 country.
+
+    Parameters
+    ----------
+    date_pub : date
+        date for which the data should be downloaded
+        only the year and month part are used
+        this commonly refers to the month of the Current Situation period
+    iso2 : str
+        iso2 code for which the data should be downloaded
+    output_dir: Path
+        path of dir to which the zip content should be written
+    use_cache: bool
+        if True, don't download if output_dir already exists
+
+    Returns
+    -------
+    country_data : str
+        if data found return the output_dir, else return None
+    """
+    url_country_date = _BASE_URL_COUNTRY.format(
+        iso2=iso2.upper(), YYYY=date_pub.year, MM=date_pub.month
+    )
+
+    # filenames have upper iso2, so use that for dirs as well
+    output_dir_country = (
+        output_dir / f"{iso2.upper()}{date_pub.strftime('%Y%m')}"
+    )
+    zip_filename = f"{output_dir_country.name}.zip"
+    if not output_dir_country.exists() or use_cache is False:
+        country_data = _download_zip(
+            url=url_country_date,
+            zip_filename=zip_filename,
+            output_dir=output_dir_country,
+        )
+    else:
+        country_data = output_dir_country
+    return country_data
+
+
+def _download_fewsnet_region(
+    date_pub: date,
+    region_name: str,
+    region_code: str,
+    output_dir: Path,
+    use_cache: bool,
+) -> Optional[Path]:
+    """
+    Download fewsnet data that covers `region_name`.
+
+    Parameters
+    ----------
+    date_pub : date
+        date for which the data should be downloaded
+        only the year and month part are used
+        this commonly refers to the month of the Current Situation period
+    region_name : str
+        name of the region to which the `iso2` belongs,
+        e.g. "east-africa"
+    region_code : str
+        code that refers to the `region_name,
+        e.g. "EA"
+    output_dir : Path
+        path to dir to which the data should be written
+    use_cache : bool
+        if True, don't download if output_dir already exists
+
+    Returns
+    -------
+    region_data : str
+        If region data exists, return the saved dir else return None
+    """
+    url_region_date = _BASE_URL_REGION.format(
+        region_code=region_code.upper(),
+        region_name=region_name,
+        YYYY=date_pub.year,
+        MM=date_pub.month,
+    )
+
+    output_dir_region = (
+        output_dir / f"{region_code.upper()}{date_pub.strftime('%Y%m')}"
+    )
+    zip_filename_region = f"{output_dir_region.name}.zip"
+    if not output_dir_region.exists() or use_cache is False:
+        region_data = _download_zip(
+            url=url_region_date,
+            zip_filename=zip_filename_region,
+            output_dir=output_dir_region,
+        )
+    else:
+        region_data = output_dir_region
+    return region_data
+
+
+def _download_zip(
+    url: str,
+    zip_filename: str,
+    output_dir: Path,
+) -> Optional[Path]:
+    """
+    Download and unzip the file at the url.
+
+    Parameters
+    ----------
+    url : str
+        url that contains the zip file to be downloaded
+    zip_filename : str
+        name of the zipfile
+    output_dir : Path
+        path of dir to which the zip content should be written
+
+    Returns
+    -------
+    None if no valid file, else output_dir
+
+    """
+    # create tempdir to write zipfile to
+    with TemporaryDirectory() as temp_dir:
+        zip_path = Path(temp_dir) / zip_filename
+        download_url(url=url, save_path=zip_path)
+        logger.info(f"Downloaded {url} to {zip_path}")
+
+        try:
+            unzip(zip_file_path=zip_path, save_dir=output_dir)
+            logger.debug(f"Unzipped to {output_dir}")
+
+        except zipfile.BadZipFile:
+            # indicates that the url returned something that wasn't a
+            # zip, happens often and indicates data for the given
+            # country - date is not available
+            logger.debug(
+                f"No zip data returned from url {url} "
+                f"check that the area and date exist."
+            )
+            return None
+
+    return output_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/geoboundingbox.py b/src/aatoolbox/utils/geoboundingbox.py
new file mode 100644
index 00000000..09e49408
--- /dev/null
+++ b/src/aatoolbox/utils/geoboundingbox.py
@@ -0,0 +1,123 @@
+"""
+Functionality to retrieve and modify boundary coordinates.
+
+It is possible to create an ``GeoBoundingBox`` object either from
+north, south, east, west coordinates,
+or from a shapefile that has been read in with geopandas.
+"""
+from typing import Union
+
+import geopandas as gpd
+import numpy as np
+
+
+class GeoBoundingBox:
+    """Create an object containing the bounds of an area.
+
+    Parameters
+    ----------
+    north : float
+        The northern latitude boundary of the area (degrees)
+    south : float
+        The southern latitude boundary of the area (degrees)
+    east : float
+        The easternmost longitude boundary of the area (degrees)
+    west : float
+        The westernmost longitude boundary of the area (degrees)
+    """
+
+    def __init__(self, north: float, south: float, east: float, west: float):
+        self.north = north
+        self.south = south
+        self.east = east
+        self.west = west
+
+    def __repr__(self):
+        """Print bounding box string."""
+        return (
+            f"N: {self.north}\nS: {self.south}\n"
+            f"E: {self.east}\nW: {self.west}"
+        )
+
+    @classmethod
+    def from_shape(cls, shape: Union[gpd.GeoSeries, gpd.GeoDataFrame]):
+        """
+        Create `GeoBoundingBox` from a geopandas object.
+
+        Parameters
+        ----------
+        shape : geopandas.GeoSeries, geopandas.GeoDataFrame
+            A shape whose bounds will be retrieved
+
+        Examples
+        --------
+        >>> import geopandas as gpd
+        >>> df_admin_boundaries = gpd.read_file("admin0_boundaries.gpkg")
+        >>> geobb = GeoBoundingBox.from_shape(df_admin_boundaries)
+        """
+        return cls(
+            north=shape.total_bounds[3],
+            south=shape.total_bounds[1],
+            east=shape.total_bounds[2],
+            west=shape.total_bounds[0],
+        )
+
+    def round_boundingbox_coords(
+        self,
+        offset_val: float = 0.0,
+        round_val: Union[int, float] = 1,
+    ):
+        """
+        Round the bounding box coordinates.
+
+        Rounding is always done outside the original bounding box.
+        I.e. the resulting bounding box is always equal or larger
+        than the original bounding box.
+
+        Parameters
+        ----------
+        offset_val : float, default = 0.0
+            Offset the coordinates by this factor.
+        round_val : int, default = 1
+            The decimal to round to. If 1, round to integers
+        """
+        for direction in ["north", "west", "south", "east"]:
+            coord = getattr(self, direction)
+            if direction in ("north", "east"):
+                function = np.ceil.__call__  # needed for mypy
+                offset_factor = 1
+            elif direction in ("south", "west"):
+                function = np.floor.__call__  # needed for mypy
+                offset_factor = -1
+            rounded_coord = (
+                function(coord / round_val) * round_val
+                + offset_factor * offset_val
+            )
+            setattr(self, direction, rounded_coord)  # noqa: FKA01
+
+    def get_filename_repr(self, p: int = 0) -> str:
+        """
+        Get succinct boundary representation for usage in filenames.
+
+        Parameters
+        ----------
+        p : int, default = 0
+            Precision, i.e. number of decimal places to round to. Default is
+            0 for ints.
+
+        Returns
+        -------
+        String containing N, S, E and W coordinates.
+        """
+
+        def _str_format(coord):
+            """Add m indicating minus value and p indicating positive value."""
+            if coord < 0:
+                return f"m{abs(coord):.{p}f}"
+            else:
+                return f"p{coord:.{p}f}"
+
+        return (
+            f"N{_str_format(self.north)}S{_str_format(self.south)}"
+            f"E{_str_format(self.east)}W{_str_format(self.west)}"
+        )
diff --git a/src/aatoolbox/utils/hdx_api.py b/src/aatoolbox/utils/hdx_api.py
new file mode 100644
index 00000000..89b6e2bc
--- /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.api.configuration import Configuration
+from hdx.data.dataset import Dataset
+
+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 98b5a582..98bb1f83 100644
--- a/src/aatoolbox/utils/io.py
+++ b/src/aatoolbox/utils/io.py
@@ -1,9 +1,68 @@
 """Function for I/O."""
+import logging
+import zipfile
 from pathlib import Path
-from typing import Union
+from typing import Any, Callable, TypeVar, Union, cast
 
+import requests
 import yaml
 
+logger = logging.getLogger(__name__)
+
+# For typing the decorator
+F = TypeVar("F", bound=Callable[..., Any])
+
+
+def download_url(
+    url: str,
+    save_path: Path,
+    chunk_size: int = 2048,
+):
+    """
+    Download the file located at `url` to `save_path`.
+
+    Parameters
+    ----------
+    url : str
+        url that contains the file to be downloaded
+    save_path : Path
+        path to the location the file should be saved
+    chunk_size : int
+        number of bytes to save at once
+    """
+    save_path.parent.mkdir(exist_ok=True, parents=True)
+    # Remove file if already exists
+    save_path.unlink(missing_ok=True)
+
+    # use a session and chunk_size to prevent
+    # crashing when downloading large files while
+    # not loosing too much speed
+    session = requests.Session()
+    r = session.get(url, stream=True)
+    r.raise_for_status()
+    with save_path.open("wb") as fd:
+        for chunk in r.iter_content(chunk_size=chunk_size):
+            fd.write(chunk)
+
+
+def unzip(
+    zip_file_path: Path,
+    save_dir: Path,
+):
+    """
+    Unzip a file.
+
+    Parameters
+    ----------
+    zip_file_path : Path
+        path to the location the zip file is saved
+    save_dir : Path
+        dir path to which the content of the zip
+        file should be saved
+    """
+    with zipfile.ZipFile(file=zip_file_path, mode="r") as zip_ref:
+        zip_ref.extractall(save_dir)
+
 
 def parse_yaml(filename: Union[str, Path]) -> dict:
     """
@@ -18,6 +77,40 @@ def parse_yaml(filename: Union[str, Path]) -> dict:
     -------
     A dictionary with the YAML file contents
     """
-    with open(filename, "r") as stream:
+    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)
diff --git a/src/aatoolbox/utils/raster.py b/src/aatoolbox/utils/raster.py
new file mode 100644
index 00000000..5fd6b772
--- /dev/null
+++ b/src/aatoolbox/utils/raster.py
@@ -0,0 +1,568 @@
+"""
+Utilities to manipulate and analyze raster data.
+
+This module extends xarray and rioxarray to provide
+additional functionality for raster processing and
+post-processing. The extension is based on the
+guidance for how to `extend xarray
+<http://xarray.pydata.org/en/stable/internals/extending-xarray.html>`_:
+
+However, since rioxarray already extends xarray, this
+modules extensions inherit from the RasterArray and
+RasterDataset extensions respectively. This ensures
+cleaner code in the module as ``rio`` methods are
+available immediately, but also means a couple of
+design decisions are followed.
+
+The xarray.DataArray and xarray.Dataset
+extensions here inherit from rioxarray base classes.
+Thus, methods that are identical for both objects
+are defined in a mixin class ``AatRasterMixin`` which
+can be inherited by the two respective extensions.
+"""
+
+import logging
+from typing import Any, Callable, Dict, List, Union
+
+import geopandas as gpd
+import numpy as np
+import pandas as pd
+import rioxarray  # noqa: F401
+import xarray as xr
+from rioxarray.exceptions import DimensionError, MissingCRS, NoDataInBounds
+from rioxarray.raster_array import RasterArray
+from rioxarray.raster_dataset import RasterDataset
+from rioxarray.rioxarray import _get_data_var_message
+
+logger = logging.getLogger(__name__)
+
+
+class AatRasterMixin:
+    """AA toolbox mixin base class."""
+
+    # setting attributes to avoid mypy error, from SO
+    # https://stackoverflow.com/questions/53120262/mypy-how-to-
+    # ignore-missing-attribute-errors-in-mixins/53228204#53228204
+    _get_obj: Callable
+    x_dim: str
+    y_dim: str
+
+    def __init__(self, xarray_obj):
+        super().__init__(xarray_obj)
+
+        # Adding lat/lon to set of default spatial dims
+        if "lat" in self._obj.dims and "lon" in self._obj.dims:
+            self._x_dim = "lon"
+            self._y_dim = "lat"
+
+        # Managing time coordinate default dims
+        self._t_dim = None
+        for t in ["t", "T", "time"]:
+            if t in self._obj.dims:
+                self._t_dim = t
+
+    # methods derived from rioxarray.rioxarray.x_dim and y_dim
+    @property
+    def t_dim(self):
+        """str: The dimension for time."""
+        if self._t_dim is None:
+            raise DimensionError(
+                "Time dimension not found. 'aat.set_time_dim()' or "
+                "using 'rename()' to change the dimension name to "
+                f"'t' can address this.{_get_data_var_message(self._obj)}"
+            )
+        return self._t_dim
+
+    def set_time_dim(
+        self, t_dim: str, inplace: bool = False
+    ) -> Union[xr.DataArray, xr.Dataset]:
+        """Set the time dimension of the dataset.
+
+        Parameters
+        ----------
+        t_dim: str
+            The name of the time dimension.
+        inplace: bool, optional
+            If True, it will modify the dataarray in place.
+            Otherwise it will return a modified copy.
+
+        Returns
+        -------
+        Union[xarray.DataArray, xarray.Dataset]
+            Data array or dataset with time dimension.
+
+        Examples
+        --------
+        >>> import xarray
+        >>> import numpy
+        >>> da = xarray.DataArray(
+        ...  numpy.arange(64).reshape(4,4,4),
+        ...  coords={"lat":numpy.array([87, 88, 89, 90]),
+        ...          "lon":numpy.array([5, 120, 199, 360]),
+        ...          "F":numpy.array([10,11,12,13])}
+        ... )
+        >>> da.aat.set_time_dim(t_dim="F", inplace=True)
+        >>> da.aat.t_dim
+        'F'
+        """
+        data_obj = self._get_obj(inplace=inplace)
+        if t_dim not in data_obj.dims:
+            raise DimensionError(
+                "Time dimension ({t_dim}) not found."
+                f"{_get_data_var_message(data_obj)}"
+            )
+        data_obj.aat._t_dim = t_dim
+        return data_obj if not inplace else None
+
+    def correct_calendar(
+        self, inplace: bool = False
+    ) -> Union[xr.DataArray, xr.Dataset]:
+        """Correct calendar attribute for recognition by xarray.
+
+        Some datasets come with a wrong calendar attribute that isn't
+        recognized by xarray. This function corrects the coordinate
+        attribute to ensure that a ``calendar`` attribute exists
+        and specifies a calendar alias that is supportable by
+        ``xarray.cftime_range`` and NetCDF in general.
+
+        Currently ensures that calendar attributes that are either
+        specified with ``units="months since"`` or ``calendar="360"``
+        explicitly have ``calendar="360_day"``. This is based on
+        discussions in `this GitHub issue
+        <https://github.com/Unidata/netcdf4-python/issues/434>`_.
+        If and when further issues are found with calendar
+        attributes, support for conversion will be added here.
+
+        Parameters
+        ----------
+        inplace: bool, optional
+            If True, it will modify the dataarray in place.
+            Otherwise it will return a modified copy.
+
+        Returns
+        -------
+        Union[xarray.DataArray, xarray.Dataset]
+            Data array or dataset with transformed calendar
+            coordinate.
+
+        Examples
+        --------
+        >>> import xarray
+        >>> import numpy
+        >>> da = xarray.DataArray(
+        ...  numpy.arange(64).reshape(4,4,4),
+        ...  coords={"lat":numpy.array([87, 88, 89, 90]),
+        ...          "lon":numpy.array([5, 120, 199, 360]),
+        ...          "t":numpy.array([10,11,12,13])}
+        ... )
+        >>> da["t"].attrs["units"] = "months since 1960-01-01"
+        >>> da_crct = da.aat.correct_calendar()
+        >>> da_crct["t"].attrs["calendar"]
+        '360_day'
+        """
+        data_obj = self._get_obj(inplace=inplace)
+        if (
+            "calendar" in data_obj[self.t_dim].attrs.keys()
+            and data_obj[self.t_dim].attrs["calendar"] == "360"
+        ):
+            data_obj[self.t_dim].attrs["calendar"] = "360_day"
+            logger.info("Calendar attribute changed from '360' to '360_day'.")
+
+        elif (
+            "units" in data_obj[self.t_dim].attrs.keys()
+            and "months since" in data_obj[self.t_dim].attrs["units"]
+        ):
+            data_obj[self.t_dim].attrs["calendar"] = "360_day"
+            logger.info(
+                "Calendar attribute '360_day' added, "
+                "equivalent of 'units' 'months since'."
+            )
+
+        else:
+            logger.info("No 'units' or 'calendar' attributes to correct.")
+
+        return data_obj if not inplace else None
+
+    def invert_coordinates(self, inplace: bool = False) -> xr.DataArray:
+        """
+        Invert latitude and longitude in data array.
+
+        This function checks for inversion of latitude and longitude
+        and inverts them if needed. Datasets with inverted coordinates
+        can produce incorrect results in certain functions like
+        ``rasterstats.zonal_stats()``. Correctly ordered coordinates
+        should be:
+
+        * latitude: Largest to smallest.
+        * longitude: Smallest to largest.
+
+        If data array already has correct coordinate ordering, it is
+        directly returned. Function largely copied from
+        https://github.com/perrygeo/python-rasterstats/issues/218.
+
+        Parameters
+        ----------
+        inplace : bool, optional
+            If True, will overwrite existing data array. Default is False.
+
+        Returns
+        -------
+        Union[xarray.DataArray, xarray.Dataset]
+            Data array or dataset with correct coordinate ordering.
+
+        Examples
+        --------
+        >>> import xarray
+        >>> import numpy
+        >>> da = xarray.DataArray(
+        ...  numpy.arange(16).reshape(4,4),
+        ...  coords={"lat":numpy.array([87, 88, 89, 90]),
+        ...          "lon":numpy.array([70, 69, 68, 67])}
+        ... )
+        >>> da_inv = da.aat.invert_coordinates()
+        >>> da_inv.get_index("lon")
+        Int64Index([67, 68, 69, 70], dtype='int64', name='lon')
+        >>> da_inv.get_index("lat")
+        Int64Index([90, 89, 88, 87], dtype='int64', name='lat')
+        """
+        data_obj = self._get_obj(inplace=inplace)
+        lon_inv, lat_inv = self._check_coords_inverted()
+
+        # Flip the raster as necessary (based on the flags)
+        inv_dict = {}
+
+        if lon_inv:
+            logger.info("Longitude was inverted, reversing coordinates.")
+            inv_dict[self.x_dim] = data_obj[self.x_dim][::-1]
+
+        if lat_inv:
+            logger.info("Latitude was inverted, reversing coordinates.")
+            inv_dict[self.y_dim] = data_obj[self.y_dim][::-1]
+
+        if inv_dict:
+            data_obj = data_obj.reindex(inv_dict)
+
+        return data_obj if not inplace else None
+
+    def _check_coords_inverted(self):
+        """
+        Check if latitude and longitude inverted.
+
+        Examples
+        --------
+        >>> import xarray
+        >>> import numpy
+        >>> da = xarray.DataArray(
+        ...  numpy.arange(16).reshape(4,4),
+        ...  coords={"lat":numpy.array([90, 89, 88, 87]),
+        ...          "lon":numpy.array([70, 69, 68, 67])}
+        ... )
+        >>> da.aat._check_coords_inverted()
+        (True, False)
+        """
+        data_obj = self._get_obj(inplace=False)
+        lat = data_obj.get_index(self.y_dim)
+        lon = data_obj.get_index(self.x_dim)
+        return lon[0] > lon[-1], lat[0] < lat[-1]
+
+    def change_longitude_range(
+        self, inplace: bool = False
+    ) -> Union[xr.DataArray, xr.Dataset]:
+        """Convert longitude range between -180 to 180 and 0 to 360.
+
+        For some raster data, outputs are incorrect if longitude ranges
+        are not converted from 0 to 360 to -180 to 180, such as the IRI
+        seasonal forecast, but *not* the IRI CAMS observational terciles,
+        which are incorrect if ranges are stored as -180 to 180.
+
+        ``change_longitude_range()`` will convert between the
+        two coordinate systems based on its current state. If coordinates
+        lie solely between 0 and 180 then there is no need for conversion
+        and the input  will be returned.
+
+        Parameters
+        ----------
+        inplace : bool, optional
+            If True, will overwrite existing data array. Default is False.
+
+        Returns
+        -------
+        Union[xarray.DataArray, xarray.Dataset]
+            Dataset with transformed longitude coordinates.
+
+        Examples
+        --------
+        >>> import xarray
+        >>> import numpy
+        >>> temp = 15 + 8 * numpy.random.randn(4, 4, 3)
+        >>> precip = 10 * np.random.rand(4, 4, 3)
+        >>> ds = xarray.Dataset(
+        ...   {
+        ...     "temperature": (["lat", "lon", "time"], temp),
+        ...     "precipitation": (["lat", "lon", "time"], precip)
+        ...   },
+        ...   coords={
+        ...     "lat":numpy.array([87, 88, 89, 90]),
+        ...     "lon":numpy.array([5, 120, 199, 360]),
+        ...     "time": pd.date_range("2014-09-06", periods=3)
+        ...   }
+        ... )
+        >>> ds_inv = ds.aat.change_longitude_range()
+        >>> ds_inv.get_index("lon")
+        Int64Index([-161, 0, 5, 120], dtype='int64', name='lon')
+        >>> # invert coordinates back to original, in place
+        >>> ds_inv.aat.change_longitude_range(inplace=True)
+        >>> ds_inv.get_index("lon")
+        Int64Index([0, 5, 120, 199], dtype='int64', name='lon')
+        """
+        data_obj = self._get_obj(inplace=inplace)
+        lon_min = data_obj.indexes[self.x_dim].min()
+        lon_max = data_obj.indexes[self.x_dim].max()
+
+        if lon_max > 180:
+            logger.info("Converting longitude from 0 360 to -180 to 180.")
+
+            data_obj[self.x_dim] = np.sort(
+                ((data_obj[self.x_dim] + 180) % 360) - 180
+            )
+
+        elif lon_min < 0:
+            logger.info("Converting longitude from -180 to 180 to 0 to 360.")
+
+            data_obj[self.x_dim] = np.sort(data_obj[self.x_dim] % 360)
+
+        else:
+            logger.info(
+                "Indeterminate longitude range and no need to convert."
+            )
+
+        return data_obj if not inplace else None
+
+
+@xr.register_dataarray_accessor("aat")
+class AatRasterArray(AatRasterMixin, RasterArray):
+    """AA toolbox extension for xarray.DataArray."""
+
+    def __init__(self, xarray_object):
+        super().__init__(xarray_object)
+
+    def compute_raster_stats(
+        self,
+        gdf: gpd.GeoDataFrame,
+        feature_col: str,
+        stats_list: List[str] = None,
+        percentile_list: List[int] = None,
+        all_touched: bool = False,
+        geom_col: str = "geometry",
+    ) -> pd.DataFrame:
+        """Compute raster statistics for polygon geometry.
+
+        ``compute_raster_stats()`` is designed to
+        quickly compute raster statistics across a polygon
+        and its features.
+
+        Parameters
+        ----------
+        gdf : geopandas.GeoDataFrame
+            GeoDataFrame with row per area for stats computation.
+        feature_col : str
+            Column in ``gdf`` to use as row/feature identifier.
+        stats_list : List[str], optional
+            List of statistics to calculate, by default None.
+            Passed to ``get_attr()``.
+        percentile_list : List[int], optional
+            List of percentiles to compute, by default None.
+        all_touched : bool, optional
+            If ``True`` all cells touching the region will be
+            included, by default False. If ``False``, only
+            cells with their centre in the region will be
+            included.
+        geom_col : str, optional
+            Column in ``gdf`` with geometry, by default "geometry".
+
+        Returns
+        -------
+        pandas.DataFrame
+            Dataframe with computed statistics.
+        """
+        data_obj = self._get_obj(inplace=False)
+        if data_obj.rio.crs is None:
+            raise MissingCRS("No CRS found, set CRS before computation.")
+
+        df_list = []
+
+        if stats_list is None:
+            stats_list = ["mean", "std", "min", "max", "sum", "count"]
+
+        for feature in gdf[feature_col].unique():
+            gdf_adm = gdf[gdf[feature_col] == feature]
+
+            da_clip = data_obj.rio.set_spatial_dims(
+                x_dim=self.x_dim, y_dim=self.y_dim
+            )
+
+            # clip returns error if no overlapping raster cells for geometry
+            # so catching and skipping rest of iteration so no stats computed
+            try:
+                da_clip = da_clip.rio.clip(
+                    gdf_adm[geom_col], all_touched=all_touched
+                )
+            except NoDataInBounds:
+                logger.warning(
+                    "No overlapping raster cells for %s, skipping.", feature
+                )
+                continue
+
+            grid_stat_all = []
+            for stat in stats_list:
+                # count automatically ignores NaNs
+                # therefore skipna can also not be given as an argument
+                # implemented count cause needed for computing percentages
+                kwargs: Dict[str, Any] = {}
+                if stat != "count":
+                    kwargs["skipna"] = True
+                # makes sum return NaN instead of 0 if array
+                # only contains NaNs
+                if stat == "sum":
+                    kwargs["min_count"] = 1
+                grid_stat = getattr(da_clip, stat)(
+                    dim=[self.x_dim, self.y_dim], **kwargs
+                ).rename(f"{stat}_{feature_col}")
+                grid_stat_all.append(grid_stat)
+
+            if percentile_list is not None:
+                grid_quant = [
+                    da_clip.quantile(quant / 100, dim=[self.x_dim, self.y_dim])
+                    .drop("quantile")
+                    .rename(f"{quant}quant_{feature_col}")
+                    for quant in percentile_list
+                ]
+                grid_stat_all.extend(grid_quant)
+
+            # if dims is 0, it throws an error when merging
+            # and then converting to a df
+            # this occurs when the input da is 2D
+            if not grid_stat_all[0].dims:
+                df_adm = pd.DataFrame(
+                    {
+                        da_stat.name: [da_stat.values]
+                        for da_stat in grid_stat_all
+                    }
+                )
+
+            else:
+                zonal_stats_xr = xr.merge(grid_stat_all)
+                df_adm = (
+                    zonal_stats_xr.to_dataframe()
+                    .drop("spatial_ref", axis=1)
+                    .reset_index()
+                )
+            df_adm[feature_col] = feature
+            df_list.append(df_adm)
+
+        df_zonal_stats = pd.concat(df_list).reset_index(drop=True)
+        return df_zonal_stats
+
+
+@xr.register_dataset_accessor("aat")
+class AatRasterDataset(AatRasterMixin, RasterDataset):
+    """AA toolbox extension for xarray.Dataset."""
+
+    def __init__(self, xarray_object):
+        super().__init__(xarray_object)
+
+    def get_raster_array(self, var: str) -> xr.DataArray:
+        """Get xarray.DataArray from variable and keep dimensions.
+
+        Accessing a component xarray.DataArray using the
+        non-coordinate variable name loses and dimensions set
+        through ``rio`` or ``aat``. This includes ``x_dim``,
+        ``y_dim``, and ``t_dim`` that have to be specifically
+        set using ``rio.set_spatial_dims()`` or
+        ``aat.set_time_dim()`` respectively. For any dataset
+        ``ds``, ``ds.get_raster_array("var")`` will retrieve
+        the data array without losing the dimensions. Using
+        ``ds["var"]`` will lose the dimensions.
+
+        Parameters
+        ----------
+        var : str
+            Name of variable.
+
+        Returns
+        -------
+        xarray.DataArray
+            A data array.
+
+        Examples
+        --------
+        >>> import xarray
+        >>> import numpy
+        >>> temp = 15 + 8 * numpy.random.randn(4, 4, 3)
+        >>> precip = 10 * np.random.rand(4, 4, 3)
+        >>> ds = xarray.Dataset(
+        ...   {
+        ...     "temperature": (["lat", "lon", "F"], temp),
+        ...     "precipitation": (["lat", "lon", "F"], precip)
+        ...   },
+        ...   coords={
+        ...     "lat":numpy.array([87, 88, 89, 90]),
+        ...     "lon":numpy.array([5, 120, 199, 360]),
+        ...     "F": pd.date_range("2014-09-06", periods=3)
+        ...   }
+        ... )
+        >>> ds.aat.set_time_dim("F", inplace=True)
+        >>> da = ds.aat.get_raster_array("temperature")
+        >>> da.aat.t_dim
+        'F'
+        >>> # directly accessing array loses set dimensions
+        >>> ds['temperature'].aat.t_dim # doctest: +NORMALIZE_WHITESPACE
+        Traceback (most recent call last):
+            ...
+        rioxarray.exceptions.DimensionError: Time dimension not found.
+            'aat.set_time_dim()' or using 'rename()' to change the
+            dimension name to 't' can address this.
+        Data variable: temperature
+        """
+        obj = self._obj[var]
+        # rioxarray attributes
+        if self._x_dim is not None and self._y_dim is not None:
+            obj.rio.set_spatial_dims(
+                x_dim=self._x_dim, y_dim=self._y_dim, inplace=True
+            )
+        if self._crs is not None:
+            obj.rio.set_crs(self._crs, inplace=True)
+
+        # raster module attributes
+        if self._t_dim is not None:
+            obj.aat.set_time_dim(self._t_dim, inplace=True)
+
+        return obj
+
+    def compute_raster_stats(
+        self, vars: Union[List[str], None] = None, **kwargs: Any
+    ):
+        """Compute raster statistics across dataset arrays.
+
+        ``compute_raster_stats()`` calculates raster statistics on
+        component data arrays of a dataset. By default, calculates on all
+        non-coordinate variables, unless a list of variable names is
+        passed in, which then have statistics calculated for them.
+
+        Parameters
+        ----------
+        vars : Union[List[str], None], optional
+            Dataset data array variables to calculate raster statistics on.
+
+        kwargs : Any
+            Keyword arguments passed to the array method
+            :meth:`~AatRasterArray.compute_raster_stats`
+
+        Returns
+        -------
+        List[pandas.DataFrame]
+            List of raster statistics data frames.
+        """
+        vars = self.vars if vars is None else vars
+        stats = [self._obj[var].compute_raster_stats(**kwargs) for var in vars]
+        return stats
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 00000000..707a1e4b
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,26 @@
+[tox]
+isolated_build = true
+envlist =
+    py36
+    py37
+    py38
+    py39
+
+[testenv]
+wheel = true
+recreate = true
+deps = -r requirements/requirements-tests.txt
+commands =
+    pytest \
+    --cov=aatoolbox \
+    --no-cov-on-fail \
+    --junitxml=.tox/test-results.xml \
+    --cov-report=xml \
+    --cov-report=term-missing
+
+[gh-actions]
+python =
+    3.6: py36
+    3.7: py37
+    3.8: py38
+    3.9: py39


Test patch:
diff --git a/.github/workflows/run-python-tests.yaml b/.github/workflows/run-python-tests.yaml
new file mode 100644
index 00000000..789b33ab
--- /dev/null
+++ b/.github/workflows/run-python-tests.yaml
@@ -0,0 +1,54 @@
+name: tests
+on: push
+jobs:
+  build-and-test:
+    name: Run unit tests
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ['3.6', '3.7', '3.8', '3.9']
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v2
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install GDAL
+      run: |
+        sudo apt-get update
+        sudo apt-get install -y \
+          libgdal-dev
+    - name: Install Python dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install --upgrade -r requirements/requirements-tests.txt
+        pip install --upgrade tox-gh-actions
+    - name: Run tests
+      run: |
+        tox
+    - name: Upload test results
+      if: always()
+      uses: actions/upload-artifact@v2
+      with:
+        name: py${{ matrix.python-version }}
+        path: .tox/test-results.xml
+    - name: Upload coverage results to Codecov
+      uses: codecov/codecov-action@v2
+      if: success() && matrix.python-version == 3.9
+
+  publish-test-results:
+    name: Publish unit test results
+    needs: build-and-test
+    runs-on: ubuntu-latest
+    if: always()
+    steps:
+      - name: Download Artifacts
+        uses: actions/download-artifact@v2
+        with:
+          path: artifacts
+      - name: Publish test results to GitHub
+        uses: EnricoMi/publish-unit-test-result-action@v1
+        with:
+          github_token: ${{ secrets.GITHUB_TOKEN }}
+          files: artifacts/**/*.xml
diff --git a/requirements/requirements-tests.txt b/requirements/requirements-tests.txt
index 0afe190d..02df4d61 100644
--- a/requirements/requirements-tests.txt
+++ b/requirements/requirements-tests.txt
@@ -1,30 +1,53 @@
-#
-# This file is autogenerated by pip-compile with python 3.8
-# To update, run:
-#
-#    pip-compile --output-file=requirements/requirements-tests.txt requirements/tests.in
-#
 attrs==21.2.0
     # via pytest
-coverage[toml]==6.0.2
+backports.entry-points-selectable==1.1.1
+    # via virtualenv
+coverage[toml]==6.1.2
     # via pytest-cov
+distlib==0.3.3
+    # via virtualenv
+filelock==3.4.0
+    # via
+    #   tox
+    #   virtualenv
 iniconfig==1.1.1
     # via pytest
-packaging==21.0
-    # via pytest
+packaging==21.3
+    # via
+    #   pytest
+    #   tox
+platformdirs==2.4.0
+    # via virtualenv
 pluggy==1.0.0
-    # via pytest
-py==1.10.0
-    # via pytest
-pyparsing==2.4.7
+    # via
+    #   pytest
+    #   tox
+py==1.11.0
+    # via
+    #   pytest
+    #   tox
+pyparsing==3.0.6
     # via packaging
 pytest==6.2.5
     # via
     #   -r requirements/tests.in
     #   pytest-cov
+    #   pytest-mock
 pytest-cov==3.0.0
     # via -r requirements/tests.in
+pytest-mock==3.6.1
+    # via -r requirements/tests.in
+six==1.16.0
+    # via
+    #   tox
+    #   virtualenv
 toml==0.10.2
-    # via pytest
-tomli==1.2.1
+    # via
+    #   pytest
+    #   tox
+tomli==1.2.2
     # via coverage
+tox==3.24.5
+    # via -r requirements/tests.in
+virtualenv==20.10.0
+    # via tox
diff --git a/requirements/tests.in b/requirements/tests.in
index 9955decc..9fb3aea7 100644
--- a/requirements/tests.in
+++ b/requirements/tests.in
@@ -1,2 +1,4 @@
 pytest
 pytest-cov
+pytest-mock
+tox
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)
diff --git a/tests/test_area.py b/tests/test_area.py
deleted file mode 100644
index 284cca6e..00000000
--- a/tests/test_area.py
+++ /dev/null
@@ -1,47 +0,0 @@
-"""Tests for the CDS Area module."""
-from geopandas import GeoSeries
-from shapely.geometry import Polygon
-
-from aatoolbox.datasources.cds.area import (
-    AreaFromShape,
-    AreaFromStations,
-    Station,
-)
-
-FAKE_STATIONS = {
-    "station_north": Station(lon=0, lat=1),
-    "station_south": Station(lon=0, lat=-2),
-    "station_east": Station(lon=3, lat=0),
-    "station_west": Station(lon=-4, lat=0),
-}
-
-
-def test_get_area_from_stations():
-    """Test that the buffer around area from stations."""
-    area = AreaFromStations(FAKE_STATIONS, buffer=0.1)
-    assert area.north == 1.1
-    assert area.south == -2.1
-    assert area.east == 3.1
-    assert area.west == -4.1
-
-
-def test_get_list_for_api():
-    """Test that the output list is in the correct order."""
-    area = AreaFromStations(FAKE_STATIONS, buffer=0)
-    assert area.list_for_api(offset_val=0.05) == [
-        1.05,
-        -4.05,
-        -2.05,
-        3.05,
-    ]
-
-
-def test_get_area_from_shape():
-    """Test that getting the area from a polygon works as expected."""
-    n, s, e, w = (1, -2, 3, -4)
-    shape = GeoSeries([Polygon([(e, n), (e, s), (w, s), (w, n)])])
-    area = AreaFromShape(shape)
-    assert area.north == n
-    assert area.south == s
-    assert area.east == e
-    assert area.west == w
diff --git a/tests/test_fewsnet.py b/tests/test_fewsnet.py
new file mode 100644
index 00000000..786af5a4
--- /dev/null
+++ b/tests/test_fewsnet.py
@@ -0,0 +1,123 @@
+"""Tests for the FewsNet module."""
+import pytest
+
+from aatoolbox.datasources.fewsnet.fewsnet import download_fewsnet
+
+
+def test_download_fewsnet_country(mock_download_call, tmp_path):
+    """Test that the correct country url and path is returned."""
+    url, output_path = mock_download_call(
+        date_pub="2020-10-01",
+        iso2="et",
+        region_name="east-africa",
+        region_code="EA",
+        country_data=True,
+        region_data=False,
+    )
+
+    assert (
+        url == "https://fdw.fews.net/api/ipcpackage/"
+        "?country_code=ET&collection_date=2020-10-01"
+    )
+
+    assert output_path == tmp_path / "ET202010"
+
+
+def test_download_fewsnet_region(mock_download_call, tmp_path):
+    """Test that the correct region url and path is returned."""
+    url, output_path = mock_download_call(
+        date_pub="2020-10-01",
+        iso2="et",
+        region_name="east-africa",
+        region_code="EA",
+        country_data=False,
+        region_data=True,
+    )
+
+    assert (
+        url == "https://fews.net/data_portal_download/download?"
+        "data_file_path=http://shapefiles.fews.net.s3.amazonaws.com/"
+        "HFIC/EA/east-africa202010.zip"
+    )
+
+    assert output_path == tmp_path / "EA202010"
+
+
+def test_download_fewsnet_nodata(mock_download_call):
+    """Test that RuntimeError is returned when no data exists."""
+    with pytest.raises(RuntimeError) as e:
+        url, output_path = mock_download_call(
+            date_pub="2020-10-01",
+            iso2="et",
+            region_name="east-africa",
+            region_code="EA",
+            country_data=False,
+            region_data=False,
+        )
+        assert output_path is None
+    assert "No data found for 2020-10" in str(e.value)
+
+
+@pytest.fixture
+def mock_download_call(mock_fake_url, mock_countryregion, tmp_path):
+    """Mock call to download_fewsnet."""
+    url_mock = mock_fake_url
+
+    def _get_country_mock(
+        date_pub, iso2, region_name, region_code, country_data, region_data
+    ):
+        mock_countryregion(country_data, region_data)
+
+        output_path = download_fewsnet(
+            date_pub=date_pub,
+            iso2=iso2,
+            region_name=region_name,
+            region_code=region_code,
+            output_dir=tmp_path,
+            use_cache=False,
+        )
+
+        _, kwargs_download_url = url_mock.call_args
+        url = kwargs_download_url["url"]
+
+        return url, output_path
+
+    return _get_country_mock
+
+
+@pytest.fixture
+def mock_fake_url(mocker):
+    """Mock url and unzip call."""
+    fakedownloadurl = mocker.patch(
+        "aatoolbox.datasources.fewsnet.fewsnet.download_url"
+    )
+    mocker.patch("aatoolbox.datasources.fewsnet.fewsnet.unzip")
+    return fakedownloadurl
+
+
+@pytest.fixture
+def mock_countryregion(mocker):
+    """Mock that no country and/or region data exists."""
+
+    def _mock_countryregion(country_data: bool, region_data: bool):
+        if country_data:
+            return None
+        else:
+            if region_data:
+                return mocker.patch(
+                    "aatoolbox.datasources.fewsnet.fewsnet."
+                    "_download_fewsnet_country",
+                    return_value=None,
+                )
+            else:
+                return mocker.patch(
+                    "aatoolbox.datasources.fewsnet.fewsnet."
+                    "_download_fewsnet_country",
+                    return_value=None,
+                ), mocker.patch(
+                    "aatoolbox.datasources.fewsnet.fewsnet."
+                    "_download_fewsnet_region",
+                    return_value=None,
+                )
+
+    return _mock_countryregion
diff --git a/tests/test_geoboundingbox.py b/tests/test_geoboundingbox.py
new file mode 100644
index 00000000..d2697957
--- /dev/null
+++ b/tests/test_geoboundingbox.py
@@ -0,0 +1,32 @@
+"""Tests for the CDS GeoBoundingBox module."""
+from geopandas import GeoSeries
+from shapely.geometry import Polygon
+
+from aatoolbox.utils.geoboundingbox import GeoBoundingBox
+
+
+def test_geoboundingbox_round_coords():
+    """Test that coordinates are correctly rounded and offset."""
+    geobb = GeoBoundingBox(north=1.05, south=-2.2, east=3.6, west=-4)
+    geobb.round_boundingbox_coords(offset_val=0.4)
+    assert geobb.north == 2.4
+    assert geobb.south == -3.4
+    assert geobb.east == 4.4
+    assert geobb.west == -4.4
+
+
+def test_geoboundingbox_filename():
+    """Test that correct file string is returned."""
+    geobb = GeoBoundingBox(north=1, south=-2, east=3, west=-4)
+    assert geobb.get_filename_repr() == "Np1Sm2Ep3Wm4"
+
+
+def test_geoboundingbox_from_shape():
+    """Test that getting the geobb from a polygon works as expected."""
+    n, s, e, w = (1, -2, 3, -4)
+    shape = GeoSeries([Polygon([(e, n), (e, s), (w, s), (w, n)])])
+    geobb = GeoBoundingBox.from_shape(shape)
+    assert geobb.north == n
+    assert geobb.south == s
+    assert geobb.east == e
+    assert geobb.west == w
diff --git a/tests/test_raster.py b/tests/test_raster.py
new file mode 100644
index 00000000..b3f5f7b8
--- /dev/null
+++ b/tests/test_raster.py
@@ -0,0 +1,9 @@
+"""Tests for the raster utilities module."""
+import doctest
+import unittest
+
+from aatoolbox.utils import raster
+
+suite = unittest.TestSuite()
+suite.addTest(doctest.DocTestSuite(raster))
+unittest.TextTestRunner().run(suite)