Repository: planetlabs/planet-client-python (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.

Pull request:
Add client method to Session class
It gets a client by the name of the client module. `orders` -> `planet.clients.orders.OrdersClient`.

The intent is to simplify the API for casual users. The API centers the Session class. It feels natural to let Session be a factory for clients. And it goes well with code autocompletion. Type `session.` in VS Code and we get a prompt for `.client()`.

![Screenshot from 2023-02-27 10-28-38](https://user-images.githubusercontent.com/33697/221638845-b6900c8a-2c40-4d5f-a1bb-6c6fa4f28301.png)

Type `session.client(` and we get a prompt to call it with a name like data, orders, or subscriptions. Actually, I think the docs could be slightly improved to help even more.

![Screenshot from 2023-02-27 10-29-30](https://user-images.githubusercontent.com/33697/221639149-9994da23-28d6-4927-a406-867aa468aaca.png)

The new method is intended to be a convenience for users and documentation writers. The Session and three client classes are not otherwise changed. They don't need to be and much testing implementation hinges on the existing relationship between client classes and Session. The pattern used by `OrdersClient(session)` is a form of dependency injection; it really does make writing good tests easier.

The CLI commands are left as they are. 

Under docs/, all examples of `client = SomethingClient(session)` have been changed to `client = session.client('something')`.

Resolves #857.

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')