Repository: planetlabs/planet-client-python

Issue:
change auth base url to the api url
The orders client base url points to the orders API endpoint. Changing the base url replaces the entire url path. Right now, the auth client base url is just the root url, and the relative path to the auth API endpoint is added in the client. Changing the base url only changes the root url. Bring the functionality of the auth client (and, therefore, auth CLI) into alignment with the functionality of the orders api: that is, change the auth base url to the api endpoint.

Discussion:

Patch:
diff --git a/planet/auth.py b/planet/auth.py
index 4730128..726e367 100644
--- a/planet/auth.py
+++ b/planet/auth.py
@@ -22,16 +22,20 @@ import httpx
 import jwt
 
 from . import constants, http, models
-from .exceptions import AuthException
 
 
 LOGGER = logging.getLogger(__name__)
 
-BASE_URL = constants.PLANET_BASE_URL
+BASE_URL = constants.PLANET_BASE_URL + 'v0/auth/'
 ENV_API_KEY = 'PL_API_KEY'
 SECRET_FILE_PATH = os.path.join(os.path.expanduser('~'), '.planet.json')
 
 
+class AuthException(Exception):
+    '''exceptions thrown by Auth'''
+    pass
+
+
 class Auth(metaclass=abc.ABCMeta):
     '''Handle authentication information for use with Planet APIs.'''
     @staticmethod
@@ -161,8 +165,6 @@ class AuthClient():
         if not self._base_url.endswith('/'):
             self._base_url += '/'
 
