planetlabs__planet-client-python METASEP

tests/data/test_sm.tif METASEP

tests/data/orders_page.json METASEP
{
    "_links": {
        "_self": "https://api.planet.com/compute/ops/orders/v2",
        "next": "https://api.planet.com/compute/ops/orders/v2?page_marker=2021-01-17T02%3A06%3A01.706517Z"
    },
    "orders": [
        {
            "_links": {
                "_self": "https://api.planet.com/compute/ops/orders/v2/f05b1ed7-11f0-43da-960c-a624f7c355c8"
            },
            "created_on": "2021-02-03T01:40:08.332Z",
            "error_hints": [],
            "id": "f05b1ed7-11f0-43da-960c-a624f7c355c8",
            "last_message": "Manifest delivery completed",
            "last_modified": "2021-02-03T01:43:31.015Z",
            "name": "test_order",
            "products": [
                {
                    "item_ids": [
                        "3949357_1454705_2020-12-01_241c",
                        "3949357_1454805_2020-12-01_241c"
                    ],
                    "item_type": "PSOrthoTile",
                    "product_bundle": "analytic"
                }
            ],
            "state": "success"
        },
        {
            "_links": {
                "_self": "https://api.planet.com/compute/ops/orders/v2/8d4799c4-5291-40c0-a7f5-adb9a974455d"
            },
            "created_on": "2021-02-03T01:40:07.359Z",
            "error_hints": [],
            "id": "8d4799c4-5291-40c0-a7f5-adb9a974455d",
            "last_message": "Manifest delivery completed",
            "last_modified": "2021-02-03T01:43:31.007Z",
            "name": "test_order",
            "products": [
                {
                    "item_ids": [
                        "3949357_1454705_2020-12-01_241c",
                        "3949357_1454805_2020-12-01_241c"
                    ],
                    "item_type": "PSOrthoTile",
                    "product_bundle": "analytic"
                }
            ],
            "state": "success"
        }
    ]
}

tests/data/order_details_psorthotile_analytic.json METASEP
{
  "name": "psorthotile_analytic",
  "products": [
    {
      "item_ids": [
        "4500474_2133707_2021-05-20_2419"
      ],
      "item_type": "PSOrthoTile",
      "product_bundle": "ANALYTIC"
    }
  ],
  "delivery": {
    "single_archive": true,
    "archive_type": "zip",
    "archive_filename": "string"
  },
  "tools": [
    {"file_format": "COG"},
    {"toar": {"scale_factor": 10000}}
  ]
}

tests/data/order_description_b0cb3448-0a74-11eb-92a1-a3d779bb08e0.json METASEP
{
    "_links": {
        "_self": "string",
        "results": [
            {
                "delivery": "success",
                "expires_at": "2020-12-04T22:25:30.262Z",
                "location": "location1",
                "name": "name1"
            },
            {
                "delivery": "success",
                "expires_at": "2020-12-04T22:25:30.264Z",
                "location": "location2",
                "name": "name2"
            },
            {
                "delivery": "success",
                "expires_at": "2020-12-04T22:25:30.267Z",
                "location": "location3",
                "name": "name3"
            }
        ]
    },
    "id": "b0cb3448-0a74-11eb-92a1-a3d779bb08e0",
    "name": "string",
    "subscription_id": 0,
    "tools": [{}],
    "products": [{}],
    "created_on": "2019-08-24T14:15:22Z",
    "last_modified": "2019-08-24T14:15:22Z",
    "state": "queued",
    "last_message": "string",
    "error_hints": [
        "string"
    ],
    "delivery": {
        "single_archive": true,
        "archive_type": "string",
        "archive_filename": "string",
        "layout": {},
        "amazon_s3": {},
        "azure_blob_storage": {},
        "google_cloud_storage": {},
        "google_earth_engine": {}
    },
    "notifications": {
        "webhook": {},
        "email": true
    },
    "order_type": "full"
}


tests/data/data_search_filter_2022-01.json METASEP
{
   "type":"AndFilter",
   "config":[
      {
         "type":"DateRangeFilter",
         "field_name":"acquired",
         "config":{
            "gte":"2022-01-01T00:00:00Z",
            "lte":"2022-01-31T00:00:00Z"
         }
      },
      {
         "type":"PermissionFilter",
         "config":[
            "assets:download"
         ]
      }
   ]
}

tests/data/data_item_20220125_075650_17_1061.json METASEP
{
  "_links": {
    "_self": "https://api.planet.com/data/v1/item-types/PSScene/items/20220125_075650_17_1061",
    "assets": "https://api.planet.com/data/v1/item-types/PSScene/items/20220125_075650_17_1061/assets/",
    "thumbnail": "https://tiles.planet.com/data/v1/item-types/PSScene/items/20220125_075650_17_1061/thumb"
  },
  "_permissions": [
    "assets.basic_analytic_4b:download",
    "assets.basic_analytic_4b_rpc:download",
    "assets.basic_analytic_4b_xml:download",
    "assets.basic_udm2:download",
    "assets.ortho_analytic_4b:download",
    "assets.ortho_analytic_4b_sr:download",
    "assets.ortho_analytic_4b_xml:download",
    "assets.ortho_udm2:download",
    "assets.ortho_visual:download",
    "assets.ps3b_analytic:download",
    "assets.ps3b_analytic_dn:download",
    "assets.ps3b_analytic_dn_xml:download",
    "assets.ps3b_analytic_xml:download",
    "assets.ps3b_basic_analytic:download",
    "assets.ps3b_basic_analytic_dn:download",
    "assets.ps3b_basic_analytic_dn_rpc:download",
    "assets.ps3b_basic_analytic_dn_xml:download",
    "assets.ps3b_basic_analytic_rpc:download",
    "assets.ps3b_basic_analytic_xml:download",
    "assets.ps3b_basic_udm:download",
    "assets.ps3b_udm:download",
    "assets.ps3b_visual_xml:download",
    "assets.ps4b_analytic:download",
    "assets.ps4b_analytic_dn:download",
    "assets.ps4b_analytic_dn_xml:download",
    "assets.ps4b_analytic_sr:download",
    "assets.ps4b_analytic_xml:download",
    "assets.ps4b_basic_analytic:download",
    "assets.ps4b_basic_analytic_dn:download",
    "assets.ps4b_basic_analytic_dn_nitf:download",
    "assets.ps4b_basic_analytic_dn_rpc:download",
    "assets.ps4b_basic_analytic_dn_rpc_nitf:download",
    "assets.ps4b_basic_analytic_dn_xml:download",
    "assets.ps4b_basic_analytic_dn_xml_nitf:download",
    "assets.ps4b_basic_analytic_nitf:download",
    "assets.ps4b_basic_analytic_rpc:download",
    "assets.ps4b_basic_analytic_rpc_nitf:download",
    "assets.ps4b_basic_analytic_xml:download",
    "assets.ps4b_basic_analytic_xml_nitf:download",
    "assets.ps4b_basic_udm:download",
    "assets.ps4b_udm:download"
  ],
  "assets": [
    "basic_analytic_4b",
    "basic_analytic_4b_rpc",
    "basic_analytic_4b_xml",
    "basic_udm2",
    "ortho_analytic_4b",
    "ortho_analytic_4b_sr",
    "ortho_analytic_4b_xml",
    "ortho_udm2",
    "ortho_visual",
    "ps3b_analytic",
    "ps3b_analytic_dn",
    "ps3b_analytic_dn_xml",
    "ps3b_analytic_xml",
    "ps3b_basic_analytic",
    "ps3b_basic_analytic_dn",
    "ps3b_basic_analytic_dn_rpc",
    "ps3b_basic_analytic_dn_xml",
    "ps3b_basic_analytic_rpc",
    "ps3b_basic_analytic_xml",
    "ps3b_basic_udm",
    "ps3b_udm",
    "ps3b_visual_xml",
    "ps4b_analytic",
    "ps4b_analytic_dn",
    "ps4b_analytic_dn_xml",
    "ps4b_analytic_sr",
    "ps4b_analytic_xml",
    "ps4b_basic_analytic",
    "ps4b_basic_analytic_dn",
    "ps4b_basic_analytic_dn_nitf",
    "ps4b_basic_analytic_dn_rpc",
    "ps4b_basic_analytic_dn_rpc_nitf",
    "ps4b_basic_analytic_dn_xml",
    "ps4b_basic_analytic_dn_xml_nitf",
    "ps4b_basic_analytic_nitf",
    "ps4b_basic_analytic_rpc",
    "ps4b_basic_analytic_rpc_nitf",
    "ps4b_basic_analytic_xml",
    "ps4b_basic_analytic_xml_nitf",
    "ps4b_basic_udm",
    "ps4b_udm"
  ],
  "geometry": {
    "coordinates": [
      [
        [
          45.595172667558195,
          2.055680485533548
        ],
        [
          45.56732981157587,
          1.9146064255603352
        ],
        [
          45.77758201329859,
          1.873549689884751
        ],
        [
          45.80447847997163,
          2.014835299336349
        ],
        [
          45.595172667558195,
          2.055680485533548
        ]
      ]
    ],
    "type": "Polygon"
  },
  "id": "20220125_075650_17_1061",
  "properties": {
    "acquired": "2022-01-25T07:56:50.17694Z",
    "anomalous_pixels": 0,
    "clear_confidence_percent": 96,
    "clear_percent": 95,
    "cloud_cover": 0.05,
    "cloud_percent": 5,
    "ground_control": false,
    "gsd": 3.6,
    "heavy_haze_percent": 0,
    "instrument": "PS2.SD",
    "item_type": "PSScene",
    "light_haze_percent": 0,
    "pixel_resolution": 3,
    "provider": "planetscope",
    "published": "2022-04-04T18:58:00Z",
    "publishing_stage": "finalized",
    "quality_category": "test",
    "satellite_azimuth": 276.4,
    "satellite_id": "1061",
    "shadow_percent": 0,
    "snow_ice_percent": 0,
    "strip_id": "5321496",
    "sun_azimuth": 140.2,
    "sun_elevation": 62.5,
    "updated": "2022-04-04T18:58:00Z",
    "view_angle": 1.1,
    "visible_confidence_percent": 79,
    "visible_percent": 95
  },
  "type": "Feature"
}

tests/data/data_item_20220125_075511_17_1061.json METASEP
{
  "_links": {
    "_self": "https://api.planet.com/data/v1/item-types/PSScene/items/20220125_075511_17_1061",
    "assets": "https://api.planet.com/data/v1/item-types/PSScene/items/20220125_075511_17_1061/assets/",
    "thumbnail": "https://tiles.planet.com/data/v1/item-types/PSScene/items/20220125_075511_17_1061/thumb"
  },
  "_permissions": [
    "assets.basic_analytic_4b:download",
    "assets.basic_analytic_4b_rpc:download",
    "assets.basic_analytic_4b_xml:download",
    "assets.basic_udm2:download",
    "assets.ortho_analytic_4b:download",
    "assets.ortho_analytic_4b_sr:download",
    "assets.ortho_analytic_4b_xml:download",
    "assets.ortho_udm2:download",
    "assets.ortho_visual:download",
    "assets.ps3b_analytic:download",
    "assets.ps3b_analytic_dn:download",
    "assets.ps3b_analytic_dn_xml:download",
    "assets.ps3b_analytic_xml:download",
    "assets.ps3b_basic_analytic:download",
    "assets.ps3b_basic_analytic_dn:download",
    "assets.ps3b_basic_analytic_dn_rpc:download",
    "assets.ps3b_basic_analytic_dn_xml:download",
    "assets.ps3b_basic_analytic_rpc:download",
    "assets.ps3b_basic_analytic_xml:download",
    "assets.ps3b_basic_udm:download",
    "assets.ps3b_udm:download",
    "assets.ps3b_visual_xml:download",
    "assets.ps4b_analytic:download",
    "assets.ps4b_analytic_dn:download",
    "assets.ps4b_analytic_dn_xml:download",
    "assets.ps4b_analytic_sr:download",
    "assets.ps4b_analytic_xml:download",
    "assets.ps4b_basic_analytic:download",
    "assets.ps4b_basic_analytic_dn:download",
    "assets.ps4b_basic_analytic_dn_nitf:download",
    "assets.ps4b_basic_analytic_dn_rpc:download",
    "assets.ps4b_basic_analytic_dn_rpc_nitf:download",
    "assets.ps4b_basic_analytic_dn_xml:download",
    "assets.ps4b_basic_analytic_dn_xml_nitf:download",
    "assets.ps4b_basic_analytic_nitf:download",
    "assets.ps4b_basic_analytic_rpc:download",
    "assets.ps4b_basic_analytic_rpc_nitf:download",
    "assets.ps4b_basic_analytic_xml:download",
    "assets.ps4b_basic_analytic_xml_nitf:download",
    "assets.ps4b_basic_udm:download",
    "assets.ps4b_udm:download"
  ],
  "assets": [
    "basic_analytic_4b",
    "basic_analytic_4b_rpc",
    "basic_analytic_4b_xml",
    "basic_udm2",
    "ortho_analytic_4b",
    "ortho_analytic_4b_sr",
    "ortho_analytic_4b_xml",
    "ortho_udm2",
    "ortho_visual",
    "ps3b_analytic",
    "ps3b_analytic_dn",
    "ps3b_analytic_dn_xml",
    "ps3b_analytic_xml",
    "ps3b_basic_analytic",
    "ps3b_basic_analytic_dn",
    "ps3b_basic_analytic_dn_rpc",
    "ps3b_basic_analytic_dn_xml",
    "ps3b_basic_analytic_rpc",
    "ps3b_basic_analytic_xml",
    "ps3b_basic_udm",
    "ps3b_udm",
    "ps3b_visual_xml",
    "ps4b_analytic",
    "ps4b_analytic_dn",
    "ps4b_analytic_dn_xml",
    "ps4b_analytic_sr",
    "ps4b_analytic_xml",
    "ps4b_basic_analytic",
    "ps4b_basic_analytic_dn",
    "ps4b_basic_analytic_dn_nitf",
    "ps4b_basic_analytic_dn_rpc",
    "ps4b_basic_analytic_dn_rpc_nitf",
    "ps4b_basic_analytic_dn_xml",
    "ps4b_basic_analytic_dn_xml_nitf",
    "ps4b_basic_analytic_nitf",
    "ps4b_basic_analytic_rpc",
    "ps4b_basic_analytic_rpc_nitf",
    "ps4b_basic_analytic_xml",
    "ps4b_basic_analytic_xml_nitf",
    "ps4b_basic_udm",
    "ps4b_udm"
  ],
  "geometry": {
    "coordinates": [
      [
        [
          46.82903564536055,
          8.39086317928473
        ],
        [
          46.80156249470065,
          8.250997541949383
        ],
        [
          47.0121579862138,
          8.209746764301961
        ],
        [
          47.039803749889344,
          8.349740205193097
        ],
        [
          46.82903564536055,
          8.39086317928473
        ]
      ]
    ],
    "type": "Polygon"
  },
  "id": "20220125_075511_17_1061",
  "properties": {
    "acquired": "2022-01-25T07:55:11.176161Z",
    "anomalous_pixels": 0,
    "clear_confidence_percent": 100,
    "clear_percent": 100,
    "cloud_cover": 0,
    "cloud_percent": 0,
    "ground_control": true,
    "gsd": 3.6,
    "heavy_haze_percent": 0,
    "instrument": "PS2.SD",
    "item_type": "PSScene",
    "light_haze_percent": 0,
    "pixel_resolution": 3,
    "provider": "planetscope",
    "published": "2022-04-04T18:57:56Z",
    "publishing_stage": "finalized",
    "quality_category": "standard",
    "satellite_azimuth": 274.8,
    "satellite_id": "1061",
    "shadow_percent": 0,
    "snow_ice_percent": 0,
    "strip_id": "5321496",
    "sun_azimuth": 147.9,
    "sun_elevation": 57.8,
    "updated": "2022-04-04T18:57:56Z",
    "view_angle": 1.1,
    "visible_confidence_percent": 100,
    "visible_percent": 100
  },
  "type": "Feature"
}

tests/data/data_item_20220125_075509_67_1061.json METASEP
{
  "_links": {
    "_self": "https://api.planet.com/data/v1/item-types/PSScene/items/20220125_075509_67_1061",
    "assets": "https://api.planet.com/data/v1/item-types/PSScene/items/20220125_075509_67_1061/assets/",
    "thumbnail": "https://tiles.planet.com/data/v1/item-types/PSScene/items/20220125_075509_67_1061/thumb"
  },
  "_permissions": [
    "assets.basic_analytic_4b:download",
    "assets.basic_analytic_4b_rpc:download",
    "assets.basic_analytic_4b_xml:download",
    "assets.basic_udm2:download",
    "assets.ortho_analytic_4b:download",
    "assets.ortho_analytic_4b_sr:download",
    "assets.ortho_analytic_4b_xml:download",
    "assets.ortho_udm2:download",
    "assets.ortho_visual:download",
    "assets.ps3b_analytic:download",
    "assets.ps3b_analytic_dn:download",
    "assets.ps3b_analytic_dn_xml:download",
    "assets.ps3b_analytic_xml:download",
    "assets.ps3b_basic_analytic:download",
    "assets.ps3b_basic_analytic_dn:download",
    "assets.ps3b_basic_analytic_dn_rpc:download",
    "assets.ps3b_basic_analytic_dn_xml:download",
    "assets.ps3b_basic_analytic_rpc:download",
    "assets.ps3b_basic_analytic_xml:download",
    "assets.ps3b_basic_udm:download",
    "assets.ps3b_udm:download",
    "assets.ps3b_visual_xml:download",
    "assets.ps4b_analytic:download",
    "assets.ps4b_analytic_dn:download",
    "assets.ps4b_analytic_dn_xml:download",
    "assets.ps4b_analytic_sr:download",
    "assets.ps4b_analytic_xml:download",
    "assets.ps4b_basic_analytic:download",
    "assets.ps4b_basic_analytic_dn:download",
    "assets.ps4b_basic_analytic_dn_nitf:download",
    "assets.ps4b_basic_analytic_dn_rpc:download",
    "assets.ps4b_basic_analytic_dn_rpc_nitf:download",
    "assets.ps4b_basic_analytic_dn_xml:download",
    "assets.ps4b_basic_analytic_dn_xml_nitf:download",
    "assets.ps4b_basic_analytic_nitf:download",
    "assets.ps4b_basic_analytic_rpc:download",
    "assets.ps4b_basic_analytic_rpc_nitf:download",
    "assets.ps4b_basic_analytic_xml:download",
    "assets.ps4b_basic_analytic_xml_nitf:download",
    "assets.ps4b_basic_udm:download",
    "assets.ps4b_udm:download"
  ],
  "assets": [
    "basic_analytic_4b",
    "basic_analytic_4b_rpc",
    "basic_analytic_4b_xml",
    "basic_udm2",
    "ortho_analytic_4b",
    "ortho_analytic_4b_sr",
    "ortho_analytic_4b_xml",
    "ortho_udm2",
    "ortho_visual",
    "ps3b_analytic",
    "ps3b_analytic_dn",
    "ps3b_analytic_dn_xml",
    "ps3b_analytic_xml",
    "ps3b_basic_analytic",
    "ps3b_basic_analytic_dn",
    "ps3b_basic_analytic_dn_rpc",
    "ps3b_basic_analytic_dn_xml",
    "ps3b_basic_analytic_rpc",
    "ps3b_basic_analytic_xml",
    "ps3b_basic_udm",
    "ps3b_udm",
    "ps3b_visual_xml",
    "ps4b_analytic",
    "ps4b_analytic_dn",
    "ps4b_analytic_dn_xml",
    "ps4b_analytic_sr",
    "ps4b_analytic_xml",
    "ps4b_basic_analytic",
    "ps4b_basic_analytic_dn",
    "ps4b_basic_analytic_dn_nitf",
    "ps4b_basic_analytic_dn_rpc",
    "ps4b_basic_analytic_dn_rpc_nitf",
    "ps4b_basic_analytic_dn_xml",
    "ps4b_basic_analytic_dn_xml_nitf",
    "ps4b_basic_analytic_nitf",
    "ps4b_basic_analytic_rpc",
    "ps4b_basic_analytic_rpc_nitf",
    "ps4b_basic_analytic_xml",
    "ps4b_basic_analytic_xml_nitf",
    "ps4b_basic_udm",
    "ps4b_udm"
  ],
  "geometry": {
    "coordinates": [
      [
        [
          46.84738976520494,
          8.487620128479884
        ],
        [
          46.819927742749805,
          8.347801105012943
        ],
        [
          47.030571385297435,
          8.30660757257819
        ],
        [
          47.05818257420601,
          8.44657660657514
        ],
        [
          46.84738976520494,
          8.487620128479884
        ]
      ]
    ],
    "type": "Polygon"
  },
  "id": "20220125_075509_67_1061",
  "properties": {
    "acquired": "2022-01-25T07:55:09.676173Z",
    "anomalous_pixels": 0,
    "clear_confidence_percent": 100,
    "clear_percent": 100,
    "cloud_cover": 0,
    "cloud_percent": 0,
    "ground_control": true,
    "gsd": 3.6,
    "heavy_haze_percent": 0,
    "instrument": "PS2.SD",
    "item_type": "PSScene",
    "light_haze_percent": 0,
    "pixel_resolution": 3,
    "provider": "planetscope",
    "published": "2022-04-04T18:57:56Z",
    "publishing_stage": "finalized",
    "quality_category": "standard",
    "satellite_azimuth": 273.8,
    "satellite_id": "1061",
    "shadow_percent": 0,
    "snow_ice_percent": 0,
    "strip_id": "5321496",
    "sun_azimuth": 148,
    "sun_elevation": 57.8,
    "updated": "2022-04-04T18:57:56Z",
    "view_angle": 1.1,
    "visible_confidence_percent": 100,
    "visible_percent": 100
  },
  "type": "Feature"
}

docs/img/logo.svg METASEP
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 214.33 214.33"><title>Untitled-5</title><path d="M115.12,58.77c-10.35,0-21.44,4.08-28.73,14.59H86.09A14.2,14.2,0,0,0,71.89,59.15H71.46v97A16,16,0,0,0,87.4,172.08v-42H87.7c3.06,5.1,11.52,13.71,28.44,13.71,25.52,0,40.84-21,40.84-41.42C157,80.8,142.54,58.77,115.12,58.77ZM114.53,130c-15.31,0-27.42-12.4-27.42-28.73,0-14.88,11.09-28.59,27-28.59,16.48,0,27.71,14.44,27.71,28.88C141.81,118.57,128.83,130,114.53,130Z" fill="#5b5d62"/><path d="M107.17,214.33A107.17,107.17,0,1,1,214.33,107.17,107.29,107.29,0,0,1,107.17,214.33Zm0-204.12a97,97,0,1,0,97,97A97.07,97.07,0,0,0,107.17,10.21Z" fill="#009da5"/></svg>
tests/unit/test_specs.py METASEP
# Copyright 2020 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 logging

import pytest

from planet import specs

LOGGER = logging.getLogger(__name__)

TEST_PRODUCT_BUNDLE = 'visual'
# must be a valid item type for TEST_PRODUCT_BUNDLE
TEST_ITEM_TYPE = 'PSScene'


def test_get_type_match():
    spec_list = ['Locket', 'drop', 'DEER']

    test_entry = 'locket'
    assert 'Locket' == specs.get_match(test_entry, spec_list)

    with pytest.raises(specs.NoMatchException):
        specs.get_match('a', ['b'])


def test_validate_bundle_supported():
    assert 'analytic' == specs.validate_bundle('ANALYTIC')


def test_validate_bundle_notsupported():
    with pytest.raises(specs.SpecificationException):
        specs.validate_bundle('notsupported')


def test_validate_item_type_supported():
    assert 'PSOrthoTile' == specs.validate_item_type('psorthotile', 'analytic')


def test_validate_item_type_notsupported_bundle():
    with pytest.raises(specs.SpecificationException):
        specs.validate_item_type('psorthotile', 'wha')


def test_validate_item_type_notsupported_itemtype():
    with pytest.raises(specs.SpecificationException):
        specs.validate_item_type('notsupported', 'analytic')


def test_validate_order_type_supported():
    assert 'full' == specs.validate_order_type('FULL')


def test_validate_order_type_notsupported():
    with pytest.raises(specs.SpecificationException):
        specs.validate_order_type('notsupported')


def test_validate_arhive_type_supported():
    assert 'zip' == specs.validate_archive_type('ZIP')


def test_validate_arhive_type_notsupported():
    with pytest.raises(specs.SpecificationException):
        specs.validate_archive_type('notsupported')


def test_validate_file_format_supported():
    assert 'COG' == specs.validate_file_format('cog')


def test_validate_file_format_notsupported():
    with pytest.raises(specs.SpecificationException):
        specs.validate_archive_type('notsupported')


def test_get_product_bundles():
    bundles = specs.get_product_bundles()
    assert TEST_PRODUCT_BUNDLE in bundles


def test_get_item_types():
    item_types = specs.get_item_types(TEST_PRODUCT_BUNDLE)
    assert TEST_ITEM_TYPE in item_types

tests/unit/test_reporting.py METASEP
# Copyright 2020 Planet Labs, Inc.
#
# 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 logging
import re

from planet import reporting

LOGGER = logging.getLogger(__name__)


def test_StateBar___init___default():
    with reporting.StateBar() as bar:
        expected_init = '..:.. - order  - state: '
        assert (re.fullmatch(expected_init, str(bar)))

        expected_update = '..:.. - order 1 - state: init'
        bar.update(order_id='1', state='init')
        assert (re.fullmatch(expected_update, str(bar)))


def test_StateBar___init___stateandorder():
    with reporting.StateBar(order_id='1', state='init') as bar:
        expected_init = '..:.. - order 1 - state: init'
        assert (re.fullmatch(expected_init, str(bar)))


def test_StateBar___init___disabled():
    with reporting.StateBar(disable=True) as bar:
        assert bar.bar.disable

        # just make sure this doesn't error out
        bar.update(order_id='1', state='init')


def test_StateBar_update():
    with reporting.StateBar() as bar:
        expected_update = '..:.. - order 1 - state: init'
        bar.update(order_id='1', state='init')
        assert (re.fullmatch(expected_update, str(bar)))


def test_StateBar_update_state():
    with reporting.StateBar() as bar:
        expected_update = '..:.. - order  - state: init'
        bar.update_state('init')
        assert (re.fullmatch(expected_update, str(bar)))

tests/unit/test_order_request.py METASEP
# Copyright 2020 Planet Labs, Inc.
#
# 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 logging

import pytest

from planet import geojson, order_request, specs

LOGGER = logging.getLogger(__name__)

TEST_ID = 'doesntmatter'
TEST_PRODUCT_BUNDLE = 'analytic_sr'
TEST_FALLBACK_BUNDLE = 'analytic'
TEST_ITEM_TYPE = 'PSOrthoTile'
TEST_ARCHIVE_FILENAME = '{{name}}_b_{order_id}}.zip'


def test_build_request():
    product = {
        "item_ids": [TEST_ID],
        "item_type": TEST_ITEM_TYPE,
        "product_bundle": f'{TEST_PRODUCT_BUNDLE},{TEST_FALLBACK_BUNDLE}'
    }
    subscription_id = 5
    delivery = {
        'archive_type': 'zip',
        'single_archive': True,
        'archive_filename': TEST_ARCHIVE_FILENAME,
        'amazon_s3': {
            'aws_access_key_id': 'aws_access_key_id',
            'aws_secret_access_key': 'aws_secret_access_key',
            'bucket': 'bucket',
            'aws_region': 'aws_region'
        }
    }
    notifications = {
        'email': 'email',
        'webhook_url': 'webhookurl',
        'webhook_per_order': True
    }
    order_type = 'partial'
    tool = {'band_math': 'jsonstring'}

    request = order_request.build_request('test_name', [product],
                                          subscription_id=subscription_id,
                                          delivery=delivery,
                                          notifications=notifications,
                                          order_type=order_type,
                                          tools=[tool])
    expected = {
        'name': 'test_name',
        'products': [product],
        'subscription_id': subscription_id,
        'delivery': delivery,
        'notifications': notifications,
        'order_type': order_type,
        'tools': [tool]
    }
    assert request == expected

    order_type = 'notsupported'
    with pytest.raises(specs.SpecificationException):
        _ = order_request.build_request('test_name', [product],
                                        subscription_id=subscription_id,
                                        delivery=delivery,
                                        notifications=notifications,
                                        order_type=order_type,
                                        tools=[tool])


def test_product():
    product_config = order_request.product(
        [TEST_ID],
        TEST_PRODUCT_BUNDLE,
        TEST_ITEM_TYPE,
        fallback_bundle=TEST_FALLBACK_BUNDLE)

    expected = {
        "item_ids": [TEST_ID],
        "item_type": TEST_ITEM_TYPE,
        "product_bundle": f'{TEST_PRODUCT_BUNDLE},{TEST_FALLBACK_BUNDLE}'
    }
    assert product_config == expected

    with pytest.raises(specs.SpecificationException):
        _ = order_request.product([TEST_ID],
                                  'notsupported',
                                  TEST_ITEM_TYPE,
                                  fallback_bundle=TEST_FALLBACK_BUNDLE)

    with pytest.raises(specs.SpecificationException):
        _ = order_request.product([TEST_ID],
                                  TEST_PRODUCT_BUNDLE,
                                  'notsupported',
                                  fallback_bundle=TEST_FALLBACK_BUNDLE)

    with pytest.raises(specs.SpecificationException):
        _ = order_request.product([TEST_ID],
                                  TEST_PRODUCT_BUNDLE,
                                  TEST_ITEM_TYPE,
                                  fallback_bundle='notsupported')


def test_notifications():
    notifications_config = order_request.notifications(
        email='email', webhook_url='webhookurl', webhook_per_order=True)
    expected = {
        'email': 'email',
        'webhook_url': 'webhookurl',
        'webhook_per_order': True
    }
    assert notifications_config == expected

    empty_notifications_config = order_request.notifications()
    empty_expected = {}
    assert empty_notifications_config == empty_expected


def test_delivery():
    as3_config = {
        'amazon_s3': {
            'aws_access_key_id': 'aws_access_key_id',
            'aws_secret_access_key': 'aws_secret_access_key',
            'bucket': 'bucket',
            'aws_region': 'aws_region'
        }
    }
    delivery_config = order_request.delivery('zip',
                                             True,
                                             TEST_ARCHIVE_FILENAME,
                                             cloud_config=as3_config)

    expected = {
        'archive_type': 'zip',
        'single_archive': True,
        'archive_filename': TEST_ARCHIVE_FILENAME,
        'amazon_s3': {
            'aws_access_key_id': 'aws_access_key_id',
            'aws_secret_access_key': 'aws_secret_access_key',
            'bucket': 'bucket',
            'aws_region': 'aws_region'
        }
    }
    assert delivery_config == expected


