diff --git a/python_nedrex b/python_nedrex
deleted file mode 160000
index ee1cd32fd15f6b73647df70bacb9d0ebd7858236..0000000000000000000000000000000000000000
--- a/python_nedrex
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit ee1cd32fd15f6b73647df70bacb9d0ebd7858236
diff --git a/python_nedrex/.editorconfig b/python_nedrex/.editorconfig
new file mode 100644
index 0000000000000000000000000000000000000000..d4a2c4405ec2e962c521a13af91bf5f7098a62a8
--- /dev/null
+++ b/python_nedrex/.editorconfig
@@ -0,0 +1,21 @@
+# http://editorconfig.org
+
+root = true
+
+[*]
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+insert_final_newline = true
+charset = utf-8
+end_of_line = lf
+
+[*.bat]
+indent_style = tab
+end_of_line = crlf
+
+[LICENSE]
+insert_final_newline = false
+
+[Makefile]
+indent_style = tab
diff --git a/python_nedrex/.github/ISSUE_TEMPLATE.md b/python_nedrex/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 0000000000000000000000000000000000000000..af313969d0e0d2d38a210e40234d1900b52e2de3
--- /dev/null
+++ b/python_nedrex/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,15 @@
+* python-nedrex version:
+* Python version:
+* Operating System:
+
+### Description
+
+Describe what you were trying to get done.
+Tell us what happened, what went wrong, and what you expected to happen.
+
+### What I Did
+
+```
+Paste the command(s) you ran and the output.
+If there was a crash, please include the traceback here.
+```
diff --git a/python_nedrex/.gitignore b/python_nedrex/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..4c915d144e4439679f75a551bbfc014439e8fa0a
--- /dev/null
+++ b/python_nedrex/.gitignore
@@ -0,0 +1,106 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# dotenv
+.env
+
+# virtualenv
+.venv
+venv/
+ENV/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+
+# IDE settings
+.vscode/
+.idea/
diff --git a/python_nedrex/.travis.yml b/python_nedrex/.travis.yml
new file mode 100644
index 0000000000000000000000000000000000000000..aaba487b1106019a316ff25958a0f6d7ae12b5dd
--- /dev/null
+++ b/python_nedrex/.travis.yml
@@ -0,0 +1,15 @@
+# Config file for automatic testing at travis-ci.com
+
+language: python
+python:
+  - 3.8
+  - 3.7
+  - 3.6
+
+# Command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors
+install: pip install -U tox-travis
+
+# Command to run tests, e.g. python setup.py test
+script: tox
+
+
diff --git a/python_nedrex/AUTHORS.rst b/python_nedrex/AUTHORS.rst
new file mode 100644
index 0000000000000000000000000000000000000000..1dbe264bc73d7c02d56b64482f07ca050384b9ea
--- /dev/null
+++ b/python_nedrex/AUTHORS.rst
@@ -0,0 +1,13 @@
+=======
+Credits
+=======
+
+Development Lead
+----------------
+
+* David James Skelton <james.skelton@newcastle.ac.uk>
+
+Contributors
+------------
+
+None yet. Why not be the first?
diff --git a/python_nedrex/CONTRIBUTING.rst b/python_nedrex/CONTRIBUTING.rst
new file mode 100644
index 0000000000000000000000000000000000000000..613ac50825a0bd720c752b727641c78a2ef2afa0
--- /dev/null
+++ b/python_nedrex/CONTRIBUTING.rst
@@ -0,0 +1,128 @@
+.. highlight:: shell
+
+============
+Contributing
+============
+
+Contributions are welcome, and they are greatly appreciated! Every little bit
+helps, and credit will always be given.
+
+You can contribute in many ways:
+
+Types of Contributions
+----------------------
+
+Report Bugs
+~~~~~~~~~~~
+
+Report bugs at https://github.com/james-skelton/python_nedrex/issues.
+
+If you are reporting a bug, please include:
+
+* Your operating system name and version.
+* Any details about your local setup that might be helpful in troubleshooting.
+* Detailed steps to reproduce the bug.
+
+Fix Bugs
+~~~~~~~~
+
+Look through the GitHub issues for bugs. Anything tagged with "bug" and "help
+wanted" is open to whoever wants to implement it.
+
+Implement Features
+~~~~~~~~~~~~~~~~~~
+
+Look through the GitHub issues for features. Anything tagged with "enhancement"
+and "help wanted" is open to whoever wants to implement it.
+
+Write Documentation
+~~~~~~~~~~~~~~~~~~~
+
+python-nedrex could always use more documentation, whether as part of the
+official python-nedrex docs, in docstrings, or even on the web in blog posts,
+articles, and such.
+
+Submit Feedback
+~~~~~~~~~~~~~~~
+
+The best way to send feedback is to file an issue at https://github.com/james-skelton/python_nedrex/issues.
+
+If you are proposing a feature:
+
+* Explain in detail how it would work.
+* Keep the scope as narrow as possible, to make it easier to implement.
+* Remember that this is a volunteer-driven project, and that contributions
+  are welcome :)
+
+Get Started!
+------------
+
+Ready to contribute? Here's how to set up `python_nedrex` for local development.
+
+1. Fork the `python_nedrex` repo on GitHub.
+2. Clone your fork locally::
+
+    $ git clone git@github.com:your_name_here/python_nedrex.git
+
+3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development::
+
+    $ mkvirtualenv python_nedrex
+    $ cd python_nedrex/
+    $ python setup.py develop
+
+4. Create a branch for local development::
+
+    $ git checkout -b name-of-your-bugfix-or-feature
+
+   Now you can make your changes locally.
+
+5. When you're done making changes, check that your changes pass flake8 and the
+   tests, including testing other Python versions with tox::
+
+    $ flake8 python_nedrex tests
+    $ python setup.py test or pytest
+    $ tox
+
+   To get flake8 and tox, just pip install them into your virtualenv.
+
+6. Commit your changes and push your branch to GitHub::
+
+    $ git add .
+    $ git commit -m "Your detailed description of your changes."
+    $ git push origin name-of-your-bugfix-or-feature
+
+7. Submit a pull request through the GitHub website.
+
+Pull Request Guidelines
+-----------------------
+
+Before you submit a pull request, check that it meets these guidelines:
+
+1. The pull request should include tests.
+2. If the pull request adds functionality, the docs should be updated. Put
+   your new functionality into a function with a docstring, and add the
+   feature to the list in README.rst.
+3. The pull request should work for Python 3.5, 3.6, 3.7 and 3.8, and for PyPy. Check
+   https://travis-ci.com/james-skelton/python_nedrex/pull_requests
+   and make sure that the tests pass for all supported Python versions.
+
+Tips
+----
+
+To run a subset of tests::
+
+$ pytest tests.test_python_nedrex
+
+
+Deploying
+---------
+
+A reminder for the maintainers on how to deploy.
+Make sure all your changes are committed (including an entry in HISTORY.rst).
+Then run::
+
+$ bump2version patch # possible: major / minor / patch
+$ git push
+$ git push --tags
+
+Travis will then deploy to PyPI if tests pass.
diff --git a/python_nedrex/HISTORY.rst b/python_nedrex/HISTORY.rst
new file mode 100644
index 0000000000000000000000000000000000000000..a00abfc2a76e39e812674a740560d4b9201c6ed2
--- /dev/null
+++ b/python_nedrex/HISTORY.rst
@@ -0,0 +1,8 @@
+=======
+History
+=======
+
+0.1.0 (2022-02-23)
+------------------
+
+* First release on PyPI.
diff --git a/python_nedrex/LICENSE b/python_nedrex/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..a5613254179213c20513fadf70bd44581641f21f
--- /dev/null
+++ b/python_nedrex/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2022, David James Skelton
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/python_nedrex/MANIFEST.in b/python_nedrex/MANIFEST.in
new file mode 100644
index 0000000000000000000000000000000000000000..965b2dda7db7c49f68857dc3aea9af37e30a745e
--- /dev/null
+++ b/python_nedrex/MANIFEST.in
@@ -0,0 +1,11 @@
+include AUTHORS.rst
+include CONTRIBUTING.rst
+include HISTORY.rst
+include LICENSE
+include README.rst
+
+recursive-include tests *
+recursive-exclude * __pycache__
+recursive-exclude * *.py[co]
+
+recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif
diff --git a/python_nedrex/Makefile b/python_nedrex/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..054489ef33bb498a7409b1df27228b0cc20dad30
--- /dev/null
+++ b/python_nedrex/Makefile
@@ -0,0 +1,89 @@
+.PHONY: clean clean-build clean-pyc clean-test coverage dist docs help install lint lint/flake8 lint/black
+.DEFAULT_GOAL := help
+
+define BROWSER_PYSCRIPT
+import os, webbrowser, sys
+
+from urllib.request import pathname2url
+
+webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1])))
+endef
+export BROWSER_PYSCRIPT
+
+define PRINT_HELP_PYSCRIPT
+import re, sys
+
+for line in sys.stdin:
+	match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line)
+	if match:
+		target, help = match.groups()
+		print("%-20s %s" % (target, help))
+endef
+export PRINT_HELP_PYSCRIPT
+
+BROWSER := python -c "$$BROWSER_PYSCRIPT"
+
+help:
+	@python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
+
+clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts
+
+clean-build: ## remove build artifacts
+	rm -fr build/
+	rm -fr dist/
+	rm -fr .eggs/
+	find . -name '*.egg-info' -exec rm -fr {} +
+	find . -name '*.egg' -exec rm -f {} +
+
+clean-pyc: ## remove Python file artifacts
+	find . -name '*.pyc' -exec rm -f {} +
+	find . -name '*.pyo' -exec rm -f {} +
+	find . -name '*~' -exec rm -f {} +
+	find . -name '__pycache__' -exec rm -fr {} +
+
+clean-test: ## remove test and coverage artifacts
+	rm -fr .tox/
+	rm -f .coverage
+	rm -fr htmlcov/
+	rm -fr .pytest_cache
+
+lint/flake8: ## check style with flake8
+	flake8 python_nedrex tests
+lint/black: ## check style with black
+	black --check python_nedrex tests
+
+lint: lint/flake8 lint/black ## check style
+
+test: ## run tests quickly with the default Python
+	pytest
+
+test-all: ## run tests on every Python version with tox
+	tox
+
+coverage: ## check code coverage quickly with the default Python
+	coverage run --source python_nedrex -m pytest
+	coverage report -m
+	coverage html
+	$(BROWSER) htmlcov/index.html
+
+docs: ## generate Sphinx HTML documentation, including API docs
+	rm -f docs/python_nedrex.rst
+	rm -f docs/modules.rst
+	sphinx-apidoc -o docs/ python_nedrex
+	$(MAKE) -C docs clean
+	$(MAKE) -C docs html
+	$(BROWSER) docs/_build/html/index.html
+
+servedocs: docs ## compile the docs watching for changes
+	watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D .
+
+release: dist ## package and upload a release
+	twine upload dist/*
+
+dist: clean ## builds source and wheel package
+	python setup.py sdist
+	python setup.py bdist_wheel
+	ls -l dist
+
+install: clean ## install the package to the active Python's site-packages
+	python setup.py install
diff --git a/python_nedrex/README.rst b/python_nedrex/README.rst
new file mode 100644
index 0000000000000000000000000000000000000000..f7946f7194b8d94928207ab193dce4d77ca55c6d
--- /dev/null
+++ b/python_nedrex/README.rst
@@ -0,0 +1,39 @@
+=============
+python-nedrex
+=============
+
+
+.. image:: https://img.shields.io/pypi/v/python_nedrex.svg
+        :target: https://pypi.python.org/pypi/python_nedrex
+
+.. image:: https://img.shields.io/travis/james-skelton/python_nedrex.svg
+        :target: https://travis-ci.com/james-skelton/python_nedrex
+
+.. image:: https://readthedocs.org/projects/python-nedrex/badge/?version=latest
+        :target: https://python-nedrex.readthedocs.io/en/latest/?version=latest
+        :alt: Documentation Status
+
+
+
+
+A Python library for interfacing with the PNeDRex API
+
+
+* Free software: MIT license
+* Documentation: https://docs.google.com/document/d/1nUngfKSXkqPi_EPaD9d1w3M0SVAuhj6PY4tRMgA8RMk/edit?usp=sharing
+..
+  * Documentation: https://python-nedrex.readthedocs.io.
+
+
+Features
+--------
+
+* TODO
+
+Credits
+-------
+
+This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template.
+
+.. _Cookiecutter: https://github.com/audreyr/cookiecutter
+.. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage
diff --git a/python_nedrex/docs/Makefile b/python_nedrex/docs/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..a94a769f58c441889de3445fa190c4129dcf47b1
--- /dev/null
+++ b/python_nedrex/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = python -msphinx
+SPHINXPROJ    = python_nedrex
+SOURCEDIR     = .
+BUILDDIR      = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/python_nedrex/docs/authors.rst b/python_nedrex/docs/authors.rst
new file mode 100644
index 0000000000000000000000000000000000000000..e122f914a87b277e565fc9567af1a7545ec9872b
--- /dev/null
+++ b/python_nedrex/docs/authors.rst
@@ -0,0 +1 @@
+.. include:: ../AUTHORS.rst
diff --git a/python_nedrex/docs/conf.py b/python_nedrex/docs/conf.py
new file mode 100755
index 0000000000000000000000000000000000000000..adec9e246797329e226ac345df60d2463d7cfa5e
--- /dev/null
+++ b/python_nedrex/docs/conf.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python
+#
+# python_nedrex documentation build configuration file, created by
+# sphinx-quickstart on Fri Jun  9 13:47:02 2017.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+# If extensions (or modules to document with autodoc) are in another
+# directory, add these directories to sys.path here. If the directory is
+# relative to the documentation root, use os.path.abspath to make it
+# absolute, like shown here.
+#
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath(".."))
+
+import python_nedrex
+
+# -- General configuration ---------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+# needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
+extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ["_templates"]
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = ".rst"
+
+# The master toctree document.
+master_doc = "index"
+
+# General information about the project.
+project = "python-nedrex"
+copyright = "2022, David James Skelton"
+author = "David James Skelton"
+
+# The version info for the project you're documenting, acts as replacement
+# for |version| and |release|, also used in various other places throughout
+# the built documents.
+#
+# The short X.Y version.
+version = python_nedrex.__version__
+# The full version, including alpha/beta/rc tags.
+release = python_nedrex.__version__
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = None
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This patterns also effect to html_static_path and html_extra_path
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = "sphinx"
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = False
+
+
+# -- Options for HTML output -------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = "alabaster"
+
+# Theme options are theme-specific and customize the look and feel of a
+# theme further.  For a list of options available for each theme, see the
+# documentation.
+#
+# html_theme_options = {}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ["_static"]
+
+
+# -- Options for HTMLHelp output ---------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = "python_nedrexdoc"
+
+
+# -- Options for LaTeX output ------------------------------------------
+
+latex_elements = {
+    # The paper size ('letterpaper' or 'a4paper').
+    #
+    # 'papersize': 'letterpaper',
+    # The font size ('10pt', '11pt' or '12pt').
+    #
+    # 'pointsize': '10pt',
+    # Additional stuff for the LaTeX preamble.
+    #
+    # 'preamble': '',
+    # Latex figure (float) alignment
+    #
+    # 'figure_align': 'htbp',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title, author, documentclass
+# [howto, manual, or own class]).
+latex_documents = [
+    (
+        master_doc,
+        "python_nedrex.tex",
+        "python-nedrex Documentation",
+        "David James Skelton",
+        "manual",
+    ),
+]
+
+
+# -- Options for manual page output ------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [(master_doc, "python_nedrex", "python-nedrex Documentation", [author], 1)]
+
+
+# -- Options for Texinfo output ----------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+texinfo_documents = [
+    (
+        master_doc,
+        "python_nedrex",
+        "python-nedrex Documentation",
+        author,
+        "python_nedrex",
+        "One line description of project.",
+        "Miscellaneous",
+    ),
+]
diff --git a/python_nedrex/docs/contributing.rst b/python_nedrex/docs/contributing.rst
new file mode 100644
index 0000000000000000000000000000000000000000..e582053ea018c369be05aae96cf730744f1dc616
--- /dev/null
+++ b/python_nedrex/docs/contributing.rst
@@ -0,0 +1 @@
+.. include:: ../CONTRIBUTING.rst
diff --git a/python_nedrex/docs/history.rst b/python_nedrex/docs/history.rst
new file mode 100644
index 0000000000000000000000000000000000000000..250649964bbc36f4bec2942f69238aa6f7c02c1a
--- /dev/null
+++ b/python_nedrex/docs/history.rst
@@ -0,0 +1 @@
+.. include:: ../HISTORY.rst
diff --git a/python_nedrex/docs/index.rst b/python_nedrex/docs/index.rst
new file mode 100644
index 0000000000000000000000000000000000000000..af528faa0fd4fca728beed7f3686952d811d023c
--- /dev/null
+++ b/python_nedrex/docs/index.rst
@@ -0,0 +1,20 @@
+Welcome to python-nedrex's documentation!
+======================================
+
+.. toctree::
+   :maxdepth: 2
+   :caption: Contents:
+
+   readme
+   installation
+   usage
+   modules
+   contributing
+   authors
+   history
+
+Indices and tables
+==================
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/python_nedrex/docs/installation.rst b/python_nedrex/docs/installation.rst
new file mode 100644
index 0000000000000000000000000000000000000000..93c04d4018d3ff7988006aaf977affd9233c20c8
--- /dev/null
+++ b/python_nedrex/docs/installation.rst
@@ -0,0 +1,51 @@
+.. highlight:: shell
+
+============
+Installation
+============
+
+
+Stable release
+--------------
+
+To install python-nedrex, run this command in your terminal:
+
+.. code-block:: console
+
+    $ pip install python_nedrex
+
+This is the preferred method to install python-nedrex, as it will always install the most recent stable release.
+
+If you don't have `pip`_ installed, this `Python installation guide`_ can guide
+you through the process.
+
+.. _pip: https://pip.pypa.io
+.. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/
+
+
+From sources
+------------
+
+The sources for python-nedrex can be downloaded from the `Github repo`_.
+
+You can either clone the public repository:
+
+.. code-block:: console
+
+    $ git clone git://github.com/james-skelton/python_nedrex
+
+Or download the `tarball`_:
+
+.. code-block:: console
+
+    $ curl -OJL https://github.com/james-skelton/python_nedrex/tarball/master
+
+Once you have a copy of the source, you can install it with:
+
+.. code-block:: console
+
+    $ python setup.py install
+
+
+.. _Github repo: https://github.com/james-skelton/python_nedrex
+.. _tarball: https://github.com/james-skelton/python_nedrex/tarball/master
diff --git a/python_nedrex/docs/make.bat b/python_nedrex/docs/make.bat
new file mode 100644
index 0000000000000000000000000000000000000000..35b330121544f73b316d9ac94cb66c52cdacf331
--- /dev/null
+++ b/python_nedrex/docs/make.bat
@@ -0,0 +1,36 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=python -msphinx
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+set SPHINXPROJ=python_nedrex
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The Sphinx module was not found. Make sure you have Sphinx installed,
+	echo.then set the SPHINXBUILD environment variable to point to the full
+	echo.path of the 'sphinx-build' executable. Alternatively you may add the
+	echo.Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.http://sphinx-doc.org/
+	exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+
+:end
+popd
diff --git a/python_nedrex/docs/readme.rst b/python_nedrex/docs/readme.rst
new file mode 100644
index 0000000000000000000000000000000000000000..72a33558153fb57def85612b021ec596ef2a51b9
--- /dev/null
+++ b/python_nedrex/docs/readme.rst
@@ -0,0 +1 @@
+.. include:: ../README.rst
diff --git a/python_nedrex/docs/usage.rst b/python_nedrex/docs/usage.rst
new file mode 100644
index 0000000000000000000000000000000000000000..d6a051382e5ca140cfb74f1a28cb103d0f96ab08
--- /dev/null
+++ b/python_nedrex/docs/usage.rst
@@ -0,0 +1,7 @@
+=====
+Usage
+=====
+
+To use python-nedrex in a project::
+
+    import python_nedrex
diff --git a/python_nedrex/format.sh b/python_nedrex/format.sh
new file mode 100755
index 0000000000000000000000000000000000000000..5915b2fd5d360bbc452cd588700b7d19c88802ad
--- /dev/null
+++ b/python_nedrex/format.sh
@@ -0,0 +1,6 @@
+black -l 120 python_nedrex
+isort --profile black python_nedrex
+flake8 --max-line-length=120 python_nedrex
+pylint --max-line-length=120 python_nedrex
+bandit python_nedrex
+mypy --strict python_nedrex
diff --git a/python_nedrex/pylintrc b/python_nedrex/pylintrc
new file mode 100644
index 0000000000000000000000000000000000000000..77a744e0a4097918044cdcb8e0b716483b13b408
--- /dev/null
+++ b/python_nedrex/pylintrc
@@ -0,0 +1,590 @@
+[MASTER]
+
+# NOTE: This has been modified to disable C0114, C0115, and C0116 (docstrings)
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code.
+extension-pkg-allow-list=
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
+# for backward compatibility.)
+extension-pkg-whitelist=
+
+# Return non-zero exit code if any of these messages/categories are detected,
+# even if score is above --fail-under value. Syntax same as enable. Messages
+# specified are enabled, while categories only check already-enabled messages.
+fail-on=
+
+# Specify a score threshold to be exceeded before program exits with error.
+fail-under=10.0
+
+# Files or directories to be skipped. They should be base names, not paths.
+ignore=CVS
+
+# Add files or directories matching the regex patterns to the ignore-list. The
+# regex matches against paths and can be in Posix or Windows format.
+ignore-paths=
+
+# Files or directories matching the regex patterns are skipped. The regex
+# matches against base names, not paths. The default value ignores emacs file
+# locks
+ignore-patterns=^\.#
+
+# Python code to execute, usually for sys.path manipulation such as
+# pygtk.require().
+#init-hook=
+
+# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
+# number of processors available to use.
+jobs=1
+
+# Control the amount of potential inferred values when inferring a single
+# object. This can help the performance when dealing with large functions or
+# complex, nested conditions.
+limit-inference-results=100
+
+# List of plugins (as comma separated values of python module names) to load,
+# usually to register additional checkers.
+load-plugins=
+
+# Pickle collected data for later comparisons.
+persistent=yes
+
+# Minimum Python version to use for version dependent checks. Will default to
+# the version used to run pylint.
+py-version=3.6
+
+# Discover python modules and packages in the file system subtree.
+recursive=no
+
+# When enabled, pylint would attempt to guess common misconfiguration and emit
+# user-friendly hints instead of false-positive error messages.
+suggestion-mode=yes
+
+# Allow loading of arbitrary C extensions. Extensions are imported into the
+# active Python interpreter and may run arbitrary code.
+unsafe-load-any-extension=no
+
+
+[MESSAGES CONTROL]
+
+# Only show warnings with the listed confidence levels. Leave empty to show
+# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
+# UNDEFINED.
+confidence=
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once). You can also use "--disable=all" to
+# disable everything first and then re-enable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use "--disable=all --enable=classes
+# --disable=W".
+disable=raw-checker-failed,
+        bad-inline-option,
+        locally-disabled,
+        file-ignored,
+        suppressed-message,
+        useless-suppression,
+        deprecated-pragma,
+        use-symbolic-message-instead,
+        missing-module-docstring,
+        missing-class-docstring,
+        missing-function-docstring
+
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+enable=c-extension-no-member
+
+
+[REPORTS]
+
+# Python expression which should return a score less than or equal to 10. You
+# have access to the variables 'fatal', 'error', 'warning', 'refactor',
+# 'convention', and 'info' which contain the number of messages in each
+# category, as well as 'statement' which is the total number of statements
+# analyzed. This score is used by the global evaluation report (RP0004).
+evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
+
+# Template used to display messages. This is a python new-style format string
+# used to format the message information. See doc for all details.
+#msg-template=
+
+# Set the output format. Available formats are text, parseable, colorized, json
+# and msvs (visual studio). You can also give a reporter class, e.g.
+# mypackage.mymodule.MyReporterClass.
+output-format=text
+
+# Tells whether to display a full report or only the messages.
+reports=no
+
+# Activate the evaluation score.
+score=yes
+
+
+[REFACTORING]
+
+# Maximum number of nested blocks for function / method body
+max-nested-blocks=5
+
+# Complete name of functions that never returns. When checking for
+# inconsistent-return-statements if a never returning function is called then
+# it will be considered as an explicit return statement and no message will be
+# printed.
+never-returning-functions=sys.exit,argparse.parse_error
+
+
+[LOGGING]
+
+# The type of string formatting that logging methods do. `old` means using %
+# formatting, `new` is for `{}` formatting.
+logging-format-style=old
+
+# Logging modules to check that the string format arguments are in logging
+# function parameter format.
+logging-modules=logging
+
+
+[SPELLING]
+
+# Limits count of emitted suggestions for spelling mistakes.
+max-spelling-suggestions=4
+
+# Spelling dictionary name. Available dictionaries: none. To make it work,
+# install the 'python-enchant' package.
+spelling-dict=
+
+# List of comma separated words that should be considered directives if they
+# appear and the beginning of a comment and should not be checked.
+spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
+
+# List of comma separated words that should not be checked.
+spelling-ignore-words=
+
+# A path to a file that contains the private dictionary; one word per line.
+spelling-private-dict-file=
+
+# Tells whether to store unknown words to the private dictionary (see the
+# --spelling-private-dict-file option) instead of raising a message.
+spelling-store-unknown-words=no
+
+
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,
+      XXX,
+      TODO
+
+# Regular expression of note tags to take in consideration.
+#notes-rgx=
+
+
+[TYPECHECK]
+
+# List of decorators that produce context managers, such as
+# contextlib.contextmanager. Add to this list to register other decorators that
+# produce valid context managers.
+contextmanager-decorators=contextlib.contextmanager
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E1101 when accessed. Python regular
+# expressions are accepted.
+generated-members=
+
+# Tells whether missing members accessed in mixin class should be ignored. A
+# class is considered mixin if its name matches the mixin-class-rgx option.
+ignore-mixin-members=yes
+
+# Tells whether to warn about missing members when the owner of the attribute
+# is inferred to be None.
+ignore-none=yes
+
+# This flag controls whether pylint should warn about no-member and similar
+# checks whenever an opaque object is returned when inferring. The inference
+# can return multiple potential results while evaluating a Python object, but
+# some branches might not be evaluated, which results in partial inference. In
+# that case, it might be useful to still emit no-member and other checks for
+# the rest of the inferred objects.
+ignore-on-opaque-inference=yes
+
+# List of class names for which member attributes should not be checked (useful
+# for classes with dynamically set attributes). This supports the use of
+# qualified names.
+ignored-classes=optparse.Values,thread._local,_thread._local
+
+# List of module names for which member attributes should not be checked
+# (useful for modules/projects where namespaces are manipulated during runtime
+# and thus existing member attributes cannot be deduced by static analysis). It
+# supports qualified module names, as well as Unix pattern matching.
+ignored-modules=
+
+# Show a hint with possible names when a member name was not found. The aspect
+# of finding the hint is based on edit distance.
+missing-member-hint=yes
+
+# The minimum edit distance a name should have in order to be considered a
+# similar match for a missing member name.
+missing-member-hint-distance=1
+
+# The total number of similar names that should be taken in consideration when
+# showing a hint for a missing member.
+missing-member-max-choices=1
+
+# Regex pattern to define which classes are considered mixins ignore-mixin-
+# members is set to 'yes'
+mixin-class-rgx=.*[Mm]ixin
+
+# List of decorators that change the signature of a decorated function.
+signature-mutators=
+
+
+[VARIABLES]
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid defining new builtins when possible.
+additional-builtins=
+
+# Tells whether unused global variables should be treated as a violation.
+allow-global-unused-variables=yes
+
+# List of names allowed to shadow builtins
+allowed-redefined-builtins=
+
+# List of strings which can identify a callback function by name. A callback
+# name must start or end with one of those strings.
+callbacks=cb_,
+          _cb
+
+# A regular expression matching the name of dummy variables (i.e. expected to
+# not be used).
+dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
+
+# Argument names that match this expression will be ignored. Default to name
+# with leading underscore.
+ignored-argument-names=_.*|^ignored_|^unused_
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=no
+
+# List of qualified module names which can have objects that can redefine
+# builtins.
+redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
+
+
+[FORMAT]
+
+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
+expected-line-ending-format=
+
+# Regexp for a line that is allowed to be longer than the limit.
+ignore-long-lines=^\s*(# )?<?https?://\S+>?$
+
+# Number of spaces of indent required inside a hanging or continued line.
+indent-after-paren=4
+
+# String used as indentation unit. This is usually "    " (4 spaces) or "\t" (1
+# tab).
+indent-string='    '
+
+# Maximum number of characters on a single line.
+max-line-length=100
+
+# Maximum number of lines in a module.
+max-module-lines=1000
+
+# Allow the body of a class to be on the same line as the declaration if body
+# contains single statement.
+single-line-class-stmt=no
+
+# Allow the body of an if to be on the same line as the test if there is no
+# else.
+single-line-if-stmt=no
+
+
+[SIMILARITIES]
+
+# Comments are removed from the similarity computation
+ignore-comments=yes
+
+# Docstrings are removed from the similarity computation
+ignore-docstrings=yes
+
+# Imports are removed from the similarity computation
+ignore-imports=yes
+
+# Signatures are removed from the similarity computation
+ignore-signatures=no
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+
+[STRING]
+
+# This flag controls whether inconsistent-quotes generates a warning when the
+# character used as a quote delimiter is used inconsistently within a module.
+check-quote-consistency=no
+
+# This flag controls whether the implicit-str-concat should generate a warning
+# on implicit string concatenation in sequences defined over several lines.
+check-str-concat-over-line-jumps=no
+
+
+[BASIC]
+
+# Naming style matching correct argument names.
+argument-naming-style=snake_case
+
+# Regular expression matching correct argument names. Overrides argument-
+# naming-style. If left empty, argument names will be checked with the set
+# naming style.
+#argument-rgx=
+
+# Naming style matching correct attribute names.
+attr-naming-style=snake_case
+
+# Regular expression matching correct attribute names. Overrides attr-naming-
+# style. If left empty, attribute names will be checked with the set naming
+# style.
+#attr-rgx=
+
+# Bad variable names which should always be refused, separated by a comma.
+bad-names=foo,
+          bar,
+          baz,
+          toto,
+          tutu,
+          tata
+
+# Bad variable names regexes, separated by a comma. If names match any regex,
+# they will always be refused
+bad-names-rgxs=
+
+# Naming style matching correct class attribute names.
+class-attribute-naming-style=any
+
+# Regular expression matching correct class attribute names. Overrides class-
+# attribute-naming-style. If left empty, class attribute names will be checked
+# with the set naming style.
+#class-attribute-rgx=
+
+# Naming style matching correct class constant names.
+class-const-naming-style=UPPER_CASE
+
+# Regular expression matching correct class constant names. Overrides class-
+# const-naming-style. If left empty, class constant names will be checked with
+# the set naming style.
+#class-const-rgx=
+
+# Naming style matching correct class names.
+class-naming-style=PascalCase
+
+# Regular expression matching correct class names. Overrides class-naming-
+# style. If left empty, class names will be checked with the set naming style.
+#class-rgx=
+
+# Naming style matching correct constant names.
+const-naming-style=UPPER_CASE
+
+# Regular expression matching correct constant names. Overrides const-naming-
+# style. If left empty, constant names will be checked with the set naming
+# style.
+#const-rgx=
+
+# Minimum line length for functions/classes that require docstrings, shorter
+# ones are exempt.
+docstring-min-length=-1
+
+# Naming style matching correct function names.
+function-naming-style=snake_case
+
+# Regular expression matching correct function names. Overrides function-
+# naming-style. If left empty, function names will be checked with the set
+# naming style.
+#function-rgx=
+
+# Good variable names which should always be accepted, separated by a comma.
+good-names=i,
+           j,
+           k,
+           ex,
+           Run,
+           _
+
+# Good variable names regexes, separated by a comma. If names match any regex,
+# they will always be accepted
+good-names-rgxs=
+
+# Include a hint for the correct naming format with invalid-name.
+include-naming-hint=no
+
+# Naming style matching correct inline iteration names.
+inlinevar-naming-style=any
+
+# Regular expression matching correct inline iteration names. Overrides
+# inlinevar-naming-style. If left empty, inline iteration names will be checked
+# with the set naming style.
+#inlinevar-rgx=
+
+# Naming style matching correct method names.
+method-naming-style=snake_case
+
+# Regular expression matching correct method names. Overrides method-naming-
+# style. If left empty, method names will be checked with the set naming style.
+#method-rgx=
+
+# Naming style matching correct module names.
+module-naming-style=snake_case
+
+# Regular expression matching correct module names. Overrides module-naming-
+# style. If left empty, module names will be checked with the set naming style.
+#module-rgx=
+
+# Colon-delimited sets of names that determine each other's naming style when
+# the name regexes allow several styles.
+name-group=
+
+# Regular expression which should only match function or class names that do
+# not require a docstring.
+no-docstring-rgx=^_
+
+# List of decorators that produce properties, such as abc.abstractproperty. Add
+# to this list to register other decorators that produce valid properties.
+# These decorators are taken in consideration only for invalid-name.
+property-classes=abc.abstractproperty
+
+# Regular expression matching correct type variable names. If left empty, type
+# variable names will be checked with the set naming style.
+#typevar-rgx=
+
+# Naming style matching correct variable names.
+variable-naming-style=snake_case
+
+# Regular expression matching correct variable names. Overrides variable-
+# naming-style. If left empty, variable names will be checked with the set
+# naming style.
+#variable-rgx=
+
+
+[CLASSES]
+
+# Warn about protected attribute access inside special methods
+check-protected-access-in-special-methods=no
+
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,
+                      __new__,
+                      setUp,
+                      __post_init__
+
+# List of member names, which should be excluded from the protected access
+# warning.
+exclude-protected=_asdict,
+                  _fields,
+                  _replace,
+                  _source,
+                  _make
+
+# List of valid names for the first argument in a class method.
+valid-classmethod-first-arg=cls
+
+# List of valid names for the first argument in a metaclass class method.
+valid-metaclass-classmethod-first-arg=cls
+
+
+[IMPORTS]
+
+# List of modules that can be imported at any level, not just the top level
+# one.
+allow-any-import-level=
+
+# Allow wildcard imports from modules that define __all__.
+allow-wildcard-with-all=no
+
+# Analyse import fallback blocks. This can be used to support both Python 2 and
+# 3 compatible code, which means that the block might have code that exists
+# only in one or another interpreter, leading to false positives when analysed.
+analyse-fallback-blocks=no
+
+# Deprecated modules which should not be used, separated by a comma.
+deprecated-modules=
+
+# Output a graph (.gv or any supported image format) of external dependencies
+# to the given file (report RP0402 must not be disabled).
+ext-import-graph=
+
+# Output a graph (.gv or any supported image format) of all (i.e. internal and
+# external) dependencies to the given file (report RP0402 must not be
+# disabled).
+import-graph=
+
+# Output a graph (.gv or any supported image format) of internal dependencies
+# to the given file (report RP0402 must not be disabled).
+int-import-graph=
+
+# Force import order to recognize a module as part of the standard
+# compatibility libraries.
+known-standard-library=
+
+# Force import order to recognize a module as part of a third party library.
+known-third-party=enchant
+
+# Couples of modules and preferred modules, separated by a comma.
+preferred-modules=
+
+
+[DESIGN]
+
+# List of regular expressions of class ancestor names to ignore when counting
+# public methods (see R0903)
+exclude-too-few-public-methods=
+
+# List of qualified class names to ignore when counting class parents (see
+# R0901)
+ignored-parents=
+
+# Maximum number of arguments for function / method.
+max-args=5
+
+# Maximum number of attributes for a class (see R0902).
+max-attributes=7
+
+# Maximum number of boolean expressions in an if statement (see R0916).
+max-bool-expr=5
+
+# Maximum number of branch for function / method body.
+max-branches=12
+
+# Maximum number of locals for function / method body.
+max-locals=15
+
+# Maximum number of parents for a class (see R0901).
+max-parents=7
+
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=20
+
+# Maximum number of return / yield for function / method body.
+max-returns=6
+
+# Maximum number of statements in function / method body.
+max-statements=50
+
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=2
+
+
+[EXCEPTIONS]
+
+# Exceptions that will emit a warning when being caught. Defaults to
+# "BaseException, Exception".
+overgeneral-exceptions=BaseException,
+                       Exception
diff --git a/python_nedrex/python_nedrex/__init__.py b/python_nedrex/python_nedrex/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc7d76faf5b55c3d3df64629e4b3760416799cb9
--- /dev/null
+++ b/python_nedrex/python_nedrex/__init__.py
@@ -0,0 +1,46 @@
+"""Top-level package for python-nedrex."""
+
+__author__ = """David James Skelton"""
+__email__ = "james.skelton@newcastle.ac.uk"
+__version__ = "0.1.1"
+
+from typing import Optional
+
+from attrs import define
+
+
+@define
+class _Config:
+    _url_base: Optional[str] = None
+    _url_vpd: Optional[str] = None
+    _api_key: Optional[str] = None
+
+    @property
+    def url_base(self) -> Optional[str]:
+        """Returns the API URL base stored on the _Config instance"""
+        return self._url_base
+
+    @property
+    def url_vpd(self) -> Optional[str]:
+        """Returns the VPD URL base stored on the _Config instance"""
+        return self._url_vpd
+
+    @property
+    def api_key(self) -> Optional[str]:
+        """Returns the API key stored on the _Config instance"""
+        return self._api_key
+
+    def set_url_base(self, url_base: str) -> None:
+        """Sets the URL base for the API in the configuration"""
+        self._url_base = url_base.rstrip("/")
+
+    def set_url_vpd(self, url_vpd: str) -> None:
+        """Sets the URL base for the VPD in the configuration"""
+        self._url_vpd = url_vpd.rstrip("/")
+
+    def set_api_key(self, key: str) -> None:
+        """Sets the API key in the configuration"""
+        self._api_key = key
+
+
+config: _Config = _Config()
diff --git a/python_nedrex/python_nedrex/bicon.py b/python_nedrex/python_nedrex/bicon.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d2693f535c286594e59b225a849b6f91277ee53
--- /dev/null
+++ b/python_nedrex/python_nedrex/bicon.py
@@ -0,0 +1,41 @@
+from pathlib import Path as _Path
+from typing import IO as _IO
+from typing import Any as _Any
+from typing import Dict as _Dict
+from typing import Optional as _Optional
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import download_file as _download_file
+from python_nedrex.common import http as _http
+
+
+def bicon_request(
+    expression_file: _IO[str],
+    lg_min: int = 10,
+    lg_max: int = 15,
+    network: str = "DEFAULT",
+) -> str:
+    files = {"expression_file": expression_file}
+    data = {"lg_min": lg_min, "lg_max": lg_max, "network": network}
+
+    url = f"{_config.url_base}/bicon/submit"
+    resp = _http.post(url, data=data, files=files, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp)
+    return result
+
+
+def check_bicon_status(uid: str) -> _Dict[str, _Any]:
+    url = f"{_config.url_base}/bicon/status"
+    resp = _http.get(url, params={"uid": uid}, headers={"x-api-key": _config.api_key})
+    result: _Dict[str, _Any] = _check_response(resp)
+    return result
+
+
+def download_bicon_data(uid: str, target: _Optional[str] = None) -> None:
+    if target is None:
+        target = str(_Path(f"{uid}.zip").resolve())
+
+    url = f"{_config.url_base}/bicon/download?uid={uid}"
+
+    _download_file(url, target)
diff --git a/python_nedrex/python_nedrex/closeness.py b/python_nedrex/python_nedrex/closeness.py
new file mode 100644
index 0000000000000000000000000000000000000000..f75d81128138fa1895b7c55f0634496f366e3907
--- /dev/null
+++ b/python_nedrex/python_nedrex/closeness.py
@@ -0,0 +1,33 @@
+from typing import List as _List
+from typing import Optional as _Optional
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import check_status_factory as _check_status_factory
+from python_nedrex.common import http as _http
+
+
+def closeness_submit(
+    seeds: _List[str],
+    only_direct_drugs: bool = True,
+    only_approved_drugs: bool = True,
+    N: _Optional[int] = None,  # pylint: disable=C0103
+) -> str:
+    url = f"{_config.url_base}/closeness/submit"
+
+    body = {"seeds": seeds, "only_direct_drugs": only_direct_drugs, "only_approved_drugs": only_approved_drugs, "N": N}
+
+    resp = _http.post(url, json=body, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp)
+    return result
+
+
+check_closeness_status = _check_status_factory("/closeness/status")
+
+
+def download_closeness_results(uid: str) -> str:
+    url = f"{_config.url_base}/closeness/download"
+    params = {"uid": uid}
+    resp = _http.get(url, params=params, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp, return_type="text")
+    return result
diff --git a/python_nedrex/python_nedrex/common.py b/python_nedrex/python_nedrex/common.py
new file mode 100644
index 0000000000000000000000000000000000000000..5a0adfdb3016d32e48faeb1426f2f8547ef6917b
--- /dev/null
+++ b/python_nedrex/python_nedrex/common.py
@@ -0,0 +1,96 @@
+import urllib.request
+from typing import Any, Callable, Dict, Optional
+
+import cachetools
+import requests  # type: ignore
+from requests.adapters import HTTPAdapter  # type: ignore
+from urllib3.util.retry import Retry  # type: ignore
+
+from python_nedrex import config
+from python_nedrex.exceptions import ConfigError, NeDRexError
+
+# Start - code derived from https://findwork.dev/blog/advanced-usage-python-requests-timeouts-retries-hooks/
+DEFAULT_TIMEOUT = 120
+
+
+class TimeoutHTTPAdapter(HTTPAdapter):  # type: ignore
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        self.timeout = DEFAULT_TIMEOUT
+        if "timeout" in kwargs:
+            self.timeout = kwargs["timeout"]
+            del kwargs["timeout"]
+        super().__init__(*args, **kwargs)
+
+    # pylint: disable=arguments-differ
+    def send(self, request: requests.Request, **kwargs: Any) -> requests.Response:
+        timeout = kwargs.get("timeout")
+        if timeout is None:
+            kwargs["timeout"] = self.timeout
+        return super().send(request, **kwargs)
+
+
+retry_strategy = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
+
+http = requests.Session()
+adapter = TimeoutHTTPAdapter(max_retries=retry_strategy)
+http.mount("https://", adapter)
+http.mount("http://", adapter)
+# End - code derived from https://findwork.dev/blog/advanced-usage-python-requests-timeouts-retries-hooks/
+
+
+def check_response(resp: requests.Response, return_type: str = "json") -> Any:
+    if resp.status_code == 401:
+        data = resp.json()
+        if data["detail"] == "An API key is required to access the requested data":
+            raise ConfigError("no API key set in the configuration")
+
+    if resp.status_code == 422:
+        data = resp.json()
+        raise NeDRexError(data["detail"])
+
+    if resp.status_code == 404:
+        raise NeDRexError("not found")
+
+    if return_type == "json":
+        data = resp.json()
+    elif return_type == "text":
+        data = resp.text
+    else:
+        raise NeDRexError(f"invalid value for return_type ({return_type!r}) in check_response")
+    return data
+
+
+@cachetools.cached(cachetools.TTLCache(1, ttl=10))
+def get_pagination_limit() -> Any:
+    url = f"{config.url_base}/pagination_max"
+    return requests.get(url, headers={"x-api-key": config.api_key}).json()
+
+
+def check_pagination_limit(limit: Optional[int], upper_limit: int) -> None:
+    if limit and upper_limit < limit:
+        raise NeDRexError(f"limit={limit:,} is too great (maximum is {upper_limit:,})")
+
+
+def download_file(url: str, target: str) -> None:
+    if config.api_key is not None:
+        opener = urllib.request.build_opener()
+        opener.addheaders = [("x-api-key", config.api_key)]
+        urllib.request.install_opener(opener)
+
+    try:
+        urllib.request.urlretrieve(url, target)
+    except urllib.error.HTTPError as err:
+        if err.code == 404:
+            raise NeDRexError("not found") from err
+        raise NeDRexError("unexpected failure") from err
+
+
+def check_status_factory(url_suffix: str) -> Callable[[str], Dict[str, Any]]:
+    def return_func(uid: str) -> Dict[str, Any]:
+        url = f"{config.url_base}{url_suffix}"
+        params = {"uid": uid}
+        resp = http.get(url, params=params, headers={"x-api-key": config.api_key})
+        result: Dict[str, Any] = check_response(resp)
+        return result
+
+    return return_func
diff --git a/python_nedrex/python_nedrex/core.py b/python_nedrex/python_nedrex/core.py
new file mode 100644
index 0000000000000000000000000000000000000000..7e8f38c09c980ab6e6db94ce40f59860210ea4b3
--- /dev/null
+++ b/python_nedrex/python_nedrex/core.py
@@ -0,0 +1,229 @@
+"""Module containing functions relating to the general routes in the NeDRex API
+
+Additionally, this module also contains routes for obtaining API keys.
+"""
+
+from typing import Any as _Any
+from typing import Dict as _Dict
+from typing import Generator as _Generator
+from typing import List as _List
+from typing import Optional as _Optional
+from typing import cast as _cast
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_pagination_limit as _check_pagination_limit
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import get_pagination_limit as _get_pagination_limit
+from python_nedrex.common import http as _http
+from python_nedrex.decorators import check_url_base as _check_url_base
+from python_nedrex.exceptions import NeDRexError as _NeDRexError
+
+
+def _check_type(coll_name: str, coll_type: str) -> bool:
+    if coll_type == "edge":
+        if coll_name in get_edge_types():
+            return True
+        raise _NeDRexError(f"type={coll_name!r} not in NeDRex edge types")
+
+    if coll_type == "node":
+        if coll_name in get_node_types():
+            return True
+        raise _NeDRexError(f"type={coll_name!r} not in NeDRex node types")
+
+    raise _NeDRexError(f"_check_type received invalid coll_type={coll_type!r}")
+
+
+@_check_url_base
+def api_keys_active() -> bool:
+    """Checks whether API keys are active for the instance of NeDRex set in the config
+
+    Returns True if the keys are active, False otherwise
+    """
+    url = f"{_config.url_base}/api_key_setting"
+    response = _http.get(url)
+    if response.status_code != 200:
+        raise Exception("Unexpected non-200 status code")
+    return _cast(bool, response.json())
+
+
+@_check_url_base
+def get_api_key(*, accept_eula: bool = False) -> _Any:
+    """Obtains a new API key from the NeDRex API.
+
+    This function will only return if accept_eula is explicitly set to True
+    """
+    if accept_eula is not True:
+        raise _NeDRexError("an API key cannot be obtained unless accept_eula is set to True")
+
+    url = f"{_config.url_base}/admin/api_key/generate"
+    response = _http.post(url, json={"accept_eula": accept_eula})
+    return response.json()
+
+
+@_check_url_base
+def get_node_types() -> _Any:
+    """
+    Returns the list of node types stored in NeDRexDB
+
+        Returns:
+            node_list (list[str]): List of node types in NeDRex
+    """
+    url: str = f"{_config.url_base}/list_node_collections"
+    response = _http.get(url, headers={"x-api-key": _config.api_key})
+    node_list = _check_response(response)
+    return node_list
+
+
+@_check_url_base
+def get_edge_types() -> _Any:
+    """
+    Returns a list of edge types stored in NeDRexDB
+
+        Returns:
+            edge_list (list[str]): List of edge types in NeDRex
+    """
+    url: str = f"{_config.url_base}/list_edge_collections"
+    response = _http.get(url, headers={"x-api-key": _config.api_key})
+    edge_list = _check_response(response)
+    return edge_list
+
+
+@_check_url_base
+def get_collection_attributes(coll_type: str, include_counts: bool = False) -> _Any:
+    """
+    Retrurns a list of the available attributes stored in NeDRex for the given type
+
+        Parameters:
+            type (str): The node or edge type to get available attributes for
+
+        Returns:
+            attr_list (list[str]): The list of available attributes for the specified node/edge type
+    """
+    url: str = f"{_config.url_base}/{coll_type}/attributes"
+    response = _http.get(url, headers={"x-api-key": _config.api_key}, params={"include_counts": include_counts})
+    attr_list = _check_response(response)
+    return attr_list
+
+
+@_check_url_base
+def get_node_ids(coll_type: str) -> _Any:
+    """
+    Returns a list of node identifiers in NeDRex for the given type
+
+        Parameters:
+            type(str): The node type to get IDs for
+        Returns:
+            node_ids (list[str]): The list of available node_ids for the specified node type
+    """
+    _check_type(coll_type, "node")
+
+    url: str = f"{_config.url_base}/{coll_type}/attributes/primaryDomainId/json"
+
+    resp = _http.get(url, headers={"x-api-key": _config.api_key})
+    data = _check_response(resp)
+    node_ids = [i["primaryDomainId"] for i in data]
+    return node_ids
+
+
+@_check_url_base
+def get_nodes(
+    node_type: str,
+    attributes: _Optional[_List[str]] = None,
+    node_ids: _Optional[_List[str]] = None,
+    limit: _Optional[int] = None,
+    offset: int = 0,
+) -> _Any:
+    """
+    Returns nodes in NeDRex for the given type
+
+        Parameters:
+            node_type (str): The node type to collect
+            attributes (Optional[list[str]]): A list of attributes to return for the collected nodes. The default
+              (None) returns all attributes.
+            node_ids (Optional[list[str]]): A list of the specific node IDs to return. The default (None) returns all
+              nodes.
+            limit (Optional[int]): A limit for the number of records to be returned. The maximum value for this is set
+              by the API.
+            offset (int): The number of records to skip before returning records. Default is 0 (no records skipped).
+        Returns:
+            node_ids (list[str]): The list of available node_ids for the specified node type
+    """
+    _check_type(node_type, "node")
+
+    upper_limit = _get_pagination_limit()
+    _check_pagination_limit(limit, upper_limit)
+
+    params = {"node_id": node_ids, "attribute": attributes, "offset": offset, "limit": limit}
+
+    resp = _http.get(
+        f"{_config.url_base}/{node_type}/attributes/json", params=params, headers={"x-api-key": _config.api_key}
+    )
+
+    items = _check_response(resp)
+    return items
+
+
+@_check_url_base
+def iter_nodes(
+    node_type: str, attributes: _Optional[_List[str]] = None, node_ids: _Optional[_List[str]] = None
+) -> _Generator[_Dict[str, _Any], None, None]:
+
+    _check_type(node_type, "node")
+    upper_limit = _get_pagination_limit()
+
+    params: _Dict[str, _Any] = {"node_id": node_ids, "attribute": attributes, "limit": upper_limit}
+
+    offset = 0
+    while True:
+        params["offset"] = offset
+        resp = _http.get(
+            f"{_config.url_base}/{node_type}/attributes/json", params=params, headers={"x-api-key": _config.api_key}
+        )
+
+        data = _check_response(resp)
+
+        for doc in data:
+            yield doc
+
+        if len(data) < upper_limit:
+            break
+        offset += upper_limit
+
+
+@_check_url_base
+def get_edges(edge_type: str, limit: _Optional[int] = None, offset: _Optional[int] = None) -> _Any:
+    """
+    Returns edges in NeDRex of the given type
+
+        Parameters:
+            edge_type (str): The node type to collect
+            limit (Optional[int]): A limit for the number of records to be returned. The maximum value for this is set
+              by the API.
+            offset (int): The number of records to skip before returning records. Default is 0 (no records skipped).
+    """
+    _check_type(edge_type, "edge")
+
+    params = {"limit": limit, "offset": offset, "api_key": _config.api_key}
+
+    resp = _http.get(f"{_config.url_base}/{edge_type}/all", params=params, headers={"x-api-key": _config.api_key})
+    items = _check_response(resp)
+    return items
+
+
+@_check_url_base
+def iter_edges(edge_type: str) -> _Generator[_Dict[str, _Any], None, None]:
+    _check_type(edge_type, "edge")
+    upper_limit = _get_pagination_limit()
+
+    offset = 0
+    while True:
+        params = {"offset": offset, "limit": upper_limit}
+        resp = _http.get(f"{_config.url_base}/{edge_type}/all", params=params, headers={"x-api-key": _config.api_key})
+        data = _check_response(resp)
+
+        for doc in data:
+            yield doc
+
+        if len(data) < upper_limit:
+            break
+        offset += upper_limit
diff --git a/python_nedrex/python_nedrex/decorators.py b/python_nedrex/python_nedrex/decorators.py
new file mode 100644
index 0000000000000000000000000000000000000000..29ffc65bff5852754ea062f8ab146884cd9e2391
--- /dev/null
+++ b/python_nedrex/python_nedrex/decorators.py
@@ -0,0 +1,24 @@
+from typing import Any, Callable, TypeVar
+
+from python_nedrex import config
+from python_nedrex.exceptions import ConfigError
+
+R = TypeVar("R")
+
+
+def check_url_base(func: Callable[..., R]) -> Callable[..., R]:
+    def wrapped_fx(*args: Any, **kwargs: Any) -> Any:
+        if hasattr(config, "url_base") and config.url_base is not None:
+            return func(*args, **kwargs)
+        raise ConfigError("API URL is not set in the config")
+
+    return wrapped_fx
+
+
+def check_url_vpd(func: Callable[..., R]) -> Callable[..., R]:
+    def wrapped_fx(*args: Any, **kwargs: Any) -> Any:
+        if hasattr(config, "url_vpd") and config.url_vpd is not None:
+            return func(*args, **kwargs)
+        raise ConfigError("VPD URL is not set in the config")
+
+    return wrapped_fx
diff --git a/python_nedrex/python_nedrex/diamond.py b/python_nedrex/python_nedrex/diamond.py
new file mode 100644
index 0000000000000000000000000000000000000000..1b5fc78f9e9a6f4b74e95f966752e093ac6c5ba1
--- /dev/null
+++ b/python_nedrex/python_nedrex/diamond.py
@@ -0,0 +1,41 @@
+from typing import List as _List
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import check_status_factory as _check_status_factory
+from python_nedrex.common import http as _http
+
+
+def diamond_submit(
+    seeds: _List[str],
+    n: int,  # pylint: disable=C0103
+    alpha: int = 1,
+    network: str = "DEFAULT",
+    edges: str = "all",
+) -> str:
+    if edges not in {"limited", "all"}:
+        raise ValueError(f"invalid value for argument edges ({edges!r}), should be all|limited")
+
+    url = f"{_config.url_base}/diamond/submit"
+    body = {
+        "seeds": seeds,
+        "n": n,
+        "alpha": alpha,
+        "network": network,
+        "edges": edges,
+    }
+
+    resp = _http.post(url, json=body, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp)
+    return result
+
+
+check_diamond_status = _check_status_factory("/diamond/status")
+
+
+def download_diamond_results(uid: str) -> str:
+    url = f"{_config.url_base}/diamond/download"
+    params = {"uid": uid}
+    resp = _http.get(url, params=params, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp, return_type="text")
+    return result
diff --git a/python_nedrex/python_nedrex/disorder.py b/python_nedrex/python_nedrex/disorder.py
new file mode 100644
index 0000000000000000000000000000000000000000..21502be3b1dda5a92b2f787b617d615a30cac8cf
--- /dev/null
+++ b/python_nedrex/python_nedrex/disorder.py
@@ -0,0 +1,31 @@
+"""Module containing python functions to access the disorder routes in the NeDRex API"""
+
+from typing import Any as _Any
+from typing import Callable as _Callable
+from typing import List as _List
+from typing import Union as _Union
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import http as _http
+from python_nedrex.decorators import check_url_base as _check_url_base
+
+
+def _generate_route(path: str) -> _Callable[[_Union[str, _List[str]]], _Any]:
+    @_check_url_base
+    def new_func(codes: _Union[str, _List[str]]) -> _Any:
+        if isinstance(codes, str):
+            codes = [codes]
+
+        url = f"{_config.url_base}/disorder/{path}"
+        resp = _http.get(url, params={"q": codes}, headers={"x-api-key": _config.api_key})
+        return _check_response(resp)
+
+    return new_func
+
+
+search_by_icd10 = _generate_route("get_by_icd10")
+get_disorder_descendants = _generate_route("descendants")
+get_disorder_ancestors = _generate_route("ancestors")
+get_disorder_parents = _generate_route("parents")
+get_disorder_children = _generate_route("children")
diff --git a/python_nedrex/python_nedrex/domino.py b/python_nedrex/python_nedrex/domino.py
new file mode 100644
index 0000000000000000000000000000000000000000..b21a36fd1026ed5f43ba1c9d381fdb968f17de9c
--- /dev/null
+++ b/python_nedrex/python_nedrex/domino.py
@@ -0,0 +1,18 @@
+from typing import List as _List
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import check_status_factory as _check_status_factory
+from python_nedrex.common import http as _http
+
+
+def domino_submit(seeds: _List[str], network: str = "DEFAULT") -> str:
+    url = f"{_config.url_base}/domino/submit"
+    body = {"seeds": seeds, "network": network}
+
+    resp = _http.post(url, json=body, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp)
+    return result
+
+
+check_domino_status = _check_status_factory("/domino/status")
diff --git a/python_nedrex/python_nedrex/exceptions.py b/python_nedrex/python_nedrex/exceptions.py
new file mode 100644
index 0000000000000000000000000000000000000000..bf9c9c6666bc30cea01a5594f09f6a22fd97ccae
--- /dev/null
+++ b/python_nedrex/python_nedrex/exceptions.py
@@ -0,0 +1,6 @@
+class NeDRexError(Exception):
+    pass
+
+
+class ConfigError(NeDRexError):
+    pass
diff --git a/python_nedrex/python_nedrex/graph.py b/python_nedrex/python_nedrex/graph.py
new file mode 100644
index 0000000000000000000000000000000000000000..fa89adb2b3c0692b485b214c4bdb466da0f44d9e
--- /dev/null
+++ b/python_nedrex/python_nedrex/graph.py
@@ -0,0 +1,82 @@
+from pathlib import Path as _Path
+from typing import Any as _Any
+from typing import Dict as _Dict
+from typing import List as _List
+from typing import Optional as _Optional
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import download_file as _download_file
+from python_nedrex.common import http as _http
+
+
+# pylint: disable=R0913
+def build_request(
+    nodes: _Optional[_List[str]] = None,
+    edges: _Optional[_List[str]] = None,
+    ppi_evidence: _Optional[_List[str]] = None,
+    include_ppi_self_loops: bool = False,
+    taxid: _Optional[_List[int]] = None,
+    drug_groups: _Optional[_List[str]] = None,
+    concise: bool = True,
+    include_omim: bool = True,
+    disgenet_threshold: float = 0.0,
+    use_omim_ids: bool = False,
+    split_drug_types: bool = False,
+) -> str:
+
+    if nodes is None:
+        nodes = ["disorder", "drug", "gene", "protein"]
+    if edges is None:
+        edges = [
+            "disorder_is_subtype_of_disorder",
+            "drug_has_indication",
+            "drug_has_target",
+            "gene_associated_with_disorder",
+            "protein_encoded_by_gene",
+            "protein_interacts_with_protein",
+        ]
+    if ppi_evidence is None:
+        ppi_evidence = ["exp"]
+    if taxid is None:
+        taxid = [9606]
+    if drug_groups is None:
+        drug_groups = ["approved"]
+
+    body = {
+        "nodes": nodes,
+        "edges": edges,
+        "ppi_evidence": ppi_evidence,
+        "ppi_self_loops": include_ppi_self_loops,
+        "taxid": taxid,
+        "drug_groups": drug_groups,
+        "concise": concise,
+        "include_omim": include_omim,
+        "disgenet_threshold": disgenet_threshold,
+        "use_omim_ids": use_omim_ids,
+        "split_drug_types": split_drug_types,
+    }
+
+    url = f"{_config.url_base}/graph/builder"
+    resp = _http.post(url, json=body, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp)
+    return result
+
+
+# pylint: enable=R0913
+
+
+def check_build_status(uid: str) -> _Dict[str, _Any]:
+    url = f"{_config.url_base}/graph/details/{uid}"
+    resp = _http.get(url, headers={"x-api-key": _config.api_key})
+    result: _Dict[str, _Any] = _check_response(resp)
+    return result
+
+
+def download_graph(uid: str, target: _Optional[str] = None) -> None:
+    if target is None:
+        target = str(_Path(f"{uid}.graphml").resolve())
+
+    url = f"{_config.url_base}/graph/download/{uid}/{uid}.graphml"
+
+    _download_file(url, target)
diff --git a/python_nedrex/python_nedrex/kpm.py b/python_nedrex/python_nedrex/kpm.py
new file mode 100644
index 0000000000000000000000000000000000000000..118f53db4cb9c4ed8bef7ee25a70b13afc5edd9a
--- /dev/null
+++ b/python_nedrex/python_nedrex/kpm.py
@@ -0,0 +1,27 @@
+from typing import Any as _Any
+from typing import Dict as _Dict
+from typing import List as _List
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import http as _http
+
+
+def kpm_submit(seeds: _List[str], k: int, network: str = "DEFAULT") -> str:
+
+    url = f"{_config.url_base}/kpm/submit"
+    body = {"seeds": seeds, "k": k, "network": network}
+
+    resp = _http.post(url, json=body, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp)
+    return result
+
+
+def check_kpm_status(uid: str) -> _Dict[str, _Any]:
+
+    url = f"{_config.url_base}/kpm/status"
+    params = {"uid": uid}
+
+    resp = _http.get(url, params=params, headers={"x-api-key": _config.api_key})
+    result: _Dict[str, _Any] = _check_response(resp)
+    return result
diff --git a/python_nedrex/python_nedrex/must.py b/python_nedrex/python_nedrex/must.py
new file mode 100644
index 0000000000000000000000000000000000000000..8bfd1af485f74768e674ec0e76bf9fcbc27d4ec9
--- /dev/null
+++ b/python_nedrex/python_nedrex/must.py
@@ -0,0 +1,36 @@
+from typing import List as _List
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import check_status_factory as _check_status_factory
+from python_nedrex.common import http as _http
+
+
+# pylint: disable=R0913
+def must_request(
+    seeds: _List[str],
+    hubpenalty: float,
+    multiple: bool,
+    trees: int,
+    maxit: int,
+    network: str = "DEFAULT",
+) -> str:
+    body = {
+        "seeds": seeds,
+        "network": network,
+        "hubpenalty": hubpenalty,
+        "multiple": multiple,
+        "trees": trees,
+        "maxit": maxit,
+    }
+
+    url = f"{_config.url_base}/must/submit"
+    resp = _http.post(url, json=body, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp)
+    return result
+
+
+# pylint: enable=R0913
+
+
+check_must_status = _check_status_factory("/must/status")
diff --git a/python_nedrex/python_nedrex/ppi.py b/python_nedrex/python_nedrex/ppi.py
new file mode 100644
index 0000000000000000000000000000000000000000..1fe05527932cebebc7bc59b8ffb98284dd05c5ff
--- /dev/null
+++ b/python_nedrex/python_nedrex/ppi.py
@@ -0,0 +1,28 @@
+from typing import Any as _Any
+from typing import Dict as _Dict
+from typing import Iterable as _Iterable
+from typing import List as _List
+from typing import Optional as _Optional
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_pagination_limit as _check_pagination_limit
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import get_pagination_limit as _get_pagination_limit
+from python_nedrex.common import http as _http
+from python_nedrex.exceptions import NeDRexError
+
+
+def ppis(evidence: _Iterable[str], skip: int = 0, limit: _Optional[int] = None) -> _List[_Dict[str, _Any]]:
+    evidence_set = set(evidence)
+    extra_evidence = evidence_set - {"exp", "pred", "ortho"}
+    if extra_evidence:
+        raise NeDRexError(f"unexpected evidence types: {extra_evidence}")
+
+    maximum_limit = _get_pagination_limit()
+    _check_pagination_limit(limit, maximum_limit)
+
+    params = {"iid_evidence": list(evidence_set), "skip": skip, "limit": limit}
+
+    resp = _http.get(f"{_config.url_base}/ppi", params=params, headers={"x-api-key": _config.api_key})
+    result: _List[_Dict[str, _Any]] = _check_response(resp)
+    return result
diff --git a/python_nedrex/python_nedrex/relations.py b/python_nedrex/python_nedrex/relations.py
new file mode 100644
index 0000000000000000000000000000000000000000..d5b7e5cfc6102439576c2501cef83c5645cd997d
--- /dev/null
+++ b/python_nedrex/python_nedrex/relations.py
@@ -0,0 +1,87 @@
+from typing import Dict as _Dict
+from typing import Iterable as _Iterable
+from typing import List as _List
+from typing import Union as _Union
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import http as _http
+
+
+def get_encoded_proteins(gene_list: _Iterable[_Union[int, str]]) -> _Dict[str, _List[str]]:
+    """
+    Genes the proteins encoded by genes in a supplied gene list.
+
+    The genes can be submitted either as a list of strings or integers.
+    """
+    genes = []
+    for gene in gene_list:
+        if isinstance(gene, int):
+            gene = str(gene)
+        if not isinstance(gene, str):
+            raise ValueError("items in gene_list must be int or str")
+
+        gene = gene.lower()
+        if not gene.startswith("entrez."):
+            genes.append(f"entrez.{gene}")
+        else:
+            genes.append(gene)
+
+    url = f"{_config.url_base}/relations/get_encoded_proteins"
+    resp = _http.get(url, params={"gene": genes}, headers={"x-api-key": _config.api_key})
+    result: _Dict[str, _List[str]] = _check_response(resp)
+    return result
+
+
+def get_drugs_indicated_for_disorders(disorder_list: _Iterable[str]) -> _Dict[str, _List[str]]:
+    disorders = []
+    for disorder in disorder_list:
+        if not isinstance(disorder, str):
+            raise ValueError("items in disorder_list must be str")
+
+        if disorder.startswith("mondo."):
+            disorders.append(disorder)
+        else:
+            disorders.append(f"mondo.{disorder}")
+
+    url = f"{_config.url_base}/relations/get_drugs_indicated_for_disorders"
+    resp = _http.get(url, params={"disorder": disorders}, headers={"x-api-key": _config.api_key})
+    result: _Dict[str, _List[str]] = _check_response(resp)
+    return result
+
+
+def get_drugs_targetting_proteins(protein_list: _Iterable[str]) -> _Dict[str, _List[str]]:
+    proteins = []
+    for protein in protein_list:
+        if not isinstance(protein, str):
+            raise ValueError("items in protein_list must be str")
+
+        if protein.startswith("uniprot."):
+            proteins.append(protein)
+        else:
+            proteins.append(f"uniprot.{protein}")
+
+    url = f"{_config.url_base}/relations/get_drugs_targetting_proteins"
+    resp = _http.get(url, params={"protein": proteins}, headers={"x-api-key": _config.api_key})
+    result: _Dict[str, _List[str]] = _check_response(resp)
+    return result
+
+
+def get_drugs_targetting_gene_products(gene_list: _Iterable[str]) -> _Dict[str, _List[str]]:
+    genes = []
+    for gene in gene_list:
+        if isinstance(gene, int):
+            gene = str(gene)
+        if not isinstance(gene, str):
+            raise ValueError("items in gene_list must be int or str")
+
+        gene = gene.lower()
+        if not gene.startswith("entrez."):
+            genes.append(f"entrez.{gene}")
+        else:
+            genes.append(gene)
+
+    url = f"{_config.url_base}/relations/get_drugs_targetting_gene_products"
+    resp = _http.get(url, params={"gene": genes}, headers={"x-api-key": _config.api_key})
+    result: _Dict[str, _List[str]] = _check_response(resp)
+    return result
diff --git a/python_nedrex/python_nedrex/robust.py b/python_nedrex/python_nedrex/robust.py
new file mode 100644
index 0000000000000000000000000000000000000000..57c5455c22ec9abc1711c12b0f4fbc86f09bb44b
--- /dev/null
+++ b/python_nedrex/python_nedrex/robust.py
@@ -0,0 +1,45 @@
+from typing import List as _List
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import check_status_factory as _check_status_factory
+from python_nedrex.common import http as _http
+
+
+# pylint: disable=R0913
+def robust_submit(
+    seeds: _List[str],
+    network: str = "DEFAULT",
+    initial_fraction: float = 0.25,
+    reduction_factor: float = 0.9,
+    num_trees: int = 30,
+    threshold: float = 0.1,
+) -> str:
+
+    body = {
+        "seeds": seeds,
+        "network": network,
+        "initial_fraction": initial_fraction,
+        "reduction_factor": reduction_factor,
+        "num_trees": num_trees,
+        "threshold": threshold,
+    }
+    url = f"{_config.url_base}/robust/submit"
+
+    resp = _http.post(url, json=body, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp)
+    return result
+
+
+# pylint: enable=R0913
+
+check_robust_status = _check_status_factory("/robust/status")
+
+
+def download_robust_results(uid: str) -> str:
+    url = f"{_config.url_base}/robust/results"
+    params = {"uid": uid}
+
+    resp = _http.get(url, params=params, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp, return_type="text")
+    return result
diff --git a/python_nedrex/python_nedrex/static.py b/python_nedrex/python_nedrex/static.py
new file mode 100644
index 0000000000000000000000000000000000000000..3f492f8da507d320b7e2edc4640d1bf4188401f6
--- /dev/null
+++ b/python_nedrex/python_nedrex/static.py
@@ -0,0 +1,31 @@
+from typing import Any as _Any
+from typing import Dict as _Dict
+from typing import Optional as _Optional
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import download_file as _download_file
+from python_nedrex.common import http as _http
+
+
+def get_metadata() -> _Dict[str, _Any]:
+    url = f"{_config.url_base}/static/metadata"
+    resp = _http.get(url, headers={"x-api-key": _config.api_key})
+    result: _Dict[str, _Any] = _check_response(resp)
+    return result
+
+
+def get_license() -> str:
+    url = f"{_config.url_base}/static/license"
+    resp = _http.get(url)
+    result: str = _check_response(resp)
+    return result
+
+
+def download_lengths_map(target: _Optional[str] = None) -> None:
+    if target is None:
+        target = "lengths.map"
+
+    url = f"{_config.url_base}/static/lengths.map"
+
+    _download_file(url, target)
diff --git a/python_nedrex/python_nedrex/trustrank.py b/python_nedrex/python_nedrex/trustrank.py
new file mode 100644
index 0000000000000000000000000000000000000000..36812c405ad98fd0c2c59e08aeafa84bc460cfc0
--- /dev/null
+++ b/python_nedrex/python_nedrex/trustrank.py
@@ -0,0 +1,41 @@
+from typing import List as _List
+from typing import Optional as _Optional
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import check_status_factory as _check_status_factory
+from python_nedrex.common import http as _http
+
+
+def trustrank_submit(
+    seeds: _List[str],
+    damping_factor: float = 0.85,
+    only_direct_drugs: bool = True,
+    only_approved_drugs: bool = True,
+    n: _Optional[int] = None,  # pylint: disable=C0103
+) -> str:
+    url = f"{_config.url_base}/trustrank/submit"
+
+    body = {
+        "seeds": seeds,
+        "damping_factor": damping_factor,
+        "only_direct_drugs": only_direct_drugs,
+        "only_approved_drugs": only_approved_drugs,
+        "N": n,
+    }
+
+    resp = _http.post(url, json=body, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp)
+    return result
+
+
+check_trustrank_status = _check_status_factory("/trustrank/status")
+
+
+def download_trustrank_results(uid: str) -> str:
+    url = f"{_config.url_base}/trustrank/download"
+    params = {"uid": uid}
+
+    resp = _http.get(url, params=params, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp, return_type="text")
+    return result
diff --git a/python_nedrex/python_nedrex/validation.py b/python_nedrex/python_nedrex/validation.py
new file mode 100644
index 0000000000000000000000000000000000000000..599793b35bb8373001949c413e63aaf99c8d62a4
--- /dev/null
+++ b/python_nedrex/python_nedrex/validation.py
@@ -0,0 +1,80 @@
+from typing import List as _List
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import check_status_factory as _check_status_factory
+from python_nedrex.common import http as _http
+
+check_validation_status = _check_status_factory("/validation/status")
+
+
+def check_module_member_type(mmt: str) -> None:
+    if mmt not in {"gene", "protein"}:
+        raise ValueError(f"module_member_type {mmt!r} is invalid (should be 'gene' or 'protein'")
+
+
+# pylint: disable=R0913
+def joint_validation_submit(
+    module_members: _List[str],
+    module_member_type: str,
+    test_drugs: _List[str],
+    true_drugs: _List[str],
+    permutations: int,
+    only_approved_drugs: bool = True,
+) -> str:
+    check_module_member_type(module_member_type)
+
+    url = f"{_config.url_base}/validation/joint"
+    body = {
+        "module_members": module_members,
+        "module_member_type": module_member_type,
+        "test_drugs": test_drugs,
+        "true_drugs": true_drugs,
+        "permutations": permutations,
+        "only_approved_drugs": only_approved_drugs,
+    }
+    resp = _http.post(url, json=body, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp)
+    return result
+
+
+# pylint: enable=R0913
+
+
+def module_validation_submit(
+    module_members: _List[str],
+    module_member_type: str,
+    true_drugs: _List[str],
+    permutations: int,
+    only_approved_drugs: bool = True,
+) -> str:
+    check_module_member_type(module_member_type)
+
+    url = f"{_config.url_base}/validation/module"
+    body = {
+        "module_members": module_members,
+        "module_member_type": module_member_type,
+        "true_drugs": true_drugs,
+        "permutations": permutations,
+        "only_approved_drugs": only_approved_drugs,
+    }
+    resp = _http.post(url, json=body, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp)
+    return result
+
+
+def drug_validation_submit(
+    test_drugs: _List[str], true_drugs: _List[str], permutations: int, only_approved_drugs: bool = True
+) -> str:
+
+    url = f"{_config.url_base}/validation/drug"
+    body = {
+        "test_drugs": test_drugs,
+        "true_drugs": true_drugs,
+        "permutations": permutations,
+        "only_approved_drugs": only_approved_drugs,
+    }
+
+    resp = _http.post(url, json=body, headers={"x-api-key": _config.api_key})
+    result: str = _check_response(resp)
+    return result
diff --git a/python_nedrex/python_nedrex/variants.py b/python_nedrex/python_nedrex/variants.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6e47a68cb3a3b64b2b56386d05115d7053a4d2d
--- /dev/null
+++ b/python_nedrex/python_nedrex/variants.py
@@ -0,0 +1,160 @@
+from typing import Any as _Any
+from typing import Dict as _Dict
+from typing import Generator as _Generator
+from typing import List as _List
+from typing import Optional as _Optional
+
+from python_nedrex import config as _config
+from python_nedrex.common import check_pagination_limit as _check_pagination_limit
+from python_nedrex.common import check_response as _check_response
+from python_nedrex.common import get_pagination_limit as _get_pagination_limit
+from python_nedrex.common import http as _http
+
+
+def get_effect_choices() -> _List[str]:
+    url = f"{_config.url_base}/variants/get_effect_choices"
+    resp = _http.get(url, headers={"x-api-key": _config.api_key})
+    result: _List[str] = _check_response(resp)
+    return result
+
+
+def get_review_status_choices() -> _List[str]:
+    url = f"{_config.url_base}/variants/get_review_choices"
+    resp = _http.get(url, headers={"x-api-key": _config.api_key})
+    result: _List[str] = _check_response(resp)
+    return result
+
+
+# pylint: disable=R0913
+def get_variant_disorder_associations(
+    variant_ids: _Optional[_List[str]] = None,
+    disorder_ids: _Optional[_List[str]] = None,
+    review_status: _Optional[_List[str]] = None,
+    effect: _Optional[_List[str]] = None,
+    limit: _Optional[int] = None,
+    offset: int = 0,
+) -> _List[_Dict[str, _Any]]:
+    max_limit = _get_pagination_limit()
+    if isinstance(limit, int):
+        _check_pagination_limit(limit, max_limit)
+    else:
+        limit = max_limit
+
+    params = {
+        "variant_id": variant_ids,
+        "disorder_id": disorder_ids,
+        "review_status": review_status,
+        "effect": effect,
+        "limit": limit,
+        "offset": offset,
+    }
+
+    url = f"{_config.url_base}/variants/get_variant_disorder_associations"
+    resp = _http.get(url, params=params, headers={"x-api-key": _config.api_key})
+    result: _List[_Dict[str, _Any]] = _check_response(resp)
+    return result
+
+
+# pylint: enable=R0913
+
+
+def iter_variant_disorder_associations(
+    variant_ids: _Optional[_List[str]] = None,
+    disorder_ids: _Optional[_List[str]] = None,
+    review_status: _Optional[_List[str]] = None,
+    effect: _Optional[_List[str]] = None,
+) -> _Generator[_Dict[str, _Any], None, None]:
+    max_limit = _get_pagination_limit()
+    offset = 0
+
+    kwargs = {
+        "variant_ids": variant_ids,
+        "disorder_ids": disorder_ids,
+        "review_status": review_status,
+        "effect": effect,
+        "limit": max_limit,
+        "offset": offset,
+    }
+
+    while True:
+        results = get_variant_disorder_associations(**kwargs)
+        if len(results) == 0:
+            return
+        yield from results
+
+        offset += max_limit
+        kwargs["offset"] = offset
+
+
+def get_variant_gene_associations(
+    variant_ids: _Optional[_List[str]] = None,
+    gene_ids: _Optional[_List[str]] = None,
+    limit: _Optional[int] = None,
+    offset: int = 0,
+) -> _List[_Dict[str, _Any]]:
+
+    max_limit = _get_pagination_limit()
+    if isinstance(limit, int):
+        _check_pagination_limit(limit, max_limit)
+    else:
+        limit = max_limit
+
+    params = {
+        "variant_id": variant_ids,
+        "gene_id": gene_ids,
+        "offset": offset,
+        "limit": limit,
+    }
+
+    url = f"{_config.url_base}/variants/get_variant_gene_associations"
+    resp = _http.get(url, params=params, headers={"x-api-key": _config.api_key})
+    result: _List[_Dict[str, _Any]] = _check_response(resp)
+    return result
+
+
+def iter_variant_gene_associations(
+    variant_ids: _Optional[_List[str]] = None,
+    gene_ids: _Optional[_List[str]] = None,
+) -> _Generator[_Dict[str, _Any], None, None]:
+    max_limit = _get_pagination_limit()
+    offset = 0
+
+    kwargs = {
+        "variant_ids": variant_ids,
+        "gene_ids": gene_ids,
+        "limit": max_limit,
+        "offset": offset,
+    }
+
+    while True:
+        results = get_variant_gene_associations(**kwargs)
+        if len(results) == 0:
+            return
+        yield from results
+
+        offset += max_limit
+        kwargs["offset"] = offset
+
+
+def get_variant_based_disorder_associated_genes(
+    disorder_id: str, review_status: _Optional[_List[str]] = None, effect: _Optional[_List[str]] = None
+) -> _List[_Dict[str, _Any]]:
+    params = {"disorder_id": disorder_id, "review_status": review_status, "effect": effect}
+
+    url = f"{_config.url_base}/variants/variant_based_disorder_associated_genes"
+
+    resp = _http.get(url, params=params, headers={"x-api-key": _config.api_key})
+    result: _List[_Dict[str, _Any]] = _check_response(resp)
+    return result
+
+
+def get_variant_based_gene_associated_disorders(
+    gene_id: str, review_status: _Optional[_List[str]] = None, effect: _Optional[_List[str]] = None
+) -> _List[_Dict[str, _Any]]:
+    params = {"gene_id": gene_id, "review_status": review_status, "effect": effect}
+
+    url = f"{_config.url_base}/variants/variant_based_gene_associated_disorders"
+
+    resp = _http.get(url, params=params, headers={"x-api-key": _config.api_key})
+    result: _List[_Dict[str, _Any]] = _check_response(resp)
+    return result
diff --git a/python_nedrex/python_nedrex/vpd.py b/python_nedrex/python_nedrex/vpd.py
new file mode 100644
index 0000000000000000000000000000000000000000..11689d8a2c50a30c99b70b4be1a80ba5d6b466c0
--- /dev/null
+++ b/python_nedrex/python_nedrex/vpd.py
@@ -0,0 +1,32 @@
+import os as _os
+from typing import Optional as _Optional
+
+from python_nedrex import config as _config
+from python_nedrex.common import http as _http
+from python_nedrex.decorators import check_url_vpd as _check_url_vpd
+
+
+@_check_url_vpd
+def get_vpd(disorder: str, number_of_patients: int, out_dir: str) -> _Optional[str]:
+    """
+    Downloads a .zip archive with the requested virtual patient data to the given directory.
+
+        Parameters:
+            disorder (str): The disorder mondoID (e.g mondo.0000090) for which the virtual patient should be retrieved.
+            number_of_patients (int): The number of simulated patients in the dataset. Can be 1, 10 or 100.
+            out_dir (str): The absolute path of a directory where the virtual patient data should be stored.
+
+        Returns:
+            archive (str): Absolute path of the downloaded zip archive or None if the requested resource does not exist.
+    """
+    archive_name: str = f"{disorder}_1000GP_{number_of_patients}VPSim.zip"
+    url: str = f"{_config.url_vpd}/vpd/{disorder}/{archive_name}"
+    archive: str = _os.path.join(out_dir, archive_name)
+
+    data = _http.get(url)
+    if data.status_code != 200:
+        return None
+
+    with open(archive, "wb") as arch:
+        arch.write(data.content)
+    return archive
diff --git a/python_nedrex/requirements.txt b/python_nedrex/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..2c150536dbde50088d6588a52ab993c0cc1ea8b9
--- /dev/null
+++ b/python_nedrex/requirements.txt
@@ -0,0 +1,5 @@
+attrs==21.4.0
+cachetools==4.2.4
+more-itertools==8.13.0
+pytest==7.0.1  
+requests==2.27.1
diff --git a/python_nedrex/requirements_dev.txt b/python_nedrex/requirements_dev.txt
new file mode 100644
index 0000000000000000000000000000000000000000..ebb3f1d1574b67f08c0fa8073ce6234d9ac46989
--- /dev/null
+++ b/python_nedrex/requirements_dev.txt
@@ -0,0 +1,12 @@
+pip==19.2.3
+bump2version==0.5.11
+wheel==0.33.6
+watchdog==0.9.0
+flake8==3.7.8
+tox==3.14.0
+coverage==4.5.4
+Sphinx==1.8.5
+twine==1.14.0
+
+pytest==6.2.4
+black==21.7b0
diff --git a/python_nedrex/setup.cfg b/python_nedrex/setup.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..249095598218cc569f99d18343afbeb95b47bfea
--- /dev/null
+++ b/python_nedrex/setup.cfg
@@ -0,0 +1,20 @@
+[bumpversion]
+current_version = 0.1.1
+commit = True
+tag = True
+
+[bumpversion:file:setup.py]
+search = version='{current_version}'
+replace = version='{new_version}'
+
+[bumpversion:file:python_nedrex/__init__.py]
+search = __version__ = '{current_version}'
+replace = __version__ = '{new_version}'
+
+[bdist_wheel]
+universal = 1
+
+[flake8]
+exclude = docs
+[tool:pytest]
+
diff --git a/python_nedrex/setup.py b/python_nedrex/setup.py
new file mode 100644
index 0000000000000000000000000000000000000000..81d119071d4e633408e4ab6e72bc9937428074e8
--- /dev/null
+++ b/python_nedrex/setup.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+
+"""The setup script."""
+
+from setuptools import setup, find_packages
+
+with open("README.rst") as readme_file:
+    readme = readme_file.read()
+
+with open("HISTORY.rst") as history_file:
+    history = history_file.read()
+
+requirements = [
+    "attrs",
+    "requests",
+    "cachetools",
+]
+
+test_requirements = [
+    "pytest>=3",
+]
+
+setup(
+    author="David James Skelton",
+    author_email="james.skelton@newcastle.ac.uk",
+    python_requires=">=3.6",
+    classifiers=[
+        "Development Status :: 2 - Pre-Alpha",
+        "Intended Audience :: Developers",
+        "License :: OSI Approved :: MIT License",
+        "Natural Language :: English",
+        "Programming Language :: Python :: 3",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+    ],
+    description="A Python library for interfacing with the PNeDRex API",
+    install_requires=requirements,
+    license="MIT license",
+    long_description=readme + "\n\n" + history,
+    include_package_data=True,
+    keywords="python_nedrex",
+    name="python_nedrex",
+    packages=find_packages(include=["python_nedrex", "python_nedrex.*"]),
+    test_suite="tests",
+    tests_require=test_requirements,
+    url="https://github.com/james-skelton/python_nedrex",
+    version="0.1.1",
+    zip_safe=False,
+)
diff --git a/python_nedrex/tests/__init__.py b/python_nedrex/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..6aa35229e03913395096d31118ae720a840f6899
--- /dev/null
+++ b/python_nedrex/tests/__init__.py
@@ -0,0 +1 @@
+"""Unit test package for python_nedrex."""
diff --git a/python_nedrex/tests/test_python_nedrex.py b/python_nedrex/tests/test_python_nedrex.py
new file mode 100644
index 0000000000000000000000000000000000000000..262a41daf7425784468def3d5040f550189909ea
--- /dev/null
+++ b/python_nedrex/tests/test_python_nedrex.py
@@ -0,0 +1,664 @@
+#!/usr/bin/env python
+
+"""Tests for `python_nedrex` package."""
+
+import os
+import re
+from pathlib import Path
+import random
+import tempfile
+import time
+from contextlib import contextmanager
+from functools import lru_cache
+
+from more_itertools import take
+
+import pytest
+import requests
+
+import python_nedrex
+from python_nedrex.common import get_pagination_limit
+from python_nedrex.core import (
+    get_edges,
+    iter_edges,
+    iter_nodes,
+    get_node_types,
+    get_edge_types,
+    get_collection_attributes,
+    get_node_ids,
+    get_nodes,
+    api_keys_active,
+)
+from python_nedrex.diamond import diamond_submit, check_diamond_status, download_diamond_results
+from python_nedrex.disorder import (
+    get_disorder_ancestors,
+    get_disorder_children,
+    get_disorder_descendants,
+    get_disorder_parents,
+    search_by_icd10,
+)
+from python_nedrex.domino import (
+    domino_submit,
+    check_domino_status
+)
+from python_nedrex.exceptions import ConfigError, NeDRexError
+from python_nedrex.graph import (
+    build_request,
+    check_build_status,
+    download_graph,
+)
+from python_nedrex.must import must_request, check_must_status
+from python_nedrex.ppi import ppis
+from python_nedrex.relations import (
+    get_encoded_proteins,
+    get_drugs_indicated_for_disorders,
+    get_drugs_targetting_proteins,
+    get_drugs_targetting_gene_products,
+)
+
+
+API_URL = "http://82.148.225.92:8123/"
+API_KEY = requests.post(f"{API_URL}admin/api_key/generate", json={"accept_eula": True}).json()
+
+
+SEEDS = [
+    "P43121",
+    "P01589",
+    "P30203",
+    "P21554",
+    "P01579",
+    "O43557",
+    "Q99572",
+    "P01920",
+    "P25942",
+    "P01189",
+    "P21580",
+    "Q02556",
+    "P01584",
+    "P01574",
+    "P02649",
+    "P29466",
+    "P22301",
+    "P16581",
+    "P06276",
+    "P11473",
+    "O60333",
+    "P19256",
+    "Q96P20",
+    "P01911",
+    "Q2KHT3",
+    "P18510",
+    "P05362",
+    "P01903",
+    "P29597",
+    "P13232",
+    "Q13191",
+    "Q06330",
+    "P04440",
+    "P78508",
+    "P19320",
+    "P19438",
+    "P02774",
+    "O75508",
+    "P29459",
+    "P16871",
+    "Q14765",
+    "Q16552",
+]
+
+UID_REGEX = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
+
+
+@contextmanager
+def url_base():
+    python_nedrex.config.set_url_base(API_URL)
+    yield
+    python_nedrex.config._url_base = None
+
+
+@contextmanager
+def api_key():
+    python_nedrex.config.set_api_key(API_KEY)
+    yield
+    python_nedrex.config._api_key = None
+
+
+@lru_cache(maxsize=10)
+def get_node_collections():
+    with api_key(), url_base():
+        collections = get_node_types()
+    return collections
+
+
+@lru_cache(maxsize=10)
+def get_edge_collections():
+    with api_key(), url_base():
+        collections = get_edge_types()
+    return collections
+
+
+def get_random_disorder_selection(n, skip_root=True):
+    random.seed(20220621)
+    with api_key(), url_base():
+        disorder_ids = set(get_node_ids("disorder"))
+    disorder_ids.remove("mondo.0000001")
+    return random.sample(sorted(disorder_ids), n)
+
+
+@pytest.fixture
+def config():
+    return {"api_url": API_URL, "api_key": API_KEY}
+
+
+@pytest.fixture
+def set_api_key(config):
+    with api_key():
+        yield
+
+
+@pytest.fixture
+def set_base_url(config):
+    with url_base():
+        yield
+
+
+def test_set_api_base(set_base_url):
+    assert python_nedrex.config._url_base == API_URL.rstrip("/")
+
+
+class TestGetNodeTypes:
+    @pytest.fixture
+    def result(self, set_base_url, set_api_key):
+        result = get_node_types()
+        return result
+
+    def test_return_type(self, result):
+        assert isinstance(result, list)
+        assert all(isinstance(item, str) for item in result)
+
+    def test_ordering(self, result):
+        assert result == sorted(result)
+
+    def test_content(self, result):
+        assert "protein" in result
+
+
+class TestGetEdgeTypes:
+    @pytest.fixture
+    def result(self, set_base_url, set_api_key):
+        result = get_edge_types()
+        return result
+
+    def test_return_type(self, result):
+        assert isinstance(result, list)
+        assert all(isinstance(item, str) for item in result)
+
+    def test_ordering(self, result):
+        assert result == sorted(result)
+
+    def test_content(self, result):
+        assert "protein_encoded_by_gene" in result
+
+
+class TestGetCollectionAttributes:
+    @pytest.mark.parametrize("collection", get_node_collections())
+    def test_get_node_collection_attributes(self, set_base_url, set_api_key, collection):
+        expected_attributes = ("primaryDomainId", "domainIds", "type")
+        coll_attributes = get_collection_attributes(collection)
+        assert all(attr in coll_attributes for attr in expected_attributes)
+
+    @pytest.mark.parametrize("collection", get_edge_collections())
+    def test_get_edge_collection_attributes(self, set_base_url, set_api_key, collection):
+        # NOTE: Exclude the protein_interacts_with_protein collection because of its size.
+        coll_attributes = get_collection_attributes(collection)
+        assert "type" in coll_attributes
+
+        assert all(attr in coll_attributes for attr in ("memberOne", "memberTwo")) or all(
+            attr in coll_attributes for attr in ("sourceDomainId", "targetDomainId")
+        )
+
+
+class TestGetNodeIds:
+    @pytest.mark.parametrize("collection", get_node_collections())
+    def test_get_node_ids(self, set_base_url, set_api_key, collection):
+        assert get_node_ids(collection)
+
+    @pytest.mark.parametrize("collection", get_edge_collections())
+    def test_get_node_ids_fails_for_edges(self, set_base_url, set_api_key, collection):
+        with pytest.raises(NeDRexError):
+            get_node_ids(collection)
+
+
+class TestGetEdgeRoutes:
+    @pytest.mark.parametrize("collection", get_edge_collections())
+    def test_return_type_get_edges(self, set_base_url, set_api_key, collection):
+        edges = get_edges(collection, limit=1_000)
+        assert isinstance(edges, list)
+
+    @pytest.mark.parametrize("collection", get_edge_collections())
+    def test_edge_attributes(self, set_base_url, set_api_key, collection):
+        result = get_collection_attributes(collection, include_counts=True)
+        total = result["document_count"]
+        attr_counts = result["attribute_counts"]
+
+        assert attr_counts["type"] == total
+        assert (
+            attr_counts.get("memberOne", 0) == attr_counts.get("memberTwo", 0) == total
+            and attr_counts.get("sourceDomainId", 0) == attr_counts.get("targetDomainId", 0) == 0
+        ) ^ (
+            attr_counts.get("memberOne", 0) == attr_counts.get("memberTwo", 0) == 0
+            and attr_counts.get("sourceDomainId", 0) == attr_counts.get("targetDomainId", 0) == total
+        )
+
+
+class TestGetNodeRoutes:
+    @pytest.mark.parametrize("collection", get_node_collections())
+    def test_get_all_nodes(self, set_base_url, set_api_key, collection):
+        assert isinstance(get_nodes(collection), list)
+
+    def test_get_specific_nodes(self, set_base_url, set_api_key):
+        nodes = get_nodes("disorder", node_ids=["mondo.0000001"])
+        assert isinstance(nodes, list)
+        assert len(nodes) == 1
+        assert nodes[0]["primaryDomainId"] == "mondo.0000001"
+
+    def test_get_drugs_with_api_key(self, set_base_url, set_api_key):
+        nodes = get_nodes("drug")
+        assert isinstance(nodes, list)
+
+    def test_get_specific_attributes(self, set_base_url, set_api_key):
+        nodes = get_nodes("disorder", attributes=["displayName"])
+        assert isinstance(nodes, list)
+        assert [set(i.keys()) == {"primaryDomainId", "displayName"} for i in nodes]
+
+    def test_get_specific_attribute_and_nodes(self, set_base_url, set_api_key):
+        nodes = get_nodes("disorder", attributes=["displayName"], node_ids=["mondo.0000001"])
+        assert isinstance(nodes, list)
+        assert len(nodes) == 1
+        assert nodes[0] == {
+            "displayName": "disease or disorder",
+            "primaryDomainId": "mondo.0000001",
+        }
+
+    def test_pagination(self, set_base_url, set_api_key):
+        nodes = get_nodes("genomic_variant", limit=1000, offset=1000)
+        assert isinstance(nodes, list)
+        assert len(nodes) == 1000
+
+    def test_consistent_pagination(self, set_base_url, set_api_key):
+        offset = 1234
+        limit = 69
+
+        nodes = get_nodes("genomic_variant", limit=limit, offset=offset)
+
+        for _ in range(10):
+            nodes_repeat = get_nodes("genomic_variant", limit=limit, offset=offset)
+            assert nodes_repeat == nodes
+
+
+class TestDisorderRoutes:
+    def test_search_by_icd10(self, set_base_url, set_api_key):
+        # NOTE: There is currently an ICD-10 mapping issue due to MONDO
+        search_by_icd10("I52")
+
+    def test_get_disorder_ancestors(self, set_base_url, set_api_key):
+        # Check that `disease or disorder`is an ancestor of `lupus nephritis`
+        # `disease or disorder` is not a parent of `lupus neprhitis`
+        lupus_nephritis = "mondo.0005556"
+        disease_or_disorder = "mondo.0000001"
+
+        result = get_disorder_ancestors(lupus_nephritis)
+        assert disease_or_disorder in result[lupus_nephritis]
+
+    def test_get_disorder_descendants(self, set_base_url, set_api_key):
+        # Check that `lupus nephritis` is a descendant of `inflammatory disease`
+        # `lupus nephritis` is not a child of `inflammatory disease`
+        inflam_disease = "mondo.0021166"
+        lupus_nephritis = "mondo.0005556"
+
+        result = get_disorder_descendants(inflam_disease)
+        assert lupus_nephritis in result[inflam_disease]
+
+    def test_get_disorder_parents(self, set_base_url, set_api_key):
+        # Check that `glomerulonephritis` is a parent of `lupus nephritis`
+        lupus_nephritis = "mondo.0005556"
+        glomerulonephritis = "mondo.0002462"
+
+        result = get_disorder_parents("mondo.0005556")
+        assert glomerulonephritis in result[lupus_nephritis]
+
+    def test_get_disorder_children(self, set_base_url, set_api_key):
+        # Check that `lupus nephritis` is a child of `glomerulonephritis`
+        glomerulonephritis = "mondo.0002462"
+        lupus_nephritis = "mondo.0005556"
+
+        result = get_disorder_children(glomerulonephritis)
+        assert lupus_nephritis in result[glomerulonephritis]
+
+    @pytest.mark.parametrize("chosen_id", get_random_disorder_selection(20))
+    def test_parent_child_reciprocity(self, set_base_url, set_api_key, chosen_id):
+        parents = get_disorder_parents(chosen_id)
+        children_of_parents = get_disorder_children(parents[chosen_id])
+        assert all(chosen_id in value for value in children_of_parents.values())
+
+    @pytest.mark.parametrize("chosen_id", get_random_disorder_selection(20))
+    def test_ancestor_descendant_reciprocity(self, set_base_url, set_api_key, chosen_id):
+        parents = get_disorder_ancestors(chosen_id)
+        descendants_of_parents = get_disorder_descendants(parents[chosen_id])
+        assert all(chosen_id in value for value in descendants_of_parents.values())
+
+
+class TestRoutesFailWithoutAPIUrl:
+    def test_get_node_type(self, set_api_key):
+        with pytest.raises(ConfigError) as excinfo:
+            get_node_types()
+        assert "API URL is not set in the config" == str(excinfo.value)
+
+    def test_get_edge_type(self, set_api_key):
+        with pytest.raises(ConfigError) as excinfo:
+            get_edge_types()
+        assert "API URL is not set in the config" == str(excinfo.value)
+
+
+class TestRoutesFailWithoutAPIKey:
+    def test_get_node_type(self, set_base_url):
+        if not api_keys_active():
+            return
+
+        with pytest.raises(ConfigError) as excinfo:
+            get_node_types()
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+        if not api_keys_active():
+            return
+
+        with pytest.raises(ConfigError) as excinfo:
+            get_edge_types()
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+    @pytest.mark.parametrize("collection", get_node_collections())
+    def test_node_routes_fail(self, set_base_url, collection):
+        if not api_keys_active():
+            return
+
+        with pytest.raises(ConfigError) as excinfo:
+            get_collection_attributes(collection)
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+        with pytest.raises(ConfigError) as excinfo:
+            get_node_ids(collection)
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+        with pytest.raises(ConfigError) as excinfo:
+            get_nodes(collection)
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+        with pytest.raises(ConfigError) as excinfo:
+            for _ in take(1, iter_nodes(collection)):
+                pass
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+    @pytest.mark.parametrize("collection", get_edge_collections())
+    def test_edge_routes_fail(self, set_base_url, collection):
+        if not api_keys_active():
+            return
+
+        with pytest.raises(ConfigError) as excinfo:
+            get_collection_attributes(collection)
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+        with pytest.raises(ConfigError) as excinfo:
+            get_edges(collection)
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+        with pytest.raises(ConfigError) as excinfo:
+            for _ in take(1, iter_edges(collection)):
+                pass
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+    def test_disorder_routes_fail(self, set_base_url):
+        disorder_id = "mondo.0000001"  # root of the MONDO tree
+        icd10_id = "I59.1"  # Heart disease, unspecified
+
+        with pytest.raises(ConfigError) as excinfo:
+            get_disorder_children(disorder_id)
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+        with pytest.raises(ConfigError) as excinfo:
+            get_disorder_parents(disorder_id)
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+        with pytest.raises(ConfigError) as excinfo:
+            get_disorder_ancestors(disorder_id)
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+        with pytest.raises(ConfigError) as excinfo:
+            get_disorder_descendants(disorder_id)
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+        with pytest.raises(ConfigError) as excinfo:
+            search_by_icd10(icd10_id)
+        assert "no API key set in the configuration" == str(excinfo.value)
+
+
+class TestPPIRoute:
+    def test_ppi_route(self, set_base_url, set_api_key):
+        ppis(["exp"], 0, get_pagination_limit())
+
+    def test_overlap_with_pagination(self, set_base_url, set_api_key):
+        page_limit = 1_000
+        delta = page_limit // 2
+        skip = delta
+
+        previous = ppis(["exp"], 0, page_limit)
+
+        for _ in range(100):
+            current = ppis(["exp"], skip, page_limit)
+            assert previous[-delta:] == current[:delta]
+            previous = current
+            skip += delta
+
+    def test_each_evidence_type_works(self, set_base_url, set_api_key):
+        for evidence_type in ["exp", "pred", "ortho"]:
+            results = ppis([evidence_type], 0, get_pagination_limit())
+            assert all(evidence_type in doc["evidenceTypes"] for doc in results)
+
+    def test_fails_with_invalid_type(self, set_base_url, set_api_key):
+        for evidence_type in ["exps", "pr3d", "orth"]:
+            with pytest.raises(NeDRexError) as excinfo:
+                ppis([evidence_type])
+            err_val = {evidence_type}
+            assert str(excinfo.value) == f"unexpected evidence types: {err_val}"
+
+    def test_fails_with_large_limit(self, set_base_url, set_api_key):
+        page_limit = get_pagination_limit()
+        with pytest.raises(NeDRexError) as excinfo:
+            ppis(["exp"], limit=page_limit + 1)
+
+        assert str(excinfo.value) == f"limit={page_limit + 1:,} is too great (maximum is {page_limit:,})"
+
+
+class TestRelationshipRoutes:
+    def test_get_encoded_proteins(self, set_base_url, set_api_key):
+        # NOTE: If result changes, check these examples are still accurate.
+
+        histamine_receptor_genes = ["3269", 3274, "entrez.11255"]  # HRH1, as str  # HRH2, as int  # HRH3, as prefix
+
+        results = get_encoded_proteins(histamine_receptor_genes)
+
+        assert "P35367" in results["3269"]
+        assert "P25021" in results["3274"]
+        assert "Q9Y5N1" in results["11255"]
+
+    def test_get_drugs_indicated_for_disorders(self, set_base_url, set_api_key):
+        # NOTE: If result changes, check these examples are still accurate.
+
+        disorders = [
+            "mondo.0005393",  # Gout
+            "0005362",  # ED
+        ]
+
+        results = get_drugs_indicated_for_disorders(disorders)
+
+        assert "DB00437" in results["0005393"]  # Allopurinol for gout
+        assert "DB00203" in results["0005362"]  # Sildenafil for ED
+
+    def test_get_drugs_targetting_proteins(self, set_base_url, set_api_key):
+        # NOTE: If result changes, check these examples are still accurate.
+
+        proteins = [
+            "P35367",  # Histamine H1 receptor, targetted by antihistamines
+            "uniprot.P03372",  # Estrogen receptor α, targetted by ethinylestradiol
+        ]
+
+        results = get_drugs_targetting_proteins(proteins)
+
+        assert "DB00341" in results["P35367"]
+        assert "DB00977" in results["P03372"]
+
+    def test_get_drugs_targetting_gene_products(self, set_base_url, set_api_key):
+        genes = [
+            "entrez.3269",  # HRH1 gene (product targetted by antihistamines)
+            2099,  # Estrogen receptor α gene (product targetted by ethinylestradiol)
+            "6532",  # SLC6A4, encodes Sodium-dependent serotonin transporter, targetted by SSRIs
+        ]
+
+        results = get_drugs_targetting_gene_products(genes)
+
+        assert "DB00341" in results["3269"]
+        assert "DB00977" in results["2099"]
+        assert "DB00215" in results["6532"]
+
+
+class TestGraphRoutes:
+    def test_default_build(self, set_base_url, set_api_key):
+        build_request()
+
+    @pytest.mark.parametrize(
+        "kwargs",
+        [
+            {"nodes": ["this_is_not_a_node"]},
+            {"edges": ["this_is_not_an_edge"]},
+            {"ppi_evidence": ["made_up"]},
+            {"taxid": ["human"]},
+        ],
+    )
+    def test_build_fails_with_invalid_params(self, kwargs, set_base_url, set_api_key):
+        with pytest.raises(NeDRexError):
+            build_request(**kwargs)
+
+    def test_get_uid(self, set_base_url, set_api_key):
+        uid = build_request()
+        assert UID_REGEX.match(uid)
+        check_build_status(uid)
+
+    def test_fails_with_invalid_uid(self, set_base_url, set_api_key):
+        uid = "this-is-not-a-valid-uid!"
+        with pytest.raises(NeDRexError):
+            check_build_status(uid)
+
+    def test_download_graph(self, set_base_url, set_api_key):
+        uid = build_request()
+        while True:
+            status = check_build_status(uid)
+            if status["status"] == "completed":
+                break
+            time.sleep(10)
+
+        download_graph(uid)
+        p = Path(f"{uid}.graphml")
+        assert p.exists()
+        p.unlink()
+
+    def test_download_graph_different_dir(self, set_base_url, set_api_key):
+        with tempfile.TemporaryDirectory() as tmpdir:
+
+            uid = build_request()
+            while True:
+                status = check_build_status(uid)
+                if status["status"] == "completed":
+                    break
+                time.sleep(10)
+
+            target = os.path.join(tmpdir, "mygraph.graphml")
+
+            download_graph(uid, target)
+            p = Path(target)
+            assert p.exists()
+            p.unlink()
+
+    def test_download_fails_with_invalid_uid(self, set_base_url, set_api_key):
+        uid = "this-is-not-a-valid-uid!"
+        with pytest.raises(NeDRexError):
+            download_graph(uid)
+
+
+class TestMustRoutes:
+    def test_simple_request(self, set_base_url, set_api_key):
+        uid = must_request(SEEDS, 0.5, True, 10, 2)
+        assert UID_REGEX.match(uid)
+
+    def test_must_status(self, set_base_url, set_api_key):
+        uid = must_request(SEEDS, 0.5, True, 10, 2)
+        status = check_must_status(uid)
+        assert isinstance(status, dict)
+        assert "status" in status.keys()
+
+    @pytest.mark.parametrize(
+        "update",
+        [
+            {"hubpenalty": 1.01},
+            {"hubpenalty": -0.01},
+            {"hubpenalty": None},
+            {"multiple": None},
+            {"trees": -1},
+            {"trees": None},
+            {"maxit": -1},
+            {"maxit": None},
+        ],
+    )
+    def test_must_fails_with_invalid_arguments(self, set_base_url, set_api_key, update):
+        kwargs = {"seeds": SEEDS, "hubpenalty": 0.5, "multiple": True, "trees": 10, "maxit": 2, "network": "DEFAULT"}
+
+        with pytest.raises(NeDRexError):
+            kwargs = {**kwargs, **update}
+            must_request(**kwargs)
+
+
+class TestDiamondRoutes:
+    def test_simple_request(self, set_base_url, set_api_key):
+        uid = diamond_submit(SEEDS, 10)
+        assert UID_REGEX.match(uid)
+
+    def test_diamond_status(self, set_base_url, set_api_key):
+        uid = diamond_submit(SEEDS, 10)
+        status = check_diamond_status(uid)
+        assert isinstance(status, dict)
+        assert "status" in status.keys()
+
+    def test_diamond_download(self, set_base_url, set_api_key):
+        uid = diamond_submit(SEEDS, 10)
+
+        while True:
+            status = check_diamond_status(uid)
+            if status["status"] == "completed":
+                break
+            time.sleep(10)
+
+        download_diamond_results(uid)
+
+    def test_diamond_fails_with_invalid_arguments(self, set_base_url, set_api_key):
+        with pytest.raises(ValueError):
+            diamond_submit(SEEDS, n=10, edges="some")
+
+
+class TestDominoRoutes:
+    def test_simple_request(self, set_base_url, set_api_key):
+        uid = domino_submit(SEEDS)
+        assert UID_REGEX.match(uid)
+    
+    def test_check_domino_status(self, set_base_url, set_api_key):
+        uid = domino_submit(SEEDS)
+        status = check_domino_status(uid)
+        assert isinstance(status, dict)
+        assert "status" in status.keys()
\ No newline at end of file
diff --git a/python_nedrex/tox.ini b/python_nedrex/tox.ini
new file mode 100644
index 0000000000000000000000000000000000000000..57d5be83ec43e13626112cdd3592297e4d5cef90
--- /dev/null
+++ b/python_nedrex/tox.ini
@@ -0,0 +1,26 @@
+[tox]
+envlist = py36, py37, py38, flake8
+
+[travis]
+python =
+    3.8: py38
+    3.7: py37
+    3.6: py36
+
+[testenv:flake8]
+basepython = python
+deps = flake8
+commands = flake8 python_nedrex tests
+
+[testenv]
+setenv =
+    PYTHONPATH = {toxinidir}
+deps =
+    -r{toxinidir}/requirements_dev.txt
+; If you want to make tox run the tests with the same versions, create a
+; requirements.txt with the pinned versions and uncomment the following line:
+;     -r{toxinidir}/requirements.txt
+commands =
+    pip install -U pip
+    pytest --basetemp={envtmpdir}
+