diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..a81c8ee121952cf06bfaf9ff9988edd8cded763c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,138 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# 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/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
diff --git a/README.md b/README.md
index 7b441aa205acbb1c1a07cc7a8189f24cb025abb1..ceed0c973f6fe22f1a313d53a4d8f090be70722a 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,3 @@
 # esap-userprofile-python-client
 
+A Python client for the ESCAPE ESAP User Profile REST API.
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..adaa6db6e1657e72679abe29edd50933e6dd5416
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,2 @@
+from .zooniverse import zooniverse
+from .shopping_client import shopping_client
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000000000000000000000000000000000000..15fac8d3538c077c158c3e182ad4ae375cc3ff21
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,23 @@
+import setuptools
+
+with open("README.md", "r") as fh:
+    long_description = fh.read()
+
+setuptools.setup(
+    name="esap-userprofile-python-client",
+    version="0.0.1",
+    author="Hugh Dickinson",
+    author_email="hugh.dickinson@open.ac.uk",
+    description="A small example package",
+    long_description=long_description,
+    long_description_content_type="text/markdown",
+    url="https://git.astron.nl/astron-sdc/esap-userprofile-python-client",
+    packages=setuptools.find_packages(),
+    install_requires=["pandas", "requests", "panoptes-client"],
+    classifiers=[
+        "Programming Language :: Python :: 3",
+        "License :: OSI Approved :: Apache Software License",
+        "Operating System :: OS Independent",
+    ],
+    python_requires=">=3.6",
+)
diff --git a/shopping_client.py b/shopping_client.py
new file mode 100644
index 0000000000000000000000000000000000000000..33e43a75a596f17b19057a19f05e2bd57f345356
--- /dev/null
+++ b/shopping_client.py
@@ -0,0 +1,25 @@
+import pandas as pd
+import requests
+import json
+import os
+import urllib.parse
+
+
+class shopping_client:
+
+    endpoint = "esap-api/accounts/user-profiles/"
+
+    def __init__(self, username, host="http://localhost:5555/"):
+        self.username = username
+        self.host = host
+        self.basket = None
+
+    def get_basket(self, reload=False):
+        if self.basket is None or reload:
+            url = urllib.parse.urljoin(self.host, shopping_client.endpoint)
+            print(url)
+            response = requests.get(url, dict(user_name=self.username))
+            print(response.content)
+            if response.ok:
+                self.basket = json.loads(response.content)["results"][0]["shopping_cart"]
+        return self.basket
diff --git a/zooniverse/__init__.py b/zooniverse/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1d4ed23a78c81d6417dacd0f2c6919c8c6b05184
--- /dev/null
+++ b/zooniverse/__init__.py
@@ -0,0 +1 @@
+from .zooniverse import zooniverse
diff --git a/zooniverse/zooniverse.py b/zooniverse/zooniverse.py
new file mode 100644
index 0000000000000000000000000000000000000000..3f3ddb464418ed99036fe74bc61b55e45c32ce9f
--- /dev/null
+++ b/zooniverse/zooniverse.py
@@ -0,0 +1,105 @@
+import requests
+import json
+import io
+import getpass
+import pandas as pd
+from panoptes_client import Panoptes, Project, Workflow
+from panoptes_client.panoptes import PanoptesAPIException
+
+
+class zooniverse:
+
+    entity_types = {"workflow": Workflow, "project": Project}
+    category_converters = {
+        "subjects": dict(metadata=json.loads, locations=json.loads),
+        "classifications": dict(metadata=json.loads, annotations=json.loads),
+    }
+
+    def __init__(self, username, password=None):
+        self.username = username
+        self.password = password
+        if self.password is None:
+            self.password = getpass.getpass()
+
+        self.panoptes = Panoptes.connect(username=self.username, password=self.password)
+
+    def is_available(self, item, verbose=False):
+        try:
+            description = self._get_entity(item).describe_export(
+                self._get_item_entry(item, "category")
+            )
+            if verbose:
+                print(description)
+            return True
+        except PanoptesAPIException as e:
+            return False
+
+    def generate(self, item, wait=False):
+        print("Generating requested export...")
+        if wait:
+            print("\t\tWaiting for generation to complete...")
+        else:
+            print("\t\tNot waiting for generation to complete...")
+        response = self._get_entity(item).get_export(
+            self._get_item_entry(item, "category"), generate=True, wait=wait
+        )
+        if response.ok:
+            return (
+                pd.read_csv(
+                    io.BytesIO(response.content),
+                    converters=zooniverse.category_converters[
+                        self._get_item_entry(item, "category")
+                    ],
+                )
+                if not wait
+                else response
+            )
+        else:
+            return None
+
+    def retrieve(self, item, generate=False, wait=False):
+        if self.is_available(item) and not generate:
+            response = self._get_entity(item).get_export(
+                self._get_item_entry(item, "category"), generate=False, wait=wait
+            )
+        else:
+            if not generate:
+                print(
+                    "Requested resource is not available and you have specified generate==False"
+                )
+                return None
+            else:
+                print("Generating requested export...")
+                if wait:
+                    print("\t\tWaiting for generation to complete...")
+                else:
+                    print("\t\tNot waiting for generation to complete...")
+                response = self._get_entity(item).get_export(
+                    self._get_item_entry(item, "category"), generate=True, wait=wait
+                )
+        if response.ok:
+            return (
+                pd.read_csv(
+                    io.BytesIO(response.content),
+                    converters=zooniverse.category_converters[
+                        self._get_item_entry(item, "category")
+                    ],
+                )
+                if not wait
+                else response
+            )
+        else:
+            return None
+
+    def _get_entity(self, item):
+        entity = zooniverse.entity_types[self._get_item_entry(item, "catalog")].find(
+            int(self._get_item_entry(item, self._catalogue_to_id_string(item)))
+        )
+        return entity
+
+    def _get_item_entry(self, item, entry):
+        item_data = json.loads(item["item_data"].replace("'", '"'))
+        return item_data.get(entry, None)
+
+    def _catalogue_to_id_string(self, item):
+        return self._get_item_entry(item, "catalog") + "_id"