def test_amazon_s3():
    as3_config = order_request.amazon_s3('aws_access_key_id',
                                         'aws_secret_access_key',
                                         'bucket',
                                         'aws_region')
    expected = {
        'amazon_s3': {
            'aws_access_key_id': 'aws_access_key_id',
            'aws_secret_access_key': 'aws_secret_access_key',
            'bucket': 'bucket',
            'aws_region': 'aws_region'
        }
    }
    assert as3_config == expected


def test_azure_blob_storage():
    abs_config = order_request.azure_blob_storage('account',
                                                  'container',
                                                  'sas_token')
    expected = {
        'azure_blob_storage': {
            'account': 'account',
            'container': 'container',
            'sas_token': 'sas_token',
        }
    }
    assert abs_config == expected


def test_google_cloud_storage():
    gcs_config = order_request.google_cloud_storage('bucket', 'credentials')

    expected = {
        'google_cloud_storage': {
            'bucket': 'bucket',
            'credentials': 'credentials',
        }
    }
    assert gcs_config == expected


def test_google_earth_engine():
    gee_config = order_request.google_earth_engine('project', 'collection')
    expected = {
        'google_earth_engine': {
            'project': 'project',
            'collection': 'collection',
        }
    }

    assert gee_config == expected


def test__tool():
    test_tool = order_request._tool('band_math', 'jsonstring')
    assert test_tool == {'band_math': 'jsonstring'}

    with pytest.raises(specs.SpecificationException):
        _ = order_request._tool('notsupported', 'jsonstring')


def test_clip_tool(geom_geojson, point_geom_geojson):
    ct = order_request.clip_tool(geom_geojson)
    expected = {'clip': {'aoi': geom_geojson}}
    assert ct == expected

    with pytest.raises(geojson.WrongTypeException):
        _ = order_request.clip_tool(point_geom_geojson)


def test_reproject_tool():
    rt = order_request.reproject_tool(projection='proj', resolution=5)
    expected = {'reproject': {'projection': 'proj', 'resolution': 5}}
    assert rt == expected


def test_tile_tool():
    tt = order_request.tile_tool(30, pixel_size=3)
    expected = {'tile': {'tile_size': 30, 'pixel_size': 3}}
    assert tt == expected


def test_toar_tool():
    tt = order_request.toar_tool(scale_factor=5)
    expected = {'toar': {'scale_factor': 5}}
    assert tt == expected

    tt_empty = order_request.toar_tool()
    expected_empty = {'toar': {}}
    assert tt_empty == expected_empty

tests/unit/test_models.py METASEP
# Copyright 2017 Planet Labs, Inc.
#
# 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 logging
import math
from unittest.mock import MagicMock
import os
from pathlib import Path
import re
from datetime import datetime

from httpx import URL
import pytest

from planet import models

LOGGER = logging.getLogger(__name__)


@pytest.fixture
def mocked_request():
    return models.Request('url', 'auth')


def mock_http_response(json=None, iter_content=None, text=None):
    m = MagicMock(name='http_response')
    m.headers = {}
    m.json.return_value = json or {}
    m.aiter_content = iter_content
    m.text = text or ''
    return m


def test_StreamingBody_name():
    r = MagicMock(name='response')
    r.request.url = URL('https://planet.com/path/to/example.tif?foo=f6f1')
    hr = MagicMock(name='http_response')
    hr.headers = {
        'date': 'Thu, 14 Feb 2019 16:13:26 GMT',
        'last-modified': 'Wed, 22 Nov 2017 17:22:31 GMT',
        'accept-ranges': 'bytes',
        'content-type': 'image/tiff',
        'content-length': '57350256',
        'content-disposition': 'attachment; filename="open_california.tif"'
    }
    r.http_response = hr
    body = models.StreamingBody(r)
    assert body.name == 'open_california.tif'

    r = MagicMock(name='response')
    r.request.url = URL('https://planet.com/path/to/example.tif?foo=f6f1')
    hr = MagicMock(name='http_response')
    hr.headers = {
        'date': 'Thu, 14 Feb 2019 16:13:26 GMT',
        'last-modified': 'Wed, 22 Nov 2017 17:22:31 GMT',
        'accept-ranges': 'bytes',
        'content-type': 'image/tiff',
        'content-length': '57350256',
    }
    r.http_response = hr
    body = models.StreamingBody(r)

    assert body.name == 'example.tif'

    r = MagicMock(name='response')
    r.request.url = URL('https://planet.com/path/to/noname/')
    hr = MagicMock(name='http_response')
    hr.headers = {
        'date': 'Thu, 14 Feb 2019 16:13:26 GMT',
        'last-modified': 'Wed, 22 Nov 2017 17:22:31 GMT',
        'accept-ranges': 'bytes',
        'content-type': 'image/tiff',
        'content-length': '57350256',
    }
    r.http_response = hr
    body = models.StreamingBody(r)

    assert body.name.startswith('planet-')
    assert (body.name.endswith('.tiff') or body.name.endswith('.tif'))


NO_NAME_HEADERS = {
    'date': 'Thu, 14 Feb 2019 16:13:26 GMT',
    'last-modified': 'Wed, 22 Nov 2017 17:22:31 GMT',
    'accept-ranges': 'bytes',
    'content-type': 'image/tiff',
    'content-length': '57350256'
}
OPEN_CALIFORNIA_HEADERS = {
    'date': 'Thu, 14 Feb 2019 16:13:26 GMT',
    'last-modified': 'Wed, 22 Nov 2017 17:22:31 GMT',
    'accept-ranges': 'bytes',
    'content-type': 'image/tiff',
    'content-length': '57350256',
    'content-disposition': 'attachment; filename="open_california.tif"'
}


@pytest.mark.parametrize('headers,expected',
                         [(OPEN_CALIFORNIA_HEADERS, 'open_california.tif'),
                          (NO_NAME_HEADERS, None),
                          ({}, None)])  # yapf: disable
def test__get_filename_from_headers(headers, expected):
    assert models._get_filename_from_headers(headers) == expected


@pytest.mark.parametrize(
    'url,expected',
    [
        (URL('https://planet.com/'), None),
        (URL('https://planet.com/path/to/'), None),
        (URL('https://planet.com/path/to/example.tif'), 'example.tif'),
        (URL('https://planet.com/path/to/example.tif?foo=f6f1&bar=baz'),
         'example.tif'),
        (URL('https://planet.com/path/to/example.tif?foo=f6f1#quux'),
         'example.tif'),
    ])
def test__get_filename_from_url(url, expected):
    assert models._get_filename_from_url(url) == expected


@pytest.mark.parametrize(
    'content_type,check',
    [
        (None,
         lambda x: re.match(r'^planet-[a-z0-9]{8}$', x, re.I) is not None),
        ('image/tiff', lambda x: x.endswith(('.tif', '.tiff'))),
    ])
def test__get_random_filename(content_type, check):
    assert check(models._get_random_filename(content_type))


@pytest.mark.asyncio
async def test_StreamingBody_write_img(tmpdir, mocked_request, open_test_img):

    async def _aiter_bytes():
        data = open_test_img.read()
        v = memoryview(data)

        chunksize = 100
        for i in range(math.ceil(len(v) / (chunksize))):
            yield v[i * chunksize:min((i + 1) * chunksize, len(v))]

    r = MagicMock(name='response')
    hr = MagicMock(name='http_response')
    hr.aiter_bytes = _aiter_bytes
    hr.num_bytes_downloaded = 0
    hr.headers['Content-Length'] = 527
    r.http_response = hr
    body = models.StreamingBody(r)

    filename = Path(str(tmpdir)) / 'test.tif'
    await body.write(filename, progress_bar=False)

    assert os.path.isfile(filename)
    assert os.stat(filename).st_size == 527


@pytest.fixture
def get_pages():
    p1 = {'links': {'next': 'blah'}, 'items': [1, 2]}
    p2 = {'links': {}, 'items': [3, 4]}
    responses = [mock_http_response(json=p1), mock_http_response(json=p2)]

    async def do_get(req):
        return responses.pop(0)

    return do_get


def test_StreamingBody_last_modified_emptyheader():
    '''This function tests the last_modified function for an empty header, by
    seeing if the last_modified is None.
    '''
    r = MagicMock(name='response')
    r.request.url = URL('https://planet.com/path/to/example.tif?foo=f6f1')
    hr = MagicMock(name='http_response')
    hr.headers = {
        'date': 'Thu, 14 Feb 2019 16:13:26 GMT',
        'accept-ranges': 'bytes',
        'content-type': 'image/tiff',
        'content-length': '57350256',
        'content-disposition': 'attachment; filename="open_california.tif"'
    }
    r.http_response = hr
    body = models.StreamingBody(r)
    output = body.last_modified()
    expected = None
    assert output == expected


def test_StreamingBody_last_modified_completeheader():
    '''This function tests the last_modified function for an existing header,
    by comparing the last_modified date to
    an expected output.
    '''
    r = MagicMock(name='response')
    r.request.url = URL('https://planet.com/path/to/example.tif?foo=f6f1')
    hr = MagicMock(name='http_response')
    hr.headers = {
        'date': 'Thu, 14 Feb 2019 16:13:26 GMT',
        'last-modified': 'Wed, 22 Nov 2017 17:22:31 GMT',
        'accept-ranges': 'bytes',
        'content-type': 'image/tiff',
        'content-length': '57350256',
        'content-disposition': 'attachment; filename="open_california.tif"'
    }
    r.http_response = hr
    body = models.StreamingBody(r)
    output = body.last_modified()
    expected = datetime.strptime(hr.headers['last-modified'],
                                 '%a, %d %b %Y %H:%M:%S GMT')
    assert output == expected


@pytest.mark.asyncio
async def test_Paged_iterator(get_pages):
    req = MagicMock()
    paged = models.Paged(req, get_pages)
    assert [1, 2, 3, 4] == [i async for i in paged]


@pytest.mark.asyncio
async def test_Paged_limit(get_pages):
    req = MagicMock()
    paged = models.Paged(req, get_pages, limit=3)
    assert [1, 2, 3] == [i async for i in paged]

tests/unit/test_http.py METASEP
# Copyright 2020 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 logging
from http import HTTPStatus
from unittest.mock import Mock

import httpx
import respx

import pytest

from planet import exceptions, http

TEST_URL = 'mock://fantastic.com'

LOGGER = logging.getLogger(__name__)


@pytest.fixture
def mock_request():
    r = Mock()
    r.http_request = httpx.Request('GET', TEST_URL)
    yield r


@pytest.fixture
def mock_response():

    def mocker(code, text='', json={"message": "nope"}):
        r = Mock()
        r.status_code = code
        r.text = text
        r.json = Mock(return_value=json)
        return r

    return mocker


def test_basesession__raise_for_status(mock_response):
    http.BaseSession._raise_for_status(
        mock_response(HTTPStatus.CREATED, json={}))

    with pytest.raises(exceptions.BadQuery):
        http.BaseSession._raise_for_status(
            mock_response(HTTPStatus.BAD_REQUEST, json={}))

    with pytest.raises(exceptions.TooManyRequests):
        http.BaseSession._raise_for_status(
            mock_response(HTTPStatus.TOO_MANY_REQUESTS, text='', json={}))

    with pytest.raises(exceptions.OverQuota):
        http.BaseSession._raise_for_status(
            mock_response(HTTPStatus.TOO_MANY_REQUESTS,
                          text='exceeded QUOTA"',
                          json={}))

    with pytest.raises(exceptions.APIError):
        http.BaseSession._raise_for_status(
            mock_response(HTTPStatus.METHOD_NOT_ALLOWED, json={}))


@pytest.mark.asyncio
async def test_session_contextmanager():
    async with http.Session():
        pass


@respx.mock
@pytest.mark.asyncio
async def test_session_request(mock_request):

    async with http.Session() as ps:
        mock_resp = httpx.Response(HTTPStatus.OK, text='bubba')
        respx.get(TEST_URL).return_value = mock_resp

        resp = await ps.request(mock_request)
        assert resp.http_response.text == 'bubba'


@respx.mock
@pytest.mark.asyncio
async def test_session_stream(mock_request):
    async with http.Session() as ps:
        mock_resp = httpx.Response(HTTPStatus.OK, text='bubba')
        respx.get(TEST_URL).return_value = mock_resp

        async with ps.stream(mock_request) as resp:
            txt = await resp.http_response.aread()
            assert txt == b'bubba'


@respx.mock
@pytest.mark.asyncio
async def test_session_request_retry(mock_request, mock_response):
    async with http.Session() as ps:
        route = respx.get(TEST_URL)
        route.side_effect = [
            httpx.Response(HTTPStatus.TOO_MANY_REQUESTS, json={}),
            httpx.Response(HTTPStatus.OK, json={})
        ]

        ps.retry_wait_time = 0  # lets not slow down tests for this
        resp = await ps.request(mock_request)
        assert resp
        assert route.call_count == 2


@respx.mock
@pytest.mark.asyncio
async def test_session_retry(mock_request):
    async with http.Session() as ps:

        async def test_func():
            raise exceptions.TooManyRequests

        ps.retry_wait_time = 0
        with pytest.raises(exceptions.TooManyRequests):
            await ps._retry(test_func)


@respx.mock
@pytest.mark.asyncio
async def test_authsession_request(mock_request):
    sess = http.AuthSession()
    mock_resp = httpx.Response(HTTPStatus.OK, text='bubba')
    respx.get(TEST_URL).return_value = mock_resp

    resp = sess.request(mock_request)
    assert resp.http_response.text == 'bubba'


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={}))

tests/unit/test_geojson.py METASEP
# Copyright 2021 Planet Labs, Inc.
#
# 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 logging

import pytest

from planet import geojson

LOGGER = logging.getLogger(__name__)


@pytest.fixture
def assert_geom_equal():

    def _tuple_to_list(obj):
        return json.loads(json.dumps(obj).replace(")", "]").replace("(", "["))

    def fcn(geom_1, geom_2):
        str_1 = _tuple_to_list(geom_1)
        str_2 = _tuple_to_list(geom_2)
        assert str_1 == str_2

    return fcn


def test_geom_from_geojson_success(geom_geojson,
                                   feature_geojson,
                                   featureclass_geojson,
                                   assert_geom_equal):
    ggeo = geojson.as_geom(geom_geojson)
    assert_geom_equal(ggeo, geom_geojson)

    fgeo = geojson.geom_from_geojson(feature_geojson)
    assert_geom_equal(fgeo, geom_geojson)

    fcgeo = geojson.geom_from_geojson(featureclass_geojson)
    assert_geom_equal(fcgeo, geom_geojson)


def test_geom_from_geojson_no_geometry(feature_geojson):
    feature_geojson.pop('geometry')
    with pytest.raises(geojson.GeoJSONException):
        _ = geojson.geom_from_geojson(feature_geojson)


def test_geom_from_geojson_missing_coordinates(geom_geojson):
    geom_geojson.pop('coordinates')
    with pytest.raises(geojson.GeoJSONException):
        _ = geojson.geom_from_geojson(geom_geojson)


def test_geom_from_geojson_missing_type(geom_geojson):
    geom_geojson.pop('type')
    with pytest.raises(geojson.GeoJSONException):
        _ = geojson.geom_from_geojson(geom_geojson)


def test_geom_from_geojson_multiple_features(featureclass_geojson):
    # duplicate the feature
    featureclass_geojson['features'] = 2 * featureclass_geojson['features']
    with pytest.raises(geojson.MultipleFeaturesException):
        _ = geojson.geom_from_geojson(featureclass_geojson)


def test_validate_geom_invalid_type(geom_geojson):
    geom_geojson['type'] = 'invalid'
    with pytest.raises(geojson.GeoJSONException):
        _ = geojson.validate_geom(geom_geojson)


def test_validate_geom_wrong_type(geom_geojson):
    geom_geojson['type'] = 'point'
    with pytest.raises(geojson.GeoJSONException):
        _ = geojson.validate_geom(geom_geojson)


def test_validate_geom_invalid_coordinates(geom_geojson):
    geom_geojson['coordinates'] = 'invalid'
    with pytest.raises(geojson.GeoJSONException):
        _ = geojson.validate_geom(geom_geojson)


def test_validate_geom_empty_coordinates(geom_geojson):
    geom_geojson['coordinates'] = []
    _ = geojson.validate_geom(geom_geojson)


def test_as_geom(geom_geojson):
    assert geojson.as_geom(geom_geojson) == geom_geojson


def test_as_polygon(geom_geojson):
    assert geojson.as_polygon(geom_geojson) == geom_geojson


def test_as_polygon_wrong_type(point_geom_geojson):
    with pytest.raises(geojson.WrongTypeException):
        _ = geojson.as_polygon(point_geom_geojson)

tests/unit/test_cli_main.py METASEP
# 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.
import logging

import pytest

import click
from click.testing import CliRunner

from planet.cli import cli

LOGGER = logging.getLogger(__name__)


@pytest.fixture
def debug_input():
    return ['debug', ' debug ', 'debu', 45]


def test_cli_orders_quiet():

    runner = CliRunner()

    # Valid and invalid inputs for the "quiet" flag
    valid_quiet_inputs = ['--quiet', ' --quiet', ' --quiet ']
    invalid_quiet_inputs = ['--quiet ', '-q', '--not_a_valid_input', 123]

    # Test the valid quiet inputs
    for quiet_input in valid_quiet_inputs:
        valid_result = runner.invoke(cli.main, args=[quiet_input, 'orders'])
        assert not valid_result.exception
        assert valid_result.exit_code == 0
    # Test the invalid quiet inputs
    for quiet_input in invalid_quiet_inputs:
        invalid_result = runner.invoke(cli.main, args=[quiet_input, 'orders'])
        assert invalid_result.exception
        assert invalid_result.exit_code != 0


def test_cli_info_verbosity(monkeypatch):
    log_level = None

    # dummy command so we can invoke cli
    @click.command()
    def test():
        pass

    cli.main.add_command(test)

    def configtest(stream, level, format):
        nonlocal log_level
        log_level = level

    monkeypatch.setattr(cli.logging, 'basicConfig', configtest)

    runner = CliRunner()
    result = runner.invoke(cli.main, args=['test'])
    assert result.exit_code == 0
    assert log_level == logging.WARNING

    def paramaterized_tests(debug_input):
        runner = CliRunner()
        result = runner.invoke(cli.main,
                               args=['--verbosity', debug_input, 'orders'])
        # Testing to ensure command was run succesfully and
        # \log_level set to debug
        if debug_input not in ['debug', ' debug ']:
            assert result.exit_code == 2
        else:
            assert result.exit_code == 0
            assert log_level == logging.DEBUG

    paramaterized_tests(debug_input)

tests/unit/test_cli_io.py METASEP
# 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 unittest.mock import patch

import pytest

from planet.cli import io


@pytest.mark.parametrize("pretty,expected",
                         [(False, '{"key": "val"}'),
                          (True, '{\n  "key": "val"\n}')])
@patch('planet.cli.io.click.echo')
def test_cli_echo_json(mock_echo, pretty, expected):
    obj = {'key': 'val'}
    io.echo_json(obj, pretty)
    mock_echo.assert_called_once_with(expected)

tests/unit/test_cli_data.py METASEP
"""Fast, isolated tests of functions in planet.cli.data."""

import json

from planet.cli.data import parse_item_types, parse_filter


def test_filter_callback():
    """Filter callback converts JSON to a dict."""
    result = parse_filter(None, None, json.dumps(dict(filter="yes")))
    assert result == {"filter": "yes"}


def test_item_types_callback():
    """Item types callback converts string to a list."""
    result = parse_item_types(None, None, "oh,yeah")
    assert result == ["oh", "yeah"]

tests/unit/test_auth.py METASEP
# Copyright 2020 Planet Labs, Inc.
#
# 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 logging

import pytest

from planet import auth

LOGGER = logging.getLogger(__name__)


# skip the global mock of _SecretFile.read
# for this module
@pytest.fixture(autouse=True, scope='module')
def test_secretfile_read():
    return


@pytest.fixture
def secret_path(monkeypatch, tmp_path):
    secret_path = str(tmp_path / '.test')
    monkeypatch.setattr(auth, 'SECRET_FILE_PATH', secret_path)
    yield secret_path


def test_Auth_from_key():
    test_auth_env1 = auth.Auth.from_key('testkey')
    assert test_auth_env1.value == 'testkey'


def test_Auth_from_key_empty():
    with pytest.raises(auth.APIKeyAuthException):
        _ = auth.Auth.from_key('')


def test_Auth_from_file(secret_path):
    with open(secret_path, 'w') as fp:
        fp.write('{"key": "testvar"}')

    test_auth = auth.Auth.from_file()
    assert test_auth.value == 'testvar'


def test_Auth_from_file_doesnotexist(secret_path):
    with pytest.raises(auth.AuthException):
        _ = auth.Auth.from_file(secret_path)


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)


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

    test_auth = auth.Auth.from_file(secret_path)
    assert test_auth.value == 'testvar'


def test_Auth_from_env(monkeypatch):
    monkeypatch.setenv('PL_API_KEY', 'testkey')
    test_auth_env = auth.Auth.from_env()
    assert test_auth_env.value == 'testkey'


def test_Auth_from_env_failure(monkeypatch):
    monkeypatch.delenv('PL_API_KEY', raising=False)
    with pytest.raises(auth.AuthException):
        _ = auth.Auth.from_env()


def test_Auth_from_env_alternate_success(monkeypatch):
    alternate = 'OTHER_VAR'
    monkeypatch.setenv(alternate, 'testkey')
    monkeypatch.delenv('PL_API_KEY', raising=False)

    test_auth_env = auth.Auth.from_env(alternate)
    assert test_auth_env.value == 'testkey'


def test_Auth_from_env_alternate_doesnotexist(monkeypatch):
    alternate = 'OTHER_VAR'
    monkeypatch.delenv(alternate, raising=False)
    monkeypatch.delenv('PL_API_KEY', raising=False)

    with pytest.raises(auth.AuthException):
        _ = auth.Auth.from_env(alternate)


def test_Auth_from_login(monkeypatch):
    auth_data = 'authdata'

    def login(*args, **kwargs):
        return {'api_key': auth_data}

    monkeypatch.setattr(auth.AuthClient, 'login', login)

    test_auth = auth.Auth.from_login('email', 'pw')
    assert test_auth.value == auth_data


def test_Auth_write_doesnotexist(tmp_path):
    test_auth = auth.Auth.from_key('test')
    secret_path = str(tmp_path / '.test')
    test_auth.write(secret_path)

    with open(secret_path, 'r') as fp:
        assert json.loads(fp.read()) == {"key": "test"}


def test_Auth_write_exists(tmp_path):
    secret_path = str(tmp_path / '.test')

    with open(secret_path, 'w') as fp:
        fp.write('{"existing": "exists"}')

    test_auth = auth.Auth.from_key('test')
    test_auth.write(secret_path)

    with open(secret_path, 'r') as fp:
        assert json.loads(fp.read()) == {"key": "test", "existing": "exists"}

tests/integration/test_orders_cli.py METASEP
# 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.
'''Test Orders CLI'''
import copy
from http import HTTPStatus
import json
from pathlib import Path
from unittest.mock import Mock

import click
from click.testing import CliRunner
import httpx
import pytest
import respx

from planet.cli import cli

TEST_URL = 'http://MockNotRealURL/api/path'
TEST_DOWNLOAD_URL = f'{TEST_URL}/download'
TEST_ORDERS_URL = f'{TEST_URL}/orders/v2'

# NOTE: These tests use a lot of the same mocked responses as test_orders_api.


@pytest.fixture
def invoke():

    def _invoke(extra_args, runner=None):
        runner = runner or CliRunner()
        args = ['orders', '--base-url', TEST_URL] + extra_args
        return runner.invoke(cli.main, args=args)

    return _invoke


def test_split_list_arg_empty_string():
    with pytest.raises(click.exceptions.BadParameter):
        cli.orders.split_list_arg(None, None, '')


def test_split_list_arg_None():
    assert cli.orders.split_list_arg(None, None, None) is None


@respx.mock
def test_cli_orders_list_basic(invoke, order_descriptions):
    next_page_url = TEST_ORDERS_URL + '/blob/?page_marker=IAmATest'
    order1, order2, order3 = order_descriptions

    page1_response = {
        "_links": {
            "_self": "string", "next": next_page_url
        },
        "orders": [order1, order2]
    }
    mock_resp1 = httpx.Response(HTTPStatus.OK, json=page1_response)
    respx.get(TEST_ORDERS_URL).return_value = mock_resp1

    page2_response = {"_links": {"_self": next_page_url}, "orders": [order3]}
    mock_resp2 = httpx.Response(HTTPStatus.OK, json=page2_response)
    respx.get(next_page_url).return_value = mock_resp2

    result = invoke(['list'])
    assert not result.exception
    assert [order1, order2, order3] == json.loads(result.output)


@respx.mock
def test_cli_orders_list_empty(invoke):
    page1_response = {"_links": {"_self": "string"}, "orders": []}
    mock_resp = httpx.Response(HTTPStatus.OK, json=page1_response)
    respx.get(TEST_ORDERS_URL).return_value = mock_resp

    result = invoke(['list'])
    assert not result.exception
    assert [] == json.loads(result.output)


@respx.mock
def test_cli_orders_list_state(invoke, order_descriptions):
    list_url = TEST_ORDERS_URL + '?state=failed'

    order1, order2, _ = order_descriptions

    page1_response = {
        "_links": {
            "_self": "string"
        }, "orders": [order1, order2]
    }
    mock_resp = httpx.Response(HTTPStatus.OK, json=page1_response)
    respx.get(list_url).return_value = mock_resp

    # if the value of state doesn't get sent as a url parameter,
    # the mock will fail and this test will fail
    result = invoke(['list', '--state', 'failed'])
    assert not result.exception
    assert [order1, order2] == json.loads(result.output)


@respx.mock
def test_cli_orders_list_limit(invoke, order_descriptions):
    order1, order2, _ = order_descriptions

    page1_response = {
        "_links": {
            "_self": "string"
        }, "orders": [order1, order2]
    }
    mock_resp = httpx.Response(HTTPStatus.OK, json=page1_response)

    # limiting is done within the client, no change to api call
    respx.get(TEST_ORDERS_URL).return_value = mock_resp

    result = invoke(['list', '--limit', '1'])
    assert not result.exception
    assert [order1] == json.loads(result.output)


@respx.mock
def test_cli_orders_list_pretty(invoke, monkeypatch, order_description):
    mock_echo_json = Mock()
    monkeypatch.setattr(cli.orders, 'echo_json', mock_echo_json)

    page1_response = {
        "_links": {
            "_self": "string"
        }, "orders": [order_description]
    }
    mock_resp = httpx.Response(HTTPStatus.OK, json=page1_response)
    respx.get(TEST_ORDERS_URL).return_value = mock_resp

    result = invoke(['list', '--pretty'])
    assert not result.exception
    mock_echo_json.assert_called_once_with([order_description], True)


@respx.mock
def test_cli_orders_get(invoke, oid, order_description):
    get_url = f'{TEST_ORDERS_URL}/{oid}'
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.get(get_url).return_value = mock_resp

    result = invoke(['get', oid])
    assert not result.exception
    assert order_description == json.loads(result.output)


@respx.mock
def test_cli_orders_get_id_not_found(invoke, oid):
    get_url = f'{TEST_ORDERS_URL}/{oid}'
    error_json = {"message": "Error message"}
    mock_resp = httpx.Response(404, json=error_json)
    respx.get(get_url).return_value = mock_resp

    result = invoke(['get', oid])
    assert result.exception
    assert 'Error: {"message": "Error message"}\n' == result.output


@respx.mock
def test_cli_orders_cancel(invoke, oid, order_description):
    cancel_url = f'{TEST_ORDERS_URL}/{oid}'
    order_description['state'] = 'cancelled'
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.put(cancel_url).return_value = mock_resp

    result = invoke(['cancel', oid])
    assert not result.exception
    assert str(mock_resp.json()) + '\n' == result.output


@respx.mock
def test_cli_orders_cancel_id_not_found(invoke, oid):
    cancel_url = f'{TEST_ORDERS_URL}/{oid}'
    error_json = {"message": "Error message"}
    mock_resp = httpx.Response(404, json=error_json)
    respx.put(cancel_url).return_value = mock_resp

    result = invoke(['cancel', oid])
    assert result.exception
    assert 'Error: {"message": "Error message"}\n' == result.output


@respx.mock
def test_cli_orders_wait_default(invoke, order_description, oid):
    get_url = f'{TEST_ORDERS_URL}/{oid}'

    order_description2 = copy.deepcopy(order_description)
    order_description2['state'] = 'success'

    route = respx.get(get_url)
    route.side_effect = [
        httpx.Response(HTTPStatus.OK, json=order_description),
        httpx.Response(HTTPStatus.OK, json=order_description2)
    ]

    runner = CliRunner()
    result = invoke(['wait', '--delay', '0', oid], runner=runner)
    assert not result.exception
    assert result.output.endswith('success\n')


@respx.mock
def test_cli_orders_wait_max_attempts(invoke, order_description, oid):
    get_url = f'{TEST_ORDERS_URL}/{oid}'

    order_description2 = copy.deepcopy(order_description)
    order_description2['state'] = 'running'
    order_description3 = copy.deepcopy(order_description)
    order_description3['state'] = 'success'

    route = respx.get(get_url)
    route.side_effect = [httpx.Response(HTTPStatus.OK, json=order_description)]

    runner = CliRunner()
    result = invoke(['wait', '--delay', '0', '--max-attempts', '1', oid],
                    runner=runner)
    assert result.exception
    assert result.output.endswith(
        'Error: Maximum number of attempts (1) reached.\n')


@pytest.fixture
def mock_download_response(oid, order_description):

    def _func():
        # Mock an HTTP response for download
        order_description['state'] = 'success'
        dl_url1 = TEST_DOWNLOAD_URL + '/1?token=IAmAToken'
        dl_url2 = TEST_DOWNLOAD_URL + '/2?token=IAmAnotherToken'
        order_description['_links']['results'] = [{
            'location': dl_url1
        }, {
            'location': dl_url2
        }]

        get_url = f'{TEST_ORDERS_URL}/{oid}'
        mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
        respx.get(get_url).return_value = mock_resp

        mock_resp1 = httpx.Response(HTTPStatus.OK,
                                    json={'key': 'value'},
                                    headers={
                                        'Content-Type':
                                        'application/json',
                                        'Content-Disposition':
                                        'attachment; filename="m1.json"'
                                    })
        respx.get(dl_url1).return_value = mock_resp1

        mock_resp2 = httpx.Response(HTTPStatus.OK,
                                    json={'key2': 'value2'},
                                    headers={
                                        'Content-Type':
                                        'application/json',
                                        'Content-Disposition':
                                        'attachment; filename="m2.json"'
                                    })
        respx.get(dl_url2).return_value = mock_resp2

    return _func


