Repository: ResonantGeoData/ResonantGeoData (language: python)

Issue:
Stub out Client Library
Initial task for ResonantGeoData/ResonantGeoData#178 
Python API from OpenAPI endpoints
We need to (automatically?) generate a Python API from the OpenAPI endpoints. Or at least provide a way to access those endpoints from a Python client.

One viable option is: https://github.com/OpenAPITools/openapi-generator

Another approach would be to manually create the API... I'm actually leaning towards this to make sure we can design the API in a way that is user friendly and have clear documentation

First, we need a few clear use cases, likely coming from ndsampler
Python API from OpenAPI endpoints
We need to (automatically?) generate a Python API from the OpenAPI endpoints. Or at least provide a way to access those endpoints from a Python client.

One viable option is: https://github.com/OpenAPITools/openapi-generator

Another approach would be to manually create the API... I'm actually leaning towards this to make sure we can design the API in a way that is user friendly and have clear documentation

First, we need a few clear use cases, likely coming from ndsampler


Discussion / hints:
we are waiting on sysadmins to create an account
Done.
Another route is: https://github.com/swagger-api/swagger-codegen


Another route is: https://github.com/swagger-api/swagger-codegen



Fix patch:
diff --git a/demos/GeoData-API.ipynb b/demos/GeoData-API.ipynb
index 050bf6836..cc5c18675 100644
--- a/demos/GeoData-API.ipynb
+++ b/demos/GeoData-API.ipynb
@@ -6,7 +6,6 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "import requests\n",
     "from io import BytesIO\n",
     "import imageio\n",
     "import rasterio\n",
@@ -15,7 +14,7 @@
     "import matplotlib.pyplot as plt\n",
     "import numpy as np\n",
     "import json\n",
