From 25fd5f0f6c33954a45f73b81974f7154cad8596e Mon Sep 17 00:00:00 2001 From: Regan-Koopmans Date: Wed, 9 Sep 2026 13:33:50 +0200 Subject: [PATCH] Pydantic models and drift detection for Destinations API - Generate Pydantic models from the Destinations API OpenAPI spec (planet/api_models/destinations.py) - Use Destination/DestinationsResponse models as return types in DestinationsClient - Add pre-release model validation in tests/drift/validate_models.py - Add nox sessions: generate_models, validate_models - Wire validate_models into the publish-pypi CI workflow - Scope everything to the Destinations API for now; other APIs noted as TODO --- .github/workflows/publish-pypi.yml | 6 +- noxfile.py | 82 ++++- planet/api_models/__init__.py | 0 planet/api_models/destinations.py | 344 +++++++++++++++++++++ planet/cli/destinations.py | 16 +- planet/clients/destinations.py | 56 ++-- planet/sync/destinations.py | 22 +- pyproject.toml | 5 + tests/drift/validate_models.py | 92 ++++++ tests/integration/test_destinations_api.py | 31 +- tests/integration/test_destinations_cli.py | 50 ++- 11 files changed, 637 insertions(+), 67 deletions(-) create mode 100644 planet/api_models/__init__.py create mode 100644 planet/api_models/destinations.py create mode 100644 tests/drift/validate_models.py diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 3aa701868..e45e53a08 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -24,9 +24,13 @@ jobs: restore-keys: | ${{ runner.os }}-pip - - name: Build, verify, and upload to PyPI + - name: Validate models against live API specs run: | pip install --upgrade nox + nox -s validate_models + + - name: Build, verify, and upload to PyPI + run: | nox -s build publish_pypi env: TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} diff --git a/noxfile.py b/noxfile.py index 1dc50a244..9f0c64cea 100644 --- a/noxfile.py +++ b/noxfile.py @@ -9,6 +9,8 @@ nox.options.sessions = ['lint', 'analyze', 'test', 'coverage', 'docs'] source_files = ("planet", "examples", "tests", "setup.py", "noxfile.py") +# Generated code — excluded from linting and formatting checks +generated_dirs = ("planet/api_models", ) BUILD_DIRS = ['build', 'dist'] @@ -17,7 +19,11 @@ def analyze(session): session.install(".[lint]") - session.run("mypy", "--ignore-missing", "planet") + session.run("mypy", + "--ignore-missing", + "--exclude", + "|".join(generated_dirs), + "planet") @nox.session @@ -63,8 +69,9 @@ def test(session): def lint(session): session.install("-e", ".[lint]") - session.run("flake8", *source_files) - session.run('yapf', '--diff', '-r', *source_files) + exclude = ",".join(generated_dirs) + session.run("flake8", f"--exclude={exclude}", *source_files) + session.run('yapf', '--diff', '-r', f'--exclude={exclude}', *source_files) @nox.session @@ -114,6 +121,75 @@ def examples(session): session.run('pytest', '--no-cov', 'examples/', '-s', *options) +@nox.session +def generate_models(session): + """Re-generate Pydantic models for the Destinations API in planet/api_models/. + + Requires datamodel-code-generator to be available on PATH: + uv tool install 'datamodel-code-generator[http]' + + Run after a known API spec change to refresh the models, then re-run + validate_models to confirm compatibility. + """ + # TODO: extend to other APIs as Pydantic models are adopted: + # "subscriptions": "https://api.planet.com/subscriptions/v1/spec", + # "orders": "https://api.planet.com/compute/ops/spec", + # "data": "https://api.planet.com/data/v1/spec", + specs = { + "destinations": "https://api.planet.com/destinations/v1/spec", + } + + header = ("# flake8: noqa\n" + "# fmt: off\n" + "# Generated code — do not edit manually.\n" + "# To regenerate, run:\n" + "# nox -s generate_models\n" + "# Requires: uv tool install 'datamodel-code-generator[http]'") + + common_args = [ + "--output-model-type", + "pydantic_v2.BaseModel", + "--custom-file-header", + header, + "--formatters", + "builtin", + ] + + for name, url in specs.items(): + session.run( + "datamodel-codegen", + "--url", + url, + "--input-file-type", + "openapi", + "--output", + f"planet/api_models/{name}.py", + *common_args, + external=True, + ) + + +@nox.session +def validate_models(session): + """Validate committed Pydantic models match the live API specs. + + Fetches live OpenAPI specs from Planet's API and compares against committed + snapshots. Fails if any spec has changed. No API key required. + + To refresh snapshots after a deliberate API change, run: + nox -s generate_models + Intended as a pre-release gate; not included in the default nox session list. + """ + session.install("-e", ".[validate_models]") + session.run( + "pytest", + "tests/drift/validate_models.py", + "-v", + "--no-cov", + "--tb=short", + ) + + @nox.session def build(session): """Build package""" diff --git a/planet/api_models/__init__.py b/planet/api_models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/planet/api_models/destinations.py b/planet/api_models/destinations.py new file mode 100644 index 000000000..11aac712f --- /dev/null +++ b/planet/api_models/destinations.py @@ -0,0 +1,344 @@ +# flake8: noqa +# fmt: off +# Generated code — do not edit manually. +# To regenerate, run: +# nox -s generate_models +# Requires: uv tool install 'datamodel-code-generator[http]' + +from enum import Enum + +from typing import Annotated +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, StringConstraints + + +class AmazonS3Params(BaseModel): + model_config = ConfigDict(extra='forbid', ) + aws_access_key_id: str = Field( + ..., + description='AWS access key ID for authentication with Amazon S3.') + aws_region: str = Field( + ..., description='The AWS region where the S3 bucket is located.') + aws_secret_access_key: str = Field( + ..., + description='AWS secret access key for authentication with Amazon S3.') + bucket: str = Field( + ..., + description= + 'The name of the Amazon S3 bucket where data will be delivered.', + ) + explicit_sse: bool | None = Field( + False, + description='Enable explicit server-side encryption headers for SSE-S3.' + ) + + +class AmazonS3PatchParams(BaseModel): + model_config = ConfigDict(extra='forbid', ) + aws_access_key_id: str = Field( + ..., + description='AWS access key ID for authentication with Amazon S3.') + aws_secret_access_key: str = Field( + ..., + description='AWS secret access key for authentication with Amazon S3.') + explicit_sse: bool | None = Field( + False, + description='Enable explicit server-side encryption headers for SSE-S3.' + ) + + +class AzureCloudStorageParams(BaseModel): + model_config = ConfigDict(extra='forbid', ) + account: str = Field( + ..., + description= + 'The name of the Azure Storage account where data will be delivered.', + ) + container: str = Field( + ..., + description= + 'The name of the Azure Blob Storage container within the account.', + ) + sas_token: str = Field( + ..., + description= + 'Shared Access Signature (SAS) token for authentication with Azure Storage.', + ) + storage_endpoint_suffix: str | None = Field( + None, + description= + 'The storage endpoint suffix for the Azure Storage service (optional).', + ) + + +class AzureCloudStoragePatchParams(BaseModel): + model_config = ConfigDict(extra='forbid', ) + sas_token: str = Field( + ..., + description= + 'Shared Access Signature (SAS) token for authentication with Azure Storage.', + ) + + +class DefaultDestinationRequest(BaseModel): + model_config = ConfigDict(extra='forbid', ) + destination_id: str = Field( + ..., description='The ID of the default destination.') + + +class DestinationType(Enum): + google_cloud_storage = 'google_cloud_storage' + amazon_s3 = 'amazon_s3' + azure_blob_storage = 'azure_blob_storage' + oracle_cloud_storage = 'oracle_cloud_storage' + s3_compatible = 's3_compatible' + + +class Error(BaseModel): + code: int + message: str + + +class GoogleCloudStorageParams(BaseModel): + model_config = ConfigDict(extra='forbid', ) + bucket: str = Field( + ..., + description= + 'The name of the Google Cloud Storage bucket where data will be delivered.', + ) + credentials: str = Field( + ..., + description= + "Base64-encoded service account JSON credentials for Google Cloud Storage access.\n\nTo encode the credentials: `cat service-account.json | base64 | tr -d '\\n'`\n", + ) + + +class GoogleCloudStoragePatchParams(BaseModel): + model_config = ConfigDict(extra='forbid', ) + credentials: str = Field( + ..., + description= + "Base64-encoded service account JSON credentials for Google Cloud Storage access.\n\nTo encode the credentials: `cat service-account.json | base64 | tr -d '\\n'`\n", + ) + + +class Links(BaseModel): + field_self: str = Field( + ..., + alias='_self', + description='RFC 3986 URI representing the location of this object.', + ) + + +class OracleCloudStorageParams(BaseModel): + model_config = ConfigDict(extra='forbid', ) + bucket: str = Field( + ..., + description= + 'The name of the Oracle Cloud Storage bucket where data will be delivered.', + ) + customer_access_key_id: str = Field( + ..., + description= + 'Customer access key ID for authentication with Oracle Cloud Storage.', + ) + customer_secret_key: str = Field( + ..., + description= + 'Customer secret key for authentication with Oracle Cloud Storage.', + ) + namespace: str = Field( + ..., + description= + 'The Oracle Object Storage namespace that contains the bucket.') + region: str = Field( + ..., + description='The Oracle Cloud region where the bucket is located.') + + +class OracleCloudStoragePatchParams(BaseModel): + model_config = ConfigDict(extra='forbid', ) + customer_access_key_id: str = Field( + ..., + description= + 'Customer access key ID for authentication with Oracle Cloud Storage.', + ) + customer_secret_key: str = Field( + ..., + description= + 'Customer secret key for authentication with Oracle Cloud Storage.', + ) + + +class Ownership(BaseModel): + is_owner: bool = Field( + ..., description='True if the user is the creator of the destination.') + owner_id: int = Field( + ..., description='The ID of the user who created the destination.') + + +class Permissions(BaseModel): + can_write: bool = Field( + ..., + description='True if the user can write to the destination (patch).') + + +class S3CompatibleParams(BaseModel): + model_config = ConfigDict(extra='forbid', ) + access_key_id: str = Field( + ..., + description= + 'Access key ID for authentication with the S3-compatible service.', + ) + bucket: str = Field( + ..., + description= + 'The name of the S3-compatible bucket where data will be delivered.', + ) + endpoint: str = Field( + ..., + description='The URL endpoint for the S3-compatible storage service.') + region: str = Field( + ..., + description= + 'The region identifier for the S3-compatible storage service.') + secret_access_key: str = Field( + ..., + description= + 'Secret access key for authentication with the S3-compatible service.', + ) + use_path_style: bool | None = Field( + False, + description= + 'Use path-style URL addressing with the bucket name in the URL path.', + ) + + +class S3CompatiblePatchParams(BaseModel): + model_config = ConfigDict(extra='forbid', ) + access_key_id: str = Field( + ..., + description= + 'Access key ID for authentication with the S3-compatible service.', + ) + secret_access_key: str = Field( + ..., + description= + 'Secret access key for authentication with the S3-compatible service.', + ) + use_path_style: bool | None = Field( + False, + description= + 'Use path-style URL addressing with the bucket name in the URL path.', + ) + + +class DestinationParameters(RootModel[GoogleCloudStorageParams + | AmazonS3Params + | AzureCloudStorageParams + | OracleCloudStorageParams + | S3CompatibleParams]): + root: (GoogleCloudStorageParams + | AmazonS3Params + | AzureCloudStorageParams + | OracleCloudStorageParams + | S3CompatibleParams) = Field( + ..., description='Parameters for the given Destination type.') + + +class DestinationPatchParameters(RootModel[GoogleCloudStoragePatchParams + | AmazonS3PatchParams + | AzureCloudStoragePatchParams + | OracleCloudStoragePatchParams + | S3CompatiblePatchParams]): + root: (GoogleCloudStoragePatchParams + | AmazonS3PatchParams + | AzureCloudStoragePatchParams + | OracleCloudStoragePatchParams + | S3CompatiblePatchParams) = Field( + ..., + description='Patch parameters for the given Destination type.') + + +class DestinationPatchRequest1(BaseModel): + model_config = ConfigDict(extra='forbid', ) + archive: bool | None = Field( + None, + description='True to archive the destination, false to unarchive.') + name: Annotated[ + str, StringConstraints(min_length=3, max_length=63)] | None = Field( + None, description='A string to uniquely identify a Destination.') + parameters: DestinationPatchParameters + + +class DestinationPatchRequest2(BaseModel): + model_config = ConfigDict(extra='forbid', ) + archive: bool = Field( + ..., + description='True to archive the destination, false to unarchive.') + name: Annotated[ + str, StringConstraints(min_length=3, max_length=63)] | None = Field( + None, description='A string to uniquely identify a Destination.') + parameters: DestinationPatchParameters | None = None + + +class DestinationPatchRequest3(BaseModel): + model_config = ConfigDict(extra='forbid', ) + archive: bool | None = Field( + None, + description='True to archive the destination, false to unarchive.') + name: Annotated[ + str, StringConstraints(min_length=3, max_length=63)] = Field( + ..., description='A string to uniquely identify a Destination.') + parameters: DestinationPatchParameters | None = None + + +class DestinationPatchRequest(RootModel[DestinationPatchRequest1 + | DestinationPatchRequest2 + | DestinationPatchRequest3]): + root: ( + DestinationPatchRequest1 | DestinationPatchRequest2 + | DestinationPatchRequest3 + ) = Field( + ..., + description= + 'A DestinationPatchRequest is an object describing how to update a Destination.', + title='Destination patch request') + + +class DestinationRequest(BaseModel): + model_config = ConfigDict(extra='forbid', ) + name: Annotated[ + str, StringConstraints(min_length=3, max_length=63)] | None = Field( + None, description='A name given to this Destination.') + parameters: DestinationParameters + type: DestinationType + + +class Destination(BaseModel): + field_links: Links = Field(..., alias='_links') + archived: AwareDatetime | None = Field( + None, description='Timestamp when the Destination was archived.') + created: AwareDatetime = Field( + ..., description='Timestamp when the Destination was created.') + default: bool | None = Field( + None, + description= + 'True if this is the default destination for the organization.') + id: str = Field(..., + description='A string to uniquely identify a Destination.') + name: str = Field(..., description='A name given to this Destination.') + ownership: Ownership + parameters: DestinationParameters + permissions: Permissions + pl_ref: str = Field(..., + alias='pl:ref', + description='A reference for the destination.') + type: DestinationType + updated: AwareDatetime = Field( + ..., description='Timestamp when the Destination was last updated.') + + +class DestinationsResponse(BaseModel): + field_links: Links = Field(..., alias='_links') + destinations: list[Destination] = Field( + ..., description='Array of Destinations.') diff --git a/planet/cli/destinations.py b/planet/cli/destinations.py index ed3a25131..5c37e706f 100644 --- a/planet/cli/destinations.py +++ b/planet/cli/destinations.py @@ -31,7 +31,7 @@ async def _patch_destination(ctx, destination_id, data, pretty): async with destinations_client(ctx) as cl: try: response = await cl.patch_destination(destination_id, data) - echo_json(response, pretty) + echo_json(response.model_dump(mode='json', by_alias=True), pretty) except Exception as e: raise ClickException(f"Failed to patch destination: {e}") @@ -48,7 +48,7 @@ async def _list_destinations(ctx, is_owner, can_write, is_default) - echo_json(response, pretty) + echo_json(response.model_dump(mode='json', by_alias=True), pretty) except Exception as e: raise ClickException(f"Failed to list destinations: {e}") @@ -57,7 +57,7 @@ async def _get_destination(ctx, destination_id, pretty): async with destinations_client(ctx) as cl: try: response = await cl.get_destination(destination_id) - echo_json(response, pretty) + echo_json(response.model_dump(mode='json', by_alias=True), pretty) except Exception as e: raise ClickException(f"Failed to get destination: {e}") @@ -66,7 +66,7 @@ async def _create_destination(ctx, data, pretty): async with destinations_client(ctx) as cl: try: response = await cl.create_destination(data) - echo_json(response, pretty) + echo_json(response.model_dump(mode='json', by_alias=True), pretty) except Exception as e: raise ClickException(f"Failed to create destination: {e}") @@ -75,7 +75,7 @@ async def _set_default_destination(ctx, destination_id, pretty): async with destinations_client(ctx) as cl: try: response = await cl.set_default_destination(destination_id) - echo_json(response, pretty) + echo_json(response.model_dump(mode='json', by_alias=True), pretty) except Exception as e: raise ClickException(f"Failed to set default destination: {e}") @@ -84,7 +84,9 @@ async def _unset_default_destination(ctx, pretty): async with destinations_client(ctx) as cl: try: response = await cl.unset_default_destination() - echo_json(response, pretty) + if response is not None: + echo_json(response.model_dump(mode='json', by_alias=True), + pretty) except Exception as e: raise ClickException(f"Failed to unset default destination: {e}") @@ -93,7 +95,7 @@ async def _get_default_destination(ctx, pretty): async with destinations_client(ctx) as cl: try: response = await cl.get_default_destination() - echo_json(response, pretty) + echo_json(response.model_dump(mode='json', by_alias=True), pretty) except Exception as e: raise ClickException(f"Failed to get default destination: {e}") diff --git a/planet/clients/destinations.py b/planet/clients/destinations.py index 1d1f0dad0..7fdd046d5 100644 --- a/planet/clients/destinations.py +++ b/planet/clients/destinations.py @@ -13,19 +13,18 @@ # the License. import logging -from typing import Any, Dict, Optional, TypeVar +from typing import Any, Dict, Optional from planet.clients.base import _BaseClient from planet.exceptions import APIError, ClientError from planet.http import Session +from ..api_models.destinations import Destination, DestinationsResponse from ..constants import PLANET_BASE_URL BASE_URL = f'{PLANET_BASE_URL}/destinations/v1/' LOGGER = logging.getLogger() -T = TypeVar("T") - DEFAULT_DESTINATION_REF = "pl:destinations/default" @@ -57,11 +56,12 @@ def __init__(self, """ super().__init__(session, base_url or BASE_URL) - async def list_destinations(self, - archived: Optional[bool] = None, - is_owner: Optional[bool] = None, - can_write: Optional[bool] = None, - is_default: Optional[bool] = None) -> Dict: + async def list_destinations( + self, + archived: Optional[bool] = None, + is_owner: Optional[bool] = None, + can_write: Optional[bool] = None, + is_default: Optional[bool] = None) -> DestinationsResponse: """ List all destinations. By default, all non-archived destinations in the requesting user's org are returned. @@ -72,7 +72,7 @@ async def list_destinations(self, is_default (bool): If True, include only the default destination. Returns: - dict: A dictionary containing the list of destinations inside the 'destinations' key. + DestinationsResponse: The list of destinations. Raises: APIError: If the API returns an error response. @@ -97,10 +97,9 @@ async def list_destinations(self, except ClientError: # pragma: no cover raise else: - dest_response = response.json() - return dest_response + return DestinationsResponse.model_validate(response.json()) - async def get_destination(self, destination_id: str) -> Dict: + async def get_destination(self, destination_id: str) -> Destination: """ Get a specific destination by its ID. @@ -108,7 +107,7 @@ async def get_destination(self, destination_id: str) -> Dict: destination_id (str): The ID of the destination to retrieve. Returns: - dict: A dictionary containing the destination details. + Destination: The destination details. Raises: APIError: If the API returns an error response. @@ -122,12 +121,11 @@ async def get_destination(self, destination_id: str) -> Dict: except ClientError: # pragma: no cover raise else: - dest = response.json() - return dest + return Destination.model_validate(response.json()) async def patch_destination(self, destination_id: str, - request: Dict[str, Any]) -> Dict: + request: Dict[str, Any]) -> Destination: """ Update a specific destination by its ID. @@ -136,7 +134,7 @@ async def patch_destination(self, request (dict): Destination content to update, only attributes to update are required. Returns: - dict: A dictionary containing the updated destination details. + Destination: The updated destination details. Raises: APIError: If the API returns an error response. @@ -152,10 +150,9 @@ async def patch_destination(self, except ClientError: # pragma: no cover raise else: - dest = response.json() - return dest + return Destination.model_validate(response.json()) - async def create_destination(self, request: Dict[str, Any]) -> Dict: + async def create_destination(self, request: Dict[str, Any]) -> Destination: """ Create a new destination. @@ -163,7 +160,7 @@ async def create_destination(self, request: Dict[str, Any]) -> Dict: request (dict): Destination content to create, all attributes are required. Returns: - dict: A dictionary containing the created destination details. + Destination: The created destination details. Raises: APIError: If the API returns an error response. @@ -178,10 +175,10 @@ async def create_destination(self, request: Dict[str, Any]) -> Dict: except ClientError: # pragma: no cover raise else: - dest = response.json() - return dest + return Destination.model_validate(response.json()) - async def set_default_destination(self, destination_id: str) -> Dict: + async def set_default_destination(self, + destination_id: str) -> Destination: """ Set an existing destination as the default destination. Default destinations are globally available to all members of an organization. An organization can have zero or one default destination at any time. @@ -191,7 +188,7 @@ async def set_default_destination(self, destination_id: str) -> Dict: destination_id (str): The ID of the destination to set as default. Returns: - dict: A dictionary containing the default destination details. + Destination: The default destination details. Raises: APIError: If the API returns an error response. @@ -208,7 +205,7 @@ async def set_default_destination(self, destination_id: str) -> Dict: except ClientError: # pragma: no cover raise else: - return response.json() + return Destination.model_validate(response.json()) async def unset_default_destination(self) -> None: """ @@ -230,13 +227,13 @@ async def unset_default_destination(self) -> None: except ClientError: # pragma: no cover raise - async def get_default_destination(self) -> Dict: + async def get_default_destination(self) -> Destination: """ Get the current default destination. The default destination is globally available to all members of an organization. Returns: - dict: A dictionary containing the default destination details. + Destination: The default destination details. Raises: APIError: If the API returns an error response. @@ -250,5 +247,4 @@ async def get_default_destination(self) -> Dict: except ClientError: # pragma: no cover raise else: - dest = response.json() - return dest + return Destination.model_validate(response.json()) diff --git a/planet/sync/destinations.py b/planet/sync/destinations.py index a95b2f977..b688c792c 100644 --- a/planet/sync/destinations.py +++ b/planet/sync/destinations.py @@ -14,6 +14,7 @@ from typing import Any, Dict, Optional from planet.clients.destinations import DestinationsClient +from planet.api_models.destinations import Destination, DestinationsResponse from planet.http import Session @@ -31,11 +32,12 @@ def __init__(self, session: Session, base_url: Optional[str] = None): self._client = DestinationsClient(session, base_url) - def list_destinations(self, - archived: Optional[bool] = None, - is_owner: Optional[bool] = None, - can_write: Optional[bool] = None, - is_default: Optional[bool] = None) -> Dict: + def list_destinations( + self, + archived: Optional[bool] = None, + is_owner: Optional[bool] = None, + can_write: Optional[bool] = None, + is_default: Optional[bool] = None) -> DestinationsResponse: """ List all destinations. By default, all non-archived destinations in the requesting user's org are returned. @@ -58,7 +60,7 @@ def list_destinations(self, can_write, is_default)) - def get_destination(self, destination_id: str) -> Dict: + def get_destination(self, destination_id: str) -> Destination: """ Get a specific destination by its ID. @@ -76,7 +78,7 @@ def get_destination(self, destination_id: str) -> Dict: self._client.get_destination(destination_id)) def patch_destination(self, destination_ref: str, - request: Dict[str, Any]) -> Dict: + request: Dict[str, Any]) -> Destination: """ Update a specific destination by its ref. @@ -94,7 +96,7 @@ def patch_destination(self, destination_ref: str, return self._client._call_sync( self._client.patch_destination(destination_ref, request)) - def create_destination(self, request: Dict[str, Any]) -> Dict: + def create_destination(self, request: Dict[str, Any]) -> Destination: """ Create a new destination. @@ -111,7 +113,7 @@ def create_destination(self, request: Dict[str, Any]) -> Dict: return self._client._call_sync( self._client.create_destination(request)) - def set_default_destination(self, destination_id: str) -> Dict: + def set_default_destination(self, destination_id: str) -> Destination: """ Set an existing destination as the default destination. Default destinations are globally available to all members of an organization. An organization can have zero or one default destination at any time. @@ -145,7 +147,7 @@ def unset_default_destination(self) -> None: return self._client._call_sync( self._client.unset_default_destination()) - def get_default_destination(self) -> Dict: + def get_default_destination(self) -> Destination: """ Get the current default destination. The default destination is globally available to all members of an organization. diff --git a/pyproject.toml b/pyproject.toml index 04837eca5..c1f6ae592 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "geojson", "httpx>=0.28.0", "jsonschema", + "pydantic>=2.0", "pyjwt>=2.1", "tqdm>=4.56", "typing-extensions", @@ -41,6 +42,10 @@ test = [ "respx>=0.22.0", "coverage[toml]" ] +validate_models = [ + "pytest==8.3.3", + "datamodel-code-generator[http]>=0.25", +] lint = [ "flake8", "mypy", diff --git a/tests/drift/validate_models.py b/tests/drift/validate_models.py new file mode 100644 index 000000000..232625e03 --- /dev/null +++ b/tests/drift/validate_models.py @@ -0,0 +1,92 @@ +# Copyright 2024 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. +"""Pre-release drift detection: regenerate Pydantic models and diff against committed files. + +How it works: + - datamodel-codegen fetches the live OpenAPI spec and generates models into a temp file. + - The output is compared against the committed file in planet/api_models/. + - The test fails if they differ, indicating the spec has changed. + +When a test fails: + 1. Review what changed in the spec. + 2. Regenerate the committed models: + nox -s generate_models + 3. Update the client code if the API change requires it. + 4. Commit the updated models. +""" +import pathlib +import subprocess +import tempfile + +import pytest + +REPO_ROOT = pathlib.Path(__file__).parent.parent.parent +MODELS_DIR = REPO_ROOT / "planet" / "api_models" + +HEADER = ("# flake8: noqa\n" + "# fmt: off\n" + "# Generated code — do not edit manually.\n" + "# To regenerate, run:\n" + "# nox -s generate_models\n" + "# Requires: uv tool install 'datamodel-code-generator[http]'") + +SPECS = { + "destinations": "https://api.planet.com/destinations/v1/spec", +} + + +def _regenerate(url: str, output: pathlib.Path) -> None: + result = subprocess.run( + [ + "datamodel-codegen", + "--url", + url, + "--input-file-type", + "openapi", + "--output", + str(output), + "--output-model-type", + "pydantic_v2.BaseModel", + "--custom-file-header", + HEADER, + "--formatters", + "builtin", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + pytest.fail(f"datamodel-codegen failed:\n{result.stderr}") + + +@pytest.mark.parametrize("name,url", SPECS.items()) +def test_models_match_spec(name, url): + committed = MODELS_DIR / f"{name}.py" + + with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as tmp: + tmp_path = pathlib.Path(tmp.name) + + try: + _regenerate(url, tmp_path) + + generated = tmp_path.read_text() + current = committed.read_text() + + if generated != current: + pytest.fail( + f"planet/api_models/{name}.py is out of date with the live spec.\n" + f"Run `nox -s generate_models` to regenerate, then commit the result." + ) + finally: + tmp_path.unlink(missing_ok=True) diff --git a/tests/integration/test_destinations_api.py b/tests/integration/test_destinations_api.py index af702b223..d395edf46 100644 --- a/tests/integration/test_destinations_api.py +++ b/tests/integration/test_destinations_api.py @@ -20,6 +20,7 @@ from planet import DestinationsClient, Session from planet.auth import Auth from planet.sync.destinations import DestinationsAPI +from planet.api_models.destinations import Destination, DestinationsResponse pytestmark = pytest.mark.anyio @@ -107,8 +108,11 @@ def construct_list_response(destinations): async def test_list_destinations(): mock_response(TEST_URL, construct_list_response(DEST_LIST)) + expected = DestinationsResponse.model_validate( + construct_list_response(DEST_LIST)) + def assertf(resp): - assert resp == construct_list_response(DEST_LIST) + assert resp == expected assertf(await cl_async.list_destinations()) assertf(cl_sync.list_destinations()) @@ -119,8 +123,11 @@ async def test_list_destinations_filtering(): mock_response(f"{TEST_URL}?archived=false&is_owner=true", construct_list_response([DEST_1])) + expected = DestinationsResponse.model_validate( + construct_list_response([DEST_1])) + def assertf(resp): - assert resp == construct_list_response([DEST_1]) + assert resp == expected assertf(await cl_async.list_destinations(archived=False, is_owner=True)) assertf(cl_sync.list_destinations(archived=False, is_owner=True)) @@ -132,8 +139,10 @@ async def test_get_destination(): url = f"{TEST_URL}/{id}" mock_response(url, DEST_1) + expected = Destination.model_validate(DEST_1) + def assertf(resp): - assert resp == DEST_1 + assert resp == expected assertf(await cl_async.get_destination(id)) assertf(cl_sync.get_destination(id)) @@ -146,8 +155,10 @@ async def test_create_destination(): method="post", status_code=HTTPStatus.CREATED) + expected = Destination.model_validate(DEST_1) + def assertf(resp): - assert resp == DEST_1 + assert resp == expected assertf(await cl_async.create_destination(DEST_1_REQ_PAYLOAD)) assertf(cl_sync.create_destination(DEST_1_REQ_PAYLOAD)) @@ -159,8 +170,10 @@ async def test_patch_destination(): url = f"{TEST_URL}/{id}" mock_response(url, DEST_2, method="patch") + expected = Destination.model_validate(DEST_2) + def assertf(resp): - assert resp == DEST_2 + assert resp == expected assertf(await cl_async.patch_destination(id, DEST_2_PATCH_PAYLOAD)) assertf(cl_sync.patch_destination(id, DEST_2_PATCH_PAYLOAD)) @@ -187,8 +200,10 @@ async def test_set_default_destination(): url = f"{TEST_URL}/default" mock_response(url, DEST_1, method="put") + expected = Destination.model_validate(DEST_1) + def assertf(resp): - assert resp == DEST_1 + assert resp == expected assertf(await cl_async.set_default_destination(id)) assertf(cl_sync.set_default_destination(id)) @@ -216,8 +231,10 @@ async def test_get_default_destination(): url = f"{TEST_URL}/default" mock_response(url, DEST_1) + expected = Destination.model_validate(DEST_1) + def assertf(resp): - assert resp == DEST_1 + assert resp == expected assertf(await cl_async.get_default_destination()) assertf(cl_sync.get_default_destination()) diff --git a/tests/integration/test_destinations_cli.py b/tests/integration/test_destinations_cli.py index f975989b8..76c137aae 100644 --- a/tests/integration/test_destinations_cli.py +++ b/tests/integration/test_destinations_cli.py @@ -22,6 +22,38 @@ TEST_DESTINATIONS_URL = 'https://api.planet.com/destinations/v1' +DEST = { + "id": "fake-dest-id", + "name": "Fake Destination", + "type": "amazon_s3", + "parameters": { + "bucket": "my-bucket", + "aws_region": "us-west-2", + "aws_access_key_id": "key", + "aws_secret_access_key": "secret" + }, + "created": "2024-01-01T00:00:00Z", + "updated": "2024-01-01T00:00:00Z", + "pl:ref": "pl:destinations/fake-dest-id", + "_links": { + "_self": "https://api.planet.com/destinations/v1/fake-dest-id" + }, + "archived": None, + "permissions": { + "can_write": True + }, + "ownership": { + "is_owner": True, "owner_id": 1 + } +} + +DEST_LIST = { + "destinations": [DEST], + "_links": { + "_self": "https://api.planet.com/destinations/v1" + } +} + @pytest.fixture def invoke(): @@ -37,7 +69,7 @@ def _invoke(extra_args, runner=None): @respx.mock def test_destinations_cli_archive(invoke): url = f"{TEST_DESTINATIONS_URL}/fake-dest-id" - respx.patch(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + respx.patch(url).return_value = httpx.Response(HTTPStatus.OK, json=DEST) result = invoke(['archive', 'fake-dest-id']) assert result.exit_code == 0 @@ -46,7 +78,7 @@ def test_destinations_cli_archive(invoke): @respx.mock def test_destinations_cli_create(invoke): respx.post(TEST_DESTINATIONS_URL).return_value = httpx.Response( - HTTPStatus.ACCEPTED, json={}) + HTTPStatus.ACCEPTED, json=DEST) # azure result = invoke([ @@ -139,7 +171,7 @@ def test_destinations_cli_create(invoke): @respx.mock def test_destinations_cli_get(invoke): url = f"{TEST_DESTINATIONS_URL}/fake-dest-id" - respx.get(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + respx.get(url).return_value = httpx.Response(HTTPStatus.OK, json=DEST) result = invoke(['get', 'fake-dest-id']) assert result.exit_code == 0 @@ -148,7 +180,7 @@ def test_destinations_cli_get(invoke): @respx.mock def test_destinations_cli_rename(invoke): url = f"{TEST_DESTINATIONS_URL}/fake-dest-id" - respx.patch(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + respx.patch(url).return_value = httpx.Response(HTTPStatus.OK, json=DEST) result = invoke(['rename', 'fake-dest-id', 'new-name']) assert result.exit_code == 0 @@ -157,7 +189,7 @@ def test_destinations_cli_rename(invoke): @respx.mock def test_destinations_cli_unarchive(invoke): url = f"{TEST_DESTINATIONS_URL}/fake-dest-id" - respx.patch(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + respx.patch(url).return_value = httpx.Response(HTTPStatus.OK, json=DEST) result = invoke(['unarchive', 'fake-dest-id']) assert result.exit_code == 0 @@ -166,7 +198,7 @@ def test_destinations_cli_unarchive(invoke): @respx.mock def test_destinations_cli_list(invoke): respx.get(TEST_DESTINATIONS_URL).return_value = httpx.Response( - HTTPStatus.OK, json={}) + HTTPStatus.OK, json=DEST_LIST) result = invoke(['list']) assert result.exit_code == 0 @@ -203,7 +235,7 @@ def test_destinations_cli_list(invoke): def test_destinations_cli_update(invoke): url = f"{TEST_DESTINATIONS_URL}/fake-dest-id" respx.patch(url).return_value = httpx.Response(HTTPStatus.ACCEPTED, - json={}) + json=DEST) # azure result = invoke( @@ -262,7 +294,7 @@ def test_destinations_cli_update(invoke): @respx.mock def test_destinations_cli_default_set(invoke): url = f"{TEST_DESTINATIONS_URL}/default" - respx.put(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + respx.put(url).return_value = httpx.Response(HTTPStatus.OK, json=DEST) result = invoke(['default', 'set', 'fake-dest-id']) assert result.exit_code == 0 @@ -285,7 +317,7 @@ def test_destinations_cli_default_set_bad_request(invoke): @respx.mock def test_destinations_cli_default_get(invoke): url = f"{TEST_DESTINATIONS_URL}/default" - respx.get(url).return_value = httpx.Response(HTTPStatus.OK, json={}) + respx.get(url).return_value = httpx.Response(HTTPStatus.OK, json=DEST) result = invoke(['default', 'get']) assert result.exit_code == 0