@respx.mock
def test_cli_orders_download_default(invoke, mock_download_response, oid):
    mock_download_response()

    runner = CliRunner()
    with runner.isolated_filesystem() as folder:
        result = invoke(['download', oid], runner=runner)
        assert not result.exception

        # basic check of progress reporting
        assert 'm1.json' in result.output

        # Check that the files were downloaded and have the correct contents
        f1_path = Path(folder) / 'm1.json'
        assert json.load(open(f1_path)) == {'key': 'value'}
        f2_path = Path(folder) / 'm2.json'
        assert json.load(open(f2_path)) == {'key2': 'value2'}


@respx.mock
def test_cli_orders_download_dest(invoke, mock_download_response, oid):
    mock_download_response()

    runner = CliRunner()
    with runner.isolated_filesystem() as folder:
        dest_dir = Path(folder) / 'foobar'
        dest_dir.mkdir()
        result = invoke(['download', '--directory', 'foobar', oid],
                        runner=runner)
        assert not result.exception

        # Check that the files were downloaded to the custom directory
        f1_path = dest_dir / 'm1.json'
        assert json.load(open(f1_path)) == {'key': 'value'}
        f2_path = dest_dir / 'm2.json'
        assert json.load(open(f2_path)) == {'key2': 'value2'}


@respx.mock
def test_cli_orders_download_overwrite(invoke,
                                       mock_download_response,
                                       oid,
                                       write_to_tmp_json_file):
    mock_download_response()

    runner = CliRunner()
    with runner.isolated_filesystem() as folder:
        filepath = Path(folder) / 'm1.json'
        write_to_tmp_json_file({'foo': 'bar'}, filepath)

        # check the file doesn't get overwritten by default
        result = invoke(['download', oid], runner=runner)
        assert not result.exception
        assert json.load(open(filepath)) == {'foo': 'bar'}

        # check the file gets overwritten
        result = invoke(['download', '--overwrite', oid], runner=runner)
        assert not result.exception
        assert json.load(open(filepath)) == {'key': 'value'}


@respx.mock
def test_cli_orders_download_state(invoke, order_description, oid):
    get_url = f'{TEST_ORDERS_URL}/{oid}'

    order_description['state'] = 'running'
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.get(get_url).return_value = mock_resp

    runner = CliRunner()
    result = invoke(['download', oid], runner=runner)

    assert result.exception
    assert 'order state (running) is not a final state.' in result.output


@pytest.mark.parametrize(
    "id_string, expected_ids",
    [('4500474_2133707_2021-05-20_2419', ['4500474_2133707_2021-05-20_2419']),
     ('4500474_2133707_2021-05-20_2419,4500474_2133707_2021-05-20_2420',
      ['4500474_2133707_2021-05-20_2419', '4500474_2133707_2021-05-20_2420'])])
@respx.mock
def test_cli_orders_create_basic_success(expected_ids,
                                         id_string,
                                         invoke,
                                         order_description):
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.post(TEST_ORDERS_URL).return_value = mock_resp

    result = invoke([
        'create',
        '--name=test',
        f'--id={id_string}',
        '--bundle=analytic',
        '--item-type=PSOrthoTile'
    ])
    assert not result.exception
    assert order_description == json.loads(result.output)

    order_request = {
        "name":
        "test",
        "products": [{
            "item_ids": expected_ids,
            "item_type": "PSOrthoTile",
            "product_bundle": "analytic"
        }],
    }
    sent_request = json.loads(respx.calls.last.request.content)
    assert sent_request == order_request


def test_cli_orders_create_basic_item_type_invalid(invoke):
    result = invoke([
        'create',
        '--name=test',
        '--id=4500474_2133707_2021-05-20_2419',
        '--bundle=analytic',
        '--item-type=invalid'
    ])
    assert result.exception
    assert 'Error: Invalid value: item_type' in result.output


def test_cli_orders_create_id_empty(invoke):
    result = invoke([
        'create',
        '--name',
        'test',
        '--id',
        '',
        '--bundle',
        'analytic',
        '--item-type',
        'PSOrthoTile'
    ])
    assert result.exit_code
    assert 'Entry cannot be an empty string.' in result.output


@respx.mock
def test_cli_orders_create_clip(invoke,
                                geom_geojson,
                                order_description,
                                write_to_tmp_json_file):
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.post(TEST_ORDERS_URL).return_value = mock_resp

    aoi_file = write_to_tmp_json_file(geom_geojson, 'aoi.geojson')

    result = invoke([
        'create',
        '--name',
        'test',
        '--id',
        '4500474_2133707_2021-05-20_2419',
        '--bundle',
        'analytic',
        '--item-type',
        'PSOrthoTile',
        '--clip',
        aoi_file
    ])
    assert not result.exception

    order_request = {
        "name":
        "test",
        "products": [{
            "item_ids": ["4500474_2133707_2021-05-20_2419"],
            "item_type": "PSOrthoTile",
            "product_bundle": "analytic",
        }],
        "tools": [{
            'clip': {
                'aoi': geom_geojson
            }
        }]
    }
    sent_request = json.loads(respx.calls.last.request.content)
    assert sent_request == order_request


@respx.mock
def test_cli_orders_create_clip_featureclass(invoke,
                                             featureclass_geojson,
                                             geom_geojson,
                                             order_description,
                                             write_to_tmp_json_file):
    """Tests that the clip option takes in feature class geojson as well"""
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.post(TEST_ORDERS_URL).return_value = mock_resp

    fc_file = write_to_tmp_json_file(featureclass_geojson, 'fc.geojson')

    result = invoke([
        'create',
        '--name',
        'test',
        '--id',
        '4500474_2133707_2021-05-20_2419',
        '--bundle',
        'analytic',
        '--item-type',
        'PSOrthoTile',
        '--clip',
        fc_file
    ])
    assert not result.exception

    order_request = {
        "name":
        "test",
        "products": [{
            "item_ids": ["4500474_2133707_2021-05-20_2419"],
            "item_type": "PSOrthoTile",
            "product_bundle": "analytic",
        }],
        "tools": [{
            'clip': {
                'aoi': geom_geojson
            }
        }]
    }
    sent_request = json.loads(respx.calls.last.request.content)
    assert sent_request == order_request


def test_cli_orders_create_clip_invalid_geometry(invoke,
                                                 point_geom_geojson,
                                                 write_to_tmp_json_file):
    aoi_file = write_to_tmp_json_file(point_geom_geojson, 'aoi.geojson')

    result = invoke([
        'create',
        '--name',
        'test',
        '--id',
        '4500474_2133707_2021-05-20_2419',
        '--bundle',
        'analytic',
        '--item-type',
        'PSOrthoTile',
        '--clip',
        aoi_file
    ])
    assert result.exception
    error_msg = ('Error: Invalid value: Invalid geometry type: ' +
                 'Point is not Polygon.')
    assert error_msg in result.output


def test_cli_orders_create_clip_and_tools(invoke,
                                          geom_geojson,
                                          write_to_tmp_json_file):
    # interestingly, it is important that both clip and tools
    # option values lead to valid json files
    aoi_file = write_to_tmp_json_file(geom_geojson, 'aoi.geojson')

    result = invoke([
        'create',
        '--name',
        'test',
        '--id',
        '4500474_2133707_2021-05-20_2419',
        '--bundle',
        'analytic',
        '--item-type',
        'PSOrthoTile',
        '--clip',
        aoi_file,
        '--tools',
        aoi_file
    ])
    assert result.exception
    assert "Specify only one of '--clip' or '--tools'" in result.output


@respx.mock
def test_cli_orders_create_cloudconfig(invoke,
                                       geom_geojson,
                                       order_description,
                                       write_to_tmp_json_file):
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.post(TEST_ORDERS_URL).return_value = mock_resp

    config_json = {
        'amazon_s3': {
            'aws_access_key_id': 'aws_access_key_id',
            'aws_secret_access_key': 'aws_secret_access_key',
            'bucket': 'bucket',
            'aws_region': 'aws_region'
        },
        'archive_type': 'zip'
    }
    config_file = write_to_tmp_json_file(config_json, 'config.json')

    result = invoke([
        'create',
        '--name',
        'test',
        '--id',
        '4500474_2133707_2021-05-20_2419',
        '--bundle',
        'analytic',
        '--item-type',
        'PSOrthoTile',
        '--cloudconfig',
        config_file
    ])
    assert not result.exception

    order_request = {
        "name":
        "test",
        "products": [{
            "item_ids": ["4500474_2133707_2021-05-20_2419"],
            "item_type": "PSOrthoTile",
            "product_bundle": "analytic",
        }],
        "delivery":
        config_json
    }
    sent_request = json.loads(respx.calls.last.request.content)
    assert sent_request == order_request


@respx.mock
def test_cli_orders_create_email(invoke, geom_geojson, order_description):
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.post(TEST_ORDERS_URL).return_value = mock_resp

    result = invoke([
        'create',
        '--name',
        'test',
        '--id',
        '4500474_2133707_2021-05-20_2419',
        '--bundle',
        'analytic',
        '--item-type',
        'PSOrthoTile',
        '--email'
    ])
    assert not result.exception

    order_request = {
        "name":
        "test",
        "products": [{
            "item_ids": ["4500474_2133707_2021-05-20_2419"],
            "item_type": "PSOrthoTile",
            "product_bundle": "analytic",
        }],
        "notifications": {
            "email": True
        }
    }
    sent_request = json.loads(respx.calls.last.request.content)
    assert sent_request == order_request


@respx.mock
def test_cli_orders_create_tools(invoke,
                                 geom_geojson,
                                 order_description,
                                 write_to_tmp_json_file):
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.post(TEST_ORDERS_URL).return_value = mock_resp

    tools_json = [{'clip': {'aoi': geom_geojson}}, {'composite': {}}]
    tools_file = write_to_tmp_json_file(tools_json, 'tools.json')

    result = invoke([
        'create',
        '--name=test',
        '--id=4500474_2133707_2021-05-20_2419',
        '--bundle=analytic',
        '--item-type=PSOrthoTile',
        f'--tools={tools_file}'
    ])
    assert not result.exception

    order_request = {
        "name":
        "test",
        "products": [{
            "item_ids": ["4500474_2133707_2021-05-20_2419"],
            "item_type": "PSOrthoTile",
            "product_bundle": "analytic",
        }],
        "tools":
        tools_json
    }
    sent_request = json.loads(respx.calls.last.request.content)
    assert sent_request == order_request


def test_cli_orders_read_file_json_doesnotexist(invoke):
    result = invoke([
        'create',
        '--name=test',
        '--id=4500474_2133707_2021-05-20_2419',
        '--bundle=analytic',
        '--item-type=PSOrthoTile',
        '--tools=doesnnotexist.json'
    ])
    assert result.exception
    error_msg = ("Error: Invalid value for '--tools': 'doesnnotexist.json': " +
                 "No such file or directory")
    assert error_msg in result.output


def test_cli_orders_read_file_json_invalidjson(invoke, tmp_path):
    invalid_filename = tmp_path / 'invalid.json'
    with open(invalid_filename, 'w') as fp:
        fp.write('[Invali]d j*son')

    result = invoke([
        'create',
        '--name=test',
        '--id=4500474_2133707_2021-05-20_2419',
        '--bundle=analytic',
        '--item-type=PSOrthoTile',
        f'--tools={invalid_filename}'
    ])
    assert result.exception
    error_msg = "Error: File does not contain valid json."
    assert error_msg in result.output

tests/integration/test_orders_api.py METASEP
# Copyright 2020 Planet Labs, Inc.
#
# 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 copy
import json
from http import HTTPStatus
import logging
import math
import os
from pathlib import Path
from unittest.mock import call, create_autospec

import httpx
import pytest
import respx

from planet import OrdersClient, exceptions, reporting
from planet.clients.orders import OrderStates

TEST_URL = 'http://www.MockNotRealURL.com/api/path'
TEST_BULK_CANCEL_URL = f'{TEST_URL}/bulk/orders/v2/cancel'
TEST_DOWNLOAD_URL = f'{TEST_URL}/download'
TEST_ORDERS_URL = f'{TEST_URL}/orders/v2'
TEST_STATS_URL = f'{TEST_URL}/stats/orders/v2'

LOGGER = logging.getLogger(__name__)


@pytest.fixture
def downloaded_content():
    return {'key': 'downloaded_file'}


@pytest.fixture
def original_content():
    return {'key': 'original_file'}


@pytest.fixture
def create_download_mock(downloaded_content, order_description, oid):
    '''
    Mock an HTTP response for download.
    '''

    def f():
        # Create mock HTTP response
        order_description['state'] = 'success'
        dl_url = TEST_DOWNLOAD_URL + '/1?token=IAmAToken'
        order_description['_links']['results'] = [
            {
                'location': dl_url
            },
        ]

        get_url = f'{TEST_ORDERS_URL}/{oid}'
        mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
        respx.get(get_url).return_value = mock_resp

        mock_resp = httpx.Response(HTTPStatus.OK,
                                   json=downloaded_content,
                                   headers={
                                       'Content-Type':
                                       'application/json',
                                       'Content-Disposition':
                                       'attachment; filename="file.json"'
                                   })
        respx.get(dl_url).return_value = mock_resp

    return f


def test_OrderStates_reached():
    assert not OrderStates.reached('running', 'queued')
    assert OrderStates.reached('running', 'running')
    assert OrderStates.reached('running', 'failed')


def test_OrderStates_passed():
    assert not OrderStates.reached('running', 'queued')
    assert not OrderStates.passed('running', 'running')
    assert OrderStates.passed('running', 'success')


@respx.mock
@pytest.mark.asyncio
async def test_list_orders_basic(order_descriptions, session):
    next_page_url = TEST_ORDERS_URL + 'blob/?page_marker=IAmATest'

    order1, order2, order3 = order_descriptions

    page1_response = {
        "_links": {
            "_self": "string", "next": next_page_url
        },
        "orders": [order1, order2]
    }
    mock_resp1 = httpx.Response(HTTPStatus.OK, json=page1_response)
    respx.get(TEST_ORDERS_URL).return_value = mock_resp1

    page2_response = {"_links": {"_self": next_page_url}, "orders": [order3]}
    mock_resp2 = httpx.Response(HTTPStatus.OK, json=page2_response)
    respx.get(next_page_url).return_value = mock_resp2

    cl = OrdersClient(session, base_url=TEST_URL)
    orders = await cl.list_orders()
    assert order_descriptions == [o async for o in orders]


@respx.mock
@pytest.mark.asyncio
async def test_list_orders_state(order_descriptions, session):
    list_url = TEST_ORDERS_URL + '?state=failed'

    order1, order2, _ = order_descriptions

    page1_response = {
        "_links": {
            "_self": "string"
        }, "orders": [order1, order2]
    }
    mock_resp = httpx.Response(HTTPStatus.OK, json=page1_response)
    respx.get(list_url).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)

    # if the value of state doesn't get sent as a url parameter,
    # the mock will fail and this test will fail
    orders = await cl.list_orders(state='failed')
    assert [order1, order2] == [o async for o in orders]


@pytest.mark.asyncio
async def test_list_orders_state_invalid_state(session):
    cl = OrdersClient(session, base_url=TEST_URL)

    with pytest.raises(exceptions.ClientError):
        await cl.list_orders(state='invalidstate')


@respx.mock
@pytest.mark.asyncio
async def test_list_orders_limit(order_descriptions, session):
    nono_page_url = TEST_ORDERS_URL + '?page_marker=OhNoNo'

    order1, order2, order3 = order_descriptions

    page1_response = {
        "_links": {
            "_self": "string", "next": nono_page_url
        },
        "orders": [order1, order2]
    }
    mock_resp = httpx.Response(HTTPStatus.OK, json=page1_response)
    respx.get(TEST_ORDERS_URL).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)

    # since nono_page_url is not mocked, an error will occur if the client
    # attempts to access the next page when the limit is already reached
    orders = await cl.list_orders(limit=1)
    assert [order1] == [o async for o in orders]


@respx.mock
@pytest.mark.asyncio
async def test_create_order(oid, order_description, order_request, session):
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.post(TEST_ORDERS_URL).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)
    order = await cl.create_order(order_request)

    assert order == order_description


@respx.mock
@pytest.mark.asyncio
async def test_create_order_bad_item_type(order_request, session):
    resp = {
        "field": {
            "Products": [{
                "message":
                "Bad item type 'invalid' for bundle type 'analytic'"
            }]
        },
        "general": [{
            "message": "Unable to accept order"
        }]
    }
    mock_resp = httpx.Response(400, json=resp)
    respx.post(TEST_ORDERS_URL).return_value = mock_resp
    order_request['products'][0]['item_type'] = 'invalid'
    cl = OrdersClient(session, base_url=TEST_URL)

    with pytest.raises(exceptions.BadQuery):
        await cl.create_order(order_request)


@respx.mock
@pytest.mark.asyncio
async def test_create_order_item_id_does_not_exist(order_request, session):
    resp = {
        "field": {
            "Details": [{
                "message": ("Item ID 4500474_2133707_2021-05-20_2419 / " +
                            "Item Type PSScene3Band doesn't exist")
            }]
        },
        "general": [{
            "message": "Unable to accept order"
        }]
    }
    mock_resp = httpx.Response(400, json=resp)
    respx.post(TEST_ORDERS_URL).return_value = mock_resp
    order_request['products'][0]['item_ids'] = \
        '4500474_2133707_2021-05-20_2419'
    cl = OrdersClient(session, base_url=TEST_URL)

    with pytest.raises(exceptions.BadQuery):
        await cl.create_order(order_request)


@respx.mock
@pytest.mark.asyncio
async def test_get_order(oid, order_description, session):
    get_url = f'{TEST_ORDERS_URL}/{oid}'
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.get(get_url).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)
    order = await cl.get_order(oid)
    assert order_description == order


@pytest.mark.asyncio
async def test_get_order_invalid_id(session):
    cl = OrdersClient(session, base_url=TEST_URL)
    with pytest.raises(exceptions.ClientError):
        await cl.get_order('-')


@respx.mock
@pytest.mark.asyncio
async def test_get_order_id_doesnt_exist(oid, session):
    get_url = f'{TEST_ORDERS_URL}/{oid}'

    resp = {"message": f'Could not load order ID: {oid}'}
    mock_resp = httpx.Response(404, json=resp)
    respx.get(get_url).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)

    with pytest.raises(exceptions.MissingResource):
        await cl.get_order(oid)


@respx.mock
@pytest.mark.asyncio
async def test_cancel_order(oid, order_description, session):
    cancel_url = f'{TEST_ORDERS_URL}/{oid}'
    order_description['state'] = 'cancelled'
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    example_resp = mock_resp.json()
    respx.put(cancel_url).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)
    json_resp = await cl.cancel_order(oid)
    assert json_resp == example_resp


@pytest.mark.asyncio
async def test_cancel_order_invalid_id(session):
    cl = OrdersClient(session, base_url=TEST_URL)
    with pytest.raises(exceptions.ClientError):
        await cl.cancel_order('invalid_order_id')


@respx.mock
@pytest.mark.asyncio
async def test_cancel_order_id_doesnt_exist(oid, session):
    cancel_url = f'{TEST_ORDERS_URL}/{oid}'

    resp = {"message": f'No such order ID: {oid}.'}
    mock_resp = httpx.Response(404, json=resp)
    respx.put(cancel_url).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)

    with pytest.raises(exceptions.MissingResource):
        await cl.cancel_order(oid)


@respx.mock
@pytest.mark.asyncio
async def test_cancel_order_id_cannot_be_cancelled(oid, session):
    cancel_url = f'{TEST_ORDERS_URL}/{oid}'

    resp = {"message": 'Order not in a cancellable state'}
    mock_resp = httpx.Response(409, json=resp)
    respx.put(cancel_url).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)

    with pytest.raises(exceptions.Conflict):
        await cl.cancel_order(oid)


@respx.mock
@pytest.mark.asyncio
async def test_cancel_orders_by_ids(session, oid):
    oid2 = '5ece1dc0-ea81-11eb-837c-acde48001122'
    test_ids = [oid, oid2]
    example_result = {
        "result": {
            "succeeded": {
                "count": 1
            },
            "failed": {
                "count":
                1,
                "failures": [{
                    "order_id": oid2,
                    "message": "Order not in a cancellable state",
                }]
            }
        }
    }
    mock_resp = httpx.Response(HTTPStatus.OK, json=example_result)
    respx.post(TEST_BULK_CANCEL_URL).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)
    res = await cl.cancel_orders(test_ids)

    assert res == example_result

    expected_body = {"order_ids": test_ids}
    actual_body = json.loads(respx.calls.last.request.content)
    assert actual_body == expected_body


@pytest.mark.asyncio
async def test_cancel_orders_by_ids_invalid_id(session, oid):
    cl = OrdersClient(session, base_url=TEST_URL)
    with pytest.raises(exceptions.ClientError):
        _ = await cl.cancel_orders([oid, "invalid_oid"])


@respx.mock
@pytest.mark.asyncio
async def test_cancel_orders_all(session):
    example_result = {
        "result": {
            "succeeded": {
                "count": 2
            }, "failed": {
                "count": 0, "failures": []
            }
        }
    }
    mock_resp = httpx.Response(HTTPStatus.OK, json=example_result)
    respx.post(TEST_BULK_CANCEL_URL).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)
    res = await cl.cancel_orders()

    assert res == example_result

    actual_body = json.loads(respx.calls.last.request.content)
    assert actual_body == {}


@respx.mock
@pytest.mark.asyncio
async def test_wait_default(oid, order_description, session):
    get_url = f'{TEST_ORDERS_URL}/{oid}'

    order_description2 = copy.deepcopy(order_description)
    order_description2['state'] = 'running'
    order_description3 = copy.deepcopy(order_description)
    order_description3['state'] = 'success'

    route = respx.get(get_url)
    route.side_effect = [
        httpx.Response(HTTPStatus.OK, json=order_description),
        httpx.Response(HTTPStatus.OK, json=order_description2),
        httpx.Response(HTTPStatus.OK, json=order_description3)
    ]

    cl = OrdersClient(session, base_url=TEST_URL)
    state = await cl.wait(oid, delay=0)
    assert state == 'success'


@respx.mock
@pytest.mark.asyncio
async def test_wait_callback(oid, order_description, session):
    get_url = f'{TEST_ORDERS_URL}/{oid}'

    order_description2 = copy.deepcopy(order_description)
    order_description2['state'] = 'running'
    order_description3 = copy.deepcopy(order_description)
    order_description3['state'] = 'success'

    route = respx.get(get_url)
    route.side_effect = [
        httpx.Response(HTTPStatus.OK, json=order_description),
        httpx.Response(HTTPStatus.OK, json=order_description2),
        httpx.Response(HTTPStatus.OK, json=order_description3)
    ]

    mock_bar = create_autospec(reporting.StateBar)
    mock_callback = mock_bar.update_state

    cl = OrdersClient(session, base_url=TEST_URL)
    await cl.wait(oid, delay=0, callback=mock_callback)

    # check state was sent to callback as expected
    expected = [call(s) for s in ['queued', 'running', 'success']]
    mock_callback.assert_has_calls(expected)


@respx.mock
@pytest.mark.asyncio
async def test_wait_state(oid, order_description, session):
    get_url = f'{TEST_ORDERS_URL}/{oid}'

    order_description2 = copy.deepcopy(order_description)
    order_description2['state'] = 'running'
    order_description3 = copy.deepcopy(order_description)
    order_description3['state'] = 'success'

    route = respx.get(get_url)
    route.side_effect = [
        httpx.Response(HTTPStatus.OK, json=order_description),
        httpx.Response(HTTPStatus.OK, json=order_description2),
        httpx.Response(HTTPStatus.OK, json=order_description3)
    ]

    cl = OrdersClient(session, base_url=TEST_URL)
    state = await cl.wait(oid, state='running', delay=0)
    assert state == 'running'


@respx.mock
@pytest.mark.asyncio
async def test_wait_max_attempts_enabled(oid, order_description, session):
    get_url = f'{TEST_ORDERS_URL}/{oid}'

    route = respx.get(get_url)
    route.side_effect = [
        httpx.Response(HTTPStatus.OK, json=order_description),
    ]

    cl = OrdersClient(session, base_url=TEST_URL)
    with pytest.raises(exceptions.ClientError):
        await cl.wait(oid, max_attempts=1, delay=0)


@respx.mock
@pytest.mark.asyncio
async def test_wait_max_attempts_disabled(oid, order_description, session):
    get_url = f'{TEST_ORDERS_URL}/{oid}'

    route = respx.get(get_url)
    route.side_effect = [
        httpx.Response(HTTPStatus.OK, json=order_description),
    ] * 300

    cl = OrdersClient(session, base_url=TEST_URL)
    with pytest.raises(RuntimeError):
        # falls off the end of the mocked responses
        await cl.wait(oid, max_attempts=0, delay=0)


@pytest.mark.asyncio
async def test_wait_invalid_oid(session):
    cl = OrdersClient(session, base_url=TEST_URL)
    with pytest.raises(exceptions.ClientError):
        await cl.wait("invalid_oid", delay=0)


@pytest.mark.asyncio
async def test_wait_invalid_state(oid, session):
    cl = OrdersClient(session, base_url=TEST_URL)
    with pytest.raises(exceptions.ClientError):
        await cl.wait(oid, state="invalid_state", delay=0)


@respx.mock
@pytest.mark.asyncio
async def test_aggegated_order_stats(session):
    example_stats = {
        "organization": {
            "queued_orders": 0, "running_orders": 6
        },
        "user": {
            "queued_orders": 0, "running_orders": 0
        }
    }
    mock_resp = httpx.Response(HTTPStatus.OK, json=example_stats)
    respx.get(TEST_STATS_URL).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)
    res = await cl.aggregated_order_stats()

    assert res == example_stats


@respx.mock
@pytest.mark.asyncio
async def test_download_asset_md(tmpdir, session):
    dl_url = TEST_DOWNLOAD_URL + '/1?token=IAmAToken'

    md_json = {'key': 'value'}
    md_headers = {
        'Content-Type': 'application/json',
        'Content-Disposition': 'attachment; filename="metadata.json"'
    }
    mock_resp = httpx.Response(HTTPStatus.OK, json=md_json, headers=md_headers)
    respx.get(dl_url).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)
    filename = await cl.download_asset(dl_url, directory=str(tmpdir))

    assert json.loads(open(filename).read()) == {'key': 'value'}
    assert Path(filename).name == 'metadata.json'


@respx.mock
@pytest.mark.asyncio
async def test_download_asset_img(tmpdir, open_test_img, session):
    dl_url = TEST_DOWNLOAD_URL + '/1?token=IAmAToken'

    img_headers = {
        'Content-Type': 'image/tiff',
        'Content-Length': '527',
        'Content-Disposition': 'attachment; filename="img.tif"'
    }

    async def _stream_img():
        data = open_test_img.read()
        v = memoryview(data)

        chunksize = 100
        for i in range(math.ceil(len(v) / (chunksize))):
            yield v[i * chunksize:min((i + 1) * chunksize, len(v))]

    # populate request parameter to avoid respx cloning, which throws
    # an error caused by respx and not this code
    # https://github.com/lundberg/respx/issues/130
    mock_resp = httpx.Response(HTTPStatus.OK,
                               stream=_stream_img(),
                               headers=img_headers,
                               request='donotcloneme')
    respx.get(dl_url).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)
    filename = await cl.download_asset(dl_url, directory=str(tmpdir))

    assert Path(filename).name == 'img.tif'
    assert os.path.isfile(filename)


@respx.mock
@pytest.mark.asyncio
async def test_download_order_success(tmpdir, order_description, oid, session):
    '''
    Test if download_order() successfully downloads an order with two files,
    given a singular order ID.
    '''

    # Mock an HTTP response for download
    order_description['state'] = 'success'
    dl_url1 = TEST_DOWNLOAD_URL + '/1?token=IAmAToken'
    dl_url2 = TEST_DOWNLOAD_URL + '/2?token=IAmAnotherToken'
    order_description['_links']['results'] = [{
        'location': dl_url1
    }, {
        'location': dl_url2
    }]

    get_url = f'{TEST_ORDERS_URL}/{oid}'
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.get(get_url).return_value = mock_resp

    mock_resp1 = httpx.Response(HTTPStatus.OK,
                                json={'key': 'value'},
                                headers={
                                    'Content-Type':
                                    'application/json',
                                    'Content-Disposition':
                                    'attachment; filename="m1.json"'
                                })
    respx.get(dl_url1).return_value = mock_resp1

    mock_resp2 = httpx.Response(HTTPStatus.OK,
                                json={'key2': 'value2'},
                                headers={
                                    'Content-Type':
                                    'application/json',
                                    'Content-Disposition':
                                    'attachment; filename="m2.json"'
                                })
    respx.get(dl_url2).return_value = mock_resp2

    cl = OrdersClient(session, base_url=TEST_URL)
    filenames = await cl.download_order(oid, directory=str(tmpdir))

    # Check there are as many files as expected, given in order_description
    assert len(filenames) == 2

    # Check that the downloaded files have the correct filename and contents
    assert json.load(open(filenames[0])) == {'key': 'value'}
    assert Path(filenames[0]).name == 'm1.json'
    assert json.load(open(filenames[1])) == {'key2': 'value2'}
    assert Path(filenames[1]).name == 'm2.json'


@respx.mock
@pytest.mark.asyncio
async def test_download_order_state(tmpdir, order_description, oid, session):
    dl_url1 = TEST_DOWNLOAD_URL + '/1?token=IAmAToken'
    order_description['_links']['results'] = [
        {
            'location': dl_url1
        },
    ]

    get_url = f'{TEST_ORDERS_URL}/{oid}'
    mock_resp = httpx.Response(HTTPStatus.OK, json=order_description)
    respx.get(get_url).return_value = mock_resp

    cl = OrdersClient(session, base_url=TEST_URL)
    with pytest.raises(exceptions.ClientError):
        await cl.download_order(oid, directory=str(tmpdir))


