Repository: planetlabs/planet-client-python
Pull request #36 (2015-07-31 04:36:14)

initial workspace support + tests
~~not great for creating workspaces from scratch via command-line args yet~~

ischneider: @kapadia I'd like to merge this ASAP as I have a 2nd PR that will depend on it. Any concerns?

FYI: note a good chunk of additions are tests + fixture and also some code duplication removed
kapadia: Do it. One item - i'm not a fan of the `@-` convention, mostly because other geo tools (e.g. Rasterio, Fiona) do not use it. I'd like to maintain conventions with these packages, especially since the CLI is most inspired by them.

Patch:
diff --git a/planet/api/client.py b/planet/api/client.py
index edf0721..f95bf12 100644
--- a/planet/api/client.py
+++ b/planet/api/client.py
@@ -16,8 +16,9 @@ import base64
 import json
 from .dispatch import RequestsDispatcher
 from . import auth
-from .exceptions import (InvalidIdentity, APIException)
+from .exceptions import InvalidIdentity
 from . import models
+from .utils import check_status
 
 
 class Client(object):
@@ -41,6 +42,11 @@ class Client(object):
                               body_type)
 
     def _get(self, path, body_type=models.JSON, params=None, callback=None):
+        # convert any JSON objects to text explicitly
+        for k, v in (params or {}).items():
+            if isinstance(v, dict):
+                params[k] = json.dumps(v)
+
         request = self._request(path, body_type, params)
         response = self.dispatcher.response(request)
         if callback:
@@ -56,18 +62,13 @@ class Client(object):
             'email': identity,
             'password': credentials
         }).result()
-        status = result.status_code
-        if status == 400:
-            raise APIException('invalid parameters, login process has changed')
-        elif status == 401:
+        if result.status_code == 400:
             # do our best to get something out to the user
             msg = result.text
             try:
                 msg = json.loads(result.text)['message']
             finally:
                 raise InvalidIdentity(msg)
-        elif status != 200:
-            raise APIException('%s: %s' % (status, result.text))
         jwt = result.text
         payload = jwt.split('.')[1]
         rem = len(payload) % 4