-    "from requests.auth import HTTPBasicAuth"
+    "import os"
    ]
   },
   {
@@ -24,52 +23,9 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "class GeoData:\n",
-    "    HOST = 'http://localhost:8000'\n",
-    "    def __init__(self, user, password):\n",
-    "        self.auth=HTTPBasicAuth(user, password)\n",
-    "\n",
-    "    @staticmethod\n",
-    "    def _load_image(content):\n",
-    "        return imageio.imread(BytesIO(content))\n",
-    "\n",
-    "    @staticmethod\n",
-    "    def _load_raster(content):\n",
-    "        memfile = MemoryFile(content)\n",
-    "        dataset = memfile.open()\n",
-    "        return dataset\n",
-    "\n",
-    "    def get_subsampled_raster(self, pk):\n",
-    "        a = requests.get(f'{GeoData.HOST}/api/geoprocess/imagery/subsample/{pk}', auth=self.auth)\n",
-    "        b = requests.get(a.json()['data']['file'], auth=self.auth)\n",
-    "        #return GeoData._load_image(b.content)\n",
-    "        return GeoData._load_raster(b.content)\n",
-    "\n",
-    "    def get_image_entry(self, pk):\n",
-    "        a = requests.get(f'{GeoData.HOST}/api/geodata/imagery/{pk}/data', auth=self.auth)\n",
-    "        #return GeoData._load_image(a.content)\n",
-    "        return GeoData._load_raster(a.content)\n",
-    "    \n",
-    "    def post_subsample(self, payload):\n",
-    "        a = requests.post(f'{GeoData.HOST}/api/geoprocess/imagery/subsample', json=payload, auth=self.auth)\n",
-    "        return json.loads(a.content)\n",
-    "    \n",
-    "    def search(self, payload):\n",
-    "        a = requests.get(f'{GeoData.HOST}/api/geosearch', params=payload, auth=self.auth)\n",
-    "        return json.loads(a.content)\n",
-    "    \n",
-    "    def get(self, url):\n",
-    "        a = requests.get(url, auth=self.auth)\n",
-    "        return json.loads(a.content)\n",
-    "        \n",
-    "client = GeoData('user', 'password')"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Search"
+    "def plot_geojson(gjs, *args, **kwargs):\n",
+    "    points = np.array(gjs['coordinates'][0])\n",
+    "    return plt.plot(points[:,0], points[:,1], *args, **kwargs)"
    ]
   },
   {
@@ -78,32 +34,16 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "bbox = {\"type\":\"Polygon\",\n",
-    " \"coordinates\":[[[-107.24629793033489,38.821513321590224],\n",
-    "                 [-107.24629793033489,39.21393900330613],\n",
-    "                 [-106.49448944065813,39.21393900330613],\n",
-    "                 [-106.49448944065813,38.821513321590224],\n",
-    "                 [-107.24629793033489,38.821513321590224]]]\n",
-    "}\n",
+    "from rgdc import Rgdc\n",
     "\n",
-    "search_params = {\n",
-    "    \"q\": json.dumps(bbox),\n",
-    "    \"predicate\": \"within\",\n",
-    "    \"datatype\": \"raster\",\n",
-    "}\n",
-    "\n",
-    "r = client.search(search_params)\n",
-    "r"
+    "client = Rgdc(username='username', password='password')"
    ]
   },
   {
-   "cell_type": "code",
-   "execution_count": null,
+   "cell_type": "markdown",
    "metadata": {},
-   "outputs": [],
    "source": [
-    "sp = r['results'][0]\n",
-    "sp"
+    "## Search"
    ]
   },
   {
@@ -112,24 +52,27 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "raster = client.get(sp['detail'])\n",
-    "raster"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## DEM Subsampling"
+    "bbox = {\"type\":\"Polygon\",\n",
+    "        \"coordinates\":[[[-105.45091240368326,39.626245373878696],\n",
+    "                                         [-105.45091240368326,39.929904289147274],\n",
+    "                                         [-104.88775649170178,39.929904289147274],\n",
+    "                                         [-104.88775649170178,39.626245373878696],\n",
+    "                                         [-105.45091240368326,39.626245373878696]]]}\n",
+    "\n",
+    "q = client.search(query=json.dumps(bbox), predicate='intersects', datatype='raster')\n",
+    "len(q)"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "scrolled": true
+   },
    "outputs": [],
    "source": [
-    "img_id = raster['image_set']['images'][0]['id']"
+    "for s in q:\n",
+    "    print(s['subentry_name'])"
    ]
   },
   {
@@ -138,25 +81,18 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "src = client.get_image_entry(img_id)\n",
-    "show(src)"
+    "plot_geojson(bbox, 'k--', label='Search Region')\n",
+    "for s in q:\n",
+    "    plot_geojson(s['footprint'], label=s['subentry_name'])\n",
+    "plt.legend()\n",
+    "plt.title(f'Count: {len(q)}')"
    ]
   },
   {
-   "cell_type": "code",
-   "execution_count": null,
+   "cell_type": "markdown",
    "metadata": {},
-   "outputs": [],
    "source": [
-    "points = np.array([[-107.08212524738178, 39.01040379702808], \n",
-    "                   [-106.96182164246767, 39.03110215679572], \n",
-    "                   [-106.90895466037738, 38.98387516880551], \n",
-    "                   [-106.9805540376965, 38.91038429753703], \n",
-    "                   [-107.07130208569401, 38.952157178475225], \n",
-    "                   [-107.08212524738178, 39.01040379702808]])\n",
-    "\n",
-    "shape = {\"type\": \"Polygon\", \n",
-    "         \"coordinates\": [points.tolist(), ]}"
+    "## Inspect Raster"
    ]
   },
   {
@@ -165,9 +101,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "ax = plt.gca()\n",
-    "show(src, ax=ax)\n",
-    "plt.plot(points[:,0], points[:,1], linewidth=5, color='k')"
+    "raster = client.session.get(q[0]['detail']).json()\n",
+    "raster"
    ]
   },
   {
@@ -176,14 +111,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "payload = {\n",
-    "            'source_image': img_id,\n",
-    "            'sample_type': 'geojson',\n",
-    "            'sample_parameters': shape,\n",
-    "        }\n",
-    "\n",
-    "result = client.post_subsample(payload)\n",
-    "result"
+    "plot_geojson(bbox, 'k--')\n",
+    "plot_geojson(raster['outline'], 'r')"
    ]
   },
   {
@@ -192,7 +121,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "product = client.get_subsampled_raster(result['id'])"
+    "count = len(raster['parent_raster']['image_set']['images'])\n",
+    "count"
    ]
   },
   {
@@ -201,10 +131,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "ax = plt.gca()\n",
-    "show(src, ax=ax, cmap='Greys')\n",
-    "show(product, ax=ax)\n",
-    "plt.plot(points[:,0], points[:,1], linewidth=5, color='k')\n",
+    "load_image = lambda imbytes: imageio.imread(BytesIO(imbytes))\n",
+    "\n",
+    "for i in range(count):\n",
+    "    thumb_bytes = client.download_raster_entry_thumbnail(raster['id'], band=i)\n",
+    "    thumb = load_image(thumb_bytes)\n",
+    "    plt.subplot(1, count, i+1)\n",
+    "    plt.imshow(thumb)\n",
+    "plt.tight_layout()\n",
     "plt.show()"
    ]
   },