@respx.mock
@pytest.mark.asyncio
async def test_download_order_overwrite_true_preexisting_data(
        tmpdir,
        oid,
        session,
        create_download_mock,
        original_content,
        downloaded_content):
    '''
    Test if download_order() overwrites pre-existing data with
    overwrite flag set to True.
    '''

    # Save JSON to a temporary file
    with open(Path(tmpdir, 'file.json'), "a") as out_file:
        json.dump(original_content, out_file)

    create_download_mock()
    cl = OrdersClient(session, base_url=TEST_URL)
    await cl.download_order(oid, directory=str(tmpdir), overwrite=True)

    # Check that the data downloaded has overwritten the original data
    assert json.load(open(Path(tmpdir, 'file.json'))) == downloaded_content


@respx.mock
@pytest.mark.asyncio
async def test_download_order_overwrite_false_preexisting_data(
        tmpdir, oid, session, create_download_mock, original_content):
    '''
    Test if download_order() does not overwrite pre-existing
    data with overwrite flag set to False.
    '''

    # Save JSON to a temporary file
    with open(Path(tmpdir, 'file.json'), "a") as out_file:
        json.dump(original_content, out_file)

    create_download_mock()
    cl = OrdersClient(session, base_url=TEST_URL)
    await cl.download_order(oid, directory=str(tmpdir), overwrite=False)

    # Check that the original data has not been overwritten
    assert json.load(open(Path(tmpdir, 'file.json'))) == original_content


@respx.mock
@pytest.mark.asyncio
async def test_download_order_overwrite_true_nonexisting_data(
        tmpdir, oid, session, create_download_mock, downloaded_content):
    '''
    Test if download_order() downloads data with overwrite flag
    set to true without pre-existing data.
    '''

    create_download_mock()
    cl = OrdersClient(session, base_url=TEST_URL)
    await cl.download_order(oid, directory=str(tmpdir), overwrite=True)

    # Check that the was data downloaded and has the correct contents
    assert json.load(open(Path(tmpdir, 'file.json'))) == downloaded_content


@respx.mock
@pytest.mark.asyncio
async def test_download_order_overwrite_false_nonexisting_data(
        tmpdir, oid, session, create_download_mock, downloaded_content):
    '''
    Test if download_order() downloads data with overwrite flag
    set to false without pre-existing data.
    '''

    create_download_mock()
    cl = OrdersClient(session, base_url=TEST_URL)
    await cl.download_order(oid, directory=str(tmpdir), overwrite=False)

    # Check that the was data downloaded and has the correct contents
    assert json.load(open(Path(tmpdir, 'file.json'))) == downloaded_content

tests/integration/test_data_cli.py METASEP
"""Tests of the Data CLI."""

from click.testing import CliRunner
import pytest

from planet.cli import cli


def test_data_command_registered():
    """planet-data command prints help and usage message."""
    result = CliRunner().invoke(cli.main, ["data", "--help"])
    assert result.exit_code == 0
    assert "Usage" in result.output
    assert "search-quick" in result.output
    # Add other sub-commands here.


# TODO: basic test for "planet data filter".


# We expect this test to fail now. When the Data API client is
# available, we will remove the xfail marker and work to get this test,
# or a better version, to pass.
@pytest.mark.xfail(reason="Data client not yet implemented")
def test_search_quick():
    """planet data search-quick prints 1 GeoJSON Feature."""
    result = CliRunner().invoke(
        cli.main,
        # When testing, we "explode" our command and its parameters
        # into a list to make parameterization more clear.
        [
            "data",
            "search-quick",
            # To keep yapf from putting option name and value on
            # different lines, use a "=".
            "--limit=10",
            "--name=test",
            "--pretty",
            "lol,wut",
            "{}"
        ])
    assert result.exit_code == 0
    assert "Feature" in result.output


# TODO: basic test for "planet data search-create".
# TODO: basic test for "planet data search-update".
# TODO: basic test for "planet data search-delete".
# TODO: basic test for "planet data search-run".
# TODO: basic test for "planet data item-get".
# TODO: basic test for "planet data asset-activate".
# TODO: basic test for "planet data asset-wait".
# TODO: basic test for "planet data asset-download".
# TODO: basic test for "planet data search-create".
# TODO: basic test for "planet data stats".

tests/integration/test_data_api.py METASEP
# 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 logging

import httpx
import pytest
import respx

from planet import DataClient

TEST_URL = 'http://www.MockNotRealURL.com/api/path'
TEST_SEARCH_URL = f'{TEST_URL}/searches'
TEST_STATS_URL = f'{TEST_URL}/stats'

LOGGER = logging.getLogger(__name__)


@pytest.fixture
def search_filter(get_test_file_json):
    filename = 'search_filter_2022_01.json'
    return get_test_file_json(filename)


@pytest.fixture
def quick_search_request(search_filter):
    return {"item_types": ["PSScene"], "filter": search_filter}


@pytest.fixture
def item_descriptions(get_test_file_json):
    item_ids = [
        '20220125_075509_67_1061',
        '20220125_075511_17_1061',
        '20220125_075650_17_1061'
    ]
    items = [get_test_file_json(f'data_item_{id}.json') for id in item_ids]
    return items


@pytest.fixture
def search_response(item_descriptions):
    response = {
        "type": "FeatureCollection",
        "_links": {
            "_first": "firstpageurl",
            "_next": "nextpageurl",
            "_self": "selfurl"
        },
        "features": item_descriptions
    }

    return response


@respx.mock
@pytest.mark.asyncio
async def test_quick_search_success(item_descriptions,
                                    quick_search_filter,
                                    quick_search_request,
                                    search_response,
                                    session):

    quick_search_url = f'{TEST_URL}/quick-search'
    mock_resp = httpx.Response(HTTPStatus.OK, json=search_response)
    respx.post(quick_search_url).return_value = mock_resp

    cl = DataClient(session, base_url=TEST_URL)
    items = list(await cl.quick_search('PSScene', quick_search_filter))

    assert mock_resp.called_once_with(quick_search_request)
    assert items == item_descriptions

tests/integration/test_auth_cli.py METASEP
# 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

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

tests/integration/test_auth_api.py METASEP
# 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')

tests/integration/conftest.py METASEP
# Copyright 2020 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 copy

import pytest

from planet import Session


@pytest.fixture
@pytest.mark.asyncio
async def session():
    async with Session() as ps:
        yield ps


@pytest.fixture
def order_descriptions(order_description):
    order1 = order_description
    order1['id'] = 'oid1'
    order2 = copy.deepcopy(order_description)
    order2['id'] = 'oid2'
    order3 = copy.deepcopy(order_description)
    order3['id'] = 'oid3'
    return [order1, order2, order3]

tests/pytest.ini METASEP
[pytest]
log_cli = True

examples/README.md METASEP
# Examples

This directory contains examples for how to use the Planet SDK for Python.

# Navigation
Each example script is named according to the convention
`<Planet API>_<use_case>.py`.

## Usage

To use these examples, just run the scripts with `python <script_name>`. These
examples hit the Planet services, so be aware that usage and fees may apply.

## Contributing

New examples are great! They just need to meet two requirements.

First off, the name of the new example needs to follow the examples naming
convention.

Second, the new example needs to be tested. All examples in this directory are
tested as a part of an automated test suite. The testing is conducted by
`test_examples.py`.
All scripts should default to downloading into the directory pointed to by the
`TEST_DOWNLOAD_DIR` environment variable, with logic for when that variable
is missing. For example, `DOWNLOAD_DIR = os.getenv('TEST_DOWNLOAD_DIR', '.')`.

Running all example script tests is a long process and also, since each script
communicates with the Planet servers, could affect your usage and incur fees.
To minimize the pain, filter the scripts tested to e.g. the script under
development with:

```console
> nox -s examples -- <script_name>.py
````

For a new script named 
`new_script.py`, this would be `nox -s examples -- new_script.py`

docs/v2_earlyaccess.md METASEP
# Early Access to Planet SDK (aka "V2")

!!! warning

    The Planet SDK for Python is in active development, and usage is provided
    as *early access* only. Use of the Planet SDK in production is **not
    recommended**.

The Planet SDK for Python is Version 2 ("V2") of what was previously referred
to as the Planet API client ("V1"). For more information, see
[Upgrading](upgrading.md).

## Installing for Local Development

  1) Clone the repository [https://github.com/planetlabs/planet-client-python/](https://github.com/planetlabs/planet-client-python/)

  2) Checkout the development branch: `git checkout main`

  3) Install the main branch as a Python package for development:
        `pip install -e .`

## How to Get Help

As The Planet SDK (V2) is in active development, features & functionality can
and will change without notice. As an early access user, 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)

docs/upgrading.md METASEP
# Upgrading

The Planet SDK for Python is Version 2 of what was previously referred to as
the Planet API client. 

Version 2 is essentially a complete rewrite and brings with it big changes to
the Python library.

In Version 1, a single client was created for all APIs,
`client=api.ClientV1(api_key=API_KEY)`. With this client, all commumication was
synchronous. Asynchronous bulk support was provided with the `downloader`
module. There was no built-in support for polling when an order was
ready to download or tracking when an order was downloaded.

In Version 2, a `Session` is used to manage all communication with the Planet
APIs. This provides for multiple asynchronous connections. An API-specific
client is created. This client manages polling and downloading, along with
any other capabilities provided by the API.

docs/index.md METASEP
# Planet SDK for Python

[![Build Status](https://travis-ci.org/planetlabs/planet-client-python.svg?branch=master)](https://travis-ci.org/planetlabs/planet-client-python)

[Planet](https://planet.com) Software Development Kit (SDK) for Python.

## Quick Start

The Planet SDK for Python allows Python developers to write software that makes
use of the following Planet APIs:

* [orders](https://developers.planet.com/docs/orders/)
* [data](https://developers.planet.com/docs/data/) (not implemented)
* [analytics](https://developers.planet.com/docs/analytics/) (not implemented)
* [basemaps](https://developers.planet.com/docs/basemaps/) (referred to in the client as `mosaics`) (not implemented)

The client modules within the Python library are asynchronous, which greatly
speeds up many interactions with Planet's APIs. Support for asynchronous
development is native to Python 3.6+ via the
[`asyncio` module](https://docs.python.org/3/library/asyncio.html). A great
resource for getting started with asynchronous programming in Python is
https://project-awesome.org/timofurrer/awesome-asyncio. The Writings and Talks
sections are particularly helpful in getting oriented.

Let's start with creating an order with the Orders API:

```python
>>> import asyncio
>>> import os
>>> import planet
>>>
>>> request = {
...   "name": "test_order",
...   "products": [
...     {
...       "item_ids": [
...         "3949357_1454705_2020-12-01_241c"
...       ],
...       "item_type": "PSOrthoTile",
...       "product_bundle": "analytic"
...     }
...   ]
... }
...
>>> async def create_order(request):
...     async with planet.Session() as ps:
...         client = planet.OrdersClient(ps)
...         return await client.create_order(request)
...
>>> oid = asyncio.run(create_order(request))

```

Not into async? No problem. Just wrap the library and async operations together
and call from your synchronous code.

```python
>>> def sync_create_order(order_details):
...     return asyncio.run(create_order(order_details))
>>>
>>> oid = sync_create_order(order_details)

```

When using `asyncio.run` to develop synchronous code with the async library,
keep in mind this excerpt from the
[asyncio.run](https://docs.python.org/3/library/asyncio-task.html#asyncio.run)
documentation:

"*This function always creates a new event loop and closes it at the end. It
should be used as a main entry point for asyncio programs, and should ideally
only be called once.*"

Do you have a use case where native synchronous support is essential? If so,
please contribute to this
[issue](https://github.com/planetlabs/planet-client-python/issues/251).

## Installation

Install with [pip](https://pip.pypa.io):

```console
$ pip install planet
```

The [--user](https://pip.pypa.io/en/stable/user_guide/#user-installs)
flag is highly recommended for those new to pip.

The Planet SDK for Python requires Python 3.7+.

## Authentication

Planet's APIs require an account for use.
[Sign up here](https://www.planet.com/explorer/?signup).

## Development

To contribute or develop with this library, see
[CONTRIBUTING](https://github.com/planetlabs/planet-client-python/CONTRIBUTING.md)

docs/guide.md METASEP
# User Guide

## API
### Session

Communication with the Planet services is provided with the `Session` class.
The recommended way to use a `Session` is as a context manager. This will
provide for automatic clean up of connections when the context is left.

```python
>>> import asyncio
>>> import os
>>> from planet import Session
>>>
>>> async def main():
...     async with Session() as sess:
...         # perform operations here
...         pass
...
>>> asyncio.run(main())

```

Alternatively, use `await Session.aclose()` to close a `Session` explicitly:

```python
>>> async def main():
...     sess = Session()
...     # perform operations here
...     await sess.aclose()
...
>>> asyncio.run(main())

```

### Authentication

There are two steps to managing authentication information, obtaining the
authentication information from Planet and then managing it for local retrieval
for authentication purposes.

The recommended method for obtaining authentication information is through
logging in, using `Auth.from_login()` (note: using something like the `getpass`
module is recommended to ensure your password remains secure). Alternatively,
the api key can be obtained directly from the Planet account site and then used
in `Auth.from_key()`.

Once the authentication information is obtained, 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, e.g. to store in an environment variable, as
`Auth.value`.

For example, to obtain and store authentication information:

```python
>>> import getpass
>>> from planet import Auth
>>>
>>> pw = getpass.getpass()
>>> auth = Auth.from_login('user', 'pw')
>>> auth.write()

```

When a `Session` is created, by default, authentication is read from the secret
file created with `Auth.write()`. This behavior 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 and the
authentication information be stored using `Auth.write()`.

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:

```python
>>> import asyncio
>>> import os
>>> from planet import Auth, Session
>>>
>>> auth = Auth.from_env('ALTERNATE_VAR')
>>> async def main():
...     async with Session(auth=auth) as sess:
...         # perform operations here
...         pass
...
>>> asyncio.run(main())

```

### Orders Client

The Orders Client mostly mirrors the
[Orders API](https://developers.planet.com/docs/orders/reference/#tag/Orders),
with the only difference being the addition of the ability to poll for when an
order is completed and to download an entire order.

```python
>>> from planet import OrdersClient
>>>
>>> async def main():
...     async with Session() as sess:
...         client = OrdersClient(sess)
...         # perform operations here
...
>>> asyncio.run(main())

```

#### Creating an Order

When creating an order, the order request details must be provided to the API
as a JSON blob. This JSON blob can be built up manually or by using the
`build_request` function.

An example of creating the request JSON with `build_request`:

```python
>>> from planet import order_request
>>> products = [
...     order_request.product(['20170614_113217_3163208_RapidEye-5'],
...                           'analytic', 'REOrthoTile')
... ]
...
>>> tools = [
...     order_request.toar_tool(scale_factor=10000),
...     order_request.reproject_tool(projection='WSG84', kernel='cubic'),
...     order_request.tile_tool(1232, origin_x=-180, origin_y=-90,
...               pixel_size=0.000027056277056,
...               name_template='C1232_30_30_{tilex:04d}_{tiley:04d}')
... ]
...
>>> request = order_request.build_request(
...     'test_order', products, tools)
...

```

The same thing, expressed as a `JSON` blob:

```python
>>> request = {
...   "name": "test_order",
...   "products": [
...     {
...       "item_ids": [
...         "20170614_113217_3163208_RapidEye-5"
...       ],
...       "item_type": "REOrthoTile",
...       "product_bundle": "analytic"
...     }
...   ],
...   "tools": [
...     {
...       "toar": {
...         "scale_factor": 10000
...       }
...     },
...     {
...       "reproject": {
...         "projection": "WSG84",
...         "kernel": "cubic"
...       }
...     },
...     {
...       "tile": {
...         "tile_size": 1232,
...         "origin_x": -180,
...         "origin_y": -90,
...         "pixel_size": 2.7056277056e-05,
...         "name_template": "C1232_30_30_{tilex:04d}_{tiley:04d}"
...       }
...     }
...   ]
... }

```

Once the order request is built up, creating an order is done within
the context of a `Session` with the `OrdersClient`:

```python
>>> async def main():
...     async with Session() as sess:
...         cl = OrdersClient(sess)
...         order = await cl.create_order(request)
...
>>> asyncio.run(main())

```

#### Polling and Downloading an Order

Once an order is created, the Orders API takes some time to create the order
and thus we must wait a while before downloading the order.
We can use polling to watch the order creation process and find out when the
order is created successfully and ready to download.

With polling and download, it is often desired to track progress as these
processes can take a long time. Therefore, in this example, we use a progress
bar from the `reporting` module to report poll status. `download_order` has
reporting built in.

```python
from planet import reporting

