Repository: planetlabs/planet-client-python

Issue:
add base_url to subscriptions client
Add base_url support to subscriptions client.

in client, construct urls from a base_url pulled from a global (which in turn is constructed from the planet.base_url constant).

NOTE: be sure to remove `root_url` variables in the subscription client code.  ([discussion](https://github.com/planetlabs/planet-client-python/pull/701#discussion_r986194642_))

This resolves some of #628 



Discussion:

Patch:
diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py
index 901a729..3343019 100644
--- a/planet/cli/subscriptions.py
+++ b/planet/cli/subscriptions.py
@@ -1,5 +1,5 @@
 """Subscriptions CLI"""
-
+from contextlib import asynccontextmanager
 import json
 
 import click
@@ -10,11 +10,22 @@ from .session import CliSession
 from planet.clients.subscriptions import SubscriptionsClient
 
 
+@asynccontextmanager
+async def subscriptions_client(ctx):
+    async with CliSession() as sess:
+        cl = SubscriptionsClient(sess, base_url=ctx.obj['BASE_URL'])
+        yield cl
+
+
 @click.group()
 @click.pass_context
-def subscriptions(ctx):
+@click.option('-u',
+              '--base-url',
+              default=None,
+              help='Assign custom base Subscriptions API URL.')
+def subscriptions(ctx, base_url):
     '''Commands for interacting with the Subscriptions API'''
-    pass
+    ctx.obj['BASE_URL'] = base_url
 
 
 # We want our command to be known as "list" on the command line but
@@ -45,8 +56,7 @@ def subscriptions(ctx):
 @coro
 async def list_subscriptions_cmd(ctx, status, limit, pretty):
     """Prints a sequence of JSON-encoded Subscription descriptions."""
-    async with CliSession() as session:
-        client = SubscriptionsClient(session)
+    async with subscriptions_client(ctx) as client:
         async for sub in client.list_subscriptions(status=status, limit=limit):
             echo_json(sub, pretty)
 
@@ -80,8 +90,7 @@ def parse_request(ctx, param, value: str) -> dict:
 @coro
 async def create_subscription_cmd(ctx, request, pretty):
     """Submits a subscription request and prints the API response."""
-    async with CliSession() as session:
-        client = SubscriptionsClient(session)
+    async with subscriptions_client(ctx) as client:
         sub = await client.create_subscription(request)
         echo_json(sub, pretty)
 
@@ -94,8 +103,7 @@ async def create_subscription_cmd(ctx, request, pretty):
 @coro
 async def cancel_subscription_cmd(ctx, subscription_id, pretty):
     """Cancels a subscription and prints the API response."""
-    async with CliSession() as session:
-        client = SubscriptionsClient(session)
+    async with subscriptions_client(ctx) as client:
         _ = await client.cancel_subscription(subscription_id)
 
 
@@ -108,8 +116,7 @@ async def cancel_subscription_cmd(ctx, subscription_id, pretty):
 @coro
 async def update_subscription_cmd(ctx, subscription_id, request, pretty):
     """Updates a subscription and prints the API response."""
-    async with CliSession() as session:
-        client = SubscriptionsClient(session)
+    async with subscriptions_client(ctx) as client:
         sub = await client.update_subscription(subscription_id, request)
         echo_json(sub, pretty)
 
@@ -122,8 +129,7 @@ async def update_subscription_cmd(ctx, subscription_id, request, pretty):
 @coro
 async def describe_subscription_cmd(ctx, subscription_id, pretty):
     """Gets the description of a subscription and prints the API response."""
-    async with CliSession() as session:
-        client = SubscriptionsClient(session)
+    async with subscriptions_client(ctx) as client:
         sub = await client.get_subscription(subscription_id)
         echo_json(sub, pretty)
 
@@ -158,8 +164,7 @@ async def list_subscription_results_cmd(ctx,
                                         status,
                                         limit):
     """Gets results of a subscription and prints the API response."""
-    async with CliSession() as session:
-        client = SubscriptionsClient(session)
+    async with subscriptions_client(ctx) as client:
         async for result in client.get_results(subscription_id,
                                                status=status,
                                                limit=limit):
diff --git a/planet/clients/subscriptions.py b/planet/clients/subscriptions.py
index ac9ea69..6f44259 100644
--- a/planet/clients/subscriptions.py
+++ b/planet/clients/subscriptions.py
@@ -6,6 +6,9 @@ from typing import AsyncIterator, Optional, Set
 from planet.exceptions import APIError, ClientError
 from planet.http import Session
 from planet.models import Paged
+from ..constants import PLANET_BASE_URL
+
+BASE_URL = f'{PLANET_BASE_URL}/subscriptions/v1/'
 
 LOGGER = logging.getLogger()
 
@@ -22,15 +25,23 @@ class SubscriptionsClient:
     exceptions raised by this class are categorized by HTTP client
     (4xx) or server (5xx) errors. This client's level of abstraction is
     low.
-
-    Attributes:
-        session (Session): an authenticated session which wraps the
-            low-level HTTP client.
     """
 
-    def __init__(self, session: Session) -> None:
+    def __init__(self,
+                 session: Session,
+                 base_url: Optional[str] = None) -> None:
+        """
+        Parameters:
+            session: Open session connected to server.
+            base_url: The base URL to use. Defaults to production subscriptions
+                API base url.
+        """
         self._session = session
 
+        self._base_url = base_url or BASE_URL
+        if self._base_url.endswith('/'):
+            self._base_url = self._base_url[:-1]
+
     async def list_subscriptions(self,
                                  status: Optional[Set[str]] = None,
                                  limit: int = 100) -> AsyncIterator[dict]:
@@ -62,11 +73,10 @@ class SubscriptionsClient:
             ITEMS_KEY = 'subscriptions'
 
         params = {'status': [val for val in status or {}]}
-        url = 'https://api.planet.com/subscriptions/v1'
 
         try:
             response = await self._session.request(method='GET',
-                                                   url=url,
+                                                   url=self._base_url,
                                                    params=params)
             async for sub in _SubscriptionsPager(response,
                                                  self._session.request,
@@ -93,11 +103,9 @@ class SubscriptionsClient:
             ClientError: on a client error.
         """
 
-        url = 'https://api.planet.com/subscriptions/v1'
-
         try:
             resp = await self._session.request(method='POST',
-                                               url=url,
+                                               url=self._base_url,
                                                json=request)
         # Forward APIError. We don't strictly need this clause, but it
         # makes our intent clear.
@@ -122,8 +130,7 @@ class SubscriptionsClient:
             APIError: on an API server error.
             ClientError: on a client error.
         """
-        root_url = 'https://api.planet.com/subscriptions/v1'
-        url = f'{root_url}/{subscription_id}/cancel'
+        url = f'{self._base_url}/{subscription_id}/cancel'
 
         try:
             _ = await self._session.request(method='POST', url=url)
@@ -149,7 +156,7 @@ class SubscriptionsClient:
             APIError: on an API server error.
             ClientError: on a client error.
         """
-        url = f'https://api.planet.com/subscriptions/v1/{subscription_id}'
+        url = f'{self._base_url}/{subscription_id}'
 
         try:
             resp = await self._session.request(method='PUT',
@@ -178,7 +185,7 @@ class SubscriptionsClient:
             APIError: on an API server error.
             ClientError: on a client error.
         """
-        url = f'https://api.planet.com/subscriptions/v1/{subscription_id}'
+        url = f'{self._base_url}/{subscription_id}'
 
         try:
             resp = await self._session.request(method='GET', url=url)
@@ -224,8 +231,7 @@ class SubscriptionsClient:
             ITEMS_KEY = 'results'
 
         params = {'status': [val for val in status or {}]}
-        root_url = 'https://api.planet.com/subscriptions/v1'
-        url = f'{root_url}/{subscription_id}/results'
+        url = f'{self._base_url}/{subscription_id}/results'
 
         try:
             resp = await self._session.request(method='GET',


Test patch:
diff --git a/tests/integration/test_subscriptions_api.py b/tests/integration/test_subscriptions_api.py
index 101125d..8b312ba 100644
--- a/tests/integration/test_subscriptions_api.py
+++ b/tests/integration/test_subscriptions_api.py
@@ -13,15 +13,18 @@ from planet.clients.subscriptions import SubscriptionsClient
 from planet.exceptions import APIError, PagingError, ServerError
 from planet.http import Session
 
+# M(url=TEST_URL) is case sensitive and matching a lower-case url, and it
+# requires that there not be a '/'
+TEST_URL = 'http://www.mocknotrealurl.com/api/path'
+
 # Mock router representing an API that returns only a 500
 # internal server error. The router is configured outside the test
 # to help keep our test more readable.
 failing_api_mock = respx.mock()
-failing_api_mock.route(
-    M(url__startswith='https://api.planet.com/subscriptions/v1')).mock(
-        return_value=Response(500, json={
-            "code": 0, "message": "string"
-        }))
+failing_api_mock.route(M(url__startswith=TEST_URL)).mock(
+    return_value=Response(500, json={
+        "code": 0, "message": "string"
+    }))
 
 
 def result_pages(status=None, size=40):
@@ -35,8 +38,7 @@ def result_pages(status=None, size=40):
         page = {'subscriptions': subs, '_links': {}}
         pm = datetime.now().isoformat()
         if len(subs) == size:
-            page['_links'][
-                'next'] = f'https://api.planet.com/subscriptions/v1?pm={pm}'
+            page['_links']['next'] = f'{TEST_URL}?pm={pm}'
         pages.append(page)
     return pages
 
@@ -48,26 +50,23 @@ def result_pages(status=None, size=40):
 api_mock = respx.mock(assert_all_called=False)
 
 # 1. Request for status: running. Response has three pages.
-api_mock.route(M(url__startswith='https://api.planet.com/subscriptions/v1'),
+api_mock.route(M(url=TEST_URL),
                M(params__contains={'status': 'running'})).mock(side_effect=[
                    Response(200, json=page)
                    for page in result_pages(status={'running'}, size=40)
                ])
 
 # 2. Request for status: failed. Response has a single empty page.
-api_mock.route(M(url__startswith='https://api.planet.com/subscriptions/v1'),
-               M(params__contains={'status': 'failed'})).mock(
-                   side_effect=[Response(200, json={'subscriptions': []})])
+api_mock.route(M(url=TEST_URL), M(params__contains={'status': 'failed'})).mock(
+    side_effect=[Response(200, json={'subscriptions': []})])
 
 # 3. Request for status: preparing. Response has a single empty page.
-api_mock.route(M(url__startswith='https://api.planet.com/subscriptions/v1'),
+api_mock.route(M(url=TEST_URL),
                M(params__contains={'status': 'preparing'})).mock(
                    side_effect=[Response(200, json={'subscriptions': []})])
 
 # 4. No status requested. Response is the same as for 1.
-api_mock.route(
-    M(url__startswith='https://api.planet.com/subscriptions/v1')
-).mock(
+api_mock.route(M(url=TEST_URL)).mock(
     side_effect=[Response(200, json=page) for page in result_pages(size=40)])
 
 
@@ -82,25 +81,21 @@ def modify_response(request):
 
 
 create_mock = respx.mock()
-create_mock.route(host='api.planet.com',
-                  path='/subscriptions/v1',
+create_mock.route(M(url=TEST_URL),
                   method='POST').mock(side_effect=modify_response)
 
 update_mock = respx.mock()
-update_mock.route(host='api.planet.com',
-                  path__regex=r'^/subscriptions/v1/(\w+)',
+update_mock.route(M(url=f'{TEST_URL}/test'),
                   method='PUT').mock(side_effect=modify_response)
 
 cancel_mock = respx.mock()
-cancel_mock.route(host='api.planet.com',
-                  path__regex=r'^/subscriptions/v1/(\w+)/cancel',
+cancel_mock.route(M(url=f'{TEST_URL}/test/cancel'),
                   method='POST').mock(side_effect=modify_response)
 
 # Mock the subscription description API endpoint.
 describe_mock = respx.mock()
 describe_mock.route(
-    host='api.planet.com',
-    path__regex=r'^/subscriptions/v1/(\w+)',
+    M(url=f'{TEST_URL}/test'),
     method='GET').mock(return_value=Response(200,
                                              json={
                                                  'id': '42',
@@ -121,7 +116,7 @@ def result_pages(status=None, size=40):
         page = {'results': results, '_links': {}}
         pm = datetime.now().isoformat()
         if len(results) == size:
-            url = f'https://api.planet.com/subscriptions/v1/42/results?pm={pm}'
+            url = f'{TEST_URL}/42/results?pm={pm}'
             page['_links']['next'] = url
         pages.append(page)
     return pages
@@ -135,22 +130,19 @@ res_api_mock = respx.mock(assert_all_called=False)
 
 # 1. Request for status: created. Response has three pages.
 res_api_mock.route(
-    M(url__startswith='https://api.planet.com/subscriptions/v1'),
+    M(url__startswith=TEST_URL),
     M(params__contains={'status': 'created'})).mock(side_effect=[
         Response(200, json=page)
         for page in result_pages(status={'created'}, size=40)
     ])
 
 # 2. Request for status: queued. Response has a single empty page.
-res_api_mock.route(
-    M(url__startswith='https://api.planet.com/subscriptions/v1'),
-    M(params__contains={'status': 'queued'})).mock(
-        side_effect=[Response(200, json={'results': []})])
+res_api_mock.route(M(url__startswith=TEST_URL),
+                   M(params__contains={'status': 'queued'})).mock(
+                       side_effect=[Response(200, json={'results': []})])
 
 # 3. No status requested. Response is the same as for 1.
-res_api_mock.route(
-    M(url__startswith='https://api.planet.com/subscriptions/v1')
-).mock(
+res_api_mock.route(M(url__startswith=TEST_URL)).mock(
     side_effect=[Response(200, json=page) for page in result_pages(size=40)])
 
 
@@ -160,7 +152,7 @@ async def test_list_subscriptions_failure():
     """ServerError is raised if there is an internal server error (500)."""
     with pytest.raises(ServerError):
         async with Session() as session:
-            client = SubscriptionsClient(session)
+            client = SubscriptionsClient(session, base_url=TEST_URL)
             _ = [sub async for sub in client.list_subscriptions()]
 
 
@@ -174,7 +166,7 @@ async def test_list_subscriptions_success(
 ):
     """Account subscriptions iterator yields expected descriptions."""
     async with Session() as session:
-        client = SubscriptionsClient(session)
+        client = SubscriptionsClient(session, base_url=TEST_URL)
         assert len([
             sub async for sub in client.list_subscriptions(status=status)
         ]) == count
@@ -186,7 +178,7 @@ async def test_create_subscription_failure():
     """APIError is raised if there is a server error."""
     with pytest.raises(ServerError):
         async with Session() as session:
-            client = SubscriptionsClient(session)
+            client = SubscriptionsClient(session, base_url=TEST_URL)
             _ = await client.create_subscription({"lol": "wut"})
 
 
@@ -195,7 +187,7 @@ async def test_create_subscription_failure():
 async def test_create_subscription_success():
     """Subscription is created, description has the expected items."""
     async with Session() as session:
-        client = SubscriptionsClient(session)
+        client = SubscriptionsClient(session, base_url=TEST_URL)
         sub = await client.create_subscription({
             'name': 'test', 'delivery': 'yes, please', 'source': 'test'
         })
@@ -208,7 +200,7 @@ async def test_cancel_subscription_failure():
     """APIError is raised if there is a server error."""
     with pytest.raises(ServerError):
         async with Session() as session:
-            client = SubscriptionsClient(session)
+            client = SubscriptionsClient(session, base_url=TEST_URL)
             _ = await client.cancel_subscription("lolwut")
 
 
@@ -217,7 +209,7 @@ async def test_cancel_subscription_failure():
 async def test_cancel_subscription_success():
     """Subscription is canceled, description has the expected items."""
     async with Session() as session:
-        client = SubscriptionsClient(session)
+        client = SubscriptionsClient(session, base_url=TEST_URL)
         _ = await client.cancel_subscription("test")
 
 
@@ -227,7 +219,7 @@ async def test_update_subscription_failure():
     """APIError is raised if there is a server error."""
     with pytest.raises(ServerError):
         async with Session() as session:
-            client = SubscriptionsClient(session)
+            client = SubscriptionsClient(session, base_url=TEST_URL)
             _ = await client.update_subscription("lolwut", {})
 
 
@@ -236,7 +228,7 @@ async def test_update_subscription_failure():
 async def test_update_subscription_success():
     """Subscription is created, description has the expected items."""
     async with Session() as session:
-        client = SubscriptionsClient(session)
+        client = SubscriptionsClient(session, base_url=TEST_URL)
         sub = await client.update_subscription(
             "test", {
                 'name': 'test', 'delivery': "no, thanks", 'source': 'test'
@@ -250,7 +242,7 @@ async def test_get_subscription_failure():
     """APIError is raised if there is a server error."""
     with pytest.raises(ServerError):
         async with Session() as session:
-            client = SubscriptionsClient(session)
+            client = SubscriptionsClient(session, base_url=TEST_URL)
             _ = await client.get_subscription("lolwut")
 
 
@@ -259,7 +251,7 @@ async def test_get_subscription_failure():
 async def test_get_subscription_success(monkeypatch):
     """Subscription description fetched, has the expected items."""
     async with Session() as session:
-        client = SubscriptionsClient(session)
+        client = SubscriptionsClient(session, base_url=TEST_URL)
         sub = await client.get_subscription("test")
         assert sub['delivery'] == "yes, please"
 
@@ -270,7 +262,7 @@ async def test_get_results_failure():
     """APIError is raised if there is a server error."""
     with pytest.raises(APIError):
         async with Session() as session:
-            client = SubscriptionsClient(session)
+            client = SubscriptionsClient(session, base_url=TEST_URL)
             _ = [res async for res in client.get_results("lolwut")]
 
 
@@ -279,7 +271,7 @@ async def test_get_results_failure():
 async def test_get_results_success():
     """Subscription description fetched, has the expected items."""
     async with Session() as session:
-        client = SubscriptionsClient(session)
+        client = SubscriptionsClient(session, base_url=TEST_URL)
         results = [res async for res in client.get_results("42")]
         assert len(results) == 100
 
@@ -287,28 +279,24 @@ async def test_get_results_success():
 paging_cycle_api_mock = respx.mock()
 
 # Identical next links is a hangup we want to avoid.
-paging_cycle_api_mock.route(
-    M(url__startswith='https://api.planet.com/subscriptions/v1')).mock(
-        side_effect=[
-            Response(200,
-                     json={
-                         'subscriptions': [{
-                             'id': '1'
-                         }],
-                         '_links': {
-                             "next": 'https://api.planet.com/subscriptions/v1'
-                         }
-                     }),
-            Response(200,
-                     json={
-                         'subscriptions': [{
-                             'id': '2'
-                         }],
-                         '_links': {
-                             "next": 'https://api.planet.com/subscriptions/v1'
-                         }
-                     })
-        ])
+paging_cycle_api_mock.route(M(url__startswith=TEST_URL)).mock(side_effect=[
+    Response(200,
+             json={
+                 'subscriptions': [{
+                     'id': '1'
+                 }], '_links': {
+                     "next": TEST_URL
+                 }
+             }),
+    Response(200,
+             json={
+                 'subscriptions': [{
+                     'id': '2'
+                 }], '_links': {
+                     "next": TEST_URL
+                 }
+             })
+])
 
 
 @pytest.mark.asyncio
@@ -317,5 +305,5 @@ async def test_list_subscriptions_cycle_break():
     """PagingError is raised if there is a paging cycle."""
     with pytest.raises(PagingError):
         async with Session() as session:
-            client = SubscriptionsClient(session)
+            client = SubscriptionsClient(session, base_url=TEST_URL)
             _ = [sub async for sub in client.list_subscriptions()]
diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py
index 0a4b97c..f7da31e 100644
--- a/tests/integration/test_subscriptions_cli.py
+++ b/tests/integration/test_subscriptions_cli.py
@@ -26,8 +26,8 @@ from test_subscriptions_api import (api_mock,
                                     update_mock,
                                     cancel_mock,
                                     describe_mock,
-                                    res_api_mock)
-
+                                    res_api_mock,
+                                    TEST_URL)
 
 # CliRunner doesn't agree with empty options, so a list of option
 # combinations which omit the empty options is best. For example,
@@ -36,6 +36,19 @@ from test_subscriptions_api import (api_mock,
 # CliRunner().invoke(cli.main, args=['subscriptions', 'list', limit]
 #
 # does not work.
+
+
+@pytest.fixture
+def invoke():
+
+    def _invoke(extra_args, runner=None, **kwargs):
+        runner = runner or CliRunner()
+        args = ['subscriptions', f'--base-url={TEST_URL}'] + extra_args
+        return runner.invoke(cli.main, args=args, **kwargs)
+
+    return _invoke
+
+
 @pytest.mark.parametrize('options,expected_count',
                          [(['--status=running'], 100), ([], 100),
                           (['--limit=1', '--status=running'], 1),
@@ -43,13 +56,11 @@ from test_subscriptions_api import (api_mock,
                           (['--limit=1', '--status=preparing'], 0)])
 @api_mock
 # Remember, parameters come before fixtures in the function definition.
-def test_subscriptions_list_options(options, expected_count):
+def test_subscriptions_list_options(invoke, options, expected_count):
     """Prints the expected sequence of subscriptions."""
     # While developing it is handy to have click's command invoker
     # *not* catch exceptions, so we can use the pytest --pdb option.
-    result = CliRunner().invoke(cli.main,
-                                args=['subscriptions', 'list'] + options,
-                                catch_exceptions=False)
+    result = invoke(['list'] + options, catch_exceptions=False)
     assert result.exit_code == 0  # success.
 
     # For a start, counting the number of "id" strings in the output
@@ -58,7 +69,7 @@ def test_subscriptions_list_options(options, expected_count):
 
 
 @failing_api_mock
-def test_subscriptions_create_failure():
+def test_subscriptions_create_failure(invoke):
     """An invalid subscription request fails to create a new subscription."""
     # This subscription request lacks the required "delivery" and
     # "source" members.
@@ -66,9 +77,8 @@ def test_subscriptions_create_failure():
 
     # The "-" argument says "read from stdin" and the input keyword
     # argument specifies what bytes go to the runner's stdin.
-    result = CliRunner().invoke(
-        cli.main,
-        args=['subscriptions', 'create', '-'],
+    result = invoke(
+        ['create', '-'],
         input=json.dumps(sub),
         # Note: catch_exceptions=True (the default) is required if we want
         # to exercise the "translate_exceptions" decorator and test for
@@ -89,13 +99,13 @@ GOOD_SUB_REQUEST = {'name': 'lol', 'delivery': True, 'source': 'wut'}
                          [('-', json.dumps(GOOD_SUB_REQUEST)),
                           (json.dumps(GOOD_SUB_REQUEST), None)])
 @create_mock
-def test_subscriptions_create_success(cmd_arg, runner_input):
+def test_subscriptions_create_success(invoke, cmd_arg, runner_input):
     """Subscriptions creation succeeds with a valid subscription request."""
+
     # The "-" argument says "read from stdin" and the input keyword
     # argument specifies what bytes go to the runner's stdin.
-    result = CliRunner().invoke(
-        cli.main,
-        args=['subscriptions', 'create', cmd_arg],
+    result = invoke(
+        ['create', cmd_arg],
         input=runner_input,
         # Note: catch_exceptions=True (the default) is required if we want
         # to exercise the "translate_exceptions" decorator and test for
@@ -111,13 +121,12 @@ BAD_SUB_REQUEST = '{0: "lolwut"}'
 
 @pytest.mark.parametrize('cmd_arg, runner_input', [('-', BAD_SUB_REQUEST),
                                                    (BAD_SUB_REQUEST, None)])
-def test_subscriptions_bad_request(cmd_arg, runner_input):
+def test_subscriptions_bad_request(invoke, cmd_arg, runner_input):
     """Short circuit and print help message if request is bad."""
     # The "-" argument says "read from stdin" and the input keyword
     # argument specifies what bytes go to the runner's stdin.
-    result = CliRunner().invoke(
-        cli.main,
-        args=['subscriptions', 'create', cmd_arg],
+    result = invoke(
+        ['create', cmd_arg],
         input=runner_input,
         # Note: catch_exceptions=True (the default) is required if we want
         # to exercise the "translate_exceptions" decorator and test for
@@ -129,11 +138,10 @@ def test_subscriptions_bad_request(cmd_arg, runner_input):
 
 
 @failing_api_mock
-def test_subscriptions_cancel_failure():
+def test_subscriptions_cancel_failure(invoke):
     """Cancel command exits gracefully from an API error."""
-    result = CliRunner().invoke(
-        cli.main,
-        args=['subscriptions', 'cancel', '42'],
+    result = invoke(
+        ['cancel', 'test'],
         # Note: catch_exceptions=True (the default) is required if we want
         # to exercise the "translate_exceptions" decorator and test for
         # failure.
@@ -143,11 +151,10 @@ def test_subscriptions_cancel_failure():
 
 
 @cancel_mock
-def test_subscriptions_cancel_success():
+def test_subscriptions_cancel_success(invoke):
     """Cancel command succeeds."""
-    result = CliRunner().invoke(
-        cli.main,
-        args=['subscriptions', 'cancel', '42'],
+    result = invoke(
+        ['cancel', 'test'],
         # Note: catch_exceptions=True (the default) is required if we want
         # to exercise the "translate_exceptions" decorator and test for
         # failure.
@@ -157,11 +164,10 @@ def test_subscriptions_cancel_success():
 
 
 @failing_api_mock
-def test_subscriptions_update_failure():
+def test_subscriptions_update_failure(invoke):
     """Update command exits gracefully from an API error."""
-    result = CliRunner().invoke(
-        cli.main,
-        args=['subscriptions', 'update', '42', json.dumps(GOOD_SUB_REQUEST)],
+    result = invoke(
+        ['update', 'test', json.dumps(GOOD_SUB_REQUEST)],
         # Note: catch_exceptions=True (the default) is required if we want
         # to exercise the "translate_exceptions" decorator and test for
         # failure.
@@ -171,14 +177,13 @@ def test_subscriptions_update_failure():
 
 
 @update_mock
-def test_subscriptions_update_success():
+def test_subscriptions_update_success(invoke):
     """Update command succeeds."""
     request = GOOD_SUB_REQUEST.copy()
     request['name'] = 'new_name'
 
-    result = CliRunner().invoke(
-        cli.main,
-        args=['subscriptions', 'update', '42', json.dumps(request)],
+    result = invoke(
+        ['update', 'test', json.dumps(request)],
         # Note: catch_exceptions=True (the default) is required if we want
         # to exercise the "translate_exceptions" decorator and test for
         # failure.
@@ -189,11 +194,10 @@ def test_subscriptions_update_success():
 
 
 @failing_api_mock
-def test_subscriptions_describe_failure():
+def test_subscriptions_describe_failure(invoke):
     """Describe command exits gracefully from an API error."""
-    result = CliRunner().invoke(
-        cli.main,
-        args=['subscriptions', 'describe', '42'],
+    result = invoke(
+        ['describe', 'test'],
         # Note: catch_exceptions=True (the default) is required if we want
         # to exercise the "translate_exceptions" decorator and test for
         # failure.
@@ -203,11 +207,10 @@ def test_subscriptions_describe_failure():
 
 
 @describe_mock
-def test_subscriptions_describe_success():
+def test_subscriptions_describe_success(invoke):
     """Describe command succeeds."""
-    result = CliRunner().invoke(
-        cli.main,
-        args=['subscriptions', 'describe', '42'],
+    result = invoke(
+        ['describe', 'test'],
         # Note: catch_exceptions=True (the default) is required if we want
         # to exercise the "translate_exceptions" decorator and test for
         # failure.
@@ -218,11 +221,10 @@ def test_subscriptions_describe_success():
 
 
 @failing_api_mock
-def test_subscriptions_results_failure():
+def test_subscriptions_results_failure(invoke):
     """Results command exits gracefully from an API error."""
-    result = CliRunner().invoke(
-        cli.main,
-        args=['subscriptions', 'results', '42'],
+    result = invoke(
+        ['results', 'test'],
         # Note: catch_exceptions=True (the default) is required if we want
         # to exercise the "translate_exceptions" decorator and test for
         # failure.
@@ -238,11 +240,10 @@ def test_subscriptions_results_failure():
                           (['--limit=1', '--status=queued'], 0)])
 @res_api_mock
 # Remember, parameters come before fixtures in the function definition.
-def test_subscriptions_results_success(options, expected_count):
+def test_subscriptions_results_success(invoke, options, expected_count):
     """Describe command succeeds."""
-    result = CliRunner().invoke(
-        cli.main,
-        args=['subscriptions', 'results', '42'] + options,
+    result = invoke(
+        ['results', 'test'] + options,
         # Note: catch_exceptions=True (the default) is required if we want
         # to exercise the "translate_exceptions" decorator and test for
         # failure.