diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 41858b9ff..29cc9915a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,6 +56,28 @@ jobs: pip install --upgrade nox nox -s analyze + validate-models: + name: Validate Models Against Live Specs + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.12 + - name: Pip cache + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip + restore-keys: | + ${{ runner.os }}-pip + - name: Validate models + run: | + pip install --upgrade nox + nox -s validate_models + test: name: Test Python ${{ matrix.python-version }} runs-on: ubuntu-latest diff --git a/README.md b/README.md index acad36f51..d0b5665a1 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,13 @@ The Planet SDK for Python is [hosted on PyPI](https://pypi.org/project/planet/) pip install planet ``` +For optional typed request and response models, generated from Planet's OpenAPI +specs, install the `models` extra. It adds a `pydantic` dependency: + +```console +pip install planet[models] +``` + To install from source, first clone this repository, then navigate to the root directory (where `setup.py` lives) and run: ```console diff --git a/noxfile.py b/noxfile.py index 1dc50a244..9ad94f6ff 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,14 +1,22 @@ +import json from pathlib import Path import shutil +import sys +import tempfile import nox +sys.path.insert(0, str(Path(__file__).parent / "scripts")) +import type_gen # noqa: E402 + nox.options.stop_on_first_error = True nox.options.reuse_existing_virtualenvs = False 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/types", ) BUILD_DIRS = ['build', 'dist'] @@ -17,7 +25,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 +75,13 @@ def test(session): def lint(session): session.install("-e", ".[lint]") - session.run("flake8", *source_files) - session.run('yapf', '--diff', '-r', *source_files) + session.run("flake8", + f"--extend-exclude={','.join(generated_dirs)}", + *source_files) + # yapf --exclude is a repeatable flag taking one fnmatch pattern; a bare + # directory name matches nothing, so the trailing /* is required. + yapf_excludes = [f"--exclude={d}/*" for d in generated_dirs] + session.run('yapf', '--diff', '-r', *yapf_excludes, *source_files) @nox.session @@ -114,6 +131,56 @@ def examples(session): session.run('pytest', '--no-cov', 'examples/', '-s', *options) +@nox.session(python="3.12") +def generate_models(session): + """Re-generate the Pydantic models in planet/types/ from the live specs. + + Output must stay byte-identical to what `nox -s validate_models` + regenerates. Run after a spec change, then re-run validate_models. + """ + session.install("-e", ".[validate_models]") + + for name, url in type_gen.SPECS.items(): + output = type_gen.MODELS_DIR / f"{name}.py" + spec = type_gen.fetch_and_patch_spec(url) + with tempfile.NamedTemporaryFile(suffix=".json", + delete=False, + mode="w") as spec_tmp: + json.dump(spec, spec_tmp) + spec_path = Path(spec_tmp.name) + try: + session.run(*type_gen.codegen_argv(spec_path, output)) + finally: + spec_path.unlink(missing_ok=True) + + +@nox.session(python="3.12") +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 + Runs in PR CI; not included in the default nox session list. + """ + session.install("-e", ".[validate_models]") + session.run( + "pytest", + "tests/drift/validate_models.py", + # Stop conftest discovery below tests/, whose conftest imports the + # full test-suite dependencies that this extra deliberately omits. + "--confcutdir=tests/drift", + # setup.cfg addopts injects --cov, but this extra deliberately omits + # pytest-cov; clear addopts rather than pull in the full test deps. + "-o", + "addopts=", + "-v", + "--tb=short", + ) + + @nox.session def build(session): """Build package""" diff --git a/planet/cli/destinations.py b/planet/cli/destinations.py index ed3a25131..6589a2b9b 100644 --- a/planet/cli/destinations.py +++ b/planet/cli/destinations.py @@ -83,8 +83,8 @@ async def _set_default_destination(ctx, destination_id, pretty): 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) + await cl.unset_default_destination() + echo_json(None, pretty) except Exception as e: raise ClickException(f"Failed to unset default destination: {e}") diff --git a/planet/types/__init__.py b/planet/types/__init__.py new file mode 100644 index 000000000..44439d145 --- /dev/null +++ b/planet/types/__init__.py @@ -0,0 +1,34 @@ +# Copyright 2026 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. +"""Typed request and response models, generated from Planet's OpenAPI specs. + +Requires pydantic, which is an optional dependency: + + pip install planet[models] + +The rest of the SDK does not import this package. Clients return plain dicts; +these models are opt-in validation on top of them: + + from planet.types.destinations import Destination + + dest = Destination.model_validate(client.get_destination(dest_id)) + +To regenerate after a spec change, run `nox -s generate_models`. +""" +try: + import pydantic as _pydantic # noqa: F401 +except ImportError as exc: # pragma: no cover + raise ImportError( + "planet.types requires pydantic, which is not installed. " + "Install it with: pip install planet[models]") from exc diff --git a/planet/types/destinations.py b/planet/types/destinations.py new file mode 100644 index 000000000..deb0b6b3c --- /dev/null +++ b/planet/types/destinations.py @@ -0,0 +1,411 @@ +# flake8: noqa +# fmt: off +# Generated code — do not edit manually. +# Reformatting this file will break `nox -s validate_models`. +# To regenerate, run: +# nox -s generate_models + +from __future__ import annotations + +from enum import Enum +from typing import Annotated + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel + + +class AmazonS3Params(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + aws_access_key_id: Annotated[ + str, Field(description='AWS access key ID for authentication with Amazon S3.') + ] + aws_region: Annotated[ + str, Field(description='The AWS region where the S3 bucket is located.') + ] + aws_secret_access_key: Annotated[ + str, + Field(description='AWS secret access key for authentication with Amazon S3.'), + ] + bucket: Annotated[ + str, + Field( + description='The name of the Amazon S3 bucket where data will be delivered.' + ), + ] + explicit_sse: Annotated[ + bool, + Field(description='Enable explicit server-side encryption headers for SSE-S3.'), + ] = False + + +class AmazonS3PatchParams(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + aws_access_key_id: Annotated[ + str, Field(description='AWS access key ID for authentication with Amazon S3.') + ] + aws_secret_access_key: Annotated[ + str, + Field(description='AWS secret access key for authentication with Amazon S3.'), + ] + explicit_sse: Annotated[ + bool, + Field(description='Enable explicit server-side encryption headers for SSE-S3.'), + ] = False + + +class AzureCloudStorageParams(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + account: Annotated[ + str, + Field( + description='The name of the Azure Storage account where data will be delivered.' + ), + ] + container: Annotated[ + str, + Field( + description='The name of the Azure Blob Storage container within the account.' + ), + ] + sas_token: Annotated[ + str, + Field( + description='Shared Access Signature (SAS) token for authentication with Azure Storage.' + ), + ] + storage_endpoint_suffix: Annotated[ + str | None, + Field( + description='The storage endpoint suffix for the Azure Storage service (optional).' + ), + ] = None + + +class AzureCloudStoragePatchParams(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + sas_token: Annotated[ + str, + Field( + description='Shared Access Signature (SAS) token for authentication with Azure Storage.' + ), + ] + + +class DefaultDestinationRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + destination_id: Annotated[ + 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): + model_config = ConfigDict( + extra='allow', + ) + code: int + message: str + + +class GoogleCloudStorageParams(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + bucket: Annotated[ + str, + Field( + description='The name of the Google Cloud Storage bucket where data will be delivered.' + ), + ] + credentials: Annotated[ + 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: Annotated[ + 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): + model_config = ConfigDict( + extra='allow', + ) + field_self: Annotated[ + str, + Field( + alias='_self', + description='RFC 3986 URI representing the location of this object.', + ), + ] + + +class OracleCloudStorageParams(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + bucket: Annotated[ + str, + Field( + description='The name of the Oracle Cloud Storage bucket where data will be delivered.' + ), + ] + customer_access_key_id: Annotated[ + str, + Field( + description='Customer access key ID for authentication with Oracle Cloud Storage.' + ), + ] + customer_secret_key: Annotated[ + str, + Field( + description='Customer secret key for authentication with Oracle Cloud Storage.' + ), + ] + namespace: Annotated[ + str, + Field( + description='The Oracle Object Storage namespace that contains the bucket.' + ), + ] + region: Annotated[ + str, Field(description='The Oracle Cloud region where the bucket is located.') + ] + + +class OracleCloudStoragePatchParams(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + customer_access_key_id: Annotated[ + str, + Field( + description='Customer access key ID for authentication with Oracle Cloud Storage.' + ), + ] + customer_secret_key: Annotated[ + str, + Field( + description='Customer secret key for authentication with Oracle Cloud Storage.' + ), + ] + + +class Ownership(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + is_owner: Annotated[ + bool, Field(description='True if the user is the creator of the destination.') + ] + owner_id: Annotated[ + int, Field(description='The ID of the user who created the destination.') + ] + + +class Permissions(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + can_write: Annotated[ + bool, + Field(description='True if the user can write to the destination (patch).'), + ] + + +class S3CompatibleParams(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + access_key_id: Annotated[ + str, + Field( + description='Access key ID for authentication with the S3-compatible service.' + ), + ] + bucket: Annotated[ + str, + Field( + description='The name of the S3-compatible bucket where data will be delivered.' + ), + ] + endpoint: Annotated[ + str, + Field(description='The URL endpoint for the S3-compatible storage service.'), + ] + region: Annotated[ + str, + Field( + description='The region identifier for the S3-compatible storage service.' + ), + ] + secret_access_key: Annotated[ + str, + Field( + description='Secret access key for authentication with the S3-compatible service.' + ), + ] + use_path_style: Annotated[ + bool, + Field( + description='Use path-style URL addressing with the bucket name in the URL path.' + ), + ] = False + + +class S3CompatiblePatchParams(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + access_key_id: Annotated[ + str, + Field( + description='Access key ID for authentication with the S3-compatible service.' + ), + ] + secret_access_key: Annotated[ + str, + Field( + description='Secret access key for authentication with the S3-compatible service.' + ), + ] + use_path_style: Annotated[ + bool, + Field( + description='Use path-style URL addressing with the bucket name in the URL path.' + ), + ] = False + + +class DestinationParameters( + RootModel[ + GoogleCloudStorageParams + | AmazonS3Params + | AzureCloudStorageParams + | OracleCloudStorageParams + | S3CompatibleParams + ] +): + root: Annotated[ + GoogleCloudStorageParams | AmazonS3Params | AzureCloudStorageParams | OracleCloudStorageParams | S3CompatibleParams, + Field(description='Parameters for the given Destination type.'), + ] + + +class DestinationPatchParameters( + RootModel[ + GoogleCloudStoragePatchParams + | AmazonS3PatchParams + | AzureCloudStoragePatchParams + | OracleCloudStoragePatchParams + | S3CompatiblePatchParams + ] +): + root: Annotated[ + GoogleCloudStoragePatchParams | AmazonS3PatchParams | AzureCloudStoragePatchParams | OracleCloudStoragePatchParams | S3CompatiblePatchParams, + Field(description='Patch parameters for the given Destination type.'), + ] + + +class DestinationPatchRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + archive: Annotated[ + bool | None, + Field(description='True to archive the destination, false to unarchive.'), + ] = None + name: Annotated[ + str | None, + Field( + description='A string to uniquely identify a Destination.', + max_length=63, + min_length=3, + ), + ] = None + parameters: DestinationPatchParameters | None = None + + +class DestinationRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + name: Annotated[ + str | None, + Field( + description='A name given to this Destination.', max_length=63, min_length=3 + ), + ] = None + parameters: DestinationParameters + type: DestinationType + + +class Destination(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + field_links: Annotated[Links, Field(alias='_links')] + archived: Annotated[ + AwareDatetime | None, + Field(description='Timestamp when the Destination was archived.'), + ] + created: Annotated[ + AwareDatetime, Field(description='Timestamp when the Destination was created.') + ] + default: Annotated[ + bool | None, + Field( + description='True if this is the default destination for the organization.' + ), + ] = False + id: Annotated[ + str, Field(description='A string to uniquely identify a Destination.') + ] + name: Annotated[str, Field(description='A name given to this Destination.')] + ownership: Ownership + parameters: DestinationParameters + permissions: Permissions + pl_ref: Annotated[ + str, Field(alias='pl:ref', description='A reference for the destination.') + ] + type: DestinationType + updated: Annotated[ + AwareDatetime, + Field(description='Timestamp when the Destination was last updated.'), + ] + + +class DestinationsResponse(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + field_links: Annotated[Links, Field(alias='_links')] + destinations: Annotated[ + list[Destination], Field(description='Array of Destinations.') + ] diff --git a/pyproject.toml b/pyproject.toml index 04837eca5..6d0619677 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,13 +34,26 @@ license = { file = "LICENSE" } dynamic = ["version"] [project.optional-dependencies] +# Typed request/response models for planet.types.*. Optional because pydantic +# pulls in pydantic-core, a compiled Rust extension. The SDK itself does not +# import it: clients return plain dicts either way. +models = [ + "pydantic>=2.0", +] test = [ + "planet[models]", "pytest==8.3.3", "anyio", "pytest-cov", "respx>=0.22.0", "coverage[toml]" ] +validate_models = [ + "planet[test]", + # Pinned exactly: the drift check byte-compares regenerated output, so a + # codegen release that changes formatting would fail it and block a release. + "datamodel-code-generator[http]==0.79.0", +] lint = [ "flake8", "mypy", @@ -55,7 +68,7 @@ docs = [ "mkdocs-macros-plugin==1.3.7" ] dev = [ - "planet[test, docs, lint]", + "planet[test, docs, lint, validate_models]", ] [project.scripts] diff --git a/scripts/constants.py b/scripts/constants.py new file mode 100644 index 000000000..28e347ed0 --- /dev/null +++ b/scripts/constants.py @@ -0,0 +1,57 @@ +# Copyright 2026 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 pathlib + +REPO_ROOT = pathlib.Path(__file__).parent.parent + +# Where generated models are written. One module per entry in SPECS. +MODELS_DIR = REPO_ROOT / "planet" / "types" + +# Live OpenAPI specs, keyed by the module name they generate. +# 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", +} + +# Prepended to every generated module. +HEADER = ("# flake8: noqa\n" + "# fmt: off\n" + "# Generated code — do not edit manually.\n" + "# Reformatting this file will break `nox -s validate_models`.\n" + "# To regenerate, run:\n" + "# nox -s generate_models") + +# Schema names whose anyOf blocks are pure required-field constraints +# (each entry has only a `required` key, no properties of its own). +# These exist solely to express "at least one of these fields must be set", +# which is a server-side validation rule. datamodel-codegen cannot represent +# that constraint cleanly: it generates N numbered classes (e.g. +# DestinationPatchRequest1/2/3) that are otherwise identical except for which +# field is marked required. +# +# We drop the anyOf during codegen so the generator emits a single, flat model +# with all fields optional. The constraint is still enforced server-side; the +# client SDK's job is to build and send the request, not to duplicate server +# validation in a way that produces unreadable generated names. +DROP_CONSTRAINT_ANY_OF: set[str] = { + "DestinationPatchRequest", +} + +# datamodel-codegen target. Pinned, not inferred from the interpreter running +# codegen: output differs between Python versions, which would fail the drift +# check. 3.10 is the project's requires-python floor. +TARGET_PYTHON_VERSION = "3.10" diff --git a/scripts/type_gen.py b/scripts/type_gen.py new file mode 100644 index 000000000..5a8ea6c40 --- /dev/null +++ b/scripts/type_gen.py @@ -0,0 +1,162 @@ +# Copyright 2026 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 json +import pathlib +import urllib.request + +from constants import ( + DROP_CONSTRAINT_ANY_OF, + HEADER, + MODELS_DIR, + REPO_ROOT, + SPECS, + TARGET_PYTHON_VERSION, +) + +__all__ = [ + "MODELS_DIR", + "REPO_ROOT", + "SPECS", + "codegen_argv", + "fetch_and_patch_spec", + "response_reachable_schemas", +] + + +def _schema_refs(node) -> list: + """Collect every ``components/schemas`` name referenced under a node.""" + found = [] + if isinstance(node, dict): + for key, value in node.items(): + if key == "$ref" and isinstance(value, str): + found.append(value.rsplit("/", 1)[-1]) + else: + found.extend(_schema_refs(value)) + elif isinstance(node, list): + for value in node: + found.extend(_schema_refs(value)) + return found + + +def response_reachable_schemas(spec: dict) -> set: + """Names of schemas the API can return, followed transitively from responses. + + Everything else is request-only. The two halves want opposite handling of + unknown fields, so codegen needs to tell them apart. + """ + schemas = spec.get("components", {}).get("schemas", {}) + + pending = [] + for path_item in spec.get("paths", {}).values(): + for operation in path_item.values(): + if isinstance(operation, dict): + pending.extend(_schema_refs(operation.get("responses", {}))) + + reachable: set = set() + while pending: + name = pending.pop() + if name in reachable or name not in schemas: + continue + reachable.add(name) + pending.extend(_schema_refs(schemas[name])) + return reachable + + +def fetch_and_patch_spec(url: str) -> dict: + """Fetch an OpenAPI spec and patch it for generating typed models. + + Two patches are applied before datamodel-codegen sees the spec. + + 1. Strip pure-constraint ``anyOf`` blocks. Some schemas use ``anyOf`` + exclusively to express "at least one of these fields must be present", + using inline objects that each carry only a ``required`` key. + datamodel-codegen cannot name these inline schemas and falls back to + numbered suffixes (``DestinationPatchRequest1``, etc.). Removing the + block yields a single, flat model. The constraint is server-enforced; + the client SDK does not need to replicate it. + + 2. Relax ``additionalProperties`` on response schemas. Codegen is run + without a global ``--extra-fields`` override, so it honours the spec: + ``additionalProperties: false`` becomes ``extra='forbid'``. That is + what we want for request models -- a typo'd key fails client side, + before the round trip. It is wrong for responses: a shipped SDK must + not raise when Planet adds a field. So every schema reachable from a + response is forced to ``additionalProperties: true``, giving + ``extra='allow'``. + + Note the overlap. ``AmazonS3Params`` and its siblings appear in both + requests and responses, so tolerance wins and they are generated as + ``allow``. Only ``*PatchParams`` and the top-level request bodies are + request-only, and those get ``forbid``. + """ + with urllib.request.urlopen(url) as resp: + spec = json.loads(resp.read()) + + schemas = spec.get("components", {}).get("schemas", {}) + for schema_name in DROP_CONSTRAINT_ANY_OF: + schema = schemas.get(schema_name) + if schema is None: + continue + # Only drop anyOf entries that are pure required-field constraints + # (no properties of their own). If an entry has properties it is a + # real subtype and must be kept. + cleaned = [ + entry for entry in schema.get("anyOf", []) + if "properties" in entry or "$ref" in entry + ] + if cleaned: + schema["anyOf"] = cleaned + else: + schema.pop("anyOf", None) + + for name in response_reachable_schemas(spec): + schema = schemas[name] + # Enums and unions (oneOf/anyOf roots) carry no properties of their + # own; additionalProperties is meaningless there and confuses codegen. + if "properties" in schema: + schema["additionalProperties"] = True + + return spec + + +def codegen_argv(input_file: pathlib.Path, output: pathlib.Path) -> list: + """Build the datamodel-codegen command line for one spec.""" + return [ + "datamodel-codegen", + "--input", + str(input_file), + "--input-file-type", + "openapi", + "--output", + str(output), + "--output-model-type", + "pydantic_v2.BaseModel", + # Express constraints as Annotated[str, Field(max_length=...)] rather + # than constr(...), which mypy rejects as an annotation in the modules + # that import these models. + "--use-annotated", + "--target-python-version", + TARGET_PYTHON_VERSION, + # The spec is OpenAPI 3.0.3 and marks fields such as Destination.archived + # as both required and `nullable: true`. Without this, codegen drops the + # nullability and the model rejects the null the API actually returns. + "--strict-nullable", + # Honour schema-level defaults on required fields (e.g. Destination.default + # defaults to false), which the API omits rather than sending explicitly. + "--use-default", + "--custom-file-header", + HEADER, + "--formatters", + "builtin", + ] diff --git a/setup.cfg b/setup.cfg index dfa8ff892..18c4e1ce5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [options] -packages = planet, planet.cli, planet.clients, planet.data, planet.sync +packages = planet, planet.types, planet.cli, planet.clients, planet.data, planet.sync [options.packages.find] exclude = examples, tests diff --git a/tests/drift/conftest.py b/tests/drift/conftest.py new file mode 100644 index 000000000..b12f46c2c --- /dev/null +++ b/tests/drift/conftest.py @@ -0,0 +1,7 @@ +import pathlib +import sys + +# type_gen lives in scripts/ (not tests/) because it generates +# production code, not test fixtures. +sys.path.insert(0, + str(pathlib.Path(__file__).parent.parent.parent / "scripts")) diff --git a/tests/drift/validate_models.py b/tests/drift/validate_models.py new file mode 100644 index 000000000..5389f2ddb --- /dev/null +++ b/tests/drift/validate_models.py @@ -0,0 +1,88 @@ +# Copyright 2026 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: + - The live OpenAPI spec is fetched, patched, and written to a temp file. + - datamodel-codegen reads that file and generates models into another temp file. + - The output is compared against the committed file in planet/types/. + - The test fails if they differ, indicating the spec has changed. + +The committed models are raw codegen output. They are excluded from yapf and +flake8 (see noxfile.py) because reformatting them would break this comparison. + +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 difflib +import json +import pathlib +import subprocess +import tempfile + +import pytest + +from type_gen import MODELS_DIR, SPECS, codegen_argv, fetch_and_patch_spec + + +def _regenerate(url: str, output: pathlib.Path) -> None: + spec = fetch_and_patch_spec(url) + with tempfile.NamedTemporaryFile(suffix=".json", delete=False, + mode="w") as spec_tmp: + json.dump(spec, spec_tmp) + spec_path = pathlib.Path(spec_tmp.name) + + try: + result = subprocess.run( + codegen_argv(spec_path, output), + capture_output=True, + text=True, + ) + if result.returncode != 0: + pytest.fail(f"datamodel-codegen failed:\n{result.stderr}") + finally: + spec_path.unlink(missing_ok=True) + + +@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: + diff = "".join( + difflib.unified_diff( + current.splitlines(keepends=True), + generated.splitlines(keepends=True), + fromfile=f"committed/{name}.py", + tofile=f"regenerated/{name}.py", + )) + pytest.fail( + f"planet/types/{name}.py is out of date with the live spec.\n" + f"Run `nox -s generate_models` to regenerate, then commit the result.\n\n" + f"{diff}") + 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..8b738deff 100644 --- a/tests/integration/test_destinations_api.py +++ b/tests/integration/test_destinations_api.py @@ -107,8 +107,10 @@ def construct_list_response(destinations): async def test_list_destinations(): mock_response(TEST_URL, construct_list_response(DEST_LIST)) + expected = 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 +121,10 @@ async def test_list_destinations_filtering(): mock_response(f"{TEST_URL}?archived=false&is_owner=true", construct_list_response([DEST_1])) + expected = 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)) 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 diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py new file mode 100644 index 000000000..1d87f739f --- /dev/null +++ b/tests/unit/test_types.py @@ -0,0 +1,156 @@ +# Copyright 2026 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. +"""Contract tests for the generated Destinations models. + +These pin the two halves of the unknown-field policy set in +scripts/type_gen.py. Request models reject unknown fields so a typo +fails client side. Response models accept them so an additive server change +does not break a shipped SDK. +""" +import pydantic +import pytest + +import planet +from planet.types.destinations import ( + AmazonS3PatchParams, + DefaultDestinationRequest, + Destination, + DestinationPatchRequest, + DestinationRequest, + DestinationsResponse, +) + +DEST = { + "id": "dest1", + "name": "Destination 1", + "type": "amazon_s3", + "parameters": { + "bucket": "bucket1", + "aws_region": "us-west-2", + "aws_access_key_id": "key1", + "aws_secret_access_key": "secret1", + }, + "created": "2024-01-01T00:00:00Z", + "updated": "2024-01-01T00:00:00Z", + "pl:ref": "ref", + "_links": { + "_self": "url" + }, + "archived": None, + "permissions": { + "can_write": True + }, + "ownership": { + "is_owner": True, "owner_id": 1 + }, +} + +REQUEST_MODELS = [ + (DestinationRequest, + { + "type": "amazon_s3", + "parameters": { + "bucket": "bucket1", + "aws_region": "us-west-2", + "aws_access_key_id": "key1", + "aws_secret_access_key": "secret1", + }, + }), + (DestinationPatchRequest, { + "archive": True + }), + (DefaultDestinationRequest, { + "destination_id": "dest1" + }), + (AmazonS3PatchParams, { + "aws_access_key_id": "key1", "aws_secret_access_key": "secret1" + }), +] + + +@pytest.mark.parametrize("model,payload", REQUEST_MODELS) +def test_request_model_accepts_valid_payload(model, payload): + assert model.model_validate(payload) + + +@pytest.mark.parametrize("model,payload", REQUEST_MODELS) +def test_request_model_rejects_unknown_field(model, payload): + """The spec marks these additionalProperties: false. Catch typos locally.""" + with pytest.raises(pydantic.ValidationError, match="extra_forbidden"): + model.model_validate({**payload, "buckett": "typo"}) + + +def test_response_model_tolerates_unknown_field(): + """An additive server change must not break a released SDK.""" + dest = Destination.model_validate({**DEST, "future_field": "value"}) + assert dest.id == "dest1" + assert dest.future_field == "value" + + +def test_response_model_tolerates_unknown_nested_param(): + """Params are echoed in responses, so they tolerate extras too.""" + params = {**DEST["parameters"], "future_param": "value"} + dest = Destination.model_validate({**DEST, "parameters": params}) + assert dest.parameters.root.future_param == "value" + + +def test_destinations_response_round_trips_aliases(): + """Serialization emits wire names, not Python field names.""" + response = DestinationsResponse.model_validate({ + "destinations": [DEST], "_links": { + "_self": "url" + } + }) + dumped = response.model_dump(mode="json", + by_alias=True, + exclude_unset=True) + + assert "_links" in dumped + assert dumped["destinations"][0]["pl:ref"] == "ref" + assert dumped["destinations"][0]["created"].startswith("2024-01-01") + assert "field_links" not in dumped["destinations"][0] + + +def test_sdk_does_not_import_pydantic_outside_planet_types(): + """pydantic is an optional extra: `pip install planet[models]`. + + Nothing outside planet/types may import it, or a plain `pip install planet` + breaks at import time. Checked by AST rather than by installing the package + two ways, so it runs in the normal suite. + """ + import ast + import pathlib + + package = pathlib.Path(planet.__file__).parent + types_dir = package / "types" + + offenders = [] + for path in package.rglob("*.py"): + if types_dir in path.parents or path.parent == types_dir: + continue + tree = ast.parse(path.read_text(), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + names = [node.module or ""] + else: + continue + if any(n == "pydantic" or n.startswith("pydantic.") + for n in names): + offenders.append(f"{path.relative_to(package)}:{node.lineno}") + + assert not offenders, ( + "pydantic imported outside planet/types, which breaks " + f"`pip install planet` without the models extra: {offenders}")