-        self._auth_url = self._base_url + 'v0/auth/'
-
     def login(
         self,
         email: str,
@@ -181,7 +183,7 @@ class AuthClient():
              A JSON object containing an `api_key` property with the user's
         API_KEY.
         '''
-        url = self._auth_url + 'login'
+        url = self._base_url + 'login'
         data = {'email': email,
                 'password': password
                 }
diff --git a/planet/cli/auth.py b/planet/cli/auth.py
index 1f5aeae..f691c7f 100644
--- a/planet/cli/auth.py
+++ b/planet/cli/auth.py
@@ -17,7 +17,7 @@ import logging
 import click
 
 import planet
-from .cmds import translate_exceptions
+from .utils import handle_exceptions
 
 LOGGER = logging.getLogger(__name__)
 
@@ -34,7 +34,7 @@ def auth(ctx, base_url):
 
 @auth.command()
 @click.pass_context
-@translate_exceptions
+@handle_exceptions
 @click.option('--email', default=None, prompt=True, help=(
     'The email address associated with your Planet credentials.'
 ))
@@ -50,7 +50,16 @@ def init(ctx, email, password):
 
 
 @auth.command()
-@translate_exceptions
 def value():
     '''Print the stored authentication information'''
-    click.echo(planet.Auth.from_file().value)
+    click.echo(get_auth().value)
+
+
+def get_auth():
+    try:
+        auth = planet.Auth.from_file()
+    except planet.auth.AuthException:
+        raise click.ClickException(
+            'Auth information does not exist or is corrupted. Initialize '
+            'with `planet auth init`.')
+    return auth
diff --git a/planet/cli/cli.py b/planet/cli/cli.py
index db73499..c4b3657 100644
--- a/planet/cli/cli.py
+++ b/planet/cli/cli.py
@@ -11,7 +11,6 @@
 # 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.
-"""CLI main entry point"""
 import logging
 import sys
 
diff --git a/planet/cli/cmds.py b/planet/cli/cmds.py
deleted file mode 100644
index 9657eae..0000000
--- a/planet/cli/cmds.py
+++ /dev/null
@@ -1,61 +0,0 @@
-# Copyright 2022 Planet Labs, PBC.
-#
-# 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.
-"""Decorators for Click commands"""
-import asyncio
-from functools import wraps
-
-import click
-
-from planet import exceptions
-
-
-# https://github.com/pallets/click/issues/85#issuecomment-503464628
-def coro(func):
-    """Wrap async functions so they can be run sync with Click.
-
-    Parameters:
-        func: a Click command function or wrapper around one.
-
-    Returns:
-        wrapper function
-    """
-    @wraps(func)
-    def wrapper(*args, **kwargs):
-        return asyncio.run(func(*args, **kwargs))
-    return wrapper
-
-
-def translate_exceptions(func):
-    """Translate internal exceptions to ClickException.
-
-    Parameters:
-        func: a Click command function or wrapper around one.
-
-    Returns:
-        wrapper function
-
-    Raises:
-        ClickException
-    """
-    @wraps(func)
-    def wrapper(*args, **kwargs):
-        try:
-            func(*args, **kwargs)
-        except exceptions.APIException as ex:
-            raise click.ClickException(ex)
-        except exceptions.AuthException:
-            raise click.ClickException(
-                'Auth information does not exist or is corrupted. Initialize '
-                'with `planet auth init`.')
-    return wrapper
diff --git a/planet/cli/io.py b/planet/cli/io.py
deleted file mode 100644
index 9b2c798..0000000
--- a/planet/cli/io.py
+++ /dev/null
@@ -1,34 +0,0 @@
-# Copyright 2022 Planet Labs, PBC.
-#
-# 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.
-"""Helpers for CLI I/O"""
-import json
-
-import click
-
-
-def echo_json(obj: object, pretty: bool = False) -> None:
-    """
-
-    Parameters:
-        obj: any object serializeable to JSON
-        pretty: whether to reformat for easy visualization
-
-    Returns:
-        None
-    """
-    if pretty:
-        json_str = json.dumps(obj, indent=2, sort_keys=True)
-        click.echo(json_str)
-    else:
-        click.echo(json.dumps(obj))
diff --git a/planet/cli/orders.py b/planet/cli/orders.py
index ab03c78..0d9be31 100644
--- a/planet/cli/orders.py
+++ b/planet/cli/orders.py
@@ -21,8 +21,7 @@ import click
 import planet
 from planet import OrdersClient, Session  # allow mocking
 
-from .cmds import coro, translate_exceptions
-from .io import echo_json
+from .utils import coro, get_auth, handle_exceptions, json_echo
 
 LOGGER = logging.getLogger(__name__)
 
@@ -46,14 +45,14 @@ async def orders_client(ctx):
               help='Assign custom base Orders API URL.')
 def orders(ctx, base_url):
     '''Commands for interacting with the Orders API'''
-    auth = planet.Auth.from_file()
+    auth = get_auth()
     ctx.obj['AUTH'] = auth
     ctx.obj['BASE_URL'] = base_url
 
 
 @orders.command()
 @click.pass_context
-@translate_exceptions
+@handle_exceptions
 @coro
 @click.option('-s', '--state',
               help='Filter orders to given state.',
@@ -67,12 +66,12 @@ async def list(ctx, state, limit, pretty):
     async with orders_client(ctx) as cl:
         orders = await cl.list_orders(state=state, limit=limit, as_json=True)
 
-    echo_json(orders, pretty)
+    json_echo(orders, pretty)
 
 
 @orders.command()
 @click.pass_context
-@translate_exceptions
+@handle_exceptions
 @coro
 @click.argument('order_id', type=click.UUID)
 @pretty
@@ -81,12 +80,12 @@ async def get(ctx, order_id, pretty):
     async with orders_client(ctx) as cl:
         order = await cl.get_order(str(order_id))
 
-    echo_json(order.json, pretty)
+    json_echo(order.json, pretty)
 
 
 @orders.command()
 @click.pass_context
-@translate_exceptions
+@handle_exceptions
 @coro
 @click.argument('order_id', type=click.UUID)
 async def cancel(ctx, order_id):
@@ -99,7 +98,7 @@ async def cancel(ctx, order_id):
 
 @orders.command()
 @click.pass_context
-@translate_exceptions
+@handle_exceptions
 @coro
 @click.argument('order_id', type=click.UUID)
 @click.option('-q', '--quiet', is_flag=True, default=False,
@@ -166,7 +165,7 @@ def read_file_json(ctx, param, value):
 
 @orders.command()
 @click.pass_context
-@translate_exceptions
+@handle_exceptions
 @coro
 @click.option('--name', required=True)
 @click.option('--id', 'ids', help='One or more comma-separated item IDs',
@@ -230,4 +229,4 @@ async def create(ctx, name, ids, bundle, item_type, email, cloudconfig, clip,
     async with orders_client(ctx) as cl:
         order = await cl.create_order(request)
 
-    echo_json(order.json, pretty)
+    json_echo(order.json, pretty)
diff --git a/planet/cli/utils.py b/planet/cli/utils.py
new file mode 100644
index 0000000..9f0bbee
--- /dev/null
+++ b/planet/cli/utils.py
@@ -0,0 +1,58 @@
+# Copyright 2022 Planet Labs, PBC.
+#
+# 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.
+"""Utilities for CLI modules"""
+import asyncio
+from functools import wraps
+import json
+
+import click
+
+import planet
+
+
+# https://github.com/pallets/click/issues/85#issuecomment-503464628
+def coro(f):
+    '''Wraps async functions so they can be run sync with Click.'''
+    @wraps(f)
+    def wrapper(*args, **kwargs):
+        return asyncio.run(f(*args, **kwargs))
+    return wrapper
+
+
+def handle_exceptions(f):
+    @wraps(f)
+    def wrapper(*args, **kwargs):
+        try:
+            f(*args, **kwargs)
+        except planet.exceptions.APIException as ex:
+            raise click.ClickException(ex)
+    return wrapper
+
+
+def get_auth():
+    try:
+        auth = planet.Auth.from_file()
+    except planet.auth.AuthException:
+        raise click.ClickException(
+            'Auth information does not exist or is corrupted. Initialize '
+            'with `planet auth init`.')
+    return auth
+
+
+def json_echo(json_dict, pretty):
+    if pretty:
+        json_str = json.dumps(json_dict, indent=2, sort_keys=True)
+        click.echo(json_str)
+    else:
+        click.echo(json.dumps(json_dict))
diff --git a/planet/exceptions.py b/planet/exceptions.py
index a9ede52..70480ca 100644
--- a/planet/exceptions.py
+++ b/planet/exceptions.py
@@ -13,13 +13,8 @@
 # limitations under the License.
 
 
-class PlanetException(Exception):
-    """Root for all exceptions thrown by the SDK"""
-    pass
-
-
-class APIException(PlanetException):
-    '''General unexpected API response'''
+class APIException(Exception):
+    '''General unexpected response'''
     @property
     def message(self):
         return self.args[0]
@@ -70,11 +65,6 @@ class InvalidIdentity(APIException):
     pass
 
 
-class RequestCancelled(PlanetException):
+class RequestCancelled(Exception):
     '''Internal exception when a request is cancelled'''
     pass
-
-
-class AuthException(PlanetException):
-    '''Exceptions encountered during authentication'''
-    pass


Test patch:
diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py
index a23510d..0a6e532 100644
--- a/tests/integration/test_auth_api.py
+++ b/tests/integration/test_auth_api.py
@@ -1,4 +1,4 @@
-# Copyright 2021 Planet Labs, Inc.
+# Copyright 2021 Planet Labs, PBC.
 #
 # 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
@@ -24,14 +24,13 @@ from planet.auth import AuthClient
 
 
 TEST_URL = 'http://MockNotRealURL/'
-AUTH_URL = TEST_URL + 'v0/auth/'
 
 LOGGER = logging.getLogger(__name__)
 
 
 @respx.mock
 def test_AuthClient_success():
-    login_url = AUTH_URL + 'login'
+    login_url = TEST_URL + 'login'
 
     payload = {'api_key': 'iamakey'}
     resp = {'token': jwt.encode(payload, 'key')}
@@ -46,7 +45,7 @@ def test_AuthClient_success():
 
 @respx.mock
 def test_AuthClient_invalid_email():
-    login_url = AUTH_URL + 'login'
+    login_url = TEST_URL + 'login'
 
     resp = {
         "errors": {
@@ -69,7 +68,7 @@ def test_AuthClient_invalid_email():
 
 @respx.mock
 def test_AuthClient_invalid_password():
-    login_url = AUTH_URL + 'login'
+    login_url = TEST_URL + 'login'
 
     resp = {
         "errors": None,
diff --git a/tests/integration/test_auth_cli.py b/tests/integration/test_auth_cli.py
new file mode 100644
index 0000000..02a57f0
--- /dev/null
+++ b/tests/integration/test_auth_cli.py
@@ -0,0 +1,55 @@
+# Copyright 2022 Planet Labs, PBC.
+#
+# 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.
+from http import HTTPStatus
+from unittest.mock import MagicMock
+
+from click.testing import CliRunner
+import httpx
+import jwt
+import pytest
+import respx
+
+import planet
+from planet.cli import cli
+
+TEST_URL = 'http://MockNotRealURL/'
+
+
+@pytest.fixture(autouse=True)
+def patch_session(monkeypatch):
+    '''Make sure we don't actually make any http calls'''
+    monkeypatch.setattr(planet, 'Session', MagicMock(spec=planet.Session))
+
+
+@respx.mock
+@pytest.mark.asyncio
+def test_cli_auth_init_base_url():
+    '''Test base url option
+
+    Uses the auth init path to ensure the base url is changed to the mocked
+    url. So, ends up testing the auth init path somewhat as well
+    '''
+    login_url = TEST_URL + 'login'
+
+    payload = {'api_key': 'iamakey'}
+    resp = {'token': jwt.encode(payload, 'key')}
+    mock_resp = httpx.Response(HTTPStatus.OK, json=resp)
+    respx.post(login_url).return_value = mock_resp
+
+    result = CliRunner().invoke(
+        cli.main,
+        args=['auth', '--base-url', TEST_URL, 'init'],
+        input='email\npw\n')
+
+    assert not result.exception