@@ -212,16 +146,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "## Annotation Subsampling"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "img_id = 234"
+    "## Download Raster"
    ]
   },
   {
@@ -230,8 +155,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "src = client.get_image_entry(img_id)\n",
-    "# show(src)"
+    "paths = client.download_raster_entry(raster['id'])\n",
+    "paths"
    ]
   },
   {
@@ -240,14 +165,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "payload = {\n",
-    "            'source_image': img_id,\n",
-    "            'sample_type': 'annotation',\n",
-    "            'sample_parameters': {'id': 251, 'outline': True},\n",
-    "        }\n",
-    "\n",
-    "result = client.post_subsample(payload)\n",
-    "result"
+    "rasters = [rasterio.open(im) for im in paths['images']]"
    ]
   },
   {
@@ -256,32 +174,14 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "product = client.get_subsampled_raster(result['id'])"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "ax = plt.gca()\n",
-    "show(src, ax=ax)\n",
-    "show(product, ax=ax, )\n",
-    "ax.set_xlim(0, src.shape[1])\n",
-    "ax.set_ylim(src.shape[0], 0)\n",
+    "for i, src in enumerate(rasters):\n",
+    "    plt.subplot(1, count, i+1)\n",
+    "    ax = plt.gca()\n",
+    "    show(src, ax=ax)\n",
+    "plt.tight_layout()\n",
     "plt.show()"
    ]
   },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "src.shape"
