From e0bcaa146dde3ab20a83754b8a8e293240dae3e5 Mon Sep 17 00:00:00 2001 From: "Carl A. Adams" Date: Tue, 8 Apr 2025 23:27:12 -0700 Subject: [PATCH 01/24] 3.0 branch --- RELEASE-PLANNING-3.0.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 RELEASE-PLANNING-3.0.md diff --git a/RELEASE-PLANNING-3.0.md b/RELEASE-PLANNING-3.0.md new file mode 100644 index 000000000..796a75f1d --- /dev/null +++ b/RELEASE-PLANNING-3.0.md @@ -0,0 +1,13 @@ +# Planet Python Client 3.0 Release Planning + +* Authentication changes: + * Migrate to OAuth2 based authentication mechanisms, leveraging the + [planet-auth-python](https://github.com/planetlabs/planet-auth-python) + library for implementation. + * Deprecate use of the legacy authentication protocol and handling of the + user's password. + * CLI changes to support changes in authentication practices. + * Support for API keys supplied by the user is maintained, but users should + be aware that there are currently no plans for sentinel-hub.com APIs to + support Planet API keys. The longer term roadmap is for all APIs to work + with OAuth service accounts. From 99fc41ca46436e47fd99cd772406dc4649683751 Mon Sep 17 00:00:00 2001 From: "Carl A. Adams" Date: Fri, 11 Apr 2025 17:34:13 -0700 Subject: [PATCH 02/24] remove dead release workflow --- .github/workflows/release.yml | 51 ----------------------------------- 1 file changed, 51 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index c2ea51ace..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Release to GitHub and PyPI - -on: - workflow_dispatch: - inputs: - prerelease: - description: 'Is this a pre-release?' - required: true - type: boolean - -jobs: - package: - name: Build, verify, & upload package - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 - with: - python-version: "3.12" - - - name: Create GitHub Release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions. - with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} - draft: false - prerelease: ${{ inputs.logLevel }} - - - run: python -m pip install build twine check-wheel-contents - - run: python -m build --sdist --wheel . - - run: ls -l dist - - run: check-wheel-contents dist/*.whl - - - name: Check long_description - run: python -m twine check dist/* - - - name: Upload to TestPyPI - uses: pypa/gh-action-pypi-publish@v1.4.2 - with: - repository_url: https://test.pypi.org/legacy/ - user: __token__ - password: ${{ secrets.TEST_PYPI_PASSWORD }} - - - name: Upload to PyPI - uses: pypa/gh-action-pypi-publish@v1.4.2 - with: - user: __token__ - password: ${{ secrets.PYPI_PASSWORD }} \ No newline at end of file From 06fbcd7b58600f6799c1759945307caef0fcde88 Mon Sep 17 00:00:00 2001 From: "Carl A. Adams" Date: Fri, 11 Apr 2025 17:34:38 -0700 Subject: [PATCH 03/24] add step to releas to check doc status. --- RELEASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index be69ca397..455fa12b7 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -28,7 +28,7 @@ The SDK follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and t 2. Verify the successful run of the Github Action `Autopublish to TestPyPI` and validate the test release on [test.pypi.org](https://test.pypi.org/project/planet/) 3. Run the Github Action `Publish on PyPI` 4. Verify the successful run of the Github Action `Publish on PyPI` and validate the release on [pypi.org](https://pypi.org/project/planet/) - +5. Verify the successful publishing of documentation to [Read the Docs](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/) ## Local publishing From c7219678478ae03477d0416b1ddf31152cc9d118 Mon Sep 17 00:00:00 2001 From: Carl Adams <57012982+carl-adams-planet@users.noreply.github.com> Date: Wed, 7 May 2025 17:00:33 +0000 Subject: [PATCH 04/24] cicd for 3.x dev branches (#1127) * enable tests for 3.x main dev branch * allow manually triggering of tests on any branch. --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3aa1b193a..3cc992056 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,9 +4,12 @@ on: pull_request: branches: - main + - main-3.0-dev push: branches: - main + - main-3.0-dev + workflow_dispatch: jobs: lint: From 356b05e1cfd45ff8fdd88116faae90d33132bd5b Mon Sep 17 00:00:00 2001 From: Carl Adams <57012982+carl-adams-planet@users.noreply.github.com> Date: Thu, 15 May 2025 15:51:46 +0000 Subject: [PATCH 05/24] oauth via plauth lib for main 3.0 dev branch (#1121) Initial merge of OAuth via the planet auth library for the SDK. This deeply changes the planet.Auth class and the CLI functions related to auth, but should be pretty light on the rest of the SDK. --- .github/workflows/publish-pypi.yml | 2 +- .gitignore | 2 + docs/cli/cli-guide.md | 40 +- docs/cli/cli-reference.md | 2 + docs/get-started/quick-start-guide.md | 8 +- docs/python/sdk-client-auth.md | 375 ++++++++++++ docs/python/sdk-guide.md | 27 +- ...auth_state__app_custom_storage__api_key.py | 2 + ...th_state__app_custom_storage__oauth_m2m.py | 102 ++++ ...rage__oauth_user_authcode__with_browser.py | 110 ++++ ...oauth_user_devicecode__external_browser.py | 147 +++++ ..._managed_auth_state__in_memory__api_key.py | 26 + ...anaged_auth_state__in_memory__oauth_m2m.py | 31 + ...mory__oauth_user_authcode__with_browser.py | 38 ++ ...oauth_user_devicecode__external_browser.py | 75 +++ ...auth_state__on_disk_cli_shared__api_key.py | 2 + ...th_state__on_disk_cli_shared__oauth_m2m.py | 32 + ...ared__oauth_user_authcode__with_browser.py | 40 ++ ...oauth_user_devicecode__external_browser.py | 77 +++ ...ged_auth_state__on_disk_legacy__api_key.py | 26 + .../cli_managed_auth_state__explicit.py | 32 + .../cli_managed_auth_state__implicit.py | 21 + ...naged_auth_state__specific_auth_profile.py | 38 ++ .../legacy_api_key_file.json | 3 + mkdocs.yml | 4 + noxfile.py | 1 + planet/__init__.py | 2 + planet/auth.py | 563 ++++++++++++------ planet/auth_builtins.py | 155 +++++ planet/cli/auth.py | 101 ++-- planet/cli/cli.py | 56 +- planet/cli/cmds.py | 11 +- planet/cli/data.py | 2 +- planet/cli/orders.py | 2 +- planet/cli/session.py | 13 +- planet/cli/subscriptions.py | 2 +- planet/constants.py | 2 - planet/exceptions.py | 5 - planet/http.py | 51 +- pyproject.toml | 30 +- setup.cfg | 4 +- tests/conftest.py | 16 - tests/integration/test_auth_api.py | 77 --- tests/integration/test_auth_cli.py | 123 ---- tests/integration/test_features_api.py | 4 +- tests/pytest.ini | 4 - tests/unit/test_auth.py | 284 +++++++-- tests/unit/test_cli_session.py | 41 +- tests/unit/test_http.py | 23 +- 49 files changed, 2157 insertions(+), 677 deletions(-) create mode 100644 docs/python/sdk-client-auth.md create mode 100644 examples/auth-session-management/app_managed_auth_state__app_custom_storage__api_key.py create mode 100644 examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py create mode 100644 examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_authcode__with_browser.py create mode 100644 examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_devicecode__external_browser.py create mode 100644 examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py create mode 100644 examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py create mode 100644 examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py create mode 100644 examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py create mode 100644 examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__api_key.py create mode 100644 examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py create mode 100644 examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py create mode 100644 examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py create mode 100644 examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py create mode 100644 examples/auth-session-management/cli_managed_auth_state__explicit.py create mode 100644 examples/auth-session-management/cli_managed_auth_state__implicit.py create mode 100644 examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py create mode 100644 examples/auth-session-management/legacy_api_key_file.json create mode 100644 planet/auth_builtins.py delete mode 100644 tests/integration/test_auth_api.py delete mode 100644 tests/integration/test_auth_cli.py delete mode 100644 tests/pytest.ini diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index cfdbf3fc6..3aa701868 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -24,7 +24,7 @@ jobs: restore-keys: | ${{ runner.os }}-pip - - name: Build, verify, and upload to TestPyPI + - name: Build, verify, and upload to PyPI run: | pip install --upgrade nox nox -s build publish_pypi diff --git a/.gitignore b/.gitignore index 17f499eed..df10277b8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,9 @@ coverage.xml *.pyc # Editors +.idea/ .vscode/ # Docs build site .venv +venv diff --git a/docs/cli/cli-guide.md b/docs/cli/cli-guide.md index 5a5701b7e..c93798b79 100644 --- a/docs/cli/cli-guide.md +++ b/docs/cli/cli-guide.md @@ -34,13 +34,13 @@ Yes. Even if you’re not writing code—and only using the "no code" CLI part o Install the Planet SDK for Python using [pip](https://pip.pypa.io): ```console -$ pip install planet +pip install planet ``` ## Step 3: Check the Planet SDK for Python version ```console -$ planet --version +planet --version ``` You should be on some version 2 of the Planet SDK for Python. @@ -55,46 +55,40 @@ To confirm your Planet account, or to get one if you don’t already have one, s ### Authenticate with the Planet server -Just as you log in when you browse to https://account.planet.com, you’ll want to sign on to your account so you have access to your account and orders. +Just as you log in when you browse to https://planet.com/account, you’ll want to sign on to your account so you have access to your account and orders. At a terminal console, type the following Planet command: ```console -$ planet auth init +planet auth login ``` -You’ll be prompted for the email and password you use to access [your account](https://account.planet.com). When you type in your password, you won’t see any indication that the characters are being accepted. But when you hit enter, you’ll know that you’ve succeeded because you’ll see on the command line: +A browser window should be opened, and you will be directed to login to your account. This +command will wait for the browser login to complete, and should exit shortly afterwards. +When this process succeeds, you will see the following message on the console: ```console -Initialized +Login succeeded. ``` -### Get your API key - -Now that you’ve logged in, you can easily retrieve your API key that is being used for requests with the following command: - +If you are in an environment where the `planet` command line utility cannot open a browser (such +as a remote shell on a cloud service provider), use the following command and follow the instructions: ```console -planet auth value +planet auth login --no-open-browser ``` -Many `planet` calls you make require an API key. This is a very convenient way to quickly grab your API key. - -#### Your API Key as an Environment Variable +### Get your Access Token -You can also set the value of your API Key as an environment variable in your terminal at the command line: +Now that you’ve logged in, you can easily retrieve an Access Token that is being used for requests with the following command: ```console -export PL_API_KEY= +planet auth print-access-token ``` -And you can see that the value was stored successfully as an environment variable with the following command: - -```console -echo $PL_API_KEY -``` +Many `planet` calls you make require an access token. This is a very convenient way to quickly grab the current access token. -!!!note "The API Key environment variable is ignored by the CLI but used by the Python library" - If you do create a `PL_API_KEY` environment variable, the CLI will be unaffected but the Planet library will use this as the source for authorization instead of the value stored in `planet auth init`. +**Note** : As a security measure, access tokens are time limited. They have a relatively short lifespan, and must +be refreshed. The `print-access-token` command takes care of this transparently for the user. ## Step 5: Search for Planet Imagery diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index a3500cee7..bf2107f69 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -4,8 +4,10 @@ title: CLI Reference This page provides documentation for our command line tools. +{% raw %} ::: mkdocs-click :module: planet.cli.cli :command: main :prog_name: planet :depth: 1 +{% endraw %} diff --git a/docs/get-started/quick-start-guide.md b/docs/get-started/quick-start-guide.md index 28abcf8da..a6ae82c4e 100644 --- a/docs/get-started/quick-start-guide.md +++ b/docs/get-started/quick-start-guide.md @@ -27,10 +27,12 @@ pip install planet ### Authentication -Use the `PL_API_KEY` environment variable to authenticate with the Planet API. For other authentication options, see the [SDK guide](../python/sdk-guide.md). +Use the `planet auth` CLI command to establish a user login session that will +be saved to the user's home directory. For other authentication options, see +the [Client Authentication Guide](../python/sdk-client-auth.md). ```bash -export PL_API_KEY=your_api_key +planet auth login ``` ### The Planet client @@ -39,7 +41,7 @@ The `Planet` class is the main entry point for the Planet SDK. It provides acces ```python from planet import Planet -pl = Planet() # automatically detects PL_API_KEY +pl = Planet() # automatically detects authentication configured by `planet auth login` ``` The Planet client has members `data`, `orders`, and `subscriptions`, which allow you to interact with the Data API, Orders API, and Subscriptions API. Usage examples for searching, ordering and creating subscriptions can be found in the [SDK guide](../python/sdk-guide.md). diff --git a/docs/python/sdk-client-auth.md b/docs/python/sdk-client-auth.md new file mode 100644 index 000000000..bcc3adfaf --- /dev/null +++ b/docs/python/sdk-client-auth.md @@ -0,0 +1,375 @@ +# Client Authentication Guide + +## Introduction +All calls to Planet APIs must be authenticated. Only authorized clients may +use Planet Platform APIs. + +For general information on how to authenticate to Planet APIs, please see +the [Authentication](https://docs.planet.com/develop/authentication) section of Planet's platform documentation. + +!!! warning + Some statements are forward-looking. OAuth2 M2M tokens are + currently only supported by `services.sentine-hub.com` and not supported + by `api.planet.com`. + + All APIs support interactive user OAuth2 access tokens, but a process for + developers to register and manage clients has not yet been made public. + We have also not yet release a way for end-users of such applications to + manage which applications have been authorized to access the platform on + their behalf. + + If you would like to developed an interactive application that uses + Planet's APIs on behalf of a user (as the `planet` CLI utility does), + please contact Planet and work with engineering to register your + application. + +---- +## Authentication Protocols +At the API protocol level underneath the SDK, there are several distinct +ways a client may authenticate to the Planet APIs, depending on the use case: + +* **OAuth2 user access tokens** - API access as the end-user, using OAuth2 +user access tokens. This is the preferred way for user interactive +applications to authenticate to Planet APIs. A web browser is required +to initialize a session, but not required for continued operation. +* **OAuth2 M2M access tokens** - API access as a service user, using OAuth2 +M2M access tokens. This is the preferred way for automated processes +to authenticate to Planet APIs that must operate without a human user. +No web browser is required, but this method carries some additional +security considerations. +* **Planet API keys** - API access as a planet end-user using a simple +fixed string bearer key. This method is being targeted for deprecation. + +### OAuth2 +OAuth2 authentication requires that the client possesses an access token +in order to make API calls. Access tokens are obtained by the client from +the Planet authorization server that is separate from the API servers, and are +presented by the client to API services to prove the client's right to make +API calls. + +Unlike Planet API keys, access tokens do not last forever for a variety of +reasons and must be regularly refreshed by the client before their expiration. +However, clients should not refresh access tokens for every API call; clients +that misbehave in this way will be throttled by the authorization service, +potentially losing access to APIs. + +When using the Planet SDK, the many of the details of obtaining and refreshing +OAuth2 access tokens will be taken care of for you. + +Planet OAuth2 access tokens will work for all Planet APIs underneath +both the `api.planet.com` and `services.sentinel-hub.com` domains. + +Planet Access tokens conform to the JSON Web Token (JWT) specification. +Tokens may be inspected to determine their expiration time, which will be +in the `exp` claim. + +!!! note + Clients should generally treat the access tokens as opaque bearer tokens. + While JWTs are open for inspection, Planet does not guarantee the stability + of undocumented claims. Rely only on those documented here. + +More information regarding OAuth2 and JWTs may be found here: + +* [RFC 6749 - The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749) +* [RFC 8628 - OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628) +* [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) +* [RFC 9068 - JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://datatracker.ietf.org/doc/html/rfc9068) + +#### OAuth2 Client Registration +!!! TODO + * link to docs for this process + * discuss registering a interactive client (that will access Planet + as the user) vs registering a M2M client identity (which is really + more like creating a new user) vs registering a confidential client. + discuss native vs web clients. + +Developers of applications must register client applications with Planet, and +will be issued a Client ID as part of that process. Developers should register +a client for each distinct application so that end-users may discretely manage +applications permitted to access Planet APIs on their behalf. + +### Planet API Keys +Planet API keys are simple fixed strings that may be presented by the client +to API services that assert the client's right to access APIs. API keys are +obtained by the user from their account page, and provided to the client +so that it may make API calls on the user's behalf. + +Planet API keys are simpler to use than OAuth2, but are considered less secure +in many ways. Because of this, Planet API keys are targeted for eventual +deprecation. Support for this method is maintained for continuity +while OAuth2 based methods are being rolled out across all Planet APIs and +clients. + +Planet API Keys will work for Planet APIs underneath `api.planet.com`, but +will **NOT** work for APIs underneath `services.sentinel-hub.com`. + +---- +## Authentication with the SDK + +Before any calls can be made to a Planet API using the SDK, it is +necessary for the user to login and establish an authentication session. +Exactly how this should be done with the SDK depends on the +application's complexity and needs. + +In simple cases, this may be managed external to the application +by using the [`planet auth`](../../cli/cli-reference/#auth) +command line utility. + +In more complex cases, an application may need to manage the +stored session itself independent of utilities provided by the CLI. In such +cases the application will be responsible for instantiating a `planet.Auth` +object, initiating user login, and saving the session resulting information. +Session information may contain sensitive information such as access and +refresh tokens, and must be stored securely by the application. Session +information will also be regularly updated during SDK operations, so the +application must handle keeping the saved session information up-to-date. + +Regardless of which authentication protocol is used, the SDK encapsulates +the details with +[`planet.Auth`](../sdk-reference/#planet.auth.Auth) and +[`planet.Session`](../sdk-reference/#planet.http.Session). + +#### Session State Storage + +Once a user login session is established using any method, the state should be +saved to secure persistent storage to allow for continued access to the Planet +platform without the need to perform the login repeatedly. If state cannot +be persisted in the application environment, the application can operate in +in-memory mode, and will be forced create a new login session every time the +application is run. In some cases, this may result in throttling by the +authorization service. + +By default, the SDK provides the option to save session state in the user's +home directory in a way that is compatible with the CLI. The SDK also +provides a way for the application to provide its own secure storage. +Applications needing to use their own storage will do so by providing +the `Auth` layer in the SDK with a custom implementation of the +`planet_auth.ObjectStorageProvider` abstract base class. See examples +below for more details. + +### Using `planet auth` CLI Managed Auth Session +For simple programs and scripts, it is easiest for the program to defer +session management to the [`planet auth`](../../cli/cli-reference/#auth) +CLI. This method will store session information in the user's home directory +in the `~/.planet.json` file and `~/.planet/` directory. The python SDK will +use the information saved in these locations to make API calls. + +When this approach is taken, the authentication session will be shared between +actions taken by the `planet` utility, and those taken by the programs built +using the SDK. Changes made by one will impact the behavior of the other. + +**Requirements and Limitations:** + +* The program must have read and write access to the user's home directory. +* This method requires that the end-user has access to and understands + the [`planet`](../../cli/cli-reference) CLI command needed to manage session + authentication. +* This approach should not be used on public terminals or in cases where the + user's home directory cannot be kept confidential. + +#### Initialize Session - Login +Session login can be performed using the following command. This command can +be used to initialize sessions using any of the authentication methods +discussed above, and will default to creating an OAuth2 user session. +Refer to the command's `--help` for more information. +```shell title="Initialize session using planet CLI" +planet auth login +``` + +#### Using Saved Session +Using the CLI managed session is the default behavior for SDK functions. +Developing an application that uses this session requires no additional +action by the developer. When a developer chooses to create an application +that behaves in this way, it will most often be done implicitly by relying +on SDK default behavior, but it may also be done explicitly. + +```python linenums="1" title="Implicitly use CLI managed login sessions" +{% include 'auth-session-management/cli_managed_auth_state__implicit.py' %} +``` + +```python linenums="1" title="Explicitly use CLI managed login sessions" +{% include 'auth-session-management/cli_managed_auth_state__explicit.py' %} +``` + +```python linenums="1" title="Use a specific session that is shared with the CLI" +{% include 'auth-session-management/cli_managed_auth_state__specific_auth_profile.py' %} + +``` + +### Manually Creating a Session Using Library Functions +If an application cannot or should not use a login session initiated by the +[`planet auth`](../../cli/cli-reference/#auth) CLI command, it will be +responsible for managing the process on its own, persisting session state as +needed. + +The process differs slightly for applications accessing Planet services on behalf +of a human user verses accessing Planet services using a service account. Depending +on the use case, applications may need to support one or the other or both (just +as the [`planet`](../../cli/cli-reference) CLI command supports both methods). + +#### OAuth2 Session for Users +User session initialization inherently involves using a web browser to +complete user authentication. This architecture allows for greater security +by keeping the user's password from being directly exposed to the application +code. This also allows for flexibility in user federation and multifactor +authentication procedures without the complexity of these needing to +be exposes to the application developer who is focused on geospatial +operations using the Planet platform, and not the nuances of user +authentication and authorization. + +##### With a Local Web Browser +In environments where a local browser is available, the Planet SDK can manage +the process of launching the browser locally, transferring control to the Planet +authorization services for session initialization, and accepting a network +callback from the local browser to regain control once the authorization +process is complete. At a network protocol level, this is establishing the user +login session using the OAuth2 authorization code flow. + +To use this method using the SDK, the following requirements must be met: + +* The application must be able to launch a local web browser. +* The web browse must be able to connect to Planet services. +* The application must be able to listen on a network port that is accessible + to the browser. + +###### Examples - Authorization Code Flow +```python linenums="1" title="Login as a user using a local browser with in memory only state persistance" +{% include 'auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py' %} +``` + +```python linenums="1" title="Login as a user using a local browser with sessions persisted on disk and shared with the CLI" +{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py' %} +``` + +```python linenums="1" title="Login as a user using a local browser with sessions persisted to application provided storage" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_authcode__with_browser.py' %} +``` + +##### Without a Local Web Browser +In environments where a local web browsers is not available the process above +will not work. For example, a remote shell to a cloud environment is not likely +to be able to open a browser on the user's desktop or receive network callbacks +from the user's desktop browser. In these cases, a browser is +still required. To login in such a case the SDK will generate a URL and a +verification code that must be presented to the user. The user must visit the +URL out of band to complete the login process while the application polls for +the completion of the login process using the SDK. At a network protocol +level, this is establishing the user login session using the OAuth2 device +code flow. + +To use this method using the SDK, the following requirements must be met: + +* The application must be able to connect to Planet services. +* The application must be able to display instructions to the user, directing + them to a web location to complete login. + +###### Examples - Device Code Flow +```python linenums="1" title="Login as a user using an external browser with in memory only state persistance" +{% include 'auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py' %} +``` + +```python linenums="1" title="Login as a user using an external browser with sessions persisted on disk and shared with the CLI" +{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py' %} +``` + +```python linenums="1" title="Login as a user using an external browser with sessions persisted to application provided storage" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_devicecode__external_browser.py' %} +``` + +#### OAuth2 Session for Service Accounts +Service account session initialization is simpler than user session +initialization, and does not require a web browser. + +While preserving session state for user sessions was a concern driven +in part by a concern for the user experience of using a web browser for +initialization, for service account it remains a concern to avoid +throttling by the authorization service. + +If applications are expected to run longer than the life of an access token +(a few hours), then in memory operations are acceptable (for example: a long +running data processing job). If application lifespan is short and frequent, +than the application should still take steps to persist the session state (for +example: a command line utility run from a shell with a short lifespan). + +Like the session state itself, service account initialization parameters are +sensitive, and it is the responsibility of the application to store them +securely. + +At a network protocol level, OAuth2 service account sessions are implemented +using the OAuth2 authorization code flow. This carries with it some additional +security considerations, discussed in +[RFC 6819 §4.4.4](https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.4). +Because of these consideration, service accounts should only be used for +workflows that are independent of a controlling user. + +##### Examples - Client Credentials Flow +```python linenums="1" title="Access APIs using a service account with in memory only state persistance" +{% include 'auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py' %} +``` + +```python linenums="1" title="Access APIs using a service account with sessions persisted on disk and shared with the CLI" +{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py' %} +``` + +```python linenums="1" title="Access APIs using a service account with sessions persisted to application provided storage" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py' %} +``` + +#### Planet API Key Sessions +Legacy applications that need to continue to support Planet API keys may do so +until API keys are deprecated. This method should not be adopted for new +development. + +##### Examples - Planet API Keys + +```python linenums="1" title="Access APIs using Planet API keys in memory" +{% include 'auth-session-management/app_managed_auth_state__in_memory__api_key.py' %} +``` + +```python linenums="1" title="Access APIs using Planet API keys using the on disk file format used by older versions of the SDK" +{% include 'auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py' %} +``` + +```json linenums="1" title="Legacy API Key file" +{% include 'auth-session-management/legacy_api_key_file.json' %} +``` + +```python linenums="1" title="Access APIs using Planet API keys with CLI managed shared state on disk" +{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__api_key.py' %} +``` + +```python linenums="1" title="Access APIs using Planet API keys using legacy on disk persistance" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__api_key.py' %} +``` + +## OAuth2 Scopes +OAuth2 uses scopes to allow users to limit how much access clients have to the Planet +service on their behalf. + +* **`planet`** - Use this scope to request access to Planet APIs. +* **`offline_acess`** - Use this scope to request a refresh token. This may + only be requested by clients that access APIs on behalf of a user. M2M + clients may not request this scope. + + +## Environment Variables +When session information is not explicitly configured, the following environment variables +will influence the library behavior when initialized to user default preferences. + +* **`PL_AUTH_PROFILE`** - Specify a custom CLI managed auth client profile by name. +* **`PL_AUTH_CLIENT_ID`** - Specify an OAuth2 M2M client ID. +* **`PL_AUTH_CLIENT_SECRET`** - Specify an OAuth2 M2M client secret. +* **`PL_AUTH_API_KEY`** - Specify a legacy Planet API key. +---- + + +## Web Services +!!! TODO + All of the above really deals with native applications running in an + environment controlled by the end-user. The considerations + are different if the application being developed is a web service where + the end-user is not directly accessing Planet APIs. This involves + "Confidential" OAuth2 client configurations, and needs to be documented + here. + +---- diff --git a/docs/python/sdk-guide.md b/docs/python/sdk-guide.md index 56a70866f..413c4d96b 100644 --- a/docs/python/sdk-guide.md +++ b/docs/python/sdk-guide.md @@ -23,32 +23,26 @@ The `Planet` class is the main entry point for the Planet SDK. It provides acces ```python from planet import Planet -pl = Planet() # automatically detects PL_API_KEY +pl = Planet() # automatically detects authentication configured by `planet auth login` ``` The Planet client has members `data`, `orders`, and `subscriptions`, which allow you to interact with the Data API, Orders API, and Subscriptions API. ### Authentication - -Use the `PL_API_KEY` environment variable to authenticate with the Planet API. +To establish a user session that will be saved to the user's home directory +and will be picked up by the SDK, execute the following command: ```bash -export PL_API_KEY=your_api_key +planet auth login ``` -These examples will assume you are using the `PL_API_KEY` environment variable. If you are, you can skip to the next section. - -#### Authenticate using the Session class - -Alternately, you can also authenticate using the `Session` class: - -```python -from planet import Auth, Session, Auth -from planet.auth import APIKeyAuth - -pl = Planet(session=Session(auth=APIKeyAuth(key='your_api_key'))) -``` +These examples will assume you have done this, and are using the SDK's default +client authentication mechanisms. If you are not, please see the +[Client Authentication Guide](sdk-client-auth.md) for a complete discussion of +all authentication options provided by the SDK. This includes user +authentication with a web browser, service account authentication for detached +workloads, and support for legacy authentication mechanisms. ### Search @@ -331,4 +325,3 @@ If there's something you're missing or are stuck, the development team would lov - To report a bug or suggest a feature, [raise an issue on GitHub](https://github.com/planetlabs/planet-client-python/issues/new) - To get in touch with the development team, email [developers@planet.com](mailto:developers@planet.com) - diff --git a/examples/auth-session-management/app_managed_auth_state__app_custom_storage__api_key.py b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__api_key.py new file mode 100644 index 000000000..033a9e530 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__api_key.py @@ -0,0 +1,2 @@ +# No example of this use case provided at this time. +# The use of M2M OAuth sessions is encouraged over the use of API keys. diff --git a/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py new file mode 100644 index 000000000..08ddb0a96 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py @@ -0,0 +1,102 @@ +import json +import logging +import os +import pathlib +import stat + +import planet + +from planet_auth import ObjectStorageProvider, ObjectStorageProvider_KeyType + +logging.basicConfig(level=logging.CRITICAL) + + +class DemoStorageProvider(ObjectStorageProvider): + """ + Simple demo custom storage provider that uses + ~/.planet-demo as a storage home for saving object. + + As a practical matter, ObjectStorageProvider_KeyType is defined + to be pathlib.Path, and we leverage that in this example. + But, storage providers are not required to use the local file + system to store objects. + """ + + def __init__(self): + self._demo_storage_root = pathlib.Path.home() / ".planet-demo" + + def load_obj(self, key: ObjectStorageProvider_KeyType) -> dict: + demo_obj_filepath = self._demo_obj_filepath(key) + return self._load_file(file_path=demo_obj_filepath) + + def save_obj(self, key: ObjectStorageProvider_KeyType, data: dict) -> None: + demo_obj_filepath = self._demo_obj_filepath(key) + self._save_file(file_path=demo_obj_filepath, data=data) + + def obj_exists(self, key: ObjectStorageProvider_KeyType) -> bool: + demo_obj_filepath = self._demo_obj_filepath(key) + return demo_obj_filepath.exists() + + def mtime(self, key: ObjectStorageProvider_KeyType) -> float: + obj_filepath = self._demo_obj_filepath(key) + return obj_filepath.stat().st_mtime + + def obj_rename(self, + src: ObjectStorageProvider_KeyType, + dst: ObjectStorageProvider_KeyType) -> None: + src_filepath = self._demo_obj_filepath(src) + dst_filepath = self._demo_obj_filepath(dst) + src_filepath.rename(dst_filepath) + + def _demo_obj_filepath(self, obj_key): + if obj_key.is_absolute(): + obj_path = self._demo_storage_root / obj_key.relative_to("/") + else: + obj_path = self._demo_storage_root / obj_key + return obj_path + + @staticmethod + def _load_file(file_path: pathlib.Path) -> dict: + logging.debug(msg="Loading JSON data from file {}".format(file_path)) + with open(file_path, mode="r", encoding="UTF-8") as file_r: + return json.load(file_r) + + @staticmethod + def _save_file(file_path: pathlib.Path, data: dict): + file_path.parent.mkdir(parents=True, exist_ok=True) + logging.debug(msg="Writing JSON data to file {}".format(file_path)) + with open(file_path, mode="w", encoding="UTF-8") as file_w: + os.chmod(file_path, stat.S_IREAD | stat.S_IWRITE) + _no_none_data = { + key: value + for key, value in data.items() if value is not None + } + file_w.write(json.dumps(_no_none_data, indent=2, sort_keys=True)) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_m2m( + client_id="__MUST_BE_END_USER_SUPPLIED__", + client_secret="__MUST_BE_END_USER_SUPPLIED__", + profile_name="my-example-name-m2m-with-custom-storage", + save_state_to_storage=True, + storage_provider=DemoStorageProvider(), + ) + + # Explicit login is not required for M2M client use. The above sufficient. + # plsdk_auth.user_login() + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_authcode__with_browser.py b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_authcode__with_browser.py new file mode 100644 index 000000000..cdbfe6577 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_authcode__with_browser.py @@ -0,0 +1,110 @@ +import json +import logging +import os +import pathlib +import stat + +import planet + +from planet_auth import ObjectStorageProvider, ObjectStorageProvider_KeyType + +logging.basicConfig(level=logging.CRITICAL) + + +class DemoStorageProvider(ObjectStorageProvider): + """ + Simple demo custom storage provider that uses + ~/.planet-demo as a storage home for saving object. + + As a practical matter, ObjectStorageProvider_KeyType is defined + to be pathlib.Path, and we leverage that in this example. + But, storage providers are not required to use the local file + system to store objects. + """ + + def __init__(self): + self._demo_storage_root = pathlib.Path.home() / ".planet-demo" + + def load_obj(self, key: ObjectStorageProvider_KeyType) -> dict: + demo_obj_filepath = self._demo_obj_filepath(key) + return self._load_file(file_path=demo_obj_filepath) + + def save_obj(self, key: ObjectStorageProvider_KeyType, data: dict) -> None: + demo_obj_filepath = self._demo_obj_filepath(key) + self._save_file(file_path=demo_obj_filepath, data=data) + + def obj_exists(self, key: ObjectStorageProvider_KeyType) -> bool: + demo_obj_filepath = self._demo_obj_filepath(key) + return demo_obj_filepath.exists() + + def mtime(self, key: ObjectStorageProvider_KeyType) -> float: + obj_filepath = self._demo_obj_filepath(key) + return obj_filepath.stat().st_mtime + + def obj_rename(self, + src: ObjectStorageProvider_KeyType, + dst: ObjectStorageProvider_KeyType) -> None: + src_filepath = self._demo_obj_filepath(src) + dst_filepath = self._demo_obj_filepath(dst) + src_filepath.rename(dst_filepath) + + def _demo_obj_filepath(self, obj_key): + if obj_key.is_absolute(): + obj_path = self._demo_storage_root / obj_key.relative_to("/") + else: + obj_path = self._demo_storage_root / obj_key + return obj_path + + @staticmethod + def _load_file(file_path: pathlib.Path) -> dict: + logging.debug(msg="Loading JSON data from file {}".format(file_path)) + with open(file_path, mode="r", encoding="UTF-8") as file_r: + return json.load(file_r) + + @staticmethod + def _save_file(file_path: pathlib.Path, data: dict): + file_path.parent.mkdir(parents=True, exist_ok=True) + logging.debug(msg="Writing JSON data to file {}".format(file_path)) + with open(file_path, mode="w", encoding="UTF-8") as file_w: + os.chmod(file_path, stat.S_IREAD | stat.S_IWRITE) + _no_none_data = { + key: value + for key, value in data.items() if value is not None + } + file_w.write(json.dumps(_no_none_data, indent=2, sort_keys=True)) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_user_auth_code( + client_id="__MUST_BE_APP_DEVELOPER_SUPPLIED__", + requested_scopes=[ + # Request access to Planet APIs + planet.PlanetOAuthScopes.PLANET, + # Request a refresh token so repeated browser logins are not required + planet.PlanetOAuthScopes.OFFLINE_ACCESS, + ], + callback_url="http://localhost:8080", + profile_name="my-example-name-auth-code-with-custom-storage", + save_state_to_storage=True, + storage_provider=DemoStorageProvider(), + ) + + # In contrast to an in-memory only application that must initialize a login every + # time, an app with persistent storage can skip this when it is not needed. + if not plsdk_auth.is_initialized(): + plsdk_auth.user_login(allow_open_browser=True) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_devicecode__external_browser.py b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_devicecode__external_browser.py new file mode 100644 index 000000000..9f36cc797 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_devicecode__external_browser.py @@ -0,0 +1,147 @@ +import json +import logging +import os +import pathlib +import stat + +import planet + +from planet_auth import ObjectStorageProvider, ObjectStorageProvider_KeyType + +logging.basicConfig(level=logging.CRITICAL) + + +class DemoStorageProvider(ObjectStorageProvider): + """ + Simple demo custom storage provider that uses + ~/.planet-demo as a storage home for saving object. + + As a practical matter, ObjectStorageProvider_KeyType is defined + to be pathlib.Path, and we leverage that in this example. + But, storage providers are not required to use the local file + system to store objects. + """ + + def __init__(self): + self._demo_storage_root = pathlib.Path.home() / ".planet-demo" + + def load_obj(self, key: ObjectStorageProvider_KeyType) -> dict: + demo_obj_filepath = self._demo_obj_filepath(key) + return self._load_file(file_path=demo_obj_filepath) + + def save_obj(self, key: ObjectStorageProvider_KeyType, data: dict) -> None: + demo_obj_filepath = self._demo_obj_filepath(key) + self._save_file(file_path=demo_obj_filepath, data=data) + + def obj_exists(self, key: ObjectStorageProvider_KeyType) -> bool: + demo_obj_filepath = self._demo_obj_filepath(key) + return demo_obj_filepath.exists() + + def mtime(self, key: ObjectStorageProvider_KeyType) -> float: + obj_filepath = self._demo_obj_filepath(key) + return obj_filepath.stat().st_mtime + + def obj_rename(self, + src: ObjectStorageProvider_KeyType, + dst: ObjectStorageProvider_KeyType) -> None: + src_filepath = self._demo_obj_filepath(src) + dst_filepath = self._demo_obj_filepath(dst) + src_filepath.rename(dst_filepath) + + def _demo_obj_filepath(self, obj_key): + if obj_key.is_absolute(): + obj_path = self._demo_storage_root / obj_key.relative_to("/") + else: + obj_path = self._demo_storage_root / obj_key + return obj_path + + @staticmethod + def _load_file(file_path: pathlib.Path) -> dict: + logging.debug(msg="Loading JSON data from file {}".format(file_path)) + with open(file_path, mode="r", encoding="UTF-8") as file_r: + return json.load(file_r) + + @staticmethod + def _save_file(file_path: pathlib.Path, data: dict): + file_path.parent.mkdir(parents=True, exist_ok=True) + logging.debug(msg="Writing JSON data to file {}".format(file_path)) + with open(file_path, mode="w", encoding="UTF-8") as file_w: + os.chmod(file_path, stat.S_IREAD | stat.S_IWRITE) + _no_none_data = { + key: value + for key, value in data.items() if value is not None + } + file_w.write(json.dumps(_no_none_data, indent=2, sort_keys=True)) + + +def initialize_user_session(plsdk_auth): + # Example of initiating a user session where the app is 100% + # responsible for the user experience. + + # 1. Initiate the login + login_initialization_info = plsdk_auth.device_user_login_initiate() + + # 2. Display necessary instructions to the user. + # + # "verification_uri" and "user_code" are required under RFC 8628. + # "verification_uri_complete" is optional under the RFC. + # + # If the user is expected to type in the URL, verification_uri will be + # shorter. If the URL may be presented in a clickable means (such as a + # link, button, or QR code) the verification_uri_complete may offer a + # better user experience. + verification_uri_complete = login_initialization_info.get( + "verification_uri_complete") + verification_uri = login_initialization_info.get("verification_uri") + user_code = login_initialization_info.get("user_code") + + print("Please activate your client.") + if verification_uri_complete: + print(f"Visit the activation site:\n" + f"\n\t{verification_uri_complete}\n" + f"\nand confirm the authorization code:\n" + f"\n\t{user_code}\n") + else: + print(f"Visit the activation site:\n" + f"\n\t{verification_uri}\n" + f"\nand enter the authorization code:\n" + f"\n\t{user_code}\n") + + # 3. Return control to the SDK. This will block until the user + # completes login. + plsdk_auth.device_user_login_complete(login_initialization_info) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_user_device_code( + client_id="__MUST_BE_APP_DEVELOPER_SUPPLIED__", + requested_scopes=[ + # Request access to Planet APIs + planet.PlanetOAuthScopes.PLANET, + # Request a refresh token so repeated browser logins are not required + planet.PlanetOAuthScopes.OFFLINE_ACCESS, + ], + profile_name="my-example-name-device-code-with-custom-storage", + save_state_to_storage=True, + storage_provider=DemoStorageProvider(), + ) + + # In contrast to an in-memory only application that must initialize a login every + # time, an app with persistent storage can skip this when it is not needed. + if not plsdk_auth.is_initialized(): + initialize_user_session(plsdk_auth) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py b/examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py new file mode 100644 index 000000000..7efb7659a --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py @@ -0,0 +1,26 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Create an auth context with the specified API key + plsdk_auth = planet.Auth.from_key( + key="__PLANET_API_KEY_MUST_BE_END_USER_SUPPLIED__") + + # Explicit login is not required for API key use. The above sufficient. + # plsdk_auth.user_login() + + # Create a Planet SDK object that uses the loaded auth session + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py new file mode 100644 index 000000000..517e186a7 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py @@ -0,0 +1,31 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_m2m( + client_id="__MUST_BE_END_USER_SUPPLIED__", + client_secret="__MUST_BE_END_USER_SUPPLIED__", + save_state_to_storage=False, + ) + + # Explicit login is not required for M2M client use. The above is sufficient. + # plsdk_auth.user_login() + + # Create a Planet SDK object that uses the loaded auth session/ + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py new file mode 100644 index 000000000..312d1136d --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py @@ -0,0 +1,38 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_user_auth_code( + client_id="__MUST_BE_APP_DEVELOPER_SUPPLIED__", + requested_scopes=[ + # Request access to Planet APIs + planet.PlanetOAuthScopes.PLANET, + # Request a refresh token so repeated browser logins are not required + planet.PlanetOAuthScopes.OFFLINE_ACCESS, + ], + callback_url="http://localhost:8080", + save_state_to_storage=False, + ) + + # An application with no persistent storage must + # initialize a login every time. This is not smooth user experience. + plsdk_auth.user_login(allow_open_browser=True) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py new file mode 100644 index 000000000..0f332cfa9 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py @@ -0,0 +1,75 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def initialize_user_session(plsdk_auth): + # Example of initiating a user session where the app is 100% + # responsible for the user experience. + + # 1. Initiate the login + login_initialization_info = plsdk_auth.device_user_login_initiate() + + # 2. Display necessary instructions to the user. + # + # "verification_uri" and "user_code" are required under RFC 8628. + # "verification_uri_complete" is optional under the RFC. + # + # If the user is expected to type in the URL, verification_uri will be + # shorter. If the URL may be presented in a clickable means (such as a + # link, button, or QR code) the verification_uri_complete may offer a + # better user experience. + verification_uri_complete = login_initialization_info.get( + "verification_uri_complete") + verification_uri = login_initialization_info.get("verification_uri") + user_code = login_initialization_info.get("user_code") + + print("Please activate your client.") + if verification_uri_complete: + print(f"Visit the activation site:\n" + f"\n\t{verification_uri_complete}\n" + f"\nand confirm the authorization code:\n" + f"\n\t{user_code}\n") + else: + print(f"Visit the activation site:\n" + f"\n\t{verification_uri}\n" + f"\nand enter the authorization code:\n" + f"\n\t{user_code}\n") + + # 3. Return control to the SDK. This will block until the user + # completes login. + plsdk_auth.device_user_login_complete(login_initialization_info) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_user_device_code( + client_id="__MUST_BE_APP_DEVELOPER_SUPPLIED__", + requested_scopes=[ + # Request access to Planet APIs + planet.PlanetOAuthScopes.PLANET, + # Request a refresh token so repeated browser logins are not required + planet.PlanetOAuthScopes.OFFLINE_ACCESS, + ], + save_state_to_storage=False, + ) + + # An application with no persistent storage must initialize a login every + # time. This is not smooth user experience. + initialize_user_session(plsdk_auth) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__api_key.py b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__api_key.py new file mode 100644 index 000000000..033a9e530 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__api_key.py @@ -0,0 +1,2 @@ +# No example of this use case provided at this time. +# The use of M2M OAuth sessions is encouraged over the use of API keys. diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py new file mode 100644 index 000000000..61d631983 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py @@ -0,0 +1,32 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_m2m( + client_id="__MUST_BE_END_USER_SUPPLIED__", + client_secret="__MUST_BE_END_USER_SUPPLIED__", + profile_name="my-name-for-example-m2m-auth-profile", + save_state_to_storage=True, + ) + + # Explicit login is not required for M2M client use. The above is sufficient. + # plsdk_auth.user_login() + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py new file mode 100644 index 000000000..5be309780 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py @@ -0,0 +1,40 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_user_auth_code( + client_id="__MUST_BE_APP_DEVELOPER_SUPPLIED__", + requested_scopes=[ + # Request access to Planet APIs + planet.PlanetOAuthScopes.PLANET, + # Request a refresh token so repeated browser logins are not required + planet.PlanetOAuthScopes.OFFLINE_ACCESS, + ], + callback_url="http://localhost:8080", + profile_name="my-name-for-example-user-session-with-local-browser", + save_state_to_storage=True, + ) + + # In contrast to an in-memory only application that must initialize a login every + # time, an app with persistent storage can skip this when it is not needed. + if not plsdk_auth.is_initialized(): + plsdk_auth.user_login(allow_open_browser=True) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py new file mode 100644 index 000000000..d0ca115ad --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py @@ -0,0 +1,77 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def initialize_user_session(plsdk_auth): + # Example of initiating a user session where the app is 100% + # responsible for the user experience. + + # 1. Initiate the login + login_initialization_info = plsdk_auth.device_user_login_initiate() + + # 2. Display necessary instructions to the user. + # + # "verification_uri" and "user_code" are required under RFC 8628. + # "verification_uri_complete" is optional under the RFC. + # + # If the user is expected to type in the URL, verification_uri will be + # shorter. If the URL may be presented in a clickable means (such as a + # link, button, or QR code) the verification_uri_complete may offer a + # better user experience. + verification_uri_complete = login_initialization_info.get( + "verification_uri_complete") + verification_uri = login_initialization_info.get("verification_uri") + user_code = login_initialization_info.get("user_code") + + print("Please activate your client.") + if verification_uri_complete: + print(f"Visit the activation site:\n" + f"\n\t{verification_uri_complete}\n" + f"\nand confirm the authorization code:\n" + f"\n\t{user_code}\n") + else: + print(f"Visit the activation site:\n" + f"\n\t{verification_uri}\n" + f"\nand enter the authorization code:\n" + f"\n\t{user_code}\n") + + # 3. Return control to the SDK. This will block until the user + # completes login. + plsdk_auth.device_user_login_complete(login_initialization_info) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_user_device_code( + client_id="__MUST_BE_APP_DEVELOPER_SUPPLIED__", + requested_scopes=[ + # Request access to Planet APIs + planet.PlanetOAuthScopes.PLANET, + # Request a refresh token so repeated browser logins are not required + planet.PlanetOAuthScopes.OFFLINE_ACCESS, + ], + profile_name="my-name-example-user-auth-with-external-browser", + save_state_to_storage=True, + ) + + # In contrast to an in-memory only application that must initialize a login every + # time, an app with persistent storage can skip this when it is not needed. + if not plsdk_auth.is_initialized(): + initialize_user_session(plsdk_auth) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py b/examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py new file mode 100644 index 000000000..a14257592 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py @@ -0,0 +1,26 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Create an auth context with a Planet API key loaded from the + # specified file that was created with older versions of the SDK + plsdk_auth = planet.Auth.from_file("legacy_api_key_file.json") + + # Explicit login is not required for API key use. The above sufficient. + # plsdk_auth.user_login() + + # Create a Planet SDK object that uses the loaded auth session + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/cli_managed_auth_state__explicit.py b/examples/auth-session-management/cli_managed_auth_state__explicit.py new file mode 100644 index 000000000..2a8a68f08 --- /dev/null +++ b/examples/auth-session-management/cli_managed_auth_state__explicit.py @@ -0,0 +1,32 @@ +import json +import logging +import planet +import sys + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Explicitly load the user's auth session from disk. The user must have + # invoked `planet auth login` before this program is run, or the API calls + # will fail. This will not initialize a new session. + plsdk_auth = planet.Auth.from_user_default_session() + + if not plsdk_auth.is_initialized(): + print( + "Login required. Execute the following command:\n\n\tplanet auth login\n" + ) + sys.exit(99) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/cli_managed_auth_state__implicit.py b/examples/auth-session-management/cli_managed_auth_state__implicit.py new file mode 100644 index 000000000..19b73f608 --- /dev/null +++ b/examples/auth-session-management/cli_managed_auth_state__implicit.py @@ -0,0 +1,21 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # By default, the Planet SDK will be instantiated with the default auth + # session configured by `planet auth` and saved to disk. This default + # initialization will also take information from environment variables. + pl = planet.Planet() + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py b/examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py new file mode 100644 index 000000000..f1583ac91 --- /dev/null +++ b/examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py @@ -0,0 +1,38 @@ +import json +import logging +import planet +import sys + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Explicitly load the user's auth session from disk for a specific + # authentication session ("profile"). The user must have invoked + # `planet auth login` before this program is run or the program + # must have performed a login() elsewhere prior to this example. + # If this has not been done, the API calls will fail. This example + # does not initialize a new session. + plsdk_auth = planet.Auth.from_profile( + profile_name="my-cli-managed-profile") + + # If required, how to login depends on what is configured in the specific + # profile. See other examples for login calls. + if not plsdk_auth.is_initialized(): + print( + "Login required. Execute the following command:\n\n\tplanet auth login --auth-profile my-cli-managed-profile\n" + ) + sys.exit(99) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/legacy_api_key_file.json b/examples/auth-session-management/legacy_api_key_file.json new file mode 100644 index 000000000..548a94c9d --- /dev/null +++ b/examples/auth-session-management/legacy_api_key_file.json @@ -0,0 +1,3 @@ +{ + "key": "__PLANET_API_KEY_MUST_BE_END_USER_SUPPLIED__" +} diff --git a/mkdocs.yml b/mkdocs.yml index 7823410b1..7c493a124 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,6 +49,9 @@ extra_css: plugins: - search + - macros: + include_dir: 'examples' + on_error_fail: true - mkdocstrings: handlers: python: @@ -83,6 +86,7 @@ nav: - cli/cli-reference.md - "Python": - python/sdk-guide.md + - python/sdk-client-auth.md - python/async-sdk-guide.md - python/sdk-reference.md - "Resources": diff --git a/noxfile.py b/noxfile.py index 620014b82..46b70b6a4 100644 --- a/noxfile.py +++ b/noxfile.py @@ -55,6 +55,7 @@ def test(session): '-v', '-Werror', '-Wignore::DeprecationWarning:tqdm.std', + '-Wignore::PendingDeprecationWarning:planet.auth', *options) diff --git a/planet/__init__.py b/planet/__init__.py index fe2729fe4..8858af955 100644 --- a/planet/__init__.py +++ b/planet/__init__.py @@ -16,12 +16,14 @@ from . import data_filter, order_request, reporting, subscription_request from .__version__ import __version__ # NOQA from .auth import Auth +from .auth_builtins import PlanetOAuthScopes from .clients import DataClient, FeaturesClient, OrdersClient, SubscriptionsClient # NOQA from .io import collect from .sync import Planet __all__ = [ 'Auth', + 'PlanetOAuthScopes', 'collect', 'DataClient', 'data_filter', diff --git a/planet/auth.py b/planet/auth.py index 57a4b6ce5..385a50f66 100644 --- a/planet/auth.py +++ b/planet/auth.py @@ -1,5 +1,5 @@ # Copyright 2020 Planet Labs, Inc. -# Copyright 2022 Planet Labs PBC. +# Copyright 2022, 2024, 2025 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. @@ -15,93 +15,361 @@ """Manage authentication with Planet APIs""" from __future__ import annotations # https://stackoverflow.com/a/33533514 import abc -import json -import logging +import copy import os import pathlib -import stat import typing -from typing import Optional - +import warnings import httpx -import jwt +from typing import List + +from .auth_builtins import _ProductionEnv, _OIDC_AUTH_CLIENT_CONFIG__USER_SKEL, _OIDC_AUTH_CLIENT_CONFIG__M2M_SKEL +import planet_auth +import planet_auth_utils + +from .constants import SECRET_FILE_PATH +from .exceptions import PlanetError + +planet_auth.setStructuredLogging(nested_key=None) + + +class Auth(abc.ABC, httpx.Auth): + """ + Handle authentication information for use with Planet APIs. + Static constructor methods should be used to create an auth context + that can be used by Planet API client modules to authenticate + requests made to the Planet service. + """ + + @staticmethod + def _normalize_profile_name(profile_name: str): + if profile_name.find(os.sep) != -1: + raise ValueError(f"Profile names cannot contain '{os.sep}'") + return profile_name.lower() + + @staticmethod + def from_user_default_session() -> Auth: + """ + Create authentication from user defaults. + + This method should be used when an application wants to defer + auth profile management to the user and the `planet auth` CLI + command entirely. + + Users may use the `planet auth login` command to initialize + and manage sessions. -from . import http -from .constants import ENV_API_KEY, PLANET_BASE_URL, SECRET_FILE_PATH -from .exceptions import AuthException + Defaults take into account environment variables (highest priority), + user configuration saved to `~/.planet.json` and `~/.planet/ + (next priority), and built-in defaults (lowest priority). -LOGGER = logging.getLogger(__name__) + This method does not support the use a custom storage provider. + The session must be initialized entirely in memory (e.g. through + environment variables), or from on disk CLI managed settings. -BASE_URL = f'{PLANET_BASE_URL}/v0/auth' + Environment Variables: + PL_AUTH_CLIENT_ID: Specify an OAuth2 M2M client ID + PL_AUTH_CLIENT_SECRET: Specify an OAuth2 M2M client secret + PL_AUTH_API_KEY: Specify a legacy Planet API key + PL_AUTH_PROFILE: Specify a custom planet_auth library auth + client profile (Advanced use cases) + """ + return _PLAuthLibAuth(plauth=planet_auth_utils.PlanetAuthFactory. + initialize_auth_client_context()) -AuthType = httpx.Auth + @staticmethod + def from_profile(profile_name: str) -> Auth: + """ + Create authentication for a user whose initialized login information + has been saved to `~/.planet.json` and `~/.planet/`. + A user should perform a login to initialize this session out-of-band + using the command `planet auth login`. -class Auth(metaclass=abc.ABCMeta): - """Handle authentication information for use with Planet APIs.""" + To initialize this session programmatically without the CLI, + you must complete an OAuth2 user login flow with one of the login + methods. + This method does not support the use a custom storage provider. + """ + pl_authlib_context = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context( + auth_profile_opt=profile_name) + return _PLAuthLibAuth(plauth=pl_authlib_context) + + # TODO: add support for confidential clients @staticmethod - def from_key(key: str) -> AuthType: + def from_oauth_user_auth_code( + client_id: str, + callback_url: str, + requested_scopes: typing.Optional[List[str]] = None, + save_state_to_storage: bool = True, + profile_name: typing.Optional[str] = None, + storage_provider: typing.Optional[ + planet_auth.ObjectStorageProvider] = None, + ) -> Auth: + """ + Create authentication for the specified registered client + application. + + Developers of applications must register clients with + Planet, and will be issued a Client ID as part of that process. + Developers should register a client for each distinct application so + that end-users may discretely manage applications permitted to access + Planet APIs on their behalf. + + This method does not perform a user login to initialize a session. + If not initialized out of band using the CLI, sessions must be initialized + with the user_login() before API calls may be made. + + Parameters: + client_id: Client ID + requested_scopes: List of requested OAuth2 scopes + callback_url: Client callback URL + profile_name: User friendly name to use when saving the configuration + to storage per the `save_state_to_storage` flag. The profile name + will be normalized to a file system compatible identifier, + regardless of storage provider. + save_state_to_storage: Boolean controlling whether login sessions + should be saved to storage. When the default storage provider is + used, they will be stored in a way that is compatible with + the `planet` CLI. + storage_provider: A custom storage provider to save session state + for the application. + """ + plauth_config_dict = copy.deepcopy(_OIDC_AUTH_CLIENT_CONFIG__USER_SKEL) + plauth_config_dict["client_type"] = "oidc_auth_code" + plauth_config_dict["client_id"] = client_id + if requested_scopes: + plauth_config_dict["scopes"] = requested_scopes + plauth_config_dict["redirect_uri"] = callback_url + + if not profile_name: + profile_name = client_id + normalized_profile_name = Auth._normalize_profile_name(profile_name) + + pl_authlib_context = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context_from_custom_config( + client_config=plauth_config_dict, + initial_token_data={}, + save_token_file=save_state_to_storage, + profile_name=normalized_profile_name, + save_profile_config=save_state_to_storage, + storage_provider=storage_provider, + ) + + return Auth._from_plauth(pl_authlib_context) + + # TODO: add support for confidential clients + @staticmethod + def from_oauth_user_device_code( + client_id: str, + requested_scopes: typing.Optional[List[str]] = None, + save_state_to_storage: bool = True, + profile_name: typing.Optional[str] = None, + storage_provider: typing.Optional[ + planet_auth.ObjectStorageProvider] = None + ) -> Auth: + """ + Create authentication for the specified registered client + application. + + Developers of applications must register clients with + Planet, and will be issued a Client ID as part of that process. + Developers should register a client for each distinct application so + that end-users may discretely manage applications permitted to access + Planet APIs on their behalf. + + This method does not perform a user login to initialize a session. + + This method does not perform a user login to initialize a session. + If not initialized out of band using the CLI, sessions must be initialized + with the device login methods before API calls may be made. + + Parameters: + client_id: Client ID + requested_scopes: List of requested OAuth2 scopes + profile_name: User friendly name to use when saving the configuration + to storage per the `save_state_to_storage` flag. The profile name + will be normalized to file system compatible identifier, regardless + of the storage provider being used. + save_state_to_storage: Boolean controlling whether login sessions + should be saved to storage. When the default storage provider is + used, they will be stored in a way that is compatible with + the `planet` CLI. + storage_provider: A custom storage provider to save session state + for the application. + """ + plauth_config_dict = copy.deepcopy(_OIDC_AUTH_CLIENT_CONFIG__USER_SKEL) + plauth_config_dict["client_type"] = "oidc_device_code" + plauth_config_dict["client_id"] = client_id + if requested_scopes: + plauth_config_dict["scopes"] = requested_scopes + + if not profile_name: + profile_name = client_id + normalized_profile_name = Auth._normalize_profile_name(profile_name) + + pl_authlib_context = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context_from_custom_config( + client_config=plauth_config_dict, + initial_token_data={}, + save_token_file=save_state_to_storage, + profile_name=normalized_profile_name, + save_profile_config=save_state_to_storage, + storage_provider=storage_provider, + ) + + return Auth._from_plauth(pl_authlib_context) + + @staticmethod + def from_oauth_m2m( + client_id: str, + client_secret: str, + requested_scopes: typing.Optional[List[str]] = None, + save_state_to_storage: bool = True, + profile_name: typing.Optional[str] = None, + storage_provider: typing.Optional[ + planet_auth.ObjectStorageProvider] = None, + ) -> Auth: + """ + Create authentication from the specified OAuth2 service account + client ID and secret. + + Parameters: + client_id: Planet service account client ID. + client_secret: Planet service account client secret. + requested_scopes: List of requested OAuth2 scopes + profile_name: User friendly name to use when saving the configuration + to storage per the `save_state_to_storage` flag. The profile name + will be normalized to a file system compatible identifier regardless + of the storage provider being used. + save_state_to_storage: Boolean controlling whether login sessions + should be saved to storage. When the default storage provider is + used, they will be stored in a way that is compatible with + the `planet` CLI. + storage_provider: A custom storage provider to save session state + for the application. + """ + plauth_config_dict = copy.deepcopy(_OIDC_AUTH_CLIENT_CONFIG__M2M_SKEL) + plauth_config_dict["client_id"] = client_id + plauth_config_dict["client_secret"] = client_secret + if requested_scopes: + plauth_config_dict["scopes"] = requested_scopes + + if not profile_name: + profile_name = client_id + normalized_profile_name = Auth._normalize_profile_name(profile_name) + + pl_authlib_context = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context_from_custom_config( + client_config=plauth_config_dict, + initial_token_data={}, + save_token_file=save_state_to_storage, + profile_name=normalized_profile_name, + save_profile_config=save_state_to_storage, + storage_provider=storage_provider, + ) + return Auth._from_plauth(pl_authlib_context) + + @staticmethod + def _from_plauth(pl_authlib_context: planet_auth.Auth) -> Auth: + """ + Create authentication from the provided Planet Auth Library + Authentication Context. Generally, applications will want to use one + of the Auth Library factory helpers to construct this context (See the + factory class). + + This method is intended for advanced use cases where the developer + has their own client ID registered, and is familiar with the + Planet Auth Library. (Registering client IDs is a feature of the + Planet Platform not yet released to the public as of January 2025.) + """ + return _PLAuthLibAuth(plauth=pl_authlib_context) + + @staticmethod + def from_key(key: typing.Optional[str]) -> Auth: """Obtain authentication from api key. Parameters: key: Planet API key """ - auth = APIKeyAuth(key=key) - LOGGER.debug('Auth obtained from api key.') - return auth + warnings.warn( + "Planet API keys will be deprecated for most use cases." + " Initialize an OAuth client, or create an OAuth service account." + " Proceeding for now.", + PendingDeprecationWarning) + if not key: + raise APIKeyAuthException('API key cannot be empty.') + + pl_authlib_context = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context( + auth_api_key_opt=key, + save_token_file=False, + ) + return _PLAuthLibAuth(plauth=pl_authlib_context) @staticmethod def from_file( - filename: Optional[typing.Union[str, - pathlib.Path]] = None) -> AuthType: + filename: typing.Optional[typing.Union[str, + pathlib.Path]] = None) -> Auth: """Create authentication from secret file. - The secret file is named `.planet.json` and is stored in the user + The default secret file is named `.planet.json` and is stored in the user directory. The file has a special format and should have been created with `Auth.write()`. + Pending deprecation: + OAuth2, which should replace API keys in most cases does not have + a direct replacement for "from_file()" in many cases. + The format of the `.planet.json file` is changing with the + migration of Planet APIs to OAuth2. With that, this method is + also being deprecated as a means to bootstrap auth configuration + with a simple API key. For the time being this method will still + be supported, but this method will fail if the file is present + with only new configuration fields, and lacks the legacy API key + field. + Parameters: filename: Alternate path for the planet secret file. """ - filename = filename or SECRET_FILE_PATH - - try: - secrets = _SecretFile(filename).read() - auth = APIKeyAuth.from_dict(secrets) - except FileNotFoundError: - raise AuthException(f'File {filename} does not exist.') - except (KeyError, json.decoder.JSONDecodeError): - raise AuthException(f'File {filename} is not the correct format.') - - LOGGER.debug(f'Auth read from secret file {filename}.') - return auth + warnings.warn("Auth.from_file() will be deprecated.", + PendingDeprecationWarning) + plauth_config = { + **_ProductionEnv.LEGACY_AUTH_AUTHORITY, + "client_type": planet_auth.PlanetLegacyAuthClientConfig.meta().get( + "client_type"), + } + pl_authlib_context = planet_auth.Auth.initialize_from_config_dict( + client_config=plauth_config, + token_file=filename or SECRET_FILE_PATH) + return _PLAuthLibAuth(plauth=pl_authlib_context) @staticmethod - def from_env(variable_name: Optional[str] = None) -> AuthType: - """Create authentication from environment variable. + def from_env(variable_name: typing.Optional[str] = None) -> Auth: + """Create authentication from environment variables. Reads the `PL_API_KEY` environment variable + Pending Deprecation: + This method is pending deprecation. The method `from_defaults()` + considers environment variables and configuration files through + the planet_auth and planet_auth_utils libraries, and works with + legacy API keys, OAuth2 M2M clients, OAuth2 interactive profiles. + This method should be used in most cases as a replacement. + Parameters: variable_name: Alternate environment variable. """ - variable_name = variable_name or ENV_API_KEY - api_key = os.getenv(variable_name, '') - try: - auth = APIKeyAuth(api_key) - LOGGER.debug(f'Auth set from environment variable {variable_name}') - except APIKeyAuthException: - raise AuthException( - f'Environment variable {variable_name} either does not exist ' - 'or is empty.') - return auth + warnings.warn( + "from_env() will be deprecated. Use from_defaults() in most" + " cases, which will consider both environment variables and user" + " configuration files.", + PendingDeprecationWarning) + variable_name = variable_name or planet_auth_utils.EnvironmentVariables.AUTH_API_KEY + api_key = os.getenv(variable_name, None) + return Auth.from_key(api_key) @staticmethod def from_login(email: str, password: str, - base_url: Optional[str] = None) -> AuthType: + base_url: typing.Optional[str] = None) -> Auth: """Create authentication from login email and password. Note: To keep your password secure, the use of `getpass` is @@ -113,159 +381,104 @@ def from_login(email: str, base_url: The base URL to use. Defaults to production authentication API base url. """ - cl = AuthClient(base_url=base_url) - auth_data = cl.login(email, password) - - api_key = auth_data['api_key'] - auth = APIKeyAuth(api_key) - LOGGER.debug('Auth set from login email and password') - return auth + raise DeprecationWarning( + "Auth.from_login() has been deprecated. Use Auth.from_user_session()." + ) @classmethod - @abc.abstractmethod - def from_dict(cls, data: dict) -> AuthType: - pass + def from_dict(cls, data: dict) -> Auth: + raise DeprecationWarning("Auth.from_dict() has been deprecated.") - @property - @abc.abstractmethod - def value(self): - pass - - @abc.abstractmethod def to_dict(self) -> dict: - pass + raise DeprecationWarning("Auth.to_dict() has been deprecated.") def store(self, - filename: Optional[typing.Union[str, pathlib.Path]] = None): - """Store authentication information in secret file. + filename: typing.Optional[typing.Union[str, + pathlib.Path]] = None): + raise DeprecationWarning("Auth.store() has been deprecated.") - Parameters: - filename: Alternate path for the planet secret file. - """ - filename = filename or SECRET_FILE_PATH - secret_file = _SecretFile(filename) - secret_file.write(self.to_dict()) + @property + def value(self): + raise DeprecationWarning("Auth.value has been deprecated.") + @abc.abstractmethod + def user_login( + self, + allow_open_browser: typing.Optional[bool] = False, + allow_tty_prompt: typing.Optional[bool] = False, + ): + """ + Perform an interactive login. User interaction will be via the TTY + and/or a local web browser, with the details dependent on the + client auth configuration. -class AuthClient: + :param allow_open_browser: + :param allow_tty_prompt: + """ - def __init__(self, base_url: Optional[str] = None): + @abc.abstractmethod + def device_user_login_initiate(self) -> dict: """ - Parameters: - base_url: The base URL to use. Defaults to production - authentication API base url. + Initiate a user login that uses the OAuth2 Device Code Flow for applications + that cannot operate a browser locally. The returned dictionary should be used + to prompt the user to complete the process, and will conform to RFC 8628. """ - self._base_url = base_url or BASE_URL - if self._base_url.endswith('/'): - self._base_url = self._base_url[:-1] - - def login(self, email: str, password: str) -> dict: - """Login using email identity and credentials. - - Note: To keep your password secure, the use of `getpass` is - recommended. - - Parameters: - email: Planet account email address. - password: Planet account password. - Returns: - A JSON object containing an `api_key` property with the user's - API_KEY. + @abc.abstractmethod + def device_user_login_complete(self, login_initialization_info: dict): + """ + Complete a user login that uses the OAuth2 Device Code Flow for applications + that was initiated by a call to `device_user_login_initiate()`. The structure + that was returned from `device_user_login_initiate()` should be passed + to this function unaltered after it has been used to prompt the user. """ - url = f'{self._base_url}/login' - data = {'email': email, 'password': password} - - sess = http.AuthSession() - resp = sess.request(url=url, method='POST', json=data) - return self.decode_response(resp) - @staticmethod - def decode_response(response): - """Decode the token JWT""" - token = response.json()['token'] - return jwt.decode(token, options={'verify_signature': False}) + @abc.abstractmethod + def is_initialized(self) -> bool: + """ + Check whether the user session has been initialized. For OAuth2 + user based sessions, this means that a login has been performed + or saved login session data has been located. For M2M and API Key + sessions, this should be true if keys or secrets have been + properly configured. + """ -class APIKeyAuthException(AuthException): +class APIKeyAuthException(PlanetError): """exceptions thrown by APIKeyAuth""" pass -class APIKeyAuth(httpx.BasicAuth, Auth): - """Planet API Key authentication.""" - DICT_KEY = 'key' - - def __init__(self, key: str): - """Initialize APIKeyAuth. - - Parameters: - key: API key. - - Raises: - APIKeyException: If API key is None or empty string. - """ - if not key: - raise APIKeyAuthException('API key cannot be empty.') - self._key = key - super().__init__(self._key, '') - - @classmethod - def from_dict(cls, data: dict) -> APIKeyAuth: - """Instantiate APIKeyAuth from a dict.""" - api_key = data[cls.DICT_KEY] - return cls(api_key) - - def to_dict(self): - """Represent APIKeyAuth as a dict.""" - return {self.DICT_KEY: self._key} - - @property - def value(self): - return self._key - - -class _SecretFile: - - def __init__(self, path: typing.Union[str, pathlib.Path]): - self.path = pathlib.Path(path) - - self.permissions = stat.S_IRUSR | stat.S_IWUSR # user rw - - # in sdk versions <=2.0.0, secret file was created with the wrong - # permissions, fix this automatically as well as catching the unlikely - # cases where the permissions get changed externally - self._enforce_permissions() +class _PLAuthLibAuth(Auth): + # The Planet Auth Library uses a "has a" authenticator pattern for its + # planet_auth.Auth context class. This SDK library employs a "is a" + # authenticator design pattern for users of its Auth context obtained + # from the constructors above. This class smooths over that design + # difference as we move to using the Planet Auth Library. + def __init__(self, plauth: planet_auth.Auth): + self._plauth = plauth - def write(self, contents: dict): - try: - secrets_to_write = self.read() - secrets_to_write.update(contents) - except (FileNotFoundError, KeyError, json.decoder.JSONDecodeError): - secrets_to_write = contents + def auth_flow(self, r: httpx._models.Request): + return self._plauth.request_authenticator().auth_flow(r) - self._write(secrets_to_write) + def user_login( + self, + allow_open_browser: typing.Optional[bool] = False, + allow_tty_prompt: typing.Optional[bool] = False, + ): + self._plauth.login( + allow_open_browser=allow_open_browser, + allow_tty_prompt=allow_tty_prompt, + ) - def _write(self, contents: dict): - LOGGER.debug(f'Writing to {self.path}') + def device_user_login_initiate(self) -> dict: + return self._plauth.device_login_initiate() - def opener(path, flags): - return os.open(path, flags, self.permissions) + def device_user_login_complete(self, login_initialization_info: dict): + return self._plauth.device_login_complete(login_initialization_info) - with open(self.path, 'w', opener=opener) as fp: - fp.write(json.dumps(contents)) + def is_initialized(self) -> bool: + return self._plauth.request_authenticator_is_ready() - def read(self) -> dict: - LOGGER.debug(f'Reading from {self.path}') - with open(self.path, 'r') as fp: - contents = json.loads(fp.read()) - return contents - def _enforce_permissions(self): - """if the file's permissions are not what they should be, fix them""" - if self.path.exists(): - # in octal, permissions is the last three bits of the mode - file_permissions = self.path.stat().st_mode & 0o777 - if file_permissions != self.permissions: - LOGGER.info('Fixing planet secret file permissions.') - self.path.chmod(self.permissions) +AuthType = Auth diff --git a/planet/auth_builtins.py b/planet/auth_builtins.py new file mode 100644 index 000000000..f870639d8 --- /dev/null +++ b/planet/auth_builtins.py @@ -0,0 +1,155 @@ +# Copyright 2024-2025 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. +import os +from typing import Dict, List, Optional +from planet_auth_config_injection import ( + AUTH_BUILTIN_PROVIDER, + BuiltinConfigurationProviderInterface, +) + +# Needs to be set before any planet_auth or planet_auth_utils imports. +os.environ[ + AUTH_BUILTIN_PROVIDER] = "planet.auth_builtins._BuiltinConfigurationProvider" + + +# No StrEnum in our lowest supported Python version +# class PlanetOAuthScopes(enum.StrEnum): +class PlanetOAuthScopes: + """ + Planet OAuth2 Scopes + """ + PLANET = "planet" + OFFLINE_ACCESS = "offline_access" + OPENID = "openid" + PROFILE = "profile" + EMAIL = "email" + + +class _ProductionEnv: + OAUTH_AUTHORITY_USER = { + "_comment": "OIDC/OAuth server used by Planet Public API endpoints", + "auth_server": "https://login.planet.com/", + "audiences": ["https://api.planet.com/"] + } + OAUTH_AUTHORITY_M2M = { + "_comment": "OIDC/OAuth server used by Planet Public API endpoints", + "auth_server": "https://services.sentinel-hub.com/auth/realms/main", + "audiences": ["https://api.planet.com/"] + } + LEGACY_AUTH_AUTHORITY = { + "_comment": "Planet legacy JWT auth server used by Planet Public API endpoints", + "legacy_auth_endpoint": "https://api.planet.com/v0/auth/login" + } + PUBLIC_OAUTH_AUTHORITIES = [ + OAUTH_AUTHORITY_USER, + OAUTH_AUTHORITY_M2M, + ] + + +_SDK_CLIENT_ID_PROD = "49lHVBYlXCdfIYqE1B9zeXt0iFHSXees" + +_OIDC_AUTH_CLIENT_CONFIG__USER_SKEL = { + **_ProductionEnv.OAUTH_AUTHORITY_USER, + "scopes": [ + PlanetOAuthScopes.PLANET, + PlanetOAuthScopes.OFFLINE_ACCESS, + # PlanetOAuthScopes.OPENID, + # PlanetOAuthScopes.PROFILE, + # PlanetOAuthScopes.EMAIL + ], + # "client_type": "oidc_device_code", # Must be provided when hydrating the SKEL + # "client_id": _SDK_CLIENT_ID_PROD, # Must be provided when hydrating the SKEL +} + +_OIDC_AUTH_CLIENT_CONFIG__SDK_PROD = { + # The well known OIDC client that is the Planet Python CLI. + # Developers should register their own clients so that users may + # manage grants for different applications. Registering applications + # also allows for application specific URLs or auth flow selection. + **_OIDC_AUTH_CLIENT_CONFIG__USER_SKEL, + "client_type": "oidc_device_code", + "client_id": _SDK_CLIENT_ID_PROD, + # FIXME: scopes currently from SKEL. + # It would be better to have per-client defaults and limits enforced by the auth server +} + +_OIDC_AUTH_CLIENT_CONFIG__M2M_SKEL = { + **_ProductionEnv.OAUTH_AUTHORITY_M2M, + "client_type": "oidc_client_credentials_secret", + # FIXME: we do not have scope or behavior parity between our M2M and our user OAuth authorities. + "scopes": [], + # "client_id": "__MUST_BE_USER_SUPPLIED__", + # "client_secret": "__MUST_BE_USER_SUPPLIED__", + # "scopes": ["planet"], + # "audiences": [""] + "_hidden": True, +} + +_LEGACY_AUTH_CLIENT_CONFIG__PROD = { + **_ProductionEnv.LEGACY_AUTH_AUTHORITY, + "client_type": "planet_legacy", + "_hidden": True, +} + + +class _BuiltinConfigurationProvider(BuiltinConfigurationProviderInterface): + """ + Concrete implementation of built-in client profiles for the planet_auth + library that pertain to the Planet Lab's cloud service. + """ + + # Real + # Using the client ID as a profile name might be nice, but is tricky... + # We normalize directory paths to lower case. The auth implementation uses + # mixed case ID strings. The odds of case normalized IDs colliding is low, + # but there is a bit of an off smell. + # BUILTIN_PROFILE_NAME_SDKCLI_CLIENT_ID = _SDK_CLIENT_ID_PROD + BUILTIN_PROFILE_NAME_PLANET_USER = "planet-user" + BUILTIN_PROFILE_NAME_PLANET_M2M = "planet-m2m" + BUILTIN_PROFILE_NAME_LEGACY = "legacy" + + # Aliases + # BUILTIN_PROFILE_ALIAS_PLANET_USER = "planet-user" + + _builtin_profile_auth_client_configs: Dict[str, dict] = { + # BUILTIN_PROFILE_NAME_SDKCLI_CLIENT_ID: _OIDC_AUTH_CLIENT_CONFIG__SDK_PROD, + BUILTIN_PROFILE_NAME_PLANET_USER: _OIDC_AUTH_CLIENT_CONFIG__SDK_PROD, + BUILTIN_PROFILE_NAME_PLANET_M2M: _OIDC_AUTH_CLIENT_CONFIG__M2M_SKEL, + BUILTIN_PROFILE_NAME_LEGACY: _LEGACY_AUTH_CLIENT_CONFIG__PROD, + } + + _builtin_profile_default_by_client_type = { + "oidc_device_code": BUILTIN_PROFILE_NAME_PLANET_USER, + "oidc_auth_code": BUILTIN_PROFILE_NAME_PLANET_USER, + "oidc_client_credentials_secret": BUILTIN_PROFILE_NAME_PLANET_M2M, + "planet_legacy": BUILTIN_PROFILE_NAME_LEGACY, + } + + _builtin_trust_realms: Dict[str, Optional[List[dict]]] = { + "PRODUCTION": _ProductionEnv.PUBLIC_OAUTH_AUTHORITIES, + "CUSTOM": None, + } + + def builtin_client_authclient_config_dicts(self) -> Dict[str, dict]: + return self._builtin_profile_auth_client_configs + + def builtin_default_profile_by_client_type(self) -> Dict[str, str]: + return self._builtin_profile_default_by_client_type + + def builtin_default_profile(self) -> str: + # return self.BUILTIN_PROFILE_NAME_DEFAULT + return self.BUILTIN_PROFILE_NAME_PLANET_USER + + def builtin_trust_environments(self) -> Dict[str, Optional[List[dict]]]: + return _BuiltinConfigurationProvider._builtin_trust_realms diff --git a/planet/cli/auth.py b/planet/cli/auth.py index 060336697..c3ef0b1e4 100644 --- a/planet/cli/auth.py +++ b/planet/cli/auth.py @@ -1,4 +1,4 @@ -# Copyright 2022 Planet Labs PBC. +# Copyright 2022-2025 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. @@ -13,69 +13,60 @@ # limitations under the License. """Auth API CLI""" import logging -import os - import click - -import planet -from planet.constants import ENV_API_KEY -from .cmds import translate_exceptions +import planet_auth_utils LOGGER = logging.getLogger(__name__) -@click.group() # type: ignore +@click.group("auth") # type: ignore @click.pass_context -@click.option('-u', - '--base-url', - default=None, - help='Assign custom base Auth API URL.') -def auth(ctx, base_url): - """Commands for working with Planet authentication""" - ctx.obj['BASE_URL'] = base_url +def cmd_auth(ctx): + """ + Commands for working with Planet authentication. + """ -@auth.command() # type: ignore -@click.pass_context -@translate_exceptions -@click.option( - '--email', - default=None, - prompt=True, - help=('The email address associated with your Planet credentials.')) -@click.password_option('--password', - confirmation_prompt=False, - help=('Account password. Will not be saved.')) -def init(ctx, email, password): - """Obtain and store authentication information""" - base_url = ctx.obj['BASE_URL'] - plauth = planet.Auth.from_login(email, password, base_url=base_url) - plauth.store() - click.echo('Initialized') - if os.getenv(ENV_API_KEY): - click.echo(f'Warning - Environment variable {ENV_API_KEY} already ' - 'exists. To update, with the new value, use the following:') - click.echo(f'export {ENV_API_KEY}=$(planet auth value)') +cmd_auth.add_command(name="login", cmd=planet_auth_utils.cmd_plauth_login) +planet_auth_utils.monkeypatch_hide_click_cmd_options( + planet_auth_utils.cmd_plauth_login, + [ + # Hide client ID / client secret until we are ready for OAuth M2M + "auth_client_id", + "auth_client_secret", + # Hide audience and organization. They are useful for plauth as a + # generic OAuth client, but within the planet SDK we only care about + # the built-ins. + "audience", + "organization", + # Hide project. We have not finalized or publicly released the + # project selection interface. + "project", + ]) +# TODO: mark print-api-key as deprecated when we better support M2M tokens +# planet_auth_utils.cmd_pllegacy_print_api_key.deprecated = True +cmd_auth.add_command(name="print-api-key", + cmd=planet_auth_utils.cmd_pllegacy_print_api_key) +cmd_auth.add_command(name="print-access-token", + cmd=planet_auth_utils.cmd_oauth_print_access_token) +cmd_auth.add_command(name="refresh", cmd=planet_auth_utils.cmd_oauth_refresh) +cmd_auth.add_command(name="reset", cmd=planet_auth_utils.cmd_plauth_reset) -@auth.command() # type: ignore -@translate_exceptions -def value(): - """Print the stored authentication information""" - click.echo(planet.Auth.from_file().value) + +# We are only plumbing a sub-set of the util lib's "profile" command, +# which is why we shadow it. +@click.group("profile") +@click.pass_context +def cmd_auth_profile(ctx): + """ + Manage auth profiles. + """ -@auth.command() # type: ignore -@translate_exceptions -@click.argument('key') -def store(key): - """Store authentication information""" - plauth = planet.Auth.from_key(key) - if click.confirm('This overrides the stored value. Continue?'): - plauth.store() - click.echo('Updated') - if os.getenv(ENV_API_KEY): - click.echo(f'Warning - Environment variable {ENV_API_KEY} already ' - 'exists. To update, with the new value, use the ' - 'following:') - click.echo(f'export {ENV_API_KEY}=$(planet auth value)') +cmd_auth_profile.add_command(name="list", + cmd=planet_auth_utils.cmd_profile_list) +cmd_auth_profile.add_command(name="show", + cmd=planet_auth_utils.cmd_profile_show) +cmd_auth_profile.add_command(name="set", cmd=planet_auth_utils.cmd_profile_set) +cmd_auth.add_command(cmd_auth_profile) diff --git a/planet/cli/cli.py b/planet/cli/cli.py index 328cf4f98..0c900bcdc 100644 --- a/planet/cli/cli.py +++ b/planet/cli/cli.py @@ -1,5 +1,5 @@ # Copyright 2017 Planet Labs, Inc. -# Copyright 2022 Planet Labs PBC. +# Copyright 2022, 2025 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. @@ -18,9 +18,10 @@ import click +import planet_auth_utils import planet -from . import auth, collect, data, orders, subscriptions, features +from . import auth, cmds, collect, data, orders, subscriptions, features LOGGER = logging.getLogger(__name__) @@ -36,7 +37,18 @@ default="warning", help=("Optional: set verbosity level to warning, info, or debug.\ Defaults to warning.")) -def main(ctx, verbosity, quiet): +@planet_auth_utils.opt_profile() +@planet_auth_utils.opt_client_id() +@planet_auth_utils.opt_client_secret() +@planet_auth_utils.opt_api_key() +@cmds.translate_exceptions +def main(ctx, + verbosity, + quiet, + auth_profile, + auth_client_id, + auth_client_secret, + auth_api_key): """Planet SDK for Python CLI""" _configure_logging(verbosity) @@ -45,6 +57,30 @@ def main(ctx, verbosity, quiet): ctx.ensure_object(dict) ctx.obj['QUIET'] = quiet + _configure_cli_auth_ctx(ctx, + auth_profile, + auth_client_id, + auth_client_secret, + auth_api_key) + + +def _configure_cli_auth_ctx(ctx, + auth_profile, + auth_client_id, + auth_client_secret, + auth_api_key): + # planet-auth library Auth context type + ctx.obj[ + 'AUTH'] = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context( + auth_profile_opt=auth_profile, + auth_client_id_opt=auth_client_id, + auth_client_secret_opt=auth_client_secret, + auth_api_key_opt=auth_api_key) + + # planet SDK Auth context type + ctx.obj['PLSDK_AUTH'] = planet.Auth._from_plauth( + pl_authlib_context=ctx.obj['AUTH']) + def _configure_logging(verbosity): """configure logging via verbosity level, corresponding @@ -73,9 +109,21 @@ def _configure_logging(verbosity): format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') -main.add_command(auth.auth) # type: ignore +# Hide the embedded util from help. It has many options and use cases that +# may not be directly the most relevant or user-friendly for the specific +# case of working against Planet Platform Services. +# The interface we want to support for the SDK CLI is a specialized +# subset defined by auth.py. +planet_auth_utils.cmd_plauth_embedded.hidden = True +main.add_command(cmd=planet_auth_utils.cmd_plauth_embedded, + name="plauth") # type: ignore + +main.add_command(auth.cmd_auth) # type: ignore main.add_command(data.data) # type: ignore main.add_command(orders.orders) # type: ignore main.add_command(subscriptions.subscriptions) # type: ignore main.add_command(collect.collect) # type: ignore main.add_command(features.features) + +if __name__ == "__main__": + main() # pylint: disable=E1120 diff --git a/planet/cli/cmds.py b/planet/cli/cmds.py index 9c8093134..63bbcf06c 100644 --- a/planet/cli/cmds.py +++ b/planet/cli/cmds.py @@ -18,6 +18,8 @@ import click +import planet_auth + from planet import exceptions from planet.cli.options import pretty @@ -116,11 +118,12 @@ def translate_exceptions(func): def wrapper(*args, **kwargs): try: func(*args, **kwargs) - except exceptions.AuthException: + except planet_auth.AuthException as pla_ex: raise click.ClickException( + f'{pla_ex}\n' 'Auth information does not exist or is corrupted. Initialize ' - 'with `planet auth init`.') - except exceptions.PlanetError as ex: - raise click.ClickException(str(ex)) + 'with `planet auth`.') + except (exceptions.PlanetError, FileNotFoundError) as ex: + raise click.ClickException(ex) return wrapper diff --git a/planet/cli/data.py b/planet/cli/data.py index b1830333c..7916a9432 100644 --- a/planet/cli/data.py +++ b/planet/cli/data.py @@ -42,7 +42,7 @@ @asynccontextmanager async def data_client(ctx): - async with CliSession() as sess: + async with CliSession(ctx) as sess: cl = DataClient(sess, base_url=ctx.obj['BASE_URL']) yield cl diff --git a/planet/cli/orders.py b/planet/cli/orders.py index 14589c20d..caa0c0f2a 100644 --- a/planet/cli/orders.py +++ b/planet/cli/orders.py @@ -76,7 +76,7 @@ def check_bundle(ctx, param, bundle) -> Optional[List[dict]]: @asynccontextmanager async def orders_client(ctx): base_url = ctx.obj['BASE_URL'] - async with CliSession() as sess: + async with CliSession(ctx) as sess: cl = OrdersClient(sess, base_url=base_url) yield cl diff --git a/planet/cli/session.py b/planet/cli/session.py index a3b28b4d0..8c1d3f6fc 100644 --- a/planet/cli/session.py +++ b/planet/cli/session.py @@ -1,12 +1,19 @@ """CLI HTTP/auth sessions.""" -from planet.auth import Auth from planet.http import Session class CliSession(Session): """Session with CLI-specific auth and identifying header""" - def __init__(self): - super().__init__(Auth.from_file()) + def __init__(self, click_ctx=None, plsdk_auth=None): + if click_ctx: + _plsdk_auth = click_ctx.obj['PLSDK_AUTH'] + else: + _plsdk_auth = None + + if plsdk_auth: + _plsdk_auth = plsdk_auth + + super().__init__(_plsdk_auth) self._client.headers.update({'X-Planet-App': 'python-cli'}) diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py index 3d6bb16da..cad27acd7 100644 --- a/planet/cli/subscriptions.py +++ b/planet/cli/subscriptions.py @@ -31,7 +31,7 @@ def check_item_types(ctx, param, item_types) -> Optional[List[dict]]: @asynccontextmanager async def subscriptions_client(ctx): - async with CliSession() as sess: + async with CliSession(ctx) as sess: cl = SubscriptionsClient(sess, base_url=ctx.obj['BASE_URL']) yield cl diff --git a/planet/constants.py b/planet/constants.py index c9b1843bc..67c0029ae 100644 --- a/planet/constants.py +++ b/planet/constants.py @@ -20,8 +20,6 @@ DATA_DIR = Path(os.path.dirname(__file__)) / 'data' -ENV_API_KEY = 'PL_API_KEY' - PLANET_BASE_URL = 'https://api.planet.com' SECRET_FILE_PATH = Path(os.path.expanduser('~')) / '.planet.json' diff --git a/planet/exceptions.py b/planet/exceptions.py index eee852bd0..1935e65aa 100644 --- a/planet/exceptions.py +++ b/planet/exceptions.py @@ -78,11 +78,6 @@ class ClientError(PlanetError): pass -class AuthException(ClientError): - """Exceptions encountered during authentication""" - pass - - class PagingError(ClientError): """For errors that occur during paging.""" pass diff --git a/planet/http.py b/planet/http.py index b09392369..1e20f2370 100644 --- a/planet/http.py +++ b/planet/http.py @@ -1,5 +1,5 @@ # Copyright 2020 Planet Labs, Inc. -# Copyright 2022 Planet Labs PBC. +# Copyright 2022, 2025 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 @@ -241,13 +241,7 @@ def __init__( read_timeout_secs: Maximum time to wait for data to be received. """ if auth is None: - # Try getting credentials from environment before checking - # in the secret file, this is the conventional order (AWS - # CLI, for example.) - try: - auth = Auth.from_env() - except exceptions.PlanetError: - auth = Auth.from_file() + auth = Auth.from_user_default_session() if read_timeout_secs is None: read_timeout_secs = DEFAULT_READ_TIMEOUT_SECS @@ -466,44 +460,3 @@ def client(self, 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.""" - - def __init__(self): - """Initialize an AuthSession. - """ - self._client = httpx.Client(timeout=None) - self._client.headers.update({'User-Agent': self._get_user_agent()}) - self._client.event_hooks['request'] = [self._log_request] - self._client.event_hooks['response'] = [ - self._log_response, self._raise_for_status - ] - - def request(self, method: str, url: str, json: dict): - """Submit a request - - Parameters: - method: HTTP request method. - url: Location of the API endpoint. - json: JSON to send. - - Returns: - Server response. - - Raises: - planet.exceptions.APIException: On API error. - """ - request = self._client.build_request(method=method, url=url, json=json) - http_resp = self._client.send(request) - return models.Response(http_resp) - - @classmethod - def _raise_for_status(cls, response): - try: - super()._raise_for_status(response) - except exceptions.BadQuery: - raise exceptions.APIError('Not a valid email address.') - except exceptions.InvalidAPIKey: - raise exceptions.APIError('Incorrect email or password.') diff --git a/pyproject.toml b/pyproject.toml index bf9151767..b3725d356 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "pyjwt>=2.1", "tqdm>=4.56", "typing-extensions", + "planet-auth==2.0.11b1746663950", ] readme = "README.md" requires-python = ">=3.9" @@ -33,16 +34,29 @@ license = { file = "LICENSE" } dynamic = ["version"] [project.optional-dependencies] -test = ["pytest==8.3.3", "anyio", "pytest-cov", "respx>=0.22.0"] -lint = ["flake8", "mypy", "yapf==0.43.0"] +test = [ + "pytest==8.3.3", + "anyio", + "pytest-cov", + "respx>=0.22.0", + "coverage[toml]" +] +lint = [ + "flake8", + "mypy", + "yapf==0.43.0", +] docs = [ - "mkdocs==1.4.2", - "mkdocs-click==0.7.0", - "mkdocs-material==8.2.11", - "mkdocstrings==0.18.1", - "mkdocs_autorefs==1.0.1", + "mkdocs==1.4.2", + "mkdocs-click==0.7.0", + "mkdocs-material==8.2.11", + "mkdocstrings==0.18.1", + "mkdocs_autorefs==1.0.1", + "mkdocs-macros-plugin==1.3.7" +] +dev = [ + "planet[test, docs, lint]", ] -dev = ["planet[test, docs, lint]"] [project.scripts] planet = "planet.cli.cli:main" diff --git a/setup.cfg b/setup.cfg index 9e8fc8855..dfa8ff892 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,7 +9,7 @@ exclude = examples, tests [tool:pytest] addopts = - -rxXs + -v -rxXs --cov --cov-report=term [coverage:run] source = planet, tests @@ -18,7 +18,7 @@ branch = True [coverage:report] skip_covered = True show_missing = True -fail_under = 98 +fail_under = 90 [yapf] based_on_style = pep8 diff --git a/tests/conftest.py b/tests/conftest.py index 073e2bd22..57d5a0f9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,26 +21,10 @@ import pytest -from planet.auth import _SecretFile - _here = Path(os.path.abspath(os.path.dirname(__file__))) _test_data_path = _here / 'data' -@pytest.fixture(autouse=True, scope='module') -def test_secretfile_read(): - """Returns valid auth results as if reading a secret file""" - - def mockreturn(self): - return {'key': 'testkey'} - - # monkeypatch fixture is not available above a function scope - # usage: https://docs.pytest.org/en/6.2.x/reference.html#pytest.MonkeyPatch - with pytest.MonkeyPatch.context() as mp: - mp.setattr(_SecretFile, 'read', mockreturn) - yield - - @pytest.fixture def open_test_img(): img_path = _test_data_path / 'test_sm.tif' diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py deleted file mode 100644 index ee8be11b3..000000000 --- a/tests/integration/test_auth_api.py +++ /dev/null @@ -1,77 +0,0 @@ -# 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 -# 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 -import logging - -import httpx -import jwt -import pytest -import respx - -from planet import exceptions -from planet.auth import AuthClient - -TEST_URL = 'http://MockNotRealURL/api/path' -TEST_LOGIN_URL = f'{TEST_URL}/login' - -LOGGER = logging.getLogger(__name__) - - -@respx.mock -def test_AuthClient_success(): - payload = {'api_key': 'iamakey'} - resp = {'token': jwt.encode(payload, 'key')} - mock_resp = httpx.Response(HTTPStatus.OK, json=resp) - respx.post(TEST_LOGIN_URL).return_value = mock_resp - - cl = AuthClient(base_url=TEST_URL) - auth_data = cl.login('email', 'password') - - assert auth_data == payload - - -@respx.mock -def test_AuthClient_invalid_email(): - resp = { - "errors": { - "email": ["Not a valid email address."] - }, - "message": "error validating request against UserAuthenticationSchema", - "status": 400, - "success": False - } - mock_resp = httpx.Response(400, json=resp) - respx.post(TEST_LOGIN_URL).return_value = mock_resp - - cl = AuthClient(base_url=TEST_URL) - with pytest.raises(exceptions.APIError, - match='Not a valid email address.'): - _ = cl.login('email', 'password') - - -@respx.mock -def test_AuthClient_invalid_password(): - resp = { - "errors": None, - "message": "Invalid email or password", - "status": 401, - "success": False - } - mock_resp = httpx.Response(401, json=resp) - respx.post(TEST_LOGIN_URL).return_value = mock_resp - - cl = AuthClient(base_url=TEST_URL) - with pytest.raises(exceptions.APIError, - match='Incorrect email or password.'): - _ = cl.login('email', 'password') diff --git a/tests/integration/test_auth_cli.py b/tests/integration/test_auth_cli.py deleted file mode 100644 index 62fbd3563..000000000 --- a/tests/integration/test_auth_cli.py +++ /dev/null @@ -1,123 +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. -from http import HTTPStatus -import json -import os - -from click.testing import CliRunner -import httpx -import jwt -import pytest -import respx - -from planet.cli import cli - -TEST_URL = 'http://MockNotRealURL/api/path' -TEST_LOGIN_URL = f'{TEST_URL}/login' - - -# skip the global mock of _SecretFile.read -# for this module -@pytest.fixture(autouse=True, scope='module') -def test_secretfile_read(): - return - - -@pytest.fixture -def redirect_secretfile(tmp_path): - """patch the cli so it works with a temporary secretfile - - this is to avoid collisions with the actual planet secretfile - """ - secretfile_path = tmp_path / 'secret.json' - - with pytest.MonkeyPatch.context() as mp: - mp.setattr(cli.auth.planet.auth, 'SECRET_FILE_PATH', secretfile_path) - yield secretfile_path - - -@respx.mock -def test_cli_auth_init_success(redirect_secretfile): - """Test the successful auth init path - - Also tests the base-url command, since we will get an exception - if the base url is not changed to the mocked url - """ - payload = {'api_key': 'test_cli_auth_init_success_key'} - resp = {'token': jwt.encode(payload, 'key')} - mock_resp = httpx.Response(HTTPStatus.OK, json=resp) - respx.post(TEST_LOGIN_URL).return_value = mock_resp - - result = CliRunner().invoke(cli.main, - args=['auth', '--base-url', TEST_URL, 'init'], - input='email\npw\n') - - # we would get a 'url not mocked' exception if the base url wasn't - # changed to the mocked url - assert not result.exception - - assert 'Initialized' in result.output - - -@respx.mock -def test_cli_auth_init_bad_pw(redirect_secretfile): - resp = { - "errors": None, - "message": "Invalid email or password", - "status": 401, - "success": False - } - mock_resp = httpx.Response(401, json=resp) - respx.post(TEST_LOGIN_URL).return_value = mock_resp - - result = CliRunner().invoke(cli.main, - args=['auth', '--base-url', TEST_URL, 'init'], - input='email\npw\n') - - assert result.exception - assert 'Error: Incorrect email or password.\n' in result.output - - -def test_cli_auth_value_success(redirect_secretfile): - key = 'test_cli_auth_value_success_key' - content = {'key': key} - with open(redirect_secretfile, 'w') as f: - json.dump(content, f) - - result = CliRunner().invoke(cli.main, ['auth', 'value']) - assert not result.exception - assert result.output == f'{key}\n' - - -def test_cli_auth_value_failure(redirect_secretfile): - result = CliRunner().invoke(cli.main, ['auth', 'value']) - assert result.exception - assert 'Error: Auth information does not exist or is corrupted.' \ - in result.output - - -def test_cli_auth_store_cancel(redirect_secretfile): - result = CliRunner().invoke(cli.main, ['auth', 'store', 'setval'], - input='') - assert not result.exception - assert not os.path.isfile(redirect_secretfile) - - -def test_cli_auth_store_confirm(redirect_secretfile): - result = CliRunner().invoke(cli.main, ['auth', 'store', 'setval'], - input='y') - assert not result.exception - - with open(redirect_secretfile, 'r') as f: - assert json.load(f) == {'key': 'setval'} diff --git a/tests/integration/test_features_api.py b/tests/integration/test_features_api.py index 4ae554836..a8cd5c7fd 100644 --- a/tests/integration/test_features_api.py +++ b/tests/integration/test_features_api.py @@ -20,7 +20,7 @@ import respx from planet import FeaturesClient, Session -from planet.auth import APIKeyAuth +from planet.auth import Auth from planet.sync.features import FeaturesAPI pytestmark = pytest.mark.anyio # noqa @@ -47,7 +47,7 @@ TEST_COLLECTION_LIST = [TEST_COLLECTION_1, TEST_COLLECTION_2] # set up test clients -test_session = Session(auth=APIKeyAuth(key="test")) +test_session = Session(auth=Auth.from_key(key="test")) cl_async = FeaturesClient(test_session, base_url=TEST_URL) cl_sync = FeaturesAPI(test_session, base_url=TEST_URL) diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index cd8c265ec..000000000 --- a/tests/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -log_cli = True -log_format = %(asctime)s %(levelname)s %(message)s -log_date_format = %Y-%m-%d %H:%M:%S diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 51ce8f414..72c5cea76 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1,5 +1,5 @@ # Copyright 2020 Planet Labs, Inc. -# Copyright 2022 Planet Labs PBC. +# Copyright 2022, 2025 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. @@ -12,12 +12,17 @@ # 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 logging +import planet_auth_utils import pytest +import planet.auth from planet import auth +import planet_auth + +import planet.auth_builtins +from planet.auth_builtins import PlanetOAuthScopes LOGGER = logging.getLogger(__name__) @@ -37,8 +42,12 @@ def secret_path(monkeypatch, tmp_path): def test_Auth_from_key(): - test_auth_env1 = auth.Auth.from_key('testkey') - assert test_auth_env1.value == 'testkey' + test_auth_env1 = auth.Auth.from_key('testkey_from_key') + # We know that planet_auth instantiates an in memory "static API key" auth client. + # test_api_key = test_auth_env1._plauth.request_authenticator().credential().legacy_api_key() + test_api_key = test_auth_env1._plauth.request_authenticator().credential( + ).api_key() + assert test_api_key == 'testkey_from_key' def test_Auth_from_key_empty(): @@ -48,43 +57,55 @@ def test_Auth_from_key_empty(): def test_Auth_from_file(secret_path): with open(secret_path, 'w') as fp: - fp.write('{"key": "testvar"}') + fp.write('{"key": "testvar_from_file"}') test_auth = auth.Auth.from_file() - assert test_auth.value == 'testvar' + # We know that planet_auth instantiates a "Legacy" auth client. + test_api_key = test_auth._plauth.request_authenticator().credential( + ).legacy_api_key() + # test_api_key = test_auth._plauth.request_authenticator().credential().api_key() + assert test_api_key == 'testvar_from_file' def test_Auth_from_file_doesnotexist(secret_path): - with pytest.raises(auth.AuthException): - _ = auth.Auth.from_file(secret_path) + test_auth = auth.Auth.from_file(secret_path) + with pytest.raises(FileNotFoundError): + _ = test_auth._plauth.request_authenticator().credential( + ).legacy_api_key() def test_Auth_from_file_wrongformat(secret_path): with open(secret_path, 'w') as fp: - fp.write('{"notkey": "testvar"}') - - with pytest.raises(auth.AuthException): - _ = auth.Auth.from_file(secret_path) + fp.write('{"notkey": "testvar_wrong_format"}') + test_auth = auth.Auth.from_file(secret_path) + with pytest.raises(planet_auth.InvalidDataException): + _ = test_auth._plauth.request_authenticator().credential( + ).legacy_api_key() def test_Auth_from_file_alternate(tmp_path): secret_path = str(tmp_path / '.test') with open(secret_path, 'w') as fp: - fp.write('{"key": "testvar"}') + fp.write('{"key": "testvar_alt_path"}') test_auth = auth.Auth.from_file(secret_path) - assert test_auth.value == 'testvar' + test_api_key = test_auth._plauth.request_authenticator().credential( + ).legacy_api_key() + assert test_api_key == 'testvar_alt_path' def test_Auth_from_env(monkeypatch): - monkeypatch.setenv('PL_API_KEY', 'testkey') + monkeypatch.setenv('PL_API_KEY', 'testkey_env') test_auth_env = auth.Auth.from_env() - assert test_auth_env.value == 'testkey' + # TODO: that I short circuit between legacy and API key auth impls makes this weird. + test_api_key = test_auth_env._plauth.request_authenticator().credential( + ).api_key() + assert test_api_key == 'testkey_env' def test_Auth_from_env_failure(monkeypatch): monkeypatch.delenv('PL_API_KEY', raising=False) - with pytest.raises(auth.AuthException): + with pytest.raises(auth.APIKeyAuthException): _ = auth.Auth.from_env() @@ -94,7 +115,10 @@ def test_Auth_from_env_alternate_success(monkeypatch): monkeypatch.delenv('PL_API_KEY', raising=False) test_auth_env = auth.Auth.from_env(alternate) - assert test_auth_env.value == 'testkey' + test_api_key = test_auth_env._plauth.request_authenticator().credential( + ).api_key() + + assert test_api_key == 'testkey' def test_Auth_from_env_alternate_doesnotexist(monkeypatch): @@ -102,55 +126,217 @@ def test_Auth_from_env_alternate_doesnotexist(monkeypatch): monkeypatch.delenv(alternate, raising=False) monkeypatch.delenv('PL_API_KEY', raising=False) - with pytest.raises(auth.AuthException): + with pytest.raises(auth.APIKeyAuthException): _ = auth.Auth.from_env(alternate) def test_Auth_from_login(monkeypatch): - auth_data = 'authdata' + # auth.AuthClient has been completely removed + # in the conversion to planet_auth + # def login(*args, **kwargs): + # return {'api_key': auth_data} + # + # monkeypatch.setattr(auth.AuthClient, 'login', login) + with pytest.raises(DeprecationWarning): + _ = auth.Auth.from_login('email', 'pw') - def login(*args, **kwargs): - return {'api_key': auth_data} - monkeypatch.setattr(auth.AuthClient, 'login', login) +def test_Auth_from_user_defaults(): + # The primary implementation is implemented and unit tested by the planet + # auth libraries. This tests that it doesn't explode with an exception. + # CI/CD currently is run by configuring auth via PL_API_KEY env var. + # What this will actually do in a user's environment depends on a lot + # of variables. + _ = auth.Auth.from_user_default_session() + + +def test_Auth_from_profile__builtin_default_profile(): + under_test = auth.Auth.from_profile( + planet_auth_utils.Builtins.builtin_default_profile_name()) + assert isinstance(under_test, planet.auth._PLAuthLibAuth) + assert isinstance(under_test._plauth.auth_client(), + planet_auth.DeviceCodeAuthClient) + + assert under_test._plauth.auth_client( + )._devicecode_client_config.auth_server( + ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_USER[ + "auth_server"] + + assert under_test._plauth.auth_client( + )._devicecode_client_config.client_id( + ) == planet.auth_builtins._SDK_CLIENT_ID_PROD - test_auth = auth.Auth.from_login('email', 'pw') - assert test_auth.value == auth_data +def test_Auth_from_user_auth_code_client(): + under_test = auth.Auth.from_oauth_user_auth_code( + client_id="mock_client_id__auth_code_client", + callback_url="http://localhost:8080", + save_state_to_storage=False) + + assert isinstance(under_test, planet.auth._PLAuthLibAuth) + assert isinstance(under_test._plauth.auth_client(), + planet_auth.AuthCodeAuthClient) + + assert under_test._plauth.auth_client( + )._authcode_client_config.auth_server( + ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_USER[ + "auth_server"] + + assert under_test._plauth.auth_client()._authcode_client_config.client_id( + ) == "mock_client_id__auth_code_client" + + assert under_test._plauth.auth_client()._authcode_client_config.scopes( + ) == planet.auth_builtins._OIDC_AUTH_CLIENT_CONFIG__USER_SKEL["scopes"] + + +def test_Auth_from_user_auth_code_client_2(): + under_test = auth.Auth.from_oauth_user_auth_code( + client_id="mock_client_id__auth_code_client_2", + callback_url="http://localhost:8080", + requested_scopes=[PlanetOAuthScopes.PLANET], + profile_name="utest-override-default-profile-name-auth-code-2", + save_state_to_storage=False) + + assert isinstance(under_test, planet.auth._PLAuthLibAuth) + assert isinstance(under_test._plauth.auth_client(), + planet_auth.AuthCodeAuthClient) + + assert under_test._plauth.auth_client( + )._authcode_client_config.auth_server( + ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_USER[ + "auth_server"] -def test_Auth_store_doesnotexist(tmp_path): - test_auth = auth.Auth.from_key('test') - secret_path = str(tmp_path / '.test') - test_auth.store(secret_path) + assert under_test._plauth.auth_client()._authcode_client_config.client_id( + ) == "mock_client_id__auth_code_client_2" + + assert under_test._plauth.auth_client()._authcode_client_config.scopes( + ) == [PlanetOAuthScopes.PLANET] + + assert under_test._plauth.profile_name( + ) == "utest-override-default-profile-name-auth-code-2" - with open(secret_path, 'r') as fp: - assert json.loads(fp.read()) == {"key": "test"} +def test_Auth_from_user_device_code_client(): + under_test = auth.Auth.from_oauth_user_device_code( + client_id="mock_client_id__device_code_client", + save_state_to_storage=False) -def test_Auth_store_exists(tmp_path): - secret_path = str(tmp_path / '.test') + assert isinstance(under_test, planet.auth._PLAuthLibAuth) + assert isinstance(under_test._plauth.auth_client(), + planet_auth.DeviceCodeAuthClient) - with open(secret_path, 'w') as fp: - fp.write('{"existing": "exists"}') + assert under_test._plauth.auth_client( + )._devicecode_client_config.auth_server( + ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_USER[ + "auth_server"] - test_auth = auth.Auth.from_key('test') - test_auth.store(secret_path) + assert under_test._plauth.auth_client( + )._devicecode_client_config.client_id( + ) == "mock_client_id__device_code_client" - with open(secret_path, 'r') as fp: - assert json.loads(fp.read()) == {"key": "test", "existing": "exists"} + assert under_test._plauth.auth_client()._devicecode_client_config.scopes( + ) == planet.auth_builtins._OIDC_AUTH_CLIENT_CONFIG__USER_SKEL["scopes"] -def test__SecretFile_permissions_doesnotexist(secret_path): - """No exception is raised if the file doesn't exist""" - auth._SecretFile(secret_path) +def test_Auth_from_user_device_code_client_2(): + under_test = auth.Auth.from_oauth_user_device_code( + client_id="mock_client_id__device_code_client_2", + requested_scopes=[ + PlanetOAuthScopes.PLANET, + ], + profile_name="utest-override-default-profile-name-device-code-2", + save_state_to_storage=False) + assert isinstance(under_test, planet.auth._PLAuthLibAuth) + assert isinstance(under_test._plauth.auth_client(), + planet_auth.DeviceCodeAuthClient) -def test__SecretFile_permissions_incorrect(secret_path): - """Incorrect permissions are fixed""" - with open(secret_path, 'w') as fp: - fp.write('{"existing": "exists"}') + assert under_test._plauth.auth_client( + )._devicecode_client_config.auth_server( + ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_USER[ + "auth_server"] + + assert under_test._plauth.auth_client( + )._devicecode_client_config.client_id( + ) == "mock_client_id__device_code_client_2" + + assert under_test._plauth.auth_client()._devicecode_client_config.scopes( + ) == [PlanetOAuthScopes.PLANET] + + assert under_test._plauth.profile_name( + ) == "utest-override-default-profile-name-device-code-2" + + +def test_Auth_from_oauth_m2m(): + under_test = auth.Auth.from_oauth_m2m( + client_id="mock_client_id__from_oauth_m2m", + client_secret="mock_client_secret__from_oauth_m2m", + requested_scopes=[ + PlanetOAuthScopes.PLANET, + ], + ) + assert isinstance(under_test, planet.auth._PLAuthLibAuth) + assert isinstance(under_test._plauth.auth_client(), + planet_auth.ClientCredentialsClientSecretAuthClient) + + assert under_test._plauth.auth_client()._ccauth_client_config.auth_server( + ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_M2M["auth_server"] + + assert under_test._plauth.auth_client()._ccauth_client_config.client_id( + ) == "mock_client_id__from_oauth_m2m" + + assert under_test._plauth.auth_client( + )._ccauth_client_config.client_secret( + ) == "mock_client_secret__from_oauth_m2m" + + assert under_test._plauth.auth_client()._ccauth_client_config.scopes() == [ + PlanetOAuthScopes.PLANET + ] + + +def test_Auth_profile_name_normalization(): + under_test = auth.Auth.from_oauth_m2m( + client_id="mock_client_id__from_oauth_m2m", + client_secret="mock_client_secret__from_oauth_m2m", + profile_name="mIxeD_CaSe") + + assert under_test._plauth.profile_name() == "mixed_case" + + +def test_Auth_profile_name_illegal(): + with pytest.raises(ValueError): + _ = auth.Auth.from_oauth_m2m( + client_id="mock_client_id__from_oauth_m2m", + client_secret="mock_client_secret__from_oauth_m2m", + profile_name="path/sep/not/allowed") + + +def test_auth_value_deprecated(): + test_auth = auth.Auth.from_key("test_deprecated_key") + with pytest.raises(DeprecationWarning): + _ = test_auth.value + + +def test_auth_store_deprecated(): + test_auth = auth.Auth.from_key("test_deprecated_key") + with pytest.raises(DeprecationWarning): + test_auth.store() + + +def test_auth_to_dict_deprecated(): + test_auth = auth.Auth.from_key("test_deprecated_key") + with pytest.raises(DeprecationWarning): + _ = test_auth.to_dict() + + +def test_auth_from_dict_deprecated(): + with pytest.raises(DeprecationWarning): + _ = auth.Auth.from_dict({}) - secret_path.chmod(0o666) - auth._SecretFile(secret_path) - assert secret_path.stat().st_mode & 0o777 == 0o600 +def test_plauth_builtins_namespace(): + # Planet auth can prefix environment and config variables with a namespace. + # Make sure that is as we want it for the SDK. + assert planet_auth_utils.EnvironmentVariables.AUTH_API_KEY == "PL_API_KEY" + assert planet_auth_utils.EnvironmentVariables.AUTH_SCOPE == "PL_AUTH_SCOPE" + assert planet_auth_utils.EnvironmentVariables.AUTH_PROFILE == "PL_AUTH_PROFILE" diff --git a/tests/unit/test_cli_session.py b/tests/unit/test_cli_session.py index c4e95f230..ed389f70c 100644 --- a/tests/unit/test_cli_session.py +++ b/tests/unit/test_cli_session.py @@ -11,7 +11,6 @@ # 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 base64 from http import HTTPStatus import json @@ -23,7 +22,6 @@ # from planet.auth import _SecretFile from planet import auth from planet.cli import session -from planet.exceptions import AuthException TEST_URL = 'mock://mock.com' @@ -63,7 +61,9 @@ async def test_CliSession_headers(test_valid_secretfile): @respx.mock @pytest.mark.anyio async def test_CliSession_auth_valid(test_valid_secretfile): - async with session.CliSession() as sess: + # The default auth + async with session.CliSession( + plsdk_auth=auth.Auth.from_key("clisessiontest")) as sess: route = respx.get(TEST_URL) route.return_value = httpx.Response(HTTPStatus.OK) @@ -71,30 +71,11 @@ async def test_CliSession_auth_valid(test_valid_secretfile): # the proper headers are included and they have the expected values received_request = route.calls.last.request - credentials = received_request.headers['authorization'].strip( - 'Authorization: Basic ') - assert base64.b64decode(credentials) == b'clisessiontest:' - - -@respx.mock -@pytest.mark.anyio -async def test_CliSession_auth_invalid(tmp_path, monkeypatch): - # write invalid secret file - secret_path = f'{tmp_path}/secret.test' - monkeypatch.setattr(auth, 'SECRET_FILE_PATH', secret_path) - with open(secret_path, 'w') as fp: - json.dump({'invalidkey': 'clisessiontest'}, fp) - - with pytest.raises(AuthException): - session.CliSession() - - -@respx.mock -@pytest.mark.anyio -async def test_CliSession_auth_nofile(tmp_path, monkeypatch): - # point to non-existant file - secret_path = f'{tmp_path}/doesnotexist.test' - monkeypatch.setattr(auth, 'SECRET_FILE_PATH', secret_path) - - with pytest.raises(AuthException): - session.CliSession() + # The planet_auth library sends the api key as bearer token. + # The older Planet SDK sent it as HTTP basic. + # Most Planet APIs accept either (and API keys are being deprecated.) + # credentials = received_request.headers['authorization'].strip( + # 'Authorization: Basic ') + # assert base64.b64decode(credentials) == b'clisessiontest:' + credentials = received_request.headers['authorization'] + assert credentials == 'api-key clisessiontest' diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 9e538543a..762b66f8c 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -195,6 +195,7 @@ async def test_session_contextmanager(): @pytest.mark.parametrize('data', (None, {'boo': 'baa'})) async def test_session_request_success(data): + # async with http.Session(auth=planet.Auth.from_plauth(pl_authlib_context=planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context(auth_profile_opt="none"))) as ps: async with http.Session() as ps: resp_json = {'foo': 'bar'} route = respx.get(TEST_URL) @@ -282,25 +283,3 @@ def test__calculate_wait(): # this doesn't really test the randomness but does test exponential # and threshold assert math.floor(wait) == expected - - -@respx.mock -@pytest.mark.anyio -async def test_authsession_request(): - sess = http.AuthSession() - resp_json = {'token': 'foobar'} - mock_resp = httpx.Response(HTTPStatus.OK, json=resp_json) - respx.get(TEST_URL).return_value = mock_resp - - resp = sess.request(method='GET', url=TEST_URL, json={'foo': 'bar'}) - assert resp.json() == resp_json - - -def test_authsession__raise_for_status(mock_response): - with pytest.raises(exceptions.APIError): - http.AuthSession._raise_for_status( - mock_response(HTTPStatus.BAD_REQUEST, json={})) - - with pytest.raises(exceptions.APIError): - http.AuthSession._raise_for_status( - mock_response(HTTPStatus.UNAUTHORIZED, json={})) From bedbf62dd42135d99ebbfc0abc9393c6f440d828 Mon Sep 17 00:00:00 2001 From: Carl Adams <57012982+carl-adams-planet@users.noreply.github.com> Date: Tue, 20 May 2025 21:34:53 +0000 Subject: [PATCH 06/24] DRAFT: Update docs prior to pushing alpha out (#1140) * update CONTRIBUTING and README to account for use of github projects and milestones. * minor updates to examples. * minor updates to auth guide. * 3.0 migration guide. --- CONTRIBUTING.md | 10 +- README.md | 10 +- RELEASE-PLANNING-3.0.md | 13 --- .../{upgrading.md => upgrading-v2.md} | 0 docs/get-started/upgrading-v3.md | 95 +++++++++++++++++++ docs/python/sdk-client-auth.md | 18 ++-- ...th_state__app_custom_storage__oauth_m2m.py | 3 +- ..._managed_auth_state__in_memory__api_key.py | 3 - ...anaged_auth_state__in_memory__oauth_m2m.py | 8 +- ...th_state__on_disk_cli_shared__oauth_m2m.py | 3 +- .../cli_managed_auth_state__implicit.py | 7 +- mkdocs.yml | 6 +- 12 files changed, 126 insertions(+), 50 deletions(-) delete mode 100644 RELEASE-PLANNING-3.0.md rename docs/get-started/{upgrading.md => upgrading-v2.md} (100%) create mode 100644 docs/get-started/upgrading-v3.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8aded047a..fdba2711e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,10 +19,12 @@ insofar as is practical within the scope of changes targeted to the next major r versioning, major releases do not guarantee backwards compatibility. Stability is not guaranteed during the development cycle. -During the development cycle of a new major release, `RELEASE-PLANNING-X.0.md` should be maintained -with a brief summary of the major and breaking changes underpinning the reason for the upcoming -major release version. Upon release, this content is expected to be folded into package documentation -as appropriate, and this file should be removed. +During the development cycle of a new major release, a GitHub Project and Milestone should be +created to track changes targeted the release. A file such as `RELEASE-PLANNING-X.0.md` in the +root of the source tree may be used for early development prior to the creation of a GitHub +project, but should be retired when a new release becomes more formalized. Upon release, +all content is expected to be folded into package documentation as appropriate (announcements, +company blog posts, changelogs, migration guides, etc.). When a new major release is ready, the development mainline branch will be renamed to `main`, and the old mainline branch will be renamed to `maint-X.0` and will be used as the base for maintenance releases. diff --git a/README.md b/README.md index b223198d7..ddb555006 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#branches) for more information on branches ##### Current Mainline Versions and Branches -| Version | Status | Branch | Documentation | Initial Release | End of Active Development | End of Maintenance | Notes | -|---------|---------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-----------------|---------------------------|--------------------|------------------------------------------------------------------------------------------------------------------------------| -| 3.x | `development` | [`main-3.0-dev`](https://github.com/planetlabs/planet-client-python/tree/main-3.0-dev) | TBD | TBD | TBD | TBD | See [RELEASE-PLANNING-X.0.md](https://github.com/planetlabs/planet-client-python/tree/main-3.0-dev/RELEASE-PLANNING-3.0.md). | -| 2.x | `active` | [`main`](https://github.com/planetlabs/planet-client-python/tree/main) | [Planet Labs Python Client v2 on Readthedocs.io](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/) | April 2023 | TBD | TBD | | -| 1.x | `end-of-life` | [`v1`](https://github.com/planetlabs/planet-client-python/tree/v1) | [Planet Labs Python Client v1 on Github.io](https://planetlabs.github.io/planet-client-python/) | April 2017 | April 2023 | TBD | | +| Version | Status | Branch | Documentation | Initial Release | End of Active Development | End of Maintenance | Notes | +|---------|---------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-----------------|---------------------------|--------------------|-------------------------------------------------------------------------------------------------| +| 3.x | `development` | [`main-3.0-dev`](https://github.com/planetlabs/planet-client-python/tree/main-3.0-dev) | [Planet Labs Python Client on ReadTheDocs.io](https://planet-sdk-for-python.readthedocs.io/en/latest/) | TBD | TBD | TBD | See [3.0.0 Release Milestone](https://github.com/planetlabs/planet-client-python/milestone/31). | +| 2.x | `active` | [`main`](https://github.com/planetlabs/planet-client-python/tree/main) | [Planet Labs Python Client v2 on ReadTheDocs.io](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/) | April 2023 | TBD | TBD | | +| 1.x | `end-of-life` | [`v1`](https://github.com/planetlabs/planet-client-python/tree/v1) | [Planet Labs Python Client v1 on Github.io](https://planetlabs.github.io/planet-client-python/) | April 2017 | April 2023 | TBD | | ## Installation and Quick Start diff --git a/RELEASE-PLANNING-3.0.md b/RELEASE-PLANNING-3.0.md deleted file mode 100644 index 796a75f1d..000000000 --- a/RELEASE-PLANNING-3.0.md +++ /dev/null @@ -1,13 +0,0 @@ -# Planet Python Client 3.0 Release Planning - -* Authentication changes: - * Migrate to OAuth2 based authentication mechanisms, leveraging the - [planet-auth-python](https://github.com/planetlabs/planet-auth-python) - library for implementation. - * Deprecate use of the legacy authentication protocol and handling of the - user's password. - * CLI changes to support changes in authentication practices. - * Support for API keys supplied by the user is maintained, but users should - be aware that there are currently no plans for sentinel-hub.com APIs to - support Planet API keys. The longer term roadmap is for all APIs to work - with OAuth service accounts. diff --git a/docs/get-started/upgrading.md b/docs/get-started/upgrading-v2.md similarity index 100% rename from docs/get-started/upgrading.md rename to docs/get-started/upgrading-v2.md diff --git a/docs/get-started/upgrading-v3.md b/docs/get-started/upgrading-v3.md new file mode 100644 index 000000000..02151a1c1 --- /dev/null +++ b/docs/get-started/upgrading-v3.md @@ -0,0 +1,95 @@ +# Upgrade from Version 2 to Version 3 + +Version 3 of the Planet SDK for Python is a major update of the SDK offering +new features, not all of which are backwards compatible with version 2. + +## Authentication +Version 3 of the SDK removes support for Planet's legacy authentication network +protocols in favor of OAuth2 based mechanisms. The legacy protocols +were never a [documented Planet API](https://docs.planet.com/develop/apis/), but could +easily be understood by inspection of the SDK code. + +Specifically, what is being deprecated in version 3 are the paths where the SDK +handled a username and password to obtain the user's API key for forward +operations. Users may still operate with an API key by retrieving it from the +Planet user interface under [My Settings](https://www.planet.com/account/#/user-settings) +and providing it to the SDK. While API keys remain supported for machine-to-machine +API use cases using `api.planet.com` APIs, OAuth2 mechanisms should be preferred +where the use case allows for it. + +Users may also continue to initialize SDK and CLI sessions with their username +and password, but rather than being processed by the SDK itself a browser must +be invoked to complete OAuth2 client session initialization. +This new method is intended to offer a number of long term benefits, including: + +* The new method provides the SDK and the CLI with access tokens that may be + used with both `api.planet.com` and `services.sentinel-hub.com` endpoints. The method + used by version 2 of the SDK was specific to `api.planet.com` endpoints, and + will never be supported by `services.sentinel-hub.com` endpoints. +* The new method extends (currently optional) multifactor authentication (MFA) + to SDK and CLI client use cases. +* The new method is compatible with other platform enhancements currently under + development by Planet's software engineering team. + +For complete details on the new mechanisms, see the [Client Authentication Guide](../python/sdk-client-auth.md). + +### CLI Usage +The [`planet auth`](../../cli/cli-reference/#auth) command has been substantially +revised to align to the new authentication mechanisms. For migration from version 2 +of the SDK, the following changes are the most important to note: + +* The `planet auth init` command has been replaced with [`planet auth login`](../../cli/cli-reference/#login). + By default, this command will open a browser window to allow the user to log + in to their Planet account and authorize the SDK or CLI to access their account. + Other options are available to support a variety of use cases, including a + `--no-open-browser` option for remote shells. See `planet auth login --help` + for complete details. +* The `planet auth value` command has been deprecated. Depending on whether the SDK + has been initialized with OAuth2 or API key authentication, + [`planet auth print-access-token`](../../cli/cli-reference/#print-access-token) + or [`planet auth print-api-key`](../../cli/cli-reference/#print-api-key) may + be used. OAuth2 sessions should be preferred where possible. +* The `planet auth store` command has been deprecated. The various options to the + `planet auth login` command should provide suitable alternatives for all use cases. + OAuth2 sessions should be favored for user interactive use cases, such as CLI usage. + `planet auth login --auth-api-key YOUR_API_KEY` may be used to initialize the SDK + with API key based authentication where the use case requires it. + +### Session Persistence +Both version 2 and version 3 of the SDK use the `~/.planet.json` file in the user's +home directory to store user's API key. If this file is present and was configured +by version 2 of the SDK, it should continue to work. + +While the `~/.planet.json` file continues to be used by version 3, and version 3 +understands files written by version 2, version 3 will not write the same information +to this file that version 2 did. Version 3 uses this file in conjunction with the +`~/.planet` directory and subdirectories to store OAuth2 tokens and additional +session information needed for a smooth user experience. + +Version 3 of the SDK provides a [`planet auth reset`](../../cli/cli-reference/#reset) +command to reset all saved state should it become corrupted. When this command is run, +the old files are moved aside rather than deleted. + +### SDK Session Initialization +See the [Client Authentication Guide](../python/sdk-client-auth.md) for a complete +discussion of all options now available. + +Basic SDK use cases should work with no alterations. +User sessions initialized by [`planet auth login`](../../cli/cli-reference/#login) +will be detected by an application using a default Planet client when +run in an environment with access to the user's home directory. For example: + +```python linenums="1" +{% include 'auth-session-management/cli_managed_auth_state__implicit.py' %} +``` + +Applications may also continue to initialize the SDK with a specific API key as follows: +```python linenums="1" +{% include 'auth-session-management/app_managed_auth_state__in_memory__api_key.py' %} +``` + +Users developing new applications should consult the [Client Authentication Guide](../python/sdk-client-auth.md) +for a complete discussion of all OAuth2 based mechanisms. OAuth2 mechanisms +should be preferred to the use of Planet API keys. + +---- diff --git a/docs/python/sdk-client-auth.md b/docs/python/sdk-client-auth.md index bcc3adfaf..a0bf32ebb 100644 --- a/docs/python/sdk-client-auth.md +++ b/docs/python/sdk-client-auth.md @@ -8,9 +8,11 @@ For general information on how to authenticate to Planet APIs, please see the [Authentication](https://docs.planet.com/develop/authentication) section of Planet's platform documentation. !!! warning - Some statements are forward-looking. OAuth2 M2M tokens are - currently only supported by `services.sentine-hub.com` and not supported - by `api.planet.com`. + Some statements in this guide are forward-looking. + + OAuth2 M2M tokens are currently only supported by `services.sentine-hub.com`, + and are not yet supported by `api.planet.com`. This is planned for a future date + to be announced. All APIs support interactive user OAuth2 access tokens, but a process for developers to register and manage clients has not yet been made public. @@ -18,10 +20,10 @@ the [Authentication](https://docs.planet.com/develop/authentication) section of manage which applications have been authorized to access the platform on their behalf. - If you would like to developed an interactive application that uses - Planet's APIs on behalf of a user (as the `planet` CLI utility does), - please contact Planet and work with engineering to register your - application. + If you would like to develop an interactive application that uses + Planet's APIs on behalf of a logged-in user (as the `planet` CLI utility + does), please contact Planet support and work with engineering to + register your application. ---- ## Authentication Protocols @@ -213,7 +215,7 @@ complete user authentication. This architecture allows for greater security by keeping the user's password from being directly exposed to the application code. This also allows for flexibility in user federation and multifactor authentication procedures without the complexity of these needing to -be exposes to the application developer who is focused on geospatial +be exposed to the application developer who is focused on geospatial operations using the Planet platform, and not the nuances of user authentication and authorization. diff --git a/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py index 08ddb0a96..67a883204 100644 --- a/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py +++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py @@ -75,8 +75,7 @@ def _save_file(file_path: pathlib.Path, data: dict): def example_main(): - # Create an auth context with a client ID that - # is unique to this application. + # Create an auth context with the client ID and secret of the service account. plsdk_auth = planet.Auth.from_oauth_m2m( client_id="__MUST_BE_END_USER_SUPPLIED__", client_secret="__MUST_BE_END_USER_SUPPLIED__", diff --git a/examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py b/examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py index 7efb7659a..cde34a5a7 100644 --- a/examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py @@ -1,9 +1,6 @@ import json -import logging import planet -logging.basicConfig(level=logging.CRITICAL) - def example_main(): # Create an auth context with the specified API key diff --git a/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py index 517e186a7..867fdd170 100644 --- a/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py @@ -1,13 +1,9 @@ import json -import logging import planet -logging.basicConfig(level=logging.CRITICAL) - def example_main(): - # Create an auth context with a client ID that - # is unique to this application. + # Create an auth context with the client ID and secret of the service account. plsdk_auth = planet.Auth.from_oauth_m2m( client_id="__MUST_BE_END_USER_SUPPLIED__", client_secret="__MUST_BE_END_USER_SUPPLIED__", @@ -17,7 +13,7 @@ def example_main(): # Explicit login is not required for M2M client use. The above is sufficient. # plsdk_auth.user_login() - # Create a Planet SDK object that uses the loaded auth session/ + # Create a Planet SDK object that uses the loaded auth session. sess = planet.Session(plsdk_auth) pl = planet.Planet(sess) diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py index 61d631983..23b23b111 100644 --- a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py @@ -6,8 +6,7 @@ def example_main(): - # Create an auth context with a client ID that - # is unique to this application. + # Create an auth context with the client ID and secret of the service account. plsdk_auth = planet.Auth.from_oauth_m2m( client_id="__MUST_BE_END_USER_SUPPLIED__", client_secret="__MUST_BE_END_USER_SUPPLIED__", diff --git a/examples/auth-session-management/cli_managed_auth_state__implicit.py b/examples/auth-session-management/cli_managed_auth_state__implicit.py index 19b73f608..bd6397803 100644 --- a/examples/auth-session-management/cli_managed_auth_state__implicit.py +++ b/examples/auth-session-management/cli_managed_auth_state__implicit.py @@ -1,18 +1,15 @@ import json -import logging import planet -logging.basicConfig(level=logging.CRITICAL) - def example_main(): # By default, the Planet SDK will be instantiated with the default auth # session configured by `planet auth` and saved to disk. This default - # initialization will also take information from environment variables. + # initialization will also inspect environment variables for configuration. pl = planet.Planet() # Use the SDK to call Planet APIs. - # Refreshing access tokens will be managed automatically by the SDK. + # Refreshing OAuth2 access tokens will be managed automatically by the SDK. for item in pl.data.list_searches(): print(json.dumps(item, indent=2, sort_keys=True)) diff --git a/mkdocs.yml b/mkdocs.yml index a4a1898f9..aec8349d9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,7 +62,7 @@ plugins: selection: inherited_members: true filters: - - "!^_" # exlude all members starting with _ + - "!^_" # exclude all members starting with _ - "^__init__$" # but always include __init__ modules and methods watch: - planet @@ -76,7 +76,9 @@ nav: - get-started/quick-start-guide.md - get-started/get-your-planet-account.md - get-started/venv-tutorial.md - - get-started/upgrading.md + - "Upgrade Guides": + - get-started/upgrading-v3.md + - get-started/upgrading-v2.md - "No Code CLI": - cli/cli-guide.md - cli/cli-intro.md From 7c2a57fa106b165468b774adf873580816b2f088 Mon Sep 17 00:00:00 2001 From: Carl Adams <57012982+carl-adams-planet@users.noreply.github.com> Date: Wed, 25 Jun 2025 19:28:52 +0000 Subject: [PATCH 07/24] updates from beta feedback (#1148) * updates from beta feedback for improved login CLI experience. * plumb profile copy * update planet-auth per feedback. * formatting for the linter * proofreading. * simplify examples * update links * minor edits to example code * reorder sections * more breadcrumbs in the 'python' housed pointer page for auth docs. * update the auth section in non-auth sub-trees of the doc site * More doc edits for clarification. Adding links to the list of protocols to specific implementaiton examples. * accepting edit suggestion. * adding suggested link * clarifying links page * unhide M2M options for now * add option to control saving to storage to constructor. Doc updates --- docs/auth/auth-dev-app-managed-apikey.md | 39 ++ docs/auth/auth-dev-app-managed-oauth.md | 174 ++++++++ docs/auth/auth-dev-cli-managed.md | 110 +++++ docs/auth/auth-overview.md | 126 ++++++ docs/auth/auth-sdk.md | 133 ++++++ docs/get-started/quick-start-guide.md | 7 +- docs/get-started/upgrading-v3.md | 10 +- docs/python/async-sdk-guide.md | 66 ++- docs/python/sdk-client-auth.md | 382 +----------------- docs/python/sdk-guide.md | 6 +- ...mory__oauth_user_authcode__with_browser.py | 3 - ...oauth_user_devicecode__external_browser.py | 3 - ...th_state__on_disk_cli_shared__oauth_m2m.py | 3 - ...ared__oauth_user_authcode__with_browser.py | 3 - ...oauth_user_devicecode__external_browser.py | 3 - ...ged_auth_state__on_disk_legacy__api_key.py | 3 - ...pp_managed_auth_state__using_sdk_app_id.py | 29 ++ .../cli_managed_auth_state__explicit.py | 3 - ...naged_auth_state__specific_auth_profile.py | 6 +- mkdocs.yml | 6 + planet/auth.py | 91 +++-- planet/cli/auth.py | 6 +- planet/cli/cli.py | 6 +- pyproject.toml | 2 +- 24 files changed, 731 insertions(+), 489 deletions(-) create mode 100644 docs/auth/auth-dev-app-managed-apikey.md create mode 100644 docs/auth/auth-dev-app-managed-oauth.md create mode 100644 docs/auth/auth-dev-cli-managed.md create mode 100644 docs/auth/auth-overview.md create mode 100644 docs/auth/auth-sdk.md create mode 100644 examples/auth-session-management/app_managed_auth_state__using_sdk_app_id.py diff --git a/docs/auth/auth-dev-app-managed-apikey.md b/docs/auth/auth-dev-app-managed-apikey.md new file mode 100644 index 000000000..1b2888c92 --- /dev/null +++ b/docs/auth/auth-dev-app-managed-apikey.md @@ -0,0 +1,39 @@ +# Application Managed Sessions - Planet API Key + +## Planet API Key Sessions +Legacy applications that need to continue to support Planet API keys may do so +until API keys are deprecated. This method should not be adopted for new +development if possible. + +### Examples - Planet API Keys + +#### In Memory Session State +Once provided with an API key, an application may operate with the API key +in memory indefinitely without the need to prompt the user for re-authentication. +```python linenums="1" title="Access APIs using Planet API keys in memory" +{% include 'auth-session-management/app_managed_auth_state__in_memory__api_key.py' %} +``` + +#### Version 2 Compatibility +The SDK continues to support files written by version 2 of the SDK to save +auth state. +```python linenums="1" title="Access APIs using Planet API keys using the on disk file format used by older versions of the SDK" +{% include 'auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py' %} +``` + +```json linenums="1" title="Legacy API Key file example" +{% include 'auth-session-management/legacy_api_key_file.json' %} +``` + +#### Session State Shared with CLI +```python linenums="1" title="Access APIs using Planet API keys with CLI managed shared state on disk" +{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__api_key.py' %} +``` + +#### Session State Saved to Application Storage + +```python linenums="1" title="Access APIs using Planet API keys with sessions persisted to application provided storage" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__api_key.py' %} +``` + +---- diff --git a/docs/auth/auth-dev-app-managed-oauth.md b/docs/auth/auth-dev-app-managed-oauth.md new file mode 100644 index 000000000..f80d58063 --- /dev/null +++ b/docs/auth/auth-dev-app-managed-oauth.md @@ -0,0 +1,174 @@ +# Application Managed Sessions - OAuth2 + +If an application cannot or should not use a login session initiated by the +[`planet auth`](../../cli/cli-reference/#auth) CLI command, the application will be +responsible for managing the process on its own, persisting session state as +needed. + +Application managed sessions may be used with all authentication protocols. +Application developers may control whether sessions are visible to the CLI. +This is managed with the `save_state_to_storage` parameter on the `planet.Auth` +constructor methods illustrated below. + +The process varies depending on the authentication protocol used. +Depending on the use case, applications may need to support multiple authentication +methods, just as the [`planet`](../../cli/cli-reference) CLI command supports interacting with Planet APIs +using either a user or a service user account. + +## OAuth2 Session for Users +User session initialization inherently involves using a web browser to +complete user authentication. This architecture allows for greater security +by keeping the user's password from being directly exposed to the application +code. This also allows for flexibility in user federation and multifactor +authentication procedures without the complexity of these needing to +be exposed to the application developer who is focused on geospatial +operations using the Planet platform, and not the nuances of user +authentication and authorization. + +### OAuth2 User Client Registration +Developers of applications must register client applications with Planet, and +will be issued a Client ID as part of that process. Developers should register +a client for each distinct application so that end-users may discretely manage +applications permitted to access Planet APIs on their behalf. + +See [OAuth2 Client Registration](http://docs.planet.com/develop/authentication/#interactive-client-registration) +for more information. + +### With a Local Web Browser +In environments where a local browser is available, the Planet SDK library can manage +the process of launching the browser locally, transferring control to the Planet +authorization services for session initialization, and accepting a network +callback from the local browser to regain control once the authorization +process is complete. At a network protocol level, this establishes the user +login session using the OAuth2 authorization code flow. + +To use this method using the SDK, the following requirements must be met: + +* The application must be able to launch a local web browser. +* The web browser must be able to connect to Planet services. +* The application must be able to listen on a network port that is accessible + to the browser. + +#### Examples - OAuth2 Authorization Code Flow + +##### In Memory Session State +When an application cannot safely store user session state, it may operate purely in memory. When this +method is used, the user will be prompted to complete the login process each time the application is run. + +```python linenums="1" title="Login as a user using a local browser with in memory only state persistance" +{% include 'auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py' %} +``` + +##### Session State Shared with CLI +Applications may save their session state in a way that is shared with the CLI. With saved state, +the user will only be prompted to complete the login process once. +```python linenums="1" title="Login as a user using a local browser with sessions persisted on disk and shared with the CLI" +{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py' %} +``` + +##### Session State Saved to Application Storage +Applications may save their session state to application provided storage. With saved state, +the user should only be prompted to complete the login process once. Using application provided storage +will result in the session state not being shared with the CLI. + +Applications needing to use their own storage will do so by providing +the `Auth` layer in the SDK with a custom implementation of the +[`planet_auth.ObjectStorageProvider`](https://planet-auth.readthedocs.io/en/latest/api-planet-auth/#planet_auth.ObjectStorageProvider) +abstract base class. See examples below for more details. + +```python linenums="1" title="Login as a user using a local browser with sessions persisted to application provided storage" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_authcode__with_browser.py' %} +``` + +### Without a Local Web Browser +In environments where a local web browser is not available, additional steps must +be taken by the application author to initialize the user session. +For example, a remote shell to a cloud environment is not likely +to be able to open a browser on the user's desktop or receive network callbacks +from the user's desktop browser. In these cases, a browser is +still required. To complete login in such a case, the SDK will generate a URL and a +verification code that must be presented to the user. The user must visit the +URL out of band to complete the login process while the application polls for +the completion of the login process using the SDK. At a network protocol +level, this establishes the user login session using the OAuth2 device +code flow. + +To use this method using the SDK, the following requirements must be met: + +* The application must be able to connect to Planet services. +* The application must be able to display instructions to the user, directing + them to a web location to complete login. + +As above, this may be done with state only persisted in memory, with state +shared with the CLI, or with state saved to application provided storage. + +#### Examples - OAuth2 Device Code Flow + +##### In Memory Session State +```python linenums="1" title="Login as a user using an external browser with in memory only state persistance" +{% include 'auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py' %} +``` + +##### Session State Shared with CLI +```python linenums="1" title="Login as a user using an external browser with sessions persisted on disk and shared with the CLI" +{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py' %} +``` + +##### Session State Saved to Application Storage +```python linenums="1" title="Login as a user using an external browser with sessions persisted to application provided storage" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_devicecode__external_browser.py' %} +``` + +## OAuth2 Session for Service Accounts +Service account session initialization is simpler than user session +initialization, and does not require a web browser. + +While preserving session state for user sessions was a concern driven +in part by a concern for the user experience of using a web browser for +initialization, for service accounts it remains a concern to avoid +throttling by the authorization service. + +If applications are expected to run longer than the life of an access token +(a few hours), then in memory operations are acceptable (for example: a long-running +data processing job). If application lifespan is short and frequent, +then the application should take steps to persist the session state (for +example: a command line utility run repeatedly from a shell with a short lifespan). + +Like the session state itself, service account initialization parameters are +sensitive, and it is the responsibility of the application to store them +securely. + +At a network protocol level, OAuth2 service account sessions are implemented +using the OAuth2 authorization code flow. This carries with it some additional +security concerns, discussed in +[RFC 6819 §4.4.4](https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.4). +Because of these considerations, service accounts should only be used for +workflows that are independent of a controlling user. + +As above, this may be done with state only persisted in memory, with state +shared with the CLI, or with state saved to application provided storage. + +### OAuth2 M2M Client Registration +Service accounts are managed under the +**OAuth Clients** panel on the [Planet Insights Account](https://insights.planet.com/account/#/) page. + +See [Sentinel Hub Authentication](https://docs.sentinel-hub.com/api/latest/api/overview/authentication/) for further information. + +### Examples - OAuth2 Client Credentials Flow + +#### In Memory Session State +```python linenums="1" title="Access APIs using a service account with in memory only state persistance" +{% include 'auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py' %} +``` + +#### Session State Shared with CLI +```python linenums="1" title="Access APIs using a service account with sessions persisted on disk and shared with the CLI" +{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py' %} +``` + +#### Session State Saved to Application Storage +```python linenums="1" title="Access APIs using a service account with sessions persisted to application provided storage" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py' %} +``` + +---- diff --git a/docs/auth/auth-dev-cli-managed.md b/docs/auth/auth-dev-cli-managed.md new file mode 100644 index 000000000..ba7f84a76 --- /dev/null +++ b/docs/auth/auth-dev-cli-managed.md @@ -0,0 +1,110 @@ +# CLI Managed Sessions +For simple programs and scripts, it is easiest for the program to defer +session management to the [`planet auth`](../../cli/cli-reference/#auth) +CLI. This method will store session information in the user's home directory +in the `~/.planet.json` file and `~/.planet/` directory. The Python SDK will +use the information saved in these locations to make API calls. + +When this approach is taken, the authentication session will be shared between +actions taken by the `planet` utility and those taken by programs built +using the SDK. Changes made by one will impact the behavior of the other. + +CLI managed sessions can be used for all authentication protocols supported +by the SDK library. + +**Requirements and Limitations:** + +* The program must have read and write access to the user's home directory. +* This method requires that the end-user has access to and understands + the [`planet`](../../cli/cli-reference) CLI command needed to manage + authentication. +* This approach should not be used on public terminals or in cases where the + user's home directory cannot be kept confidential. + +## Initialize Session - CLI Login +Session login can be performed using the following command. This command can +be used to initialize sessions using any of the supported authentication methods, +and will default to creating an OAuth2 user session. +Refer to the command's `--help` for more information. + + +```shell title="Initialize session using planet CLI." +planet auth login +``` + +A particular configuration may be selected by using the `--auth-profile` option. +`planet-user` is the default, but may be [overridden](../auth-sdk/#configuration) +by the runtime environment. + + +```shell title="Initialize session using planet CLI, forcing the built-in user interactive OAuth2 login flow." +planet auth login --auth-profile planet-user +``` + + +```shell title="Initialize session using planet CLI, forcing the use of the specified service principal." +planet auth login --auth-client-id --auth-client-secret +``` + + +```shell title="Initialize session using planet CLI, forcing the use of a legacy Planet API key." +planet auth login --auth-api-key +``` + +## Using Saved Session +Using a CLI managed session is the default behavior for SDK functions. +Developing an application that uses a CLI managed session requires no additional +action by the developer. When a developer chooses to create an application +that behaves in this way, it will most often be done implicitly by relying +on SDK default behavior, but it may also be done explicitly. + +### CLI Selected Session +The default behavior of the SDK is to defer which session is loaded to CLI. + + +```python linenums="1" title="Implicitly use CLI managed login sessions, deferring session selection to the user and the CLI." +{% include 'auth-session-management/cli_managed_auth_state__implicit.py' %} +``` + +```python linenums="1" title="Explicitly use CLI managed login sessions, deferring session selection to the user and the CLI." +{% include 'auth-session-management/cli_managed_auth_state__explicit.py' %} +``` + +### Application Selected Session +Applications may be developed to always select a specific CLI managed profile. +This may be useful in cases where an application wishes to guide the user +experience towards expecting an auth session that is separate from the default +sessions used by the CLI. + +In cases where the application has access to the +user's home directory and saved sessions, forcing the use of a particular +profile circumvents the user's CLI managed preferences. + + +Note: This first example does not create the session `my-app-profile`. +This must be created either through a separate code path as show in +the [Application Managed Sessions](../auth-dev-app-managed-oauth) guide, +or by using a CLI command to copy an existing profile such as +`planet auth profile copy planet-user my-app-profile`. + +```python linenums="1" title="Use a specific session that is shared with the CLI." +{% include 'auth-session-management/cli_managed_auth_state__specific_auth_profile.py' %} +``` + + +It is also possible to force the use of the SDK's built-in OAuth2 application ID +for interactive user applications. This capability is provided for developer +convenience, primarily for smaller programs and scripts. Larger applications +developed for multiple users should +[register](../auth-dev-app-managed-oauth/#oauth2-user-client-registration) +a unique application ID. + +This second example also initiates a login and does not save session state to storage. +This means this example does not depend on the CLI, and may be considered a simple +example of an [Application Managed Session](../auth-dev-app-managed-oauth). + +```python linenums="1" title="Use the Planet SDK with an OAuth2 user session initialized by the application and utilizing the SDK's built-in OAuth2 application ID." +{% include 'auth-session-management/app_managed_auth_state__using_sdk_app_id.py' %} +``` + +--- diff --git a/docs/auth/auth-overview.md b/docs/auth/auth-overview.md new file mode 100644 index 000000000..c8148e03d --- /dev/null +++ b/docs/auth/auth-overview.md @@ -0,0 +1,126 @@ +# Client Authentication Overview + +## Introduction +All calls to Planet APIs must be authenticated. Only authorized clients may +use Planet Platform APIs. + +For general information on how to authenticate to Planet APIs, please see +the [Authentication](https://docs.planet.com/develop/authentication/) section of Planet's platform documentation. +This documentation focuses on the use of the Planet Python SDK and +[`planet`](../../cli/cli-reference) CLI. + +!!! info + Work to unify authentication practices between `api.planet.com` and `services.sentinel-hub.com` + is ongoing and being rolled out in phases over time. Documentation referring + to work in progress is marked as such 🚧. + + Of particular note is the general shift towards OAuth2 based authentication, + and a corresponding move away from Planet API keys. + +---- + +## Authentication Protocols +At the HTTP protocol level underneath the SDK, there are several distinct +ways a client may authenticate to the Planet APIs, depending on the use case. +See [Authentication Protocols](https://docs.planet.com/develop/authentication/#authentication-protocols) for a +complete discussion of when to choose a particular method. + +* **OAuth2 user access tokens** - API access as the end-user, using OAuth2 + user access tokens. This is the preferred way for user-interactive + applications to authenticate to Planet APIs. A registered client application + and a web browser are required to initialize a session. A web browser is not + required for continued operation. The SDK itself is a registered + client application that may be used for this purpose. + + Examples of applications that fall into this category include + [ArcGIS Pro](https://www.esri.com/en-us/arcgis/products/arcgis-pro/overview), + [QGIS](https://qgis.org/), and the SDK's own [`planet`](../../cli/cli-reference) + CLI program. All Planet first-party web applications also use this method. + +* **OAuth2 M2M access tokens** (🚧 _Work in progress_) - API access as a service user, using OAuth2 + M2M access tokens. This is the new preferred way for automated processes + to authenticate to Planet APIs that must operate without a human user. + No web browser is required, but this method carries some additional + security considerations. + +* **Planet API keys** (⚠️ _Pending future deprecation_) - API access as a Planet end-user using a simple + fixed string bearer key. This is the method that has historically been + documented and recommended for developers using Planet APIs. + +### OAuth2 +OAuth2 authentication requires that the client possess an access token +in order to make API calls. Access tokens are obtained by the client from +the Planet authorization server, which is separate from the API servers, and are +presented by the client to API services to assert the client's right to make +API calls. + +Unlike Planet API keys, access tokens do not last forever for a variety of +reasons and must be regularly refreshed by the client before their expiration. +When using the Planet SDK, many of the details of obtaining and refreshing +OAuth2 access tokens will be taken care of for you. + +OAuth2 defines many different ways to obtain access tokens, and a full discussion +is beyond the scope of this SDK user guide. Please refer to the [Resources](#resources) +below for more information. Planet broadly divides OAuth2 use cases into +user-interactive and machine-to-machine use cases, as described in this guide. + +**SDK Examples:** + +* **OAuth2 user access tokens** + * [Using the CLI (Quick start)](../auth-dev-cli-managed/#planet-auth-login-planet-user) + * [Forcing use of SDK Built-in Application ID in code (Quick start)](../auth-dev-cli-managed/#use-cli-session-force-builtin) + * [Using a custom registered application ID](../auth-dev-app-managed-oauth/#oauth2-session-for-users) +* **OAuth2 M2M access tokens** + * [Using the CLI (Quick start)](../auth-dev-cli-managed/#planet-auth-login-planet-m2m) + * [Using a M2M Access Token in code](../auth-dev-app-managed-oauth/#oauth2-session-for-service-accounts) + +!!! info + OAuth2 user access tokens currently work for all Planet APIs under both + the `api.planet.com` and `services.sentinel-hub.com` domains. + + 🚧 OAuth2 machine-to-machine (M2M) access tokens are currently available for use + with `services.sentinel-hub.com` APIs. Work to support `api.planet.com` is + ongoing. It should also be noted that at this time no API clients for + `services.sentinel-hub.com` APIs have been incorporated into this SDK. + The SDK may still be used to obtain and manage M2M access tokens to + support external applications. + +### Planet API Keys +Planet API keys are simple fixed strings that may be presented by the client +to API services to assert the client's right to access APIs. API keys are +obtained by the user from their [Account](https://www.planet.com/account) page +under the [_My Settings_](https://www.planet.com/account/#/user-settings) tab. + +**SDK Examples:** + +* **Planet API keys** + * [Using the CLI (Quick start)](../auth-dev-cli-managed/#planet-auth-login-planet-apikey) + * [Using a Planet API Key in code](../auth-dev-app-managed-apikey) + + +!!! warning + Planet API keys are being targeted for eventual deprecation in favor + of OAuth2 mechanisms for most use cases. No specific timeframe has been + set for disabling API keys, but new development should use OAuth2 + mechanisms where possible. + + Planet API keys will work for Planet APIs underneath `api.planet.com`, but + will **NOT** work for APIs underneath `services.sentinel-hub.com`. + + There is no plan for API keys to ever be supported by APIs underneath + `services.sentinel-hub.com`. + +---- + +## Resources +More information regarding Authentication to Planet APIs, OAuth2, and JWTs +may be found here: + +* [Planet Authentication](https://docs.planet.com/develop/authentication/) +* [RFC 6749 - The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749) +* [RFC 8628 - OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628) +* [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) +* [RFC 9068 - JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://datatracker.ietf.org/doc/html/rfc9068) +* [RFC 6819 - OAuth 2.0 Threat Model and Security Considerations](https://datatracker.ietf.org/doc/html/rfc6819) + +---- diff --git a/docs/auth/auth-sdk.md b/docs/auth/auth-sdk.md new file mode 100644 index 000000000..1831a86f0 --- /dev/null +++ b/docs/auth/auth-sdk.md @@ -0,0 +1,133 @@ +# Authentication with the SDK + +## Overview +The [`planet.Auth`](../../python/sdk-reference/#planet.auth.Auth) class is the +main class that is responsible for managing how clients built with the SDK +authenticate to the Planet Insights Platform API services. By default, +API clients provided by the SDK will create an `Auth` instance that is connected +to login sessions managed by the [`planet auth`](../../cli/cli-reference/#auth) +CLI utility, with state saved to the `.planet.json` file and `.planet` +directory in the user's home directory. + +When applications require more control over the authentication process, +constructor methods on the [`planet.Auth`](../../python/sdk-reference/#planet.auth.Auth) +class may be used to create instances with specific configurations. +`Auth` instances may then be wrapped in [`planet.Session`](../../python/sdk-reference/#planet.http.Session) +objects so they can be attached to the +[`planet.Planet`](../../python/sdk-reference/#planet.client.Planet) synchronous +client, or various [asynchronous API clients](../../python/async-sdk-guide/) provided by the SDK. + +## Configuration + +When determining how to authenticate requests made against the Planet +APIs, the default behavior of the SDK and the Planet CLI is to load +configuration from a number of sources at runtime: + +- Highest priority is given to arguments passed to the [`Auth`](../../python/sdk-reference/#planet.auth.Auth) + class (when using the SDK) or via the command line (when using the CLI). + When saving preferences using the CLI, configuration is saved to + configuration files (below). +- Next, environment variables are checked. + Of these, `PL_API_KEY` has been used by Planet software for many years, + and is the most likely to be set in a user's environment. + The other environment variables are new to version 3 of the Planet Python SDK. + **Note**: This means that environment variables override configuration + saved by the `planet` CLI program. See [Environment Variables](#environment-variables) + below. +- Then, the configuration file `.planet.json` and files underneath + the `.planet` directory in the user's home directory are consulted. + These configuration files may be managed with the + [`planet auth profile`](../../cli/cli-reference/#profile) CLI command. +- Finally, built-in defaults will be used. + +### Environment Variables +When the SDK is not otherwise explicitly configured by an application, +or behavior is not overridden by command-line arguments, the following +environment variables will be used: + +| Variable | Description | +|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------| +| **`PL_AUTH_PROFILE`** | Specify a custom CLI managed auth client profile by name. This must name a valid CLI managed profile or an error will occur. | +| **`PL_AUTH_CLIENT_ID`** | Specify an OAuth2 M2M client ID. `PL_AUTH_CLIENT_SECRET` must also be specified, or this will be ignored. | +| **`PL_AUTH_CLIENT_SECRET`** | Specify an OAuth2 M2M client secret. `PL_AUTH_CLIENT_ID` must also be specified, or this will be ignored. | +| **`PL_AUTH_API_KEY`** | Specify a legacy Planet API key. | + +When multiple conflicting environment variables are set, `PL_AUTH_PROFILE` is +preferred over `PL_AUTH_CLIENT_ID` and `PL_AUTH_CLIENT_SECRET`, which are +preferred over `PL_AUTH_API_KEY`. + +### Reset User Configuration +The following commands may be used to clear an environment of any +previously configured settings: + +```sh title="Clear saved authentication settings" +unset PL_API_KEY +unset PL_AUTH_PROFILE +unset PL_AUTH_CLIENT_ID +unset PL_AUTH_CLIENT_SECRET +planet auth reset +``` + +## Profiles +Collectively, the configuration of the SDK to use a specific authentication +protocol (see [overview](../auth-overview#authentication-protocols)) and a +working set of session state information is termed a _profile_ by the SDK +and the CLI. Profiles are an abstraction of the SDK and the CLI, and are +not inherent to authentication to the Planet platform generally. + +The [`planet auth profile`](../../cli/cli-reference/#profile) CLI command +is provided to manage persistent profiles and sessions in the user's home +directory. These home directory persisted profiles are shared between the CLI +and applications built using the SDK. + +Applications built using the SDK may be configured to bypass home directory +profile and session storage, if this better suits the needs of the application. +See [Applicaiton Managed Sessions](../auth-dev-app-managed-oauth) for detailed +examples. + +## Sessions + +Before any calls can be made to a Planet API using the SDK, it is +necessary for the user to login and establish an authentication session. +Exactly how this should be done with the SDK depends on the +application's complexity and needs. + +In simple cases, this may be managed external to the application +by using the [`planet auth`](../../cli/cli-reference/#auth) +command-line utility. See [CLI Managed Sessions](../auth-dev-cli-managed) +for examples. + +In more complex cases, an application may need to manage the +stored session itself independent of utilities provided by the CLI. In such +cases, the application will be responsible for instantiating a `planet.Auth` +object, initiating user login, and saving the resulting session information. +Session information may contain sensitive information such as access and +refresh tokens, and must be stored securely by the application. Session +information will also be regularly updated during SDK operations, so the +application must handle callbacks to store updated session information. +See [Application Managed Sessions](../auth-dev-app-managed-oauth) +for examples. + +### Session Persistence + +Once a user login session is established using any method, the state should be +saved to secure persistent storage to allow for continued access to the Planet +platform without the need to perform the login repeatedly. If state cannot +be persisted in the application environment, the application can operate in +in-memory mode, but will be forced to create a new login session every time the +application is run. If the rate of repeated logins is too great, this may +result in throttling by the authorization service. Particular attention should +be paid to this when creating automated processes that utilize service users. + +The SDK provides the option to save session state in the user's +home directory in a way that is compatible with the CLI. +When [CLI Managed Sessions](../auth-dev-cli-managed) are used, no additional +steps should be required of the application developer. + +The SDK also provides a way for the application to provide its own secure +storage. Applications needing to use their own storage will do so by +providing the `Auth` layer in the SDK with a custom implementation of the +[`planet_auth.ObjectStorageProvider`](https://planet-auth.readthedocs.io/en/latest/api-planet-auth/#planet_auth.ObjectStorageProvider) +abstract base class. + +---- diff --git a/docs/get-started/quick-start-guide.md b/docs/get-started/quick-start-guide.md index a6ae82c4e..7a04b3615 100644 --- a/docs/get-started/quick-start-guide.md +++ b/docs/get-started/quick-start-guide.md @@ -27,9 +27,10 @@ pip install planet ### Authentication -Use the `planet auth` CLI command to establish a user login session that will -be saved to the user's home directory. For other authentication options, see -the [Client Authentication Guide](../python/sdk-client-auth.md). +Use the [`planet auth`](../../cli/cli-reference/#auth) CLI command to establish +a user login session that will be saved to the user's home directory. This +session will be picked up by SDK library functions by default. For other +authentication options, see the [Client Authentication Guide](../auth/auth-overview.md). ```bash planet auth login diff --git a/docs/get-started/upgrading-v3.md b/docs/get-started/upgrading-v3.md index 02151a1c1..bc515acfc 100644 --- a/docs/get-started/upgrading-v3.md +++ b/docs/get-started/upgrading-v3.md @@ -20,7 +20,7 @@ where the use case allows for it. Users may also continue to initialize SDK and CLI sessions with their username and password, but rather than being processed by the SDK itself a browser must be invoked to complete OAuth2 client session initialization. -This new method is intended to offer a number of long term benefits, including: +This new method is intended to offer a number of long-term benefits, including: * The new method provides the SDK and the CLI with access tokens that may be used with both `api.planet.com` and `services.sentinel-hub.com` endpoints. The method @@ -31,7 +31,7 @@ This new method is intended to offer a number of long term benefits, including: * The new method is compatible with other platform enhancements currently under development by Planet's software engineering team. -For complete details on the new mechanisms, see the [Client Authentication Guide](../python/sdk-client-auth.md). +For complete details on the new mechanisms, see the [Client Authentication Guide](../auth/auth-overview.md). ### CLI Usage The [`planet auth`](../../cli/cli-reference/#auth) command has been substantially @@ -57,7 +57,7 @@ of the SDK, the following changes are the most important to note: ### Session Persistence Both version 2 and version 3 of the SDK use the `~/.planet.json` file in the user's -home directory to store user's API key. If this file is present and was configured +home directory to store the user's API key. If this file is present and was configured by version 2 of the SDK, it should continue to work. While the `~/.planet.json` file continues to be used by version 3, and version 3 @@ -71,7 +71,7 @@ command to reset all saved state should it become corrupted. When this command the old files are moved aside rather than deleted. ### SDK Session Initialization -See the [Client Authentication Guide](../python/sdk-client-auth.md) for a complete +See the [Client Authentication Guide](../auth/auth-overview.md) for a complete discussion of all options now available. Basic SDK use cases should work with no alterations. @@ -88,7 +88,7 @@ Applications may also continue to initialize the SDK with a specific API key as {% include 'auth-session-management/app_managed_auth_state__in_memory__api_key.py' %} ``` -Users developing new applications should consult the [Client Authentication Guide](../python/sdk-client-auth.md) +Users developing new applications should consult the [Client Authentication Guide](../auth/auth-overview.md) for a complete discussion of all OAuth2 based mechanisms. OAuth2 mechanisms should be preferred to the use of Planet API keys. diff --git a/docs/python/async-sdk-guide.md b/docs/python/async-sdk-guide.md index af0162486..68dd00143 100644 --- a/docs/python/async-sdk-guide.md +++ b/docs/python/async-sdk-guide.md @@ -6,7 +6,7 @@ This guide is for the Planet Async SDK for Python users who want to use asynchro This guide walks you through the steps: -* **[Authenticate](#authenticate-with-planet-services)**—pass your username and password to Planet services to verify your permissions to data. +* **[Authenticate](#authenticate-with-planet-services)**—authenticate to Planet services to verify your permissions to data. * **[Create a session](#create-a-session)**—set up a context for calling on Planet servers and receiving data back. * **[Create an order](#create-an-order)**—build an orders client, send the request within the session context, and download it when it’s ready. * **[Collect and list data](#collecting-results)**—handle the potentially large number of results from a search for imagery. @@ -22,52 +22,36 @@ pip install planet ## Authenticate with Planet services -An SDK `Session` requires authentication to communicate with Planet services. This -authentication information is retrieved when a `Session` is created. By default, -a `Session` retrieves authorization key from the environment variable `PL_API_KEY` or a secret file, in that order of priority. - -The SDK provides the `auth.Auth` class for managing authentication information. -This module can be used to obtain authentication information from the username -and password with `Auth.from_login()`. Additionally, it can be created with -the API key obtained directly from the Planet account site with `Auth.from_key()`. - -Once you have provided the authentication information (in other words, the username and API key), it can be accessed by way of the `Auth.value`. The most convenient way of managing it for local use is to write it to a secret file using `Auth.write()`. For example, to obtain and store authentication information: - -Once you have provided the authentication information (in other words, the account username and password), it can be accessed by way of `Auth.value`. The most convenient way of managing it for local use is to write it to a secret file using `Auth.write()`. -It can also be accessed, for example, to store in an environment variable, such as -`Auth.value`. - -Here is an example of retrieving and storing authentication information: - -```python -# Get the user account name and password -# from the command line and environment, -# and store credentials in an Auth object -import getpass -from planet import Auth - -user = input("Username: ") -pw = getpass.getpass() -auth = Auth.from_login(user,pw) -auth.store() -``` - -The default authentication behavior of the `Session` can be modified by specifying -`Auth` explicitly using the methods `Auth.from_file()` and `Auth.from_env()`. -While `Auth.from_key()` and `Auth.from_login` can be used, it is recommended -that those functions be used in authentication initialization. Authentication -information should be stored using `Auth.store()`. - -You can customize the manner of retrieval and location to read from when retrieving the authorization information. The file and environment variable read from can be customized in the respective functions. For example, authentication can be read from a custom -environment variable, as in the following code: +An SDK `Session` requires authentication to communicate with Planet services. The +details of authentication are managed with the [`planet.Auth`](../../python/sdk-reference/#planet.auth.Auth) +class, and are configured when a `Session` is created. Default behavior +shares the responsibility of managing authentication sessions with the [`planet auth`](../../cli/cli-reference/#auth) +CLI utility, which stores authentication sessions in the user's home directory. + +The default authentication behavior of the `Session` can be modified by providing an +`Auth` instance when creating the `Session`. There are many different ways to +create an `Auth` instance, depending on the use case. See +[Client Authentication Overview](../../auth/auth-overview/) +and [Authentication with the SDK](../../auth/auth-sdk/) for more details concerning +Planet Insights Platform authentication with the SDK. For general information on +how to authenticate to Planet APIs, please see the +[Authentication](https://docs.planet.com/develop/authentication/) section of Planet's +platform documentation. + +For example, a program may wish create the `Auth` instance prior to setting up +the `Session` to guide the user towards external setup steps: ```python import asyncio -import os +import sys from planet import Auth, Session -auth = Auth.from_env('ALTERNATE_VAR') +auth = Auth.from_user_default_session() async def main(): + if not auth.is_initialized(): + print("Login required. Execute the following command:\n\n\tplanet auth login\n") + sys.exit(99) + async with Session(auth=auth) as sess: # perform operations here pass diff --git a/docs/python/sdk-client-auth.md b/docs/python/sdk-client-auth.md index a0bf32ebb..fe22f5179 100644 --- a/docs/python/sdk-client-auth.md +++ b/docs/python/sdk-client-auth.md @@ -1,377 +1,15 @@ -# Client Authentication Guide +# Authentication SDK Guide -## Introduction -All calls to Planet APIs must be authenticated. Only authorized clients may -use Planet Platform APIs. +For general information on how to authenticate to Planet APIs, see the +[Authentication](https://docs.planet.com/develop/authentication/) section of Planet's +Insights Platform documentation. -For general information on how to authenticate to Planet APIs, please see -the [Authentication](https://docs.planet.com/develop/authentication) section of Planet's platform documentation. +See [Client Authentication Overview](../../auth/auth-overview) for an overview +of authentication to the Planet Insights Platform that is geared towards SDK +users, and includes a discussion of authentication protocols that are under +construction and available for early access to SDK users. -!!! warning - Some statements in this guide are forward-looking. - - OAuth2 M2M tokens are currently only supported by `services.sentine-hub.com`, - and are not yet supported by `api.planet.com`. This is planned for a future date - to be announced. - - All APIs support interactive user OAuth2 access tokens, but a process for - developers to register and manage clients has not yet been made public. - We have also not yet release a way for end-users of such applications to - manage which applications have been authorized to access the platform on - their behalf. - - If you would like to develop an interactive application that uses - Planet's APIs on behalf of a logged-in user (as the `planet` CLI utility - does), please contact Planet support and work with engineering to - register your application. - ----- -## Authentication Protocols -At the API protocol level underneath the SDK, there are several distinct -ways a client may authenticate to the Planet APIs, depending on the use case: - -* **OAuth2 user access tokens** - API access as the end-user, using OAuth2 -user access tokens. This is the preferred way for user interactive -applications to authenticate to Planet APIs. A web browser is required -to initialize a session, but not required for continued operation. -* **OAuth2 M2M access tokens** - API access as a service user, using OAuth2 -M2M access tokens. This is the preferred way for automated processes -to authenticate to Planet APIs that must operate without a human user. -No web browser is required, but this method carries some additional -security considerations. -* **Planet API keys** - API access as a planet end-user using a simple -fixed string bearer key. This method is being targeted for deprecation. - -### OAuth2 -OAuth2 authentication requires that the client possesses an access token -in order to make API calls. Access tokens are obtained by the client from -the Planet authorization server that is separate from the API servers, and are -presented by the client to API services to prove the client's right to make -API calls. - -Unlike Planet API keys, access tokens do not last forever for a variety of -reasons and must be regularly refreshed by the client before their expiration. -However, clients should not refresh access tokens for every API call; clients -that misbehave in this way will be throttled by the authorization service, -potentially losing access to APIs. - -When using the Planet SDK, the many of the details of obtaining and refreshing -OAuth2 access tokens will be taken care of for you. - -Planet OAuth2 access tokens will work for all Planet APIs underneath -both the `api.planet.com` and `services.sentinel-hub.com` domains. - -Planet Access tokens conform to the JSON Web Token (JWT) specification. -Tokens may be inspected to determine their expiration time, which will be -in the `exp` claim. - -!!! note - Clients should generally treat the access tokens as opaque bearer tokens. - While JWTs are open for inspection, Planet does not guarantee the stability - of undocumented claims. Rely only on those documented here. - -More information regarding OAuth2 and JWTs may be found here: - -* [RFC 6749 - The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749) -* [RFC 8628 - OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628) -* [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) -* [RFC 9068 - JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://datatracker.ietf.org/doc/html/rfc9068) - -#### OAuth2 Client Registration -!!! TODO - * link to docs for this process - * discuss registering a interactive client (that will access Planet - as the user) vs registering a M2M client identity (which is really - more like creating a new user) vs registering a confidential client. - discuss native vs web clients. - -Developers of applications must register client applications with Planet, and -will be issued a Client ID as part of that process. Developers should register -a client for each distinct application so that end-users may discretely manage -applications permitted to access Planet APIs on their behalf. - -### Planet API Keys -Planet API keys are simple fixed strings that may be presented by the client -to API services that assert the client's right to access APIs. API keys are -obtained by the user from their account page, and provided to the client -so that it may make API calls on the user's behalf. - -Planet API keys are simpler to use than OAuth2, but are considered less secure -in many ways. Because of this, Planet API keys are targeted for eventual -deprecation. Support for this method is maintained for continuity -while OAuth2 based methods are being rolled out across all Planet APIs and -clients. - -Planet API Keys will work for Planet APIs underneath `api.planet.com`, but -will **NOT** work for APIs underneath `services.sentinel-hub.com`. - ----- -## Authentication with the SDK - -Before any calls can be made to a Planet API using the SDK, it is -necessary for the user to login and establish an authentication session. -Exactly how this should be done with the SDK depends on the -application's complexity and needs. - -In simple cases, this may be managed external to the application -by using the [`planet auth`](../../cli/cli-reference/#auth) -command line utility. - -In more complex cases, an application may need to manage the -stored session itself independent of utilities provided by the CLI. In such -cases the application will be responsible for instantiating a `planet.Auth` -object, initiating user login, and saving the session resulting information. -Session information may contain sensitive information such as access and -refresh tokens, and must be stored securely by the application. Session -information will also be regularly updated during SDK operations, so the -application must handle keeping the saved session information up-to-date. - -Regardless of which authentication protocol is used, the SDK encapsulates -the details with -[`planet.Auth`](../sdk-reference/#planet.auth.Auth) and -[`planet.Session`](../sdk-reference/#planet.http.Session). - -#### Session State Storage - -Once a user login session is established using any method, the state should be -saved to secure persistent storage to allow for continued access to the Planet -platform without the need to perform the login repeatedly. If state cannot -be persisted in the application environment, the application can operate in -in-memory mode, and will be forced create a new login session every time the -application is run. In some cases, this may result in throttling by the -authorization service. - -By default, the SDK provides the option to save session state in the user's -home directory in a way that is compatible with the CLI. The SDK also -provides a way for the application to provide its own secure storage. -Applications needing to use their own storage will do so by providing -the `Auth` layer in the SDK with a custom implementation of the -`planet_auth.ObjectStorageProvider` abstract base class. See examples -below for more details. - -### Using `planet auth` CLI Managed Auth Session -For simple programs and scripts, it is easiest for the program to defer -session management to the [`planet auth`](../../cli/cli-reference/#auth) -CLI. This method will store session information in the user's home directory -in the `~/.planet.json` file and `~/.planet/` directory. The python SDK will -use the information saved in these locations to make API calls. - -When this approach is taken, the authentication session will be shared between -actions taken by the `planet` utility, and those taken by the programs built -using the SDK. Changes made by one will impact the behavior of the other. - -**Requirements and Limitations:** - -* The program must have read and write access to the user's home directory. -* This method requires that the end-user has access to and understands - the [`planet`](../../cli/cli-reference) CLI command needed to manage session - authentication. -* This approach should not be used on public terminals or in cases where the - user's home directory cannot be kept confidential. - -#### Initialize Session - Login -Session login can be performed using the following command. This command can -be used to initialize sessions using any of the authentication methods -discussed above, and will default to creating an OAuth2 user session. -Refer to the command's `--help` for more information. -```shell title="Initialize session using planet CLI" -planet auth login -``` - -#### Using Saved Session -Using the CLI managed session is the default behavior for SDK functions. -Developing an application that uses this session requires no additional -action by the developer. When a developer chooses to create an application -that behaves in this way, it will most often be done implicitly by relying -on SDK default behavior, but it may also be done explicitly. - -```python linenums="1" title="Implicitly use CLI managed login sessions" -{% include 'auth-session-management/cli_managed_auth_state__implicit.py' %} -``` - -```python linenums="1" title="Explicitly use CLI managed login sessions" -{% include 'auth-session-management/cli_managed_auth_state__explicit.py' %} -``` - -```python linenums="1" title="Use a specific session that is shared with the CLI" -{% include 'auth-session-management/cli_managed_auth_state__specific_auth_profile.py' %} - -``` - -### Manually Creating a Session Using Library Functions -If an application cannot or should not use a login session initiated by the -[`planet auth`](../../cli/cli-reference/#auth) CLI command, it will be -responsible for managing the process on its own, persisting session state as -needed. - -The process differs slightly for applications accessing Planet services on behalf -of a human user verses accessing Planet services using a service account. Depending -on the use case, applications may need to support one or the other or both (just -as the [`planet`](../../cli/cli-reference) CLI command supports both methods). - -#### OAuth2 Session for Users -User session initialization inherently involves using a web browser to -complete user authentication. This architecture allows for greater security -by keeping the user's password from being directly exposed to the application -code. This also allows for flexibility in user federation and multifactor -authentication procedures without the complexity of these needing to -be exposed to the application developer who is focused on geospatial -operations using the Planet platform, and not the nuances of user -authentication and authorization. - -##### With a Local Web Browser -In environments where a local browser is available, the Planet SDK can manage -the process of launching the browser locally, transferring control to the Planet -authorization services for session initialization, and accepting a network -callback from the local browser to regain control once the authorization -process is complete. At a network protocol level, this is establishing the user -login session using the OAuth2 authorization code flow. - -To use this method using the SDK, the following requirements must be met: - -* The application must be able to launch a local web browser. -* The web browse must be able to connect to Planet services. -* The application must be able to listen on a network port that is accessible - to the browser. - -###### Examples - Authorization Code Flow -```python linenums="1" title="Login as a user using a local browser with in memory only state persistance" -{% include 'auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py' %} -``` - -```python linenums="1" title="Login as a user using a local browser with sessions persisted on disk and shared with the CLI" -{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py' %} -``` - -```python linenums="1" title="Login as a user using a local browser with sessions persisted to application provided storage" -{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_authcode__with_browser.py' %} -``` - -##### Without a Local Web Browser -In environments where a local web browsers is not available the process above -will not work. For example, a remote shell to a cloud environment is not likely -to be able to open a browser on the user's desktop or receive network callbacks -from the user's desktop browser. In these cases, a browser is -still required. To login in such a case the SDK will generate a URL and a -verification code that must be presented to the user. The user must visit the -URL out of band to complete the login process while the application polls for -the completion of the login process using the SDK. At a network protocol -level, this is establishing the user login session using the OAuth2 device -code flow. - -To use this method using the SDK, the following requirements must be met: - -* The application must be able to connect to Planet services. -* The application must be able to display instructions to the user, directing - them to a web location to complete login. - -###### Examples - Device Code Flow -```python linenums="1" title="Login as a user using an external browser with in memory only state persistance" -{% include 'auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py' %} -``` - -```python linenums="1" title="Login as a user using an external browser with sessions persisted on disk and shared with the CLI" -{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py' %} -``` - -```python linenums="1" title="Login as a user using an external browser with sessions persisted to application provided storage" -{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_devicecode__external_browser.py' %} -``` - -#### OAuth2 Session for Service Accounts -Service account session initialization is simpler than user session -initialization, and does not require a web browser. - -While preserving session state for user sessions was a concern driven -in part by a concern for the user experience of using a web browser for -initialization, for service account it remains a concern to avoid -throttling by the authorization service. - -If applications are expected to run longer than the life of an access token -(a few hours), then in memory operations are acceptable (for example: a long -running data processing job). If application lifespan is short and frequent, -than the application should still take steps to persist the session state (for -example: a command line utility run from a shell with a short lifespan). - -Like the session state itself, service account initialization parameters are -sensitive, and it is the responsibility of the application to store them -securely. - -At a network protocol level, OAuth2 service account sessions are implemented -using the OAuth2 authorization code flow. This carries with it some additional -security considerations, discussed in -[RFC 6819 §4.4.4](https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.4). -Because of these consideration, service accounts should only be used for -workflows that are independent of a controlling user. - -##### Examples - Client Credentials Flow -```python linenums="1" title="Access APIs using a service account with in memory only state persistance" -{% include 'auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py' %} -``` - -```python linenums="1" title="Access APIs using a service account with sessions persisted on disk and shared with the CLI" -{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py' %} -``` - -```python linenums="1" title="Access APIs using a service account with sessions persisted to application provided storage" -{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py' %} -``` - -#### Planet API Key Sessions -Legacy applications that need to continue to support Planet API keys may do so -until API keys are deprecated. This method should not be adopted for new -development. - -##### Examples - Planet API Keys - -```python linenums="1" title="Access APIs using Planet API keys in memory" -{% include 'auth-session-management/app_managed_auth_state__in_memory__api_key.py' %} -``` - -```python linenums="1" title="Access APIs using Planet API keys using the on disk file format used by older versions of the SDK" -{% include 'auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py' %} -``` - -```json linenums="1" title="Legacy API Key file" -{% include 'auth-session-management/legacy_api_key_file.json' %} -``` - -```python linenums="1" title="Access APIs using Planet API keys with CLI managed shared state on disk" -{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__api_key.py' %} -``` - -```python linenums="1" title="Access APIs using Planet API keys using legacy on disk persistance" -{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__api_key.py' %} -``` - -## OAuth2 Scopes -OAuth2 uses scopes to allow users to limit how much access clients have to the Planet -service on their behalf. - -* **`planet`** - Use this scope to request access to Planet APIs. -* **`offline_acess`** - Use this scope to request a refresh token. This may - only be requested by clients that access APIs on behalf of a user. M2M - clients may not request this scope. - - -## Environment Variables -When session information is not explicitly configured, the following environment variables -will influence the library behavior when initialized to user default preferences. - -* **`PL_AUTH_PROFILE`** - Specify a custom CLI managed auth client profile by name. -* **`PL_AUTH_CLIENT_ID`** - Specify an OAuth2 M2M client ID. -* **`PL_AUTH_CLIENT_SECRET`** - Specify an OAuth2 M2M client secret. -* **`PL_AUTH_API_KEY`** - Specify a legacy Planet API key. ----- - - -## Web Services -!!! TODO - All of the above really deals with native applications running in an - environment controlled by the end-user. The considerations - are different if the application being developed is a web service where - the end-user is not directly accessing Planet APIs. This involves - "Confidential" OAuth2 client configurations, and needs to be documented - here. +[Authentication with the SDK](../../auth/auth-sdk) provides a primer +on how to use the Planet SDK for Python to authenticate to Planet APIs. ---- diff --git a/docs/python/sdk-guide.md b/docs/python/sdk-guide.md index a9c90323b..9dad20999 100644 --- a/docs/python/sdk-guide.md +++ b/docs/python/sdk-guide.md @@ -38,11 +38,11 @@ planet auth login ``` These examples will assume you have done this, and are using the SDK's default -client authentication mechanisms. If you are not, please see the -[Client Authentication Guide](sdk-client-auth.md) for a complete discussion of +client authentication mechanisms. For more advanced use cases, see the +[Client Authentication Guide](../auth/auth-overview.md) for a complete discussion of all authentication options provided by the SDK. This includes user authentication with a web browser, service account authentication for detached -workloads, and support for legacy authentication mechanisms. +workloads using OAuth2, and support for legacy applications using Planet API keys. ### Search diff --git a/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py index 312d1136d..a7274db99 100644 --- a/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py @@ -1,9 +1,6 @@ import json -import logging import planet -logging.basicConfig(level=logging.CRITICAL) - def example_main(): # Create an auth context with a client ID that diff --git a/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py index 0f332cfa9..087dacabf 100644 --- a/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py @@ -1,9 +1,6 @@ import json -import logging import planet -logging.basicConfig(level=logging.CRITICAL) - def initialize_user_session(plsdk_auth): # Example of initiating a user session where the app is 100% diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py index 23b23b111..7afcf7652 100644 --- a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py @@ -1,9 +1,6 @@ import json -import logging import planet -logging.basicConfig(level=logging.CRITICAL) - def example_main(): # Create an auth context with the client ID and secret of the service account. diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py index 5be309780..4beeb5a28 100644 --- a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py @@ -1,9 +1,6 @@ import json -import logging import planet -logging.basicConfig(level=logging.CRITICAL) - def example_main(): # Create an auth context with a client ID that diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py index d0ca115ad..0b74a39d1 100644 --- a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py @@ -1,9 +1,6 @@ import json -import logging import planet -logging.basicConfig(level=logging.CRITICAL) - def initialize_user_session(plsdk_auth): # Example of initiating a user session where the app is 100% diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py b/examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py index a14257592..7c0761069 100644 --- a/examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py @@ -1,9 +1,6 @@ import json -import logging import planet -logging.basicConfig(level=logging.CRITICAL) - def example_main(): # Create an auth context with a Planet API key loaded from the diff --git a/examples/auth-session-management/app_managed_auth_state__using_sdk_app_id.py b/examples/auth-session-management/app_managed_auth_state__using_sdk_app_id.py new file mode 100644 index 000000000..afdc8fd96 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__using_sdk_app_id.py @@ -0,0 +1,29 @@ +import json +import planet + + +def example_main(): + # Load the OAuth2 user-interactive client configration that is built-into the SDK. + # This configuration is shared with the `planet` CLI command. + # When save_state_to_storage is true, sessions will be shared with the + # CLI and saved to the user's home directory. When save_state_to_storage + # is false, the state will only be persistent in memory and the + # user will need to login each time the application is run. + plsdk_auth = planet.Auth.from_profile("planet-user", + save_state_to_storage=False) + + if not plsdk_auth.is_initialized(): + plsdk_auth.user_login(allow_open_browser=True, allow_tty_prompt=True) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + example_main() diff --git a/examples/auth-session-management/cli_managed_auth_state__explicit.py b/examples/auth-session-management/cli_managed_auth_state__explicit.py index 2a8a68f08..2ad7b8c42 100644 --- a/examples/auth-session-management/cli_managed_auth_state__explicit.py +++ b/examples/auth-session-management/cli_managed_auth_state__explicit.py @@ -1,10 +1,7 @@ import json -import logging import planet import sys -logging.basicConfig(level=logging.CRITICAL) - def example_main(): # Explicitly load the user's auth session from disk. The user must have diff --git a/examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py b/examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py index f1583ac91..42f334312 100644 --- a/examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py +++ b/examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py @@ -1,10 +1,7 @@ import json -import logging import planet import sys -logging.basicConfig(level=logging.CRITICAL) - def example_main(): # Explicitly load the user's auth session from disk for a specific @@ -13,8 +10,7 @@ def example_main(): # must have performed a login() elsewhere prior to this example. # If this has not been done, the API calls will fail. This example # does not initialize a new session. - plsdk_auth = planet.Auth.from_profile( - profile_name="my-cli-managed-profile") + plsdk_auth = planet.Auth.from_profile(profile_name="my-app-profile") # If required, how to login depends on what is configured in the specific # profile. See other examples for login calls. diff --git a/mkdocs.yml b/mkdocs.yml index aec8349d9..039dd2798 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -92,6 +92,12 @@ nav: - python/sdk-client-auth.md - python/async-sdk-guide.md - python/sdk-reference.md + - "Client Authentication": + - auth/auth-overview.md + - auth/auth-sdk.md + - auth/auth-dev-cli-managed.md + - auth/auth-dev-app-managed-oauth.md + - auth/auth-dev-app-managed-apikey.md - "Resources": - resources/index.md - "Home": 'index.md' diff --git a/planet/auth.py b/planet/auth.py index 385a50f66..f7acdb11a 100644 --- a/planet/auth.py +++ b/planet/auth.py @@ -50,50 +50,81 @@ def _normalize_profile_name(profile_name: str): @staticmethod def from_user_default_session() -> Auth: """ - Create authentication from user defaults. + Create authentication context from user defaults. This method should be used when an application wants to defer auth profile management to the user and the `planet auth` CLI command entirely. - Users may use the `planet auth login` command to initialize - and manage sessions. + Users may use the `planet auth login` and `planet auth profile + commands to initialize and manage sessions. Defaults take into account environment variables (highest priority), - user configuration saved to `~/.planet.json` and `~/.planet/ + user configuration saved to `~/.planet.json` and `~/.planet/` (next priority), and built-in defaults (lowest priority). This method does not support the use a custom storage provider. - The session must be initialized entirely in memory (e.g. through - environment variables), or from on disk CLI managed settings. Environment Variables: - PL_AUTH_CLIENT_ID: Specify an OAuth2 M2M client ID - PL_AUTH_CLIENT_SECRET: Specify an OAuth2 M2M client secret - PL_AUTH_API_KEY: Specify a legacy Planet API key - PL_AUTH_PROFILE: Specify a custom planet_auth library auth - client profile (Advanced use cases) + + | Variable Name | Description | + | --------------------- | ------------------------------------------------------------------ | + | PL_AUTH_CLIENT_ID | Specify an OAuth2 M2M client ID | + | PL_AUTH_CLIENT_SECRET | Specify an OAuth2 M2M client secret | + | PL_AUTH_API_KEY | Specify a legacy Planet API key | + | PL_AUTH_PROFILE | Specify a previously saved planet_auth library auth client profile | + """ return _PLAuthLibAuth(plauth=planet_auth_utils.PlanetAuthFactory. initialize_auth_client_context()) @staticmethod - def from_profile(profile_name: str) -> Auth: + def from_profile( + profile_name: str, + save_state_to_storage: bool = True, + ) -> Auth: """ - Create authentication for a user whose initialized login information - has been saved to `~/.planet.json` and `~/.planet/`. + Create authentication context from an auth session that has been + initialized and saved to `~/.planet.json` and `~/.planet/`. - A user should perform a login to initialize this session out-of-band - using the command `planet auth login`. + Users can initialize and save such a session out-of-band + using the `planet auth login` and `planet auth profile` commands. To initialize this session programmatically without the CLI, you must complete an OAuth2 user login flow with one of the login - methods. + methods on this class. The login method used must be compatible + with the specified profile. This method does not support the use a custom storage provider. + + In addition to sharing sessions with other programs through the user's + home directory, this method may also be used to load SDK built-in + client profiles. This is provided as a developer convenience. + Applications _should_ register unique client IDs with the Planet service + and use `from_oauth_user_auth_code()` or `from_oauth_user_device_code()` + to create profiles unique to the application. + At present, the following built-in profiles are available: + + | Profile Name | Description | + | ------------ | -------------------------------------------------------------------- | + | `planet-user` | User interactive OAuth2 client profile shared with the `planet` CLI. | + + Parameters: + profile_name: Named profile from which to load auth configuration + and state. This should be a name of a CLI managed profile. + save_state_to_storage: Boolean controlling whether login sessions + should be saved to storage. This nearly always should be true, + since this constructor exists to share state through storage + backed profiles. The only exception may be when using a SDK + built-in profile in an application that should not attempt to + save state to disk. """ + if not profile_name: + raise APIKeyAuthException('Profile name cannot be empty.') pl_authlib_context = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context( - auth_profile_opt=profile_name) + auth_profile_opt=profile_name, + save_token_file=save_state_to_storage, + save_profile_config=save_state_to_storage) return _PLAuthLibAuth(plauth=pl_authlib_context) # TODO: add support for confidential clients @@ -108,7 +139,7 @@ def from_oauth_user_auth_code( planet_auth.ObjectStorageProvider] = None, ) -> Auth: """ - Create authentication for the specified registered client + Create authentication context for the specified registered client application. Developers of applications must register clients with @@ -169,7 +200,7 @@ def from_oauth_user_device_code( planet_auth.ObjectStorageProvider] = None ) -> Auth: """ - Create authentication for the specified registered client + Create authentication context for the specified registered client application. Developers of applications must register clients with @@ -182,7 +213,8 @@ def from_oauth_user_device_code( This method does not perform a user login to initialize a session. If not initialized out of band using the CLI, sessions must be initialized - with the device login methods before API calls may be made. + with the device login methods `device_user_login_initiate()` and + `device_user_login_complete()` before API calls may be made. Parameters: client_id: Client ID @@ -348,17 +380,17 @@ def from_env(variable_name: typing.Optional[str] = None) -> Auth: Reads the `PL_API_KEY` environment variable Pending Deprecation: - This method is pending deprecation. The method `from_defaults()` + This method is pending deprecation. The method `from_user_default_session()` considers environment variables and configuration files through the planet_auth and planet_auth_utils libraries, and works with - legacy API keys, OAuth2 M2M clients, OAuth2 interactive profiles. + legacy API keys, OAuth2 M2M clients, and OAuth2 interactive profiles. This method should be used in most cases as a replacement. Parameters: variable_name: Alternate environment variable. """ warnings.warn( - "from_env() will be deprecated. Use from_defaults() in most" + "from_env() will be deprecated. Use from_user_default_session() in most" " cases, which will consider both environment variables and user" " configuration files.", PendingDeprecationWarning) @@ -370,17 +402,6 @@ def from_env(variable_name: typing.Optional[str] = None) -> Auth: def from_login(email: str, password: str, base_url: typing.Optional[str] = None) -> Auth: - """Create authentication from login email and password. - - Note: To keep your password secure, the use of `getpass` is - recommended. - - Parameters: - email: Planet account email address. - password: Planet account password. - base_url: The base URL to use. Defaults to production - authentication API base url. - """ raise DeprecationWarning( "Auth.from_login() has been deprecated. Use Auth.from_user_session()." ) diff --git a/planet/cli/auth.py b/planet/cli/auth.py index c3ef0b1e4..a789ec7aa 100644 --- a/planet/cli/auth.py +++ b/planet/cli/auth.py @@ -32,8 +32,8 @@ def cmd_auth(ctx): planet_auth_utils.cmd_plauth_login, [ # Hide client ID / client secret until we are ready for OAuth M2M - "auth_client_id", - "auth_client_secret", + # "auth_client_id", + # "auth_client_secret", # Hide audience and organization. They are useful for plauth as a # generic OAuth client, but within the planet SDK we only care about # the built-ins. @@ -69,4 +69,6 @@ def cmd_auth_profile(ctx): cmd_auth_profile.add_command(name="show", cmd=planet_auth_utils.cmd_profile_show) cmd_auth_profile.add_command(name="set", cmd=planet_auth_utils.cmd_profile_set) +cmd_auth_profile.add_command(name="copy", + cmd=planet_auth_utils.cmd_profile_copy) cmd_auth.add_command(cmd_auth_profile) diff --git a/planet/cli/cli.py b/planet/cli/cli.py index 0c900bcdc..2b63cd03d 100644 --- a/planet/cli/cli.py +++ b/planet/cli/cli.py @@ -70,12 +70,16 @@ def _configure_cli_auth_ctx(ctx, auth_client_secret, auth_api_key): # planet-auth library Auth context type + # Embedded click commands imported from planet_auth_utils expect + # this in the 'AUTH' context field. ctx.obj[ 'AUTH'] = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context( auth_profile_opt=auth_profile, auth_client_id_opt=auth_client_id, auth_client_secret_opt=auth_client_secret, - auth_api_key_opt=auth_api_key) + auth_api_key_opt=auth_api_key, + use_env=True, + use_configfile=True) # planet SDK Auth context type ctx.obj['PLSDK_AUTH'] = planet.Auth._from_plauth( diff --git a/pyproject.toml b/pyproject.toml index dd97e18a4..2fe2ec1aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "pyjwt>=2.1", "tqdm>=4.56", "typing-extensions", - "planet-auth==2.0.11b1746663950", + "planet-auth==2.0.11b1748473964", ] readme = "README.md" requires-python = ">=3.9" From a527c05f19d07cfc7ed5bcb253265314e3dcd3fd Mon Sep 17 00:00:00 2001 From: Carl Adams <57012982+carl-adams-planet@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:54:19 +0000 Subject: [PATCH 08/24] bump dependencies (#1163) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2fe2ec1aa..770df1d34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "pyjwt>=2.1", "tqdm>=4.56", "typing-extensions", - "planet-auth==2.0.11b1748473964", + "planet-auth>=2.1.0", ] readme = "README.md" requires-python = ">=3.9" From 473bbceb98b3191dd3906571bfe8cc0787961e5f Mon Sep 17 00:00:00 2001 From: "Carl A. Adams" Date: Tue, 15 Jul 2025 13:03:13 -0700 Subject: [PATCH 09/24] update doc links --- README.md | 20 ++++++++++---------- docs/resources/index.md | 2 +- mkdocs.yml | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ddb555006..574dc41a1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The [Planet](https://planet.com) Software Development Kit (SDK) for Python provides both a Python API and a command-line interface (CLI) to make use of [the Planet APIs](https://docs.planet.com/develop/apis/). Everything you need to get started is found in our -[online documentation](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/). +[online documentation](https://planet-sdk-for-python.readthedocs.io/en/latest/). Version 2.0 includes support for the core workflows of the following APIs: @@ -33,7 +33,7 @@ with semantic version identifiers that comply with [PEP 440](https://peps.python The Semantic Versioning stability scheme only applies to APIs that are considered part of the public API. This includes library APIs exported from the `planet` package and documented in our -[SDK developer documentation](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/), +[SDK developer documentation](https://planet-sdk-for-python.readthedocs.io/en/latest/), and the `planet` CLI interface used for scripts. It does not include library interfaces below the top level `planet` Python package which are considered internal and subject to change without notice. @@ -81,11 +81,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#branches) for more information on branches ##### Current Mainline Versions and Branches -| Version | Status | Branch | Documentation | Initial Release | End of Active Development | End of Maintenance | Notes | -|---------|---------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-----------------|---------------------------|--------------------|-------------------------------------------------------------------------------------------------| -| 3.x | `development` | [`main-3.0-dev`](https://github.com/planetlabs/planet-client-python/tree/main-3.0-dev) | [Planet Labs Python Client on ReadTheDocs.io](https://planet-sdk-for-python.readthedocs.io/en/latest/) | TBD | TBD | TBD | See [3.0.0 Release Milestone](https://github.com/planetlabs/planet-client-python/milestone/31). | -| 2.x | `active` | [`main`](https://github.com/planetlabs/planet-client-python/tree/main) | [Planet Labs Python Client v2 on ReadTheDocs.io](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/) | April 2023 | TBD | TBD | | -| 1.x | `end-of-life` | [`v1`](https://github.com/planetlabs/planet-client-python/tree/v1) | [Planet Labs Python Client v1 on Github.io](https://planetlabs.github.io/planet-client-python/) | April 2017 | April 2023 | TBD | | +| Version | Status | Branch | Documentation | Initial Release | End of Active Development | End of Maintenance | Notes | +|---------|---------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-----------------------|---------------------------|--------------------|-------------------------------------------------------------------------------------------------| +| 3.x | `development` | [`main-3.0-dev`](https://github.com/planetlabs/planet-client-python/tree/main-3.0-dev) | [Planet Labs Python Client on ReadTheDocs.io](https://planet-sdk-for-python.readthedocs.io/en/latest/) | Targeting August 2025 | TBD | TBD | See [3.0.0 Release Milestone](https://github.com/planetlabs/planet-client-python/milestone/31). | +| 2.x | `active` | [`main`](https://github.com/planetlabs/planet-client-python/tree/main) | [Planet Labs Python Client v2 on ReadTheDocs.io](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/) | April 2023 | TBD | TBD | | +| 1.x | `end-of-life` | [`v1`](https://github.com/planetlabs/planet-client-python/tree/v1) | [Planet Labs Python Client v1 on Github.io](https://planetlabs.github.io/planet-client-python/) | April 2017 | April 2023 | TBD | | ## Installation and Quick Start @@ -103,7 +103,7 @@ pip install . Note that the above commands will install the Planet SDK into the global system Python unless a virtual environment is enabled. For more information on configuring a virtual environment from system Python, see the official Python [venv](https://docs.python.org/3/library/venv.html) documentation. For users who are running multiple versions of Python via [pyenv](https://github.com/pyenv/pyenv), see the [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv) extension documentation. -Detailed installation instructions for the Planet SDK can be found in the [Quick Start Guide](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/get-started/quick-start-guide/) of the documentation. +Detailed installation instructions for the Planet SDK can be found in the [Quick Start Guide](https://planet-sdk-for-python.readthedocs.io/en/latest/get-started/quick-start-guide/) of the documentation. ## Contributing and Development @@ -111,7 +111,7 @@ To contribute or develop with this library, see [CONTRIBUTING.md](CONTRIBUTING.m ## Documentation -Documentation is currently [hosted online](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/) +Documentation is currently [hosted online](https://planet-sdk-for-python.readthedocs.io/en/latest/) It should be considered 'in progress', with many updates to come. It can also be built and hosted locally (see [CONTRIBUTING.md](CONTRIBUTING.md)) or can be read from source in the [docs](/docs) directory. @@ -119,4 +119,4 @@ read from source in the [docs](/docs) directory. ## Authentication Planet's APIs require an account for use. To get started you need to -[Get a Planet Account](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/get-started/get-your-planet-account/). +[Get a Planet Account](https://planet-sdk-for-python.readthedocs.io/en/latest/get-started/get-your-planet-account/). diff --git a/docs/resources/index.md b/docs/resources/index.md index ff7bdc473..1d61eab24 100644 --- a/docs/resources/index.md +++ b/docs/resources/index.md @@ -43,4 +43,4 @@ To contribute or develop with this library, see ## Version 1 of this SDK -[Version 1 of this SDK](https://github.com/planetlabs/planet-client-python/tree/1.5.2) is significantly different (see the [documentation](https://planet-sdk-for-python.readthedocs.io/en/latest/)). Version 2 is not backward compatible. Make sure to create a separate virtual environment if you need to work with both versions. For more information on how to do this, see the [Virtual Environments and the Planet SDK for Python](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/get-started/venv-tutorial/). +[Version 1 of this SDK](https://github.com/planetlabs/planet-client-python/tree/1.5.2) is significantly different (see the [documentation](https://planet-sdk-for-python.readthedocs.io/en/latest/)). Version 2 was not backward compatible. Make sure to create a separate virtual environment if you need to work with both versions. For more information on how to do this, see the [Virtual Environments and the Planet SDK for Python](https://planet-sdk-for-python.readthedocs.io/en/latest/get-started/venv-tutorial/). diff --git a/mkdocs.yml b/mkdocs.yml index 039dd2798..d9ed18ade 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: Planet SDK for Python -site_url: https://planet-sdk-for-python-v2.readthedocs.io/en/latest/ +site_url: https://planet-sdk-for-python.readthedocs.io/en/latest/ site_author: https://docs.planet.com site_description: >- A Python library to discover and retrieve earth observation data from Planet Labs PBC. From 19d39b0e79153b0e8cf94eb3f9b23c1e5bdf7279 Mon Sep 17 00:00:00 2001 From: Adrian Sonnenschein Date: Thu, 24 Jul 2025 09:15:54 -0400 Subject: [PATCH 10/24] Sync CLAUDE.md with main-3.0-dev (#1172) Add CLAUDE.md (#1168) * add claude md * change nox examples python version to 3.12 --- CLAUDE.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..fbf150373 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,83 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Testing +- **Run all tests**: `nox` (runs lint, analyze, test, coverage, docs) +- **Run tests only**: `nox -s test` +- **Run tests on specific Python version**: `nox -s test-3.12` +- **Run single test file**: `nox -s test-3.12 -- tests/unit/test_http.py` +- **Run tests by keyword**: `nox -s test-3.12 -- -k test__Limiter` +- **Fast rerun (reuse environments)**: `nox -r` + +### Linting and Code Quality +- **Linting**: `nox -s lint` +- **Type checking**: `nox -s analyze` +- **Code coverage**: `nox -s coverage` +- **Format code**: `yapf --in-place -r .` +- **Check formatting**: `yapf --diff -r .` + +### Documentation +- **Build docs**: `nox -s docs` +- **Serve docs locally**: `nox -s watch` +- **Test documentation examples**: `nox -s docs_test` + +### Example Testing +- **Test all examples**: `nox -s examples` +- **Test specific example**: `nox -s examples -- script_name.py` + +### Building and Publishing +- **Build package**: `nox -s build` +- **Clean build directories**: `nox -s clean` + +## Code Architecture + +### High-Level Structure +The Planet SDK provides both a Python API and CLI for Planet's APIs (Data, Orders, Subscriptions, Features). + +### Core Components + +**API Clients** (`planet/clients/`): +- `DataClient` - Search Planet's imagery catalog +- `OrdersClient` - Process and download imagery +- `SubscriptionsClient` - Auto-process and deliver imagery +- `FeaturesClient` - Upload areas of interest + +**Sync Client** (`planet/sync/`): +- `Planet` class - High-level synchronous interface combining all clients + +**CLI** (`planet/cli/`): +- Entry point: `planet.cli.cli:main` +- Command modules: `data.py`, `orders.py`, `subscriptions.py`, `features.py` + +**Core Infrastructure**: +- `http.py` - HTTP session management and authentication +- `auth.py` - Authentication handling +- `models.py` - Data models and response objects +- `exceptions.py` - Custom exception classes + +**Request Building**: +- `data_filter.py` - Data API search filters +- `order_request.py` - Orders API request construction +- `subscription_request.py` - Subscriptions API request construction + +### Key Patterns +- All API clients extend `base.py:BaseClient` +- Async and sync versions available (clients vs sync modules) +- CLI commands use Click framework with shared options in `options.py` +- Request/response validation via `specs.py` and `models.py` + +## Testing Configuration +- Uses pytest with configuration in `setup.cfg` +- Supports Python 3.9-3.13 +- Coverage threshold: 90% (configured in setup.cfg) +- Integration tests require Planet API credentials +- Unit tests in `tests/unit/`, integration tests in `tests/integration/` + +## Code Style +- Follows PEP8 via YAPF formatter +- Type hints checked with mypy +- Flake8 linting with specific ignores (see setup.cfg) +- Docstrings in Google format for auto-generated API docs \ No newline at end of file From 4d3beef1dd0774767c1efc197e74d5b36fb69ea5 Mon Sep 17 00:00:00 2001 From: Adrian Sonnenschein Date: Thu, 24 Jul 2025 10:02:11 -0400 Subject: [PATCH 11/24] Simplify subscription clip rules in the Subscriptions API SDK (#1169) * update clip tool rules * fix test --- docs/python/sdk-guide.md | 2 +- planet/subscription_request.py | 50 +++------------------ tests/integration/test_subscriptions_cli.py | 2 +- tests/unit/test_subscription_request.py | 13 +----- 4 files changed, 8 insertions(+), 59 deletions(-) diff --git a/docs/python/sdk-guide.md b/docs/python/sdk-guide.md index 19cf53ed6..e8c00a49e 100644 --- a/docs/python/sdk-guide.md +++ b/docs/python/sdk-guide.md @@ -225,7 +225,7 @@ You will need your ACCESS_KEY_ID, SECRET_ACCESS_KEY, bucket and region name. To subscribe to scenes that match a filter, use the `subscription_request` module to build a request, and pass it to the `subscriptions.create_subscription()` method of the client. -By default, a request to create a subscription will not clip matching imagery which intersects the source geometry. To clip to the subscription source geometry, set `planet.subscription_request.build_request()` keyword argument `clip_to_source = True` as in the example below. To clip to a custom geometry, set `planet.subscription_request.build_request()` keyword argument `clip_to_source = False` (or omit it entirely to fall back on the default value), and instead configure the custom clip AOI with `planet.subscription_request.clip_tool()`. +By default, a request to create a subscription will not clip matching imagery which intersects the source geometry. To clip to the subscription source geometry, set `planet.subscription_request.build_request()` keyword argument `clip_to_source = True` as in the example below. Custom clip AOIs are no longer supported in subscriptions. Warning: the following code will create a subscription, consuming quota based on your plan. diff --git a/planet/subscription_request.py b/planet/subscription_request.py index 4aa4b5ead..18e9aad4b 100644 --- a/planet/subscription_request.py +++ b/planet/subscription_request.py @@ -72,10 +72,10 @@ def build_request(name: str, collection_id: A Sentinel Hub collection ID. create_configuration: Automatically create a layer configuration for your collection. clip_to_source: Whether or not to clip to the source geometry (defaults to False). If - True, a clip configuration that specifies the subscription source geometry as clip - AOI will be added to the list of requested tools. If True and 'clip_tool()' is - also specified, an exception will be raised. If False, no clip configuration - will be added to the list of requested tools unless 'clip_tool()' is specified. + True, a clip configuration will be added to the list of requested tools that + automatically clips to the subscription source geometry. If True and a clip tool is + also specified in the tools list, an exception will be raised. If False, no clip + configuration will be added to the list of requested tools. Returns: dict: a representation of a Subscriptions API request for @@ -136,12 +136,7 @@ def build_request(name: str, "clip_to_source option conflicts with a configured clip tool." ) else: - tool_list.append({ - 'type': 'clip', - 'parameters': { - 'aoi': source['parameters']['geometry'] - } - }) + tool_list.append({'type': 'clip', 'parameters': {}}) details['tools'] = tool_list @@ -638,41 +633,6 @@ def band_math_tool(b1: str, return _tool('bandmath', parameters) -def clip_tool(aoi: Mapping) -> dict: - """Specify a subscriptions API clip tool. - - Imagery and udm files will be clipped to your area of interest. nodata - pixels will be preserved. Xml file attributes “filename”, “numRows”, - “numColumns” and “footprint” will be updated based on the clip results. - - The clipped output files will have “_clip” appended to their file names. If - the clip aoi is so large that full scenes may be delivered without any - clipping, those files will not have “_clip” appended to their file name. - - NOTE: To clip to the source geometry, set the 'clip_to_source' parameter - of 'planet.subscription_request.build_request()' to True instead of using - this tool. - - Parameters: - aoi: GeoJSON polygon or multipolygon defining the clip area, with up to - 500 vertices. The minimum geographic area of any polygon or - internal ring is one square meter. - - Raises: - planet.exceptions.ClientError: If aoi is not a valid polygon or - multipolygon. - """ - - valid_types = ['Polygon', 'MultiPolygon', 'ref'] - - geom = geojson.as_geom_or_ref(dict(aoi)) - if geom['type'].lower() not in [v.lower() for v in valid_types]: - raise ClientError( - f'Invalid geometry type: {geom["type"]} is not in {valid_types}.') - - return _tool('clip', {'aoi': geom}) - - def file_format_tool(file_format: str) -> dict: """Specify a subscriptions API file format tool. diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py index b911f7daa..fda7e26e9 100644 --- a/tests/integration/test_subscriptions_cli.py +++ b/tests/integration/test_subscriptions_cli.py @@ -385,7 +385,7 @@ def test_request_base_clip_to_source(geom_fixture, request, invoke): req = json.loads(result.output) tool = req["tools"][0] assert tool["type"] == "clip" - assert tool["parameters"]["aoi"] == geom + assert tool["parameters"] == {} def test_request_catalog_success(mock_bundles, invoke, geom_geojson): diff --git a/tests/unit/test_subscription_request.py b/tests/unit/test_subscription_request.py index deafbf94e..ec17dc25f 100644 --- a/tests/unit/test_subscription_request.py +++ b/tests/unit/test_subscription_request.py @@ -87,7 +87,7 @@ def test_build_request_clip_to_source_success(geom_geojson): clip_to_source=True, ) assert req["tools"][1]["type"] == "clip" - assert req["tools"][1]["parameters"]["aoi"] == geom_geojson + assert req["tools"][1]["parameters"] == {} def test_build_request_clip_to_source_failure(geom_geojson): @@ -499,17 +499,6 @@ def test_band_math_tool_invalid_pixel_type(): pixel_type="invalid") -def test_clip_tool_success(geom_geojson): - res = subscription_request.clip_tool(geom_geojson) - expected = {"type": "clip", "parameters": {"aoi": geom_geojson}} - assert res == expected - - -def test_clip_tool_invalid_type(point_geom_geojson): - with pytest.raises(exceptions.ClientError): - subscription_request.clip_tool(point_geom_geojson) - - def test_file_format_tool_success(): res = subscription_request.file_format_tool('COG') From 94e3b8d9c036ae515294869811d6a7cfe4933c12 Mon Sep 17 00:00:00 2001 From: Ian Schneider Date: Mon, 12 May 2025 13:59:08 -0600 Subject: [PATCH 12/24] initial mosaics cli + async client --- docs/python/sdk-reference.md | 4 + examples/mosaics-cli.sh | 16 ++ planet/__init__.py | 3 +- planet/cli/cli.py | 2 + planet/cli/mosaics.py | 279 ++++++++++++++++++++++++ planet/cli/types.py | 11 + planet/clients/__init__.py | 8 +- planet/clients/mosaics.py | 397 +++++++++++++++++++++++++++++++++++ 8 files changed, 718 insertions(+), 2 deletions(-) create mode 100755 examples/mosaics-cli.sh create mode 100644 planet/cli/mosaics.py create mode 100644 planet/clients/mosaics.py diff --git a/docs/python/sdk-reference.md b/docs/python/sdk-reference.md index dee669b39..f1bf5257d 100644 --- a/docs/python/sdk-reference.md +++ b/docs/python/sdk-reference.md @@ -10,6 +10,10 @@ title: Python SDK API Reference rendering: show_root_full_path: false +## ::: planet.MosaicsClient + rendering: + show_root_full_path: false + ## ::: planet.OrdersClient rendering: show_root_full_path: false diff --git a/examples/mosaics-cli.sh b/examples/mosaics-cli.sh new file mode 100755 index 000000000..912135110 --- /dev/null +++ b/examples/mosaics-cli.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +echo -e "Global Basemap Series" +planet mosaics series list --name-contains=Global | jq .[].name + +echo -e "\nLatest Global Monthly" +planet mosaics series list-mosaics "Global Monthly" --latest --pretty + +echo -e "\nHow Many Quads?" +planet mosaics search 09462e5a-2af0-4de3-a710-e9010d8d4e58 --bbox=-100,40,-100,40.1 + +echo -e "\nWhat Scenes Contributed to Quad?" +planet mosaics contributions 09462e5a-2af0-4de3-a710-e9010d8d4e58 455-1273 + +echo -e "\nDownload Them!" +planet mosaics download 09462e5a-2af0-4de3-a710-e9010d8d4e58 --bbox=-100,40,-100,40.1 --output-dir=quads \ No newline at end of file diff --git a/planet/__init__.py b/planet/__init__.py index 8858af955..4547c495a 100644 --- a/planet/__init__.py +++ b/planet/__init__.py @@ -17,7 +17,7 @@ from .__version__ import __version__ # NOQA from .auth import Auth from .auth_builtins import PlanetOAuthScopes -from .clients import DataClient, FeaturesClient, OrdersClient, SubscriptionsClient # NOQA +from .clients import DataClient, FeaturesClient, MosaicsClient, OrdersClient, SubscriptionsClient # NOQA from .io import collect from .sync import Planet @@ -28,6 +28,7 @@ 'DataClient', 'data_filter', 'FeaturesClient', + 'MosaicsClient', 'OrdersClient', 'order_request', 'Planet', diff --git a/planet/cli/cli.py b/planet/cli/cli.py index 2b63cd03d..bfa04cb80 100644 --- a/planet/cli/cli.py +++ b/planet/cli/cli.py @@ -20,6 +20,7 @@ import planet_auth_utils import planet +from planet.cli import mosaics from . import auth, cmds, collect, data, orders, subscriptions, features @@ -128,6 +129,7 @@ def _configure_logging(verbosity): main.add_command(subscriptions.subscriptions) # type: ignore main.add_command(collect.collect) # type: ignore main.add_command(features.features) +main.add_command(mosaics.mosaics) if __name__ == "__main__": main() # pylint: disable=E1120 diff --git a/planet/cli/mosaics.py b/planet/cli/mosaics.py new file mode 100644 index 000000000..165b9481d --- /dev/null +++ b/planet/cli/mosaics.py @@ -0,0 +1,279 @@ +import asyncio +from contextlib import asynccontextmanager + +import click + +from planet.cli.cmds import command +from planet.cli.io import echo_json +from planet.cli.session import CliSession +from planet.cli.types import BoundingBox, DateTime, Geometry +from planet.cli.validators import check_geom +from planet.clients.mosaics import MosaicsClient + + +@asynccontextmanager +async def client(ctx): + async with CliSession() as sess: + cl = MosaicsClient(sess, base_url=ctx.obj['BASE_URL']) + yield cl + + +include_links = click.option("--links", + is_flag=True, + help=("If enabled, include API links")) + +name_contains = click.option( + "--name-contains", + type=str, + help=("Match if the name contains text, case-insensitive")) + +bbox = click.option('--bbox', + type=BoundingBox(), + help=("Region to download as comma-delimited strings: " + " lon_min,lat_min,lon_max,lat_max")) + +interval = click.option("--interval", + type=str, + help=("Match this interval, e.g. 1 mon")) + +acquired_gt = click.option("--acquired_gt", + type=DateTime(), + help=("Imagery acquisition after than this date")) + +acquired_lt = click.option("--acquired_lt", + type=DateTime(), + help=("Imagery acquisition before than this date")) + +geometry = click.option('--geometry', + type=Geometry(), + callback=check_geom, + help=("A geojson geometry to search with. " + "Can be a string, filename, or - for stdin.")) + + +def _strip_links(resource): + if isinstance(resource, dict): + resource.pop("_links", None) + return resource + + +async def _output(result, pretty, include_links=False): + if asyncio.iscoroutine(result): + result = await result + if result is None: + raise click.ClickException("not found") + if not include_links: + _strip_links(result) + echo_json(result, pretty) + else: + results = [_strip_links(r) async for r in result] + echo_json(results, pretty) + + +@click.group() # type: ignore +@click.pass_context +@click.option('-u', + '--base-url', + default=None, + help='Assign custom base Mosaics API URL.') +def mosaics(ctx, base_url): + """Commands for interacting with the Mosaics API""" + ctx.obj['BASE_URL'] = base_url + + +@mosaics.group() # type: ignore +def series(): + """Commands for interacting with Mosaic Series through the Mosaics API""" + + +@command(mosaics, name="contributions") +@click.argument("name") +@click.argument("quad") +async def quad_contributions(ctx, name, quad, pretty): + '''Get contributing scenes for a mosaic quad + + Example: + + planet mosaics contribution global_monthly_2025_04_mosaic 575-1300 + ''' + async with client(ctx) as cl: + item = await cl.get_quad(name, quad) + await _output(cl.get_quad_contributions(item), pretty) + + +@command(mosaics, name="info") +@click.argument("name", required=True) +@include_links +async def mosaic_info(ctx, name, pretty, links): + """Get information for a specific mosaic + + Example: + + planet mosaics info global_monthly_2025_04_mosaic + """ + async with client(ctx) as cl: + await _output(cl.get_mosaic(name), pretty, links) + + +@command(mosaics, name="list") +@name_contains +@interval +@acquired_gt +@acquired_lt +@include_links +async def mosaics_list(ctx, + name_contains, + interval, + acquired_gt, + acquired_lt, + pretty, + links): + """List all mosaics + + Example: + + planet mosaics info global_monthly_2025_04_mosaic + """ + async with client(ctx) as cl: + await _output( + cl.list_mosaics(name_contains=name_contains, + interval=interval, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt), + pretty, + links) + + +@command(series, name="info") +@click.argument("name", required=True) +@include_links +async def series_info(ctx, name, pretty, links): + """Get information for a specific series + + Example: + + planet series info "Global Quarterly" + """ + async with client(ctx) as cl: + await _output(cl.get_series(name), pretty, links) + + +@command(series, name="list") +@name_contains +@interval +@acquired_gt +@acquired_lt +@include_links +async def series_list(ctx, + name_contains, + interval, + acquired_gt, + acquired_lt, + pretty, + links): + """List series + + Example: + + planet mosaics series list --name-contains=Global + """ + async with client(ctx) as cl: + await _output( + cl.list_series( + name_contains, + interval, + acquired_gt, + acquired_lt, + ), + pretty, + links) + + +@command(series, name="list-mosaics") +@click.argument("name", required=True) +@click.option("--latest", + is_flag=True, + help=("Get the latest mosaic in the series")) +@acquired_gt +@acquired_lt +@include_links +async def list_series_mosaics(ctx, + name, + acquired_gt, + acquired_lt, + latest, + links, + pretty): + """List mosaics in a series + + Example: + + planet mosaics series list-mosaics global_monthly_2025_04_mosaic + """ + async with client(ctx) as cl: + await _output( + cl.list_series_mosaics(name, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt, + latest=latest), + pretty, + links) + + +@command(mosaics, name="search") +@click.argument("name", required=True) +@bbox +@geometry +@click.option("--summary", + is_flag=True, + help=("Get a count of how many quads would be returned")) +@include_links +async def list_quads(ctx, name, bbox, geometry, summary, links, pretty): + """Search quads + + Example: + + planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41 + """ + async with client(ctx) as cl: + mosaic = await cl.get_mosaic(name) + if mosaic is None: + raise click.ClickException("No mosaic named " + name) + await _output( + cl.list_quads(mosaic, + minimal=False, + bbox=bbox, + geometry=geometry, + summary=summary), + pretty, + links) + + +@command(mosaics, name="download") +@click.argument("name", required=True) +@click.option('--output-dir', + default='.', + help=('Directory for file download.'), + type=click.Path(exists=True, + resolve_path=True, + writable=True, + file_okay=False)) +@bbox +@geometry +async def download(ctx, name, output_dir, bbox, geometry, **kwargs): + """Download quads from a mosaic + + Example: + + planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41 + """ + quiet = ctx.obj['QUIET'] + async with client(ctx) as cl: + mosaic = await cl.get_mosaic(name) + if mosaic is None: + raise click.ClickException("No mosaic named " + name) + await cl.download_quads(mosaic, + bbox=bbox, + geometry=geometry, + directory=output_dir, + progress_bar=not quiet) diff --git a/planet/cli/types.py b/planet/cli/types.py index 6032fe709..c3168ea52 100644 --- a/planet/cli/types.py +++ b/planet/cli/types.py @@ -140,3 +140,14 @@ def convert(self, value, param, ctx) -> datetime: self.fail(str(e)) return value + + +class BoundingBox(click.ParamType): + name = 'bbox' + + def convert(self, val, param, ctx): + try: + xmin, ymin, xmax, ymax = map(float, val.split(',')) + except (TypeError, ValueError): + raise click.BadParameter('Invalid bounding box') + return (xmin, ymin, xmax, ymax) diff --git a/planet/clients/__init__.py b/planet/clients/__init__.py index b1304d8da..7cbd04672 100644 --- a/planet/clients/__init__.py +++ b/planet/clients/__init__.py @@ -14,17 +14,23 @@ # limitations under the License. from .data import DataClient from .features import FeaturesClient +from .mosaics import MosaicsClient from .orders import OrdersClient from .subscriptions import SubscriptionsClient __all__ = [ - 'DataClient', 'FeaturesClient', 'OrdersClient', 'SubscriptionsClient' + 'DataClient', + 'FeaturesClient', + 'MosaicsClient', + 'OrdersClient', + 'SubscriptionsClient' ] # Organize client classes by their module name to allow lookup. _client_directory = { 'data': DataClient, 'features': FeaturesClient, + 'mosaics': MosaicsClient, 'orders': OrdersClient, 'subscriptions': SubscriptionsClient } diff --git a/planet/clients/mosaics.py b/planet/clients/mosaics.py new file mode 100644 index 000000000..fb813ebc5 --- /dev/null +++ b/planet/clients/mosaics.py @@ -0,0 +1,397 @@ +import asyncio +from pathlib import Path +from typing import AsyncIterator, Awaitable, Optional, Tuple, Type, TypeVar, Union, cast +from planet.constants import PLANET_BASE_URL +from planet.http import Session +from planet.models import Paged, Response, StreamingBody +from uuid import UUID + +BASE_URL = f'{PLANET_BASE_URL}/basemaps/v1' + +T = TypeVar("T") + +Number = Union[int, float] + +BBox = Tuple[Number, Number, Number, Number] + + +class Series(Paged): + ITEMS_KEY = 'series' + NEXT_KEY = '_next' + + +class Mosaics(Paged): + ITEMS_KEY = 'mosaics' + NEXT_KEY = '_next' + + +class MosaicQuads(Paged): + ITEMS_KEY = 'items' + NEXT_KEY = '_next' + + +def _is_uuid(val: str) -> bool: + try: + UUID(val) + return True + except ValueError: + return False + + +class MosaicsClient: + """High-level asynchronous access to Planet's Mosaics API. + + Example: + ```python + >>> import asyncio + >>> from planet import Session + >>> + >>> async def main(): + ... async with Session() as sess: + ... cl = sess.client('data') + ... # use client here + ... + >>> asyncio.run(main()) + ``` + """ + + def __init__(self, session: Session, base_url: Optional[str] = None): + """ + Parameters: + session: Open session connected to server. + base_url: The base URL to use. Defaults to production orders 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] + + def _call_sync(self, f: Awaitable[T]) -> T: + """block on an async function call, using the call_sync method of the session""" + return self._session._call_sync(f) + + def _url(self, path: str) -> str: + return f"{BASE_URL}/{path}" + + async def _get_by_name(self, path: str, pager: Type[Paged], + name: str) -> Optional[dict]: + response = await self._session.request( + method='GET', + url=self._url(path), + params={ + "name__is": name, + }, + ) + listing = response.json()[pager.ITEMS_KEY] + return listing[0] if listing else None + + async def _get_by_id(self, path: str, id: str) -> dict: + response = await self._session.request(method="GET", + url=self._url(f"{path}/{id}")) + return response.json() + + async def _get(self, name_or_id: str, path: str, + pager: Type[Paged]) -> Optional[dict]: + if _is_uuid(name_or_id): + return await self._get_by_id(path, name_or_id) + return await self._get_by_name(path, pager, name_or_id) + + async def get_mosaic(self, name_or_id: str) -> Optional[dict]: + """Get the API representation of a mosaic by name or id. + + :param name str: The name or id of the mosaic + :returns: dict or None (if searching by name) + :raises planet.api.exceptions.APIException: On API error. + """ + return await self._get(name_or_id, "mosaics", Mosaics) + + async def get_series(self, name_or_id: str) -> Optional[dict]: + """Get the API representation of a series by name or id. + + :param name str: The name or id of the series + :returns: dict or None (if searching by name) + :raises planet.api.exceptions.APIException: On API error. + """ + return await self._get(name_or_id, "series", Series) + + async def list_series( + self, + name_contains: Optional[str] = None, + interval: Optional[str] = None, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None) -> AsyncIterator[dict]: + """ + List the series you have access to. + + Example: + + ``` + series = await client.list_series() + async for s in series: + print(s) + ``` + """ + params = {} + if name_contains: + params["name__contains"] = name_contains + if interval: + params["interval"] = interval + if acquired_gt: + params["acquired__gt"] = acquired_gt + if acquired_lt: + params["acquired__lt"] = acquired_lt + resp = await self._session.request( + method='GET', + url=self._url("series"), + params=params, + ) + async for item in Series(resp, self._session.request): + yield item + + async def list_mosaics( + self, + name_contains: Optional[str] = None, + interval: Optional[str] = None, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None, + latest: bool = False, + ) -> AsyncIterator[dict]: + """ + List the mosaics you have access to. + + Example: + + ``` + mosaics = await client.list_mosacis() + async for m in mosaics: + print(m) + ``` + """ + params = {} + if name_contains: + params["name__contains"] = name_contains + if interval: + params["interval"] = interval + if acquired_gt: + params["acquired__gt"] = acquired_gt + if acquired_lt: + params["acquired__lt"] = acquired_lt + if latest: + params["latest"] = "yes" + resp = await self._session.request( + method='GET', + url=self._url("mosaics"), + params=params, + ) + async for item in Mosaics(resp, self._session.request): + yield item + + async def list_series_mosaics( + self, + name_or_id: str, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None, + latest: bool = False, + ) -> AsyncIterator[dict]: + """ + List the mosaics in a series. + + Example: + + ``` + mosaics = await client.list_series_mosaics("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + async for m in mosaics: + print(m) + ``` + """ + if not _is_uuid(name_or_id): + series = await self._get_by_name("series", Series, name_or_id) + if series is None: + return + name_or_id = series["id"] + params = {} + if acquired_gt: + params["acquired__gt"] = acquired_gt + if acquired_lt: + params["acquired__lt"] = acquired_lt + if latest: + params["latest"] = "yes" + resp = await self._session.request( + method="GET", + url=self._url(f"series/{name_or_id}/mosaics"), + params=params, + ) + async for item in Mosaics(resp, self._session.request): + yield item + + async def list_quads(self, + mosaic: dict, + minimal: bool = False, + bbox: Optional[BBox] = None, + geometry: Optional[dict] = None, + summary: bool = False) -> AsyncIterator[dict]: + """ + List the a mosaic's quads. + + Example: + + ``` + mosaic = await client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + quads = await client.list_quads(mosaic) + async for q in quads: + print(q) + ``` + """ + if geometry: + resp = await self._quads_geometry(mosaic, + geometry, + minimal, + summary) + else: + if bbox is None: + xmin, ymin, xmax, ymax = cast(BBox, mosaic['bbox']) + search = (max(-180, xmin), + max(-85, ymin), + min(180, xmax), + min(85, ymax)) + else: + search = bbox + resp = await self._quads_bbox(mosaic, search, minimal, summary) + # kinda yucky - yields a different "shaped" dict + if summary: + yield resp.json()["summary"] + return + async for item in MosaicQuads(resp, self._session.request): + yield item + + async def _quads_geometry(self, + mosaic: dict, + geometry: dict, + minimal: bool, + summary: bool) -> Response: + params = {} + if minimal: + params["minimal"] = "true" + if summary: + params["summary"] = "true" + mosaic_id = mosaic["id"] + return await self._session.request( + method="POST", + url=self._url(f"mosaics/{mosaic_id}/quads/search"), + params=params, + json=geometry, + ) + + async def _quads_bbox(self, + mosaic: dict, + bbox: BBox, + minimal: bool, + summary: bool) -> Response: + quads_template = mosaic["_links"]["quads"] + # this is fully qualified URL, so don't use self._url + url = quads_template.replace("{lx},{ly},{ux},{uy}", + ",".join([str(f) for f in bbox])) + # params will overwrite the templated query + if minimal: + url += "&minimal=true" + if summary: + url += "&summary=true" + return await self._session.request( + method="GET", + url=url, + ) + + async def get_quad(self, name_or_id: str, quad_id: str) -> dict: + """ + Get a mosaic's quad information. + + Example: + + ``` + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + print(quad) + ``` + """ + if not _is_uuid(name_or_id): + mosaic = await self.get_mosaic(name_or_id) + if mosaic is None: + return {} + name_or_id = cast(str, mosaic["id"]) + resp = await self._session.request( + method="GET", + url=self._url(f"mosaics/{name_or_id}/quads/{quad_id}"), + ) + return resp.json() + + async def get_quad_contributions(self, quad: dict) -> list[dict]: + """ + Get a mosaic's quad information. + + Example: + + ``` + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + contributions = await client.get_quad_contributions(quad) + print(contributions) + ``` + """ + resp = await self._session.request( + "GET", + quad["_links"]["items"], + ) + return resp.json()["items"] + + async def download_quad(self, + quad: dict, + directory, + overwrite: bool = False, + progress_bar=False): + url = quad["_links"]["download"] + Path(directory).mkdir(exist_ok=True, parents=True) + async with self._session.stream(method='GET', url=url) as resp: + body = StreamingBody(resp) + dest = Path(directory, body.name) + await body.write(dest, + overwrite=overwrite, + progress_bar=progress_bar) + """ + Download a quad to a directory. + + Example: + + ``` + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + await client.download_quad(quad, ".") + ``` + """ + + async def download_quads(self, + mosaic: dict, + directory: str, + bbox: Optional[BBox] = None, + geometry: Optional[dict] = None, + progress_bar: bool = False, + concurrency: int = 4): + """ + Download a mosaics' quads to a directory. + + Example: + + ``` + mosaic = await cl.get_mosaic(name) + client.download_quads(mosaic, '.', bbox=(-100, 40, -100, 41)) + ``` + """ + jobs = [] + async for q in self.list_quads(mosaic, + minimal=True, + bbox=bbox, + geometry=geometry): + jobs.append( + self.download_quad(q, directory, progress_bar=progress_bar)) + if len(jobs) == concurrency: + await asyncio.gather(*jobs) + jobs = [] + await asyncio.gather(*jobs) From d11d905d7e8910628744e5550cddec4ffb559b48 Mon Sep 17 00:00:00 2001 From: Ian Schneider Date: Mon, 19 May 2025 11:16:47 -0600 Subject: [PATCH 13/24] address feedback and typos - make it clearer that name or ID can be used to lookup items - add typed dicts to represent API responses/resources - accept typed dict or name/ID when possible - raise MissingResource when attempting a get by name with no match - use positional/keyword-only parameters in client methods --- examples/mosaics-cli.sh | 12 ++-- planet/cli/mosaics.py | 73 ++++++++++++------------ planet/clients/mosaics.py | 116 +++++++++++++++++++++++--------------- planet/models.py | 12 ++++ 4 files changed, 124 insertions(+), 89 deletions(-) diff --git a/examples/mosaics-cli.sh b/examples/mosaics-cli.sh index 912135110..997c93c9a 100755 --- a/examples/mosaics-cli.sh +++ b/examples/mosaics-cli.sh @@ -1,16 +1,16 @@ #!/bin/bash -echo -e "Global Basemap Series" +echo -e "List the mosaic series that have the word Global in their name" planet mosaics series list --name-contains=Global | jq .[].name -echo -e "\nLatest Global Monthly" +echo -e "\nWhat is the latest mosaic in the series named Global Monthly, with output indented" planet mosaics series list-mosaics "Global Monthly" --latest --pretty -echo -e "\nHow Many Quads?" -planet mosaics search 09462e5a-2af0-4de3-a710-e9010d8d4e58 --bbox=-100,40,-100,40.1 +echo -e "\nHow many quads are in the mosaic with this ID (name also accepted!)?" +planet mosaics search 09462e5a-2af0-4de3-a710-e9010d8d4e58 --bbox=-100,40,-100,40.1 | jq .[].id -echo -e "\nWhat Scenes Contributed to Quad?" +echo -e "\nWhat scenes contributed to this quad in the mosaic with this ID (name also accepted)?" planet mosaics contributions 09462e5a-2af0-4de3-a710-e9010d8d4e58 455-1273 -echo -e "\nDownload Them!" +echo -e "\nDownload them to a directory named quads!" planet mosaics download 09462e5a-2af0-4de3-a710-e9010d8d4e58 --bbox=-100,40,-100,40.1 --output-dir=quads \ No newline at end of file diff --git a/planet/cli/mosaics.py b/planet/cli/mosaics.py index 165b9481d..c78e9d440 100644 --- a/planet/cli/mosaics.py +++ b/planet/cli/mosaics.py @@ -87,32 +87,32 @@ def series(): @command(mosaics, name="contributions") -@click.argument("name") +@click.argument("name_or_id") @click.argument("quad") -async def quad_contributions(ctx, name, quad, pretty): - '''Get contributing scenes for a mosaic quad +async def quad_contributions(ctx, name_or_id, quad, pretty): + '''Get contributing scenes for a quad in a mosaic specified by name or ID Example: planet mosaics contribution global_monthly_2025_04_mosaic 575-1300 ''' async with client(ctx) as cl: - item = await cl.get_quad(name, quad) + item = await cl.get_quad(name_or_id, quad) await _output(cl.get_quad_contributions(item), pretty) @command(mosaics, name="info") -@click.argument("name", required=True) +@click.argument("name_or_id", required=True) @include_links -async def mosaic_info(ctx, name, pretty, links): - """Get information for a specific mosaic +async def mosaic_info(ctx, name_or_id, pretty, links): + """Get information for a mosaic specified by name or ID Example: planet mosaics info global_monthly_2025_04_mosaic """ async with client(ctx) as cl: - await _output(cl.get_mosaic(name), pretty, links) + await _output(cl.get_mosaic(name_or_id), pretty, links) @command(mosaics, name="list") @@ -128,11 +128,11 @@ async def mosaics_list(ctx, acquired_lt, pretty, links): - """List all mosaics + """List information for all available mosaics Example: - planet mosaics info global_monthly_2025_04_mosaic + planet mosaics list --name-contains global_monthly """ async with client(ctx) as cl: await _output( @@ -145,17 +145,17 @@ async def mosaics_list(ctx, @command(series, name="info") -@click.argument("name", required=True) +@click.argument("name_or_id", required=True) @include_links -async def series_info(ctx, name, pretty, links): - """Get information for a specific series +async def series_info(ctx, name_or_id, pretty, links): + """Get information for a series specified by name or ID Example: planet series info "Global Quarterly" """ async with client(ctx) as cl: - await _output(cl.get_series(name), pretty, links) + await _output(cl.get_series(name_or_id), pretty, links) @command(series, name="list") @@ -171,7 +171,7 @@ async def series_list(ctx, acquired_lt, pretty, links): - """List series + """List information for available series Example: @@ -180,17 +180,17 @@ async def series_list(ctx, async with client(ctx) as cl: await _output( cl.list_series( - name_contains, - interval, - acquired_gt, - acquired_lt, + name_contains=name_contains, + interval=interval, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt, ), pretty, links) @command(series, name="list-mosaics") -@click.argument("name", required=True) +@click.argument("name_or_id", required=True) @click.option("--latest", is_flag=True, help=("Get the latest mosaic in the series")) @@ -198,13 +198,13 @@ async def series_list(ctx, @acquired_lt @include_links async def list_series_mosaics(ctx, - name, + name_or_id, acquired_gt, acquired_lt, latest, - links, - pretty): - """List mosaics in a series + pretty, + links): + """List mosaics in a series specified by name or ID Example: @@ -212,7 +212,7 @@ async def list_series_mosaics(ctx, """ async with client(ctx) as cl: await _output( - cl.list_series_mosaics(name, + cl.list_series_mosaics(name_or_id, acquired_gt=acquired_gt, acquired_lt=acquired_lt, latest=latest), @@ -221,24 +221,24 @@ async def list_series_mosaics(ctx, @command(mosaics, name="search") -@click.argument("name", required=True) +@click.argument("name_or_id", required=True) @bbox @geometry @click.option("--summary", is_flag=True, help=("Get a count of how many quads would be returned")) @include_links -async def list_quads(ctx, name, bbox, geometry, summary, links, pretty): - """Search quads +async def list_quads(ctx, name_or_id, bbox, geometry, summary, pretty, links): + """Search quads in a mosaic specified by name or ID Example: planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41 """ async with client(ctx) as cl: - mosaic = await cl.get_mosaic(name) + mosaic = await cl.get_mosaic(name_or_id) if mosaic is None: - raise click.ClickException("No mosaic named " + name) + raise click.ClickException("No mosaic named " + name_or_id) await _output( cl.list_quads(mosaic, minimal=False, @@ -250,18 +250,17 @@ async def list_quads(ctx, name, bbox, geometry, summary, links, pretty): @command(mosaics, name="download") -@click.argument("name", required=True) +@click.argument("name_or_id", required=True) @click.option('--output-dir', - default='.', - help=('Directory for file download.'), + help=('Directory for file download. Defaults to mosaic name'), type=click.Path(exists=True, resolve_path=True, writable=True, file_okay=False)) @bbox @geometry -async def download(ctx, name, output_dir, bbox, geometry, **kwargs): - """Download quads from a mosaic +async def download(ctx, name_or_id, output_dir, bbox, geometry, **kwargs): + """Download quads from a mosaic by name or ID Example: @@ -269,9 +268,9 @@ async def download(ctx, name, output_dir, bbox, geometry, **kwargs): """ quiet = ctx.obj['QUIET'] async with client(ctx) as cl: - mosaic = await cl.get_mosaic(name) + mosaic = await cl.get_mosaic(name_or_id) if mosaic is None: - raise click.ClickException("No mosaic named " + name) + raise click.ClickException("No mosaic named " + name_or_id) await cl.download_quads(mosaic, bbox=bbox, geometry=geometry, diff --git a/planet/clients/mosaics.py b/planet/clients/mosaics.py index fb813ebc5..48d08edf7 100644 --- a/planet/clients/mosaics.py +++ b/planet/clients/mosaics.py @@ -2,8 +2,9 @@ from pathlib import Path from typing import AsyncIterator, Awaitable, Optional, Tuple, Type, TypeVar, Union, cast from planet.constants import PLANET_BASE_URL +from planet.exceptions import MissingResource from planet.http import Session -from planet.models import Paged, Response, StreamingBody +from planet.models import Mosaic, Paged, Quad, Response, Series, StreamingBody from uuid import UUID BASE_URL = f'{PLANET_BASE_URL}/basemaps/v1' @@ -15,17 +16,17 @@ BBox = Tuple[Number, Number, Number, Number] -class Series(Paged): +class _SeriesPage(Paged): ITEMS_KEY = 'series' NEXT_KEY = '_next' -class Mosaics(Paged): +class _MosaicsPage(Paged): ITEMS_KEY = 'mosaics' NEXT_KEY = '_next' -class MosaicQuads(Paged): +class _QuadsPage(Paged): ITEMS_KEY = 'items' NEXT_KEY = '_next' @@ -59,7 +60,7 @@ def __init__(self, session: Session, base_url: Optional[str] = None): """ Parameters: session: Open session connected to server. - base_url: The base URL to use. Defaults to production orders API + base_url: The base URL to use. Defaults to production Mosaics base url. """ self._session = session @@ -76,7 +77,7 @@ def _url(self, path: str) -> str: return f"{BASE_URL}/{path}" async def _get_by_name(self, path: str, pager: Type[Paged], - name: str) -> Optional[dict]: + name: str) -> dict: response = await self._session.request( method='GET', url=self._url(path), @@ -85,7 +86,9 @@ async def _get_by_name(self, path: str, pager: Type[Paged], }, ) listing = response.json()[pager.ITEMS_KEY] - return listing[0] if listing else None + if len(listing): + return listing[0] + raise MissingResource(f"{name} not found") async def _get_by_id(self, path: str, id: str) -> dict: response = await self._session.request(method="GET", @@ -93,31 +96,37 @@ async def _get_by_id(self, path: str, id: str) -> dict: return response.json() async def _get(self, name_or_id: str, path: str, - pager: Type[Paged]) -> Optional[dict]: + pager: Type[Paged]) -> dict: if _is_uuid(name_or_id): return await self._get_by_id(path, name_or_id) return await self._get_by_name(path, pager, name_or_id) - async def get_mosaic(self, name_or_id: str) -> Optional[dict]: + async def _resolve_mosaic(self, mosaic: Union[Mosaic, str]) -> Mosaic: + if isinstance(mosaic, Mosaic): + return mosaic + return await self.get_mosaic(mosaic) + + async def get_mosaic(self, name_or_id: str) -> Mosaic: """Get the API representation of a mosaic by name or id. :param name str: The name or id of the mosaic :returns: dict or None (if searching by name) :raises planet.api.exceptions.APIException: On API error. """ - return await self._get(name_or_id, "mosaics", Mosaics) + return Mosaic(await self._get(name_or_id, "mosaics", _MosaicsPage)) - async def get_series(self, name_or_id: str) -> Optional[dict]: + async def get_series(self, name_or_id: str) -> Series: """Get the API representation of a series by name or id. :param name str: The name or id of the series :returns: dict or None (if searching by name) :raises planet.api.exceptions.APIException: On API error. """ - return await self._get(name_or_id, "series", Series) + return Series(await self._get(name_or_id, "series", _SeriesPage)) async def list_series( self, + *, name_contains: Optional[str] = None, interval: Optional[str] = None, acquired_gt: Optional[str] = None, @@ -147,11 +156,12 @@ async def list_series( url=self._url("series"), params=params, ) - async for item in Series(resp, self._session.request): + async for item in _SeriesPage(resp, self._session.request): yield item async def list_mosaics( self, + *, name_contains: Optional[str] = None, interval: Optional[str] = None, acquired_gt: Optional[str] = None, @@ -164,7 +174,7 @@ async def list_mosaics( Example: ``` - mosaics = await client.list_mosacis() + mosaics = await client.list_mosaics() async for m in mosaics: print(m) ``` @@ -185,12 +195,14 @@ async def list_mosaics( url=self._url("mosaics"), params=params, ) - async for item in Mosaics(resp, self._session.request): + async for item in _MosaicsPage(resp, self._session.request): yield item async def list_series_mosaics( self, - name_or_id: str, + /, + series: Union[Series, str], + *, acquired_gt: Optional[str] = None, acquired_lt: Optional[str] = None, latest: bool = False, @@ -206,11 +218,13 @@ async def list_series_mosaics( print(m) ``` """ - if not _is_uuid(name_or_id): - series = await self._get_by_name("series", Series, name_or_id) - if series is None: - return - name_or_id = series["id"] + if isinstance(series, Series): + series_id = series["id"] + elif not _is_uuid(series): + series = Series(await self._get_by_name("series", + _SeriesPage, + series)) + series_id = series["id"] params = {} if acquired_gt: params["acquired__gt"] = acquired_gt @@ -220,18 +234,20 @@ async def list_series_mosaics( params["latest"] = "yes" resp = await self._session.request( method="GET", - url=self._url(f"series/{name_or_id}/mosaics"), + url=self._url(f"series/{series_id}/mosaics"), params=params, ) - async for item in Mosaics(resp, self._session.request): + async for item in _MosaicsPage(resp, self._session.request): yield item async def list_quads(self, - mosaic: dict, + /, + mosaic: Union[Mosaic, str], + *, minimal: bool = False, bbox: Optional[BBox] = None, geometry: Optional[dict] = None, - summary: bool = False) -> AsyncIterator[dict]: + summary: bool = False) -> AsyncIterator[Quad]: """ List the a mosaic's quads. @@ -244,6 +260,7 @@ async def list_quads(self, print(q) ``` """ + mosaic = await self._resolve_mosaic(mosaic) if geometry: resp = await self._quads_geometry(mosaic, geometry, @@ -263,11 +280,11 @@ async def list_quads(self, if summary: yield resp.json()["summary"] return - async for item in MosaicQuads(resp, self._session.request): - yield item + async for item in _QuadsPage(resp, self._session.request): + yield Quad(item) async def _quads_geometry(self, - mosaic: dict, + mosaic: Mosaic, geometry: dict, minimal: bool, summary: bool) -> Response: @@ -285,7 +302,7 @@ async def _quads_geometry(self, ) async def _quads_bbox(self, - mosaic: dict, + mosaic: Mosaic, bbox: BBox, minimal: bool, summary: bool) -> Response: @@ -303,7 +320,7 @@ async def _quads_bbox(self, url=url, ) - async def get_quad(self, name_or_id: str, quad_id: str) -> dict: + async def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad: """ Get a mosaic's quad information. @@ -314,18 +331,15 @@ async def get_quad(self, name_or_id: str, quad_id: str) -> dict: print(quad) ``` """ - if not _is_uuid(name_or_id): - mosaic = await self.get_mosaic(name_or_id) - if mosaic is None: - return {} - name_or_id = cast(str, mosaic["id"]) + mosaic = await self._resolve_mosaic(mosaic) + mosaic_id = mosaic["id"] resp = await self._session.request( method="GET", - url=self._url(f"mosaics/{name_or_id}/quads/{quad_id}"), + url=self._url(f"mosaics/{mosaic_id}/quads/{quad_id}"), ) - return resp.json() + return Quad(resp.json()) - async def get_quad_contributions(self, quad: dict) -> list[dict]: + async def get_quad_contributions(self, quad: Quad) -> list[dict]: """ Get a mosaic's quad information. @@ -344,10 +358,12 @@ async def get_quad_contributions(self, quad: dict) -> list[dict]: return resp.json()["items"] async def download_quad(self, - quad: dict, - directory, + /, + quad: Quad, + *, + directory: str = ".", overwrite: bool = False, - progress_bar=False): + progress_bar: bool = False): url = quad["_links"]["download"] Path(directory).mkdir(exist_ok=True, parents=True) async with self._session.stream(method='GET', url=url) as resp: @@ -363,13 +379,16 @@ async def download_quad(self, ``` quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") - await client.download_quad(quad, ".") + await client.download_quad(quad) ``` """ async def download_quads(self, - mosaic: dict, - directory: str, + /, + mosaic: Union[Mosaic, str], + *, + directory: Optional[str] = None, + overwrite: bool = False, bbox: Optional[BBox] = None, geometry: Optional[dict] = None, progress_bar: bool = False, @@ -381,16 +400,21 @@ async def download_quads(self, ``` mosaic = await cl.get_mosaic(name) - client.download_quads(mosaic, '.', bbox=(-100, 40, -100, 41)) + client.download_quads(mosaic, bbox=(-100, 40, -100, 41)) ``` """ jobs = [] + mosaic = await self._resolve_mosaic(mosaic) + directory = directory or mosaic["name"] async for q in self.list_quads(mosaic, minimal=True, bbox=bbox, geometry=geometry): jobs.append( - self.download_quad(q, directory, progress_bar=progress_bar)) + self.download_quad(q, + directory=directory, + overwrite=overwrite, + progress_bar=progress_bar)) if len(jobs) == concurrency: await asyncio.gather(*jobs) jobs = [] diff --git a/planet/models.py b/planet/models.py index e7321e24c..7e15cb7b8 100644 --- a/planet/models.py +++ b/planet/models.py @@ -314,3 +314,15 @@ def ref(self): * an instance of a Planet Feature (e.g. the return value from `pl.features.get_items(collection_id)`) * an instance of a class that implements __geo_interface__ (Shapely, GeoPandas geometries) """ + + +class Mosaic(dict): + """The API representation of a Planet mosaic""" + + +class Series(dict): + """The API representation of a Planet mosaic series""" + + +class Quad(dict): + """The API representation of a mosaic quad""" From 2530afb5875384e8c4a3bfc5fe4e2f9ce6dc21b0 Mon Sep 17 00:00:00 2001 From: Ian Schneider Date: Wed, 21 May 2025 15:25:13 -0600 Subject: [PATCH 14/24] add sync client and tests, minor fixups --- planet/cli/mosaics.py | 12 +- planet/clients/mosaics.py | 81 ++--- planet/sync/mosaics.py | 241 +++++++++++++++ tests/integration/test_mosaics_api.py | 41 +++ tests/integration/test_mosaics_cli.py | 410 ++++++++++++++++++++++++++ 5 files changed, 741 insertions(+), 44 deletions(-) create mode 100644 planet/sync/mosaics.py create mode 100644 tests/integration/test_mosaics_api.py create mode 100644 tests/integration/test_mosaics_cli.py diff --git a/planet/cli/mosaics.py b/planet/cli/mosaics.py index c78e9d440..f8bafd0ed 100644 --- a/planet/cli/mosaics.py +++ b/planet/cli/mosaics.py @@ -60,8 +60,6 @@ def _strip_links(resource): async def _output(result, pretty, include_links=False): if asyncio.iscoroutine(result): result = await result - if result is None: - raise click.ClickException("not found") if not include_links: _strip_links(result) echo_json(result, pretty) @@ -236,11 +234,8 @@ async def list_quads(ctx, name_or_id, bbox, geometry, summary, pretty, links): planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41 """ async with client(ctx) as cl: - mosaic = await cl.get_mosaic(name_or_id) - if mosaic is None: - raise click.ClickException("No mosaic named " + name_or_id) await _output( - cl.list_quads(mosaic, + cl.list_quads(name_or_id, minimal=False, bbox=bbox, geometry=geometry, @@ -268,10 +263,7 @@ async def download(ctx, name_or_id, output_dir, bbox, geometry, **kwargs): """ quiet = ctx.obj['QUIET'] async with client(ctx) as cl: - mosaic = await cl.get_mosaic(name_or_id) - if mosaic is None: - raise click.ClickException("No mosaic named " + name_or_id) - await cl.download_quads(mosaic, + await cl.download_quads(name_or_id, bbox=bbox, geometry=geometry, directory=output_dir, diff --git a/planet/clients/mosaics.py b/planet/clients/mosaics.py index 48d08edf7..9f2bd5e32 100644 --- a/planet/clients/mosaics.py +++ b/planet/clients/mosaics.py @@ -1,10 +1,25 @@ +# Copyright 2025 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. + import asyncio from pathlib import Path -from typing import AsyncIterator, Awaitable, Optional, Tuple, Type, TypeVar, Union, cast +from typing import AsyncIterator, Optional, Tuple, Type, TypeVar, Union, cast +from planet.clients.base import _BaseClient from planet.constants import PLANET_BASE_URL from planet.exceptions import MissingResource from planet.http import Session -from planet.models import Mosaic, Paged, Quad, Response, Series, StreamingBody +from planet.models import GeoInterface, Mosaic, Paged, Quad, Response, Series, StreamingBody from uuid import UUID BASE_URL = f'{PLANET_BASE_URL}/basemaps/v1' @@ -39,7 +54,7 @@ def _is_uuid(val: str) -> bool: return False -class MosaicsClient: +class MosaicsClient(_BaseClient): """High-level asynchronous access to Planet's Mosaics API. Example: @@ -49,7 +64,7 @@ class MosaicsClient: >>> >>> async def main(): ... async with Session() as sess: - ... cl = sess.client('data') + ... cl = sess.client('mosaics') ... # use client here ... >>> asyncio.run(main()) @@ -63,18 +78,10 @@ def __init__(self, session: Session, base_url: Optional[str] = None): base_url: The base URL to use. Defaults to production Mosaics 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] - - def _call_sync(self, f: Awaitable[T]) -> T: - """block on an async function call, using the call_sync method of the session""" - return self._session._call_sync(f) + super().__init__(session, base_url or BASE_URL) def _url(self, path: str) -> str: - return f"{BASE_URL}/{path}" + return f"{self._base_url}/{path}" async def _get_by_name(self, path: str, pager: Type[Paged], name: str) -> dict: @@ -88,7 +95,12 @@ async def _get_by_name(self, path: str, pager: Type[Paged], listing = response.json()[pager.ITEMS_KEY] if len(listing): return listing[0] - raise MissingResource(f"{name} not found") + # mimic the response for 404 when search is empty + resource = "Mosaic" + if path == "series": + resource = "Series" + raise MissingResource('{"message":"%s Not Found: %s"}' % + (resource, name)) async def _get_by_id(self, path: str, id: str) -> dict: response = await self._session.request(method="GET", @@ -130,7 +142,7 @@ async def list_series( name_contains: Optional[str] = None, interval: Optional[str] = None, acquired_gt: Optional[str] = None, - acquired_lt: Optional[str] = None) -> AsyncIterator[dict]: + acquired_lt: Optional[str] = None) -> AsyncIterator[Series]: """ List the series you have access to. @@ -157,7 +169,7 @@ async def list_series( params=params, ) async for item in _SeriesPage(resp, self._session.request): - yield item + yield Series(item) async def list_mosaics( self, @@ -166,8 +178,7 @@ async def list_mosaics( interval: Optional[str] = None, acquired_gt: Optional[str] = None, acquired_lt: Optional[str] = None, - latest: bool = False, - ) -> AsyncIterator[dict]: + ) -> AsyncIterator[Mosaic]: """ List the mosaics you have access to. @@ -188,15 +199,13 @@ async def list_mosaics( params["acquired__gt"] = acquired_gt if acquired_lt: params["acquired__lt"] = acquired_lt - if latest: - params["latest"] = "yes" resp = await self._session.request( method='GET', url=self._url("mosaics"), params=params, ) async for item in _MosaicsPage(resp, self._session.request): - yield item + yield Mosaic(item) async def list_series_mosaics( self, @@ -206,7 +215,7 @@ async def list_series_mosaics( acquired_gt: Optional[str] = None, acquired_lt: Optional[str] = None, latest: bool = False, - ) -> AsyncIterator[dict]: + ) -> AsyncIterator[Mosaic]: """ List the mosaics in a series. @@ -218,6 +227,7 @@ async def list_series_mosaics( print(m) ``` """ + series_id = series if isinstance(series, Series): series_id = series["id"] elif not _is_uuid(series): @@ -238,7 +248,7 @@ async def list_series_mosaics( params=params, ) async for item in _MosaicsPage(resp, self._session.request): - yield item + yield Mosaic(item) async def list_quads(self, /, @@ -246,7 +256,7 @@ async def list_quads(self, *, minimal: bool = False, bbox: Optional[BBox] = None, - geometry: Optional[dict] = None, + geometry: Optional[Union[dict, GeoInterface]] = None, summary: bool = False) -> AsyncIterator[Quad]: """ List the a mosaic's quads. @@ -262,6 +272,8 @@ async def list_quads(self, """ mosaic = await self._resolve_mosaic(mosaic) if geometry: + if isinstance(geometry, GeoInterface): + geometry = geometry.__geo_interface__ resp = await self._quads_geometry(mosaic, geometry, minimal, @@ -364,14 +376,6 @@ async def download_quad(self, directory: str = ".", overwrite: bool = False, progress_bar: bool = False): - url = quad["_links"]["download"] - Path(directory).mkdir(exist_ok=True, parents=True) - async with self._session.stream(method='GET', url=url) as resp: - body = StreamingBody(resp) - dest = Path(directory, body.name) - await body.write(dest, - overwrite=overwrite, - progress_bar=progress_bar) """ Download a quad to a directory. @@ -382,6 +386,14 @@ async def download_quad(self, await client.download_quad(quad) ``` """ + url = quad["_links"]["download"] + Path(directory).mkdir(exist_ok=True, parents=True) + async with self._session.stream(method='GET', url=url) as resp: + body = StreamingBody(resp) + dest = Path(directory, body.name) + await body.write(dest, + overwrite=overwrite, + progress_bar=progress_bar) async def download_quads(self, /, @@ -390,7 +402,8 @@ async def download_quads(self, directory: Optional[str] = None, overwrite: bool = False, bbox: Optional[BBox] = None, - geometry: Optional[dict] = None, + geometry: Optional[Union[dict, + GeoInterface]] = None, progress_bar: bool = False, concurrency: int = 4): """ diff --git a/planet/sync/mosaics.py b/planet/sync/mosaics.py new file mode 100644 index 000000000..12b300eed --- /dev/null +++ b/planet/sync/mosaics.py @@ -0,0 +1,241 @@ +# Copyright 2025 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 typing import Iterator, Optional, TypeVar, Union +from planet.clients.mosaics import BBox, MosaicsClient +from planet.http import Session +from planet.models import GeoInterface, Mosaic, Quad, Series + +T = TypeVar("T") + + +class MosaicsAPI: + + _client: MosaicsClient + + def __init__(self, session: Session, base_url: Optional[str] = None): + """ + Parameters: + session: Open session connected to server. + base_url: The base URL to use. Defaults to production Mosaics API + base url. + """ + self._client = MosaicsClient(session, base_url) + + def get_mosaic(self, name_or_id: str) -> Mosaic: + """Get the API representation of a mosaic by name or id. + + :param name str: The name or id of the mosaic + :returns: dict or None (if searching by name) + :raises planet.api.exceptions.APIException: On API error. + """ + return self._client._call_sync(self._client.get_mosaic(name_or_id)) + + def get_series(self, name_or_id: str) -> Series: + """Get the API representation of a series by name or id. + + :param name str: The name or id of the series + :returns: dict or None (if searching by name) + :raises planet.api.exceptions.APIException: On API error. + """ + return self._client._call_sync(self._client.get_series(name_or_id)) + + def list_series(self, + *, + name_contains: Optional[str] = None, + interval: Optional[str] = None, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None) -> Iterator[Series]: + """ + List the series you have access to. + + Example: + + ``` + series = client.list_series() + for s in series: + print(s) + ``` + """ + return self._client._aiter_to_iter( + self._client.list_series(name_contains=name_contains, + interval=interval, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt)) + + def list_mosaics( + self, + *, + name_contains: Optional[str] = None, + interval: Optional[str] = None, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None, + ) -> Iterator[Mosaic]: + """ + List the mosaics you have access to. + + Example: + + ``` + mosaics = client.list_mosaics() + for m in mosaics: + print(m) + ``` + """ + return self._client._aiter_to_iter( + self._client.list_mosaics( + name_contains=name_contains, + interval=interval, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt, + )) + + def list_series_mosaics( + self, + /, + series: Union[Series, str], + *, + acquired_gt: Optional[str] = None, + acquired_lt: Optional[str] = None, + latest: bool = False, + ) -> Iterator[Mosaic]: + """ + List the mosaics in a series. + + Example: + + ``` + mosaics = client.list_series_mosaics("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + for m in mosaics: + print(m) + ``` + """ + return self._client._aiter_to_iter( + self._client.list_series_mosaics( + series, + acquired_gt=acquired_gt, + acquired_lt=acquired_lt, + latest=latest, + )) + + def list_quads(self, + /, + mosaic: Union[Mosaic, str], + *, + minimal: bool = False, + bbox: Optional[BBox] = None, + geometry: Optional[Union[dict, GeoInterface]] = None, + summary: bool = False) -> Iterator[Quad]: + """ + List the a mosaic's quads. + + Example: + + ``` + mosaic = await client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + quads = await client.list_quads(mosaic) + for q in quads: + print(q) + ``` + """ + return self._client._aiter_to_iter( + self._client.list_quads( + mosaic, + minimal=minimal, + bbox=bbox, + geometry=geometry, + summary=summary, + )) + + def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad: + """ + Get a mosaic's quad information. + + Example: + + ``` + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + print(quad) + ``` + """ + return self._client._call_sync(self._client.get_quad(mosaic, quad_id)) + + def get_quad_contributions(self, quad: Quad) -> list[dict]: + """ + Get a mosaic's quad information. + + Example: + + ``` + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + contributions = await client.get_quad_contributions(quad) + print(contributions) + ``` + """ + return self._client._call_sync( + self._client.get_quad_contributions(quad)) + + def download_quad(self, + /, + quad: Quad, + *, + directory: str = ".", + overwrite: bool = False, + progress_bar: bool = False): + """ + Download a quad to a directory. + + Example: + + ``` + quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + await client.download_quad(quad) + ``` + """ + self._client._call_sync( + self.download_quad(quad, + directory=directory, + overwrite=overwrite, + progress_bar=progress_bar)) + + def download_quads(self, + /, + mosaic: Union[Mosaic, str], + *, + directory: Optional[str] = None, + overwrite: bool = False, + bbox: Optional[BBox] = None, + geometry: Optional[Union[dict, GeoInterface]] = None, + progress_bar: bool = False, + concurrency: int = 4): + """ + Download a mosaics' quads to a directory. + + Example: + + ``` + mosaic = await cl.get_mosaic(name) + client.download_quads(mosaic, bbox=(-100, 40, -100, 41)) + ``` + """ + return self._client._call_sync( + self._client.download_quads( + mosaic, + directory=directory, + overwrite=overwrite, + bbox=bbox, + geometry=geometry, + progress_bar=progress_bar, + concurrency=concurrency, + )) diff --git a/tests/integration/test_mosaics_api.py b/tests/integration/test_mosaics_api.py new file mode 100644 index 000000000..a6a9a05aa --- /dev/null +++ b/tests/integration/test_mosaics_api.py @@ -0,0 +1,41 @@ +import asyncio +import functools +import inspect +from unittest.mock import patch +from planet.sync.mosaics import MosaicsAPI +from tests.integration import test_mosaics_cli +import pytest + +from concurrent.futures import ThreadPoolExecutor + + +def async_wrap(api): + pool = ThreadPoolExecutor() + + def make_async(fn): + + @functools.wraps(fn) + async def wrapper(*args, **kwargs): + future = pool.submit(fn, *args, **kwargs) + res = await asyncio.wrap_future(future) + if inspect.isgenerator(res): + return list(res) + return res + + return wrapper + + members = inspect.getmembers(api, inspect.isfunction) + funcs = {m[0]: make_async(m[1]) for m in members if m[0][0] != "_"} + funcs["__init__"] = getattr(api, "__init__") + funcs["_pool"] = pool + return type("AsyncAPI", (object, ), funcs) + + +# @pytest.mark.skip +@pytest.mark.parametrize( + "tc", [pytest.param(tc, id=tc.id) for tc in test_mosaics_cli.test_cases]) +def test_api(tc): + api = async_wrap(MosaicsAPI) + with patch('planet.cli.mosaics.MosaicsClient', api): + test_mosaics_cli.run_test(tc) + api._pool.shutdown() diff --git a/tests/integration/test_mosaics_cli.py b/tests/integration/test_mosaics_cli.py new file mode 100644 index 000000000..839f1514e --- /dev/null +++ b/tests/integration/test_mosaics_cli.py @@ -0,0 +1,410 @@ +from dataclasses import dataclass +from pathlib import Path +import json +from typing import Optional +import httpx +import pytest + +import respx +from click.testing import CliRunner + +from planet.cli import cli + +baseurl = "http://basemaps.com/v1/" + +uuid = "09462e5a-2af0-4de3-a710-e9010d8d4e58" + + +def url(path: str) -> str: + return baseurl + path + + +def request(path: str, + json, + method="GET", + status=200, + headers=None, + stream=None): + + def go(): + respx.request(method, + url(path)).return_value = httpx.Response(status, + json=json, + headers=headers, + stream=stream) + + return go + + +def quad_item_downloads(cnt): + return [{ + "_links": { + "download": url(f"mosaics/download-a-quad/{i}") + } + } for i in range(cnt)] + + +def quad_item_download_requests(cnt): + return [ + request( + f"mosaics/download-a-quad/{i}", + None, + stream=stream(), + headers={ + "Content-Length": "100", + "Content-Disposition": f'attachment: filename="quad-{i}.tif"' + }) for i in range(cnt) + ] + + +async def stream(): + yield bytes("data" * 25, encoding="ascii") + + +@dataclass +class CLITestCase: + id: str + command: list[str] + args: list[str] + requests: list + exit_code: int = 0 + output: Optional[dict] = None + expect_files: Optional[list[str]] = None + exception: Optional[str] = None + + +info_cases = [ + CLITestCase(id="info", + command=["info"], + args=[uuid], + output={"name": "a mosaic"}, + requests=[ + request(f"mosaics/{uuid}", {"name": "a mosaic"}), + ]), + CLITestCase(id="info not exist by uuid", + command=["info"], + args=[uuid], + output='Error: {"message":"Mosaic Not Found: fff"}\n', + exit_code=1, + requests=[ + request(f"mosaics/{uuid}", + {"message": "Mosaic Not Found: fff"}, + status=404), + ]), + CLITestCase(id="info not exist by name", + command=["info"], + args=["fff"], + output='Error: {"message":"Mosaic Not Found: fff"}\n', + exit_code=1, + requests=[request("mosaics?name__is=fff", {"mosaics": []})]), +] + +list_mosaic_cases = [ + CLITestCase(id="list", + command=["list"], + args=[], + output=[{ + "name": "a mosaic" + }], + requests=[ + request("mosaics", {"mosaics": [{ + "name": "a mosaic" + }]}), + ]), + CLITestCase( + id="list with filters", + command=["list"], + args=[ + "--name-contains", + "name", + "--interval", + "1 day", + "--acquired_lt", + "2025-05-19", + "--acquired_gt", + "2024-05-19" + ], + output=[{ + "name": "a mosaic" + }], + requests=[ + request( + "mosaics?name__contains=name&interval=1+day&acquired__gt=2024-05-19+00%3A00%3A00&acquired__lt=2025-05-19+00%3A00%3A00", + {"mosaics": [{ + "name": "a mosaic" + }]}), + ]), +] + +series_info_cases = [ + CLITestCase( + id="series info", + command=["series", "info"], + args=["Global Monthly"], + output={"id": "123"}, + requests=[ + request("series?name__is=Global+Monthly", + {"series": [{ + "id": "123" + }]}) + ], + ), + CLITestCase( + id="series info by name does not exist", + command=["series", "info"], + args=["non-existing-series"], + output='Error: {"message":"Series Not Found: non-existing-series"}\n', + exit_code=1, + requests=[ + request("series?name__is=non-existing-series", {"series": []}) + ], + ), + CLITestCase( + id="series info by uuid does not exist", + command=["series", "info"], + args=[uuid], + output='Error: {"message":"Series Not Found: fff"}\n', + exit_code=1, + requests=[ + request(f"series/{uuid}", {"message": "Series Not Found: fff"}, + status=404), + ], + ), +] + +list_series_cases = [ + CLITestCase(id="series list", + command=["series", "list"], + args=[], + output=[{ + "name": "a series" + }], + requests=[ + request("series", {"series": [{ + "name": "a series" + }]}), + ]), + CLITestCase( + id="series list filters", + command=["series", "list"], + args=[ + "--name-contains", + "name", + "--interval", + "1 day", + "--acquired_lt", + "2025-05-19", + "--acquired_gt", + "2024-05-19" + ], + output=[{ + "name": "a series" + }], + requests=[ + request( + "series?name__contains=name&interval=1+day&acquired__gt=2024-05-19+00%3A00%3A00&acquired__lt=2025-05-19+00%3A00%3A00", + {"series": [{ + "name": "a series" + }]}), + ]), + CLITestCase(id="series list-mosaics", + command=["series", "list-mosaics"], + args=[uuid], + output=[{ + "name": "a mosaic" + }], + requests=[ + request( + "series/09462e5a-2af0-4de3-a710-e9010d8d4e58/mosaics", + {"mosaics": [{ + "name": "a mosaic" + }]}), + ]), + CLITestCase( + id="series list-mosaics filters", + command=["series", "list-mosaics"], + args=[ + "Some Series", + "--acquired_lt", + "2025-05-19", + "--acquired_gt", + "2024-05-19", + "--latest" + ], + output=[{ + "name": "a mosaic" + }], + requests=[ + request("series?name__is=Some+Series", {"series": [{ + "id": "123" + }]}), + request( + "series/123/mosaics?acquired__gt=2024-05-19+00%3A00%3A00&acquired__lt=2025-05-19+00%3A00%3A00&latest=yes", + {"mosaics": [{ + "name": "a mosaic" + }]}), + ]), +] + +search_cases = [ + CLITestCase( + id="mosaics search bbox", + command=["search"], + args=[uuid, "--bbox", "-100,40,-100,40"], + output=[{ + "id": "455-1272" + }], + requests=[ + request( + f"mosaics/{uuid}", + { + "_links": { + "quads": url( + "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox={lx},{ly},{ux},{uy}" + ) + } + }), + request( + "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox=-100.0,40.0,-100.0,40.0", + {"items": [{ + "id": "455-1272" + }]}), + ]), + CLITestCase( + id="mosaics search bbox summary", + command=["search"], + args=[uuid, "--bbox", "-100,40,-100,40", "--summary"], + output=[{ + "total_quads": 1234 + }], + requests=[ + request( + f"mosaics/{uuid}", + { + "_links": { + "quads": url( + "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox={lx},{ly},{ux},{uy}" + ) + } + }), + request( + "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox=-100.0,40.0,-100.0,40.0&summary=true", + { + # note this gets stripped from expected output + "items": [{ + "id": "455-1272" + }], + "summary": { + "total_quads": 1234 + } + }), + ]), +] + +download_cases = [ + CLITestCase( + id="mosaics download bbox", + command=["download"], + args=[uuid, "--bbox", '-100,40,-100,40'], + requests=[ + request( + f"mosaics/{uuid}", + { + "id": "123", + "name": "a mosaic", + "_links": { + "quads": url( + "mosaics/123/quads?bbox={lx},{ly},{ux},{uy}") + } + }), + request( + "mosaics/123/quads?bbox=-100.0,40.0,-100.0,40.0&minimal=true", + {"items": quad_item_downloads(1)}), + *quad_item_download_requests(1), + ], + expect_files=[ + "a mosaic/quad-0.tif", + ]), + CLITestCase( + id="mosaics download geometry", + command=["download"], + args=[uuid, "--geometry", '{"type": "Point", "coordinates": [0,0]}'], + requests=[ + request(f"mosaics/{uuid}", { + "id": "123", "name": "a mosaic" + }), + request("mosaics/123/quads/search?minimal=true", {}, + status=302, + method="POST", + headers={"Location": url("mosaics/search-link")}), + request("mosaics/search-link", {"items": quad_item_downloads(5)}), + *quad_item_download_requests(5), + ], + expect_files=[ + "a mosaic/quad-0.tif", + "a mosaic/quad-1.tif", + "a mosaic/quad-2.tif", + "a mosaic/quad-3.tif", + "a mosaic/quad-4.tif", + ]) +] + +other_cases = [ + CLITestCase( + id="quad contributions", + command=["contributions"], + args=["mosaic-name", "quad-id"], + output=[{ + "link": "https://api.planet.com/some/item" + }], + requests=[ + request("mosaics?name__is=mosaic-name", + {"mosaics": [{ + "id": "123" + }]}), + request( + "mosaics/123/quads/quad-id", + {"_links": { + "items": url("mosaics/123/quads/quad-id/items") + }}), + request("mosaics/123/quads/quad-id/items", + {"items": [{ + "link": "https://api.planet.com/some/item" + }]}) + ]), +] + +test_cases = info_cases + series_info_cases + list_mosaic_cases + list_series_cases + search_cases + download_cases + other_cases + + +@pytest.mark.parametrize("tc", + [pytest.param(tc, id=tc.id) for tc in test_cases]) +def test_cli(tc: CLITestCase): + run_test(tc) + + +@respx.mock +def run_test(tc: CLITestCase): + runner = CliRunner() + with runner.isolated_filesystem() as folder: + for r in tc.requests: + r() + + args = ["mosaics", "-u", baseurl] + tc.command + tc.args + result = runner.invoke(cli.main, args=args) + # result.exception may be SystemExit which we want to ignore + # but if we don't raise a "true error" exception, there's no + # stack trace, making it difficult to diagnose + if result.exception and tc.exit_code == 0: + raise result.exception + assert result.exit_code == tc.exit_code, result.output + if tc.output: + try: + # error output (always?) not JSON + output = json.loads(result.output) + except json.JSONDecodeError: + output = result.output + assert output == tc.output + if tc.expect_files: + for f in tc.expect_files: + assert Path(folder, f).exists(), f From e22b178c4b46b5bda4cb2182ccc3641258f4ab99 Mon Sep 17 00:00:00 2001 From: Ian Schneider Date: Tue, 17 Jun 2025 11:09:32 -0600 Subject: [PATCH 15/24] some tweaks - make separate summarize_quads function - use quad ID for download file name to avoid hitting download endpoint to determine name from content-disposition headers - add full_extent option for explicitly using the mosaic bbox for listing (rather than defaulting when bbox/geometry not provided) - required bbox or geometry for downloading - minor doc fixes add language for styling code blocks --- planet/cli/mosaics.py | 18 +-- planet/clients/mosaics.py | 165 +++++++++++++++++++------- planet/sync/mosaics.py | 98 ++++++++++----- tests/integration/test_mosaics_cli.py | 35 +++--- 4 files changed, 212 insertions(+), 104 deletions(-) diff --git a/planet/cli/mosaics.py b/planet/cli/mosaics.py index f8bafd0ed..8254da449 100644 --- a/planet/cli/mosaics.py +++ b/planet/cli/mosaics.py @@ -234,14 +234,16 @@ async def list_quads(ctx, name_or_id, bbox, geometry, summary, pretty, links): planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41 """ async with client(ctx) as cl: - await _output( - cl.list_quads(name_or_id, - minimal=False, - bbox=bbox, - geometry=geometry, - summary=summary), - pretty, - links) + if summary: + result = cl.summarize_quads(name_or_id, + bbox=bbox, + geometry=geometry) + else: + result = cl.list_quads(name_or_id, + minimal=False, + bbox=bbox, + geometry=geometry) + await _output(result, pretty, links) @command(mosaics, name="download") diff --git a/planet/clients/mosaics.py b/planet/clients/mosaics.py index 9f2bd5e32..98a8f48ee 100644 --- a/planet/clients/mosaics.py +++ b/planet/clients/mosaics.py @@ -14,10 +14,10 @@ import asyncio from pathlib import Path -from typing import AsyncIterator, Optional, Tuple, Type, TypeVar, Union, cast +from typing import AsyncIterator, Optional, Sequence, Type, TypeVar, Union, cast from planet.clients.base import _BaseClient from planet.constants import PLANET_BASE_URL -from planet.exceptions import MissingResource +from planet.exceptions import ClientError, MissingResource from planet.http import Session from planet.models import GeoInterface, Mosaic, Paged, Quad, Response, Series, StreamingBody from uuid import UUID @@ -28,7 +28,11 @@ Number = Union[int, float] -BBox = Tuple[Number, Number, Number, Number] +BBox = Sequence[Number] +"""BBox is a rectangular area described by 2 corners +where the positional meaning in the sequence is +left, bottom, right, and top, respectively +""" class _SeriesPage(Paged): @@ -121,18 +125,16 @@ async def _resolve_mosaic(self, mosaic: Union[Mosaic, str]) -> Mosaic: async def get_mosaic(self, name_or_id: str) -> Mosaic: """Get the API representation of a mosaic by name or id. - :param name str: The name or id of the mosaic - :returns: dict or None (if searching by name) - :raises planet.api.exceptions.APIException: On API error. + Parameters: + name_or_id: The name or id of the mosaic """ return Mosaic(await self._get(name_or_id, "mosaics", _MosaicsPage)) async def get_series(self, name_or_id: str) -> Series: """Get the API representation of a series by name or id. - :param name str: The name or id of the series - :returns: dict or None (if searching by name) - :raises planet.api.exceptions.APIException: On API error. + Parameters: + name_or_id: The name or id of the mosaic """ return Series(await self._get(name_or_id, "series", _SeriesPage)) @@ -148,7 +150,7 @@ async def list_series( Example: - ``` + ```python series = await client.list_series() async for s in series: print(s) @@ -184,7 +186,7 @@ async def list_mosaics( Example: - ``` + ```python mosaics = await client.list_mosaics() async for m in mosaics: print(m) @@ -221,7 +223,7 @@ async def list_series_mosaics( Example: - ``` + ```python mosaics = await client.list_series_mosaics("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") async for m in mosaics: print(m) @@ -250,26 +252,86 @@ async def list_series_mosaics( async for item in _MosaicsPage(resp, self._session.request): yield Mosaic(item) - async def list_quads(self, - /, - mosaic: Union[Mosaic, str], - *, - minimal: bool = False, - bbox: Optional[BBox] = None, - geometry: Optional[Union[dict, GeoInterface]] = None, - summary: bool = False) -> AsyncIterator[Quad]: + async def summarize_quads( + self, + /, + mosaic: Union[Mosaic, str], + *, + bbox: Optional[BBox] = None, + geometry: Optional[Union[dict, GeoInterface]] = None) -> dict: + """ + Get a summary of a quad list for a mosaic. + + If the bbox or geometry is not provided, the entire list is considered. + + Examples: + + Get the total number of quads in the mosaic. + + ```python + mosaic = await client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + summary = await client.summarize_quads(mosaic) + print(summary["total_quads"]) + ``` + """ + resp = await self._list_quads(mosaic, + minimal=True, + bbox=bbox, + geometry=geometry, + summary=True) + return resp.json()["summary"] + + async def list_quads( + self, + /, + mosaic: Union[Mosaic, str], + *, + minimal: bool = False, + full_extent: bool = False, + bbox: Optional[BBox] = None, + geometry: Optional[Union[dict, GeoInterface]] = None + ) -> AsyncIterator[Quad]: """ List the a mosaic's quads. + Parameters: + mosaic: the mosaic to list + minimal: if False, response includes full metadata + full_extent: if True, the mosaic's extent will be used to list + bbox: only quads intersecting the bbox will be listed + geometry: only quads intersecting the geometry will be listed + + Raises: + ClientError: if `geometry`, `bbox` or `full_extent` is not specified. + Example: - ``` + List the quad at a single point (note the extent has the same corners) + + ```python mosaic = await client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") - quads = await client.list_quads(mosaic) + quads = await client.list_quads(mosaic, bbox=[-100, 40, -100, 40]) async for q in quads: print(q) ``` """ + if not any((geometry, bbox, full_extent)): + raise ClientError("one of: geometry, bbox, full_extent required") + resp = await self._list_quads(mosaic, + minimal=minimal, + bbox=bbox, + geometry=geometry) + async for item in _QuadsPage(resp, self._session.request): + yield Quad(item) + + async def _list_quads(self, + /, + mosaic: Union[Mosaic, str], + *, + minimal: bool = False, + bbox: Optional[BBox] = None, + geometry: Optional[Union[dict, GeoInterface]] = None, + summary: bool = False) -> Response: mosaic = await self._resolve_mosaic(mosaic) if geometry: if isinstance(geometry, GeoInterface): @@ -279,21 +341,16 @@ async def list_quads(self, minimal, summary) else: - if bbox is None: + if not bbox: xmin, ymin, xmax, ymax = cast(BBox, mosaic['bbox']) - search = (max(-180, xmin), - max(-85, ymin), - min(180, xmax), - min(85, ymax)) - else: - search = bbox - resp = await self._quads_bbox(mosaic, search, minimal, summary) - # kinda yucky - yields a different "shaped" dict - if summary: - yield resp.json()["summary"] - return - async for item in _QuadsPage(resp, self._session.request): - yield Quad(item) + bbox = [ + max(-180, xmin), + max(-85, ymin), + min(180, xmax), + min(85, ymax) + ] + resp = await self._quads_bbox(mosaic, bbox, minimal, summary) + return resp async def _quads_geometry(self, mosaic: Mosaic, @@ -305,6 +362,10 @@ async def _quads_geometry(self, params["minimal"] = "true" if summary: params["summary"] = "true" + # this could be fixed in the API ... + # for a summary, we don't need to get any listings + # zero is ignored, but in case that gets rejected, just use 1 + params["_page_size"] = "1" mosaic_id = mosaic["id"] return await self._session.request( method="POST", @@ -338,7 +399,7 @@ async def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad: Example: - ``` + ```python quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") print(quad) ``` @@ -357,7 +418,7 @@ async def get_quad_contributions(self, quad: Quad) -> list[dict]: Example: - ``` + ```python quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") contributions = await client.get_quad_contributions(quad) print(contributions) @@ -381,19 +442,26 @@ async def download_quad(self, Example: - ``` + ```python quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") await client.download_quad(quad) ``` """ url = quad["_links"]["download"] Path(directory).mkdir(exist_ok=True, parents=True) + dest = Path(directory, quad["id"] + ".tif") + # this avoids a request to the download endpoint which would + # get counted as a download even if only the headers were read + # and the response content is ignored (like if when the file + # exists and overwrite is False) + if dest.exists() and not overwrite: + return async with self._session.stream(method='GET', url=url) as resp: - body = StreamingBody(resp) - dest = Path(directory, body.name) - await body.write(dest, - overwrite=overwrite, - progress_bar=progress_bar) + await StreamingBody(resp).write( + dest, + # pass along despite our manual handling + overwrite=overwrite, + progress_bar=progress_bar) async def download_quads(self, /, @@ -409,13 +477,18 @@ async def download_quads(self, """ Download a mosaics' quads to a directory. + Raises: + ClientError: if `geometry` or `bbox` is not specified. + Example: - ``` + ```python mosaic = await cl.get_mosaic(name) - client.download_quads(mosaic, bbox=(-100, 40, -100, 41)) + client.download_quads(mosaic, bbox=(-100, 40, -100, 40)) ``` """ + if not any((bbox, geometry)): + raise ClientError("bbox or geometry is required") jobs = [] mosaic = await self._resolve_mosaic(mosaic) directory = directory or mosaic["name"] diff --git a/planet/sync/mosaics.py b/planet/sync/mosaics.py index 12b300eed..9134db687 100644 --- a/planet/sync/mosaics.py +++ b/planet/sync/mosaics.py @@ -36,18 +36,16 @@ def __init__(self, session: Session, base_url: Optional[str] = None): def get_mosaic(self, name_or_id: str) -> Mosaic: """Get the API representation of a mosaic by name or id. - :param name str: The name or id of the mosaic - :returns: dict or None (if searching by name) - :raises planet.api.exceptions.APIException: On API error. + Parameters: + name_or_id: The name or id of the mosaic """ return self._client._call_sync(self._client.get_mosaic(name_or_id)) def get_series(self, name_or_id: str) -> Series: """Get the API representation of a series by name or id. - :param name str: The name or id of the series - :returns: dict or None (if searching by name) - :raises planet.api.exceptions.APIException: On API error. + Parameters: + name_or_id: The name or id of the mosaic """ return self._client._call_sync(self._client.get_series(name_or_id)) @@ -62,7 +60,7 @@ def list_series(self, Example: - ``` + ```python series = client.list_series() for s in series: print(s) @@ -87,7 +85,7 @@ def list_mosaics( Example: - ``` + ```python mosaics = client.list_mosaics() for m in mosaics: print(m) @@ -115,7 +113,7 @@ def list_series_mosaics( Example: - ``` + ```python mosaics = client.list_series_mosaics("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") for m in mosaics: print(m) @@ -129,22 +127,60 @@ def list_series_mosaics( latest=latest, )) - def list_quads(self, - /, - mosaic: Union[Mosaic, str], - *, - minimal: bool = False, - bbox: Optional[BBox] = None, - geometry: Optional[Union[dict, GeoInterface]] = None, - summary: bool = False) -> Iterator[Quad]: + def summarize_quads( + self, + /, + mosaic: Union[Mosaic, str], + *, + bbox: Optional[BBox] = None, + geometry: Optional[Union[dict, GeoInterface]] = None) -> dict: + """ + Get a summary of a quad list for a mosaic. + + If the bbox or geometry is not provided, the entire list is considered. + + Examples: + + Get the total number of quads in the mosaic. + + ```python + mosaic = client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + summary = client.summarize_quads(mosaic) + print(summary["total_quads"]) + ``` + """ + return self._client._call_sync( + self._client.summarize_quads(mosaic, bbox=bbox, geometry=geometry)) + + def list_quads( + self, + /, + mosaic: Union[Mosaic, str], + *, + minimal: bool = False, + full_extent: bool = False, + bbox: Optional[BBox] = None, + geometry: Optional[Union[dict, + GeoInterface]] = None) -> Iterator[Quad]: """ List the a mosaic's quads. + + Parameters: + mosaic: the mosaic to list + minimal: if False, response includes full metadata + full_extent: if True, the mosaic's extent will be used to list + bbox: only quads intersecting the bbox will be listed + geometry: only quads intersecting the geometry will be listed + + Raises: + ValueError: if `geometry`, `bbox` or `full_extent` is not specified. + Example: - ``` - mosaic = await client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") - quads = await client.list_quads(mosaic) + ```python + mosaic = client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5") + quads = client.list_quads(mosaic) for q in quads: print(q) ``` @@ -153,9 +189,9 @@ def list_quads(self, self._client.list_quads( mosaic, minimal=minimal, + full_extent=full_extent, bbox=bbox, geometry=geometry, - summary=summary, )) def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad: @@ -164,8 +200,8 @@ def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad: Example: - ``` - quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + ```python + quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") print(quad) ``` """ @@ -177,9 +213,9 @@ def get_quad_contributions(self, quad: Quad) -> list[dict]: Example: - ``` - quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") - contributions = await client.get_quad_contributions(quad) + ```python + quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + contributions = client.get_quad_contributions(quad) print(contributions) ``` """ @@ -198,9 +234,9 @@ def download_quad(self, Example: - ``` - quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") - await client.download_quad(quad) + ```python + quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678") + client.download_quad(quad) ``` """ self._client._call_sync( @@ -224,8 +260,8 @@ def download_quads(self, Example: - ``` - mosaic = await cl.get_mosaic(name) + ```python + mosaic = cl.get_mosaic(name) client.download_quads(mosaic, bbox=(-100, 40, -100, 41)) ``` """ diff --git a/tests/integration/test_mosaics_cli.py b/tests/integration/test_mosaics_cli.py index 839f1514e..1b28ca2a8 100644 --- a/tests/integration/test_mosaics_cli.py +++ b/tests/integration/test_mosaics_cli.py @@ -40,20 +40,19 @@ def quad_item_downloads(cnt): return [{ "_links": { "download": url(f"mosaics/download-a-quad/{i}") - } + }, + "id": f"456-789{i}" } for i in range(cnt)] def quad_item_download_requests(cnt): return [ - request( - f"mosaics/download-a-quad/{i}", - None, - stream=stream(), - headers={ - "Content-Length": "100", - "Content-Disposition": f'attachment: filename="quad-{i}.tif"' - }) for i in range(cnt) + request(f"mosaics/download-a-quad/{i}", + None, + stream=stream(), + headers={ + "Content-Length": "100", + }) for i in range(cnt) ] @@ -274,9 +273,7 @@ class CLITestCase: id="mosaics search bbox summary", command=["search"], args=[uuid, "--bbox", "-100,40,-100,40", "--summary"], - output=[{ - "total_quads": 1234 - }], + output={"total_quads": 1234}, requests=[ request( f"mosaics/{uuid}", @@ -288,7 +285,7 @@ class CLITestCase: } }), request( - "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox=-100.0,40.0,-100.0,40.0&summary=true", + "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox=-100.0,40.0,-100.0,40.0&minimal=true&summary=true", { # note this gets stripped from expected output "items": [{ @@ -323,7 +320,7 @@ class CLITestCase: *quad_item_download_requests(1), ], expect_files=[ - "a mosaic/quad-0.tif", + "a mosaic/456-7890.tif", ]), CLITestCase( id="mosaics download geometry", @@ -341,11 +338,11 @@ class CLITestCase: *quad_item_download_requests(5), ], expect_files=[ - "a mosaic/quad-0.tif", - "a mosaic/quad-1.tif", - "a mosaic/quad-2.tif", - "a mosaic/quad-3.tif", - "a mosaic/quad-4.tif", + "a mosaic/456-7890.tif", + "a mosaic/456-7891.tif", + "a mosaic/456-7892.tif", + "a mosaic/456-7893.tif", + "a mosaic/456-7894.tif", ]) ] From 4dbe92b95daad450bd22c63e3a8215fee8e0707c Mon Sep 17 00:00:00 2001 From: Adrian Sonnenschein Date: Fri, 25 Jul 2025 10:42:06 -0400 Subject: [PATCH 16/24] Refactor planetary_variable_source() signature (#1170) * remove source type param from pv source method * docs * update method names and signatrues --- docs/cli/cli-subscriptions.md | 23 +++++++++--------- docs/hooks/mkdocs_hooks.py | 1 + planet/cli/subscriptions.py | 27 +++++++-------------- planet/subscription_request.py | 25 ++++++------------- tests/integration/test_subscriptions_cli.py | 22 ++++++----------- tests/unit/test_subscription_request.py | 20 ++++++--------- 6 files changed, 43 insertions(+), 75 deletions(-) diff --git a/docs/cli/cli-subscriptions.md b/docs/cli/cli-subscriptions.md index f7c551824..508fcb7bb 100644 --- a/docs/cli/cli-subscriptions.md +++ b/docs/cli/cli-subscriptions.md @@ -450,22 +450,21 @@ planet subscriptions request-catalog \ --filter filter.json > request-catalog.json ``` -### Planetary Variable Request - -Subscribing to Planetary Variables is much like subscribing to imagery from -Planet's catalog. The `planet subscriptions request-pv` command can construct the source -part of a Planetary Variable request like `request-catalog` does for cataloged -imagery. Planetary Variable subscriptions come in 4 types and are further -subdivided within these types by an identifier. See [Subscribing to Planetary -Variables](https://docs.planet.com/develop/apis/subscriptions/sources/#planetary-variable-and-analysis-ready-source-types) -for details. To constrain data delivery by space and time, you will use the +### Planetary Variable and Analysis-Ready Source Requests + +Subscribing to Planetary Variables and Analysis-Ready data is much like subscribing to imagery from +Planet's catalog. The `planet subscriptions request-source` command can construct the source +part of a Planetary Variable or Analysis-Ready source request like `request-catalog` does for cataloged +imagery. See [Subscribing to Planetary +Variables and Analysis Ready sources](https://docs.planet.com/develop/apis/subscriptions/sources/#planetary-variable-and-analysis-ready-source-types) +for details about different product options. To constrain data delivery by space and time, you will use the `--geometry`, `start-time`, and `end-time` options described above. ```sh -planet subscriptions request-pv \ - --var-id BIOMASS-PROXY_V3.0_10 \ +planet subscriptions request-source \ + --source-id BIOMASS-PROXY_V3.0_10 \ --geometry geometry.geojson \ - --start-time 2022-08-24T00:00:00-07:00 > request-pv.json + --start-time 2022-08-24T00:00:00-07:00 > request-source.json ``` ### Subscription Tools diff --git a/docs/hooks/mkdocs_hooks.py b/docs/hooks/mkdocs_hooks.py index b12c66a7e..c713b63fd 100644 --- a/docs/hooks/mkdocs_hooks.py +++ b/docs/hooks/mkdocs_hooks.py @@ -1,5 +1,6 @@ from planet import __version__ as _pl_sdk_version + def on_config(config): """ This is for injecting the package version into mkdocs diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py index cad27acd7..b6701bd3c 100644 --- a/planet/cli/subscriptions.py +++ b/planet/cli/subscriptions.py @@ -535,15 +535,9 @@ def request_catalog(item_types, @subscriptions.command() # type: ignore @translate_exceptions -@click.option( - '--var-type', - required=False, - help='A Planetary Variable type. See documentation for all available types.' -) -@click.option( - '--var-id', - required=True, - help='A Planetary Variable ID. See documentation for all available IDs.') +@click.option('--source-id', + required=True, + help='A source ID. See documentation for all available IDs.') @click.option( '--geometry', required=True, @@ -559,17 +553,14 @@ def request_catalog(item_types, type=types.DateTime(), help='Date and time to end subscription.') @pretty -def request_pv(var_type, var_id, geometry, start_time, end_time, pretty): - """Generate a Planetary Variable subscription source. +def request_source(source_id, geometry, start_time, end_time, pretty): + """Generate a subscription source. - Planetary Variables come in 4 types and are further subdivided - within these types. See [Subscribing to Planetary Variables](https://docs.planet.com/develop/apis/subscriptions/sources/#planetary-variable-and-analysis-ready-source-types) - or the [OpenAPI spec](https://api.planet.com/subscriptions/v1/spec) for - more details. + See [Subscribing to Planetary Variables and Analysis Ready sources](https://docs.planet.com/develop/apis/subscriptions/sources/#planetary-variable-and-analysis-ready-source-types) + or the [OpenAPI spec](https://api.planet.com/subscriptions/v1/spec) to learn more about different product options. """ - res = subscription_request.planetary_variable_source( - var_type, - var_id, + res = subscription_request.subscription_source( + source_id, geometry, start_time, end_time=end_time, diff --git a/planet/subscription_request.py b/planet/subscription_request.py index 18e9aad4b..72b36e80e 100644 --- a/planet/subscription_request.py +++ b/planet/subscription_request.py @@ -272,19 +272,16 @@ def catalog_source( return {"parameters": parameters} -def planetary_variable_source( - var_type: Optional[str], - var_id: str, +def subscription_source( + source_id: str, geometry: Union[dict, str], start_time: datetime, end_time: Optional[datetime] = None, ) -> dict: - """Construct a Planetary Variable subscription source. + """Construct a subscription source. - Planetary Variables come in 4 types and are further subdivided - within these types. See [Subscribing to Planetary Variables](https://docs.planet.com/develop/apis/subscriptions/sources/#planetary-variable-and-analysis-ready-source-types) - or the [OpenAPI spec](https://api.planet.com/subscriptions/v1/spec) for - more details. + See [Subscribing to Planetary Variables and Analysis Ready sources](https://docs.planet.com/develop/apis/subscriptions/sources/#planetary-variable-and-analysis-ready-source-types) + or the [OpenAPI spec](https://api.planet.com/subscriptions/v1/spec) to learn more about different product options. The return value can be passed to [planet.subscription_request.build_request][]. @@ -292,10 +289,7 @@ def planetary_variable_source( Note: this function does not validate variable types and ids. Parameters: - var_type: Planetary Variable type. See documentation for all - available types. Used to be a required parameter but - is now optional and can be 'None'. - var_id: A Planetary Variable ID. See documenation for all + source_id: A source ID. See documenation for all available IDs. geometry: The area of interest of the subscription that will be used to determine matches. May be a geojson-like dict or a @@ -315,8 +309,7 @@ def planetary_variable_source( Examples: ```python - pv_source = planetary_variables_source( - "soil_water_content", + pv_source = subscription_source( "SWC-AMSR2-C_V1.0_100", geometry={ "type": "Polygon", @@ -343,7 +336,7 @@ def planetary_variable_source( # TODO: validation of variable types and ids. parameters = { - "id": var_id, + "id": source_id, "geometry": geojson.as_geom_or_ref(geometry), } @@ -359,8 +352,6 @@ def planetary_variable_source( raise ClientError('Could not convert end_time to an iso string') source: dict[str, Any] = {"parameters": parameters} - if var_type: - source["type"] = var_type return source diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py index fda7e26e9..f252e3ce6 100644 --- a/tests/integration/test_subscriptions_cli.py +++ b/tests/integration/test_subscriptions_cli.py @@ -418,33 +418,25 @@ def test_subscriptions_results_csv(invoke): assert result.output.splitlines() == ["id,status", "1234-abcd,SUCCESS"] -@pytest.mark.parametrize("geom, source_type", - [("geom_geojson", "biomass_proxy"), - ("geom_reference", None), - ("str_geom_reference", None)]) -def test_request_pv_success(invoke, geom, source_type, request): - """Request-pv command succeeds""" +@pytest.mark.parametrize("geom", + [("geom_geojson"), ("geom_reference"), + ("str_geom_reference")]) +def test_request_source_success(invoke, geom, request): + """Request-source command succeeds""" geom = request.getfixturevalue(geom) if isinstance(geom, dict): geom = json.dumps(geom) cmd = [ - "request-pv", - "--var-id=BIOMASS-PROXY_V3.0_10", + "request-source", + "--source-id=BIOMASS-PROXY_V3.0_10", f"--geometry={geom}", "--start-time=2021-03-01T00:00:00", ] - if source_type: - cmd.append(f"--var-type={source_type}") - result = invoke(cmd) assert result.exit_code == 0 # success. source = json.loads(result.output) - if source_type: - assert source["type"] == "biomass_proxy" - else: - assert "type" not in source assert source["parameters"]["id"] == "BIOMASS-PROXY_V3.0_10" diff --git a/tests/unit/test_subscription_request.py b/tests/unit/test_subscription_request.py index ec17dc25f..858cc6c26 100644 --- a/tests/unit/test_subscription_request.py +++ b/tests/unit/test_subscription_request.py @@ -556,28 +556,22 @@ def test_toar_tool_success(): @pytest.mark.parametrize( - "var_type, var_id", + "source_id", [ - ("biomass_proxy", "BIOMASS-PROXY_V3.0_10"), # actual real type and id. - ("var1", "VAR1-ABCD"), # nonsense type and id - (None, "BIOMASS-PROXY_V3.0_10"), # None type with valid id + ("BIOMASS-PROXY_V3.0_10"), # actual valid id. + ("VAR1-ABCD"), # nonsense id ]) -def test_pv_source_success(geom_geojson, var_type, var_id): +def test_subscription_source_success(geom_geojson, source_id): """Configure a planetary variable subscription source.""" - source = subscription_request.planetary_variable_source( - var_type, - var_id, + source = subscription_request.subscription_source( + source_id, geometry=geom_geojson, start_time=datetime(2021, 3, 1), end_time=datetime(2021, 3, 2), ) - if var_type: - assert source["type"] == var_type - else: - assert "type" not in source params = source["parameters"] - assert params["id"] == var_id + assert params["id"] == source_id assert params["geometry"] == geom_geojson assert params["start_time"].startswith("2021-03-01") From 1adae09a1d27d46ea059efd3e9c11b206b148d22 Mon Sep 17 00:00:00 2001 From: Adrian Sonnenschein Date: Mon, 28 Jul 2025 12:28:58 -0400 Subject: [PATCH 17/24] Sync client base_url option (#1171) * update base url option * simplify to base_url --- planet/cli/subscriptions.py | 3 ++- planet/sync/client.py | 21 +++++++++++---- tests/unit/test_client.py | 52 +++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_client.py diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py index b6701bd3c..d748289cd 100644 --- a/planet/cli/subscriptions.py +++ b/planet/cli/subscriptions.py @@ -87,7 +87,8 @@ def subscriptions(ctx, base_url): "pending", "completed", "suspended", - "failed" + "failed", + "invalid", ]), multiple=True, help="Select subscriptions in one or more states. Default is all.") diff --git a/planet/sync/client.py b/planet/sync/client.py index 691b67e66..c04890ad4 100644 --- a/planet/sync/client.py +++ b/planet/sync/client.py @@ -6,6 +6,7 @@ from .subscriptions import SubscriptionsAPI from planet.http import Session from planet.__version__ import __version__ +from planet.constants import PLANET_BASE_URL SYNC_CLIENT_X_PLANET_APP = "python-sdk-sync" @@ -37,17 +38,27 @@ class Planet: Parameters: session: Optional Session. The Session can be provided allowing for customization, and will default to standard behavior when not provided. + base_url: Optional base URL for Planet APIs. Defaults to (https://api.planet.com). + Each API will append its specific path suffix (/data/v1, /compute/ops, etc.). """ - def __init__(self, session: Optional[Session] = None) -> None: + def __init__(self, + session: Optional[Session] = None, + base_url: Optional[str] = None) -> None: self._session = session or Session() self._session._client.headers.update({ "X-Planet-App": SYNC_CLIENT_X_PLANET_APP, "User-Agent": f"planet-client-python/{__version__}/sync", }) - self.data = DataAPI(self._session) - self.orders = OrdersAPI(self._session) - self.subscriptions = SubscriptionsAPI(self._session) - self.features = FeaturesAPI(self._session) + # Use provided base URL or default + planet_base = base_url or PLANET_BASE_URL + + # Create API instances with service-specific URL paths + self.data = DataAPI(self._session, f"{planet_base}/data/v1/") + self.orders = OrdersAPI(self._session, f"{planet_base}/compute/ops") + self.subscriptions = SubscriptionsAPI( + self._session, f"{planet_base}/subscriptions/v1/") + self.features = FeaturesAPI(self._session, + f"{planet_base}/features/v1/ogc/my/") diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 000000000..27c4376ae --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,52 @@ +# Copyright 2025 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. +"""Tests for the synchronous Planet client.""" + +from planet.sync import Planet + + +class TestPlanetSyncClient: + """Test cases for the Planet synchronous client.""" + + def test_planet_default_initialization(self): + """Test that Planet client initializes correctly with defaults.""" + pl = Planet() + + assert pl.data is not None + assert pl.data._client._base_url == "https://api.planet.com/data/v1" + + assert pl.orders is not None + assert pl.orders._client._base_url == "https://api.planet.com/compute/ops" + + assert pl.subscriptions is not None + assert pl.subscriptions._client._base_url == "https://api.planet.com/subscriptions/v1" + + assert pl.features is not None + assert pl.features._client._base_url == "https://api.planet.com/features/v1/ogc/my" + + def test_planet_custom_base_url_initialization(self): + """Test that Planet client accepts custom base URL.""" + pl = Planet(base_url="https://custom.planet.com") + + assert pl.data is not None + assert pl.data._client._base_url == "https://custom.planet.com/data/v1" + + assert pl.orders is not None + assert pl.orders._client._base_url == "https://custom.planet.com/compute/ops" + + assert pl.subscriptions is not None + assert pl.subscriptions._client._base_url == "https://custom.planet.com/subscriptions/v1" + + assert pl.features is not None + assert pl.features._client._base_url == "https://custom.planet.com/features/v1/ogc/my" From 4fa00020a3478fa439121ef936832c4127975157 Mon Sep 17 00:00:00 2001 From: Adrian Sonnenschein Date: Mon, 28 Jul 2025 12:29:49 -0400 Subject: [PATCH 18/24] Add 'Additional Breaking Changes' section to v3.0 migration guide (#1173) * add additional breaking changes section to v3 migration guide * add breaking change note for request_source() and subscription_source() methods --- docs/get-started/upgrading-v3.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/get-started/upgrading-v3.md b/docs/get-started/upgrading-v3.md index bc515acfc..a8fc6c760 100644 --- a/docs/get-started/upgrading-v3.md +++ b/docs/get-started/upgrading-v3.md @@ -92,4 +92,9 @@ Users developing new applications should consult the [Client Authentication Guid for a complete discussion of all OAuth2 based mechanisms. OAuth2 mechanisms should be preferred to the use of Planet API keys. +## Additional Breaking Changes + +* Deprecated `planet.subscription_request.clip_tool()` method for defining custom clip AOIs with requests to create subscriptions. Subscriptions API no longer supports custom clip AOIs; instead users can opt-in to clip to their subscription source geometry by including kwarg `clip_to_source=True` when constructing requests via `planet.subscription_request.build_request()`. See [PR #1169](https://github.com/planetlabs/planet-client-python/pull/1169) for implementation details. +* Renamed `planet.cli.subscriptions.request_pv()` to `planet.cli.subscriptions.request_source()`, and removed `var_type` positional argument from the signature. This change, in effect renames the CLI argument `planet subscriptions request-pv` to `planet subscriptions request-source`. Also renamed `planet.subscription_request.planetary_variable_source()` to `planet.subscription_request.subscription_source()`. Source type positional arguments are removed from these methods in favor of `source_id`. See [PR #1170](https://github.com/planetlabs/planet-client-python/pull/1170) for implementation details. + ---- From 3ea85e60894582d6d38ee3ee2a791b517cd23288 Mon Sep 17 00:00:00 2001 From: "Carl A. Adams" Date: Thu, 31 Jul 2025 15:09:03 -0700 Subject: [PATCH 19/24] fixing linting. --- planet/sync/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/planet/sync/client.py b/planet/sync/client.py index 6bc65737b..993b35271 100644 --- a/planet/sync/client.py +++ b/planet/sync/client.py @@ -59,7 +59,8 @@ def __init__(self, # Create API instances with service-specific URL paths self.data = DataAPI(self._session, f"{planet_base}/data/v1/") - self.destinations = DestinationsAPI(self._session, f"{planet_base}/destinations/v1") + self.destinations = DestinationsAPI(self._session, + f"{planet_base}/destinations/v1") self.orders = OrdersAPI(self._session, f"{planet_base}/compute/ops") self.subscriptions = SubscriptionsAPI( self._session, f"{planet_base}/subscriptions/v1/") From 87fa4a11590774fd8912a4df85bf476e7c86741f Mon Sep 17 00:00:00 2001 From: charcey <156946780+charcey@users.noreply.github.com> Date: Fri, 1 Aug 2025 12:30:47 -0500 Subject: [PATCH 20/24] Merging in post v2-to-v3 merge fixes for destinations manager (#1180) * Update docs for Destinations API and add CLI tests (#1176) * make source type truly optional for planetary_variable_source * complete cli docs * Revert "make source type truly optional for planetary_variable_source" This reverts commit aa6dba8fcc8218c93ba8853b4748ff9e85cfb007. * readme update * test fixes * fix test * fix space in readme --- README.md | 1 + design-docs/CLI-Core.md | 1 + design-docs/CLI-Destinations.md | 196 ++++++++++++++++ docs/cli/cli-guide.md | 3 +- docs/python/sdk-reference.md | 8 + mkdocs.yml | 1 + tests/integration/test_destinations_api.py | 4 +- tests/integration/test_destinations_cli.py | 253 +++++++++++++++++++++ 8 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 design-docs/CLI-Destinations.md create mode 100644 tests/integration/test_destinations_cli.py diff --git a/README.md b/README.md index 574dc41a1..7768d287e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Version 2.0 includes support for the core workflows of the following APIs: * [Orders](https://docs.planet.com/develop/apis/orders/) - Process and download or deliver imagery. * [Subscriptions](https://docs.planet.com/develop/apis/subscriptions/) - Set up a search to auto-process and deliver imagery. * [Features](https://docs.planet.com/develop/apis/features/) - Upload areas of interest to the Planet platform. +* [Destinations](https://docs.planet.com/develop/apis/destinations/) - Create destinations to securely store cloud credentials. After the initial 2.0 release there will be additional work to support the remaining Planet APIs: [basemaps](https://docs.planet.com/develop/apis/basemaps/), diff --git a/design-docs/CLI-Core.md b/design-docs/CLI-Core.md index 06c5914c1..914712a2f 100644 --- a/design-docs/CLI-Core.md +++ b/design-docs/CLI-Core.md @@ -15,6 +15,7 @@ subscriptions. * [Orders](CLI-Orders.md) * [Data](CLI-Data.md) * [Subscriptions](CLI-Subscriptions.md) +* [Destinations](CLI-Destinations.md) ## CLI Base diff --git a/design-docs/CLI-Destinations.md b/design-docs/CLI-Destinations.md new file mode 100644 index 000000000..c2d713416 --- /dev/null +++ b/design-docs/CLI-Destinations.md @@ -0,0 +1,196 @@ +# Destinations Command-Line Interface Specification + +This document lays out the command-line interface to interact with the Planet +[Destinations API](https://docs.planet.com/develop/apis/destinations/). + +## Overview + +The `planet destinations` command group allows you to list, create, update, archive, unarchive, and rename cloud storage destinations, including Amazon S3, S3-compatible, Google Cloud Storage, Azure Blob Storage, and Oracle Cloud Storage. + +--- + +## Global Options + +- `-u, --base-url TEXT` + Assign custom base Destinations API URL. + +--- + +## Commands + +### List Destinations + +```sh +planet destinations list [OPTIONS] +``` + +**Options:** +- `--archived [true|false]` + Include only archived destinations (`true`) or exclude them (`false`). +- `--is-owner [true|false]` + Include only destinations owned by the requesting user (`true`) or exclude them (`false`). +- `--can-write [true|false]` + Include only destinations the user can modify (`true`) or exclude them (`false`). + +**Example:** +```sh +planet destinations list --archived false --is-owner true --can-write true +``` + +--- + +### Get Destination + +```sh +planet destinations get DESTINATION_ID [OPTIONS] +``` + +Retrieve detailed information about a specific destination. + +**Example:** +```sh +planet destinations get my-destination-id +``` + +--- + +### Create Destinations + +#### Amazon S3 + +```sh +planet destinations create s3 --bucket BUCKET --region REGION --access-key-id KEY --secret-access-key SECRET [--explicit-sse] [--name NAME] +``` + +**Options:** +- `--bucket` (required): S3 bucket name. +- `--region` (required): AWS region. +- `--access-key-id` (required): AWS access key ID. +- `--secret-access-key` (required): AWS secret access key. +- `--explicit-sse`: Explicitly set headers for server-side encryption (SSE). +- `--name`: Optional name for the destination. + +#### S3-Compatible + +```sh +planet destinations create s3-compatible --bucket BUCKET --endpoint ENDPOINT --region REGION --access-key-id KEY --secret-access-key SECRET [--use-path-style] [--name NAME] +``` + +**Options:** +- `--bucket` (required): Bucket name. +- `--endpoint` (required): Endpoint URL. +- `--region` (required): Region. +- `--access-key-id` (required): Access key ID. +- `--secret-access-key` (required): Secret access key. +- `--use-path-style`: Use path-style addressing. +- `--name`: Optional name for the destination. + +#### Google Cloud Storage (GCS) + +```sh +planet destinations create gcs --bucket BUCKET --credentials BASE64_JSON [--name NAME] +``` + +**Options:** +- `--bucket` (required): GCS bucket name. +- `--credentials` (required): Base64-encoded service account credentials (JSON). +- `--name`: Optional name for the destination. + +#### Azure Blob Storage + +```sh +planet destinations create azure --container CONTAINER --account ACCOUNT --sas-token SAS_TOKEN [--storage-endpoint-suffix SUFFIX] [--name NAME] +``` + +**Options:** +- `--container` (required): Blob storage container name. +- `--account` (required): Azure account. +- `--sas-token` (required): Shared-Access Signature token. +- `--storage-endpoint-suffix`: Custom Azure Storage endpoint suffix. +- `--name`: Optional name for the destination. + +#### Oracle Cloud Storage (OCS) + +```sh +planet destinations create ocs --bucket BUCKET --access-key-id KEY --secret-access-key SECRET --namespace NAMESPACE --region REGION [--name NAME] +``` + +**Options:** +- `--bucket` (required): Oracle bucket name. +- `--access-key-id` (required): Oracle account access key. +- `--secret-access-key` (required): Oracle account secret key. +- `--namespace` (required): Oracle Object Storage namespace. +- `--region` (required): Oracle region. +- `--name`: Optional name for the destination. + +--- + +### Update Destinations + +#### Amazon S3 + +```sh +planet destinations update s3 DESTINATION_ID --access-key-id KEY --secret-access-key SECRET [--explicit-sse] +``` + +#### S3-Compatible + +```sh +planet destinations update s3-compatible DESTINATION_ID --access-key-id KEY --secret-access-key SECRET [--use-path-style] +``` + +#### Google Cloud Storage (GCS) + +```sh +planet destinations update gcs DESTINATION_ID --credentials BASE64_JSON +``` + +#### Azure Blob Storage + +```sh +planet destinations update azure DESTINATION_ID --sas-token SAS_TOKEN +``` + +#### Oracle Cloud Storage (OCS) + +```sh +planet destinations update ocs DESTINATION_ID --access-key-id KEY --secret-access-key SECRET +``` + +--- + +### Archive/Unarchive Destinations + +#### Archive + +```sh +planet destinations archive DESTINATION_ID +``` + +#### Unarchive + +```sh +planet destinations unarchive DESTINATION_ID +``` + +--- + +### Rename Destination + +```sh +planet destinations rename DESTINATION_ID NEW_NAME +``` + +--- + +## Notes + +- For GCS, the `--credentials` argument must be the base64-encoded JSON of your Google Cloud service account key. + To encode a JSON file to base64: + ```sh + cat my_creds.json | base64 | tr -d '\n' + ``` + +- All commands support the `--base-url` option for custom API endpoints. + +--- diff --git a/docs/cli/cli-guide.md b/docs/cli/cli-guide.md index c93798b79..3ef5b3f29 100644 --- a/docs/cli/cli-guide.md +++ b/docs/cli/cli-guide.md @@ -18,7 +18,8 @@ If you’re already comfortable with CLI tools you can safely skip this section. extensive examples. * **[CLI for Orders API](cli-orders.md)** dives into the `planet orders` commands with numerous samples to get you started. -* **[CLI for Subscriptions API](cli-subscriptions.md)** - explains the `planet subscriptions` commands +* **[CLI for Subscriptions API](cli-subscriptions.md)** - explains the `planet subscriptions` commands. +* **[CLI for Destinations API](cli-destinations.md)** - explores the `planet destinations` commands with examples. * **[CLI Tips & Tricks](cli-tips-tricks.md)** highlights a number of interesting geospatial CLI command-line tools and shows you how to use them in conjunction with Planet’s tools. diff --git a/docs/python/sdk-reference.md b/docs/python/sdk-reference.md index f1bf5257d..97b3dea8b 100644 --- a/docs/python/sdk-reference.md +++ b/docs/python/sdk-reference.md @@ -42,6 +42,14 @@ title: Python SDK API Reference rendering: show_root_full_path: false +## ::: planet.DestinationsClient + rendering: + show_root_full_path: false + +## ::: planet.FeaturesClient + rendering: + show_root_full_path: false + ## ::: planet.Planet rendering: show_root_full_path: false diff --git a/mkdocs.yml b/mkdocs.yml index d9ed18ade..1c2dac744 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -85,6 +85,7 @@ nav: - cli/cli-data.md - cli/cli-orders.md - cli/cli-subscriptions.md + - cli/cli-destinations.md - cli/cli-tips-tricks.md - cli/cli-reference.md - "Python": diff --git a/tests/integration/test_destinations_api.py b/tests/integration/test_destinations_api.py index 18ee02875..e480bc539 100644 --- a/tests/integration/test_destinations_api.py +++ b/tests/integration/test_destinations_api.py @@ -18,7 +18,7 @@ import httpx from planet import DestinationsClient, Session -from planet.auth import APIKeyAuth +from planet.auth import Auth from planet.sync.destinations import DestinationsAPI pytestmark = pytest.mark.anyio @@ -86,7 +86,7 @@ DEST_LIST = [DEST_1, DEST_2] -test_session = Session(auth=APIKeyAuth(key="test")) +test_session = Session(auth=Auth.from_key(key="test")) cl_async = DestinationsClient(test_session, base_url=TEST_URL) cl_sync = DestinationsAPI(test_session, base_url=TEST_URL) diff --git a/tests/integration/test_destinations_cli.py b/tests/integration/test_destinations_cli.py new file mode 100644 index 000000000..50f806234 --- /dev/null +++ b/tests/integration/test_destinations_cli.py @@ -0,0 +1,253 @@ +# Copyright 2025 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. +"""Test Destinations CLI""" +import pytest +import respx +import httpx +from http import HTTPStatus +from click.testing import CliRunner + +from planet.cli import cli + +TEST_DESTINATIONS_URL = 'https://api.planet.com/destinations/v1' + + +@pytest.fixture +def invoke(): + + def _invoke(extra_args, runner=None): + runner = runner or CliRunner() + args = ['destinations'] + extra_args + return runner.invoke(cli.main, args=args) + + return _invoke + + +@respx.mock +def test_destinations_cli_archive(invoke): + url = f"{TEST_DESTINATIONS_URL}/fake-dest-id" + respx.patch(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + + result = invoke(['archive', 'fake-dest-id']) + assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_create(invoke): + respx.post(TEST_DESTINATIONS_URL).return_value = httpx.Response( + HTTPStatus.ACCEPTED, json={}) + + # azure + result = invoke([ + 'create', + 'azure', + '--container', + 'my-container', + '--account', + 'mystorage', + '--sas-token', + '?sv=...', + '--storage-endpoint-suffix', + 'core.windows.net', + '--name', + 'my-azure-destination' + ]) + assert result.exit_code == 0 + + # gcs + result = invoke([ + 'create', + 'gcs', + '--bucket', + 'my-bucket', + '--credentials', + 'eyJ0eXAiOiJKV1Qi...', + '--name', + 'my-gcs-destination' + ]) + assert result.exit_code == 0 + + # ocs + result = invoke([ + 'create', + 'ocs', + '--bucket', + 'my-bucket', + '--access-key-id', + 'OCID...', + '--secret-access-key', + 'SECRET...', + '--namespace', + 'my-namespace', + '--region', + 'us-ashburn-1', + '--name', + 'my-ocs-destination' + ]) + assert result.exit_code == 0 + + # s3 + result = invoke([ + 'create', + 's3', + '--bucket', + 'my-bucket', + '--region', + 'us-west-2', + '--access-key-id', + 'AKIA...', + '--secret-access-key', + 'SECRET...', + '--explicit-sse', + '--name', + 'my-s3-destination' + ]) + assert result.exit_code == 0 + + # s3-compatible + result = invoke([ + 'create', + 's3-compatible', + '--bucket', + 'my-bucket', + '--endpoint', + 'https://objects.example.com', + '--region', + 'us-east-1', + '--access-key-id', + 'AKIA...', + '--secret-access-key', + 'SECRET...', + '--use-path-style', + '--name', + 'my-s3-comp-destination' + ]) + assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_get(invoke): + url = f"{TEST_DESTINATIONS_URL}/fake-dest-id" + respx.get(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + + result = invoke(['get', 'fake-dest-id']) + assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_rename(invoke): + url = f"{TEST_DESTINATIONS_URL}/fake-dest-id" + respx.patch(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + + result = invoke(['rename', 'fake-dest-id', 'new-name']) + assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_unarchive(invoke): + url = f"{TEST_DESTINATIONS_URL}/fake-dest-id" + respx.patch(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + + result = invoke(['unarchive', 'fake-dest-id']) + assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_list(invoke): + respx.get(TEST_DESTINATIONS_URL).return_value = httpx.Response( + HTTPStatus.OK, json={}) + + result = invoke(['list']) + assert result.exit_code == 0 + + result = invoke(['list', '--archived', 'true']) + assert result.exit_code == 0 + + result = invoke(['list', '--is-owner', 'true']) + assert result.exit_code == 0 + + result = invoke(['list', '--can-write', 'true']) + assert result.exit_code == 0 + + result = invoke(['list', '--archived', 'false']) + assert result.exit_code == 0 + + result = invoke(['list', '--is-owner', 'false']) + assert result.exit_code == 0 + + result = invoke(['list', '--can-write', 'false']) + assert result.exit_code == 0 + + result = invoke(['list', '--archived', 'false', '--is-owner', 'true']) + assert result.exit_code == 0 + + +@respx.mock +def test_destinations_cli_update(invoke): + url = f"{TEST_DESTINATIONS_URL}/fake-dest-id" + respx.patch(url).return_value = httpx.Response(HTTPStatus.ACCEPTED, + json={}) + + # azure + result = invoke( + ['update', 'azure', 'fake-dest-id', '--sas-token', '?sv=...']) + assert result.exit_code == 0 + + # gcs + result = invoke([ + 'update', + 'gcs', + 'fake-dest-id', + '--credentials', + 'eyJ0eXAiOiJKV1Qi...' + ]) + assert result.exit_code == 0 + + # ocs + result = invoke([ + 'update', + 'ocs', + 'fake-dest-id', + '--access-key-id', + 'OCID...', + '--secret-access-key', + 'SECRET...' + ]) + assert result.exit_code == 0 + + # s3 + result = invoke([ + 'update', + 's3', + 'fake-dest-id', + '--access-key-id', + 'AKIA...', + '--secret-access-key', + 'SECRET...', + '--explicit-sse' + ]) + assert result.exit_code == 0 + + # s3-compatible + result = invoke([ + 'update', + 's3-compatible', + 'fake-dest-id', + '--access-key-id', + 'AKIA...', + '--secret-access-key', + 'SECRET...', + '--use-path-style' + ]) + assert result.exit_code == 0 From fc6ceeb32f635c8e900544766ac679cdc2a89946 Mon Sep 17 00:00:00 2001 From: Carl Adams <57012982+carl-adams-planet@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:50:57 +0000 Subject: [PATCH 21/24] Update documents for release (#1177) Approved by Matt Ballard for release. * Remove references to email inbox that is no longer monitored. * update trial words and getting your credentials * delete get your pl account --------- Co-authored-by: Matt Ballard --- README.md | 3 +- docs/cli/cli-guide.md | 11 +------ docs/custom_theme/home.html | 3 -- docs/get-started/get-your-planet-account.md | 34 --------------------- docs/get-started/quick-start-guide.md | 3 +- docs/python/sdk-guide.md | 1 - docs/resources/index.md | 3 -- mkdocs.yml | 4 +-- 8 files changed, 4 insertions(+), 58 deletions(-) delete mode 100644 docs/get-started/get-your-planet-account.md diff --git a/README.md b/README.md index 7768d287e..ccb90e7be 100644 --- a/README.md +++ b/README.md @@ -119,5 +119,4 @@ read from source in the [docs](/docs) directory. ## Authentication -Planet's APIs require an account for use. To get started you need to -[Get a Planet Account](https://planet-sdk-for-python.readthedocs.io/en/latest/get-started/get-your-planet-account/). +Planet's APIs require an account for use. To learn how to authenticate, see the [client authentication overview](https://planet-sdk-for-python.readthedocs.io/en/latest/auth/auth-overview/) \ No newline at end of file diff --git a/docs/cli/cli-guide.md b/docs/cli/cli-guide.md index 3ef5b3f29..f45848f02 100644 --- a/docs/cli/cli-guide.md +++ b/docs/cli/cli-guide.md @@ -48,15 +48,7 @@ You should be on some version 2 of the Planet SDK for Python. ## Step 4: Sign on to your account -Planet SDK for Python, like the Planet APIs, requires an account for use. - -### Have your Planet account username and password ready - -To confirm your Planet account, or to get one if you don’t already have one, see [Get your Planet Account](../get-started/get-your-planet-account.md). - -### Authenticate with the Planet server - -Just as you log in when you browse to https://planet.com/account, you’ll want to sign on to your account so you have access to your account and orders. +Planet SDK for Python, like the Planet APIs, requires an account for use. Just as you log in when you browse to https://planet.com/account, you’ll want to sign on to your account so you have access to your account and orders. At a terminal console, type the following Planet command: @@ -141,4 +133,3 @@ As The Planet SDK (V2) is in active development, features & functionality will c If there's something you're missing or are stuck, the development team would love to hear from you. - To report a bug or suggest a feature, [raise an issue on GitHub](https://github.com/planetlabs/planet-client-python/issues/new) - - To get in touch with the development team, email [developers@planet.com](mailto:developers@planet.com) diff --git a/docs/custom_theme/home.html b/docs/custom_theme/home.html index dfbc899f3..651c26522 100644 --- a/docs/custom_theme/home.html +++ b/docs/custom_theme/home.html @@ -14,9 +14,6 @@

Get started - - Get a Planet account - diff --git a/docs/get-started/get-your-planet-account.md b/docs/get-started/get-your-planet-account.md deleted file mode 100644 index d324cee3b..000000000 --- a/docs/get-started/get-your-planet-account.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: Get Your Planet Account ---- - -Whenever you use the Planet SDK for Python to connect to Planet’s APIs, you’ll need to authenticate with the Planet server. To do so, you need a Planet account with your own username and password. - -## Confirm your Planet account - -### Sign on to Planet Explorer - -If you go to Planet Explorer, and you have an account, you’ll be prompted to enter your account username and password. That’s the same username and password you’ll use to authenticate with the Planet servers, here. - -Once in Explorer, you can select the user icon in the upper right corner to go to your Account page: - -My Account Icon in Planet Explorer - -### Sign up for the Planet Developer Program - -If you don’t have an account, but you’d like to take our APIs for a spin and see how you might intergrate Planet into your existing geospatial workflows, you can sign up for the Developer Program. - -## Authenticating with the Planet server - -After you’ve installed the Planet SDK, you can authenticate with the Planet server as outlined in the [No-Code CLI Guide](../../cli/cli-guide/#authentication) - -## Next steps - -Now that you have confirmed your Planet account username and password, you can take the other steps in the [Quick Start](../quick-start-guide). diff --git a/docs/get-started/quick-start-guide.md b/docs/get-started/quick-start-guide.md index 7a04b3615..3a545c37d 100644 --- a/docs/get-started/quick-start-guide.md +++ b/docs/get-started/quick-start-guide.md @@ -13,7 +13,7 @@ Your feedback on this version of our client is appreciated. Please raise an issu This package requires [Python 3.9 or greater](https://python.org/downloads/). A virtual environment is strongly recommended. -You will need your Planet API credentials. You can find your API key in [Planet Explorer](https://planet.com/explorer) under Account Settings. +You will need Planet credentials to use this SDK. You can learn more about authentication options and where to find your credentials in our [authentication documentation](https://docs.planet.com/develop/authentication/). ## Installation @@ -54,4 +54,3 @@ As The Planet SDK (V2) is in active development, features & functionality will c If there's something you're missing or are stuck, the development team would love to hear from you. - To report a bug or suggest a feature, [raise an issue on GitHub](https://github.com/planetlabs/planet-client-python/issues/new) - - To get in touch with the development team, email [developers@planet.com](mailto:developers@planet.com) diff --git a/docs/python/sdk-guide.md b/docs/python/sdk-guide.md index e8c00a49e..2dd7984f4 100644 --- a/docs/python/sdk-guide.md +++ b/docs/python/sdk-guide.md @@ -327,4 +327,3 @@ As The Planet SDK (V2) is in active development, features & functionality will c If there's something you're missing or are stuck, the development team would love to hear from you. - To report a bug or suggest a feature, [raise an issue on GitHub](https://github.com/planetlabs/planet-client-python/issues/new) - - To get in touch with the development team, email [developers@planet.com](mailto:developers@planet.com) diff --git a/docs/resources/index.md b/docs/resources/index.md index 1d61eab24..838b6513e 100644 --- a/docs/resources/index.md +++ b/docs/resources/index.md @@ -27,9 +27,6 @@ This pre-release SDK has implemented interfaces for several Planet APIs. Check o * [Orders](https://docs.planet.com/develop/apis/orders/) * [Subscriptions](https://docs.planet.com/develop/apis/subscriptions/) -## Email Developer Relations - -We are eager to share this pre-release with you and encourage you to test your workflows rigorously. Based on your feedback, we may roll out additional updates to improve your experience. Besides joining the discussion, and filing issues and pull requests here, feel free to share your general feedback with us at developers@planet.com. ## Contribute to this open source project To contribute or develop with this library, see diff --git a/mkdocs.yml b/mkdocs.yml index 1c2dac744..540bf459f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,7 +74,6 @@ hooks: nav: - "Get Started": - get-started/quick-start-guide.md - - get-started/get-your-planet-account.md - get-started/venv-tutorial.md - "Upgrade Guides": - get-started/upgrading-v3.md @@ -101,8 +100,7 @@ nav: - auth/auth-dev-app-managed-apikey.md - "Resources": - resources/index.md - - "Home": 'index.md' - + markdown_extensions: - pymdownx.highlight - pymdownx.superfences From 1c708869f39e6b0762cbc6e539eb825900a0a1ff Mon Sep 17 00:00:00 2001 From: Ian Schneider Date: Wed, 6 Aug 2025 13:30:48 -0600 Subject: [PATCH 22/24] fix recursive call to download_quad in sync client Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- planet/sync/mosaics.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/planet/sync/mosaics.py b/planet/sync/mosaics.py index 9134db687..f1ae88ed6 100644 --- a/planet/sync/mosaics.py +++ b/planet/sync/mosaics.py @@ -240,10 +240,10 @@ def download_quad(self, ``` """ self._client._call_sync( - self.download_quad(quad, - directory=directory, - overwrite=overwrite, - progress_bar=progress_bar)) + self._client.download_quad(quad, + directory=directory, + overwrite=overwrite, + progress_bar=progress_bar)) def download_quads(self, /, From b0a8f7106346de722c0d1b866500d4c0f7640075 Mon Sep 17 00:00:00 2001 From: Torben Barsballe Date: Wed, 6 Aug 2025 12:33:15 -0700 Subject: [PATCH 23/24] Update active branches for 3.0 release --- CONTRIBUTING.md | 4 ++-- README.md | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fdba2711e..15677deff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,8 +26,8 @@ project, but should be retired when a new release becomes more formalized. Upon all content is expected to be folded into package documentation as appropriate (announcements, company blog posts, changelogs, migration guides, etc.). -When a new major release is ready, the development mainline branch will be renamed to `main`, and the -old mainline branch will be renamed to `maint-X.0` and will be used as the base for maintenance releases. +When a new major release is ready, the old mainline branch will be copied to `maint-X.x` (e.g. `maint-2.x`), +to be used as the base for maintenance releases, and the development mainline branch will be merged into to `main` #### Development Branches diff --git a/README.md b/README.md index ccb90e7be..ba7bb4a51 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#branches) for more information on branches ##### Current Mainline Versions and Branches -| Version | Status | Branch | Documentation | Initial Release | End of Active Development | End of Maintenance | Notes | -|---------|---------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-----------------------|---------------------------|--------------------|-------------------------------------------------------------------------------------------------| -| 3.x | `development` | [`main-3.0-dev`](https://github.com/planetlabs/planet-client-python/tree/main-3.0-dev) | [Planet Labs Python Client on ReadTheDocs.io](https://planet-sdk-for-python.readthedocs.io/en/latest/) | Targeting August 2025 | TBD | TBD | See [3.0.0 Release Milestone](https://github.com/planetlabs/planet-client-python/milestone/31). | -| 2.x | `active` | [`main`](https://github.com/planetlabs/planet-client-python/tree/main) | [Planet Labs Python Client v2 on ReadTheDocs.io](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/) | April 2023 | TBD | TBD | | -| 1.x | `end-of-life` | [`v1`](https://github.com/planetlabs/planet-client-python/tree/v1) | [Planet Labs Python Client v1 on Github.io](https://planetlabs.github.io/planet-client-python/) | April 2017 | April 2023 | TBD | | +| Version | Status | Branch | Documentation | Initial Release | End of Active Development | End of Maintenance | Notes | +|---------|---------------|----------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-----------------|---------------------------|--------------------|------------------------------------------------------------------------------------------------------------------------------| +| 3.x | `active` | [`main`](https://github.com/planetlabs/planet-client-python/tree/main) | TBD | TBD | TBD | TBD | See [RELEASE-PLANNING-X.0.md](https://github.com/planetlabs/planet-client-python/tree/main-3.0-dev/RELEASE-PLANNING-3.0.md). | +| 2.x | `maintenance` | [`maint-2.x`](https://github.com/planetlabs/planet-client-python/tree/maint-2.x) | [Planet Labs Python Client v2 on Readthedocs.io](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/) | April 2023 | TBD | TBD | | +| 1.x | `end-of-life` | [`v1`](https://github.com/planetlabs/planet-client-python/tree/v1) | [Planet Labs Python Client v1 on Github.io](https://planetlabs.github.io/planet-client-python/) | April 2017 | April 2023 | TBD | | ## Installation and Quick Start From 43b13f5067c26846fff7d4dce349c34f51d6221a Mon Sep 17 00:00:00 2001 From: Carl Adams <57012982+carl-adams-planet@users.noreply.github.com> Date: Wed, 6 Aug 2025 19:39:21 +0000 Subject: [PATCH 24/24] Carl/release merge cleanup (#1184) * Remove warning. We will support these for now. * Remove dead code in comment. * update comments --- planet/auth.py | 5 ----- tests/unit/test_auth.py | 6 ------ tests/unit/test_cli_session.py | 2 +- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/planet/auth.py b/planet/auth.py index f7acdb11a..688f0d64a 100644 --- a/planet/auth.py +++ b/planet/auth.py @@ -322,11 +322,6 @@ def from_key(key: typing.Optional[str]) -> Auth: Parameters: key: Planet API key """ - warnings.warn( - "Planet API keys will be deprecated for most use cases." - " Initialize an OAuth client, or create an OAuth service account." - " Proceeding for now.", - PendingDeprecationWarning) if not key: raise APIKeyAuthException('API key cannot be empty.') diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 72c5cea76..b6f8ce8e3 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -131,12 +131,6 @@ def test_Auth_from_env_alternate_doesnotexist(monkeypatch): def test_Auth_from_login(monkeypatch): - # auth.AuthClient has been completely removed - # in the conversion to planet_auth - # def login(*args, **kwargs): - # return {'api_key': auth_data} - # - # monkeypatch.setattr(auth.AuthClient, 'login', login) with pytest.raises(DeprecationWarning): _ = auth.Auth.from_login('email', 'pw') diff --git a/tests/unit/test_cli_session.py b/tests/unit/test_cli_session.py index ed389f70c..86ae35764 100644 --- a/tests/unit/test_cli_session.py +++ b/tests/unit/test_cli_session.py @@ -73,7 +73,7 @@ async def test_CliSession_auth_valid(test_valid_secretfile): received_request = route.calls.last.request # The planet_auth library sends the api key as bearer token. # The older Planet SDK sent it as HTTP basic. - # Most Planet APIs accept either (and API keys are being deprecated.) + # Most Planet APIs accept either. # credentials = received_request.headers['authorization'].strip( # 'Authorization: Basic ') # assert base64.b64decode(credentials) == b'clisessiontest:'