>>> async def create_poll_and_download():
...     async with Session() as sess:
...         cl = OrdersClient(sess)
...         with reporting.StateBar(state='creating') as bar:
...             # create order
...             order = await cl.create_order(request)
...             bar.update(state='created', order_id=order['id'])
...
...             # poll
...             await cl.poll(order['id'], report=bar.update)
...
...         # download
...         await cl.download_order(order['id'])
...
>>> asyncio.run(create_poll_and_download())
```

## CLI

### Authentication

The `auth` command allows the CLI to authenticate with Planet servers. Before
any other command is run, the CLI authentication should be initiated with

```console
$ planet auth init
```

To store the authentication information in an environment variable, e.g.
for passing into a Docker instance:

```console
$ export PL_API_KEY=$(planet auth value)
```

### Orders API

Most `orders` cli commands are simple wrappers around the
[Planet Orders API](https://developers.planet.com/docs/orders/reference/#tag/Orders)
commands.


#### Create Order File Inputs

Creating an order supports JSON files as inputs and these need to follow certain
formats.


##### --cloudconfig
The file given with the `--cloudconfig` option should contain JSON that follows
the options and format given in
[Delivery to Cloud Storage](https://developers.planet.com/docs/orders/delivery/#delivery-to-cloud-storage).

An example would be:

Example: `cloudconfig.json`
```
{
    "amazon_s3": {
        "aws_access_key_id": "aws_access_key_id",
        "aws_secret_access_key": "aws_secret_access_key",
        "bucket": "bucket",
        "aws_region": "aws_region"
    },
    "archive_type": "zip"
}
```

##### --clip
The file given with the `--clip` option should contain valid [GeoJSON](https://geojson.org/).
It can be a Polygon geometry, a Feature, or a FeatureClass. If it is a FeatureClass,
only the first Feature is used for the clip geometry.

Example: `aoi.geojson`
```
{
    "type": "Polygon",
    "coordinates": [
        [
            [
                37.791595458984375,
                14.84923123791421
            ],
            [
                37.90214538574219,
                14.84923123791421
            ],
            [
                37.90214538574219,
                14.945448293647944
            ],
            [
                37.791595458984375,
                14.945448293647944
            ],
            [
                37.791595458984375,
                14.84923123791421
            ]
        ]
    ]
}
```

##### --tools
The file given with the `--tools` option should contain JSON that follows the
format for a toolchain, the "tools" section of an order. The toolchain options
and format are given in
[Creating Toolchains](https://developers.planet.com/docs/orders/tools-toolchains/#creating-toolchains).

Example: `tools.json`
```
[
    {
        "toar": {
            "scale_factor": 10000
        }
    },
    {
        "reproject": {
            "projection": "WGS84",
            "kernel": "cubic"
        }
    },
    {
        "tile": {
            "tile_size": 1232,
            "origin_x": -180,
            "origin_y": -90,
            "pixel_size": 2.7056277056e-05,
            "name_template": "C1232_30_30_{tilex:04d}_{tiley:04d}"
        }
    }
```


docs/cli.md METASEP
# CLI Reference

This page provides documentation for our command line tools.

::: mkdocs-click
    :module: planet.cli.cli
    :command: main 
    :prog_name: planet
    :depth: 1

docs/api.md METASEP
# API Reference 

## ::: planet.Auth
    rendering:
      show_root_full_path: false

## ::: planet.Session
    rendering:
      show_root_full_path: false

## ::: planet.OrdersClient
    rendering:
      show_root_full_path: false

## ::: planet.order_request
    rendering:
      show_root_full_path: false

## ::: planet.reporting
    rendering:
      show_root_full_path: false



tests/conftest.py METASEP
# Copyright 2020 Planet Labs, Inc.
#
# 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 os
from pathlib import Path

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'
    with open(img_path, 'rb') as img:
        yield img


@pytest.fixture
def get_test_file_json():

    def func(filename):
        file_path = _test_data_path / filename
        return json.load(open(file_path, 'r'))

    return func


@pytest.fixture
def order_description(get_test_file_json):
    filename = 'order_description_b0cb3448-0a74-11eb-92a1-a3d779bb08e0.json'
    return get_test_file_json(filename)


@pytest.fixture
def order_request(get_test_file_json):
    filename = 'order_details_psorthotile_analytic.json'
    return get_test_file_json(filename)


@pytest.fixture
def orders_page(get_test_file_json):
    filename = 'orders_page.json'
    return get_test_file_json(filename)


@pytest.fixture
def oid():
    return 'b0cb3448-0a74-11eb-92a1-a3d779bb08e0'


@pytest.fixture
def write_to_tmp_json_file(tmp_path):

    def write(data, filename):
        cc = tmp_path / filename
        with open(cc, 'w') as fp:
            json.dump(data, fp)
        return cc

    return write


@pytest.fixture
def geom_geojson():
    # these need to be tuples, not list, or they will be changed
    # by shapely
    return {
        "type":
        "Polygon",
        "coordinates":
        [[[37.791595458984375, 14.84923123791421],
          [37.90214538574219, 14.84923123791421],
          [37.90214538574219, 14.945448293647944],
          [37.791595458984375, 14.945448293647944],
          [37.791595458984375, 14.84923123791421]]]
    }  # yapf: disable


@pytest.fixture
def feature_geojson(geom_geojson):
    return {"type": "Feature", "properties": {}, "geometry": geom_geojson}


@pytest.fixture
def featureclass_geojson(feature_geojson):
    return {"type": "FeatureCollection", "features": [feature_geojson]}


@pytest.fixture
def point_geom_geojson():
    return {
        "type": "Point",
        "coordinates": [37.791595458984375, 14.84923123791421]
    }

examples/test_examples.py METASEP
# Copyright 2020 Planet Labs, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Test example scripts in this directory

This is not an example script. This is a pytest module that provides automated
testing of the example scripts in this directory to ensure they are always
up-to-date and run successfully.

YAY automated testing and up-to-date examples!
"""
import logging
from pathlib import Path
import os
import subprocess
import sys

import pytest

LOGGER = logging.getLogger(__name__)

# All python files in the current directory except this one
# ref: https://stackoverflow.com/a/56813896
SCRIPTS = [
    s for s in Path(__file__).parent.resolve().glob('*.py')
    if s.name != Path(__file__).name
]


# use the script name in the test name
def idfn(script_path):
    return script_path.name


#  provide an environment variable that points to the test temporary download
# directory, to be used by the script for all downloads
@pytest.mark.parametrize('script', SCRIPTS, ids=idfn)
def test_example_script_execution(script, tmpdir):
    completed = subprocess.run(
        [sys.executable, str(script)],
        env={
            'TEST_DOWNLOAD_DIR': str(tmpdir),
            'PL_API_KEY': os.getenv('PL_API_KEY')
        },
        stderr=subprocess.PIPE  # capture stdout for reporting
    )

    assert not completed.returncode, (
        'script failed with following std_err output:\n'
        f'{str(completed.stderr, "utf-8")}')

examples/orders_create_and_download_multiple_orders.py METASEP
# Copyright 2020 Planet Labs, Inc.
#
# 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.
"""Example of creating and downloading multiple orders.

This is an example of submitting two orders, waiting for them to complete, and
downloading them. The orders each clip a set of images to a specific area of
interest (AOI), so they cannot be combined into one order.

[Planet Explorer](https://www.planet.com/explorer/) was used to define
the AOIs and get the image ids.
"""
import asyncio
import os

import planet

DOWNLOAD_DIR = os.getenv('TEST_DOWNLOAD_DIR', '.')

iowa_aoi = {
    "type":
    "Polygon",
    "coordinates": [[[-91.198465, 42.893071], [-91.121931, 42.893071],
                     [-91.121931, 42.946205], [-91.198465, 42.946205],
                     [-91.198465, 42.893071]]]
}

iowa_images = ['20200925_161029_69_2223', '20200925_161027_48_2223']
iowa_order = planet.order_request.build_request(
    'iowa_order',
    [planet.order_request.product(iowa_images, 'analytic', 'PSScene4Band')],
    tools=[planet.order_request.clip_tool(iowa_aoi)])

oregon_aoi = {
    "type":
    "Polygon",
    "coordinates": [[[-117.558734, 45.229745], [-117.452447, 45.229745],
                     [-117.452447, 45.301865], [-117.558734, 45.301865],
                     [-117.558734, 45.229745]]]
}

oregon_images = ['20200909_182525_1014', '20200909_182524_1014']
oregon_order = planet.order_request.build_request(
    'oregon_order',
    [planet.order_request.product(oregon_images, 'analytic', 'PSScene4Band')],
    tools=[planet.order_request.clip_tool(oregon_aoi)])


async def create_and_download(order_detail, directory, client):
    with planet.reporting.StateBar(state='creating') as reporter:
        # create
        order = await client.create_order(order_detail)
        reporter.update(state='created', order_id=order.id)

        # wait for completion
        await client.wait(order.id, report=reporter.update_state)

    # download
    await client.download_order(order.id, progress_bar=True)


async def main():
    async with planet.Session() as ps:
        client = planet.OrdersClient(ps)

        await asyncio.gather(
            create_and_download(iowa_order, DOWNLOAD_DIR, client),
            create_and_download(oregon_order, DOWNLOAD_DIR, client),
        )


if __name__ == '__main__':
    asyncio.run(main())

planet/data/orders_product_bundle_2022_02_02.json METASEP
{
  "bundles": {
    "analytic": {
      "name": "Analytic Radiance (TOAR)",
      "description": "Calibrated to top of atmosphere radiance",
      "bands": "all",
      "rectification": "orthorectified",
      "radiometry": "at-sensor",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm",
      "assets": {
        "Landsat8L1G": [
          "analytic_b1",
          "analytic_b2",
          "analytic_b3",
          "analytic_b4",
          "analytic_b5",
          "analytic_b6",
          "analytic_b7",
          "analytic_b8",
          "analytic_b9",
          "analytic_b10",
          "analytic_b11",
          "analytic_bqa",
          "metadata_txt"
        ],
        "PSOrthoTile": [
          "analytic",
          "udm",
          "analytic_xml"
        ],
        "PSScene3Band": [
          "analytic",
          "analytic_xml",
          "udm"
        ],
        "PSScene4Band": [
          "analytic",
          "analytic_xml",
          "udm"
        ],
        "REOrthoTile": [
          "analytic",
          "analytic_xml",
          "udm"
        ],
        "Sentinel1": [
          "ortho_analytic_vv",
          "ortho_analytic_vh"
        ],
        "Sentinel2L1C": [
          "analytic_b1",
          "analytic_b2",
          "analytic_b3",
          "analytic_b4",
          "analytic_b5",
          "analytic_b6",
          "analytic_b7",
          "analytic_b8",
          "analytic_b8a",
          "analytic_b9",
          "analytic_b10",
          "analytic_b11",
          "analytic_b12",
          "metadata_aux"
        ],
        "SkySatScene": [
          "ortho_analytic",
          "ortho_analytic_udm"
        ],
        "SkySatCollect": [
          "ortho_analytic",
          "ortho_analytic_udm"
        ]
      }
    },
    "analytic_udm2": {
      "name": "Analytic Radiance (TOAR)",
      "description": "Calibrated to top of atmosphere radiance",
      "bands": "all",
      "rectification": "orthorectified",
      "radiometry": "at-sensor",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm, udm2",
      "assets": {
        "PSOrthoTile": [
          "analytic",
          "udm",
          "udm2",
          "analytic_xml"
        ],
        "PSScene4Band": [
          "analytic",
          "analytic_xml",
          "udm",
          "udm2"
        ],
        "PSScene": [
          "ortho_analytic_4b",
          "ortho_analytic_4b_xml",
          "ortho_udm2"
        ],
        "SkySatScene": [
          "ortho_analytic",
          "ortho_analytic_udm",
          "ortho_analytic_udm2"
        ],
        "SkySatCollect": [
          "ortho_analytic",
          "ortho_analytic_udm",
          "ortho_analytic_udm2"
        ]
      }
    },
    "analytic_3b_udm2": {
      "name": "Analytic Radiance (TOAR) 3b",
      "description": "Calibrated to top of atmosphere radiance, 3 band",
      "bands": "3-band",
      "rectification": "orthorectified",
      "radiometry": "at-sensor",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm2",
      "assets": {
        "PSScene": [
          "ortho_analytic_3b",
          "ortho_analytic_3b_xml",
          "ortho_udm2"
        ]
      }
    },
    "analytic_5b": {
      "name": "Analytic Radiance (TOAR) 5b",
      "description": "Calibrated to top of atmosphere radiance, 5 band",
      "bands": "all",
      "rectification": "orthorectified",
      "radiometry": "at-sensor",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm",
      "assets": {
        "PSOrthoTile": [
          "analytic_5b",
          "analytic_5b_xml",
          "udm"
        ]
      }
    },
    "analytic_5b_udm2": {
      "name": "Analytic Radiance (TOAR) 5b",
      "description": "Calibrated to top of atmosphere radiance, 5 band",
      "bands": "all",
      "rectification": "orthorectified",
      "radiometry": "at-sensor",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm, udm2",
      "assets": {
        "PSOrthoTile": [
          "analytic_5b",
          "analytic_5b_xml",
          "udm",
          "udm2"
        ]
      }
    },
    "analytic_8b_udm2": {
      "name": "Analytic Radiance (TOAR) 8b",
      "description": "Calibrated to top of atmosphere radiance, 8 band",
      "bands": "8-band",
      "rectification": "orthorectified",
      "radiometry": "at-sensor",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm2",
      "assets": {
        "PSScene": [
          "ortho_analytic_8b",
          "ortho_analytic_8b_xml",
          "ortho_udm2"
        ]
      }
    },
    "visual": {
      "name": "Visual",
      "description": "RGB only -- color corrected and optimized for visual analysis",
      "bands": "3-band",
      "rectification": "orthorectified",
      "radiometry": "visual",
      "fileType": "GeoTIFF",
      "assets": {
        "Landsat8L1G": [
          "visual"
        ],
        "PSOrthoTile": [
          "visual",
          "visual_xml"
        ],
        "PSScene3Band": [
          "visual",
          "visual_xml"
        ],
        "PSScene": [
          "ortho_visual"
        ],
        "REOrthoTile": [
          "visual",
          "visual_xml"
        ],
        "Sentinel2L1C": [
          "visual"
        ],
        "SkySatCollect": [
          "ortho_visual"
        ],
        "SkySatScene": [
          "ortho_visual"
        ]
      }
    },
    "uncalibrated_dn": {
      "name": "Uncalibrated DN",
      "description": "Uncalibrated digital numbers, suitable for custom radiometric processing",
      "bands": "all",
      "rectification": "orthorectified",
      "radiometry": "digital numbers",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm",
      "assets": {
        "PSOrthoTile": [
          "analytic_dn",
          "udm",
          "analytic_dn_xml"
        ],
        "PSScene3Band": [
          "analytic_dn",
          "analytic_dn_xml",
          "udm"
        ],
        "PSScene4Band": [
          "analytic_dn",
          "analytic_dn_xml",
          "udm"
        ],
        "SkySatCollect": [
          "ortho_analytic_dn",
          "ortho_analytic_udm"
        ],
        "SkySatScene": [
          "ortho_analytic_dn",
          "ortho_analytic_udm"
        ]
      }
    },
    "uncalibrated_dn_udm2": {
      "name": "Uncalibrated DN",
      "description": "Uncalibrated digital numbers, suitable for custom radiometric processing",
      "bands": "all",
      "rectification": "orthorectified",
      "radiometry": "digital numbers",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm, udm2",
      "assets": {
        "PSOrthoTile": [
          "analytic_dn",
          "analytic_dn_xml",
          "udm",
          "udm2"
        ],
        "PSScene4Band": [
          "analytic_dn",
          "analytic_dn_xml",
          "udm",
          "udm2"
        ],
        "SkySatCollect": [
          "ortho_analytic_dn",
          "ortho_analytic_udm",
          "ortho_analytic_udm2"
        ],
        "SkySatScene": [
          "ortho_analytic_dn",
          "ortho_analytic_udm",
          "ortho_analytic_udm2"
        ]
      }
    },
    "basic_analytic": {
      "name": "Basic Analytic Radiance (TOAR)",
      "description": "Calibrated to top of atmosphere radiance - includes RPC text file for georeferencing",
      "bands": "all",
      "rectification": "non-orthorectified",
      "radiometry": "at-sensor",
      "fileType": "TIFF and RPCs",
      "auxiliaryFiles": "udm",
      "assets": {
        "PSScene3Band": [
          "basic_analytic",
          "basic_udm",
          "basic_analytic_rpc",
          "basic_analytic_xml"
        ],
        "PSScene4Band": [
          "basic_analytic",
          "basic_udm",
          "basic_analytic_rpc",
          "basic_analytic_xml"
        ],
        "REScene": [
          "basic_analytic_b1",
          "basic_analytic_b2",
          "basic_analytic_b3",
          "basic_analytic_b4",
          "basic_analytic_b5",
          "basic_analytic_xml",
          "basic_analytic_rpc",
          "basic_udm",
          "basic_analytic_sci",
          "browse"
        ],
        "SkySatScene": [
          "basic_analytic",
          "basic_analytic_rpc",
          "basic_analytic_udm"
        ]
      }
    },
    "basic_analytic_udm2": {
      "name": "Basic Analytic Radiance (TOAR)",
      "description": "Calibrated to top of atmosphere radiance - includes RPC text file for georeferencing",
      "bands": "all",
      "rectification": "non-orthorectified",
      "radiometry": "at-sensor",
      "fileType": "TIFF and RPCs",
      "auxiliaryFiles": "udm, udm2",
      "assets": {
        "PSScene4Band": [
          "basic_analytic",
          "basic_udm",
          "basic_udm2",
          "basic_analytic_rpc",
          "basic_analytic_xml"
        ],
        "PSScene": [
          "basic_analytic_4b",
          "basic_udm2",
          "basic_analytic_4b_rpc",
          "basic_analytic_4b_xml"
        ],
        "SkySatScene": [
          "basic_analytic",
          "basic_analytic_rpc",
          "basic_analytic_udm"
        ]
      }
    },
    "basic_analytic_8b_udm2": {
      "name": "Basic Analytic Radiance (TOAR) 8b",
      "description": "Calibrated to top of atmosphere radiance - includes RPC text file for georeferencing",
      "bands": "8-band",
      "rectification": "non-orthorectified",
      "radiometry": "at-sensor",
      "fileType": "TIFF and RPCs",
      "auxiliaryFiles": "udm2",
      "assets": {
        "PSScene": [
          "basic_analytic_8b",
          "basic_udm2",
          "basic_analytic_4b_rpc",
          "basic_analytic_8b_xml"
        ]
      }
    },
    "basic_uncalibrated_dn": {
      "name": "Basic Uncalibrated DN",
      "description": "Uncalibrated digital numbers - includes RPC text file for georeferencing",
      "bands": "all",
      "rectification": "non-orthorectified",
      "radiometry": "digital numbers",
      "fileType": "TIFF and RPCs",
      "auxiliaryFiles": "udm",
      "assets": {
        "PSScene3Band": [
          "basic_analytic_dn",
          "basic_analytic_dn_xml",
          "basic_udm",
          "basic_analytic_dn_rpc"
        ],
        "PSScene4Band": [
          "basic_analytic_dn",
          "basic_analytic_dn_xml",
          "basic_udm",
          "basic_analytic_dn_rpc"
        ],
        "SkySatScene": [
          "basic_analytic_dn",
          "basic_analytic_dn_rpc",
          "basic_analytic_udm"
        ]
      }
    },
    "basic_uncalibrated_dn_udm2": {
      "name": "Basic Uncalibrated DN",
      "description": "Uncalibrated digital numbers - includes RPC text file for georeferencing",
      "bands": "all",
      "rectification": "non-orthorectified",
      "radiometry": "digital numbers",
      "fileType": "TIFF and RPCs",
      "auxiliaryFiles": "udm, udm2",
      "assets": {
        "PSScene4Band": [
          "basic_analytic_dn",
          "basic_analytic_dn_xml",
          "basic_udm",
          "basic_udm2",
          "basic_analytic_dn_rpc"
        ],
        "SkySatScene": [
          "basic_analytic_dn",
          "basic_analytic_dn_rpc",
          "basic_analytic_udm"
        ]
      }
    },
    "analytic_sr": {
      "name": "Surface Reflectance",
      "description": "Corrected for surface reflectance – recommended for most analytic applications",
      "bands": "all",
      "rectification": "orthorectified",
      "radiometry": "surface reflectance",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm",
      "assets": {
        "PSScene4Band": [
          "analytic_sr",
          "udm",
          "analytic_xml"
        ],
        "PSOrthoTile": [
          "analytic_sr",
          "udm",
          "analytic_xml"
        ],
        "REOrthoTile": [
          "analytic_sr",
          "udm",
          "analytic_xml"
        ],
        "MOD09GQ": [
          "analytic_num_observations",
          "analytic_orbit_pnt",
          "analytic_granule_pnt",
          "analytic_sur_refl_b01",
          "analytic_sur_refl_b02",
          "analytic_qc_250m",
          "analytic_obscov",
          "analytic_iobs_res"
        ],
        "MYD09GQ": [
          "analytic_num_observations",
          "analytic_orbit_pnt",
          "analytic_granule_pnt",
          "analytic_sur_refl_b01",
          "analytic_sur_refl_b02",
          "analytic_qc_250m",
          "analytic_obscov",
          "analytic_iobs_res"
        ],
        "MOD09GA": [
          "analytic_num_observations_500m",
          "analytic_num_observations_1km",
          "analytic_state_1km",
          "analytic_sensor_zenith",
          "analytic_sensor_azimuth",
          "analytic_range",
          "analytic_solar_zenith",
          "analytic_solar_azimuth",
          "analytic_gflags",
          "analytic_orbit_pnt",
          "analytic_granule_pnt",
          "analytic_sur_refl_b01",
          "analytic_sur_refl_b02",
          "analytic_sur_refl_b03",
          "analytic_sur_refl_b04",
          "analytic_sur_refl_b05",
          "analytic_sur_refl_b06",
          "analytic_sur_refl_b07",
          "analytic_qc_500m",
          "analytic_obscov_500m",
          "analytic_iobs_res",
          "analytic_q_scan"
        ],
        "MYD09GA": [
          "analytic_num_observations_500m",
          "analytic_num_observations_1km",
          "analytic_state_1km",
          "analytic_sensor_zenith",
          "analytic_sensor_azimuth",
          "analytic_range",
          "analytic_solar_zenith",
          "analytic_solar_azimuth",
          "analytic_gflags",
          "analytic_orbit_pnt",
          "analytic_granule_pnt",
          "analytic_sur_refl_b01",
          "analytic_sur_refl_b02",
          "analytic_sur_refl_b03",
          "analytic_sur_refl_b04",
          "analytic_sur_refl_b05",
          "analytic_sur_refl_b06",
          "analytic_sur_refl_b07",
          "analytic_qc_500m",
          "analytic_obscov_500m",
          "analytic_iobs_res",
          "analytic_q_scan"
        ],
        "SkySatCollect": [
          "ortho_analytic_sr",
          "ortho_analytic_udm"
        ],
        "SkySatScene": [
          "ortho_analytic_sr",
          "ortho_analytic_udm"
        ]
      }
    },
    "analytic_sr_udm2": {
      "name": "Surface Reflectance 4b",
      "description": "Corrected for surface reflectance – recommended for most analytic applications, 4 band",
      "bands": "all",
      "rectification": "orthorectified",
      "radiometry": "surface reflectance",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm, udm2",
      "assets": {
        "PSScene4Band": [
          "analytic_sr",
          "analytic_xml",
          "udm",
          "udm2"
        ],
        "PSScene": [
          "ortho_analytic_4b_sr",
          "ortho_analytic_4b_xml",
          "ortho_udm2"
        ],
        "PSOrthoTile": [
          "analytic_sr",
          "udm",
          "udm2",
          "analytic_xml"
        ],
        "SkySatCollect": [
          "ortho_analytic_sr",
          "ortho_analytic_udm",
          "ortho_analytic_udm2"
        ],
        "SkySatScene": [
          "ortho_analytic_sr",
          "ortho_analytic_udm",
          "ortho_analytic_udm2"
        ]
      }
    },
    "analytic_8b_sr_udm2": {
      "name": "Surface Reflectance 8b",
      "description": "Corrected for surface reflectance – recommended for most analytic applications, 8 band",
      "bands": "8-band",
      "rectification": "orthorectified",
      "radiometry": "surface reflectance",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm2",
      "assets": {
        "PSScene": [
          "ortho_analytic_8b_sr",
          "ortho_analytic_8b_xml",
          "ortho_udm2"
        ]
      }
    },
    "basic_uncalibrated_dn_nitf": {
      "name": "Basic Uncalibrated DN - NITF",
      "description": "Uncalibrated digital numbers - includes RPC text file for georeferencing",
      "bands": "all",
      "rectification": "non-orthorectified",
      "radiometry": "digital numbers",
      "fileType": "NITF",
      "auxiliaryFiles": "udm",
      "assets": {
        "PSScene4Band": [
          "basic_analytic_dn_nitf",
          "basic_analytic_dn_xml_nitf",
          "basic_analytic_dn_rpc_nitf",
          "basic_udm"
        ]
      }
    },
    "basic_uncalibrated_dn_nitf_udm2": {
      "name": "Basic Uncalibrated DN - NITF",
      "description": "Uncalibrated digital numbers - includes RPC text file for georeferencing",
      "bands": "all",
      "rectification": "non-orthorectified",
      "radiometry": "digital numbers",
      "fileType": "NITF",
      "auxiliaryFiles": "udm, udm2",
      "assets": {
        "PSScene4Band": [
          "basic_analytic_dn_nitf",
          "basic_analytic_dn_xml_nitf",
          "basic_analytic_dn_rpc_nitf",
          "basic_udm",
          "basic_udm2"
        ]
      }
    },
    "basic_analytic_nitf": {
      "name": "Basic Analytic Radiance (TOAR) – NITF",
      "description": " Calibrated to top of atmosphere radiance - includes RPC text file for georeferencing",
      "bands": "all",
      "rectification": "non-orthorectified",
      "radiometry": "at-sensor",
      "fileType": "NITF",
      "auxiliaryFiles": "udm",
      "assets": {
        "PSScene4Band": [
          "basic_analytic_nitf",
          "basic_analytic_rpc_nitf",
          "basic_analytic_xml_nitf",
          "basic_udm"
        ],
        "REScene": [
          "basic_analytic_b1_nitf",
          "basic_analytic_b2_nitf",
          "basic_analytic_b3_nitf",
          "basic_analytic_b4_nitf",
          "basic_analytic_b5_nitf",
          "basic_analytic_xml_nitf",
          "basic_analytic_rpc",
          "basic_udm",
          "basic_analytic_sci",
          "browse"
        ]
      }
    },
    "basic_analytic_nitf_udm2": {
      "name": "Basic Analytic Radiance (TOAR) – NITF",
      "description": " Calibrated to top of atmosphere radiance - includes RPC text file for georeferencing",
      "bands": "all",
      "rectification": "non-orthorectified",
      "radiometry": "at-sensor",
      "fileType": "NITF",
      "auxiliaryFiles": "udm, udm2",
      "assets": {
        "PSScene4Band": [
          "basic_analytic_nitf",
          "basic_analytic_rpc_nitf",
          "basic_analytic_xml_nitf",
          "basic_udm",
          "basic_udm2"
        ]
      }
    },
    "basic_panchromatic": {
      "name": "Basic Panchromatic",
      "description": "Unrectified panchromatic band - includes RPC text file for georeferencing",
      "bands": "panchromatic",
      "rectification": "non-orthorectified",
      "radiometry": "at-sensor",
      "fileType": "GeoTIFF",
      "assets": {
        "SkySatScene": [
          "basic_panchromatic",
          "basic_panchromatic_rpc",
          "basic_panchromatic_udm2"
        ]
      }
    },
    "basic_panchromatic_dn": {
      "name": "Basic Panchromatic DN",
      "description": "Unrectified, panchromatic band, uncalibrated digital numbers - includes RPC text file for georeferencing",
      "bands": "panchromatic",
      "rectification": "non-orthorectified",
      "radiometry": "digital numbers",
      "fileType": "TIFF and RPCs",
      "assets": {
        "SkySatScene": [
          "basic_panchromatic_dn",
          "basic_panchromatic_dn_rpc",
          "basic_panchromatic_udm2"
        ]
      }
    },
    "panchromatic": {
      "name": "Panchromatic",
      "description": "Panchromatic band, calibrated to top of atmosphere radiance",
      "bands": "panchromatic",
      "rectification": "orthorectified",
      "radiometry": "at-sensor",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm",
      "assets": {
        "SkySatCollect": [
          "ortho_panchromatic",
          "ortho_panchromatic_udm"
        ],
        "SkySatScene": [
          "ortho_panchromatic",
          "ortho_panchromatic_udm"
        ]
      }
    },
    "panchromatic_dn": {
      "name": "Panchromatic DN",
      "description": "Uncalibrated panchromatic band - suitable for custom radiometric processing",
      "bands": "panchromatic",
      "rectification": "orthorectified",
      "radiometry": "digital numbers",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm",
      "assets": {
        "SkySatCollect": [
          "ortho_panchromatic_dn",
          "ortho_panchromatic_udm"
        ],
        "SkySatScene": [
          "ortho_panchromatic_dn",
          "ortho_panchromatic_udm"
        ]
      }
    },
    "panchromatic_dn_udm2": {
      "name": "Panchromatic DN",
      "description": "Uncalibrated panchromatic band - suitable for custom radiometric processing",
      "bands": "panchromatic",
      "rectification": "orthorectified",
      "radiometry": "digital numbers",
      "fileType": "GeoTIFF",
      "auxiliaryFiles": "udm, udm2",
      "assets": {
        "SkySatCollect": [
          "ortho_panchromatic_dn",
          "ortho_panchromatic_udm",
          "ortho_panchromatic_udm2"
        ],
        "SkySatScene": [
          "ortho_panchromatic_dn",
          "ortho_panchromatic_udm",
          "ortho_panchromatic_udm2"
        ]
      }
    },
    "pansharpened": {
      "name": "Ortho-pansharpened",
      "description": "Pansharpened, color corrected, 4-band multispectral data",
      "bands": "all",
      "rectification": "orthorectified",
      "radiometry": "digital numbers",
      "fileType": "GeoTIFF",
      "enhancements": "pansharpen",
      "auxiliaryFiles": "udm",
      "assets": {
        "SkySatCollect": [
          "ortho_pansharpened",
          "ortho_pansharpened_udm"
        ],
        "SkySatScene": [
          "ortho_pansharpened",
          "ortho_pansharpened_udm"
        ]
      }
    },
    "pansharpened_udm2": {
      "name": "Ortho-pansharpened",
      "description": "Pansharpened, color corrected, 4-band multispectral data",
      "bands": "all",
      "rectification": "orthorectified",
      "radiometry": "digital numbers",
      "fileType": "GeoTIFF",
      "enhancements": "pansharpen",
      "auxiliaryFiles": "udm, udm2",
      "assets": {
        "SkySatCollect": [
          "ortho_pansharpened",
          "ortho_pansharpened_udm",
          "ortho_pansharpened_udm2"
        ],
        "SkySatScene": [
          "ortho_pansharpened",
          "ortho_pansharpened_udm",
          "ortho_pansharpened_udm2"
        ]
      }
    },
    "basic_l1a_dn": {
      "name": "Basic L1A",
      "description": "Unrectified, uncalibrated panchromatic band only, no super resolution applied - includes RPC text file for georeferencing",
      "bands": "panchromatic",
      "rectification": "non-orthorectified",
      "radiometry": "digital numbers",
      "fileType": "TIFF and RPCs",
      "assets": {
        "SkySatScene": [
          "basic_l1a_panchromatic_dn",
          "basic_l1a_panchromatic_dn_rpc"
        ],
        "SkySatCollect": [
          "basic_l1a_all_frames"
        ]
      }
    }
  },
  "version": "2022-02-02"
}

planet/data/README.md METASEP
# data 
This directory contains static external resources used by the codebase.

## orders_product_bundle_2022_02_02.json
This file was downloaded from the Planet
[Product Bundles Reference](https://developers.planet.com/docs/orders/product-bundles-reference/)
page and is stored without any adjustments.

setup.cfg METASEP
[bdist_wheel]
universal: 1

[metadata]
license_file: LICENSE

[tool:pytest]
addopts =
    --cov=planet
    --cov=tests
    --cov-report=term-missing
    --cov-report=xml
    --cov-fail-under 98
    -rxXs

[coverage:run]
source = planet,tests
branch = True

[coverage:report]
skip_covered = True
show_missing = True

[yapf]
based_on_style = pep8
split_all_top_level_comma_separated_values=true

mkdocs.yml METASEP
site_name: Planet SDK for Python
site_description: Python client library for Planet's APIs.
site_url: https://planetlabs.github.io/planet-client-python/

theme:
    name: 'material'
    logo: img/logo.svg
    favicon: img/logo.svg

plugins:
  - search
  - mkdocstrings:
      handlers:
        python:
          rendering:
            show_root_heading: true
          selection:
            inherited_members: true
            filters:
              - "!^_"  # exlude all members starting with _
              - "^__init__$"  # but always include __init__ modules and methods
      watch:
        - planet

repo_name: planetlabs/planet-client-python
repo_url: https://github.com/planetlabs/planet-client-python
edit_uri: ""

nav:
    - Introduction: 'index.md'
    - User Guide: 'guide.md'
    - Upgrading: 'upgrading.md'
    - Early Access: 'v2_earlyaccess.md'
    - API Reference: 'api.md'
    - CLI Reference: 'cli.md'

markdown_extensions:
  - pymdownx.highlight
  - pymdownx.superfences
  - mkdocs-click
  - admonition

gh-release METASEP
#!/bin/bash

: ${TAG:?'Provide TAG'}
DRAFT=${DRAFT:=true}
BODY="${BODY:=$TAG}"
REQUEST=$(printf '{"tag_name": "v%s","draft": %s, "name": "%s", "body": "%s"}' $TAG $DRAFT $TAG "$BODY")
URL="https://api.github.com/repos/planetlabs/planet-client-python/releases"

echo 'Creating release with request:'
echo $REQUEST
echo -n 'Looking good? (Y/N) :'
read CREATE
[ $CREATE != "Y" ] && exit 0

echo 'creating release in github'
RELEASE=$(curl -n -XPOST --data "${REQUEST}" "${URL}")
RELEASE_ID=$(echo $RELEASE | jq .id)
RELEASE_URL=$(echo $RELEASE | jq -r .upload_url)
RELEASE_URL=${RELEASE_URL/\{?name\}/?}
echo 'uploading binary to' ${RELEASE_URL}
curl -H 'Content-Type: application/zip' -n -XPOST --data-binary @dist/planet ${RELEASE_URL}name=planet
echo 'uploading docs to' ${RELEASE_URL}
curl -H 'Content-Type: application/zip' -n -XPOST --data-binary @dist/planet-docs.zip ${RELEASE_URL}name=planet
echo "created ${TAG}, draft=${DRAFT}"

README.md METASEP
# Planet SDK for Python

[![Build Status](https://travis-ci.org/planetlabs/planet-client-python.svg?branch=master)](https://travis-ci.org/planetlabs/planet-client-python)

[Planet](https://planet.com) Software Development Kit (SDK) for Python.

## Versions and Stability

The default branch (main) of this repo is for the [Planet SDK for 
Python](https://github.com/planetlabs/planet-client-python/projects/2),
a complete rewrite and upgrade from the original [Planet Python 
Client](https://developers.planet.com/docs/pythonclient/). If you 
are looking for the source code to that library see the 
[v1](https://github.com/planetlabs/planet-client-python/tree/v1) branch.

The Planet SDK for Python is in 'pre-release' stages, working towards an 
initial release around July. Active development is tracked in the [Planet SDK 
for Python Project](https://github.com/planetlabs/planet-client-python/projects/2). 
The initial release will support Orders, Data and Subscription API's in the 
command-line interface, with corresponding Python libraries. We expect 'beta' 
milestones to be released in some form for each of the API's. After the 
initial July release there will be additional work to support the remaining 
Planet API's ([analytics](https://developers.planet.com/docs/analytics/), 
[basemaps](https://developers.planet.com/docs/basemaps/) and 
[tasking](https://developers.planet.com/docs/tasking/)).

## Documentation

Full documentation is not yet hosted online but can be built and hosted locally
(see [CONTRIBUTING.md](CONTRIBUTING.md)) or can be read from source in the
`[docs](docs/)` directory.

## Quick Start

The Planet SDK for Python allows Python developers to write software that makes
use of the following Planet APIs:

* [orders](https://developers.planet.com/docs/orders/)
* [data](https://developers.planet.com/docs/data/) (not yet implemented)
* [subscriptions](https://developers.planet.com/docs/subscriptions/) (not 
 yet implemented)

The client modules within the Python library are asynchronous, which greatly
speeds up many interactions with Planet's APIs. Support for asynchronous
development is native to Python 3.6+ via the
[`asyncio` module](https://docs.python.org/3/library/asyncio.html). A great
resource for getting started with asynchronous programming in Python is
https://project-awesome.org/timofurrer/awesome-asyncio. The Writings and Talks
sections are particularly helpful in getting oriented.

Let's start with creating an order with the Orders API:

```python
>>> import asyncio
>>> import os
>>> import planet
>>>
>>> request = {
...   "name": "test_order",
...   "products": [
...     {
...       "item_ids": [
...         "3949357_1454705_2020-12-01_241c"
...       ],
...       "item_type": "PSOrthoTile",
...       "product_bundle": "analytic"
...     }
...   ]
... }
...
>>> async def create_order(request):
...     async with planet.Session() as ps:
...         client = planet.OrdersClient(ps)
...         return await client.create_order(request)
...
>>> oid = asyncio.run(create_order(request))

```

Not into async? No problem. Just wrap the library and async operations together
and call from your synchronous code.

```python
>>> def sync_create_order(order_details):
...     return asyncio.run(create_order(order_details))
>>>
>>> oid = sync_create_order(order_details)

```

When using `asyncio.run` to develop synchronous code with the async library,
keep in mind this excerpt from the
[asyncio.run](https://docs.python.org/3/library/asyncio-task.html#asyncio.run)
documentation:

"*This function always creates a new event loop and closes it at the end. It
should be used as a main entry point for asyncio programs, and should ideally
only be called once.*"

Do you have a use case where native synchronous support is essential? If so,
please contribute to this
[discussion](https://github.com/planetlabs/planet-client-python/issues/251).

Why async? Because things get *really cool* when you want to work with multiple
orders. See [orders_create_and_download_multiple_orders.py](examples/orders_create_and_download_multiple_orders.py) for
an example of submitting two orders, waiting for them to complete, and
downloading them. The orders each clip a set of images to a specific area of
interest (AOI), so they cannot be combined into one order.
(hint: [Planet Explorer](https://www.planet.com/explorer/) was used to define
the AOIs and get the image ids.)

## Installation

Install with [pip](https://pip.pypa.io):

```console
$ python -m pip install . 
```

The [--user](https://pip.pypa.io/en/stable/user_guide/#user-installs)
flag is highly recommended for those new to pip.

The Planet SDK for Python requires Python 3.7+.

## Authentication

Planet's APIs require an account for use.
[Sign up here](https://www.planet.com/explorer/?signup).

## Development

To contribute or develop with this library, see
[CONTRIBUTING.md](CONTRIBUTING.md).

DESIGN.md METASEP
# Design

## Overview

This document outlines the high-level design that informs the development of
the Planet SDK for Python.

## Scope

The scope of this package is to provide a low-level Python API and CLI for
Planet’s services. Commands map close to 1:1 with service APIs and all service
operations are supported.

## Principles

### Interface

The principles of the interface design, ordered according to priority, are:
* Map the API names and endpoints as close as possible
* Synchronoize the Python API and CLI
* Adhere to command line design in:
  * Popular SDKs (aws and gcloud)
  * The geospatial toolset (gdal, rasterio)
  * GNU standards
* Support JSON in stdin/stdout

### Implementation

The principles of the implementation design, in no particular order, are:

* Auto-generate as much as possible from the API specifications
* The CLI is a thin wrapper around the Python API
* Python API is asynchronous, CLI is synchronous

## Errors

### API Exceptions

Exception hierarchy:
 - Every exception inherits from the base exception, `PlanetError`.
 - All client-side exceptions are raised as `ClientError`.
 - All server-side errors are raised as specific exceptions based on the
http code. They all inherit from `APIError` and contain the unedited server
error message.

### CLI Return Codes

The following are the values and descriptions of all return codes
that can be returned at the end of running a Planet CLI command.

| Value | Description |
| ----------- | ----------- |
| 0 | Command was run successfully. |
| 1 | An error occured while the command was running. |
| 2 | Command was not run due to invalid syntax or unknown or invalid parameter.|

CONTRIBUTING.md METASEP
# Contributing

Thank you for your interest in contributing to the Planet SDK for Python! This
document explains how to contribute successfully.

## Pull Requests and Continuous Integration

When a Pull Request (PR) is created, the Continuous Integration (CI) runs a 
series of checks. Before a PR can be considered, all checks must pass.

The CI checks:
* all tests on all supported versions of Python
* linting / formatting
* docs build successfully

To minimize the feedback loop, we have configured Nox so that it can be used
to run all of these checks on the local machine. See the Tools section for
information on how to run Nox.

## Tools

This repository uses two primary tools for development:
* [Nox](https://nox.thea.codes/) to automate testing
* [YAPF](https://github.com/google/yapf) to enforce style guidelines

Install Nox in your local dev environment:

```console
    $ pip install nox
```

Install YAPF in your local dev environment:

```console
    $ pip install yapf
```

### Nox

In this repository, Nox is used to automate testing, linting, and to build
documentation. Nox manages virtual environments for you, specifying Python
versions and installing the the local, dynamic version of the Plant SDK for
Python and required development packages.

Run Nox with the default sessions (same checks as CI):

```console
    $ nox
```

If no changes have been made to the Nox environment since it was last run,
speed up the run by reusing the environment:

```console
    $ nox -r
```

The configuration for Nox is given in `noxfile.py`. See the Nox link above for
advanced usage.

##### Alternative to Nox
Nox is the recommended way to manage local testing. With Nox it is not necessary
to install the Planet SDK for Python on the local machine for testing.
However, Nox is not necessary for local testing. Where Nox is not used, a
virtual environment is highly recommended.
[pyenvwrapper](https://github.com/pyenv/pyenv-virtualenv) manages virtual
environments and works well with pyenv. To install the local, dynamic version
of the Planet SDK for Python and required development packages into the virtual
environment use:

```console
    $ pip install -e .[dev]
```

### YAPF

Code in this repository follows the
[PEP8](https://www.python.org/dev/peps/pep-0008/) style guide and uses
[YAPF](https://github.com/google/yapf) to enforce and automate formatting.
Linting in Nox will fail if YAPF would reformat a portion of the code.
Helpfully, YAPF can be used to automatically reformat the code for you so you
don't have to worry about formatting issues. WIN!

To see how YAPF would reformat a file:

```console
    $ yapf --diff [file]
```

To reformat the file:

```console
    $ yapf --in-place [file]
```

The configuration for YAPF is given in `setup.cfg` and `.yapfignore`.
See the YAPF link above for advanced usage.

##### Alternative to YAPF

YAPF is not required to follow the style and formatting guidelines. You can
perform all formatting on your own using the linting output as a guild. Painful,
maybe, but possible!

## Testing

Installing all supported Python versions locally is recommended. This allows
Nox to fully reproduce the CI tests.
One way of achieving this is with [pyenv](https://github.com/pyenv/pyenv).
If a specific Python version isn't available on your development machine,
Nox will just skip that version in the local tests.

Testing is performed with [pytest](https://docs.pytest.org/) and
[pytest-cov](https://pytest-cov.readthedocs.io/). The configuration for these
packages is given in `setup.cfg`.

Command-line arguments can be passed to pytest within Nox. For example, to only
run the tests on a certain file, use:

```console
    $ nox -- [file]
```

By default, Nox runs tests on all supported Python versions along with other
CI checks. However, Nox can run a test on a single Python version.

To run tests on python 3.7:

```console
    $ nox -s test-3.7
```

## Linting

Linting is performed using [flake8](https://flake8.pycqa.org/)
and YAPF (mentioned above). By default, Nox runs the lint check along with
all other CI checks. However, Nox can run just the linting check.

To run lint check:

```console
    $ nox -s lint
```

## Documentation

Documentation is built from Markdown files in the `docs` directory using
[MkDocs](https://www.mkdocs.org/) according to `mkdocs.yml`. The API reference
is auto-populated from code docstrings. These docstrings must be in the
[google format](https://mkdocstrings.github.io/handlers/python/#google-style)
(note: we use `Parameters` in our docstrings).

By default, Nox builds the docs along with other CI checks. However, Nox can
also be used to only build the docs or to build and serve the docs locally
to assist documentation development.

To build the documentation:

```console
    $ nox -s docs
```

To build and host an automatically-updated local version of the documentation:

```console
    $ nox -s watch
```

In addition to verifying that the documentation renders correctly locally,
the accuracy of the code examples must be verified. See Testing Documentation
below.


### Testing Documentation

NOTE: Doc tests need to be reworked and are failing. See
[#275](https://github.com/planetlabs/planet-client-python/issues/275).

There are many code examples written into the documentation that need to be
tested to ensure they are accurate. These tests are not run by default because
they communicate with the Planet services, and thus are slower and also could
incur usages.

To test the documentation, run the Nox `docs_test` session:

```console
    $ nox -s docs_test
```

This will test all code examples in Markdown documents.
To only test one document:

```console
    $ nox -s docs_test -- <document_name>.md
```

### Testing Examples

The `examples` directory is populated with many helpful examples of how to
use the Planet SDK for Python in real use cases. These examples also need to
be tested to ensure they are accurate. These tests are not run by default
because they are long and communicate with the Planet services; and thus are
very slow and also could incur usages.

To test the examples, run the Nox `examples` session:

```console
    $ nox -s examples
```

This will test all scripts within the `examples` directory.
To only test one script:

```console
    $ nox -s examples -- <script_name>.py
```

For more information on developing examples, see the examples
[README.md](examples/README.md)

CHANGES.txt METASEP
1.4.6 (2020-05-18)

- New: you can now pass a search string to the Orders CLI via `--ids_from_search`
- New: the Orders CLI now has a shortcut `--clip` for clipping operations
- Fix: you can now download Order locations one by one
- Multiple test fixes: Python 2.7 tests, flake8, & make html-docs warning
- Add support for new 5-band asset types

1.4.5 (2020-02-11)

 - Add support for new skysat video item and asset types
 - Add `strict` keyword argument to searches

1.4.4 (2019-12-17)

- Pin requests-futures version to avoid error message.
- Fix broke jq parsing for regular data search
- Under certain conditions the response body can get printed more than once

1.4.3 (2019-11-14)

- Updating copyright date for modified files
- Removing monkey patch
- Make sure base_url has a trailing /
- Simplified configuration of analytics client to be consistent

1.4.2 (2019-10-04)

- added new asset type

1.4.1 (2019-09-17)

- fix quick search regression: allow multiple item types as options in quick search again

1.4.0 (2019-09-16)

- Add CLI support for Planet Orders API

1.3.2 (2019-08-27)

- add new skysat asset types

1.3.1 (2019-07-19)
-------------------------------------
- report the default value for item limit
- replace hardcoded values with global variables
- add header for data examples in Examples page
- adding before, after options, separating `features list` from `features 
- Added default limit of 100 to `analytics collections features list`
- Added separate `analytics collections features list-all` that will keep retrieving results with no limit
- Added before, after options to both `analytics collections features list` and `analytics collections features list-all`

1.3.0 (2019-07-10)
-------------------------------------
- Added support for Analytic Feeds
- Fixing incorrect shorthand CLI arg
- Add comment to explain why the get resource entrypoint won't work in AF next.
- unit tests for analytics commands
- Adding auto-pagination for analytics commands
- Add additional mosaics entrypoints to list mosaics for a series, as well mosaics for AF resources
- Separate out analytics configuration to allow for using analytics alongside mosaics, even in AF next

1.2.4 (2019-06-27)
-------------------------------------
- Add new UDM2 and related asset types

1.2.3 (2019-06-11)
-------------------------------------
- Authenticate item and asset type calls from CLI
- Update tox config to remove obsolete Python 3 versions and allow pytest args
- mimetypes.guess_extension() returns either .tif or .tiff - test accordingly
- Let date_range filter handle dates given with and without timezones
- Update copyright date
- Update Planet address
- Limit arg is required for items_iter()
- Add pywin32 dep for windows platforms
- Flake8 fixes
- Fix improper handling of mixed states when activating multiple assets with Downloader.
- Force TravisCI to upgrade pytest to avoid conflicts
- Add new item types to CLI tool in order to match API's item-types endpoint.
- Don't let final report get swallowed up with ANSI output
- Output the same report for all downloads via CLI

1.2.2 (2019-03-04)
-------------------------------------
- Use requests' urlparse for Py2 & Py3 compatibility
- Prevent empty filenames when attempting to download assets without a Content-Disposition header
- Add more Python 3.7 support by removing keywords
- Rename --rbox to --bbox and leave --bbox as an alias for backwards compatibility
- De-duplicate explicit jq hyperlink (avoids sphinx warning)
- Add examples of mosaic functionality
- Update CLI command help info for mosaic functionality
- Add LICENSE explicitly to setup.py so that pex can find it

1.2.1 (2018-11-08)
-------------------------------------
- Set login headers as content-type:application/json during planet init

1.2.0 (2018-10-10)
-------------------------------------
- Added support for mosaics
- Unpinned setuptools
- Upgraded setuptools and pip
- Built universal wheels
- Implemented filter combining in the CLI
- Fixed flake8 violations, mostly trivial
- Added sentinel1 item type
- Added SR & skysat asset types to defaults
- Added tox config for testing against multiple Python versions
- Regenerated docs

1.1.0 (2017-09-15)
-------------------------------------
- update docs to use cloud_cover as field_name
- move version and update user agent with client name and version
- fix cli reference doc generation, flags have dash
- Support new assets as they become available in the API without changes needed to the client

1.0.1 (2017-08-22)
-------------------------------------
- update the doc strings with defaults
- update allowed item/asset types for skysat
- date_range improvements, handle Z suffix in strp_lenient

1.0.0 (2017-04-01) Code name: no-joke
-------------------------------------
- Remove V0 API support
- Initial V1 Data API support

0.0.6 (2016-02-17)
------------------
- Support for 'aoi_id' (saved geometry) queries in 'search' command
- More robust handling of potential download failures

0.0.5 (2015-12-01)
------------------
- Support RapidEye in CLI
- Fix examples in README
- Fix duplicate example in doc

0.0.4 (2015-09-23)
------------------
- Some API fixes related to trailing slashes
- Documentation refresh
- Area search for mosaic quads
- Use workspace id for search
- Editing workspace filters on CLI
- More products for sync tool
- Fix out of sync issue in sync tool
- Delete discarded temp files in atomic file handling
- Cancellation of async processes

0.0.3 (2015-08-25)
------------------
- Resolve extraneous 'None' appearing in CLI usage
- Documentation reworking
- Check for destination directory existence
- Some AOI validity checking for WKT/GeoJSON
- Paging improvements + support in CLI search results
- Mosaic/quads support
- Atomic file writing
- Pretty print by default when attached to terminal output
- Initial workspace support

0.0.2 (2015-08-05)
------------------
- Add init command for fetching and storing key
- Paging fixes for search/metadata
- Fix bad redirect handling related to API change
- Use published field, not acquired field for syncing

0.0.1 (2015-07-17)
------------------
- Initial release

planet/cli/orders.py METASEP
# 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.
"""Orders API CLI"""
from contextlib import asynccontextmanager
import json
import logging

import click

import planet
from planet import OrdersClient, Session  # allow mocking
from .cmds import coro, translate_exceptions
from .io import echo_json

LOGGER = logging.getLogger(__name__)

pretty = click.option('--pretty', is_flag=True, help='Format JSON output')


@asynccontextmanager
async def orders_client(ctx):
    auth = ctx.obj['AUTH']
    base_url = ctx.obj['BASE_URL']
    async with Session(auth=auth) as sess:
        cl = OrdersClient(sess, base_url=base_url)
        yield cl


@click.group()
@click.pass_context
@click.option('-u',
              '--base-url',
              default=None,
              help='Assign custom base Orders API URL.')
def orders(ctx, base_url):
    '''Commands for interacting with the Orders API'''
    ctx.obj['AUTH'] = planet.Auth.from_file()
    ctx.obj['BASE_URL'] = base_url


@orders.command()
@click.pass_context
@translate_exceptions
@coro
@click.option('--state',
              help='Filter orders to given state.',
              type=click.Choice(planet.clients.orders.ORDER_STATE_SEQUENCE,
                                case_sensitive=False))
@click.option('-l',
              '--limit',
              help='Filter orders to given limit.',
              default=None,
              type=int)
@pretty
async def list(ctx, state, limit, pretty):
    '''List orders'''
    async with orders_client(ctx) as cl:
        orders = await cl.list_orders(state=state, limit=limit)
        orders_list = [o async for o in orders]

    echo_json(orders_list, pretty)


@orders.command()
@click.pass_context
@translate_exceptions
@coro
@click.argument('order_id', type=click.UUID)
@pretty
async def get(ctx, order_id, pretty):
    """Get order

    This command outputs the order description, optionally pretty-printed.
    """
    async with orders_client(ctx) as cl:
        order = await cl.get_order(str(order_id))

    echo_json(order, pretty)


@orders.command()
@click.pass_context
@translate_exceptions
@coro
@click.argument('order_id', type=click.UUID)
async def cancel(ctx, order_id):
    '''Cancel order by order ID.

    This command cancels a queued order and outputs the cancelled order
    details.
    '''
    async with orders_client(ctx) as cl:
        json_resp = await cl.cancel_order(str(order_id))

    click.echo(json_resp)


def split_list_arg(ctx, param, value):
    if value is None:
        return None
    elif value == '':
        # note, this is specifically checking for an empty string
        click.BadParameter('Entry cannot be an empty string.')

    # split list by ',' and remove whitespace
    entries = [i.strip() for i in value.split(',')]

    # validate passed entries
    for e in entries:
        if not e:
            raise click.BadParameter('Entry cannot be an empty string.')
    return entries


@orders.command()
@click.pass_context
@translate_exceptions
@coro
@click.argument('order_id', type=click.UUID)
@click.option('--delay',
              type=int,
              default=5,
              help='Time (in seconds) between polls.')
@click.option('--max-attempts',
              type=int,
              default=5,
              help='Maximum number of polls. Set to zero for no limit.')
@click.option('--state',
              help='State prior to a final state that will end polling.',
              type=click.Choice(planet.clients.orders.ORDER_STATE_SEQUENCE,
                                case_sensitive=False))
async def wait(ctx, order_id, delay, max_attempts, state):
    """Wait until order reaches desired state.

    Reports the state of the order on the last poll.

    This function polls the Orders API to determine the order state, with
    the specified delay between each polling attempt, until the
    order reaches a final state or earlier state, if specified.
    If the maximum number of attempts is reached before polling is
    complete, an exception is raised. Setting --max-attempts to zero will
    result in no limit on the number of attempts.

    Setting --delay to zero results in no delay between polling attempts.
    This will likely result in throttling by the Orders API, which has
    a rate limit of 10 requests per second. If many orders are being
    polled asynchronously, consider increasing the delay to avoid
    throttling.

    By default, polling completes when the order reaches a final state.
    If --state is specified, polling will complete when the specified earlier
    state is reached or passed.
    """
    quiet = ctx.obj['QUIET']
    async with orders_client(ctx) as cl:
        with planet.reporting.StateBar(order_id=order_id,
                                       disable=quiet) as bar:
            state = await cl.wait(str(order_id),
                                  state=state,
                                  delay=delay,
                                  max_attempts=max_attempts,
                                  callback=bar.update_state)
    click.echo(state)


@orders.command()
@click.pass_context
@translate_exceptions
@coro
@click.argument('order_id', type=click.UUID)
@click.option('--directory',
              default='.',
              help=('Base directory for file download.'),
              type=click.Path(exists=True,
                              resolve_path=True,
                              writable=True,
                              file_okay=False))
@click.option('--overwrite',
              is_flag=True,
              default=False,
              help=('Overwrite files if they already exist.'))
async def download(ctx, order_id, overwrite, directory):
    """Download order by order ID."""
    quiet = ctx.obj['QUIET']
    async with orders_client(ctx) as cl:
        await cl.download_order(str(order_id),
                                directory=directory,
                                overwrite=overwrite,
                                progress_bar=not quiet)


def read_file_geojson(ctx, param, value):
    # skip this if the filename is None
    if not value:
        return value

    json_value = read_file_json(ctx, param, value)
    geo = planet.geojson.as_geom(json_value)
    return geo


def read_file_json(ctx, param, value):
    # skip this if the filename is None
    if not value:
        return value

    try:
        LOGGER.debug('reading json from file')
        json_value = json.load(value)
    except json.decoder.JSONDecodeError:
        raise click.ClickException('File does not contain valid json.')

    return json_value


@orders.command()
@click.pass_context
@translate_exceptions
@coro
@click.option('--name', required=True)
@click.option('--id',
              'ids',
              help='One or more comma-separated item IDs',
              type=click.STRING,
              callback=split_list_arg,
              required=True)
# @click.option('--ids_from_search',
#               help='Embedded data search')
@click.option(
    '--bundle',
    multiple=False,
    required=True,
    help='Specify bundle',
    type=click.Choice(planet.specs.get_product_bundles(),
                      case_sensitive=False),
)
@click.option('--item-type',
              multiple=False,
              required=True,
              help='Specify an item type',
              type=click.STRING)
@click.option('--email',
              default=False,
              is_flag=True,
              help='Send email notification when Order is complete')
@click.option('--cloudconfig',
              help='Cloud delivery config json file.',
              type=click.File('rb'),
              callback=read_file_json)
@click.option('--clip',
              help='Clip GeoJSON file.',
              type=click.File('rb'),
              callback=read_file_geojson)
@click.option('--tools',
              help='Toolchain json file.',
              type=click.File('rb'),
              callback=read_file_json)
@pretty
async def create(ctx,
                 name,
                 ids,
                 bundle,
                 item_type,
                 email,
                 cloudconfig,
                 clip,
                 tools,
                 pretty):
    '''Create an order.'''
    try:
        product = planet.order_request.product(ids, bundle, item_type)
    except planet.specs.SpecificationException as e:
        raise click.BadParameter(e)

    if email:
        notifications = planet.order_request.notifications(email=email)
    else:
        notifications = None

    if cloudconfig:
        delivery = planet.order_request.delivery(cloud_config=cloudconfig)
    else:
        delivery = None

    if clip and tools:
        raise click.BadParameter("Specify only one of '--clip' or '--tools'")
    elif clip:
        try:
            clip = planet.geojson.as_polygon(clip)
        except planet.geojson.GeoJSONException as e:
            raise click.BadParameter(e)

        tools = [planet.order_request.clip_tool(clip)]

    request = planet.order_request.build_request(name,
                                                 products=[product],
                                                 delivery=delivery,
                                                 notifications=notifications,
                                                 tools=tools)

    async with orders_client(ctx) as cl:
        order = await cl.create_order(request)

    echo_json(order, pretty)

planet/cli/io.py METASEP
# 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.
"""Helpers for CLI I/O"""
import json

import click


def echo_json(obj: object, pretty: bool = False) -> None:
    """

    Parameters:
        obj: any object serializeable to JSON
        pretty: whether to reformat for easy visualization

    Returns:
        None
    """
    if pretty:
        json_str = json.dumps(obj, indent=2, sort_keys=True)
        click.echo(json_str)
    else:
        click.echo(json.dumps(obj))

planet/cli/data.py METASEP
"""The Planet Data CLI."""

import json
from typing import AsyncIterator, List

import click

from .cmds import coro, translate_exceptions
from .io import echo_json

# Parameter callbacks are what we use to validate and transform the
# values of arguments and options. On the command line, everything is
# a string, but we want to transform to natural Python types as soon as
# possible.


def parse_item_types(ctx, param, value: str) -> List[str]:
    """Turn a string of comma-separated names into a list of names."""
    # Note: we could also normalize case and validate the names against
    # our schema here.
    return [part.strip() for part in value.split(",")]


def parse_filter(ctx, param, value: str) -> dict:
    """Turn filter JSON into a dict."""
    return json.loads(value)


@click.group()
@click.pass_context
def data(ctx):
    """A group of commands for interacting with the Data API."""


# TODO: filter().


@data.command()
@translate_exceptions
# Our CLI command functions are async functions. They need an event
# loop to be executed. The coro decorator provides that event loop.
@coro
# The command has two positional arguments. ITEM_TYPES comes first.
# By default these are required and will evaluate to str type.
@click.argument("item_types", callback=parse_item_types)
@click.argument("filter", callback=parse_filter)
# The command has three options.
@click.option("--limit",
              type=int,
              default=100,
              help="Maximum number of results to return.")
@click.option("--name", help="Name for the saved search.")
@click.option("--pretty", is_flag=True, help="Pretty print output.")
# This decorator gives our function a context (named "ctx") that may
# contain parameters set by the "planet data" command group, like
# "planet --quiet data" or "planet --verbosity=debug data".
@click.pass_context
async def search_quick(ctx,
                       item_types: List[str],
                       filter: dict,
                       limit: int,
                       pretty: bool,
                       name: str):
    """Execute a structured item search and print results.

    Results are represented as a sequence of GeoJSON features.

    """

    # Note: API tokens will be found in "ctx". They are unused in this
    # example.
    #
    # The Planet Data client will return a stream of GeoJSON
    # Feature-like dicts (aka an "iterator"). The search_quick function
    # will iterate over the features, encode them as JSON, and echo
    # them. We simulate that here.
    async def example_client(item_types,
                             filter,
                             limit=0,
                             name=None) -> AsyncIterator[dict]:
        """This is a placeholder.

        We'll delete this and use the real method from
        planet.clients.data when it is available.

        """
        # Note: we re-emit the input item types and filter to help us
        # sanity check during early sprints.
        for feature in item_types:
            yield dict(item_type=feature)

        yield dict(filter=filter)

    # CLI functions raise ClickException when any kind of intentionally
    # raised PlanetError occurs. We *don't* handle other errors now
    # because those will be caused by bugs in our code and we want them
    # raw and unfiltered.
    async for feature in example_client(item_types,
                                        filter,
                                        limit=limit,
                                        name=name):
        echo_json(feature, pretty=pretty)


# TODO: search_create()".
# TODO: search_update()".
# TODO: search_delete()".
# TODO: search_run()".
# TODO: item_get()".
# TODO: asset_activate()".
# TODO: asset_wait()".
# TODO: asset_download()".
# TODO: search_create()".
# TODO: stats()".

planet/cli/cmds.py METASEP
# 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.
"""Decorators for Click commands"""
import asyncio
from functools import wraps

import click

from planet import exceptions


# https://github.com/pallets/click/issues/85#issuecomment-503464628
def coro(func):
    """Wrap async functions so they can be run sync with Click.

    Parameters:
        func: a Click command function or wrapper around one.

    Returns:
        wrapper function
    """

    @wraps(func)
    def wrapper(*args, **kwargs):
        return asyncio.run(func(*args, **kwargs))

    return wrapper


def translate_exceptions(func):
    """Translate internal exceptions to ClickException.

    Parameters:
        func: a Click command function or wrapper around one.

    Returns:
        wrapper function

    Raises:
        ClickException
    """

    @wraps(func)
    def wrapper(*args, **kwargs):
        try:
            func(*args, **kwargs)
        except exceptions.AuthException:
            raise click.ClickException(
                'Auth information does not exist or is corrupted. Initialize '
                'with `planet auth init`.')
        except exceptions.PlanetError as ex:
            raise click.ClickException(ex)

    return wrapper

planet/cli/cli.py METASEP
# Copyright 2017 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.
"""CLI main entry point"""
import logging
import sys

import click

import planet

from . import auth, data, orders

LOGGER = logging.getLogger(__name__)


@click.group()
@click.pass_context
@click.option('--verbosity',
              default="warning",
              help=("Optional: set verbosity level to warning, info, or debug.\
                  Defaults to warning."))
@click.option('--quiet',
              is_flag=True,
              default=False,
              help='Disable ANSI control output.')
@click.version_option(version=planet.__version__)
@click.version_option(version=planet.__version__)
def main(ctx, verbosity, quiet):
    """Planet API Client
    Parameters:
        ctx -- context object
        verbosity -- user input for verbosity."""
    _configure_logging(verbosity)

    # ensure that ctx.obj exists and is a dict (in case `cli()` is called
    # by means other than the `if` block below)
    ctx.ensure_object(dict)
    ctx.obj['QUIET'] = quiet


def _configure_logging(verbosity):
    """configure logging via verbosity level, corresponding
    to log levels warning, info and debug respectfully.

    Parameters:
        verbosity -- user input for verbosity.
    Raises:
        click.BadParameter: on unexpected parameter input """
    # make the user input string lowercase & strip leading/trailing spaces
    verbosity_input = verbosity.lower()
    verbosity_input = verbosity_input.strip()

    if verbosity_input == 'warning':
        log_level = logging.WARNING
    elif verbosity_input == 'info':
        log_level = logging.INFO
    elif verbosity_input == 'debug':
        log_level = logging.DEBUG
    else:
        raise click.BadParameter("please set verbosity to \
            warning, info, or debug.")
    logging.basicConfig(
        stream=sys.stderr,
        level=log_level,
        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')


main.add_command(auth.auth)
main.add_command(data.data)
main.add_command(orders.orders)

planet/cli/auth.py METASEP
# 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.
"""Auth API CLI"""
import logging

import click

import planet
from .cmds import translate_exceptions

LOGGER = logging.getLogger(__name__)


@click.group()
@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


@auth.command()
@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.write()
    click.echo('Initialized')


@auth.command()
@translate_exceptions
def value():
    '''Print the stored authentication information'''
    click.echo(planet.Auth.from_file().value)

planet/cli/__init__.py METASEP

setup.py METASEP
# Copyright 2015 Planet Labs, Inc.
#
# 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 pathlib import Path
from setuptools import setup, find_packages

with open('planet/__version__.py') as f:
    for line in f:
        if line.find("__version__") >= 0:
            version = line.split("=")[1].strip()
            version = version.strip('"')
            version = version.strip("'")
            continue

install_requires = [
    'click>=8.0.0',
    'geojson',
    'httpx==0.16.1',
    'pyjwt>=2.1',
    'tqdm>=4.56',
]

test_requires = [
    'pytest', 'pytest-asyncio==0.16', 'pytest-cov', 'respx==0.16.3'
]

lint_requires = ['flake8', 'yapf']

doc_requires = [
    'mkdocs==1.3',
    'mkdocs-click==0.7.0',
    'mkdocs-material==8.2.11',
    'mkdocstrings==0.18.1'
]

setup(
    name='planet',
    version=version,
    description=u"Planet SDK for Python",
    long_description=Path("README.md").read_text("utf-8"),
    classifiers=[
        'Development Status :: 2 - Pre-Alpha',
        'Environment :: Console',
        'Intended Audience :: Developers',
        'License :: OSI Approved :: Apache Software License',
        'Operating System :: OS Independent',
        'Programming Language :: Python',
        'Topic :: Scientific/Engineering',
        'Topic :: Software Development',
        'Topic :: Utilities'
    ],
    keywords='planet api sdk client',
    author='Jennifer Reiber Kyle',
    author_email='jennifer.kyle@planet.com',
    url='https://github.com/planetlabs/planet-client-python',
    license='Apache 2.0',
    packages=find_packages(exclude=['examples', 'tests']),
    package_data={
        "": ["LICENSE", "CONTRIBUTING.md"],
        "planet": ["data/*"],
    },
    include_package_data=True,
    zip_safe=False,
    python_requires='>=3.7',
    install_requires=install_requires,
    extras_require={
        'test': test_requires,
        'lint': lint_requires,
        'docs': doc_requires,
        'dev': test_requires + lint_requires + doc_requires
    },
    entry_points={
        'console_scripts': [
            'planet=planet.cli.cli:main',
        ],
    },
)

noxfile.py METASEP
import nox

nox.options.stop_on_first_error = True
nox.options.reuse_existing_virtualenvs = False

nox.options.sessions = ['test', 'lint', 'docs', 'install']

source_files = ("planet", "examples", "tests", "setup.py", "noxfile.py")


@nox.session(python=["3.7", "3.8", "3.9"])
def test(session):
    # must include -e to allow coverage file update
    session.install("-e", ".[test]")

    options = session.posargs
    if '-k' in options:
        options.append('--no-cov')
    session.run('pytest', '--ignore', 'examples/', '-v', *options)


@nox.session
def lint(session):
    session.install("-e", ".[lint]")

    session.run("flake8", *source_files)
    session.run('yapf', '--diff', '-r', *source_files)


@nox.session
def install(session):
    # just make sure it can be installed
    session.install(".")


@nox.session
def docs_test(session):
    session.install("-e", ".[docs]")

    options = session.posargs

    # Because these doc examples can be long-running, output
    # the INFO and above log messages so we know what's happening
    session.run('pytest',
                '--doctest-glob',
                '*.md',
                '--no-cov',
                '--ignore',
                'examples/',
                '--ignore',
                'tests/',
                '--log-cli-level=INFO',
                *options)


@nox.session
def docs(session):
    session.install("-e", ".[docs]")

    session.run("mkdocs", "build")


@nox.session
def watch(session):
    session.install("-e", ".[docs]")

    session.run("mkdocs", "serve")


@nox.session
def examples(session):
    session.install("-e", ".[test]")

    options = session.posargs

    # Because these example scripts can be long-running, output the
    # example's stdout so we know what's happening
    session.run('pytest', '--no-cov', 'examples/', '-s', *options)

planet/specs.py METASEP
# Copyright 2020 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.
"""Functionality for validating against the Planet API specification."""
import json
import logging
import os
from pathlib import Path

DATA_DIR = 'data'
PRODUCT_BUNDLE_SPEC_NAME = 'orders_product_bundle_2022_02_02.json'
SUPPORTED_TOOLS = [
    'band_math',
    'clip',
    'composite',
    'coregister',
    'file_format',
    'reproject',
    'tile',
    'toar',
    'harmonize'
]
SUPPORTED_ORDER_TYPES = ['full', 'partial']
SUPPORTED_ARCHIVE_TYPES = ['zip']
SUPPORTED_FILE_FORMATS = ['COG', 'PL_NITF']

LOGGER = logging.getLogger(__name__)


class SpecificationException(Exception):
    '''No match was found'''
    pass


def validate_bundle(bundle):
    supported = get_product_bundles()
    return _validate_field(bundle, supported, 'product_bundle')


def validate_item_type(item_type, bundle):
    bundle = validate_bundle(bundle)
    supported = get_item_types(bundle)
    return _validate_field(item_type, supported, 'item_type')


def validate_order_type(order_type):
    return _validate_field(order_type, SUPPORTED_ORDER_TYPES, 'order_type')


def validate_archive_type(archive_type):
    return _validate_field(archive_type,
                           SUPPORTED_ARCHIVE_TYPES,
                           'archive_type')


def validate_tool(tool):
    return _validate_field(tool, SUPPORTED_TOOLS, 'tool')


def validate_file_format(file_format):
    return _validate_field(file_format, SUPPORTED_FILE_FORMATS, 'file_format')


def _validate_field(value, supported, field_name):
    try:
        value = get_match(value, supported)
    except (NoMatchException):
        opts = ', '.join(["'" + s + "'" for s in supported])
        msg = f'{field_name} - \'{value}\' is not one of {opts}.'
        raise SpecificationException(msg)
    return value


class NoMatchException(Exception):
    '''No match was found'''
    pass


def get_match(test_entry, spec_entries):
    '''Find and return matching spec entry regardless of capitalization.

    This is helpful for working with the API spec, where the capitalization
    is hard to remember but must be exact otherwise the API throws an
    exception.'''
    try:
        match = next(t for t in spec_entries
                     if t.lower() == test_entry.lower())
    except (StopIteration):
        raise NoMatchException

    return match


def get_product_bundles():
    '''Get product bundles supported by Orders API.'''
    spec = _get_product_bundle_spec()
    return spec['bundles'].keys()


def get_item_types(product_bundle):
    '''Get item types supported by Orders API for the given product bundle.'''
    spec = _get_product_bundle_spec()
    return spec['bundles'][product_bundle]['assets'].keys()


def _get_product_bundle_spec_path():
    curr_path = Path(os.path.dirname(__file__))
    data_dir = curr_path / DATA_DIR
    return data_dir / PRODUCT_BUNDLE_SPEC_NAME


def _get_product_bundle_spec():
    with open(_get_product_bundle_spec_path()) as f:
        data = json.load(f)
    return data

planet/reporting.py METASEP
# Copyright 2021 Planet Labs, Inc.
#
# 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.
"""Functionality for reporting progress."""
import logging

from tqdm.asyncio import tqdm

LOGGER = logging.getLogger(__name__)


class ProgressBar():
    """Abstract base class for progress bar reporters."""

    def __init__(self, disable: bool = False):
        self.bar = None
        self.disable = disable

    def __str__(self):
        return str(self.bar)

    def __enter__(self):
        self.open_bar()
        return self

    def __exit__(self, *args):
        self.bar.close()

    def open_bar(self):
        """Initialize and start the progress bar."""
        return NotImplementedError


class StateBar(ProgressBar):
    """Bar reporter of order state.

    Example:
        ```python
        from planet import reporting

        with reporting.StateBar(state='creating') as bar:
            bar.update(state='created', order_id='oid')
            ...
        ```
    """

    def __init__(
        self,
        order_id: str = None,
        state: str = None,
        disable: bool = False,
    ):
        """Initialize the object.

        Parameters:
            order_id: Id of the order.
            state: State of the order.
        """

        self.state = state or ''
        self.order_id = order_id or ''
        super().__init__(disable=disable)

    def open_bar(self):
        """Initialize and start the progress bar."""
        self.bar = tqdm(
            bar_format="{elapsed} - {desc} - {postfix[0]}: {postfix[1]}",
            desc=self.desc,
            postfix=["state", self.state],
            disable=self.disable)

    @property
    def desc(self):
        return f'order {self.order_id}'

    def update_state(self, state: str):
        """Simple function to be used as a callback for state reporting"""
        self.update(state=state)

    def update(self, state: str = None, order_id: str = None):
        if state:
            self.state = state
            try:
                self.bar.postfix[1] = self.state
            except AttributeError:
                # If the bar is disabled, attempting to access self.bar.postfix
                # will result in an error. In this case, just skip it.
                pass

        if order_id:
            self.order_id = order_id
            self.bar.set_description_str(self.desc, refresh=False)

        self.bar.refresh()

planet/order_request.py METASEP
# Copyright 2020 Planet Labs, Inc.
#
# 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.
"""Functionality for preparing order details for use in creating an order"""
from __future__ import annotations  # https://stackoverflow.com/a/33533514
import logging
from typing import List

from . import geojson, specs

LOGGER = logging.getLogger(__name__)


def build_request(name: str,
                  products: List[dict],
                  subscription_id: int = 0,
                  delivery: dict = None,
                  notifications: dict = None,
                  order_type: str = None,
                  tools: List[dict] = None) -> dict:
    '''Prepare an order request.

    ```python
    >>> from planet.api.order_details import (
    ...     build_request, product, toar_tool, reproject_tool, tile_tool)
    ...
    >>> products = [
    ...     product(['20170614_113217_3163208_RapidEye-5'],
    ...             'analytic', 'REOrthoTile')])
    ... ]
    ...
    >>> tools = [
    ...     toar_tool(scale_factor=10000),
    ...     reproject_tool(projection='WSG84', kernel='cubic'),
    ...     tile_tool(1232, origin_x=-180, origin_y=-90,
    ...               pixel_size=0.000027056277056,
    ...               name_template='C1232_30_30_{tilex:04d}_{tiley:04d}')
    ... ]
    ...
    >>> order_request = build_request(
    ...     'test_order', products, tools)
    ...

    ```

    Parameters:
        name: Name of the order.
        products: Product(s) from the Data API to order.
        subscription_id: Apply this orders against this quota subscription.
        delivery: Specify custom delivery handling.
        notifications: Specify custom notifications handling.
        order_type: Accept a partial order, indicated by 'partial'.
        tools: Tools to apply to the products. Order defines
            the toolchain order of operatations.

    Raises:
        planet.specs.SpecificationException: If order_type is not a valid
            order type.
    '''
    details = {'name': name, 'products': products}

    if subscription_id:
        details['subscription_id'] = subscription_id

    if delivery:
        details['delivery'] = delivery

    if notifications:
        details['notifications'] = notifications

    if order_type:
        order_type = specs.validate_order_type(order_type)
        details['order_type'] = order_type

    if tools:
        details['tools'] = tools

    return details


def product(item_ids: List[str],
            product_bundle: str,
            item_type: str,
            fallback_bundle: str = None) -> dict:
    '''Product description for an order detail.

    Parameters:
        item_ids: IDs of the catalog items to include in the order.
        product_bundle: Set of asset types for the catalog items.
        item_type: The class of spacecraft and processing characteristics
            for the catalog items.
        fallback_bundle: In case product_bundle not having
            all asset types available, which would result in failed
            delivery, try a fallback bundle

    Raises:
        planet.specs.SpecificationException: If bundle or fallback bundle
            are not valid bundles or if item_type is not valid for the given
            bundle or fallback bundle.
    '''
    product_bundle = specs.validate_bundle(product_bundle)
    item_type = specs.validate_item_type(item_type, product_bundle)

    if fallback_bundle is not None:
        fallback_bundle = specs.validate_bundle(fallback_bundle)
        specs.validate_item_type(item_type, fallback_bundle)
        product_bundle = ','.join([product_bundle, fallback_bundle])

    product_dict = {
        'item_ids': item_ids,
        'item_type': item_type,
        'product_bundle': product_bundle
    }
    return product_dict


def notifications(email: bool = None,
                  webhook_url: str = None,
                  webhook_per_order: bool = None) -> dict:
    '''Notifications description for an order detail.

    Parameters:
        email: Enable email notifications for an order.
        webhook_url: URL for notification when the order is ready.
        webhook_per_order: Request a single webhook call per order instead
            of one call per each delivered item.
    '''
    return dict((k, v) for k, v in locals().items() if v)


def delivery(archive_type: str = None,
             single_archive: bool = False,
             archive_filename: str = None,
             cloud_config: dict = None) -> dict:
    '''Order delivery configuration.

    Example:
        ```python
        amazon_s3_config = amazon_s3(
            'access_key',
            'secret_access_key',
            'bucket_name',
            'us-east-2',
            'folder1/prefix/'
        )
        delivery_config = delivery(
            archive_type='zip',
            single_archive=True,
            archive_filename='{{order_id}}.zip'
            cloud_config=amazon_s3_config
        )
        ```

    Parameters:
        archive_type: Archive order files. Only supports 'zip'.
        single_archive: Archive all bundles together in a single file.
        archive_filename: Custom naming convention to use to name the
            archive file that is received. Uses the template variables
            {{name}} and {{order_id}}. e.g. "{{name}}_{{order_id}}.zip".
        cloud_config: Cloud delivery configuration.

    Raises:
        planet.specs.SpecificationException: If archive_type is not valid.
    '''
    if archive_type:
        archive_type = specs.validate_archive_type(archive_type)

    fields = ['archive_type', 'single_archive', 'archive_filename']
    values = [archive_type, single_archive, archive_filename]

    config = dict((k, v) for k, v in zip(fields, values) if v)

    if cloud_config:
        config.update(cloud_config)
    return config


def amazon_s3(aws_access_key_id: str,
              aws_secret_access_key: str,
              bucket: str,
              aws_region: str,
              path_prefix: str = None) -> dict:
    '''Amazon S3 Cloud configuration.

    Parameters:
        aws_access_key_id: S3 account access key.
        aws_secret_access_key: S3 account secret key.
        bucket: The name of the bucket that will receive the order output.
        aws_region: The region where the bucket lives in AWS.
        path_prefix: Custom string to prepend to the files delivered to the
            bucket. A slash (/) character will be treated as a "folder".
            Any other characters will be added as a prefix to the files.
    '''
    cloud_details = {
        'aws_access_key_id': aws_access_key_id,
        'aws_secret_access_key': aws_secret_access_key,
        'bucket': bucket,
        'aws_region': aws_region,
    }

    if path_prefix:
        cloud_details['path_prefix'] = path_prefix

    return {'amazon_s3': cloud_details}


def azure_blob_storage(account: str,
                       container: str,
                       sas_token: str,
                       storage_endpoint_suffix: str = None,
                       path_prefix: str = None) -> dict:
    '''Azure Blob Storage configuration.

    Parameters:
        account: Azure account.
        container: ABS container name.
        sas_token: Shared-Access Signature token. Token should be specified
            without a leading '?'.
        storage_endpoint_suffix: Deliver order to a sovereign cloud.
        path_prefix: Custom string to prepend to the files delivered to the
            bucket. A slash (/) character will be treated as a "folder".
            Any other characters will be added as a prefix to the files.
    '''
    cloud_details = {
        'account': account,
        'container': container,
        'sas_token': sas_token,
    }

    if storage_endpoint_suffix:
        cloud_details['storage_endpoint_suffix'] = storage_endpoint_suffix

    if path_prefix:
        cloud_details['path_prefix'] = path_prefix

    return {'azure_blob_storage': cloud_details}


def google_cloud_storage(bucket: str,
                         credentials: str,
                         path_prefix: str = None) -> dict:
    '''Google Cloud Storage configuration.

    Parameters:
        bucket: GCS bucket name.
        credentials: JSON-string of service account for bucket.
        path_prefix: Custom string to prepend to the files delivered to the
            bucket. A slash (/) character will be treated as a "folder".
            Any other characters will be added as a prefix to the files.
    '''
    cloud_details = {
        'bucket': bucket,
        'credentials': credentials,
    }

    if path_prefix:
        cloud_details['path_prefix'] = path_prefix

    return {'google_cloud_storage': cloud_details}


def google_earth_engine(project: str, collection: str) -> dict:
    '''Google Earth Engine configuration.

    Parameters:
        project: GEE project name.
        collection: GEE Image Collection name.
    '''
    cloud_details = {
        'project': project,
        'collection': collection,
    }
    return {'google_earth_engine': cloud_details}


def _tool(name: str, parameters: dict) -> dict:
    '''Create the API spec representation of a tool.

    See [Tools and Toolchains](
    https://developers.planet.com/docs/orders/tools-toolchains/)
    for more information on available tools and tool parameters.

    Parameters:
        name: Tool name.
        parameters: Tool parameters.

    Raises:
        planet.specs.SpecificationException: If name is not the name of a valid
            Orders API tool.
    '''
    name = specs.validate_tool(name)
    return {name: parameters}


def clip_tool(aoi: dict) -> dict:
    '''Create the API spec representation of a clip tool.

    Example:
        ```python
        aoi = {
            "type": "Polygon",
            "coordinates": [[
                [37.791595458984375, 14.84923123791421],
                [37.90214538574219, 14.84923123791421],
                [37.90214538574219, 14.945448293647944],
                [37.791595458984375, 14.945448293647944],
                [37.791595458984375, 14.84923123791421]
            ]]
          }
        tool = clip_tool(aoi)
        ```

    Parameters:
        aoi: clip GeoJSON.

    Raises:
        geojson.GeoJSONException: If GeoJSON is not a valid polygon.
    '''
    parameters = {'aoi': geojson.as_polygon(aoi)}
    return _tool('clip', parameters)


def composite_tool() -> dict:
    '''Create the API spec representation of a composite tool.
    '''
    return _tool('composite', {})


def coregister_tool(anchor_item: str) -> dict:
    '''Create the API spec representation of a coregister tool.

    Parameters:
        anchor_item: The item_id of the item to which all other items should be
            coregistered.
    '''
    return _tool('coregister', {'anchor_item': anchor_item})


def file_format_tool(file_format: str) -> dict:
    '''Create the API spec representation of a file format tool.

    Parameters:
        file_format: The format of the tool output. Either 'COG' or 'PL_NITF'.

    Raises:
        planet.specs.SpecificationException: If file_format is not one of
            'COG' or 'PL_NITF'
    '''
    file_format = specs.validate_file_format(file_format)
    return _tool('file_format', {'format': file_format})


def reproject_tool(projection: str,
                   resolution: float = None,
                   kernel: str = None) -> dict:
    '''Create the API spec representation of a reproject tool.

    Parameters:
        projection: A coordinate system in the form EPSG:n. (ex. EPSG:4326 for
            WGS84, EPSG:32611 for UTM 11 North (WGS84), or EPSG:3857 for Web
            Mercator).
        resolution: The pixel width and height in the output file. The API
            default is the resolution of the input item. This value will be in
            meters unless the coordinate system is geographic (like EPSG:4326),
            then it will be a pixel size in decimal degrees.
        kernel: The resampling kernel used. The API default is "near". This
            parameter also supports "bilinear", "cubic", "cubicspline",
            "lanczos", "average" and "mode".
    '''
    parameters = dict((k, v) for k, v in locals().items() if v)
    return _tool('reproject', parameters)


def tile_tool(tile_size: int,
              origin_x: float = None,
              origin_y: float = None,
              pixel_size: float = None,
              name_template: str = None,
              conformal_x_scaling: bool = None) -> dict:
    '''Create the API spec representation of a reproject tool.

    Parameters:
        tile_size: Height and width of output tiles in pixels and lines
            (always square).
        origin_x: Tiling system x origin in projected coordinates. The API
            default is zero.
        origin_y: Tiling system y origin in projected coordinates. The API
            default is zero.
        pixel_size: Tiling system pixel size in projected coordinates. The API
            default is the pixel_size of input raster.
        name_template: A naming template for creating output tile filenames.
            The API default is "{tilex}_{tiley}.tif" resulting in filenames
            like 128_200.tif. The {tilex} and {tiley} parameters can be of the
            form {tilex:06d} to produce a fixed width field with leading zeros.
    '''
    parameters = dict((k, v) for k, v in locals().items() if v)
    return _tool('tile', parameters)


def toar_tool(scale_factor: int = None, ) -> dict:
    '''Create the API spec representation of a TOAR tool.

    Parameters:
        scale_factor: Scale factor applied to convert 0.0 to 1.0 reflectance
            floating point values to a value that fits in 16bit integer pixels.
            The API default is 10000. Values over 65535 could result in high
            reflectances not fitting in 16bit integers.
    '''
    parameters = {}
    if scale_factor:
        parameters['scale_factor'] = scale_factor
    return _tool('toar', parameters)


def harmonize_tool() -> dict:
    '''Create the API spec representation of a harmonize tool.

    Currently, only "PS2" (Dove Classic) is supported as a target sensor, and
    it will transform only items captured by “PS2.SD” (Dove-R).
    '''
    return _tool('harmonize', {'target_sensor': 'PS2'})

planet/models.py METASEP
# Copyright 2015 Planet Labs, Inc.
#
# 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.
"""Manage data for requests and responses."""
import copy
from datetime import datetime
import logging
import mimetypes
import random
import re
import string

import httpx
from tqdm.asyncio import tqdm

LOGGER = logging.getLogger(__name__)


class RequestException(Exception):
    """Exceptions thrown by RequestException"""
    pass


class Request():
    '''Handles a HTTP request for the Planet server.

    :param url: URL of API endpoint
    :type url: str
    :param params: Values to send in the query string. Defaults to None.
    :type params: dict, list of tuples, or bytes, optional
    :param data: Object to send in the body. Defaults to None.
    :type data: dict, list of tuples, bytes, or file-like object, optional
    :param json: JSON to send. Defaults to None.
    :type json: dict, optional
    :param method: HTTP request method. Defaults to 'GET'
    :type method: str, optional
    :raises RequestException: When provided `body_type` is not a subclass of
        :py:class:`planet.api.models.Body`
    '''

    def __init__(self, url, params=None, data=None, json=None, method='GET'):
        if data or json:
            headers = {'Content-Type': 'application/json'}
        else:
            headers = None

        self.http_request = httpx.Request(method,
                                          url,
                                          params=params,
                                          data=data,
                                          json=json,
                                          headers=headers)

    @property
    def url(self):
        return self.http_request.url

    @url.setter
    def url(self, url):
        '''Set the url.

        :param url: URL of API endpoint
        :type url: str
        '''
        self.http_request.url = httpx.URL(url)


class Response():
    '''Handles the Planet server's response to a HTTP request.

    :param request: Request that was submitted to the server
    :type request: :py:Class:`Request`
    :param http_response: Response that was received from the server
    :type http_response: :py:Class:`requests.models.Response`
    '''

    def __init__(self, request, http_response):
        self.request = request
        self.http_response = http_response

    def __repr__(self):
        return f'<models.Response [{self.status_code}]>'

    @property
    def status_code(self):
        '''HTTP status code.

        :returns: status code
        :rtype: int
        '''
        return self.http_response.status_code

    def json(self):
        '''Get response json.

        :returns:response json
        :rtype: dict
        '''
        return self.http_response.json()

    async def aclose(self):
        await self.http_response.aclose()


class StreamingBody():
    '''A representation of a streaming resource from the API.

    :param response: Response that was received from the server
    :type response: :py:Class:`requests.models.Response`
        '''

    def __init__(self, response):
        self.response = response.http_response
        self.url = response.request.url

    @property
    def name(self):
        '''The name of this resource.
        The default is to use the content-disposition header value from the
        response. If not found, falls back to resolving the name from the url
        or generating a random name with the type from the response.

        :returns: name of this resource
        :rtype: str
        '''
        name = (_get_filename_from_headers(self.response.headers)
                or _get_filename_from_url(self.url) or _get_random_filename(
                    self.response.headers.get('content-type')))
        return name

    @property
    def size(self):
        '''The size of the body.

        :returns: size of the body
        :rtype: int
        '''
        return int(self.response.headers['Content-Length'])

    @property
    def num_bytes_downloaded(self):
        '''The number of bytes downloaded.

        :returns: number of bytes downloaded
        :rtype: int
        '''
        return self.response.num_bytes_downloaded

    def last_modified(self):
        '''Read the last-modified header as a datetime, if present.

        :returns: last-modified header
        :rtype: datatime or None
        '''
        lm = self.response.headers.get('last-modified', None)
        return datetime.strptime(lm, '%a, %d %b %Y %H:%M:%S GMT') if lm \
            else None

    async def aiter_bytes(self):
        async for c in self.response.aiter_bytes():
            yield c

    async def write(self, filename, overwrite=True, progress_bar=True):
        '''Write the body to a file.

        :param filename: Name to assign to downloaded file.
        :type filename: str
        :param overwrite: Overwrite any existing files. Defaults to True
        :type overwrite: boolean, optional
        :param progress_bar: Show progress bar during download. Defaults to
            True.
        :type progress_bar: boolean, optional
        '''

        class _LOG():

            def __init__(self, total, unit, filename, disable):
                self.total = total
                self.unit = unit
                self.disable = disable
                self.previous = 0
                self.filename = filename

                if not self.disable:
                    LOGGER.debug(f'writing to {self.filename}')

            def update(self, new):
                if new - self.previous > self.unit and not self.disable:
                    # LOGGER.debug(f'{new-self.previous}')
                    perc = int(100 * new / self.total)
                    LOGGER.debug(f'{self.filename}: '
                                 f'wrote {perc}% of {self.total}')
                    self.previous = new

        unit = 1024 * 1024

        mode = 'wb' if overwrite else 'xb'
        try:
            with open(filename, mode) as fp:
                _log = _LOG(self.size,
                            16 * unit,
                            filename,
                            disable=progress_bar)
                with tqdm(total=self.size,
                          unit_scale=True,
                          unit_divisor=unit,
                          unit='B',
                          desc=filename,
                          disable=not progress_bar) as progress:
                    previous = self.num_bytes_downloaded
                    async for chunk in self.aiter_bytes():
                        fp.write(chunk)
                        new = self.num_bytes_downloaded
                        _log.update(new)
                        progress.update(new - previous)
                        previous = new
        except FileExistsError:
            LOGGER.info(f'File {filename} exists, not overwriting')


def _get_filename_from_headers(headers):
    """Get a filename from the Content-Disposition header, if available.

    :param headers dict: a ``dict`` of response headers
    :returns: a filename (i.e. ``basename``)
    :rtype: str or None
    """
    cd = headers.get('content-disposition', '')
    match = re.search('filename="?([^"]+)"?', cd)
    return match.group(1) if match else None


def _get_filename_from_url(url):
    """Get a filename from a URL.

    :returns: a filename (i.e. ``basename``)
    :rtype: str or None
    """
    path = url.path
    name = path[path.rfind('/') + 1:]
    return name or None


def _get_random_filename(content_type=None):
    """Get a pseudo-random, Planet-looking filename.

    :returns: a filename (i.e. ``basename``)
    :rtype: str
    """
    extension = mimetypes.guess_extension(content_type or '') or ''
    characters = string.ascii_letters + '0123456789'
    letters = ''.join(random.sample(characters, 8))
    name = 'planet-{}{}'.format(letters, extension)
    return name


class Paged():
    '''Asynchronous iterator over results in a paged resource from the Planet
    server.
    Each returned result is a json dict.

    :param request: Open session connected to server
    :type request: planet.api.http.ASession
    :param do_request_fcn: Function for submitting a request. Takes as input
        a planet.api.models.Request and returns planet.api.models.Response.
    :type do_request_fcn: function
    :param limit: Limit orders to given limit. Defaults to None
    :type limit: int, optional
    '''
    LINKS_KEY = 'links'
    NEXT_KEY = 'next'
    ITEMS_KEY = 'items'
    TYPE = None

    def __init__(self, request, do_request_fcn, limit=None):
        self.request = request
        self._do_request = do_request_fcn

        self._pages = None
        self._items = []

        self.i = 0
        self.limit = limit

    def __aiter__(self):
        return self

    async def __anext__(self):
        '''Asynchronous next.
        :returns: next item as json
        :rtype: dict
        '''
        # This was implemented because traversing _get_pages()
        # in an async generator was resulting in retrieving all the
        # pages, when the goal is to stop retrieval when the limit
        # is reached
        if self.limit is not None and self.i >= self.limit:
            raise StopAsyncIteration

        try:
            item = self._items.pop(0)
            self.i += 1
        except IndexError:
            self._pages = self._pages or self._get_pages()
            page = await self._pages.__anext__()
            self._items = page[self.ITEMS_KEY]
            try:
                item = self._items.pop(0)
                self.i += 1
            except IndexError:
                raise StopAsyncIteration

        return item

    async def _get_pages(self):
        request = copy.deepcopy(self.request)
        LOGGER.debug('getting first page')
        resp = await self._do_request(request)
        page = resp.json()
        yield page

        next_url = self._next_link(page)
        while (next_url):
            LOGGER.debug('getting next page')
            request.url = next_url
            resp = await self._do_request(request)
            page = resp.json()
            yield page
            next_url = self._next_link(page)

    def _next_link(self, page):
        try:
            next_link = page[self.LINKS_KEY][self.NEXT_KEY]
            LOGGER.debug(f'next: {next_link}')
        except KeyError:
            LOGGER.debug('end of the pages')
            next_link = False
        return next_link

planet/http.py METASEP
# Copyright 2020 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.
"""Functionality to perform HTTP requests"""
from __future__ import annotations  # https://stackoverflow.com/a/33533514
import asyncio
from http import HTTPStatus
import logging

import httpx

from .auth import Auth
from . import exceptions, models
from .__version__ import __version__

RETRY_COUNT = 5
RETRY_WAIT_TIME = 1  # seconds

LOGGER = logging.getLogger(__name__)


class BaseSession():

    @staticmethod
    def _get_user_agent():
        return 'planet-client-python/' + __version__

    @staticmethod
    def _log_request(request):
        LOGGER.info(f'{request.method} {request.url} - Sent')

    @staticmethod
    def _log_response(response):
        request = response.request
        LOGGER.info(f'{request.method} {request.url} - '
                    f'Status {response.status_code}')

    @classmethod
    def _raise_for_status(cls, response):
        status = response.status_code

        miminum_bad_request_code = HTTPStatus.MOVED_PERMANENTLY
        if status < miminum_bad_request_code:
            return

        exception = {
            HTTPStatus.BAD_REQUEST: exceptions.BadQuery,
            HTTPStatus.UNAUTHORIZED: exceptions.InvalidAPIKey,
            HTTPStatus.FORBIDDEN: exceptions.NoPermission,
            HTTPStatus.NOT_FOUND: exceptions.MissingResource,
            HTTPStatus.CONFLICT: exceptions.Conflict,
            HTTPStatus.TOO_MANY_REQUESTS: exceptions.TooManyRequests,
            HTTPStatus.INTERNAL_SERVER_ERROR: exceptions.ServerError
        }.get(status, exceptions.APIError)
        LOGGER.debug(f"Exception type: {exception}")

        msg = response.text
        LOGGER.debug(f"Response text: {msg}")

        if exception == exceptions.TooManyRequests:
            # differentiate between over quota and rate-limiting
            if 'quota' in msg.lower():
                exception = exceptions.OverQuota

        raise exception(msg)


class Session(BaseSession):
    '''Context manager for asynchronous communication with the Planet service.

    The default behavior is to read authentication information stored in the
    secret file. This behavior can be overridden by providing an `auth.Auth`
    instance as an argument.

    Example:
    ```python
    >>> import asyncio
    >>> from planet import Session
    >>>
    >>> async def main():
    ...     async with Session() as sess:
    ...         # communicate with services here
    ...         pass
    ...
    >>> asyncio.run(main())

    ```

    Example:
    ```python
    >>> import async
    >>> from planet import Auth, Session
    >>>
    >>> async def main():
    ...     auth = Auth.from_key('examplekey')
    ...     async with Session(auth=auth) as sess:
    ...         # communicate with services here
    ...         pass
    ...
    >>> asyncio.run(main())

    ```
    '''

    def __init__(self, auth: Auth = None):
        """Initialize a Session.

        Parameters:
            auth: Planet server authentication.
        """
        auth = auth or Auth.from_file()

        self._client = httpx.AsyncClient(auth=auth)
        self._client.headers.update({'User-Agent': self._get_user_agent()})

        async def alog_request(*args, **kwargs):
            return self._log_request(*args, **kwargs)

        async def alog_response(*args, **kwargs):
            return self._log_response(*args, **kwargs)

        async def araise_for_status(*args, **kwargs):
            return self._raise_for_status(*args, **kwargs)

        self._client.event_hooks['request'] = [alog_request]
        self._client.event_hooks['response'] = [
            alog_response, araise_for_status
        ]
        self.retry_wait_time = RETRY_WAIT_TIME
        self.retry_count = RETRY_COUNT

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args):
        await self.aclose()

    async def aclose(self):
        await self._client.aclose()

    async def _retry(self, func, *a, **kw):
        """Run an asynchronous request function with retry.

        Raises:
            planet.exceptions.TooManyRequests: When retry limit is exceeded.
        """
        # TODO: retry will be provided in httpx v1 [1] with usage [2]
        # 1. https://github.com/encode/httpcore/pull/221
        # 2. https://github.com/encode/httpx/blob/
        # 89fb0cbc69ea07b123dd7b36dc1ed9151c5d398f/docs/async.md#explicit-transport-instances # noqa
        # TODO: if throttling is necessary, check out [1] once v1
        # 1. https://github.com/encode/httpx/issues/984
        retry_count = self.retry_count
        wait_time = self.retry_wait_time

        num_tries = 0
        while True:
            num_tries += 1
            try:
                resp = await func(*a, **kw)
                break
            except exceptions.TooManyRequests as e:
                if num_tries > retry_count:
                    raise e
                else:
                    LOGGER.debug(f'Try {num_tries}')
                    LOGGER.info(f'Too Many Requests: sleeping {wait_time}s')
                    # TODO: consider exponential backoff
                    # https://developers.planet.com/docs/data/api-mechanics/
                    await asyncio.sleep(wait_time)
        return resp

    async def request(self,
                      request: models.Request,
                      stream: bool = False) -> models.Response:
        """Submit a request with retry.

        Parameters:
            request: Request to submit.
            stream: Get the body as a stream.

        Returns:
            Server response.

        Raises:
            planet.exceptions.APIException: On API error.
            planet.exceptions.ClientError: When retry limit is exceeded.
        """
        # TODO: retry will be provided in httpx v1 [1] with usage [2]
        # 1. https://github.com/encode/httpcore/pull/221
        # 2. https://github.com/encode/httpx/blob/
        # 89fb0cbc69ea07b123dd7b36dc1ed9151c5d398f/docs/async.md#explicit-transport-instances # noqa
        # TODO: if throttling is necessary, check out [1] once v1
        # 1. https://github.com/encode/httpx/issues/984
        return await self._retry(self._request, request, stream=stream)

    async def _request(self, request, stream=False):
        """Submit a request"""
        http_resp = await self._client.send(request.http_request,
                                            stream=stream)
        return models.Response(request, http_resp)

    def stream(self, request: models.Request) -> Stream:
        """Submit a request and get the response as a stream context manager.

        Parameters:
            request: Request to submit

        Returns:
            Context manager providing the body as a stream.
        """
        return Stream(session=self, request=request)


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, request):
        """Submit a request

        Parameters:
            request: Request to submit.

        Returns:
            Server response.

        Raises:
            planet.exceptions.APIException: On API error.
        """

        http_resp = self._client.send(request.http_request)
        return models.Response(request, 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.')


class Stream():
    '''Context manager for asynchronous response stream from Planet server.'''

    def __init__(self, session: Session, request: models.Request):
        """
        Parameters:
            session: Open session to Planet server.
            request:  Request to submit.
        """
        self.session = session
        self.request = request

    async def __aenter__(self):
        self.response = await self.session.request(
            request=self.request,
            stream=True,
        )
        return self.response

    async def __aexit__(self, exc_type=None, exc_value=None, traceback=None):
        await self.response.aclose()

planet/geojson.py METASEP
# Copyright 2021 Planet Labs, Inc.
#
# 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.
"""Functionality for interacting with GeoJSON."""

import logging

import geojson as gj

LOGGER = logging.getLogger(__name__)


class GeoJSONException(Exception):
    '''invalid geojson'''
    pass


class WrongTypeException(GeoJSONException):
    '''wrong GeoJSON type'''
    pass


class MultipleFeaturesException(GeoJSONException):
    '''multiple features when only one is acceptable'''
    pass


def as_geom(data: dict) -> dict:
    """Extract the geometry from GeoJSON and validate.

    Parameters:
        data: GeoJSON geometry, Feature, or FeatureClass.

    Returns:
        GeoJSON geometry.

    Raises:
        GeoJSONException: If data is not valid GeoJSON geometry, Feature,
            or FeatureClass.
        DataLossWarning: If more than one Feature is in a FeatureClass.
    """
    geom = geom_from_geojson(data)
    validate_geom(geom)
    return geom


def as_polygon(data: dict) -> dict:
    geom = as_geom(data)
    geom_type = geom['type']
    if geom_type.lower() != 'polygon':
        raise WrongTypeException(
            f'Invalid geometry type: {geom_type} is not Polygon.')
    return geom


def geom_from_geojson(data: dict) -> dict:
    """Get GeoJSON geometry from GeoJSON.

    Parameters:
        data: GeoJSON geometry, Feature, or FeatureClass.

    Returns:
        GeoJSON geometry.

    Raises:
        GeoJSONException: If data is not valid GeoJSON geometry
    """
    if set(('coordinates', 'type')).issubset(set(data.keys())):
        # already a geom
        ret = data
    else:
        try:
            # feature
            ret = as_geom(data['geometry'])
        except KeyError:
            try:
                # featureclass
                features = data['features']
            except KeyError:
                raise GeoJSONException('Invalid GeoJSON: {data}')

            if len(features) > 1:
                raise MultipleFeaturesException(
                    'FeatureClass has multiple features. Only one feature '
                    'can be used to get geometry.')

            ret = as_geom(features[0])
    return ret


def validate_geom(data: dict):
    """Validate GeoJSON geometry.

    Parameters:
        data: GeoJSON geometry.

    Raises:
        GeoJSONException: If data is not a valid GeoJSON geometry.
    """
    if 'type' not in data:
        raise GeoJSONException("Missing 'type' key.")
    if 'coordinates' not in data:
        raise GeoJSONException("Missing 'coordinates' key.")

    try:
        cls = getattr(gj, data["type"])
        obj = cls(data["coordinates"])
        if not obj.is_valid:
            raise GeoJSONException(obj.errors())
    except AttributeError as err:
        raise GeoJSONException("Not a GeoJSON geometry type") from err
    except ValueError as err:
        raise GeoJSONException("Not a GeoJSON coordinate value") from err

    return

planet/exceptions.py METASEP
# Copyright 2015 Planet Labs, Inc.
#
# 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.


class PlanetError(Exception):
    """Root for all exceptions thrown by the SDK"""
    pass


class APIError(PlanetError):
    '''General unexpected API response'''


class BadQuery(APIError):
    '''Invalid inputs, HTTP 400'''
    pass


class InvalidAPIKey(APIError):
    '''Invalid key, HTTP 401'''
    pass


class NoPermission(APIError):
    '''Insufficient permissions, HTTP 403'''
    pass


class MissingResource(APIError):
    '''Request for non existing resource, HTTP 404'''
    pass


class Conflict(APIError):
    '''Request conflict with current state of the target resource, HTTP 409'''
    pass


class TooManyRequests(APIError):
    '''Too many requests, HTTP 429'''
    pass


class OverQuota(APIError):
    '''Quota exceeded, HTTP 429'''
    pass


class ServerError(APIError):
    '''Unexpected internal server error, HTTP 500'''
    pass


class InvalidIdentity(APIError):
    '''Raised when logging in with invalid credentials'''
    pass


class ClientError(PlanetError):
    """Exceptions thrown client-side"""
    pass


class AuthException(ClientError):
    '''Exceptions encountered during authentication'''
    pass

planet/constants.py METASEP
# Copyright 2020 Planet Labs, Inc.
#
# 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.
'''Constants used across the code base'''
import os

PLANET_BASE_URL = 'https://api.planet.com'

SECRET_FILE_PATH = os.path.join(os.path.expanduser('~'), '.planet.json')

planet/auth.py METASEP
# Copyright 2020 Planet Labs, Inc.
#
# 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.
'''Manage authentication with Planet APIs'''
from __future__ import annotations  # https://stackoverflow.com/a/33533514
import abc
import json
import logging
import os

import httpx
import jwt

from . import http, models
from .constants import PLANET_BASE_URL, SECRET_FILE_PATH
from .exceptions import AuthException

LOGGER = logging.getLogger(__name__)

BASE_URL = f'{PLANET_BASE_URL}/v0/auth'
ENV_API_KEY = 'PL_API_KEY'


class Auth(metaclass=abc.ABCMeta):
    '''Handle authentication information for use with Planet APIs.'''

    @staticmethod
    def from_key(key: str) -> Auth:
        '''Obtain authentication from api key.

        Parameters:
            key: Planet API key
        '''
        auth = APIKeyAuth(key=key)
        LOGGER.debug('Auth obtained from api key.')
        return auth

    @staticmethod
    def from_file(filename: str = None) -> Auth:
        '''Create authentication from secret file.

        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()`.

        Parameters:
            filename: Alternate path for the planet secret file.

        '''
        filename = filename or SECRET_FILE_PATH

        try:
            secrets = _SecretFile(filename).read()
            auth = APIKeyAuth.from_dict(secrets)
        except FileNotFoundError:
            raise AuthException(f'File {filename} does not exist.')
        except (KeyError, json.decoder.JSONDecodeError):
            raise AuthException(f'File {filename} is not the correct format.')

        LOGGER.debug(f'Auth read from secret file {filename}.')
        return auth

    @staticmethod
    def from_env(variable_name: str = None) -> Auth:
        '''Create authentication from environment variable.

        Reads the `PL_API_KEY` environment variable

        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

    @staticmethod
    def from_login(email: str, password: str, base_url: str = None) -> Auth:
        '''Create authentication from login email and password.

        Note: To keep your password secure, the use of `getpass` is
        recommended.

        Parameters:
            email: Planet account email address.
            password: Planet account password.
            base_url: The base URL to use. Defaults to production
                authentication API base url.
        '''
        cl = AuthClient(base_url=base_url)
        auth_data = cl.login(email, password)

        api_key = auth_data['api_key']
        auth = APIKeyAuth(api_key)
        LOGGER.debug('Auth set from login email and password')
        return auth

    @classmethod
    @abc.abstractmethod
    def from_dict(cls, data: dict) -> Auth:
        pass

    @property
    @abc.abstractmethod
    def value(self):
        pass

    def write(self, filename: str = None):
        '''Write authentication information.

        Parameters:
            filename: Alternate path for the planet secret file.
        '''
        filename = filename or SECRET_FILE_PATH
        secret_file = _SecretFile(filename)
        secret_file.write(self.to_dict())


class AuthClient():

    def __init__(self, base_url: str = None):
        """
        Parameters:
            base_url: The base URL to use. Defaults to production
                authentication API base url.
        """
        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def login(self, email: str, password: str) -> dict:
        '''Login using email identity and credentials.

        Note: To keep your password secure, the use of `getpass` is
        recommended.

        Parameters:
            email: Planet account email address.
            password:  Planet account password.

        Returns:
             A JSON object containing an `api_key` property with the user's
        API_KEY.
        '''
        url = f'{self._base_url}/login'
        data = {'email': email, 'password': password}

        sess = http.AuthSession()
        req = models.Request(url, method='POST', json=data)
        resp = sess.request(req)
        auth_data = self.decode_response(resp)
        return auth_data

    @staticmethod
    def decode_response(response):
        '''Decode the token JWT'''
        token = response.json()['token']
        return jwt.decode(token, options={'verify_signature': False})


class APIKeyAuthException(Exception):
    '''exceptions thrown by APIKeyAuth'''
    pass


class APIKeyAuth(httpx.BasicAuth, Auth):
    '''Planet API Key authentication.'''
    DICT_KEY = 'key'

    def __init__(self, key: str):
        '''Initialize APIKeyAuth.

        Parameters:
            key: API key.

        Raises:
            APIKeyException: If API key is None or empty string.
        '''
        if not key:
            raise APIKeyAuthException('API key cannot be empty.')
        self._key = key
        super().__init__(self._key, '')

    @classmethod
    def from_dict(cls, data: dict) -> APIKeyAuth:
        '''Instantiate APIKeyAuth from a dict.'''
        api_key = data[cls.DICT_KEY]
        return cls(api_key)

    def to_dict(self):
        '''Represent APIKeyAuth as a dict.'''
        return {self.DICT_KEY: self._key}

    @property
    def value(self):
        return self._key


class _SecretFile():

    def __init__(self, path):
        self.path = path

    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

        self._write(secrets_to_write)

    def _write(self, contents: dict):
        LOGGER.debug(f'Writing to {self.path}')
        with open(self.path, 'w') as fp:
            fp.write(json.dumps(contents))

    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

planet/__version__.py METASEP
__version__ = '2.0.0'

planet/__init__.py METASEP
# Copyright 2020 Planet Labs, Inc.
#
# 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 Session
from . import order_request, reporting
from .__version__ import __version__  # NOQA
from .auth import Auth
from .clients import OrdersClient

__all__ = [
    Session,
    OrdersClient,
    order_request,
    reporting,
    Auth,
]

planet/clients/orders.py METASEP
# Copyright 2020 Planet Labs, Inc.
#
# 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.
"""Functionality for interacting with the orders api"""
import asyncio
import logging
import os
import time
import typing
import uuid

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response, StreamingBody

BASE_URL = f'{PLANET_BASE_URL}/compute/ops'
STATS_PATH = '/stats/orders/v2'
ORDERS_PATH = '/orders/v2'
BULK_PATH = '/bulk/orders/v2'

# Order states https://developers.planet.com/docs/orders/ordering/#order-states
# this is in order of state progression except for final states
ORDER_STATE_SEQUENCE = \
    ('queued', 'running', 'failed', 'success', 'partial', 'cancelled')

LOGGER = logging.getLogger(__name__)


class Orders(Paged):
    '''Asynchronous iterator over Orders from a paged response describing
    orders.'''
    LINKS_KEY = '_links'
    NEXT_KEY = 'next'
    ITEMS_KEY = 'orders'


class OrderStates():
    SEQUENCE = ORDER_STATE_SEQUENCE

    @classmethod
    def _get_position(cls, state):
        return cls.SEQUENCE.index(state)

    @classmethod
    def reached(cls, state, test):
        return cls._get_position(test) >= cls._get_position(state)

    @classmethod
    def passed(cls, state, test):
        return cls._get_position(test) > cls._get_position(state)

    @classmethod
    def is_final(cls, test):
        return cls.passed('running', test)


class OrdersClient():
    """High-level asynchronous access to Planet's orders API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, OrdersClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = OrdersClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production orders API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    @staticmethod
    def _check_order_id(oid):
        """Raises planet.exceptions.ClientError if oid is not a valid UUID"""
        try:
            uuid.UUID(hex=oid)
        except (ValueError, AttributeError):
            msg = f'Order id ({oid}) is not a valid UUID hexadecimal string.'
            raise exceptions.ClientError(msg)

    def _orders_url(self):
        return f'{self._base_url}{ORDERS_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def create_order(self, request: dict) -> dict:
        '''Create an order request.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, OrdersClient
        >>> from planet.order_request import build_request, product
        >>>
        >>> async def main():
        ...     image_ids = ['3949357_1454705_2020-12-01_241c']
        ...     request = build_request(
        ...         'test_order',
        ...         [product(image_ids, 'analytic', 'psorthotile')]
        ...     )
        ...     async with Session() as sess:
        ...         cl = OrdersClient(sess)
        ...         order = await cl.create_order(request)
        ...
        >>> asyncio.run(main())
        ```

        Parameters:
            request: order request definition

        Returns:
            JSON description of the created order

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._orders_url()

        req = self._request(url, method='POST', json=request)
        resp = await self._do_request(req)
        return resp.json()

    async def get_order(self, order_id: str) -> dict:
        '''Get order details by Order ID.

        Parameters:
            order_id: The ID of the order

        Returns:
            JSON description of the order

        Raises:
            planet.exceptions.ClientError: If order_id is not a valid UUID.
            planet.exceptions.APIError: On API error.
        '''
        self._check_order_id(order_id)
        url = f'{self._orders_url()}/{order_id}'

        req = self._request(url, method='GET')
        resp = await self._do_request(req)
        return resp.json()

    async def cancel_order(self, order_id: str) -> dict:
        '''Cancel a queued order.

        Parameters:
            order_id: The ID of the order

        Returns:
            Results of the cancel request

        Raises:
            planet.exceptions.ClientError: If order_id is not a valid UUID.
            planet.exceptions.APIError: On API error.
        '''
        self._check_order_id(order_id)
        url = f'{self._orders_url()}/{order_id}'

        req = self._request(url, method='PUT')
        resp = await self._do_request(req)
        return resp.json()

    async def cancel_orders(self, order_ids: typing.List[str] = None) -> dict:
        '''Cancel queued orders in bulk.

        Parameters:
            order_ids: The IDs of the orders. If empty or None, all orders in a
                pre-running state will be cancelled.

        Returns:
            Results of the bulk cancel request

        Raises:
            planet.exceptions.ClientError: If an entry in order_ids is not a
                valid UUID.
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}{BULK_PATH}/cancel'
        cancel_body = {}
        if order_ids:
            for oid in order_ids:
                self._check_order_id(oid)
            cancel_body['order_ids'] = order_ids

        req = self._request(url, method='POST', json=cancel_body)
        resp = await self._do_request(req)
        return resp.json()

    async def aggregated_order_stats(self) -> dict:
        '''Get aggregated counts of active orders.

        Returns:
            Aggregated order counts

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._stats_url()
        req = self._request(url, method='GET')
        resp = await self._do_request(req)
        return resp.json()

    async def download_asset(self,
                             location: str,
                             filename: str = None,
                             directory: str = None,
                             overwrite: bool = False,
                             progress_bar: bool = True) -> str:
        """Download ordered asset.

        Parameters:
            location: Download location url including download token.
            filename: Custom name to assign to downloaded file.
            directory: Base directory for file download.
            overwrite: Overwrite any existing files.
            progress_bar: Show progress bar during download.

        Returns:
            Path to downloaded file.

        Raises:
            planet.exceptions.APIError: On API error.
        """
        req = self._request(location, method='GET')

        async with self._session.stream(req) as resp:
            body = StreamingBody(resp)
            dl_path = os.path.join(directory or '.', filename or body.name)
            await body.write(dl_path,
                             overwrite=overwrite,
                             progress_bar=progress_bar)
        return dl_path

    async def download_order(self,
                             order_id: str,
                             directory: str = None,
                             overwrite: bool = False,
                             progress_bar: bool = False) -> typing.List[str]:
        """Download all assets in an order.

        Parameters:
            order_id: The ID of the order
            directory: Root directory for file download.
            overwrite: Overwrite files if they already exist.
            progress_bar: Show progress bar during download.

        Returns:
            Paths to downloaded files.

        Raises:
            planet.exceptions.APIError: On API error.
            planet.exceptions.ClientError: If the order is not in a final
                state.
        """
        order = await self.get_order(order_id)

        order_state = order['state']
        if not OrderStates.is_final(order_state):
            raise exceptions.ClientError(
                'Order cannot be downloaded because the order state '
                f'({order_state}) is not a final state. '
                'Consider using wait functionality before '
                'attempting to download.')

        locations = self._get_order_locations(order)
        LOGGER.info(
            f'downloading {len(locations)} assets from order {order_id}')

        filenames = [
            await self.download_asset(location,
                                      directory=directory,
                                      overwrite=overwrite,
                                      progress_bar=progress_bar)
            for location in locations
        ]
        return filenames

    @staticmethod
    def _get_order_locations(order):
        links = order['_links']
        results = links.get('results', None)
        return list(r['location'] for r in results if r)

    async def wait(self,
                   order_id: str,
                   state: str = None,
                   delay: int = 5,
                   max_attempts: int = 200,
                   callback: typing.Callable[[str], None] = None) -> str:
        """Wait until order reaches desired state.

        Returns the state of the order on the last poll.

        This function polls the Orders API to determine the order state, with
        the specified delay between each polling attempt, until the
        order reaches a final state, or earlier state, if specified.
        If the maximum number of attempts is reached before polling is
        complete, an exception is raised. Setting 'max_attempts' to zero will
        result in no limit on the number of attempts.

        Setting 'delay' to zero results in no delay between polling attempts.
        This will likely result in throttling by the Orders API, which has
        a rate limit of 10 requests per second. If many orders are being
        polled asynchronously, consider increasing the delay to avoid
        throttling.

        By default, polling completes when the order reaches a final state.
        If 'state' is given, polling will complete when the specified earlier
        state is reached or passed.

        Example:
            ```python
            from planet.reporting import StateBar

            with StateBar() as bar:
                await wait(order_id, callback=bar.update_state)
            ```

        Parameters:
            order_id: The ID of the order.
            state: State prior to a final state that will end polling.
            delay: Time (in seconds) between polls.
            max_attempts: Maximum number of polls. Set to zero for no limit.
            callback: Function that handles state progress updates.

        Returns
            State of the order.

        Raises:
            planet.exceptions.APIError: On API error.
            planet.exceptions.ClientError: If order_id or state is not valid or
                if the maximum number of attempts is reached before the
                specified state or a final state is reached.
        """
        if state and state not in ORDER_STATE_SEQUENCE:
            raise exceptions.ClientError(
                f'{state} must be one of {ORDER_STATE_SEQUENCE}')

        # loop without end if max_attempts is zero
        # otherwise, loop until num_attempts reaches max_attempts
        num_attempts = 0
        while not max_attempts or num_attempts < max_attempts:
            t = time.time()

            order = await self.get_order(order_id)
            current_state = order['state']

            LOGGER.debug(current_state)

            if callback:
                callback(current_state)

            if OrderStates.is_final(current_state) or \
                    (state and OrderStates.reached(state, current_state)):
                break

            sleep_time = max(delay - (time.time() - t), 0)
            LOGGER.debug(f'sleeping {sleep_time}s')
            await asyncio.sleep(sleep_time)

            num_attempts += 1

        if max_attempts and num_attempts >= max_attempts:
            raise exceptions.ClientError(
                f'Maximum number of attempts ({max_attempts}) reached.')

        return current_state

    async def list_orders(self, state: str = None, limit: int = None):
        """Get all order requests.

        Parameters:
            state: Filter orders to given state.
            limit: Limit orders to given limit.

        Returns:
            User orders that match the query

        Raises:
            planet.exceptions.APIError: On API error.
            planet.exceptions.ClientError: If state is not valid.
        """
        url = self._orders_url()

        if state:
            if state not in ORDER_STATE_SEQUENCE:
                raise exceptions.ClientError(
                    f'Order state ({state}) is not a valid state. '
                    f'Valid states are {ORDER_STATE_SEQUENCE}')
            params = {"state": state}
        else:
            params = None

        request = self._request(url, 'GET', params=params)
        return Orders(request, self._do_request, limit=limit)

planet/clients/__init__.py METASEP
# Copyright 2021 Planet Labs, Inc.
#
# 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 .orders import OrdersClient

__all__ = [
    OrdersClient,
]

planet/clients/data.py METASEP

# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
                            name: str,
                            item_types: typing.List[str],
                            search_filter: dict,
                            enable_email: bool = False) -> dict:
        '''Create a new saved structured item search.

        Parameters:
            name: The name of the saved search.
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            enable_email: Send a daily email when new results are added.

        Returns:
            Description of the saved search.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._searches_url()

        # TODO: validate item_types
        request_json = {
            'name': name, 'filter': search_filter, 'item_types': item_types
        }
        if enable_email:
            request_json['__daily_email_enabled'] = True

        request = self._request(url,
                                method='POST',
                                json=request_json)
        response = await self._do_request(request)
        return response.json()

infile
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

infile
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

infile
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
                            name: str,
                            item_types: typing.List[str],
                            search_filter: dict,
                            enable_email: bool = False) -> dict:
        '''Create a new saved structured item search.

        Parameters:
            name: The name of the saved search.
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            enable_email: Send a daily email when new results are added.

        Returns:
            Description of the saved search.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
                            name: str,
                            item_types: typing.List[str],
                            search_filter: dict,
                            enable_email: bool = False) -> dict:
        '''Create a new saved structured item search.

        Parameters:
            name: The name of the saved search.
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            enable_email: Send a daily email when new results are added.

        Returns:
            Description of the saved search.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._searches_url()

        # TODO: validate item_types
        request_json = {
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
                            name: str,
                            item_types: typing.List[str],
                            search_filter: dict,
                            enable_email: bool = False) -> dict:
        '''Create a new saved structured item search.

        Parameters:
            name: The name of the saved search.
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            enable_email: Send a daily email when new results are added.

        Returns:
            Description of the saved search.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._searches_url()

        # TODO: validate item_types
        request_json = {
            'name': name, 'filter': search_filter, 'item_types': item_types
        }
        if enable_email:
            request_json['__daily_email_enabled'] = True

        request = self._request(url,
                                method='POST',
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
                            name: str,
                            item_types: typing.List[str],
                            search_filter: dict,
                            enable_email: bool = False) -> dict:
        '''Create a new saved structured item search.

        Parameters:
            name: The name of the saved search.
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            enable_email: Send a daily email when new results are added.

        Returns:
            Description of the saved search.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._searches_url()

        # TODO: validate item_types
        request_json = {
            'name': name, 'filter': search_filter, 'item_types': item_types
        }
        if enable_email:
            request_json['__daily_email_enabled'] = True

        request = self._request(url,
                                method='POST',
                                json=request_json)
        response = await self._do_request(request)
        return response.json()

    async def get_stats(self,
                        item_types: typing.List[str],
                        search_filter: dict,
                        interval: str) -> dict:
        '''Get item search statistics.

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            interval: The size of the histogram date buckets.

        Returns:
            Returns a date bucketed histogram of items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
            planet.exceptions.ClientError: If interval is not valid.
        '''
        if interval not in STATS_INTERVAL:
            raise exceptions.ClientError(
                f'{interval} must be one of {STATS_INTERVAL}')

        url = self._stats_url()
        request_json = {
            'interval': interval,
            'filter': search_filter,
            'item_types': item_types
        }
        request = self._request(url, method='POST', json=request_json)
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
                            name: str,
                            item_types: typing.List[str],
                            search_filter: dict,
                            enable_email: bool = False) -> dict:
        '''Create a new saved structured item search.

        Parameters:
            name: The name of the saved search.
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            enable_email: Send a daily email when new results are added.

        Returns:
            Description of the saved search.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._searches_url()

        # TODO: validate item_types
        request_json = {
            'name': name, 'filter': search_filter, 'item_types': item_types
        }
        if enable_email:
            request_json['__daily_email_enabled'] = True

        request = self._request(url,
                                method='POST',
                                json=request_json)
        response = await self._do_request(request)
        return response.json()

    async def get_stats(self,
                        item_types: typing.List[str],
                        search_filter: dict,
                        interval: str) -> dict:
        '''Get item search statistics.

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            interval: The size of the histogram date buckets.

        Returns:
            Returns a date bucketed histogram of items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
            planet.exceptions.ClientError: If interval is not valid.
        '''
        if interval not in STATS_INTERVAL:
            raise exceptions.ClientError(
                f'{interval} must be one of {STATS_INTERVAL}')

        url = self._stats_url()
        request_json = {
            'interval': interval,
            'filter': search_filter,
            'item_types': item_types
        }
        request = self._request(url, method='POST', json=request_json)
        response = await self._do_request(request)
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
                            name: str,
                            item_types: typing.List[str],
                            search_filter: dict,
                            enable_email: bool = False) -> dict:
        '''Create a new saved structured item search.

        Parameters:
            name: The name of the saved search.
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            enable_email: Send a daily email when new results are added.

        Returns:
            Description of the saved search.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._searches_url()

        # TODO: validate item_types
        request_json = {
            'name': name, 'filter': search_filter, 'item_types': item_types
        }
        if enable_email:
            request_json['__daily_email_enabled'] = True

        request = self._request(url,
                                method='POST',
                                json=request_json)
        response = await self._do_request(request)
        return response.json()

    async def get_stats(self,
                        item_types: typing.List[str],
                        search_filter: dict,
                        interval: str) -> dict:
        '''Get item search statistics.

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            interval: The size of the histogram date buckets.

        Returns:
            Returns a date bucketed histogram of items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
            planet.exceptions.ClientError: If interval is not valid.
        '''
        if interval not in STATS_INTERVAL:
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
                            name: str,
                            item_types: typing.List[str],
                            search_filter: dict,
                            enable_email: bool = False) -> dict:
        '''Create a new saved structured item search.

        Parameters:
            name: The name of the saved search.
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            enable_email: Send a daily email when new results are added.

        Returns:
            Description of the saved search.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._searches_url()

        # TODO: validate item_types
        request_json = {
            'name': name, 'filter': search_filter, 'item_types': item_types
        }
        if enable_email:
            request_json['__daily_email_enabled'] = True

        request = self._request(url,
                                method='POST',
                                json=request_json)
        response = await self._do_request(request)
        return response.json()

    async def get_stats(self,
                        item_types: typing.List[str],
                        search_filter: dict,
                        interval: str) -> dict:
        '''Get item search statistics.

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            interval: The size of the histogram date buckets.

        Returns:
            Returns a date bucketed histogram of items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
            planet.exceptions.ClientError: If interval is not valid.
        '''
        if interval not in STATS_INTERVAL:
            raise exceptions.ClientError(
                f'{interval} must be one of {STATS_INTERVAL}')

inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
                            name: str,
                            item_types: typing.List[str],
                            search_filter: dict,
                            enable_email: bool = False) -> dict:
        '''Create a new saved structured item search.

        Parameters:
            name: The name of the saved search.
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            enable_email: Send a daily email when new results are added.

        Returns:
            Description of the saved search.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._searches_url()

        # TODO: validate item_types
        request_json = {
            'name': name, 'filter': search_filter, 'item_types': item_types
        }
        if enable_email:
            request_json['__daily_email_enabled'] = True

        request = self._request(url,
                                method='POST',
                                json=request_json)
        response = await self._do_request(request)
        return response.json()

    async def get_stats(self,
                        item_types: typing.List[str],
                        search_filter: dict,
                        interval: str) -> dict:
        '''Get item search statistics.

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            interval: The size of the histogram date buckets.

        Returns:
            Returns a date bucketed histogram of items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
            planet.exceptions.ClientError: If interval is not valid.
        '''
        if interval not in STATS_INTERVAL:
            raise exceptions.ClientError(
                f'{interval} must be one of {STATS_INTERVAL}')

        url = self._stats_url()
        request_json = {
            'interval': interval,
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
                            name: str,
                            item_types: typing.List[str],
                            search_filter: dict,
                            enable_email: bool = False) -> dict:
        '''Create a new saved structured item search.

        Parameters:
            name: The name of the saved search.
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            enable_email: Send a daily email when new results are added.

        Returns:
            Description of the saved search.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._searches_url()

        # TODO: validate item_types
        request_json = {
            'name': name, 'filter': search_filter, 'item_types': item_types
        }
        if enable_email:
            request_json['__daily_email_enabled'] = True

        request = self._request(url,
                                method='POST',
                                json=request_json)
        response = await self._do_request(request)
        return response.json()

    async def get_stats(self,
                        item_types: typing.List[str],
                        search_filter: dict,
                        interval: str) -> dict:
        '''Get item search statistics.

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            interval: The size of the histogram date buckets.

        Returns:
            Returns a date bucketed histogram of items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
            planet.exceptions.ClientError: If interval is not valid.
        '''
        if interval not in STATS_INTERVAL:
            raise exceptions.ClientError(
                f'{interval} must be one of {STATS_INTERVAL}')

        url = self._stats_url()
        request_json = {
            'interval': interval,
            'filter': search_filter,
            'item_types': item_types
        }
inproject
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

non_informative
non_informative
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.
non_informative
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
non_informative
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
non_informative
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
                            name: str,
                            item_types: typing.List[str],
                            search_filter: dict,
                            enable_email: bool = False) -> dict:
        '''Create a new saved structured item search.

        Parameters:
            name: The name of the saved search.
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            enable_email: Send a daily email when new results are added.

        Returns:
            Description of the saved search.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._searches_url()

        # TODO: validate item_types
        request_json = {
            'name': name, 'filter': search_filter, 'item_types': item_types
        }
        if enable_email:
            request_json['__daily_email_enabled'] = True

        request = self._request(url,
                                method='POST',
                                json=request_json)
        response = await self._do_request(request)
        return response.json()

    async def get_stats(self,
                        item_types: typing.List[str],
                        search_filter: dict,
random
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
                           limit: int = None) -> typing.AsyncIterator[dict]:
        '''Execute a quick search.

        Quick searches are saved for a short period of time (~month). The
        `name` parameter of the search defaults to the search id if `name`
        is not given.

        Returns an iterator over all items matching the search.

        Example:

        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     item_types = ['PSScene']
        ...     sfilter = {
        ...         "type":"DateRangeFilter",
        ...         "field_name":"acquired",
        ...         "config":{
        ...             "gt":"2019-12-31T00:00:00Z",
        ...             "lte":"2020-01-31T00:00:00Z"
        ...         }
        ...     }
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         items = await cl.quick_search(item_types, sfilter)
        ...
        >>> asyncio.run(main())

        ```

        Parameters:
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            sort: Override default of 'published desc' for field and direction
                to order results by. Specified as '<field> <direction>' where
                direction is either 'desc' for descending direction or 'asc'
                for ascending direction.
            name: The name of the saved search.
            limit: Maximum number of items to return.

        Returns:
            Ordered items matching the filter.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = f'{self._base_url}/quick-search'

        # TODO: validate item_types
        request_json = {'filter': search_filter, 'item_types': item_types}
        if name:
            request_json['name'] = name

        params = {}
        if sort:
            # TODO: validate sort
            params['sort'] = sort

        request = self._request(url,
                                method='POST',
                                json=request_json,
                                params=params)
        return Items(request, self._do_request, limit=limit)

    async def create_search(self,
                            name: str,
                            item_types: typing.List[str],
                            search_filter: dict,
                            enable_email: bool = False) -> dict:
        '''Create a new saved structured item search.

        Parameters:
            name: The name of the saved search.
            item_types: The item types to include in the search.
            search_filter: Structured search criteria.
            enable_email: Send a daily email when new results are added.

        Returns:
            Description of the saved search.

        Raises:
            planet.exceptions.APIError: On API error.
        '''
        url = self._searches_url()

        # TODO: validate item_types
random
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

random
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
SEARCHES_PATH = '/searches'
STATS_PATH = '/stats'

STATS_INTERVAL = ('hour', 'day', 'week', 'month', 'year')

LOGGER = logging.getLogger(__name__)


class Items(Paged):
    '''Asynchronous iterator over items from a paged response.'''
    LINKS_KEY = '_links'
    NEXT_KEY = '_next'
    ITEMS_KEY = 'features'


class DataClient():
    """High-level asynchronous access to Planet's data API.

    Example:
        ```python
        >>> import asyncio
        >>> from planet import Session, DataClient
        >>>
        >>> async def main():
        ...     async with Session() as sess:
        ...         cl = DataClient(sess)
        ...         # use client here
        ...
        >>> asyncio.run(main())

        ```
    """

    def __init__(self, session: Session, base_url: str = None):
        """
        Parameters:
            session: Open session connected to server.
            base_url: The base URL to use. Defaults to production data API
                base url.
        """
        self._session = session

        self._base_url = base_url or BASE_URL
        if self._base_url.endswith('/'):
            self._base_url = self._base_url[:-1]

    def _searches_url(self):
        return f'{self._base_url}{SEARCHES_PATH}'

    def _stats_url(self):
        return f'{self._base_url}{STATS_PATH}'

    def _request(self, url, method, data=None, params=None, json=None):
        return Request(url, method=method, data=data, params=params, json=json)

    async def _do_request(self, request: Request) -> Response:
        '''Submit a request and get response.

        Parameters:
            request: request to submit
        '''
        return await self._session.request(request)

    async def quick_search(self,
                           item_types: typing.List[str],
                           search_filter: dict,
                           name: str = None,
                           sort: str = None,
random
# 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.
"""Functionality for interacting with the data api"""
import logging
import typing

from .. import exceptions
from ..constants import PLANET_BASE_URL
from ..http import Session
from ..models import Paged, Request, Response

BASE_URL = f'{PLANET_BASE_URL}/data/v1/'
random
    async def get_stats(self,     async def create_search(self,     async def quick_search(self,         url = f'{self._base_url}/quick-search'         request_json = {'filter': search_filter, 'item_types': item_types}         if name:             request_json['name'] = name         request = self._request(url,                                 json=request_json,         return Items(request, self._do_request, limit=limit)                             name: str,                            search_filter: dict,         url = self._searches_url()             'name': name, 'filter': search_filter, 'item_types': item_types                                 json=request_json)         response = await self._do_request(request)         return response.json()         self._session = session         return Request(url, method=method, data=data, params=params, json=json)     async def _do_request(self, request: Request) -> Response:             raise exceptions.ClientError(         return await self._session.request(request)         url = self._stats_url()                            name: str = None,             'filter': search_filter,         request = self._request(url, method='POST', json=request_json)     def __init__(self, session: Session, base_url: str = None): # Copyright 2022 Planet Labs, PBC.         ...         }     """                         interval: str) -> dict:         request_json = { BASE_URL = f'{PLANET_BASE_URL}/data/v1/'                            limit: int = None) -> typing.AsyncIterator[dict]: SEARCHES_PATH = '/searches'