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/.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
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/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 15f44f9e0..ccb90e7be 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:
@@ -15,7 +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.
+* [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/),
@@ -34,7 +34,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.
@@ -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) | 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/) | 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
@@ -104,7 +104,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
@@ -112,12 +112,11 @@ 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.
## 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/).
+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/RELEASE.md b/RELEASE.md
index b0aedbdbd..d94bb1b80 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -54,7 +54,6 @@ The SDK follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and t
* _`version`_: [https://planet-sdk-for-python-v2.readthedocs.io/en/X.YY.ZZ/](https://planet-sdk-for-python-v2.readthedocs.io/en/X.YY.ZZ/) - Should point to version `X.YY.ZZ`.
* Pre-release versions should _not_ impact the default version of the documentation. Pre-release version may be published as the `latest` version.
-
## Local publishing
Publishing to testpypi and pypi can also be performed locally with:
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/cli/cli-guide.md b/docs/cli/cli-guide.md
index 65e0df118..f45848f02 100644
--- a/docs/cli/cli-guide.md
+++ b/docs/cli/cli-guide.md
@@ -35,67 +35,53 @@ 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.
## 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://account.planet.com, 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:
```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
@@ -147,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/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/cli/cli-subscriptions.md b/docs/cli/cli-subscriptions.md
index ee94f553c..3e08e44cb 100644
--- a/docs/cli/cli-subscriptions.md
+++ b/docs/cli/cli-subscriptions.md
@@ -451,22 +451,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/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:
-
-
-
-### 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 28abcf8da..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
@@ -27,10 +27,13 @@ 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/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
-export PL_API_KEY=your_api_key
+planet auth login
```
### The Planet client
@@ -39,7 +42,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).
@@ -51,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/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..a8fc6c760
--- /dev/null
+++ b/docs/get-started/upgrading-v3.md
@@ -0,0 +1,100 @@
+# 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](../auth/auth-overview.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 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
+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](../auth/auth-overview.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](../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.
+
+## 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.
+
+----
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/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
new file mode 100644
index 000000000..fe22f5179
--- /dev/null
+++ b/docs/python/sdk-client-auth.md
@@ -0,0 +1,15 @@
+# Authentication SDK Guide
+
+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.
+
+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.
+
+[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 5dcd71c3d..2dd7984f4 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. 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 using OAuth2, and support for legacy applications using Planet API keys.
### Search
@@ -231,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.
@@ -333,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/python/sdk-reference.md b/docs/python/sdk-reference.md
index baa3ee688..97b3dea8b 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/docs/resources/index.md b/docs/resources/index.md
index ff7bdc473..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
@@ -43,4 +40,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/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..67a883204
--- /dev/null
+++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py
@@ -0,0 +1,101 @@
+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 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__",
+ 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..cde34a5a7
--- /dev/null
+++ b/examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py
@@ -0,0 +1,23 @@
+import json
+import planet
+
+
+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..867fdd170
--- /dev/null
+++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py
@@ -0,0 +1,27 @@
+import json
+import planet
+
+
+def example_main():
+ # 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__",
+ 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..a7274db99
--- /dev/null
+++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py
@@ -0,0 +1,35 @@
+import json
+import planet
+
+
+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..087dacabf
--- /dev/null
+++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py
@@ -0,0 +1,72 @@
+import json
+import planet
+
+
+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..7afcf7652
--- /dev/null
+++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py
@@ -0,0 +1,28 @@
+import json
+import planet
+
+
+def example_main():
+ # 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__",
+ 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..4beeb5a28
--- /dev/null
+++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py
@@ -0,0 +1,37 @@
+import json
+import planet
+
+
+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..0b74a39d1
--- /dev/null
+++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py
@@ -0,0 +1,74 @@
+import json
+import planet
+
+
+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..7c0761069
--- /dev/null
+++ b/examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py
@@ -0,0 +1,23 @@
+import json
+import planet
+
+
+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/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
new file mode 100644
index 000000000..2ad7b8c42
--- /dev/null
+++ b/examples/auth-session-management/cli_managed_auth_state__explicit.py
@@ -0,0 +1,29 @@
+import json
+import planet
+import sys
+
+
+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..bd6397803
--- /dev/null
+++ b/examples/auth-session-management/cli_managed_auth_state__implicit.py
@@ -0,0 +1,18 @@
+import json
+import planet
+
+
+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 inspect environment variables for configuration.
+ pl = planet.Planet()
+
+ # Use the SDK to call Planet APIs.
+ # 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))
+
+
+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..42f334312
--- /dev/null
+++ b/examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py
@@ -0,0 +1,34 @@
+import json
+import planet
+import sys
+
+
+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-app-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/examples/mosaics-cli.sh b/examples/mosaics-cli.sh
new file mode 100755
index 000000000..997c93c9a
--- /dev/null
+++ b/examples/mosaics-cli.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+
+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 "\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 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 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 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/mkdocs.yml b/mkdocs.yml
index a3615b9f4..540bf459f 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.
@@ -50,6 +50,9 @@ extra_css:
plugins:
- search
+ - macros:
+ include_dir: 'examples'
+ on_error_fail: true
- mkdocstrings:
handlers:
python:
@@ -59,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
@@ -71,9 +74,10 @@ hooks:
nav:
- "Get Started":
- 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
@@ -85,12 +89,18 @@ nav:
- cli/cli-reference.md
- "Python":
- python/sdk-guide.md
+ - 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'
-
+
markdown_extensions:
- pymdownx.highlight
- pymdownx.superfences
diff --git a/noxfile.py b/noxfile.py
index 833c1c3c5..4fe10d2b6 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 9b8be55b1..41a9e62b6 100644
--- a/planet/__init__.py
+++ b/planet/__init__.py
@@ -16,17 +16,20 @@
from . import data_filter, order_request, reporting, subscription_request
from .__version__ import __version__ # NOQA
from .auth import Auth
-from .clients import DataClient, DestinationsClient, FeaturesClient, OrdersClient, SubscriptionsClient # NOQA
+from .auth_builtins import PlanetOAuthScopes
+from .clients import DataClient, DestinationsClient, FeaturesClient, MosaicsClient, OrdersClient, SubscriptionsClient # NOQA
from .io import collect
from .sync import Planet
__all__ = [
'Auth',
+ 'PlanetOAuthScopes',
'collect',
'DataClient',
'data_filter',
'DestinationsClient',
'FeaturesClient',
+ 'MosaicsClient',
'OrdersClient',
'order_request',
'Planet',
diff --git a/planet/auth.py b/planet/auth.py
index 57a4b6ce5..688f0d64a 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,257 +15,486 @@
"""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 . import http
-from .constants import ENV_API_KEY, PLANET_BASE_URL, SECRET_FILE_PATH
-from .exceptions import AuthException
+from typing import List
-LOGGER = logging.getLogger(__name__)
+from .auth_builtins import _ProductionEnv, _OIDC_AUTH_CLIENT_CONFIG__USER_SKEL, _OIDC_AUTH_CLIENT_CONFIG__M2M_SKEL
+import planet_auth
+import planet_auth_utils
-BASE_URL = f'{PLANET_BASE_URL}/v0/auth'
+from .constants import SECRET_FILE_PATH
+from .exceptions import PlanetError
-AuthType = httpx.Auth
+planet_auth.setStructuredLogging(nested_key=None)
-class Auth(metaclass=abc.ABCMeta):
- """Handle authentication information for use with Planet APIs."""
+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 from_key(key: str) -> AuthType:
- """Obtain authentication from api key.
+ 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()
- Parameters:
- key: Planet API key
+ @staticmethod
+ def from_user_default_session() -> Auth:
"""
- auth = APIKeyAuth(key=key)
- LOGGER.debug('Auth obtained from api key.')
- return auth
+ Create authentication context from user defaults.
- @staticmethod
- def from_file(
- filename: Optional[typing.Union[str,
- pathlib.Path]] = None) -> AuthType:
- """Create authentication from secret file.
+ This method should be used when an application wants to defer
+ auth profile management to the user and the `planet auth` CLI
+ command entirely.
- The 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()`.
+ Users may use the `planet auth login` and `planet auth profile
+ commands to initialize and manage sessions.
- Parameters:
- filename: Alternate path for the planet secret file.
+ Defaults take into account environment variables (highest priority),
+ user configuration saved to `~/.planet.json` and `~/.planet/`
+ (next priority), and built-in defaults (lowest priority).
- """
- filename = filename or SECRET_FILE_PATH
+ This method does not support the use a custom storage provider.
+
+ Environment Variables:
- 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.')
+ | 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 |
- LOGGER.debug(f'Auth read from secret file {filename}.')
- return auth
+ """
+ return _PLAuthLibAuth(plauth=planet_auth_utils.PlanetAuthFactory.
+ initialize_auth_client_context())
@staticmethod
- def from_env(variable_name: Optional[str] = None) -> AuthType:
- """Create authentication from environment variable.
+ def from_profile(
+ profile_name: str,
+ save_state_to_storage: bool = True,
+ ) -> Auth:
+ """
+ Create authentication context from an auth session that has been
+ initialized and saved to `~/.planet.json` and `~/.planet/`.
- Reads the `PL_API_KEY` environment variable
+ Users can initialize and save such a session out-of-band
+ using the `planet auth login` and `planet auth profile` commands.
- 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
+ To initialize this session programmatically without the CLI,
+ you must complete an OAuth2 user login flow with one of the login
+ methods on this class. The login method used must be compatible
+ with the specified profile.
- @staticmethod
- def from_login(email: str,
- password: str,
- base_url: Optional[str] = None) -> AuthType:
- """Create authentication from login email and password.
+ This method does not support the use a custom storage provider.
- Note: To keep your password secure, the use of `getpass` is
- recommended.
+ 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:
- email: Planet account email address.
- password: Planet account password.
- base_url: The base URL to use. Defaults to production
- authentication API base url.
+ 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,
+ 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
+ @staticmethod
+ 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:
"""
- cl = AuthClient(base_url=base_url)
- auth_data = cl.login(email, password)
+ Create authentication context for the specified registered client
+ application.
- api_key = auth_data['api_key']
- auth = APIKeyAuth(api_key)
- LOGGER.debug('Auth set from login email and password')
- return auth
+ 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.
- @classmethod
- @abc.abstractmethod
- def from_dict(cls, data: dict) -> AuthType:
- pass
+ 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.
- @property
- @abc.abstractmethod
- def value(self):
- pass
+ 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 context for the specified registered client
+ application.
- @abc.abstractmethod
- def to_dict(self) -> dict:
- pass
+ 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.
- def store(self,
- filename: Optional[typing.Union[str, pathlib.Path]] = None):
- """Store authentication information in secret file.
+ 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 `device_user_login_initiate()` and
+ `device_user_login_complete()` before API calls may be made.
Parameters:
- filename: Alternate path for the planet secret file.
+ 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.
"""
- filename = filename or SECRET_FILE_PATH
- secret_file = _SecretFile(filename)
- secret_file.write(self.to_dict())
-
+ 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)
-class AuthClient:
-
- def __init__(self, base_url: Optional[str] = None):
+ @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:
- base_url: The base URL to use. Defaults to production
- authentication API base url.
+ 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.
"""
- self._base_url = base_url or BASE_URL
- if self._base_url.endswith('/'):
- self._base_url = self._base_url[:-1]
+ 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)
- def login(self, email: str, password: str) -> dict:
- """Login using email identity and credentials.
+ @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)
- Note: To keep your password secure, the use of `getpass` is
- recommended.
+ @staticmethod
+ def from_key(key: typing.Optional[str]) -> Auth:
+ """Obtain authentication from api key.
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.
+ key: Planet API key
"""
- url = f'{self._base_url}/login'
- data = {'email': email, 'password': password}
+ if not key:
+ raise APIKeyAuthException('API key cannot be empty.')
- sess = http.AuthSession()
- resp = sess.request(url=url, method='POST', json=data)
- return self.decode_response(resp)
+ 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 decode_response(response):
- """Decode the token JWT"""
- token = response.json()['token']
- return jwt.decode(token, options={'verify_signature': False})
+ def from_file(
+ filename: typing.Optional[typing.Union[str,
+ pathlib.Path]] = None) -> Auth:
+ """Create authentication from secret file.
+ 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()`.
-class APIKeyAuthException(AuthException):
- """exceptions thrown by APIKeyAuth"""
- pass
+ 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.
-class APIKeyAuth(httpx.BasicAuth, Auth):
- """Planet API Key authentication."""
- DICT_KEY = 'key'
+ """
+ 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)
- def __init__(self, key: str):
- """Initialize APIKeyAuth.
+ @staticmethod
+ def from_env(variable_name: typing.Optional[str] = None) -> Auth:
+ """Create authentication from environment variables.
- Parameters:
- key: API key.
+ Reads the `PL_API_KEY` environment variable
- Raises:
- APIKeyException: If API key is None or empty string.
+ Pending Deprecation:
+ 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, and OAuth2 interactive profiles.
+ This method should be used in most cases as a replacement.
+
+ Parameters:
+ variable_name: Alternate environment variable.
"""
- if not key:
- raise APIKeyAuthException('API key cannot be empty.')
- self._key = key
- super().__init__(self._key, '')
+ warnings.warn(
+ "from_env() will be deprecated. Use from_user_default_session() 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: typing.Optional[str] = None) -> Auth:
+ raise DeprecationWarning(
+ "Auth.from_login() has been deprecated. Use Auth.from_user_session()."
+ )
@classmethod
- def from_dict(cls, data: dict) -> APIKeyAuth:
- """Instantiate APIKeyAuth from a dict."""
- api_key = data[cls.DICT_KEY]
- return cls(api_key)
+ def from_dict(cls, data: dict) -> Auth:
+ raise DeprecationWarning("Auth.from_dict() has been deprecated.")
- def to_dict(self):
- """Represent APIKeyAuth as a dict."""
- return {self.DICT_KEY: self._key}
+ def to_dict(self) -> dict:
+ raise DeprecationWarning("Auth.to_dict() has been deprecated.")
+
+ def store(self,
+ filename: typing.Optional[typing.Union[str,
+ pathlib.Path]] = None):
+ raise DeprecationWarning("Auth.store() has been deprecated.")
@property
def value(self):
- return self._key
+ 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.
+
+ :param allow_open_browser:
+ :param allow_tty_prompt:
+ """
+
+ @abc.abstractmethod
+ def device_user_login_initiate(self) -> dict:
+ """
+ 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.
+ """
+
+ @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.
+ """
+ @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 _SecretFile:
- def __init__(self, path: typing.Union[str, pathlib.Path]):
- self.path = pathlib.Path(path)
+class APIKeyAuthException(PlanetError):
+ """exceptions thrown by APIKeyAuth"""
+ pass
- 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..a789ec7aa 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,62 @@
# 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_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 7468c2c5e..467b1e5b1 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,11 @@
import click
+import planet_auth_utils
import planet
+from planet.cli import mosaics
-from . import auth, collect, data, destinations, orders, subscriptions, features
+from . import auth, cmds, collect, data, destinations, orders, subscriptions, features
LOGGER = logging.getLogger(__name__)
@@ -36,7 +38,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 +58,34 @@ 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
+ # 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,
+ use_env=True,
+ use_configfile=True)
+
+ # 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,10 +114,23 @@ 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) # type: ignore
main.add_command(destinations.destinations) # type: ignore
+main.add_command(mosaics.mosaics) # type: ignore
+
+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/mosaics.py b/planet/cli/mosaics.py
new file mode 100644
index 000000000..8254da449
--- /dev/null
+++ b/planet/cli/mosaics.py
@@ -0,0 +1,272 @@
+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 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_or_id")
+@click.argument("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_or_id, quad)
+ await _output(cl.get_quad_contributions(item), pretty)
+
+
+@command(mosaics, name="info")
+@click.argument("name_or_id", required=True)
+@include_links
+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_or_id), 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 information for all available mosaics
+
+ Example:
+
+ planet mosaics list --name-contains global_monthly
+ """
+ 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_or_id", required=True)
+@include_links
+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_or_id), 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 information for available series
+
+ Example:
+
+ planet mosaics series list --name-contains=Global
+ """
+ async with client(ctx) as cl:
+ await _output(
+ cl.list_series(
+ name_contains=name_contains,
+ interval=interval,
+ acquired_gt=acquired_gt,
+ acquired_lt=acquired_lt,
+ ),
+ pretty,
+ links)
+
+
+@command(series, name="list-mosaics")
+@click.argument("name_or_id", 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_or_id,
+ acquired_gt,
+ acquired_lt,
+ latest,
+ pretty,
+ links):
+ """List mosaics in a series specified by name or ID
+
+ Example:
+
+ planet mosaics series list-mosaics global_monthly_2025_04_mosaic
+ """
+ async with client(ctx) as cl:
+ await _output(
+ cl.list_series_mosaics(name_or_id,
+ acquired_gt=acquired_gt,
+ acquired_lt=acquired_lt,
+ latest=latest),
+ pretty,
+ links)
+
+
+@command(mosaics, name="search")
+@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_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:
+ 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")
+@click.argument("name_or_id", required=True)
+@click.option('--output-dir',
+ 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_or_id, output_dir, bbox, geometry, **kwargs):
+ """Download quads from a mosaic by name or ID
+
+ Example:
+
+ planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41
+ """
+ quiet = ctx.obj['QUIET']
+ async with client(ctx) as cl:
+ await cl.download_quads(name_or_id,
+ bbox=bbox,
+ geometry=geometry,
+ directory=output_dir,
+ progress_bar=not quiet)
diff --git a/planet/cli/orders.py b/planet/cli/orders.py
index 0bf47f86d..4bb645fdf 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 b9109874d..4eaf1b5ef 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
@@ -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.")
@@ -541,15 +542,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,
@@ -565,17 +560,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/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 1f278e3db..6aae646f6 100644
--- a/planet/clients/__init__.py
+++ b/planet/clients/__init__.py
@@ -15,6 +15,7 @@
from .data import DataClient
from .destinations import DestinationsClient
from .features import FeaturesClient
+from .mosaics import MosaicsClient
from .orders import OrdersClient
from .subscriptions import SubscriptionsClient
@@ -22,6 +23,7 @@
'DataClient',
'DestinationsClient',
'FeaturesClient',
+ 'MosaicsClient',
'OrdersClient',
'SubscriptionsClient'
]
@@ -31,6 +33,7 @@
'data': DataClient,
'destinations': DestinationsClient,
'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..98a8f48ee
--- /dev/null
+++ b/planet/clients/mosaics.py
@@ -0,0 +1,507 @@
+# 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, Optional, Sequence, Type, TypeVar, Union, cast
+from planet.clients.base import _BaseClient
+from planet.constants import PLANET_BASE_URL
+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
+
+BASE_URL = f'{PLANET_BASE_URL}/basemaps/v1'
+
+T = TypeVar("T")
+
+Number = Union[int, float]
+
+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):
+ ITEMS_KEY = 'series'
+ NEXT_KEY = '_next'
+
+
+class _MosaicsPage(Paged):
+ ITEMS_KEY = 'mosaics'
+ NEXT_KEY = '_next'
+
+
+class _QuadsPage(Paged):
+ ITEMS_KEY = 'items'
+ NEXT_KEY = '_next'
+
+
+def _is_uuid(val: str) -> bool:
+ try:
+ UUID(val)
+ return True
+ except ValueError:
+ return False
+
+
+class MosaicsClient(_BaseClient):
+ """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('mosaics')
+ ... # 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 Mosaics
+ base url.
+ """
+ super().__init__(session, base_url or BASE_URL)
+
+ def _url(self, path: str) -> str:
+ return f"{self._base_url}/{path}"
+
+ async def _get_by_name(self, path: str, pager: Type[Paged],
+ name: str) -> dict:
+ response = await self._session.request(
+ method='GET',
+ url=self._url(path),
+ params={
+ "name__is": name,
+ },
+ )
+ listing = response.json()[pager.ITEMS_KEY]
+ if len(listing):
+ return listing[0]
+ # 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",
+ url=self._url(f"{path}/{id}"))
+ return response.json()
+
+ async def _get(self, name_or_id: str, path: str,
+ 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 _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.
+
+ 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.
+
+ Parameters:
+ name_or_id: The name or id of the mosaic
+ """
+ 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,
+ acquired_lt: Optional[str] = None) -> AsyncIterator[Series]:
+ """
+ List the series you have access to.
+
+ Example:
+
+ ```python
+ 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 _SeriesPage(resp, self._session.request):
+ yield Series(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,
+ ) -> AsyncIterator[Mosaic]:
+ """
+ List the mosaics you have access to.
+
+ Example:
+
+ ```python
+ mosaics = await client.list_mosaics()
+ 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
+ resp = await self._session.request(
+ method='GET',
+ url=self._url("mosaics"),
+ params=params,
+ )
+ async for item in _MosaicsPage(resp, self._session.request):
+ yield Mosaic(item)
+
+ async def list_series_mosaics(
+ self,
+ /,
+ series: Union[Series, str],
+ *,
+ acquired_gt: Optional[str] = None,
+ acquired_lt: Optional[str] = None,
+ latest: bool = False,
+ ) -> AsyncIterator[Mosaic]:
+ """
+ List the mosaics in a series.
+
+ Example:
+
+ ```python
+ mosaics = await client.list_series_mosaics("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5")
+ async for m in mosaics:
+ print(m)
+ ```
+ """
+ series_id = series
+ 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
+ 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/{series_id}/mosaics"),
+ params=params,
+ )
+ async for item in _MosaicsPage(resp, self._session.request):
+ yield Mosaic(item)
+
+ 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, 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):
+ geometry = geometry.__geo_interface__
+ resp = await self._quads_geometry(mosaic,
+ geometry,
+ minimal,
+ summary)
+ else:
+ if not bbox:
+ xmin, ymin, xmax, ymax = cast(BBox, mosaic['bbox'])
+ 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,
+ geometry: dict,
+ minimal: bool,
+ summary: bool) -> Response:
+ params = {}
+ if minimal:
+ 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",
+ url=self._url(f"mosaics/{mosaic_id}/quads/search"),
+ params=params,
+ json=geometry,
+ )
+
+ async def _quads_bbox(self,
+ mosaic: Mosaic,
+ 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, mosaic: Union[Mosaic, str], quad_id: str) -> Quad:
+ """
+ Get a mosaic's quad information.
+
+ Example:
+
+ ```python
+ quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678")
+ print(quad)
+ ```
+ """
+ mosaic = await self._resolve_mosaic(mosaic)
+ mosaic_id = mosaic["id"]
+ resp = await self._session.request(
+ method="GET",
+ url=self._url(f"mosaics/{mosaic_id}/quads/{quad_id}"),
+ )
+ return Quad(resp.json())
+
+ async def get_quad_contributions(self, quad: Quad) -> list[dict]:
+ """
+ Get a mosaic's quad information.
+
+ Example:
+
+ ```python
+ 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: Quad,
+ *,
+ directory: str = ".",
+ overwrite: bool = False,
+ progress_bar: bool = False):
+ """
+ Download a quad to a directory.
+
+ 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:
+ await StreamingBody(resp).write(
+ dest,
+ # pass along despite our manual handling
+ overwrite=overwrite,
+ progress_bar=progress_bar)
+
+ async 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.
+
+ 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, 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"]
+ async for q in self.list_quads(mosaic,
+ minimal=True,
+ bbox=bbox,
+ geometry=geometry):
+ jobs.append(
+ self.download_quad(q,
+ directory=directory,
+ overwrite=overwrite,
+ progress_bar=progress_bar))
+ if len(jobs) == concurrency:
+ await asyncio.gather(*jobs)
+ jobs = []
+ await asyncio.gather(*jobs)
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 53f626d82..f971bb8d2 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
@@ -489,44 +483,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/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"""
diff --git a/planet/subscription_request.py b/planet/subscription_request.py
index 4aa4b5ead..72b36e80e 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
@@ -277,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][].
@@ -297,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
@@ -320,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",
@@ -348,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),
}
@@ -364,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
@@ -638,41 +624,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/planet/sync/client.py b/planet/sync/client.py
index 39b38c5ab..993b35271 100644
--- a/planet/sync/client.py
+++ b/planet/sync/client.py
@@ -7,6 +7,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"
@@ -39,18 +40,29 @@ 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.destinations = DestinationsAPI(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.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/")
+ self.features = FeaturesAPI(self._session,
+ f"{planet_base}/features/v1/ogc/my/")
diff --git a/planet/sync/mosaics.py b/planet/sync/mosaics.py
new file mode 100644
index 000000000..9134db687
--- /dev/null
+++ b/planet/sync/mosaics.py
@@ -0,0 +1,277 @@
+# 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.
+
+ 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.
+
+ Parameters:
+ name_or_id: The name or id of the mosaic
+ """
+ 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:
+
+ ```python
+ 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:
+
+ ```python
+ 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:
+
+ ```python
+ 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 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:
+
+ ```python
+ mosaic = client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5")
+ quads = client.list_quads(mosaic)
+ for q in quads:
+ print(q)
+ ```
+ """
+ return self._client._aiter_to_iter(
+ self._client.list_quads(
+ mosaic,
+ minimal=minimal,
+ full_extent=full_extent,
+ bbox=bbox,
+ geometry=geometry,
+ ))
+
+ def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad:
+ """
+ Get a mosaic's quad information.
+
+ Example:
+
+ ```python
+ quad = 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:
+
+ ```python
+ quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678")
+ contributions = 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:
+
+ ```python
+ quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678")
+ 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:
+
+ ```python
+ mosaic = 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/pyproject.toml b/pyproject.toml
index 59ef0923a..770df1d34 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,6 +14,7 @@ dependencies = [
"pyjwt>=2.1",
"tqdm>=4.56",
"typing-extensions",
+ "planet-auth>=2.1.0",
]
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 e91e27e59..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
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_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_features_api.py b/tests/integration/test_features_api.py
index 1192244a3..4e6202825 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/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..1b28ca2a8
--- /dev/null
+++ b/tests/integration/test_mosaics_cli.py
@@ -0,0 +1,407 @@
+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}")
+ },
+ "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",
+ }) 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&minimal=true&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/456-7890.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/456-7890.tif",
+ "a mosaic/456-7891.tif",
+ "a mosaic/456-7892.tif",
+ "a mosaic/456-7893.tif",
+ "a mosaic/456-7894.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
diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py
index b911f7daa..f252e3ce6 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):
@@ -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/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..b6f8ce8e3 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,211 @@ 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'
+ 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()
- test_auth = auth.Auth.from_login('email', 'pw')
- assert test_auth.value == auth_data
+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)
-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(
+ )._devicecode_client_config.auth_server(
+ ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_USER[
+ "auth_server"]
- with open(secret_path, 'r') as fp:
- assert json.loads(fp.read()) == {"key": "test"}
+ assert under_test._plauth.auth_client(
+ )._devicecode_client_config.client_id(
+ ) == planet.auth_builtins._SDK_CLIENT_ID_PROD
-def test_Auth_store_exists(tmp_path):
- secret_path = str(tmp_path / '.test')
+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)
- with open(secret_path, 'w') as fp:
- fp.write('{"existing": "exists"}')
+ 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"]
- test_auth = auth.Auth.from_key('test')
- test_auth.store(secret_path)
+ assert under_test._plauth.auth_client()._authcode_client_config.client_id(
+ ) == "mock_client_id__auth_code_client"
- with open(secret_path, 'r') as fp:
- assert json.loads(fp.read()) == {"key": "test", "existing": "exists"}
+ assert under_test._plauth.auth_client()._authcode_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_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)
-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(
+ )._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_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"
+
+
+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)
+
+ 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(
+ ) == "mock_client_id__device_code_client"
+
+ assert under_test._plauth.auth_client()._devicecode_client_config.scopes(
+ ) == planet.auth_builtins._OIDC_AUTH_CLIENT_CONFIG__USER_SKEL["scopes"]
+
+
+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)
+
+ 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..86ae35764 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.
+ # 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_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"
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={}))
diff --git a/tests/unit/test_subscription_request.py b/tests/unit/test_subscription_request.py
index deafbf94e..858cc6c26 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')
@@ -567,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")