Repository: planetlabs/planet-client-python

Issue:
Add a method to get a client from a Session instance
As in AWS boto3: https://boto3.amazonaws.com/v1/documentation/api/latest/guide/session.html#custom-session. For planet, this would look like

```python
session = planet.Session(...)
client = session.client("data")
```

Currently, we need to do the following.

```
session = planet.Session(...)
client = DataClient(session)
```

There's nothing wrong with this kind of dependency injection, and we're going to keep this usage. But what I'm proposing is more handy and is easy to remember.

Comments:

Patch:
diff --git a/CHANGES.txt b/CHANGES.txt
index 0e5970a..fb8f864 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,3 +1,9 @@
+2.0b2 (TBD)
+
+Added:
+- The Session class can now construct clients by name with its client method
+  (#858).
+
 2.0.0-beta.1 (2022-12-07)
 
 Changed:
diff --git a/docs/get-started/upgrading.md b/docs/get-started/upgrading.md
index 7edf21f..a82fafa 100644
--- a/docs/get-started/upgrading.md
+++ b/docs/get-started/upgrading.md
@@ -24,7 +24,7 @@ The best way of doing this is wrapping any code that invokes a client class in a
 
 ```python
 async with Session() as session:
-    client = OrdersClient(session)
+    client = session.client('orders')
     result = await client.create_order(order)
 # Process result
 ```
@@ -40,12 +40,12 @@ In V2, all `*Client` methods (for example, `DataClient().search`, `OrderClient()
 ```python
 import asyncio
 from datetime import datetime
-from planet import Session, DataClient
+from planet import Session
 from planet import data_filter as filters
  
 async def do_search():
     async with Session() as session:
-        client = DataClient(session)
+        client = session.client('data')
         date_filter = filters.date_range_filter('acquired', gte=datetime.fromisoformat("2022-11-18"), lte=datetime.fromisoformat("2022-11-21"))
         cloud_filter = filters.range_filter('cloud_cover', lte=0.1)
         download_filter = filters.permission_filter()
@@ -74,11 +74,11 @@ Is now
 
 ```python
 async with Session() as session:
-    items = [i async for i in planet.DataClient(session).search(["PSScene"], all_filters)]
+    items = [i async for i in session.client('data').search(["PSScene"], all_filters)]
 ```
 
 ## Orders API
 
-The Orders API capabilities in V1 were quite primitive, but those that did exist have been retained in much the same form; `ClientV1().create_order` becomes `OrderClient(session).create_order`. (As with the `DataClient`, you must also use `async` and `Session` with `OrderClient`.)
+The Orders API capabilities in V1 were quite primitive, but those that did exist have been retained in much the same form; `ClientV1().create_order` becomes `OrdersClient(session).create_order`. (As with the `DataClient`, you must also use `async` and `Session` with `OrdersClient`.)
 
 Additionally, there is now also an order builder in `planet.order_request`, similar to the preexisting search filter builder. For more details on this, refer to the [Creating an Order](../../python/sdk-guide/#creating-an-order).
diff --git a/docs/python/sdk-guide.md b/docs/python/sdk-guide.md
index 4e1ac2c..ba7a0e0 100644
--- a/docs/python/sdk-guide.md
+++ b/docs/python/sdk-guide.md
@@ -116,7 +116,7 @@ from planet import OrdersClient
 
 async def main():
     async with Session() as sess:
-        client = OrdersClient(sess)
+        client = sess.client('orders')
         # perform operations here
 
 asyncio.run(main())
@@ -198,7 +198,7 @@ the context of a `Session` with the `OrdersClient`:
 ```python
 async def main():
     async with Session() as sess:
-        cl = OrdersClient(sess)
+        cl = sess.client('orders')
         order = await cl.create_order(request)
 
 asyncio.run(main())
@@ -222,7 +222,7 @@ from planet import reporting
 
 async def create_wait_and_download():
     async with Session() as sess:
-        cl = OrdersClient(sess)
+        cl = sess.client('orders')
         with reporting.StateBar(state='creating') as bar:
             # create order
             order = await cl.create_order(request)
@@ -272,7 +272,7 @@ from planet import collect, OrdersClient, Session
 
 async def main():
     async with Session() as sess:
-        client = OrdersClient(sess)
+        client = sess.client('orders')
         orders_list = collect(client.list_orders())
 
 asyncio.run(main())
@@ -297,7 +297,7 @@ from planet import DataClient
 
 async def main():
     async with Session() as sess:
-        client = DataClient(sess)
+        client = sess.client('data')
         # perform operations here
 
 asyncio.run(main())
@@ -344,7 +344,7 @@ the context of a `Session` with the `DataClient`:
 ```python
 async def main():
     async with Session() as sess:
-        cl = DataClient(sess)
+        cl = sess.client('data')
         items = [i async for i in cl.search(['PSScene'], sfilter)]
 
 asyncio.run(main())
@@ -364,7 +364,7 @@ print command to report wait status. `download_asset` has reporting built in.
 ```python
 async def download_and_validate():
     async with Session() as sess:
-        cl = DataClient(sess)
+        cl = sess.client('data')
 
         # get asset description
         item_type_id = 'PSScene'
diff --git a/planet/__init__.py b/planet/__init__.py
index 6f081a6..fcaf682 100644
--- a/planet/__init__.py
+++ b/planet/__init__.py
@@ -16,7 +16,7 @@ from .http import Session
 from . import order_request, reporting
 from .__version__ import __version__  # NOQA
 from .auth import Auth
-from .clients import DataClient, OrdersClient  # NOQA
+from .clients import DataClient, OrdersClient, SubscriptionsClient  # NOQA
 from .io import collect
 
 __all__ = [
@@ -24,6 +24,7 @@ __all__ = [
     'collect',
     'DataClient'
     'OrdersClient',
+    'SubscriptionsClient',
     'order_request',
     'reporting',
     'Session',
diff --git a/planet/clients/__init__.py b/planet/clients/__init__.py
index 9fb246c..138726a 100644
--- a/planet/clients/__init__.py
+++ b/planet/clients/__init__.py
@@ -12,10 +12,20 @@
 # 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 .data import DataClient
 from .orders import OrdersClient
+from .subscriptions import SubscriptionsClient
 
 __all__ = [
     'DataClient',
     'OrdersClient',
+    'SubscriptionsClient',
 ]
+
+# Organize client classes by their module name to allow concise lookup.
+_client_directory = {
+    'data': DataClient,
+    'orders': OrdersClient,
+    'subscriptions': SubscriptionsClient
+}
diff --git a/planet/http.py b/planet/http.py
index 97983c0..0f649ea 100644
--- a/planet/http.py
+++ b/planet/http.py
@@ -22,7 +22,9 @@ import logging
 import random
 import time
 from typing import AsyncGenerator, Optional
+
 import httpx
+from typing_extensions import Literal
 
 from .auth import Auth, AuthType
 from . import exceptions, models
@@ -413,6 +415,29 @@ class Session(BaseSession):
         finally:
             await response.aclose()
 
+    def client(self,
+               name: Literal['data', 'orders', 'subscriptions'],
+               base_url: Optional[str] = None) -> object:
+        """Get a client by its module name.
+
+        Parameters:
+            name: one of 'data', 'orders', or 'subscriptions'.
+
+        Returns:
+            A client instance.
+
+        Raises:
+            ClientError when no such client can be had.
+
+        """
+        # To avoid circular dependency.
+        from planet.clients import _client_directory
+
+        try:
+            return _client_directory[name](self, base_url=base_url)
+        except KeyError:
+            raise exceptions.ClientError("No such client.")
+
 
 class AuthSession(BaseSession):
     """Synchronous connection to the Planet Auth service."""
diff --git a/setup.py b/setup.py
index 1255154..85b94f9 100644
--- a/setup.py
+++ b/setup.py
@@ -30,6 +30,7 @@ install_requires = [
     'jsonschema',
     'pyjwt>=2.1',
     'tqdm>=4.56',
+    'typing-extensions',
 ]
 
 test_requires = ['pytest', 'pytest-asyncio==0.16', 'pytest-cov', 'respx==0.19']


Test patch:
diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py
new file mode 100644
index 0000000..bf5baca
--- /dev/null
+++ b/tests/unit/test_session.py
@@ -0,0 +1,23 @@
+"""Session module tests."""
+
+import pytest
+
+from planet import DataClient, OrdersClient, SubscriptionsClient, Session
+from planet.exceptions import ClientError
+
+
+@pytest.mark.parametrize("client_name,client_class",
+                         [('data', DataClient), ('orders', OrdersClient),
+                          ('subscriptions', SubscriptionsClient)])
+def test_session_get_client(client_name, client_class):
+    """Get a client from a session."""
+    session = Session()
+    client = session.client(client_name)
+    assert isinstance(client, client_class)
+
+
+def test_session_get_client_error():
+    """Get an exception when no such client exists."""
+    session = Session()
+    with pytest.raises(ClientError):
+        _ = session.client('bogus')