-   ]
-  },
   {
    "cell_type": "code",
    "execution_count": null,
@@ -292,9 +192,9 @@
  ],
  "metadata": {
   "kernelspec": {
-   "display_name": "Python [conda env:geospatial]",
+   "display_name": "Python [conda env:django] *",
    "language": "python",
-   "name": "conda-env-geospatial-py"
+   "name": "conda-env-django-py"
   },
   "language_info": {
    "codemirror_mode": {
@@ -306,7 +206,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.7.6"
+   "version": "3.8.3"
   }
  },
  "nbformat": 4,
diff --git a/rgd/geodata/serializers.py b/rgd/geodata/serializers.py
index 3f2071eb8..a8085e465 100644
--- a/rgd/geodata/serializers.py
+++ b/rgd/geodata/serializers.py
@@ -69,6 +69,11 @@ class Meta:
 
 
 class ChecksumFileSerializer(serializers.ModelSerializer):
+    def to_representation(self, value):
+        ret = super().to_representation(value)
+        ret['download_url'] = value.get_url()
+        return ret
+
     class Meta:
         model = models.ChecksumFile
         fields = '__all__'
@@ -124,6 +129,16 @@ class Meta:
 class ImageEntrySerializer(serializers.ModelSerializer):
     image_file = ImageFileSerializer()
 
+    def to_representation(self, value):
+        ret = super().to_representation(value)
+        realtive_thumbnail_uri = reverse('image-thumbnail', args=[value.id])
+        if 'request' in self.context:
+            request = self.context['request']
+            ret['thumbnail'] = request.build_absolute_uri(realtive_thumbnail_uri)
+        else:
+            ret['thumbnail'] = realtive_thumbnail_uri
+        return ret
+
     class Meta:
         model = models.ImageEntry
         fields = '__all__'
diff --git a/rgdc/README.md b/rgdc/README.md
new file mode 100644
index 000000000..97ee6b466
--- /dev/null
+++ b/rgdc/README.md
@@ -0,0 +1,87 @@
+# rgdc - ResonantGeoDataClient
+
+The **rgdc** Python package allows users to make web requests to a ResonantGeoData instance within a Python script.
+
+
+# Installation
+```
+pip install rgdc
+```
+
+# Usage
+### Search and display results
+```
+import json
+import matplotlib.pyplot as plt
+import numpy as np
+
+from rgdc import Rgdc
+
+def plot_geojson(gjs, *args, **kwargs):
+    points = np.array(gjs['coordinates'][0])
+    return plt.plot(points[:,0], points[:,1], *args, **kwargs)
+
+client = Rgdc(username='username', password='password')
+bbox = {
+    "type":"Polygon",
+    "coordinates":[
+        [
+            [-105.45091240368326,39.626245373878696],
+            [-105.45091240368326,39.929904289147274],
+            [-104.88775649170178,39.929904289147274],
+            [-104.88775649170178,39.626245373878696],
+            [-105.45091240368326,39.626245373878696]
+        ]
+    ]
+}
+
+q = client.search(query=json.dumps(bbox), predicate='intersects', datatype='raster')
+
+for s in q:
+    print(s['subentry_name'])
+
+plot_geojson(bbox, 'k--', label='Search Region')
+
+for s in q:
+    plot_geojson(s['footprint'], label=s['subentry_name'])
+
+plt.legend()
+plt.title(f'Count: {len(q)}')
+```
+
+### Inspect raster
+```
+import imageio
+from io import BytesIO
+import requests
+
+raster = requests.get(q[0]['detail']).json()
+plot_geojson(bbox, 'k--')
+plot_geojson(raster['outline'], 'r')
+load_image = lambda imbytes: imageio.imread(BytesIO(imbytes))
+
+count = len(raster['parent_raster']['image_set']['images'])
+for i in range(count):
+    thumb_bytes = client.download_raster_entry_thumbnail(raster['id'], band=i)
+    thumb = load_image(thumb_bytes)
+    plt.subplot(1, count, i+1)
+    plt.imshow(thumb)
+
+plt.tight_layout()
+plt.show()
+```
+
+### Download Raster
+```
+import rasterio
+from rasterio.plot import show
+
+paths = client.download_raster_entry(raster['id'])
+rasters = [rasterio.open(im) for im in paths['images']]
+for i, src in enumerate(rasters):
+    plt.subplot(1, count, i+1)
+    ax = plt.gca()
+    show(src, ax=ax)
+plt.tight_layout()
+plt.show()
+```
diff --git a/rgdc/pyproject.toml b/rgdc/pyproject.toml
new file mode 100644
index 000000000..a3f719c4a
--- /dev/null
+++ b/rgdc/pyproject.toml
@@ -0,0 +1,17 @@
+[build-system]
+requires = ["setuptools >= 42", "wheel", "setuptools-scm[toml]>=3.4"]
+build-backend = "setuptools.build_meta"
+
+[tool.black]
+line-length = 100
+skip-string-normalization = true
+target-version = ["py38"]
+exclude='\.eggs|\.git|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist'
+
+[tool.isort]
+profile = "black"
+line_length = 100
+# Sort by name, don't cluster "from" vs "import"
+force_sort_within_sections = true
+# Combines "as" imports on the same line
+combine_as_imports = true
diff --git a/rgdc/rgdc/__init__.py b/rgdc/rgdc/__init__.py
new file mode 100644
index 000000000..0d2461b9c
--- /dev/null
+++ b/rgdc/rgdc/__init__.py
@@ -0,0 +1,2 @@
+from .rgdc import Rgdc  # noqa F401
+from .version import __version__  # noqa F401
diff --git a/rgdc/rgdc/rgdc.py b/rgdc/rgdc/rgdc.py
new file mode 100644
index 000000000..085456306
--- /dev/null
+++ b/rgdc/rgdc/rgdc.py
@@ -0,0 +1,286 @@
+from base64 import b64encode
+from dataclasses import dataclass
+import json
+from json.decoder import JSONDecodeError
+from pathlib import Path
+import tempfile
+from typing import Dict, Iterator, List, Optional, Tuple, Union
+
+from geomet import wkt
+from tqdm import tqdm
+
+from .session import RgdcSession
+from .types import DATETIME_OR_STR_TUPLE, SEARCH_DATATYPE_CHOICE, SEARCH_PREDICATE_CHOICE
+from .utils import DEFAULT_RGD_API, datetime_to_str, download_checksum_file_to_path
+
+
+@dataclass
+class RasterDownload:
+    path: Path
+    images: List[Path]
+    ancillary: List[Path]
+
+
+class Rgdc:
+    def __init__(
+        self,
+        api_url: str = DEFAULT_RGD_API,
+        username: Optional[str] = None,
+        password: Optional[str] = None,
+    ):
+        """
+        Initialize a RGD Client.
+
+        Args:
+            api_url: The base url of the RGD API instance.
+            username: The username to authenticate to the instance with, if any.
+            password: The password associated with the provided username.
+
+        Returns:
+            A new Rgdc instance.
+        """
+        auth_header = None
+        if username and password:
+            encoded_credentials = b64encode(f'{username}:{password}'.encode('utf-8')).decode()
+            auth_header = f'Basic {encoded_credentials}'
+
+        self.session = RgdcSession(base_url=api_url, auth_header=auth_header)
+
+    def list_image_entry_tiles(self, image_entry_id: Union[str, int]) -> Dict:
+        """List geodata imagery image_entry tiles."""
+        r = self.session.get(f'geodata/imagery/{image_entry_id}/tiles')
+        r.raise_for_status()
+
+        return r.json()
+
+    def download_image_entry_file(
+        self, image_entry_id: Union[str, int], chunk_size: int = 1024 * 1024
+    ) -> Iterator[bytes]:
+        """
+        Download the associated ImageFile data for this ImageEntry directly from S3.
+
+        Args:
+            image_entry_id: The ID of the ImageEntry to download.
+            chunk_size: The size (in bytes) of each item in the returned iterator (defaults to 1MB).
+
+        Returns:
+            An iterator of byte chunks.
+        """
+        r = self.session.get(f'geodata/imagery/{image_entry_id}/data', stream=True)
+        r.raise_for_status()
+
+        return r.iter_content(chunk_size=chunk_size)
+
+    def download_image_entry_thumbnail(
+        self,
+        image_entry_id: Union[str, int],
+    ) -> bytes:
+        """
+        Download the generated thumbnail for this ImageEntry.
+
+        Args:
+            image_entry_id: The ID of the ImageEntry to download.
+
+        Returns:
+            Thumbnail bytes.
+        """
+        r = self.session.get(f'geoprocess/imagery/{image_entry_id}/thumbnail')
+        r.raise_for_status()
+        return r.content
+
+    def download_raster_entry_thumbnail(
+        self,
+        raster_meta_entry_id: Union[str, int],
+        band: int = 0,
+    ) -> bytes:
+        """
+        Download the generated thumbnail for this ImageEntry.
+
+        Args:
+            raster_meta_entry_id: The id of the RasterMetaEntry, which is a child to the desired raster entry.
+            band: The index of the image in the raster's image set to produce thumbnail from.
+
+        Returns:
+            Thumbnail bytes.
+        """
+        r = self.session.get(f'geodata/imagery/raster/{raster_meta_entry_id}')
+        r.raise_for_status()
+        parent_raster = r.json().get('parent_raster', {})
+        images = parent_raster.get('image_set', {}).get('images', [])
+        try:
+            return self.download_image_entry_thumbnail(images[band]['id'])
+        except IndexError:
+            raise IndexError(f'Band index ({band}) out of range.')
+
+    def download_raster_entry(
+        self,
+        raster_meta_entry_id: Union[str, int],
+        pathname: Optional[str] = None,
+        nest_with_name: bool = False,
+    ) -> RasterDownload:
+        """
+        Download the image set associated with a raster entry to disk.
+
+        Args:
+            raster_meta_entry_id: The id of the RasterMetaEntry, which is a child to the desired raster entry.
+            pathname: The directory to download the image set to. If not supplied, a temporary directory will be used.
+            nest_with_name: If True, nests the download within an additional directory, using the raster entry name.
+
+        Returns:
+            A dictionary of the paths to all files downloaded under the directory.
+        """
+        r = self.session.get(f'geodata/imagery/raster/{raster_meta_entry_id}')
+        r.raise_for_status()
+        parent_raster = r.json().get('parent_raster', {})
+
+        # Create dirs after request to avoid empty dirs if failed
+        if pathname is None:
+            pathname = tempfile.mkdtemp()
+
+        # Handle optional nesting with raster entry name
+        path = Path(pathname)
+        parent_raster_name: Optional[str] = parent_raster.get('name')
+
+        if nest_with_name and parent_raster_name:
+            path = path / parent_raster_name
+
+        # Ensure base download directory exists
+        if not path.exists():
+            path.mkdir()
+
+        # Initialize dataclass
+        raster_download = RasterDownload(path, [], [])
+
+        # Download images
+        images = parent_raster.get('image_set', {}).get('images', [])
+        for image in tqdm(images, desc='Downloading image files'):
+            file = image.get('image_file', {}).get('file', {})
+            file_path = download_checksum_file_to_path(file, path)
+            if file_path:
+                raster_download.images.append(file_path)
+
+        # Download ancillary files
+        ancillary = parent_raster.get('ancillary_files', [])
+        for file in tqdm(ancillary, desc='Downloading ancillary files'):
+            file_path = download_checksum_file_to_path(file, path)
+            if file_path:
+                raster_download.ancillary.append(file_path)
+
+        return raster_download
+
+    def search(
+        self,
+        query: Optional[Union[Dict, str]] = None,
+        predicate: Optional[SEARCH_PREDICATE_CHOICE] = None,
+        relates: Optional[str] = None,
+        distance: Optional[Tuple[float, float]] = None,
+        acquired: Optional[DATETIME_OR_STR_TUPLE] = None,
+        created: Optional[DATETIME_OR_STR_TUPLE] = None,
+        modified: Optional[DATETIME_OR_STR_TUPLE] = None,
+        datatype: Optional[SEARCH_DATATYPE_CHOICE] = None,
+        instrumentation: Optional[str] = None,
+        num_bands: Optional[Tuple[int, int]] = None,
+        resolution: Optional[Tuple[int, int]] = None,
+        cloud_cover: Optional[Tuple[float, float]] = None,
+        frame_rate: Optional[Tuple[int, int]] = None,
+        limit: Optional[int] = None,
+        offset: Optional[int] = None,
+    ) -> Iterator[Dict]:
+        """
+        Search for geospatial entries based on various criteria.
+
+        For Ranges (Tuples), an entry of `None` means that side of the range is unbounded.
+        E.g. a range of (2, None) is 2 or more, (None, 5) is at most 5, (2, 5) is between 2 and 5.
+
+        Args:
+            query: Either a WKT GeoJSON representation, a GeoJSON string, or a GeoJSON dict.
+            predicate: A named spatial predicate based on the DE-9IM. This spatial predicate will
+                be used to filter data such that predicate(a, b) where b is the queried geometry.
+            relates: Specify exactly how the queried geometry should relate to the data using a
+                DE-9IM string code.
+            distance: The min/max distance around the queried geometry in meters.
+            acquired: The min/max date and time (ISO 8601) when data was acquired.
+            created: The min/max date and time (ISO 8601) when data was created.
+            modified: The min/max date and time (ISO 8601) when data was modified.
+            datatype: The datatype to provide.
+            instrumentation: The instrumentation used to acquire at least one of these data.
+            num_bands: The min/max number of bands in the raster.
+            resolution: The min/max resolution of the raster.
+            cloud_cover: The min/max cloud coverage of the raster.
+            frame_rate: The min/max frame rate of the video.
+            limit: The maximum number of results to return.
+            offset: The number of results to skip.
+
+        Returns:
+            An list of Spatial Entries.
+        """
+        # The dict that will be used to store params.
+        # Initialize with queries that won't be additionally processed.
+        params = {
+            'predicate': predicate,
+            'relates': relates,
+            'datatype': datatype,
+            'instrumentation': instrumentation,
+            'limit': limit,
+            'offset': offset,
+        }
+
+        if query:
+            if isinstance(query, str):
+                try:
+                    query = json.loads(query)
+                except JSONDecodeError:
+                    pass
+
+            if isinstance(query, dict):
+                # Allow failure on invalid format
+                query = wkt.dumps(query)
+
+            params['q'] = query
+
+        # Process range params
+
+        if distance and len(distance) == 2:
+            dmin, dmax = distance
+            params['distance_min'] = dmin
+            params['distance_max'] = dmax
+
+        # TODO: Determine if the before/after param order needs to be swapped?
+        if acquired and len(acquired) == 2:
+            amin, amax = acquired
+            params['acquired_before'] = datetime_to_str(amax)
+            params['acquired_after'] = datetime_to_str(amin)
+
+        if created and len(created) == 2:
+            cmin, cmax = created
+            params['created_before'] = datetime_to_str(cmax)
+            params['created_after'] = datetime_to_str(cmin)
+
+        if modified and len(modified) == 2:
+            mmin, mmax = modified
+            params['modified_before'] = datetime_to_str(mmax)
+            params['modified_after'] = datetime_to_str(mmin)
+
+        if num_bands and len(num_bands) == 2:
+            nbmin, nbmax = num_bands
+            params['num_bands_min'] = nbmin
+            params['num_bands_max'] = nbmax
+
+        if resolution and len(resolution) == 2:
+            rmin, rmax = resolution
+            params['resolution_min'] = rmin
+            params['resolution_max'] = rmax
+
+        if cloud_cover and len(cloud_cover) == 2:
+            ccmin, ccmax = cloud_cover
+            params['cloud_cover_min'] = ccmin
+            params['cloud_cover_max'] = ccmax
+
+        if frame_rate and len(frame_rate) == 2:
+            frmin, frmax = frame_rate
+            params['frame_rate_min'] = frmin
+            params['frame_rate_max'] = frmax
+
+        response = self.session.get('geosearch', params=params)
+        response.raise_for_status()
+        return [result for result in response.json()['results']]
diff --git a/rgdc/rgdc/session.py b/rgdc/rgdc/session.py
new file mode 100644
index 000000000..7b69cc496
--- /dev/null
+++ b/rgdc/rgdc/session.py
@@ -0,0 +1,26 @@
+from typing import Optional
+
+from requests_toolbelt.sessions import BaseUrlSession
+
+from .version import __version__
+
+
+class RgdcSession(BaseUrlSession):
+    def __init__(self, base_url: str, auth_header: Optional[str] = None):
+        """
+        Initialize a session with a ResonantGeoData server.
+
+        base_url: The base url of the RGD API instance.
+        auth_header: If provided, set as the `Authorization` header on each request
+        """
+        base_url = f'{base_url.rstrip("/")}/'  # tolerate input with or without trailing slash
+        super().__init__(base_url=base_url)
+        self.headers.update(
+            {
+                'User-agent': f'rgdc/{__version__}',
+                'Accept': 'application/json',
+            }
+        )
+
+        if auth_header:
+            self.headers['Authorization'] = auth_header
diff --git a/rgdc/rgdc/types.py b/rgdc/rgdc/types.py
new file mode 100644
index 000000000..f0c950ba1
--- /dev/null
+++ b/rgdc/rgdc/types.py
@@ -0,0 +1,15 @@
+from datetime import datetime
+from typing import Literal, Tuple, Union
+
+DATETIME_OR_STR_TUPLE = Tuple[Union[datetime, str], Union[datetime, str]]
+SEARCH_DATATYPE_CHOICE = Literal['raster', 'fmv', 'geometry']
+SEARCH_PREDICATE_CHOICE = Literal[
+    'contains',
+    'crosses',
+    'disjoint',
+    'equals',
+    'intersects',
+    'overlaps',
+    'touches',
+    'within',
+]
diff --git a/rgdc/rgdc/utils.py b/rgdc/rgdc/utils.py
new file mode 100644
index 000000000..1d8252eef
--- /dev/null
+++ b/rgdc/rgdc/utils.py
@@ -0,0 +1,79 @@
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, Iterator, List
+
+import requests
+from requests import Response, Session
+
+DEFAULT_RGD_API = 'https://www.resonantgeodata.com/api'
+
+
+def pager(session: Session, url: str, **kwargs) -> Iterator[Response]:
+    """Exhaust a DRF Paginated list."""
+    while True:
+        r = session.get(url, **kwargs)
+        yield r
+
+        if 'next' in r.links:
+            url = r.links['next']['url']
+        else:
+            break
+
+
+def iterate_response_bytes(
+    url: str, chunk_size: int = 1024 * 1024, raise_for_status: bool = True
+) -> Iterator[bytes]:
+    """Return the response body as an iterator of bytes, where each item is `chunk_size` bytes long."""
+    r = requests.get(url, stream=True)
+
+    if raise_for_status:
+        r.raise_for_status()
+
+    return r.iter_content(chunk_size=chunk_size)
+
+
+def datetime_to_str(value: object):
+    """Convert datetime objects to ISO8601 strings."""
+    if value is not None:
+        if isinstance(value, datetime):
+            return value.isoformat()
+
+    return value
+
+
+def download_checksum_file_to_path(file: Dict, path: Path):
+    """
+    Download a RGD ChecksumFile to a given path.
+
+    Args:
+        file: A RGD ChecksumFile serialized as a Dict.
+        path: The root path to download this file to.
+
+    Returns:
+        The path on disk the file was downloaded to.
+    """
+    filepath = file.get('name')
+    file_download_url = file.get('download_url')
+
+    # Skip image if some fields are missing
+    if not (file and filepath and file_download_url):
+        # TODO: throw a warning to let user know this file failed
+        return
+
+    # Parse file path to identifiy nested directories
+    filepath: str = filepath.lstrip('/')
+    split_filepath: List[str] = filepath.split('/')
+    parent_dirname = '/'.join(split_filepath[:-1])
+    filename = split_filepath[-1]
+
+    # Create nested directory if necessary
+    parent_path = path / parent_dirname if parent_dirname else path
+    parent_path.mkdir(parents=True, exist_ok=True)
+
+    # Download contents to file
+    file_path = parent_path / filename
+    with open(file_path, 'wb') as open_file_path:
+        for chunk in iterate_response_bytes(file_download_url):
+            open_file_path.write(chunk)
+
+    return file_path
diff --git a/rgdc/rgdc/version.py b/rgdc/rgdc/version.py
new file mode 100644
index 000000000..e61a072ca
--- /dev/null
+++ b/rgdc/rgdc/version.py
@@ -0,0 +1,1 @@
+__version__ = '0.1.0-client.0'
diff --git a/rgdc/setup.py b/rgdc/setup.py
new file mode 100644
index 000000000..c551d2e65
--- /dev/null
+++ b/rgdc/setup.py
@@ -0,0 +1,44 @@
+from pathlib import Path
+
+from setuptools import find_packages, setup
+
+base_dir = Path(__file__).parent
+readme_file = base_dir / 'README.md'
+if readme_file.exists():
+    with readme_file.open() as f:
+        long_description = f.read()
+else:
+    # When this is first installed in development Docker, README.md is not available
+    long_description = ''
+
+# Load version
+with open(base_dir / 'rgdc' / 'version.py') as version_file:
+    version = version_file.read().replace('__version__ = ', '').replace("'", '').strip()
+
+setup(
+    name='rgdc',
+    version=version,
+    description='Make web requests to a ResonantGeoData instance.',
+    long_description=long_description,
+    long_description_content_type='text/markdown',
+    license='Apache 2.0',
+    author='Kitware, Inc.',
+    author_email='kitware@kitware.com',
+    keywords='',
+    classifiers=[
+        'Development Status :: 3 - Alpha',
+        'License :: OSI Approved :: Apache Software License',
+        'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python',
+    ],
+    python_requires='>=3.8',
+    packages=find_packages(exclude=['tests']),
+    install_requires=[
+        'requests',
+        'requests-toolbelt',
+        'geomet',
+        'tqdm',
+    ],
+    extras_require={'dev': ['ipython']},
+)


Test patch:
diff --git a/rgd/geodata/tests/test_client.py b/rgd/geodata/tests/test_client.py
new file mode 100644
index 000000000..106be454a
--- /dev/null
+++ b/rgd/geodata/tests/test_client.py
@@ -0,0 +1,27 @@
+from pathlib import Path
+import re
+
+import pkg_resources
+import pytest
+
+RGDC_VERSION_REGEX = r'(\d+\.\d+\.\d+)-client\.\d+'
+
+
+@pytest.fixture
+def rgdc_base_version():
+    base_dir = Path(__file__).parent.parent.parent.parent
+    version_file = base_dir / 'rgdc' / 'rgdc' / 'version.py'
+
+    with open(version_file) as file_in:
+        file_content = file_in.read()
+        version = re.search(RGDC_VERSION_REGEX, file_content)
+
+    return version.group(1)
+
+
+def test_client_version_match(rgdc_base_version):
+    rgd_version = next(
+        pkg.version for pkg in pkg_resources.working_set if pkg.key == 'resonantgeodata'
+    )
+
+    assert rgd_version == rgdc_base_version