@@ -128,3 +129,31 @@ class Client(object):
             Mosaic name as returned by `list_mosaics`.
         """
         return self._get('mosaics/%s' % name).get_body()
+
+    def get_workspaces(self):
+        return self._get('workspaces').get_body()
+
+    def get_workspace(self, id):
+        return self._get('workspaces/%s' % id).get_body()
+
+    def set_workspace(self, workspace, id=None):
+        if id:
+            workspace['id'] = id
+            url = 'workspaces/%s' % id
+            method = 'PUT'
+        else:
+            'id' in workspace and workspace.pop('id')
+            url = 'workspaces/'
+            method = 'POST'
+        # without these, scenes UI breaks
+        defaults = {
+            "image_statistics.image_quality": {
+                "gte": 1
+            }
+        }
+        defaults.update(workspace)
+        result = self.dispatcher.dispatch_request(method, self.base_url + url,
+                                                  data=json.dumps(defaults),
+                                                  auth=self.auth)
+        check_status(result)
+        return models.JSON(None, result, self.dispatcher)
diff --git a/planet/api/dispatch.py b/planet/api/dispatch.py
index 27e2b09..7187c5b 100644
--- a/planet/api/dispatch.py
+++ b/planet/api/dispatch.py
@@ -14,6 +14,7 @@
 
 import re
 from requests_futures.sessions import FuturesSession
+from requests import Request
 from . utils import check_status
 from . models import Response
 from . exceptions import InvalidAPIKey
@@ -76,3 +77,14 @@ class RequestsDispatcher(object):
         response = self._dispatch_async(request, callback).result()
         check_status(response)
         return response
+
+    def dispatch_request(self, method, url, auth=None, params=None, data=None):
+        headers = {}
+        content_type = 'application/json'
+        if auth:
+            headers.update({
+                'Authorization': 'api-key %s' % auth.value,
+                'content-Type': content_type
+            })
+        req = Request(method, url, params=params, data=data, headers=headers)
+        return self.session.send(req.prepare())
diff --git a/planet/api/utils.py b/planet/api/utils.py
index ea90c2f..6a68f75 100644
--- a/planet/api/utils.py
+++ b/planet/api/utils.py
@@ -34,12 +34,74 @@ def read_planet_json():
     return contents
 
 
+def build_conditions(workspace):
+    '''Convert a workspace to conditions/filters that can be used in API
+    queries. Walks the workspace.filters object and returns a dict of query
+    parameters in the form of [metadata name].[comparator]:[value] pairs.
+    '''
+    conditions = {}
+    filters = workspace.get('filters')
+
+    keys = set(filters.keys())
+    keys.remove('geometry')
+
+    # workspace stores this as an int, API wants a label
+    rules = filters.get('image_statistics.image_quality', None)
+    if rules:
+        labels = ['test', 'standard', 'target']
+        for k, v in rules.items():
+            rules[k] = labels[int(v)]
+        filters['image_statistics.image_quality'] = rules
+
+    for key in keys:
+        rules = filters[key]
+        conditions.update([
+            ('%s.%s' % (key, r), v) for r, v in rules.items()
+        ])
+    return conditions
+
+
 def write_planet_json(contents):
     fname = _planet_json_file()
     with open(fname, 'w') as fp:
         fp.write(json.dumps(contents))
 
 
+def feature_collection(geometry):
+    return {
+        "type": "FeatureCollection",
+        "features": [{
+            "type": "Feature",
+            "properties": {},
+            "geometry": geometry
+        }]
+    }
+
+
+def get_workspace_geometry(workspace):
+    return workspace['filters'].get('geometry', {}).get('intersects', None)
+
+
+def geometry_from_json(obj):
+    '''try to find a geometry in the provided JSON object'''
+    obj_type = obj.get('type', None)
+    if not obj_type:
+        return None
+    if obj_type == 'FeatureCollection':
+        features = obj.get('features', [])
+        if len(features):
+            obj = obj['features'][0]
+            obj_type = obj.get('type', None)
+        else:
+            return None
+    if obj_type == 'Feature':
+        geom = obj['geometry']
+    else:
+        # @todo we're just assuming it's a geometry at this point
+        geom = obj
+    return geom
+
+
 def check_status(response):
     '''check the status of the response and if needed raise an APIException'''
     status = response.status_code
diff --git a/planet/scripts/__init__.py b/planet/scripts/__init__.py
index 020deac..02d95e8 100644
--- a/planet/scripts/__init__.py
+++ b/planet/scripts/__init__.py
@@ -16,6 +16,7 @@ import sys
 import time
 import json
 import logging
+import re
 import warnings
 from os import path
 
@@ -37,6 +38,7 @@ pretty = click.option('-pp', '--pretty', default=False, is_flag=True)
 scene_type = click.option('-s', '--scene-type', default='ortho')
 dest_dir = click.option('-d', '--dest', help='Destination directory',
                         type=click.Path(file_okay=False, resolve_path=True))
+workspace = click.option('--workspace')
 
 
 # monkey patch warnings module to hide InsecurePlatformWarning - the warning
@@ -109,6 +111,49 @@ def total_bytes(responses):
     return sum([len(r.get_body()) for r in responses])
 
 
+def echo_json_response(response, pretty):
+    res = response.get_raw()
+    if pretty:
+        res = json.dumps(json.loads(res), indent=2)
+    click.echo(res)
+
+
+def read(value, split=False):
+    '''Get the value of an option interpreting as a file implicitly or
+    explicitly and falling back to the value if not explicitly specified.
+    If the value is '@name', then a file must exist with name and the returned
+    value will be the contents of that file. If the value is '@-' or '-', then
+    stdin will be read and returned as the value. Finally, if a file exists
+    with the provided value, that file will be read. Otherwise, the value
+    will be returned.
+    '''
+    v = str(value)
+    retval = value
+    if v[0] == '@' or v == '-':
+        fname = '-' if v == '-' else v[1:]
+        try:
+            with click.open_file(fname) as fp:
+                if not fp.isatty():
+                    retval = fp.read()
+                else:
+                    retval = None
+        except IOError as ioe:
+            # if explicit and problems, raise
+            if v[0] == '@':
+                raise click.ClickException(str(ioe))
+    elif path.exists(v) and path.isfile(v):
+        with click.open_file(v) as fp:
+            retval = fp.read()
+    if retval and split and type(retval) != tuple:
+        retval = _split(retval.strip())
+    return retval
+
+
+def _split(value):
+    '''return input split on any whitespace'''
+    return re.split('\s+', value)
+
+
 @click.group()
 @click.option('-w', '--workers', default=4,
               help=('The number of concurrent downloads when requesting '
@@ -156,37 +201,37 @@ def init(email, password):
 @pretty
 @scene_type
 @cli.command('search')
-@click.argument("aoi", default="-", required=False)
+@click.argument('aoi', default='@-', required=False)
 @click.option('--count', type=click.INT, required=False,
               help="Set the number of returned scenes.")
-@click.option("--where", nargs=3, multiple=True,
-              help=("Provide additional search criteria. See "
-                    "https://www.planet.com/docs/v0/scenes/#metadata for "
-                    " search metadata fields."))
-def get_scenes_list(scene_type, pretty, aoi, count, where):
+@click.option('--where', nargs=3, multiple=True,
+              help=('Provide additional search criteria. See '
+                    'https://www.planet.com/docs/v0/scenes/#metadata for '
+                    'search metadata fields.'))
+@click.option('--workspace')
+def get_scenes_list(scene_type, pretty, aoi, count, where, workspace):
     '''Get a list of scenes'''
 
-    if aoi == "-":
-        src = click.open_file('-')
-        if not src.isatty():
-            lines = src.readlines()
-            aoi = ''.join([line.strip() for line in lines])
-        else:
-            aoi = None
+    aoi = read(aoi)
+    conditions = {}
+
+    if workspace:
+        workspace = call_and_wrap(client().get_workspace, workspace).get()
+        ws_filters = api.utils.build_conditions(workspace)
+        if aoi is None:
+            aoi = api.utils.get_workspace_geometry(workspace)
+            aoi = api.utils.feature_collection(aoi)
+        conditions.update(ws_filters)
 
     if where:
-        conditions = {
-            "%s.%s" % condition[0:2]: condition[2]
+        conditions.update([
+            ("%s.%s" % condition[0:2], condition[2])
             for condition in where
-        }
-    else:
-        conditions = {}
+        ])
 
-    res = call_and_wrap(client().get_scenes_list, scene_type=scene_type,
-                        intersects=aoi, count=count, **conditions).get_raw()
-    if pretty:
-        res = json.dumps(json.loads(res), indent=2)
-    click.echo(res)
+    echo_json_response(call_and_wrap(client().get_scenes_list,
+                       scene_type=scene_type, intersects=aoi, count=count,
+                       **conditions), pretty)
 
 
 @pretty
@@ -196,13 +241,8 @@ def get_scenes_list(scene_type, pretty, aoi, count, where):
 def metadata(scene_id, scene_type, pretty):
     '''Get scene metadata'''
 
-    res = call_and_wrap(client().get_scene_metadata,
-                        scene_id, scene_type).get_raw()
-
-    if pretty:
-        res = json.dumps(json.loads(res), indent=2)
-
-    click.echo(res)
+    echo_json_response(call_and_wrap(client().get_scene_metadata,
+                       scene_id, scene_type), pretty)
 
 
 @scene_type
@@ -214,18 +254,14 @@ def metadata(scene_id, scene_type, pretty):
                   ['visual', 'analytic', 'qa']
               ), default='visual')
 @cli.command('download')
-@click.pass_context
-def fetch_scene_geotiff(ctx, scene_ids, scene_type, product, dest):
+def fetch_scene_geotiff(scene_ids, scene_type, product, dest):
     """
     Download full scene image(s).
     """
 
-    if len(scene_ids) == 0:
-        src = click.open_file('-')
-        if not src.isatty():
-            scene_ids = map(lambda s: s.strip(), src.readlines())
-        else:
-            click.echo(ctx.get_usage())
+    scene_ids = read(scene_ids, split=True)
+    if not scene_ids:
+        return
 
     start_time = time.time()
     futures = client().fetch_scene_geotiffs(scene_ids, scene_type, product,
@@ -244,10 +280,9 @@ def fetch_scene_geotiff(ctx, scene_ids, scene_type, product, dest):
 def fetch_scene_thumbnails(scene_ids, scene_type, size, fmt, dest):
     '''Fetch scene thumbnail(s)'''
 
-    if len(scene_ids) == 0:
-        src = click.open_file('-')
-        if not src.isatty():
-            scene_ids = map(lambda s: s.strip(), src.readlines())
+    scene_ids = read(scene_ids, split=True)
+    if not scene_ids:
+        return
 
     futures = client().fetch_scene_thumbnails(scene_ids, scene_type, size, fmt,
                                               api.write_to_file(dest))
@@ -325,18 +360,79 @@ def sync(destination, scene_type, limit):
         summarize_throughput(transferred, start_time)
 
 
+@pretty
 @cli.command('mosaics')
-def list_mosaics():
+def list_mosaics(pretty):
     """
     List all mosaics
     """
-    click.echo(call_and_wrap(client().list_mosaics).get_raw())
+    echo_json_response(call_and_wrap(client().list_mosaics), pretty)
 
 
+@pretty
 @cli.command('mosaic')
 @click.argument('mosaic_name', nargs=1)
-def get_mosaic(mosaic_name):
+def get_mosaic(mosaic_name, pretty):
     """
     Describe a specified mosaic
     """
-    click.echo(call_and_wrap(client().get_mosaic, mosaic_name).get_raw())
+    echo_json_response(call_and_wrap(client().get_mosaic, mosaic_name), pretty)
+
+
+@pretty
+@cli.command('list-workspaces')
+def list_workspaces(pretty):
+    """
+    List workspaces.
+    """
+    echo_json_response(call_and_wrap(client().get_workspaces), pretty)
+
+
+@pretty
+@cli.command('get-workspace')
+@click.argument('id', nargs=1)
+def get_workspace(pretty, id):
+    """
+    Get workspace.
+    """
+    echo_json_response(call_and_wrap(client().get_workspace, id), pretty)
+
+
+@cli.command('set-workspace')
+@click.argument("workspace", default="@-", required=False)
+@click.option('--id', help='if provided, update the workspace with this id')
+@click.option('--aoi', help='the geometry to use')
+@click.option('--name')
+@click.option('--create', is_flag=True)
+def set_workspace(id, aoi, name, create, workspace):
+    '''Create or modify a workspace'''
+    workspace = read(workspace)
+    try:
+        workspace = json.loads(workspace) if workspace else {}
+    except ValueError:
+        raise click.ClickException('workspace must be JSON')
+    # what workspace id are we working with
+    if not id:
+        id = workspace.get('id', None)
+    if create:
+        id = None
+
+    aoi = read(aoi)
+    if aoi:
+        try:
+            aoi = json.loads(aoi)
+        except ValueError:
+            raise click.ClickException('workspace aoi must be JSON')
+        geom = api.utils.geometry_from_json(aoi)
+        if geom is None:
+            raise click.ClickException('unable to find geometry in aoi')
+        workspace['filters'] = {
+            'geometry': {
+                'intersects': geom
+            }
+        }
+    if name:
+        workspace['name'] = name
+
+    echo_json_response(call_and_wrap(client().set_workspace,
+                       workspace, id), pretty)


Test patch:
diff --git a/tests/_common.py b/tests/_common.py
new file mode 100644
index 0000000..951a192
--- /dev/null
+++ b/tests/_common.py
@@ -0,0 +1,28 @@
+# Copyright 2015 Planet Labs, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import json
+import os
+
+
+TEST_DIR = os.path.dirname(os.path.realpath(__file__))
+FIXTURE_DIR = os.path.join(TEST_DIR, 'fixtures')
+
+
+def read_fixture(name):
+    with open(os.path.join(FIXTURE_DIR, name), 'r') as src:
+        return src.read()
+
+
+def clone(j):
+    return json.loads(json.dumps(j))
diff --git a/tests/fixtures/workspace.json b/tests/fixtures/workspace.json
new file mode 100644
index 0000000..41a14d3
--- /dev/null
+++ b/tests/fixtures/workspace.json
@@ -0,0 +1,47 @@
+{
+  "id": 8675309,
+  "filters": {
+    "geometry": {
+      "intersects": {
+        "type": "Polygon",
+        "coordinates": [
+          [
+            [
+              -105.05148517036162,
+              40.502229334744584
+            ],
+            [
+              -105.18606768989285,
+              40.42099179539889
+            ],
+            [
+              -105.0456486835452,
+              40.36555813724945
+            ],
+            [
+              -105.05148517036162,
+              40.502229334744584
+            ]
+          ]
+        ]
+      }
+    },
+    "age": {
+      "lte": 63072000
+    },
+    "sat.alt": {
+      "gte": 200,
+      "lte": 650
+    },
+    "sat.off_nadir": {
+      "lte": 25
+    },
+    "image_statistics.image_quality": {
+      "gte": 1
+    },
+    "image_statistics.snr": {
+      "gt": 10
+    }
+  },
+  "name": "testing123"
+}
\ No newline at end of file
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 4cb4700..9c2aa40 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -19,8 +19,14 @@ focus should be on asserting any CLI logic prior to client method invocation
 lower level lib/client tests go in the test_mod suite
 '''
 
-import os
+from contextlib import contextmanager
 import json
+import os
+import sys
+try:
+    from StringIO import StringIO as Buffy
+except ImportError:
+    from io import BytesIO as Buffy
 
 from click import ClickException
 from click.testing import CliRunner
@@ -31,11 +37,10 @@ import planet
 from planet import api
 from planet.api import models
 from planet import scripts
+from _common import read_fixture
+from _common import clone
 
 
-TEST_DIR = os.path.dirname(os.path.realpath(__file__))
-FIXTURE_DIR = os.path.join(TEST_DIR, 'fixtures')
-
 client = MagicMock(name='client', spec=api.Client)
 scripts.client = lambda: client
 runner = CliRunner()
@@ -56,6 +61,52 @@ def assert_cli_exception(cause, expected):
         assert str(ex) == expected
 
 
+@contextmanager
+def stdin(content):
+    saved = sys.stdin
+    sys.stdin = Buffy(content.encode('utf-8'))
+    yield
+    sys.stdin = saved
+
+
+def test_read(tmpdir):
+    # no special files in arguments, expect what's been passed in
+    assert None == scripts.read(None)
+    assert 'foo' == scripts.read('foo')
+    assert (1,) == scripts.read((1,))
+
+    # same but with split
+    assert None == scripts.read(None, split=True)
+    assert ['foo'] == scripts.read('foo', split=True)
+    assert (1,) == scripts.read((1,), split=True)
+
+    # stdin specifiers
+    with stdin('text'):
+        assert 'text' == scripts.read('-')
+    with stdin('text'):
+        assert 'text' == scripts.read('@-')
+
+    # explicit file specifier
+    infile = tmpdir.join('infile')
+    infile.write('farb')
+    assert 'farb' == scripts.read('@%s' % infile)
+
+    # implied file
+    assert 'farb' == scripts.read('%s' % infile)
+
+    # failed explict file
+    try:
+        noexist = 'not-here-hopefully'
+        scripts.read('@%s' % noexist)
+        assert False
+    except ClickException as ex:
+        assert str(ex) == "[Errno 2] No such file or directory: '%s'" % noexist
+
+    # splitting
+    xs = scripts.read(' x\nx\r\nx\t\tx\t\n x ', split=True)
+    assert ['x'] * 5 == xs
+
+
 def test_exception_translation():
     assert_cli_exception(api.exceptions.BadQuery('bogus'), 'BadQuery: bogus')
     assert_cli_exception(api.exceptions.APIException('911: alert'),
@@ -83,9 +134,7 @@ def test_api_key_flag():
 
 def test_search():
 
-    fixture_path = os.path.join(FIXTURE_DIR, 'search.geojson')
-    with open(fixture_path, 'r') as src:
-        expected = src.read()
+    expected = read_fixture('search.geojson')
 
     response = MagicMock(spec=models.JSON)
     response.get_raw.return_value = expected
@@ -99,13 +148,8 @@ def test_search():
 
 def test_search_by_aoi():
 
-    aoi_path = os.path.join(FIXTURE_DIR, 'aoi.geojson')
-    with open(aoi_path, 'r') as src:
-        aoi = src.read()
-
-    fixture_path = os.path.join(FIXTURE_DIR, 'search-by-aoi.geojson')
-    with open(fixture_path, 'r') as src:
-        expected = src.read()
+    aoi = read_fixture('search-by-aoi.geojson')
+    expected = read_fixture('search-by-aoi.geojson')
 
     response = MagicMock(spec=models.JSON)
     response.get_raw.return_value = expected
@@ -121,9 +165,7 @@ def test_search_by_aoi():
 def test_metadata():
 
     # Read in fixture
-    fixture_path = os.path.join(FIXTURE_DIR, '20150615_190229_0905.geojson')
-    with open(fixture_path, 'r') as src:
-        expected = src.read()
+    expected = read_fixture('20150615_190229_0905.geojson')
 
     # Construct a response from the fixture
     response = MagicMock(spec=models.JSON)
@@ -160,3 +202,69 @@ def test_init():
         assert data['key'] == 'SECRIT'
     finally:
         os.unlink(test_file)
+
+
+def _set_workspace(workspace, *args, **kw):
+    response = MagicMock(spec=models.JSON)
+    response.get_raw.return_value = '{"status": "OK"}'
+
+    client.set_workspace.return_value = response
+    client.set_workspace.reset_mock()
+    args = ['set-workspace'] + list(args)
+    if workspace is not None:
+        args += [json.dumps(workspace)]
+    result = runner.invoke(scripts.cli, args, input=kw.get('input', None))
+    assert result.exit_code == kw.get('expected_status', 0)
+
+
+def test_workspace_create_no_id():
+
+    workspace = json.loads(read_fixture('workspace.json'))
+    workspace.pop('id')
+    expected = clone(workspace)
+    _set_workspace(workspace)
+    client.set_workspace.assert_called_once_with(expected, None)
+
+
+def test_workspace_create_from_existing():
+
+    workspace = json.loads(read_fixture('workspace.json'))
+    expected = clone(workspace)
+    _set_workspace(workspace, '--create')
+    client.set_workspace.assert_called_once_with(expected, None)
+
+
+def test_workspace_update_from_existing_with_id():
+
+    workspace = json.loads(read_fixture('workspace.json'))
+    expected = clone(workspace)
+    _set_workspace(workspace, '--id', '12345')
+    client.set_workspace.assert_called_once_with(expected, '12345')
+
+
+def test_workspace_update_stdin():
+    workspace = json.loads(read_fixture('workspace.json'))
+    expected = clone(workspace)
+    _set_workspace(workspace)
+    client.set_workspace.assert_called_once_with(expected, workspace['id'])
+
+
+def test_workspace_create_aoi_stdin():
+    geometry = {'type': 'Geometry'}
+    expected = {
+        'name': 'foobar',
+        'filters': {
+            'geometry': {
+                'intersects': geometry
+            }
+        }
+    }
+
+    # since the CLI wants to read from stdin for the 'workspace' arg,
+    # provide an empty workspace
+    _set_workspace({}, '--name', 'foobar', '--aoi', json.dumps(geometry))
+    client.set_workspace.assert_called_once_with(expected, None)
+
+    _set_workspace({}, '--name', 'foobar', '--aoi', '@-',
+                   input=json.dumps(geometry))
+    client.set_workspace.assert_called_once_with(expected, None)
diff --git a/tests/test_mod.py b/tests/test_mod.py
index d7543cd..53c5e0a 100644
--- a/tests/test_mod.py
+++ b/tests/test_mod.py
@@ -22,6 +22,8 @@ import json
 import os
 
 from planet import api
+from _common import read_fixture
+from _common import clone
 import pytest
 import requests_mock
 
@@ -88,7 +90,7 @@ def test_fetch_scene_info_scene_id(client):
     with requests_mock.Mocker() as m:
         uri = os.path.join(client.base_url, 'scenes/ortho/x22')
         m.get(uri, text='bananas', status_code=200)
-        client.get_scene_metadata('x22').get_raw() == 'bananas'
+        assert client.get_scene_metadata('x22').get_raw() == 'bananas'
 
 
 def test_login(client):
@@ -108,7 +110,7 @@ def test_login_failure(client):
     with requests_mock.Mocker() as m:
         uri = os.path.join(client.base_url, 'auth/login')
         response = json.dumps({'message': 'invalid'})
-        m.post(uri, text=response, status_code=401)
+        m.post(uri, text=response, status_code=400)
         try:
             client.login('jimmy', 'crackcorn')
         except api.exceptions.InvalidIdentity as ex:
@@ -117,20 +119,6 @@ def test_login_failure(client):
             assert False
 
 
-def test_login_errors(client):
-    '''Verify login functionality'''
-    with requests_mock.Mocker() as m:
-        uri = os.path.join(client.base_url, 'auth/login')
-        response = 'An error occurred'
-        m.post(uri, text=response, status_code=500)
-        try:
-            client.login('jimmy', 'crackcorn')
-        except api.exceptions.APIException as ex:
-            assert str(ex) == '500: %s' % response
-        else:
-            assert False
-
-
 def test_get_scenes_list(client):
     with requests_mock.Mocker() as m:
         uri = os.path.join(client.base_url, 'scenes/ortho')
@@ -155,3 +143,13 @@ def test_get_scenes_list(client):
         # total of 5 responses
         assert len(pages) == 5
         assert all([p is not None for p in pages])
+
+
+def test_workspace_set_create(client):
+    with requests_mock.Mocker() as m:
+        uri = os.path.join(client.base_url, 'workspaces/')
+        workspace = json.loads(read_fixture('workspace.json'))
+        request = clone(workspace)
+        request.pop('id')
+        m.post(uri, text=json.dumps(workspace), status_code=200)
+        assert client.set_workspace(request).get_raw() == json.dumps(workspace)
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..260b7e7
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,45 @@
+# Copyright 2015 Planet Labs, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import json
+from planet.api import utils
+from _common import read_fixture
+
+
+def test_geometry_from_json():
+    # base case, no geometry
+    assert None == utils.geometry_from_json({})
+    # from an empty feature collection
+    collection = {'type': 'FeatureCollection', 'features': []}
+    assert None == utils.geometry_from_json(collection)
+
+    # simple geometry, we're guessing by the type property w/ no further checks
+    geom = {'type': 'Polygon'}
+    assert geom == utils.geometry_from_json(geom)
+    # from a feature
+    feature = {'type': 'Feature', 'geometry': geom}
+    assert geom == utils.geometry_from_json(feature)
+    # from a feature collection
+    collection = {'type': 'FeatureCollection', 'features': [feature]}
+    assert geom == utils.geometry_from_json(collection)
+
+
+def test_build_conditions():
+    workspace = json.loads(read_fixture('workspace.json'))
+    c = utils.build_conditions(workspace)
+    assert c['image_statistics.image_quality.gte'] == 'standard'
+    assert c['image_statistics.snr.gt'] == 10
+    assert c['sat.off_nadir.lte'] == 25
+    assert c['sat.alt.gte'] == 200
+    assert c['sat.alt.lte'] == 650
+    assert c['age.lte'] == 63072000